voice_audio_multi/app/server/runtime.cpp
Fabio c36510c21e
Some checks failed
Linux Build / Linux cpu (push) Has been cancelled
Linux Build / Linux cpu-clang (push) Has been cancelled
Linux Build / Linux vulkan (push) Has been cancelled
mac-build / macOS CPU (push) Has been cancelled
Nix Build / Nix (cpu) (push) Has been cancelled
Nix Build / Nix (vulkan) (push) Has been cancelled
windows-build / Windows CPU (push) Has been cancelled
Initial import: audio.cpp with Orange Pi documentation
2026-11-04 19:05:22 +01:00

2661 lines
119 KiB
C++

#include "runtime.h"
#include "live_speech_input.h"
#include "model_memory.h"
#include "multipart.h"
#include "request_dispatch.h"
#include "server_protocol.h"
#include "server_ui.h"
#include "speech_option_exceptions.h"
#include "task_stream_format.h"
#include "ui_assets.h"
#include "../cli/request.h"
#include "../common/base64.h"
#include "../common/build_info.h"
#include "../streaming/pcm_source.h"
#include "../streaming/streaming.h"
#include "engine/framework/core/host_memory.h"
#include "engine/framework/core/load_progress.h"
#include "engine/framework/debug/trace.h"
#include "engine/framework/io/json.h"
#include "engine/framework/model_spec/metadata.h"
#include "engine/framework/model_spec/options.h"
#include "engine/framework/model_spec/package.h"
#include "engine/framework/runtime/errors.h"
#include "engine/framework/runtime/registry.h"
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cctype>
#include <cstdint>
#include <fstream>
#include <functional>
#include <iomanip>
#include <iostream>
#include <limits>
#include <optional>
#include <sstream>
#include <stdexcept>
#include <string_view>
#include <system_error>
#include <thread>
#include <unordered_map>
#include <utility>
namespace minitts::server {
namespace {
using engine::io::json::Value;
using minitts::app::base64_decode;
using minitts::app::base64_encode;
using Clock = std::chrono::steady_clock;
class CallbackRecipeModelExecutor final : public minitts::app::IRecipeModelExecutor {
public:
using Run = std::function<engine::runtime::TaskResult(
std::string_view,
const engine::runtime::TaskSpec &,
const engine::runtime::TaskRequest &)>;
using Stream = std::function<engine::runtime::TaskResult(std::string_view,
const engine::runtime::TaskSpec &, const engine::runtime::TaskRequest &,
const minitts::app::RecipeEventSink &, const minitts::app::AudioChunkStream *)>;
CallbackRecipeModelExecutor(Run run, Stream stream) : run_(std::move(run)), stream_(std::move(stream)) {}
engine::runtime::TaskResult run(
std::string_view role,
const engine::runtime::TaskSpec & task,
const engine::runtime::TaskRequest & request) override {
return run_(role, task, request);
}
engine::runtime::TaskResult stream(std::string_view role, const engine::runtime::TaskSpec & task,
const engine::runtime::TaskRequest & request, const minitts::app::RecipeEventSink & sink,
const minitts::app::AudioChunkStream * source) override {
return stream_(role, task, request, sink, source);
}
private:
Run run_;
Stream stream_;
};
void materialize_embedded_demo_voices(const std::filesystem::path & voice_dir) {
std::filesystem::create_directories(voice_dir);
for (const auto & voice : embedded_demo_voices()) {
std::ofstream wav(
voice_dir / (std::string(voice.name) + ".wav"),
std::ios::binary | std::ios::trunc);
if (!wav) {
throw std::runtime_error("failed to create embedded demo voice: " + std::string(voice.name));
}
wav.write(voice.wav_bytes.data(), static_cast<std::streamsize>(voice.wav_bytes.size()));
if (!wav) {
throw std::runtime_error("failed to write embedded demo voice: " + std::string(voice.name));
}
}
const auto prompt_text = embedded_demo_voice_prompt_text();
std::ofstream prompts(voice_dir / "prompt_text", std::ios::binary | std::ios::trunc);
if (!prompts) {
throw std::runtime_error("failed to create embedded demo voice prompt_text");
}
prompts.write(prompt_text.data(), static_cast<std::streamsize>(prompt_text.size()));
if (!prompts) {
throw std::runtime_error("failed to write embedded demo voice prompt_text");
}
}
} // namespace
ServerState::ServerState(
ServerConfig config,
std::filesystem::path request_base,
std::filesystem::path ui_resource_anchor)
: config_(std::move(config)),
request_base_(std::filesystem::absolute(std::move(request_base)).lexically_normal()),
recipes_(minitts::app::make_recipe_registry()) {
#if !defined(AUDIOCPP_HAS_NATIVE_MODEL_MANAGER)
(void) ui_resource_anchor;
#endif
if (config_.backend != engine::core::BackendType::Cuda) {
std::cerr
<< "audio.cpp is optimized for CUDA. The "
<< backend_name(config_.backend)
<< " server backend is intended for portability and testing, but performance and model coverage may be lower than CUDA.\n";
}
if (config_.ui_enabled || config_.ui_management) {
upload_root_ = std::filesystem::temp_directory_path() /
("audiocpp-ui-" + std::to_string(
std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::system_clock::now().time_since_epoch()).count()));
std::filesystem::create_directories(upload_root_);
}
#if defined(AUDIOCPP_HAS_NATIVE_MODEL_MANAGER)
if (config_.ui_management) {
repository_root_ = find_repository_root(request_base_, ui_resource_anchor).value_or(request_base_);
const auto binary_directory = ui_resource_anchor.empty()
? request_base_
: std::filesystem::absolute(ui_resource_anchor).lexically_normal();
default_models_root_ = (binary_directory / "models").lexically_normal();
models_root_ = default_models_root_;
request_base_ = binary_directory;
std::filesystem::create_directories(models_root_);
std::cerr
<< "native WebUI model root: " << models_root_ << "\n"
<< "native WebUI package resources: " << repository_root_ << "\n";
model_installer_ = std::make_unique<ModelInstaller>(
repository_root_,
models_root_);
}
#endif
// Demo voices start the WebUI's voice list when the server has no voice
// library. Without UI management they are added only when the UI was asked
// for, so a server that has the UI on by default does not gain voices.
if ((config_.ui_management || (config_.ui_enabled && config_.ui_requested)) &&
!config_.voice_dir.has_value()) {
const auto embedded_voices = upload_root_ / "demo_voices";
materialize_embedded_demo_voices(embedded_voices);
config_.voice_dir = embedded_voices;
}
register_static_server_frontends(frontends_);
load_models();
if (config_.idle_unload_ms > 0) {
idle_unload_thread_ = std::thread(&ServerState::idle_unload_loop, this);
}
}
ServerState::~ServerState() {
idle_unload_shutdown_.store(true, std::memory_order_relaxed);
if (idle_unload_thread_.joinable()) {
idle_unload_thread_.join();
}
if (!upload_root_.empty()) {
std::error_code ec;
std::filesystem::remove_all(upload_root_, ec);
}
}
HttpResponse ServerState::handle(const HttpRequest & request) {
return handle_request(request, true);
}
HttpResponse ServerState::forward_to_core(const HttpRequest & request) {
return handle_request(request, false);
}
std::filesystem::path ServerState::resolve_request_path(const std::filesystem::path & path) const {
return resolve_path(request_base_, path);
}
std::filesystem::path ServerState::make_frontend_temp_path(std::string_view filename) {
std::lock_guard<std::mutex> lock(upload_root_mutex_);
if (upload_root_.empty()) {
upload_root_ = std::filesystem::temp_directory_path() /
("audiocpp-server-" + std::to_string(
std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::system_clock::now().time_since_epoch()).count()));
std::filesystem::create_directories(upload_root_);
}
const auto id = next_upload_id_.fetch_add(1);
return upload_root_ / (std::to_string(id) + "-" + safe_upload_name(std::string(filename)));
}
std::unique_ptr<ServerFrontendListener> ServerState::make_frontend_listener(std::string_view name) const {
return frontends_.make_listener(name);
}
HttpResponse ServerState::handle_request(const HttpRequest & request, bool use_frontends) {
return RequestDispatcher::handle(*this, request, use_frontends,
[this](const HttpRequest & extra) -> std::optional<HttpResponse> {
if (extra.method == "GET" && extra.path == "/v1/recipes") {
return json_response(recipes_json());
}
if (extra.method == "POST" && extra.path == "/v1/recipes/run") {
return handle_recipe_run(extra.body);
}
return std::nullopt;
});
}
HttpResponse ServerState::handle_health() const {
size_t model_count = 0;
{
std::lock_guard<std::mutex> lock(models_mutex_);
model_count = models_.size();
}
return json_response(
"{\"status\":\"ok\",\"backend\":\"" + std::string(backend_name(config_.backend)) +
"\",\"models\":" + std::to_string(model_count) +
",\"ui\":" + (config_.ui_enabled ? "true" : "false") +
",\"ui_management\":" + (config_.ui_management ? "true" : "false") +
",\"version\":" + json_quote(minitts::app::build_version()) +
",\"git\":" + json_quote(minitts::app::build_git_sha()) +
",\"git_date\":" + json_quote(minitts::app::build_git_date()) + "}");
}
void ServerState::load_models() {
int eager_loaded = 0;
for (auto & config : config_.models) {
configured_models_.emplace(config.id, config);
auto loaded = make_model(std::move(config));
if (!model_index_.emplace(loaded->config.id, models_.size()).second) {
throw std::runtime_error("duplicate server model id: " + loaded->config.id);
}
if (!loaded->config.lazy) {
if (config_.max_loaded_models > 0 && eager_loaded >= config_.max_loaded_models) {
// Loading it now would immediately evict an earlier entry, so an
// over-limit config would churn through doomed loads at startup.
// Register the id and defer the load to the model's first request.
std::cerr << "model '" << loaded->config.id
<< "' registered but not loaded (max_loaded_models="
<< config_.max_loaded_models << " reached); it loads on first use\n";
} else {
ensure_model_loaded_locked(*loaded);
++eager_loaded;
// Models-completed boundary for trace-log consumers driving a
// load progress bar: one step per resident model. The final
// 1.0 is emitted once below. Opt-in (--load-progress 1).
if (engine::core::load_progress_enabled() &&
eager_loaded < static_cast<int>(config_.models.size())) {
engine::debug::trace_log_scalar(
"server.load.progress",
static_cast<double>(eager_loaded) / static_cast<double>(config_.models.size()));
}
}
}
models_.push_back(std::move(loaded));
}
if (engine::core::load_progress_enabled() &&
!config_.models.empty() && eager_loaded == static_cast<int>(config_.models.size())) {
// Every configured model is resident and the listening banner follows
// immediately: complete the models-completed curve at 1.0.
engine::debug::trace_log_scalar("server.load.progress", 1.0);
}
}
std::unique_ptr<ServerState::LoadedModel> ServerState::make_model(ServerModelConfig config) {
auto loaded = std::make_unique<LoadedModel>();
loaded->config = std::move(config);
loaded->task = engine::runtime::TaskSpec{
engine::runtime::parse_voice_task_kind(loaded->config.task),
engine::runtime::parse_run_mode(loaded->config.mode),
};
load_voice_presets(*loaded);
refresh_model_option_flags(*loaded);
return loaded;
}
void ServerState::refresh_model_option_flags(LoadedModel & model) {
const auto effective_override = model.config.model_spec_override.has_value()
? model.config.model_spec_override
: config_.model_spec_override;
// Deliberately uncaught: model_accepts_request_option already returns true for a
// model with no contract, swallowing only the missing-contract errors and
// rethrowing the rest. Anything that propagates here is therefore a real
// misconfiguration (invalid spec, missing override file, family mismatch) and
// must fail at registration rather than be assumed away.
model.accepts_reference_text = model_accepts_request_option(
model.config.family,
"reference_text",
effective_override,
model.config.path);
model.accepts_language = model_accepts_request_option(
model.config.family,
"language",
effective_override,
model.config.path);
if (model.task.task == engine::runtime::VoiceTaskKind::Tts ||
model.task.task == engine::runtime::VoiceTaskKind::VoiceCloning ||
model.task.task == engine::runtime::VoiceTaskKind::VoiceDesign) {
model.accepts_speed = model_accepts_request_option(
model.config.family, "speed", effective_override, model.config.path);
model.accepts_speaking_rate = model_accepts_request_option(
model.config.family, "speaking_rate", effective_override, model.config.path);
}
model.request_params_json = model_request_params_json(
model.config.family, effective_override, model.config.path);
}
HttpResponse ServerState::handle_model_load(const std::string & body_text) {
if (!config_.ui_management) {
return error_response(403, "dynamic model management is disabled", "forbidden");
}
const auto body = engine::io::json::parse(body_text);
if (body.find("slots") != nullptr) {
return error_response(400,
"slots requires --parallel-jobs at server startup",
"invalid_request_error");
}
auto requested = model_config_from_json(body, request_base_, false);
requested.path = resolve_ui_model_path(engine::io::json::require_string(body, "path"));
apply_default_session_options(config_, requested);
if (const auto configured = configured_models_.find(requested.id); configured != configured_models_.end()) {
inherit_configured_model_fields(configured->second, requested);
}
LoadedModel * existing = nullptr;
{
std::lock_guard<std::mutex> state_lock(models_mutex_);
const auto found = model_index_.find(requested.id);
if (found != model_index_.end()) {
existing = models_.at(found->second).get();
}
}
if (existing != nullptr) {
BusyGuard::Lock run_lock = acquire_model_run(*existing, std::nullopt);
std::unique_lock<std::shared_mutex> metadata_lock(existing->metadata_mutex);
// The WebUI re-posts model metadata when it loads a configured model.
// Treat an omitted device as "keep the registration's assignment" so
// that action cannot silently move the model back to the server default.
if (!requested.device.has_value()) {
requested.device = existing->config.device;
}
const bool changed =
existing->config.path != requested.path ||
existing->config.family != requested.family ||
existing->config.task != requested.task ||
existing->config.mode != requested.mode ||
existing->config.device != requested.device ||
existing->config.load_options != requested.load_options ||
existing->config.session_options != requested.session_options ||
existing->config.model_spec_override != requested.model_spec_override;
if (changed) {
existing->unload();
existing->voice_presets.clear();
existing->default_voice_preset.reset();
existing->config = std::move(requested);
existing->task = engine::runtime::TaskSpec{
engine::runtime::parse_voice_task_kind(existing->config.task),
engine::runtime::parse_run_mode(existing->config.mode),
};
load_voice_presets(*existing);
refresh_model_option_flags(*existing);
}
ensure_model_loaded_locked(*existing);
return json_response(
"{\"id\":" + json_quote(existing->config.id) +
",\"loaded\":true,\"reconfigured\":" + (changed ? "true" : "false") + "}");
}
auto loaded = make_model(std::move(requested));
ensure_model_loaded_locked(*loaded);
const std::string id = loaded->config.id;
{
std::lock_guard<std::mutex> state_lock(models_mutex_);
if (model_index_.find(id) != model_index_.end()) {
throw std::runtime_error("model id was registered concurrently: " + id);
}
model_index_.emplace(id, models_.size());
models_.push_back(std::move(loaded));
}
return json_response("{\"id\":" + json_quote(id) + ",\"loaded\":true,\"reconfigured\":false}");
}
std::filesystem::path ServerState::resolve_ui_model_path(const std::filesystem::path & path) const {
return minitts::server::resolve_ui_model_path(path, request_base_, repository_root_);
}
HttpResponse ServerState::handle_model_unload(const std::string & body_text) {
if (!config_.ui_management) {
return error_response(403, "dynamic model management is disabled", "forbidden");
}
const auto body = engine::io::json::parse(body_text);
const std::string id = engine::io::json::require_string(body, "id");
LoadedModel * model = nullptr;
{
std::lock_guard<std::mutex> state_lock(models_mutex_);
const auto found = model_index_.find(id);
if (found == model_index_.end()) {
return error_response(404, "unknown model id: " + id, "not_found");
}
model = models_.at(found->second).get();
}
BusyGuard::Lock run_lock = acquire_model_run(*model, std::nullopt);
model->unload();
return json_response("{\"id\":" + json_quote(id) + ",\"loaded\":false}");
}
HttpResponse ServerState::handle_path_status(const std::string & body_text) const {
return ui_path_status_response(
body_text, config_.ui_management, request_base_, repository_root_);
}
HttpResponse ServerState::handle_ui_upload(const HttpRequest & request) {
return ui_upload_response(
request,
config_.ui_enabled || config_.ui_management,
[this](std::string_view filename) {
const auto id = next_upload_id_.fetch_add(1);
return upload_root_ / (std::to_string(id) + "-" + std::string(filename));
});
}
#if defined(AUDIOCPP_HAS_NATIVE_MODEL_MANAGER)
// Resolves a "package_id" to the installed package's load target before
// handing the request to handle_model_load, which only ever sees a path.
// Path-based loads never touch the installer or its mutex.
HttpResponse ServerState::handle_package_model_load(const std::string & body_text) {
if (!config_.ui_management) {
return handle_model_load(body_text);
}
const auto body = engine::io::json::parse(body_text);
std::string package_id;
if (auto error = ui_model_load_package_id(body, package_id)) {
return *error;
}
if (package_id.empty()) {
return handle_model_load(body_text);
}
std::optional<engine::package_manager::PackageInfo> info;
{
std::lock_guard<std::mutex> lock(model_installer_mutex_);
if (!model_installer_) {
return error_response(403, "UI model management is disabled", "forbidden");
}
info = model_installer_->package_info(package_id);
}
std::string resolved_body;
if (auto error = resolve_ui_package_model_load(body, package_id, info, resolved_body)) {
return *error;
}
return handle_model_load(resolved_body);
}
HttpResponse ServerState::handle_model_install(const std::string & body_text) {
if (!config_.ui_management) {
return error_response(403, "UI model installation is disabled", "forbidden");
}
const auto request = parse_ui_model_install_request(body_text);
std::lock_guard<std::mutex> lock(model_installer_mutex_);
if (!model_installer_) {
return error_response(403, "UI model installation is disabled", "forbidden");
}
return ui_model_install_response(request, *model_installer_);
}
HttpResponse ServerState::handle_model_remove(const std::string & body_text) {
if (!config_.ui_management) {
return error_response(403, "UI model removal is disabled", "forbidden");
}
const auto package_id = parse_ui_model_id(body_text);
std::lock_guard<std::mutex> lock(model_installer_mutex_);
if (!model_installer_) {
return error_response(403, "UI model removal is disabled", "forbidden");
}
return ui_model_remove_response(package_id, *model_installer_);
}
HttpResponse ServerState::handle_model_install_stop(const std::string & body_text) {
if (!config_.ui_management) {
return error_response(403, "UI model installation is disabled", "forbidden");
}
const auto package_id = parse_ui_model_id(body_text);
std::lock_guard<std::mutex> lock(model_installer_mutex_);
if (!model_installer_) {
return error_response(403, "UI model installation is disabled", "forbidden");
}
return ui_model_install_stop_response(package_id, *model_installer_);
}
HttpResponse ServerState::handle_model_clean_partial(const std::string & body_text) {
if (!config_.ui_management) {
return error_response(403, "UI model cleanup is disabled", "forbidden");
}
const auto package_id = parse_ui_model_id(body_text);
std::lock_guard<std::mutex> lock(model_installer_mutex_);
if (!model_installer_) {
return error_response(403, "UI model cleanup is disabled", "forbidden");
}
return ui_model_clean_partial_response(package_id, *model_installer_);
}
HttpResponse ServerState::handle_model_install_status(const HttpRequest & request) const {
if (!config_.ui_management) {
return error_response(403, "UI model installation is disabled", "forbidden");
}
std::lock_guard<std::mutex> lock(model_installer_mutex_);
if (!model_installer_) {
return error_response(403, "UI model installation is disabled", "forbidden");
}
return ui_model_install_status_response(request, *model_installer_);
}
HttpResponse ServerState::handle_model_package_sizes() {
if (!config_.ui_management) {
return error_response(403, "UI model installation is disabled", "forbidden");
}
std::lock_guard<std::mutex> lock(model_installer_mutex_);
if (!model_installer_) {
return error_response(403, "UI model installation is disabled", "forbidden");
}
return ui_model_package_sizes_response(*model_installer_);
}
HttpResponse ServerState::handle_models_root_get() const {
if (!config_.ui_management) {
return error_response(403, "UI model management is disabled", "forbidden");
}
std::lock_guard<std::mutex> lock(model_installer_mutex_);
if (!model_installer_) {
return error_response(403, "UI model management is disabled", "forbidden");
}
return ui_models_root_response(models_root_, default_models_root_);
}
HttpResponse ServerState::handle_models_root_set(const std::string & body_text) {
if (!config_.ui_management) {
return error_response(403, "UI model management is disabled", "forbidden");
}
const auto body = engine::io::json::parse(body_text);
const auto requested_text = engine::io::json::optional_string(body, "path", "");
auto requested = requested_text.empty()
? default_models_root_
: std::filesystem::path(requested_text);
if (requested.is_relative()) {
requested = request_base_ / requested;
}
requested = std::filesystem::absolute(requested).lexically_normal();
std::lock_guard<std::mutex> lock(model_installer_mutex_);
if (!model_installer_) {
return error_response(403, "UI model management is disabled", "forbidden");
}
if (requested == models_root_) {
return json_response(
"{\"models_root\":" + json_quote(models_root_.string()) +
",\"default_models_root\":" + json_quote(default_models_root_.string()) +
",\"is_default\":" + (models_root_ == default_models_root_ ? "true" : "false") + "}");
}
if (model_installer_->has_active_jobs()) {
return error_response(
409,
"cannot change the models folder while a package download is running",
"model_install_active");
}
std::error_code error;
if (std::filesystem::exists(requested, error) && !std::filesystem::is_directory(requested, error)) {
return error_response(400, "models folder path is not a directory: " + requested.string(), "invalid_request_error");
}
std::filesystem::create_directories(requested, error);
if (error) {
return error_response(
400,
"could not create models folder '" + requested.string() + "': " + error.message(),
"invalid_request_error");
}
models_root_ = requested;
model_installer_ = std::make_unique<ModelInstaller>(repository_root_, models_root_);
std::cerr << "native WebUI model root changed to: " << models_root_ << "\n";
return json_response(
"{\"models_root\":" + json_quote(models_root_.string()) +
",\"default_models_root\":" + json_quote(default_models_root_.string()) +
",\"is_default\":" + (models_root_ == default_models_root_ ? "true" : "false") + "}");
}
HttpResponse ServerState::handle_directory_browser(const std::string & body_text) const {
if (!config_.ui_management) {
return error_response(403, "UI directory browsing is disabled", "forbidden");
}
const auto body = engine::io::json::parse(body_text);
const auto requested_path = engine::io::json::optional_string(body, "path", "");
std::filesystem::path models_root;
{
std::lock_guard<std::mutex> lock(model_installer_mutex_);
models_root = models_root_;
}
return ui_directory_browser_response(requested_path, models_root, request_base_);
}
#endif
HttpResponse ServerState::handle_ui_asset() const {
return ui_asset_response(config_.ui_enabled);
}
HttpResponse ServerState::handle_ui_voice_preview(const HttpRequest & request) const {
return ui_voice_preview_response(request, config_.ui_enabled, config_.voice_dir);
}
RuntimeVoicePreset ServerState::load_runtime_voice_preset(
const ServerModelConfig::VoicePreset & preset) const {
RuntimeVoicePreset out;
out.voice_id = preset.voice_id;
out.reference_text = preset.reference_text;
out.options = preset.options;
if (preset.voice_ref.has_value()) {
out.audio = minitts::cli::read_audio_buffer(*preset.voice_ref);
}
return out;
}
void ServerState::load_voice_presets(LoadedModel & model) const {
for (const auto & [name, preset] : model.config.voice_presets) {
auto [it, inserted] = model.voice_presets.emplace(name, load_runtime_voice_preset(preset));
if (!inserted) {
throw std::runtime_error("duplicate runtime voice preset for model " + model.config.id + ": " + name);
}
(void) it;
}
if (model.config.default_voice_preset_id.has_value()) {
const auto it = model.voice_presets.find(*model.config.default_voice_preset_id);
if (it == model.voice_presets.end()) {
throw std::runtime_error(
"default_voice_preset for model " + model.config.id +
" was not loaded: " +
*model.config.default_voice_preset_id);
}
model.default_voice_preset = it->second;
} else if (model.config.default_voice_preset.has_value()) {
model.default_voice_preset = load_runtime_voice_preset(*model.config.default_voice_preset);
}
}
void ServerState::evict_for_model_limit(const LoadedModel & loading) {
const int limit = config_.max_loaded_models;
std::vector<LoadedModel *> resident;
{
std::lock_guard<std::mutex> state_lock(models_mutex_);
for (const auto & model : models_) {
if (model.get() != &loading && model->session != nullptr) {
resident.push_back(model.get());
}
}
}
// `loading` itself will occupy one slot, so at most limit - 1 others may stay.
int to_evict = static_cast<int>(resident.size()) - (limit - 1);
if (to_evict <= 0) {
return;
}
std::sort(resident.begin(), resident.end(), [](const LoadedModel * a, const LoadedModel * b) {
return a->last_used_ms.load(std::memory_order_relaxed) <
b->last_used_ms.load(std::memory_order_relaxed);
});
for (LoadedModel * victim : resident) {
if (to_evict == 0) {
return;
}
// A victim mid-inference is only try-acquired: a blocking wait here could
// deadlock against that run evicting in return, and a busy model is in
// active use anyway -- prefer the next-oldest instead.
const auto lock = victim->busy.try_acquire();
if (!lock.has_value()) {
continue;
}
victim->unload();
--to_evict;
}
if (to_evict > 0) {
throw ServerBusyError(
"cannot load model '" + loading.config.id + "': max_loaded_models=" +
std::to_string(limit) + " and every loaded model is busy; retry later");
}
}
void ServerState::ensure_model_loaded_locked(LoadedModel & model) {
last_activity_ms_.store(steady_now_ms(), std::memory_order_relaxed);
model.last_used_ms.store(steady_now_ms(), std::memory_order_relaxed);
if (model.session != nullptr) {
return;
}
// Serialize the whole "evict -> memory check -> load" sequence only when a guard
// actually needs it: eviction (max_loaded_models > 0) or the memory pre-check
// (min_free_memory_mb > 0). With both off there is nothing for concurrent loads
// to race over, so unrelated first-load requests keep their original concurrency.
const bool serialize_load =
config_.max_loaded_models > 0 || config_.min_free_memory_mb > 0;
std::unique_lock<std::mutex> load_lock(model_load_mutex_, std::defer_lock);
if (serialize_load) {
load_lock.lock();
}
if (config_.max_loaded_models > 0) {
evict_for_model_limit(model);
// Note: if ensure_model_fits_memory() below then refuses the load, the
// evicted model is already unloaded (freed memory is never restored).
// That is acceptable: the 503 tells the client to retry later.
}
ensure_model_fits_memory(model.config);
auto registry = engine::runtime::make_default_registry();
engine::runtime::ModelLoadRequest load_request;
load_request.model_path = model.config.path;
load_request.model_spec_override = model.config.model_spec_override.has_value()
? model.config.model_spec_override
: config_.model_spec_override;
load_request.family_hint = model.config.family;
load_request.config_id = model.config.config_id;
load_request.weight_id = model.config.weight_id;
load_request.options = model.config.load_options;
engine::runtime::SessionOptions session_options;
session_options.backend.type = config_.backend;
session_options.backend.impl = config_.backend_impl;
session_options.backend.device = model.config.device.value_or(config_.device);
session_options.backend.threads = config_.threads;
session_options.options = model.config.session_options;
engine::debug::trace_log_scalar("server.model.id", model.config.id);
engine::debug::trace_log_scalar("server.model.path", model.config.path.string());
engine::debug::trace_log_scalar("server.model.family", model.config.family);
engine::debug::trace_log_scalar(
"server.model.task",
std::string_view(engine::runtime::to_string(model.task.task)));
engine::debug::trace_log_scalar(
"server.model.mode",
std::string_view(engine::runtime::to_string(model.task.mode)));
engine::debug::trace_log_scalar("server.model.backend", std::string_view(backend_name(session_options.backend.type)));
engine::debug::trace_log_scalar("server.model.device", int64_t{session_options.backend.device});
engine::debug::trace_log_scalar("server.model.threads", int64_t{session_options.backend.threads});
engine::debug::trace_log_scalar(
"server.model.session_option_count",
static_cast<int64_t>(session_options.options.size()));
for (const auto & [key, value] : session_options.options) {
engine::debug::trace_log_scalar("server.model.session_options." + key, value);
}
auto loaded_model = registry.load(load_request);
auto session = loaded_model->create_task_session(model.task, session_options);
// The weight stores uploaded during session construction: close the
// load-progress bracket opened by registry.load (see finish_model_load_trace).
engine::runtime::finish_model_load_trace();
auto * offline = dynamic_cast<engine::runtime::IOfflineVoiceTaskSession *>(session.get());
auto * streaming = dynamic_cast<engine::runtime::IStreamingVoiceTaskSession *>(session.get());
if (model.task.mode == engine::runtime::RunMode::Offline && offline == nullptr) {
throw std::runtime_error("configured model does not provide offline execution: " + model.config.id);
}
if (model.task.mode == engine::runtime::RunMode::Streaming && streaming == nullptr) {
throw std::runtime_error("configured model does not provide streaming execution: " + model.config.id);
}
model.model = std::move(loaded_model);
model.session = std::move(session);
model.offline = offline;
model.streaming = streaming;
model.loaded.store(true);
}
LiveIngestLimits ServerState::live_ingest_limits(const HttpRequest & request) const {
const std::string model_id = query_param(request.query, "model");
if (model_id.empty()) {
return config_.live_ingest;
}
const auto it = model_index_.find(model_id);
if (it == model_index_.end()) {
return config_.live_ingest;
}
return resolve_live_ingest_limits(config_.live_ingest, models_.at(it->second)->config.live_ingest);
}
ServerState::LoadedModel & ServerState::require_model(const Value & body) {
const std::string id = engine::io::json::require_string(body, "model");
std::lock_guard<std::mutex> state_lock(models_mutex_);
const auto it = model_index_.find(id);
if (it == model_index_.end()) {
throw std::runtime_error("unknown model id: " + id);
}
return *models_.at(it->second);
}
engine::runtime::TaskRequest ServerState::build_speech_request(const LoadedModel & model, const Value & body) const {
std::shared_lock<std::shared_mutex> metadata_lock(model.metadata_mutex);
return minitts::server::build_speech_request(
body,
SpeechRequestContext{
request_base_, config_.voice_dir, model.voice_presets, model.default_voice_preset,
model.accepts_reference_text, model.accepts_speed,
model.accepts_speaking_rate,
model.config.default_request_options});
}
struct ServerState::TimedTaskResult {
engine::runtime::TaskResult result;
double wall_ms = 0.0;
std::optional<double> ttft_ms;
};
engine::runtime::RunMode ServerState::model_run_mode(const LoadedModel & model) const {
std::shared_lock<std::shared_mutex> metadata_lock(model.metadata_mutex);
return model.task.mode;
}
BusyGuard::Lock ServerState::acquire_model_run(
LoadedModel & model,
std::optional<int> request_timeout_ms) {
int timeout_ms = 0;
std::string model_id;
{
std::shared_lock<std::shared_mutex> metadata_lock(model.metadata_mutex);
timeout_ms = resolve_busy_timeout_ms(
model.config.busy_timeout_ms.value_or(config_.busy_timeout_ms),
request_timeout_ms);
model_id = model.config.id;
}
return model.busy.acquire(timeout_ms, model_id);
}
ServerState::TimedTaskResult ServerState::run_model(
LoadedModel & model,
const engine::runtime::TaskRequest & request,
std::optional<int> busy_timeout_ms) {
BusyGuard::Lock lock = acquire_model_run(model, busy_timeout_ms);
ensure_model_loaded_locked(model);
if (model.offline == nullptr) {
throw std::runtime_error("configured model does not provide offline execution: " + model.config.id);
}
const auto started = Clock::now();
model.session->prepare(engine::runtime::build_preparation_request(request));
auto result = model.offline->run(request);
// Mark activity at completion too: idle unload must measure from when the
// request finished, not when it started, or a long inference would look idle
// (and be unloaded) the moment it returns.
last_activity_ms_.store(steady_now_ms(), std::memory_order_relaxed);
return TimedTaskResult{std::move(result), elapsed_ms(started), std::nullopt};
}
// `audio` selects where the samples come from: null means request.audio_input, as
// every caller did before live ingest existed; non-null pulls them from a stream
// as they arrive. Everything else — locking, preparation, timing — is identical,
// so both entry points share this body rather than drifting apart.
ServerState::TimedTaskResult ServerState::run_streaming_model_impl(
LoadedModel & model,
const engine::runtime::TaskRequest & request,
const minitts::app::AudioChunkStream * audio,
const std::function<void(const engine::runtime::StreamEvent &)> & event_sink,
std::optional<int> busy_timeout_ms, bool include_artifacts) {
BusyGuard::Lock lock = acquire_model_run(model, busy_timeout_ms);
ensure_model_loaded_locked(model);
if (model.streaming == nullptr) {
throw std::runtime_error("configured model does not provide streaming execution: " + model.config.id);
}
const auto started = Clock::now();
model.session->prepare(engine::runtime::build_preparation_request(request));
TimedTaskResult timed_result;
const bool diarization = model.task.task == engine::runtime::VoiceTaskKind::Diarization;
const auto sink = [&](const engine::runtime::StreamEvent & event) {
if (!timed_result.ttft_ms.has_value() &&
(stream_event_has_output(event, include_artifacts) || (diarization && !event.speaker_turns.empty()))) {
timed_result.ttft_ms = elapsed_ms(started);
}
if (event_sink) {
event_sink(event);
}
};
auto result = audio != nullptr
? minitts::app::run_streaming_task(*model.streaming, request, sink, *audio)
: minitts::app::run_streaming_task(*model.streaming, request, sink);
timed_result.result = std::move(result);
timed_result.wall_ms = elapsed_ms(started);
if (!timed_result.ttft_ms.has_value() &&
(task_result_has_output(timed_result.result) ||
(diarization && !timed_result.result.speaker_turns.empty()))) {
timed_result.ttft_ms = timed_result.wall_ms;
}
// Mark activity at completion too (see run_model): idle unload measures from
// request finish, so a long stream is not unloaded the moment it ends.
last_activity_ms_.store(steady_now_ms(), std::memory_order_relaxed);
return timed_result;
}
ServerState::TimedTaskResult ServerState::run_streaming_model(
LoadedModel & model,
const engine::runtime::TaskRequest & request,
const std::function<void(const engine::runtime::StreamEvent &)> & event_sink,
std::optional<int> busy_timeout_ms, bool include_artifacts) {
return run_streaming_model_impl(model, request, nullptr, event_sink, busy_timeout_ms, include_artifacts);
}
ServerState::TimedTaskResult ServerState::run_streaming_model_from(
LoadedModel & model,
const engine::runtime::TaskRequest & request,
const minitts::app::AudioChunkStream & audio,
const std::function<void(const engine::runtime::StreamEvent &)> & event_sink,
std::optional<int> busy_timeout_ms) {
return run_streaming_model_impl(model, request, &audio, event_sink, busy_timeout_ms);
}
HttpResponse ServerState::handle_speech(const std::string & body_text) {
const auto body = engine::io::json::parse(body_text);
auto & model = require_model(body);
const auto request = build_speech_request(model, body);
if (body.find("stream_format") != nullptr || bool_field(body, "stream", false)) {
return handle_speech_stream(model, request, body);
}
const auto busy_timeout_ms = parse_busy_timeout_override(body);
const auto timed_result = model_run_mode(model) == engine::runtime::RunMode::Streaming
? run_streaming_model(model, request, {}, busy_timeout_ms)
: run_model(model, request, busy_timeout_ms);
const auto & audio = select_audio_output(timed_result.result);
const auto wav = encode_pcm16_wav(audio);
const auto response_format = engine::io::json::optional_string(body, "response_format", "wav");
if (response_format == "json" || response_format == "b64_json") {
return json_response(
"{\"audio\":" + json_quote(base64_encode(wav)) +
",\"format\":\"wav\",\"timing\":" + timing_json(timed_result.wall_ms, audio) + "}");
}
HttpResponse response;
response.status = 200;
response.content_type = "audio/wav";
response.body = std::string(reinterpret_cast<const char *>(wav.data()), wav.size());
response.headers = timing_headers(timed_result.wall_ms, audio);
return response;
}
HttpResponse ServerState::handle_speech_stream(
LoadedModel & model,
const engine::runtime::TaskRequest & request,
const Value & body) {
if (model_run_mode(model) != engine::runtime::RunMode::Streaming) {
throw std::runtime_error("speech streaming requires a model configured with mode=streaming");
}
const auto stream_format = engine::io::json::optional_string(body, "stream_format", "sse");
const auto response_format = engine::io::json::optional_string(body, "response_format", "pcm");
if (response_format != "pcm") {
throw std::runtime_error("streaming speech currently supports response_format=pcm");
}
if (stream_format != "sse" && stream_format != "audio") {
throw std::runtime_error("streaming speech stream_format must be sse or audio");
}
const auto busy_timeout_ms = parse_busy_timeout_override(body);
LoadedModel * model_ptr = &model;
auto stream_body = [this, model_ptr, request, busy_timeout_ms](HttpStreamWriter & writer) {
bool wrote_audio = false;
const auto timed_result = run_streaming_model(
*model_ptr,
request,
[&](const engine::runtime::StreamEvent & event) {
std::vector<engine::runtime::AudioBuffer> buffers;
if (event.audio_output.has_value()) {
buffers.push_back(*event.audio_output);
}
for (const auto & named : event.named_audio_outputs) {
buffers.push_back(named.audio);
}
for (const auto & audio : buffers) {
const auto pcm = encode_pcm16_samples(audio);
write_sse(
writer,
"{\"type\":\"speech.audio.delta\",\"audio\":" +
json_quote(base64_encode(pcm)) +
"}");
wrote_audio = true;
}
},
busy_timeout_ms);
if (!wrote_audio) {
throw std::runtime_error("streaming speech model produced no audio delta events");
}
write_sse(
writer,
"{\"type\":\"speech.audio.done\",\"timing\":" +
ttft_timing_json(require_ttft_ms(timed_result.ttft_ms)) +
"}");
write_sse_done(writer);
};
if (stream_format == "sse") {
return sse_response(std::move(stream_body));
}
return chunked_audio_response([this, model_ptr, request, busy_timeout_ms](HttpStreamWriter & writer) {
bool wrote_audio = false;
(void)run_streaming_model(
*model_ptr,
request,
[&](const engine::runtime::StreamEvent & event) {
if (event.audio_output.has_value()) {
const auto pcm = encode_pcm16_samples(*event.audio_output);
writer.write(std::string(reinterpret_cast<const char *>(pcm.data()), pcm.size()));
wrote_audio = true;
}
for (const auto & named : event.named_audio_outputs) {
const auto pcm = encode_pcm16_samples(named.audio);
writer.write(std::string(reinterpret_cast<const char *>(pcm.data()), pcm.size()));
wrote_audio = true;
}
},
busy_timeout_ms);
if (!wrote_audio) {
throw std::runtime_error("streaming speech model produced no audio delta events");
}
});
}
HttpResponse ServerState::handle_speech_live(const HttpRequest & request) {
if (request.body_stream == nullptr) {
return error_response(
400,
"live speech requires an incrementally delivered body (Transfer-Encoding: chunked)",
"invalid_request_error");
}
LoadedModel * model_ptr = nullptr;
int sample_rate = 16000;
int channels = 1;
std::optional<int> busy_timeout_ms;
minitts::app::PcmSampleFormat sample_format = minitts::app::PcmSampleFormat::S16LE;
std::string stream_format = "sse";
bool return_text = false;
engine::runtime::TaskRequest task_request;
try {
const std::string model_id = query_param(request.query, "model");
if (model_id.empty()) {
throw std::runtime_error("live speech requires a 'model' query parameter");
}
const std::string input = decoded_query_param(request.query, "input");
engine::io::json::Value::Object fields;
fields.emplace("model", engine::io::json::Value::make_string(model_id));
fields.emplace("input", engine::io::json::Value::make_string(input));
const std::string language = decoded_query_param(request.query, "language");
if (!language.empty()) {
fields.emplace("language", engine::io::json::Value::make_string(language));
}
const std::string voice = decoded_query_param(request.query, "voice");
if (!voice.empty()) {
fields.emplace("voice", engine::io::json::Value::make_string(voice));
}
const auto body = engine::io::json::Value::make_object(std::move(fields));
auto & model = require_model(body);
if (model.task.mode != engine::runtime::RunMode::Streaming) {
throw std::runtime_error(
"live speech requires a model configured with mode=streaming: " +
model.config.id);
}
if (input.empty() && live_speech_requires_input(model.task.task)) {
throw std::runtime_error("live speech requires an 'input' query parameter");
}
model_ptr = &model;
const auto parse_bounded_int = [&](const char * key, int fallback, int minimum, int maximum) {
const std::string raw = query_param(request.query, key);
if (raw.empty()) {
return fallback;
}
long long value = 0;
size_t consumed = 0;
try {
value = std::stoll(raw, &consumed);
} catch (const std::exception &) {
throw std::runtime_error(
std::string("live speech ") + key + " must be an integer");
}
if (consumed != raw.size()) {
throw std::runtime_error(
std::string("live speech ") + key + " must be an integer");
}
if (value < minimum || value > maximum) {
throw std::runtime_error(
std::string("live speech ") + key + " must be between " +
std::to_string(minimum) + " and " + std::to_string(maximum));
}
return static_cast<int>(value);
};
sample_rate = parse_bounded_int("sample_rate", 16000, 1000, 384'000);
channels = parse_bounded_int("channels", 1, 1, 16);
if (!query_param(request.query, "busy_timeout_ms").empty()) {
busy_timeout_ms = parse_bounded_int(
"busy_timeout_ms", 0, 0, std::numeric_limits<int>::max());
}
const std::string sample_format_name = query_param(request.query, "sample_format");
sample_format = minitts::app::parse_pcm_sample_format(
sample_format_name.empty() ? "s16le" : sample_format_name);
const std::string response_format = query_param(request.query, "response_format").empty()
? "pcm"
: query_param(request.query, "response_format");
if (response_format != "pcm") {
throw std::runtime_error("live speech currently supports response_format=pcm");
}
stream_format = query_param(request.query, "stream_format").empty()
? "sse"
: query_param(request.query, "stream_format");
if (stream_format != "sse" && stream_format != "audio") {
throw std::runtime_error("live speech stream_format must be sse or audio");
}
// The text a model writes as it speaks, such as a spoken reply's
// words, comes as SSE events next to the audio; raw PCM has no place
// for it.
const std::string return_text_value = query_param(request.query, "return_text");
if (!return_text_value.empty() && return_text_value != "true" && return_text_value != "1" &&
return_text_value != "false" && return_text_value != "0") {
throw std::runtime_error("live speech return_text must be true or false");
}
return_text = return_text_value == "true" || return_text_value == "1";
if (return_text && stream_format != "sse") {
throw std::runtime_error("live speech return_text needs stream_format=sse");
}
task_request = build_speech_request(model, body);
// Neither input nor language leaves text_input unset, as /v1/tasks/run
// does without them, so the model uses its default prompt.
if (input.empty() && language.empty()) {
task_request.text_input.reset();
}
engine::runtime::AudioBuffer audio_contract;
audio_contract.sample_rate = sample_rate;
audio_contract.channels = channels;
task_request.audio_input = std::move(audio_contract);
const auto add_query_option = [&](const char * key, const char * option_key) {
const std::string value = decoded_query_param(request.query, key);
if (!value.empty()) {
task_request.options[option_key] = value;
}
};
add_query_option("seed", "seed");
add_query_option("temperature", "temperature");
add_query_option("top_k", "top_k");
add_query_option("top_p", "top_p");
add_query_option("max_tokens", "max_tokens");
add_query_option("max_steps", "max_steps");
add_query_option("repetition_penalty", "repetition_penalty");
add_query_option("guidance_scale", "guidance_scale");
add_query_option("num_inference_steps", "num_inference_steps");
add_query_option("instructions", "system_prompt");
add_query_option("reference_text", "reference_text");
add_query_option("voice_id", "voice_id");
add_query_option("system_prompt", "system_prompt");
add_query_option("text_temperature", "text_temperature");
add_query_option("text_top_k", "text_top_k");
add_query_option("do_sample", "do_sample");
} catch (const std::runtime_error & ex) {
return error_response(400, ex.what(), "invalid_request_error");
}
std::istream * pcm_input = request.body_stream;
if (stream_format == "audio") {
return chunked_audio_response([this, model_ptr, task_request, pcm_input, sample_rate, channels, sample_format, busy_timeout_ms](
HttpStreamWriter & writer) {
const minitts::app::AudioStreamFormat format{sample_rate, channels};
const auto audio = minitts::app::make_pcm_chunk_stream(*pcm_input, format, sample_format);
bool wrote_audio = false;
(void)run_streaming_model_from(
*model_ptr,
task_request,
audio,
[&](const engine::runtime::StreamEvent & event) {
if (event.audio_output.has_value()) {
const auto pcm = encode_pcm16_samples(*event.audio_output);
writer.write(std::string(reinterpret_cast<const char *>(pcm.data()), pcm.size()));
wrote_audio = true;
}
for (const auto & named : event.named_audio_outputs) {
const auto pcm = encode_pcm16_samples(named.audio);
writer.write(std::string(reinterpret_cast<const char *>(pcm.data()), pcm.size()));
wrote_audio = true;
}
},
busy_timeout_ms);
if (!wrote_audio) {
throw std::runtime_error("live speech model produced no audio delta events");
}
});
}
return sse_response(
[this, model_ptr, task_request, pcm_input, sample_rate, channels, sample_format, busy_timeout_ms, return_text](
HttpStreamWriter & writer) {
const minitts::app::AudioStreamFormat format{sample_rate, channels};
const auto audio = minitts::app::make_pcm_chunk_stream(*pcm_input, format, sample_format);
const auto request_started = Clock::now();
std::optional<double> first_audio_ms;
std::optional<double> input_end_ms;
minitts::app::AudioChunkStream timed_audio;
timed_audio.format = audio.format;
timed_audio.read = [
reader = audio.read,
request_started,
&input_end_ms
](int64_t max_samples, std::vector<float> & samples) mutable {
const bool has_more = reader(max_samples, samples);
if (!has_more && !input_end_ms.has_value()) {
input_end_ms = elapsed_ms(request_started);
}
return has_more;
};
bool wrote_audio = false;
std::string streamed_text;
const auto timed_result = run_streaming_model_from(
*model_ptr,
task_request,
timed_audio,
[&](const engine::runtime::StreamEvent & event) {
// An event's text goes before its audio: a model that
// speaks what it writes writes it first.
if (return_text && event.partial_text.has_value() && !event.partial_text->text.empty()) {
write_sse(
writer,
"{\"type\":\"speech.text.delta\",\"delta\":" +
json_quote(event.partial_text->text) +
"}");
streamed_text += event.partial_text->text;
}
if (event.audio_output.has_value()) {
if (!first_audio_ms.has_value()) {
first_audio_ms = elapsed_ms(request_started);
}
const auto pcm = encode_pcm16_samples(*event.audio_output);
write_sse(
writer,
"{\"type\":\"speech.audio.delta\",\"audio\":" +
json_quote(base64_encode(pcm)) +
"}");
wrote_audio = true;
}
for (const auto & named : event.named_audio_outputs) {
if (!first_audio_ms.has_value()) {
first_audio_ms = elapsed_ms(request_started);
}
const auto pcm = encode_pcm16_samples(named.audio);
write_sse(
writer,
"{\"type\":\"speech.audio.delta\",\"audio\":" +
json_quote(base64_encode(pcm)) +
"}");
wrote_audio = true;
}
},
busy_timeout_ms);
if (!wrote_audio) {
throw std::runtime_error("live speech model produced no audio delta events");
}
if (return_text) {
// The model's final text when it gives one, else what it
// streamed; a model that writes none fails the request rather
// than returning less than was asked for.
const auto & text = timed_result.result.text_output;
if (!text.has_value() && streamed_text.empty()) {
throw std::runtime_error("live speech model wrote no text; return_text needs a model that writes text");
}
write_sse(
writer,
"{\"type\":\"speech.text.done\",\"text\":" +
json_quote(text.has_value() ? text->text : streamed_text) +
"}");
}
write_sse(
writer,
"{\"type\":\"speech.audio.done\",\"timing\":" +
live_speech_timing_json(
require_ttft_ms(first_audio_ms),
require_input_end_ms(input_end_ms)) +
"}");
write_sse_done(writer);
});
}
// The streaming response carries transcript deltas only, so it has nowhere to put
// the detail arrays. Refusing is better than accepting the request and silently
// returning none of what the route exists to return.
constexpr const char * kDetailStreamUnsupported =
"streaming is not supported on /v1/audio/transcriptions/details; "
"use /v1/audio/transcriptions for a streamed transcript";
HttpResponse ServerState::handle_transcription(const HttpRequest & request, bool detail) {
std::string content_type;
if (const auto it = request.headers.find("content-type"); it != request.headers.end()) {
content_type = it->second;
}
if (engine::debug::log_enabled()) {
engine::debug::log_message(
"[SERVER_TRANSCRIPTION_DEBUG] core.transcription.enter content_type=" + content_type +
" body_bytes=" + std::to_string(request.body.size()));
}
if (const auto boundary = extract_multipart_boundary(content_type)) {
if (engine::debug::log_enabled()) {
engine::debug::log_message("[SERVER_TRANSCRIPTION_DEBUG] core.transcription.route multipart");
}
return handle_transcription_multipart(request.body, *boundary, detail);
}
if (engine::debug::log_enabled()) {
engine::debug::log_message("[SERVER_TRANSCRIPTION_DEBUG] core.transcription.route json");
}
return handle_transcription_json(request.body, detail);
}
HttpResponse ServerState::handle_transcription_json(const std::string & body_text, bool detail) {
const auto body = engine::io::json::parse(body_text);
auto & model = require_model(body);
const auto request = apply_default_request_options(
model.config.default_request_options,
build_openai_transcription_request(body, request_base_, model.accepts_language));
const auto busy_timeout_ms = parse_busy_timeout_override(body);
if (bool_field(body, "stream", false)) {
if (detail) {
return error_response(400, kDetailStreamUnsupported, "invalid_request_error");
}
return run_transcription_stream(model, request, busy_timeout_ms);
}
return run_transcription(model, request, busy_timeout_ms, detail);
}
// Accepts the same multipart/form-data shape OpenAI's Whisper API (and clients built against it,
// e.g. Open WebUI) send: a "file" part with the audio bytes, plus "model" and optional "language"
// fields. audio.cpp's native JSON request only takes a server-local path, so the uploaded bytes are
// spooled to a temp file and routed through the existing JSON request builder.
HttpResponse ServerState::handle_transcription_multipart(
const std::string & body_text, const std::string & boundary, bool detail) {
const auto parts = parse_multipart_body(body_text, boundary);
if (engine::debug::log_enabled()) {
engine::debug::log_message(
"[SERVER_TRANSCRIPTION_DEBUG] core.multipart.parts count=" + std::to_string(parts.size()) +
" body_bytes=" + std::to_string(body_text.size()));
}
log_multipart_request_summary_if_enabled(config_, parts);
const MultipartPart * file_part = nullptr;
std::string model_id;
std::string language;
std::string prompt;
std::optional<int> busy_timeout_ms;
bool stream = false;
for (const auto & part : parts) {
if (part.name == "file") {
file_part = &part;
} else if (part.name == "model") {
model_id = part.data;
} else if (part.name == "language") {
language = part.data;
} else if (part.name == "prompt" || part.name == "text") {
// Recognition-context biasing. "prompt" is the OpenAI
// transcription field name; "text" matches the JSON request
// builder, which already forwards it to request.text_input.
prompt = part.data;
} else if (part.name == "busy_timeout_ms") {
try {
busy_timeout_ms = std::stoi(part.data);
} catch (const std::exception &) {
throw std::runtime_error("multipart busy_timeout_ms field must be an integer");
}
if (*busy_timeout_ms < 0) {
throw std::runtime_error("busy_timeout_ms must be >= 0 (0 means no client-side bound)");
}
} else if (part.name == "stream") {
if (part.data == "true" || part.data == "True" || part.data == "1") {
stream = true;
} else if (part.data == "false" || part.data == "False" || part.data == "0") {
stream = false;
} else {
throw std::runtime_error("multipart transcription stream field must be true or false");
}
}
}
if (file_part == nullptr || file_part->data.empty()) {
if (engine::debug::log_enabled()) {
engine::debug::log_message("[SERVER_TRANSCRIPTION_DEBUG] core.multipart.missing_file");
}
throw std::runtime_error("multipart transcription request requires a non-empty 'file' field");
}
if (model_id.empty()) {
throw std::runtime_error("multipart transcription request requires a 'model' field");
}
if (!is_wav_upload_filename(file_part->filename)) {
if (engine::debug::log_enabled()) {
engine::debug::log_message(
"[SERVER_TRANSCRIPTION_DEBUG] core.multipart.reject_non_wav filename=" + file_part->filename +
" bytes=" + std::to_string(file_part->data.size()));
}
return error_response(
400,
"only WAV audio uploads are currently supported for transcription; MP3 support is planned",
"invalid_request_error");
}
engine::io::json::Value::Object fields;
fields.emplace("model", engine::io::json::Value::make_string(model_id));
if (!language.empty()) {
fields.emplace("language", engine::io::json::Value::make_string(language));
}
if (!prompt.empty()) {
fields.emplace("text", engine::io::json::Value::make_string(prompt));
}
const auto body = engine::io::json::Value::make_object(std::move(fields));
auto & model = require_model(body);
const auto request = apply_default_request_options(
model.config.default_request_options,
build_openai_transcription_request(
body, request_base_, model.accepts_language, &file_part->data));
if (stream) {
if (detail) {
return error_response(400, kDetailStreamUnsupported, "invalid_request_error");
}
return run_transcription_stream(model, request, busy_timeout_ms);
}
return run_transcription(model, request, busy_timeout_ms, detail);
}
HttpResponse ServerState::handle_batch_transcriptions(const HttpRequest & request) {
std::string content_type;
if (const auto it = request.headers.find("content-type"); it != request.headers.end()) {
content_type = it->second;
}
const auto boundary = extract_multipart_boundary(content_type);
if (!boundary.has_value()) {
return error_response(
400,
"batch transcription requests must use multipart/form-data",
"invalid_request_error");
}
return handle_batch_transcriptions_multipart(request.body, *boundary);
}
HttpResponse ServerState::handle_batch_transcriptions_multipart(
const std::string & body_text,
const std::string & boundary) {
const auto parts = parse_multipart_body(body_text, boundary);
log_multipart_request_summary_if_enabled(config_, parts);
std::vector<const MultipartPart *> file_parts;
std::string model_id;
std::string language;
std::string prompt;
std::unordered_map<std::string, std::string> options;
std::optional<int> busy_timeout_ms;
for (const auto & part : parts) {
if (part.name == "file") {
file_parts.push_back(&part);
} else if (part.name == "model") {
model_id = part.data;
} else if (part.name == "language") {
language = part.data;
} else if (part.name == "prompt" || part.name == "text") {
prompt = part.data;
} else if (part.name == "options") {
const auto option_fields = engine::io::json::parse(part.data);
options = options_from_object(&option_fields);
} else if (part.name == "busy_timeout_ms") {
try {
busy_timeout_ms = std::stoi(part.data);
} catch (const std::exception &) {
throw std::runtime_error("multipart busy_timeout_ms field must be an integer");
}
if (*busy_timeout_ms < 0) {
throw std::runtime_error("busy_timeout_ms must be >= 0 (0 means no client-side bound)");
}
}
}
if (model_id.empty()) {
throw std::runtime_error("batch transcription request requires a 'model' field");
}
if (file_parts.empty()) {
throw std::runtime_error("batch transcription request requires at least one 'file' field");
}
for (const auto * file_part : file_parts) {
if (file_part->data.empty()) {
throw std::runtime_error("batch transcription files must not be empty");
}
if (!is_wav_upload_filename(file_part->filename)) {
return error_response(
400,
"only WAV audio uploads are currently supported for batch transcription",
"invalid_request_error");
}
}
engine::io::json::Value::Object model_fields;
model_fields.emplace("model", engine::io::json::Value::make_string(model_id));
const auto model_body = engine::io::json::Value::make_object(std::move(model_fields));
auto & model = require_model(model_body);
if (model_run_mode(model) != engine::runtime::RunMode::Offline) {
return error_response(
400,
"batch transcription requires a model configured with mode=offline",
"invalid_request_error");
}
std::vector<engine::runtime::TaskRequest> requests;
requests.reserve(file_parts.size());
for (const auto * file_part : file_parts) {
engine::io::json::Value::Object fields;
fields.emplace("model", engine::io::json::Value::make_string(model_id));
if (!language.empty()) {
fields.emplace("language", engine::io::json::Value::make_string(language));
}
if (!prompt.empty()) {
fields.emplace("text", engine::io::json::Value::make_string(prompt));
}
engine::io::json::Value::Object option_fields;
for (const auto & [key, value] : options) {
option_fields.emplace(key, engine::io::json::Value::make_string(value));
}
if (!option_fields.empty()) {
fields.emplace("options", engine::io::json::Value::make_object(std::move(option_fields)));
}
const auto body = engine::io::json::Value::make_object(std::move(fields));
requests.push_back(apply_default_request_options(
model.config.default_request_options,
build_openai_transcription_request(
body, request_base_, model.accepts_language, &file_part->data)));
}
{
BusyGuard::Lock lock = acquire_model_run(model, busy_timeout_ms);
ensure_model_loaded_locked(model);
if (dynamic_cast<engine::runtime::IBatchedOfflineVoiceTaskSession *>(model.session.get()) == nullptr) {
return error_response(
400,
"configured model does not provide native offline batching: " + model.config.id,
"invalid_request_error");
}
}
std::vector<std::string> filenames;
filenames.reserve(file_parts.size());
for (const auto * file_part : file_parts) {
filenames.push_back(file_part->filename);
}
LoadedModel * model_ptr = &model;
return sse_response([
this,
model_ptr,
requests = std::move(requests),
filenames = std::move(filenames),
busy_timeout_ms](HttpStreamWriter & writer) {
BusyGuard::Lock lock = acquire_model_run(*model_ptr, busy_timeout_ms);
ensure_model_loaded_locked(*model_ptr);
auto * batched = dynamic_cast<engine::runtime::IBatchedOfflineVoiceTaskSession *>(model_ptr->session.get());
if (batched == nullptr) {
throw std::runtime_error(
"configured model does not provide native offline batching: " + model_ptr->config.id);
}
double total_audio_duration_ms = 0.0;
for (const auto & request : requests) {
total_audio_duration_ms += audio_duration_ms(*request.audio_input);
}
const auto started = Clock::now();
model_ptr->session->prepare(engine::runtime::build_preparation_request(requests.front()));
std::vector<bool> completed(requests.size(), false);
size_t completed_count = 0;
batched->run_batch(requests, [&](size_t index, engine::runtime::TaskResult result) {
if (index >= requests.size() || completed[index]) {
throw std::runtime_error("batched session returned an invalid result index");
}
const auto & audio = *requests[index].audio_input;
std::ostringstream out;
out << "{\"type\":\"batch.transcription.result\",\"index\":" << index
<< ",\"filename\":" << json_quote(filenames[index])
<< ",\"text\":"
<< (result.text_output ? json_quote(result.text_output->text) : "\"\"");
if (result.text_output && !result.text_output->language.empty()) {
out << ",\"language\":" << json_quote(result.text_output->language);
}
write_transcript_detail_fields(out, result);
if (!result.speech_segments.empty() || !result.speaker_turns.empty() ||
!result.word_timestamps.empty()) {
out << ",\"sample_rate\":" << audio.sample_rate;
}
out << "}";
write_sse(writer, out.str());
completed[index] = true;
++completed_count;
});
const double wall_ms = elapsed_ms(started);
last_activity_ms_.store(steady_now_ms(), std::memory_order_relaxed);
if (completed_count != requests.size()) {
throw std::runtime_error("batched session returned an unexpected result count");
}
std::ostringstream done;
done << "{\"type\":\"batch.transcription.done\",\"result_count\":" << completed_count
<< ",\"timing\":{\"wall_ms\":" << wall_ms
<< ",\"audio_duration_ms\":" << total_audio_duration_ms
<< ",\"rtf\":" << audio_rtf(wall_ms, total_audio_duration_ms) << "}}";
write_sse(writer, done.str());
write_sse_done(writer);
});
}
HttpResponse ServerState::run_transcription(
LoadedModel & model,
const engine::runtime::TaskRequest & request,
std::optional<int> busy_timeout_ms,
bool detail) {
const auto timed_result = model_run_mode(model) == engine::runtime::RunMode::Streaming
? run_streaming_model(model, request, {}, busy_timeout_ms)
: run_model(model, request, busy_timeout_ms);
const auto & result = timed_result.result;
if (!result.text_output.has_value()) {
std::shared_lock<std::shared_mutex> metadata_lock(model.metadata_mutex);
// Diarization has no transcript, even when silence yields no speaker turns.
if (!detail || model.task.task != engine::runtime::VoiceTaskKind::Diarization) {
throw std::runtime_error("model result did not contain transcript text");
}
}
if (!request.audio_input.has_value()) {
throw std::runtime_error("transcription timing requires audio_input");
}
if (!detail) {
return json_response(
"{\"text\":" + json_quote(result.text_output->text) +
",\"timing\":" + timing_json(timed_result.wall_ms, *request.audio_input) + "}");
}
// Models that align words or separate speakers report them through the same
// detail fields /v1/tasks/run serialises, and the transcription response
// discards them. This is the opt-in route that keeps them, so the shape stays
// a superset of the plain one: text first, timing last, details in between.
std::ostringstream out;
out << "{\"text\":" << (result.text_output ? json_quote(result.text_output->text) : "\"\"");
if (result.text_output && !result.text_output->language.empty()) {
out << ",\"language\":" << json_quote(result.text_output->language);
}
write_transcript_detail_fields(out, result);
// Detail spans are sample offsets, so the rate they are counted in has to
// travel with them or a client cannot turn them into timestamps.
if (!result.speech_segments.empty() || !result.speaker_turns.empty() ||
!result.word_timestamps.empty()) {
out << ",\"sample_rate\":" << request.audio_input->sample_rate;
}
out << ",\"timing\":" << timing_json(timed_result.wall_ms, *request.audio_input) << "}";
return json_response(out.str());
}
HttpResponse ServerState::run_transcription_stream(
LoadedModel & model,
const engine::runtime::TaskRequest & request,
std::optional<int> busy_timeout_ms) {
if (model_run_mode(model) != engine::runtime::RunMode::Streaming) {
throw std::runtime_error("transcription stream=true requires a model configured with mode=streaming");
}
LoadedModel * model_ptr = &model;
bool diarization;
{
std::shared_lock<std::shared_mutex> metadata_lock(model.metadata_mutex);
diarization = model.task.task == engine::runtime::VoiceTaskKind::Diarization;
}
return sse_response([this, model_ptr, request, busy_timeout_ms, diarization](HttpStreamWriter & writer) {
const auto timed_result = run_streaming_model(
*model_ptr,
request,
[&](const engine::runtime::StreamEvent & event) {
if (diarization) {
if (!event.speaker_turns.empty()) {
write_sse(writer, diarization_event_json(
event.speaker_turns, request.audio_input->sample_rate, false));
}
return;
}
if (event.partial_text_snapshot.has_value()) {
write_sse(writer, "{\"type\":\"transcript.text.partial\",\"text\":" +
json_quote(event.partial_text_snapshot->text) + "}");
}
if (!event.partial_text.has_value() || event.partial_text->text.empty()) {
return;
}
write_sse(
writer,
"{\"type\":\"transcript.text.delta\",\"delta\":" +
json_quote(event.partial_text->text) +
"}");
},
busy_timeout_ms);
if (diarization) {
write_sse(writer, diarization_event_json(
timed_result.result.speaker_turns, request.audio_input->sample_rate, true, timed_result.ttft_ms));
write_sse_done(writer);
return;
}
if (!timed_result.result.text_output.has_value()) {
throw std::runtime_error("streaming transcription result did not contain transcript text");
}
write_sse(
writer,
"{\"type\":\"transcript.text.done\",\"text\":" +
json_quote(timed_result.result.text_output->text) +
",\"timing\":" +
ttft_timing_json(require_ttft_ms(timed_result.ttft_ms)) +
"}");
write_sse_done(writer);
});
}
HttpResponse ServerState::handle_alignment(const HttpRequest & request) {
std::string content_type;
if (const auto it = request.headers.find("content-type"); it != request.headers.end()) {
content_type = it->second;
}
if (const auto boundary = extract_multipart_boundary(content_type)) {
return handle_alignment_multipart(request.body, *boundary);
}
return error_response(
400,
"audio alignment requests must use multipart/form-data",
"invalid_request_error");
}
HttpResponse ServerState::handle_alignment_multipart(const std::string & body_text, const std::string & boundary) {
const auto parts = parse_multipart_body(body_text, boundary);
log_multipart_request_summary_if_enabled(config_, parts);
const MultipartPart * file_part = nullptr;
std::string model_id;
std::string text;
std::string language;
std::optional<int> busy_timeout_ms;
for (const auto & part : parts) {
if (part.name == "file") {
file_part = &part;
} else if (part.name == "model") {
model_id = part.data;
} else if (part.name == "text") {
text = part.data;
} else if (part.name == "language") {
language = part.data;
} else if (part.name == "busy_timeout_ms") {
try {
busy_timeout_ms = std::stoi(part.data);
} catch (const std::exception &) {
return error_response(
400,
"multipart busy_timeout_ms field must be an integer",
"invalid_request_error");
}
if (*busy_timeout_ms < 0) {
return error_response(
400,
"busy_timeout_ms must be >= 0 (0 means no client-side bound)",
"invalid_request_error");
}
}
}
if (file_part == nullptr || file_part->data.empty()) {
return error_response(
400,
"multipart alignment request requires a non-empty 'file' field",
"invalid_request_error");
}
if (model_id.empty()) {
return error_response(
400,
"multipart alignment request requires a 'model' field",
"invalid_request_error");
}
if (text.empty()) {
return error_response(
400,
"multipart alignment request requires a non-empty 'text' field",
"invalid_request_error");
}
if (!is_wav_upload_filename(file_part->filename)) {
return error_response(
400,
"only WAV audio uploads are currently supported for alignment",
"invalid_request_error");
}
LoadedModel * model_ptr = nullptr;
{
std::lock_guard<std::mutex> state_lock(models_mutex_);
const auto it = model_index_.find(model_id);
if (it == model_index_.end()) {
return error_response(
400,
"unknown model id: " + model_id,
"invalid_request_error");
}
model_ptr = models_.at(it->second).get();
}
auto & model = *model_ptr;
if (model.task.task != engine::runtime::VoiceTaskKind::Alignment) {
return error_response(
400,
"audio alignment requires a model configured with task=align",
"invalid_request_error");
}
if (model.task.mode != engine::runtime::RunMode::Offline) {
return error_response(
400,
"audio alignment requires a model configured with mode=offline",
"invalid_request_error");
}
engine::runtime::TaskRequest task_request;
task_request.audio_input = minitts::cli::read_audio_buffer(std::string_view(file_part->data));
task_request.text_input = engine::runtime::Transcript{std::move(text), std::move(language)};
task_request = apply_default_request_options(
model.config.default_request_options, std::move(task_request));
return run_alignment(model, task_request, busy_timeout_ms);
}
HttpResponse ServerState::run_alignment(
LoadedModel & model,
const engine::runtime::TaskRequest & request,
std::optional<int> busy_timeout_ms) {
if (model_run_mode(model) != engine::runtime::RunMode::Offline) {
throw std::runtime_error("audio alignment requires a model configured with mode=offline");
}
const auto timed_result = run_model(model, request, busy_timeout_ms);
if (timed_result.result.word_timestamps.empty()) {
throw std::runtime_error("alignment model produced no word timestamps");
}
if (!request.audio_input.has_value()) {
throw std::runtime_error("alignment timing requires audio_input");
}
return json_response(alignment_result_json(timed_result.result, *request.audio_input, timed_result.wall_ms));
}
// Live PCM ingest. The client streams raw interleaved samples in a chunked request
// body while transcript deltas stream back as SSE on the same connection, so
// partials track capture instead of waiting for a finished upload. Same event shape
// as the file-backed `stream=true` path, so a client can share one SSE reader.
//
// Deliberately a single request rather than open/append/finish session endpoints:
// the busy lock is held for the length of a run, so a session spread across
// separate requests would pin the model while idling between a client's appends —
// and wedge it outright if that client vanished. One request bounds the lock by the
// lifetime of the connection.
HttpResponse ServerState::handle_transcription_live(const HttpRequest & request) {
if (request.body_stream == nullptr) {
return error_response(
400,
"live transcription requires an incrementally delivered body (Transfer-Encoding: chunked)",
"invalid_request_error");
}
// Everything from here to the end of parameter parsing validates client input, so
// a failure is the caller's fault and belongs in a 4xx. Left to propagate, these
// would reach the generic handler and be reported as 500, which tells a client
// to retry an identical request that cannot ever succeed.
LoadedModel * model_ptr = nullptr;
bool diarization = false;
int sample_rate = 16000;
int channels = 1;
std::optional<int> busy_timeout_ms;
minitts::app::PcmSampleFormat sample_format = minitts::app::PcmSampleFormat::S16LE;
engine::runtime::TaskRequest task_request;
try {
const std::string model_id = query_param(request.query, "model");
if (model_id.empty()) {
throw std::runtime_error("live transcription requires a 'model' query parameter");
}
// There is no request body to carry JSON — it is all audio — so the parameters
// arrive as query params and are re-shaped into the object require_model expects.
engine::io::json::Value::Object fields;
fields.emplace("model", engine::io::json::Value::make_string(model_id));
const auto body = engine::io::json::Value::make_object(std::move(fields));
auto & model = require_model(body);
if (model.task.mode != engine::runtime::RunMode::Streaming) {
throw std::runtime_error(
"live transcription requires a model configured with mode=streaming: " +
model.config.id);
}
model_ptr = &model;
{
std::shared_lock<std::shared_mutex> metadata_lock(model.metadata_mutex);
diarization = model.task.task == engine::runtime::VoiceTaskKind::Diarization;
}
// These two are not merely descriptive: the streaming policy multiplies them
// into a per-chunk sample count, which sizes a buffer allocated after the model
// lock has been taken. Left unbounded, a request naming an absurd rate or
// channel count overflows that multiplication or asks for a multi-terabyte
// allocation while holding the lock, so both are range-checked at the edge.
const auto parse_bounded_int = [&](const char * key, int fallback, int minimum, int maximum) {
const std::string raw = query_param(request.query, key);
if (raw.empty()) {
return fallback;
}
// Parsed to the end of the string on purpose: stoi stops at the first
// non-digit, so "16000junk" would silently become 16000. A misdeclared
// format produces a confident, wrong transcript, so reject it instead.
long long value = 0;
size_t consumed = 0;
try {
value = std::stoll(raw, &consumed);
} catch (const std::exception &) {
throw std::runtime_error(
std::string("live transcription ") + key + " must be an integer");
}
if (consumed != raw.size()) {
throw std::runtime_error(
std::string("live transcription ") + key + " must be an integer");
}
if (value < minimum || value > maximum) {
throw std::runtime_error(
std::string("live transcription ") + key + " must be between " +
std::to_string(minimum) + " and " + std::to_string(maximum));
}
return static_cast<int>(value);
};
sample_rate = parse_bounded_int("sample_rate", 16000, 1000, 384'000);
channels = parse_bounded_int("channels", 1, 1, 16);
// The other routes take this in their JSON body; this one has no body to put
// it in, so it arrives as a query param. Same meaning either way: a request
// may shorten its own wait for the model lock but never lengthen it past the
// configured ceiling — resolve_busy_timeout_ms clamps it.
if (!query_param(request.query, "busy_timeout_ms").empty()) {
busy_timeout_ms = parse_bounded_int(
"busy_timeout_ms", 0, 0, std::numeric_limits<int>::max());
}
const std::string sample_format_name = query_param(request.query, "sample_format");
sample_format = minitts::app::parse_pcm_sample_format(
sample_format_name.empty() ? "s16le" : sample_format_name);
// Format contract with no samples: prepare() takes the rate and channel count
// from here, and the samples themselves arrive from the body. Same shape the
// CLI's stdin path builds (app/cli/main.cpp:472-482).
engine::runtime::AudioBuffer audio_contract;
audio_contract.sample_rate = sample_rate;
audio_contract.channels = channels;
task_request.audio_input = std::move(audio_contract);
const std::string language = decoded_query_param(request.query, "language");
// Recognition-context biasing (hotwords), same meaning as the multipart
// route's `prompt` field; URL-encoded because it rides in the query.
const std::string prompt = decoded_query_param(request.query, "prompt");
if (!language.empty()) {
task_request.options["language"] = language;
}
if (!language.empty() || !prompt.empty()) {
task_request.text_input = engine::runtime::Transcript{prompt, language};
}
task_request = apply_default_request_options(
model.config.default_request_options, std::move(task_request));
} catch (const std::runtime_error & ex) {
// Deliberately runtime_error and not exception: every rejection above is
// thrown as one, while a genuine server fault inside this block is not.
// std::bad_alloc derives from std::exception directly and std::out_of_range
// from std::logic_error, so both keep propagating to the 500 path instead of
// being mislabelled as the caller's mistake.
return error_response(400, ex.what(), "invalid_request_error");
}
std::istream * pcm_input = request.body_stream;
return sse_response(
[this, model_ptr, task_request, pcm_input, sample_rate, channels, sample_format, busy_timeout_ms, diarization](
HttpStreamWriter & writer) {
const minitts::app::AudioStreamFormat format{sample_rate, channels};
const auto audio = minitts::app::make_pcm_chunk_stream(*pcm_input, format, sample_format);
const auto timed_result = run_streaming_model_from(
*model_ptr,
task_request,
audio,
[&](const engine::runtime::StreamEvent & event) {
if (diarization) {
if (!event.speaker_turns.empty()) {
write_sse(writer, diarization_event_json(event.speaker_turns, sample_rate, false));
}
return;
}
if (event.partial_text_snapshot.has_value()) {
write_sse(writer, "{\"type\":\"transcript.text.partial\",\"text\":" +
json_quote(event.partial_text_snapshot->text) + "}");
}
if (!event.partial_text.has_value() || event.partial_text->text.empty()) {
return;
}
write_sse(
writer,
"{\"type\":\"transcript.text.delta\",\"delta\":" +
json_quote(event.partial_text->text) +
"}");
},
busy_timeout_ms);
if (diarization) {
write_sse(writer, diarization_event_json(
timed_result.result.speaker_turns, sample_rate, true, timed_result.ttft_ms));
write_sse_done(writer);
return;
}
if (!timed_result.result.text_output.has_value()) {
throw std::runtime_error("live transcription result did not contain transcript text");
}
write_sse(
writer,
"{\"type\":\"transcript.text.done\",\"text\":" +
json_quote(timed_result.result.text_output->text) +
",\"timing\":" +
ttft_timing_json(require_ttft_ms(timed_result.ttft_ms)) +
"}");
write_sse_done(writer);
});
}
// /v1/tasks/run folds a top-level `language` into the option map, so the same
// unset-field problem reaches models through this route as well. Remove only the
// option this route synthesised, never one the caller wrote inside `options`:
// an explicit option is a deliberate choice, and the strict rejection is the
// right answer to it.
engine::runtime::TaskRequest drop_unsupported_language_option(
engine::runtime::TaskRequest request,
const Value & request_json,
bool accepts_language_option) {
if (accepts_language_option) {
return request;
}
const auto * top_level = request_json.find("language");
if (top_level == nullptr || !top_level->is_string()) {
return request;
}
const auto it = request.options.find("language");
if (it != request.options.end() && it->second == top_level->as_string()) {
request.options.erase(it);
}
return request;
}
HttpResponse ServerState::handle_generic_run(const std::string & body_text) {
const auto body = engine::io::json::parse(body_text);
auto & model = require_model(body);
const auto * request_json = body.find("request");
const auto & effective_json = request_json != nullptr ? *request_json : body;
std::optional<engine::runtime::AudioBuffer> inline_audio;
if (const auto * encoded = effective_json.find("audio_base64")) {
if (effective_json.find("audio") != nullptr) {
return error_response(400, "provide only one of audio or audio_base64", "invalid_request_error");
}
if (!encoded->is_string() || encoded->as_string().empty()) {
return error_response(400, "audio_base64 must be a non-empty string", "invalid_request_error");
}
// The HTTP body limit bounds the encoded payload before this allocation.
try {
const auto bytes = base64_decode(encoded->as_string());
if (bytes.empty()) {
return error_response(400, "audio_base64 contains no audio", "invalid_request_error");
}
inline_audio = minitts::cli::read_audio_buffer(
std::string_view(reinterpret_cast<const char *>(bytes.data()), bytes.size()));
} catch (const std::runtime_error &) {
return error_response(400, "audio_base64 must contain a valid Base64-encoded WAV", "invalid_request_error");
}
}
auto parsed_request = minitts::cli::build_request_from_json(effective_json, request_base_);
if (inline_audio) {
parsed_request.audio_input = std::move(*inline_audio);
}
const auto request = apply_default_request_options(
model.config.default_request_options,
drop_unsupported_language_option(
std::move(parsed_request),
effective_json,
model.accepts_language));
const auto busy_timeout_ms = parse_busy_timeout_override(body);
const auto timed_result = model_run_mode(model) == engine::runtime::RunMode::Streaming
? run_streaming_model(model, request, {}, busy_timeout_ms)
: run_model(model, request, busy_timeout_ms);
return json_response(task_result_json(timed_result.result, timed_result.wall_ms));
}
HttpResponse ServerState::handle_generic_batch(const std::string & body_text) {
const auto body = engine::io::json::parse(body_text);
auto & model = require_model(body);
if (model_run_mode(model) != engine::runtime::RunMode::Offline) {
return error_response(
400,
"task batching requires a model configured with mode=offline",
"invalid_request_error");
}
const auto * request_values = body.find("requests");
if (request_values == nullptr || !request_values->is_array() || request_values->as_array().empty()) {
return error_response(
400,
"task batch request requires a non-empty 'requests' array",
"invalid_request_error");
}
std::vector<engine::runtime::TaskRequest> requests;
requests.reserve(request_values->as_array().size());
for (const auto & request_json : request_values->as_array()) {
if (!request_json.is_object()) {
return error_response(
400,
"task batch entries must be JSON objects",
"invalid_request_error");
}
requests.push_back(apply_default_request_options(
model.config.default_request_options,
drop_unsupported_language_option(
minitts::cli::build_request_from_json(request_json, request_base_),
request_json,
model.accepts_language)));
}
const auto busy_timeout_ms = parse_busy_timeout_override(body);
{
BusyGuard::Lock lock = acquire_model_run(model, busy_timeout_ms);
ensure_model_loaded_locked(model);
if (dynamic_cast<engine::runtime::IBatchedOfflineVoiceTaskSession *>(model.session.get()) == nullptr) {
return error_response(
400,
"configured model does not provide native offline batching: " + model.config.id,
"invalid_request_error");
}
}
LoadedModel * model_ptr = &model;
return sse_response([
this,
model_ptr,
requests = std::move(requests),
busy_timeout_ms](HttpStreamWriter & writer) {
BusyGuard::Lock lock = acquire_model_run(*model_ptr, busy_timeout_ms);
ensure_model_loaded_locked(*model_ptr);
auto * batched = dynamic_cast<engine::runtime::IBatchedOfflineVoiceTaskSession *>(
model_ptr->session.get());
if (batched == nullptr) {
throw std::runtime_error(
"configured model does not provide native offline batching: " + model_ptr->config.id);
}
double total_audio_duration_ms = 0.0;
bool all_requests_have_audio = true;
for (const auto & request : requests) {
if (!request.audio_input.has_value()) {
all_requests_have_audio = false;
break;
}
total_audio_duration_ms += audio_duration_ms(*request.audio_input);
}
const auto started = Clock::now();
model_ptr->session->prepare(engine::runtime::build_preparation_request(requests.front()));
std::vector<bool> completed(requests.size(), false);
size_t completed_count = 0;
batched->run_batch(requests, [&](size_t index, engine::runtime::TaskResult result) {
if (index >= requests.size() || completed[index]) {
throw std::runtime_error("batched session returned an invalid result index");
}
write_sse(
writer,
"{\"type\":\"task.batch.result\",\"index\":" + std::to_string(index) +
",\"result\":" + task_result_json_with_timing(result, "null") + "}");
completed[index] = true;
++completed_count;
});
const double wall_ms = elapsed_ms(started);
last_activity_ms_.store(steady_now_ms(), std::memory_order_relaxed);
if (completed_count != requests.size()) {
throw std::runtime_error("batched session returned an unexpected result count");
}
std::ostringstream done;
done << "{\"type\":\"task.batch.done\",\"result_count\":" << completed_count
<< ",\"timing\":{\"wall_ms\":" << wall_ms;
if (all_requests_have_audio) {
done << ",\"audio_duration_ms\":" << total_audio_duration_ms
<< ",\"rtf\":" << audio_rtf(wall_ms, total_audio_duration_ms);
}
done << "}}";
write_sse(writer, done.str());
write_sse_done(writer);
});
}
HttpResponse ServerState::handle_generic_stream(const std::string & body_text) {
const auto body = engine::io::json::parse(body_text);
auto & model = require_model(body);
const bool sse = task_stream_sse_requested(body);
const bool include_artifacts = bool_field(body, "include_artifacts", false);
const auto * request_json = body.find("request");
const auto & effective_json = request_json != nullptr ? *request_json : body;
auto request = apply_default_request_options(
model.config.default_request_options,
drop_unsupported_language_option(
minitts::cli::build_request_from_json(effective_json, request_base_),
effective_json,
model.accepts_language));
if (sse) {
const auto busy_timeout_ms = parse_busy_timeout_override(body);
bool diarization;
{
std::shared_lock<std::shared_mutex> metadata_lock(model.metadata_mutex);
diarization = model.task.task == engine::runtime::VoiceTaskKind::Diarization;
}
LoadedModel * model_ptr = &model;
// Each event goes out as the model produces it, as the object the JSON
// response puts in "events", and the result follows as the object it
// puts under "result". The model lock is taken inside the stream, as on
// the other streamed routes, so a busy model is an error event here.
return sse_response([this, model_ptr, request = std::move(request), busy_timeout_ms, diarization, include_artifacts](
HttpStreamWriter & writer) {
const auto timed_result = run_streaming_model(
*model_ptr,
request,
[&](const engine::runtime::StreamEvent & event) {
write_sse(writer, task_stream_event_line(stream_event_json(event, diarization, include_artifacts)));
},
busy_timeout_ms, include_artifacts);
const bool silent_diarization = diarization && !timed_result.ttft_ms.has_value();
write_sse(writer, task_stream_done_line(
silent_diarization
? task_result_json_with_timing(timed_result.result, "{\"ttft_ms\":null}")
: streaming_task_result_json(timed_result.result, timed_result.ttft_ms)));
write_sse_done(writer);
});
}
std::vector<engine::runtime::StreamEvent> events;
const auto timed_result = run_streaming_model(
model,
request,
[&](const engine::runtime::StreamEvent & event) {
events.push_back(event);
},
parse_busy_timeout_override(body), include_artifacts);
std::ostringstream out;
std::shared_lock<std::shared_mutex> metadata_lock(model.metadata_mutex);
const bool diarization = model.task.task == engine::runtime::VoiceTaskKind::Diarization;
out << "{\"events\":[";
for (size_t i = 0; i < events.size(); ++i) {
if (i != 0) {
out << ",";
}
out << stream_event_json(events[i], diarization, include_artifacts);
}
const bool silent_diarization = diarization && !timed_result.ttft_ms.has_value();
out << "],\"result\":" << (silent_diarization
? task_result_json_with_timing(timed_result.result, "{\"ttft_ms\":null}")
: streaming_task_result_json(timed_result.result, timed_result.ttft_ms)) << "}";
return json_response(out.str());
}
ServerState::TimedTaskResult ServerState::run_recipe(
const Value & body,
const minitts::app::RecipeEventSink & event_sink) {
const auto recipe_id = engine::io::json::require_string(body, "recipe");
const minitts::app::IRecipe * recipe_ptr = nullptr;
try {
recipe_ptr = &recipes_.require(recipe_id);
} catch (const std::runtime_error &) {
throw engine::runtime::InvalidRequestError("unknown recipe: " + recipe_id);
}
const auto & recipe = *recipe_ptr;
const auto * models = body.find("models");
if (models == nullptr || !models->is_object()) {
throw engine::runtime::InvalidRequestError("recipe request requires a 'models' object");
}
std::unordered_map<std::string, std::string> model_ids;
for (const auto & role : recipe.roles()) {
const auto * value = models->find(role.name);
if (value == nullptr || !value->is_string() || value->as_string().empty()) {
throw engine::runtime::InvalidRequestError(
"recipe '" + recipe_id + "' requires model role '" + role.name + "'");
}
model_ids.emplace(role.name, value->as_string());
}
const auto * request_json = body.find("request");
if (request_json == nullptr || !request_json->is_object()) {
throw engine::runtime::InvalidRequestError("recipe request requires a 'request' object");
}
auto request = minitts::cli::build_request_from_json(*request_json, request_base_);
const auto busy_timeout_ms = parse_busy_timeout_override(body);
const auto execute = [
this,
&model_ids,
busy_timeout_ms](
std::string_view role,
const engine::runtime::TaskSpec & task,
const engine::runtime::TaskRequest & stage_request,
const minitts::app::RecipeEventSink & sink, const minitts::app::AudioChunkStream * source) {
const auto selected = model_ids.find(std::string(role));
if (selected == model_ids.end()) {
throw std::runtime_error("recipe requested an undeclared model role: " + std::string(role));
}
Value::Object model_field;
model_field.emplace("model", Value::make_string(selected->second));
auto & model = require_model(Value::make_object(std::move(model_field)));
engine::runtime::TaskRequest effective_request;
{
std::shared_lock<std::shared_mutex> metadata_lock(model.metadata_mutex);
if (model.task.task != task.task || model.task.mode != task.mode) {
throw engine::runtime::InvalidRequestError(
"recipe role '" + std::string(role) + "' requires " +
engine::runtime::to_string(task.task) + "/" +
engine::runtime::to_string(task.mode) + ", but model '" +
model.config.id + "' is configured as " +
engine::runtime::to_string(model.task.task) + "/" +
engine::runtime::to_string(model.task.mode));
}
effective_request = apply_default_request_options(model.config.default_request_options, stage_request);
}
if (task.mode == engine::runtime::RunMode::Streaming) {
return run_streaming_model_impl(model, effective_request, source, sink, busy_timeout_ms, true).result;
}
return run_model(model, effective_request, busy_timeout_ms).result;
};
CallbackRecipeModelExecutor executor(
[&](std::string_view role, const engine::runtime::TaskSpec & task,
const engine::runtime::TaskRequest & stage_request) {
return execute(role, task, stage_request, {}, nullptr);
}, execute);
const auto started = Clock::now();
auto result = recipe.run(executor, request, event_sink);
return TimedTaskResult{std::move(result), elapsed_ms(started), std::nullopt};
}
HttpResponse ServerState::handle_recipe_run(const std::string & body_text) {
const auto body = engine::io::json::parse(body_text);
if (!bool_field(body, "stream", false)) {
const auto timed = run_recipe(body);
return json_response(task_result_json(timed.result, timed.wall_ms));
}
return sse_response([this, body_text](HttpStreamWriter & writer) {
const auto stream_body = engine::io::json::parse(body_text);
const auto timed = run_recipe(
stream_body,
[&](const engine::runtime::StreamEvent & event) {
write_sse(writer, task_stream_event_line(stream_event_json(event, false,
bool_field(stream_body, "include_artifacts", false))));
});
write_sse(
writer,
task_stream_done_line(task_result_json_with_timing(
timed.result,
"{\"wall_ms\":" + std::to_string(timed.wall_ms) + "}")));
write_sse_done(writer);
});
}
std::string ServerState::recipes_json() const {
std::ostringstream out;
out << "{\"object\":\"list\",\"data\":[";
const auto ids = recipes_.ids();
for (size_t i = 0; i < ids.size(); ++i) {
if (i != 0) {
out << ",";
}
const auto & recipe = recipes_.require(ids[i]);
out << "{\"id\":" << json_quote(ids[i])
<< ",\"description\":" << json_quote(recipe.description())
<< ",\"roles\":[";
const auto roles = recipe.roles();
for (size_t r = 0; r < roles.size(); ++r) {
if (r != 0) {
out << ",";
}
out << "{\"name\":" << json_quote(roles[r].name)
<< ",\"task\":" << json_quote(engine::runtime::to_string(roles[r].task.task))
<< ",\"mode\":" << json_quote(engine::runtime::to_string(roles[r].task.mode))
<< "}";
}
out << "]}";
}
out << "]}";
return out.str();
}
// Cached-voice discovery for the "voice"/cached_voice_id request field. Families that
// support voice presets (e.g. pocket_tts, see assets.cpp: model_root/embeddings/<id>.safetensors)
// keep them under an "embeddings" directory next to the model weights; other families simply
// have no such directory and report no voices. Used by clients (llama-swap's playground, and
// potentially Open WebUI) that call GET /v1/audio/voices?model=<id> to populate a voice picker
// instead of guessing generic names like "alloy"/"nova".
HttpResponse ServerState::handle_voices(const HttpRequest & request) const {
std::lock_guard<std::mutex> state_lock(models_mutex_);
const std::string model_id = query_param(request.query, "model");
std::vector<std::string> voices;
size_t model_idx = SIZE_MAX;
if (!model_id.empty()) {
const auto it = model_index_.find(model_id);
if (it != model_index_.end()) {
model_idx = it->second;
}
} else if (models_.size() == 1) {
model_idx = 0;
}
if (model_idx != SIZE_MAX) {
const auto & model = *models_.at(model_idx);
std::shared_lock<std::shared_mutex> metadata_lock(model.metadata_mutex);
for (const auto & [name, preset] : model.voice_presets) {
(void) preset;
voices.push_back(name);
}
const auto embeddings_dir = model.config.path / "embeddings";
std::error_code ec;
if (std::filesystem::is_directory(embeddings_dir, ec)) {
for (const auto & entry : std::filesystem::directory_iterator(embeddings_dir, ec)) {
if (entry.is_regular_file() && entry.path().extension() == ".safetensors") {
voices.push_back(entry.path().stem().string());
}
}
}
}
if (config_.voice_dir.has_value()) {
std::error_code ec;
if (std::filesystem::is_directory(*config_.voice_dir, ec)) {
for (const auto & entry : std::filesystem::directory_iterator(*config_.voice_dir, ec)) {
if (entry.is_regular_file() && entry.path().extension() == ".wav") {
voices.push_back(entry.path().stem().string());
}
}
}
}
std::sort(voices.begin(), voices.end());
voices.erase(std::unique(voices.begin(), voices.end()), voices.end());
std::ostringstream out;
out << "{\"voices\":[";
for (size_t i = 0; i < voices.size(); ++i) {
if (i != 0) {
out << ",";
}
out << json_quote(voices[i]);
}
out << "]}";
return json_response(out.str());
}
std::string ServerState::models_json(bool include_session_options, bool include_params) const {
std::lock_guard<std::mutex> state_lock(models_mutex_);
std::ostringstream out;
out << "{\"object\":\"list\",\"data\":[";
for (size_t i = 0; i < models_.size(); ++i) {
if (i != 0) {
out << ",";
}
const auto & model = *models_[i];
std::shared_lock<std::shared_mutex> metadata_lock(model.metadata_mutex);
out << "{\"id\":" << json_quote(model.config.id)
<< ",\"object\":\"model\""
<< ",\"owned_by\":\"engine\""
<< ",\"family\":" << json_quote(model.config.family)
<< ",\"task\":" << json_quote(engine::runtime::to_string(model.task.task))
<< ",\"mode\":" << json_quote(engine::runtime::to_string(model.task.mode))
<< ",\"device\":" << model.config.device.value_or(config_.device)
<< ",\"loaded\":" << (model.loaded.load() ? "true" : "false")
<< ",\"configured\":" << (configured_models_.count(model.config.id) != 0 ? "true" : "false")
<< ",\"path\":" << json_quote(model.config.path.string());
if (include_session_options) {
out << ",\"session_options\":" << options_json(model.config.session_options);
}
if (include_params) {
out << ",\"params\":" << model.request_params_json;
}
out << "}";
}
out << "]}";
return out.str();
}
std::string ServerState::get_allowed_origin(const HttpRequest & request) const {
// TODO: Handle lists of specific origins.
if (config_.cors_origins == "*") {
if (const auto it = request.headers.find("origin"); it != request.headers.end()) {
return it->second;
}
}
return "";
}
void ServerState::LoadedModel::unload() {
offline = nullptr;
streaming = nullptr;
session.reset();
model.reset();
loaded.store(false);
}
void ServerState::idle_unload_loop() {
const auto interval_ms = std::max<std::int64_t>(1000, config_.idle_unload_ms / 10);
auto deadline = steady_now_ms() + interval_ms;
while (!idle_unload_shutdown_.load(std::memory_order_relaxed)) {
// Sleep in small slices so SIGTERM (destructor join) never waits out a
// full poll interval; the deadline keeps the idle check cadence intact.
const auto now = steady_now_ms();
if (now >= deadline) {
if (idle_unload_shutdown_.load(std::memory_order_relaxed)) {
break;
}
const auto idle_ms = now - last_activity_ms_.load(std::memory_order_relaxed);
if (idle_ms >= config_.idle_unload_ms) {
unload_idle_models();
}
deadline = steady_now_ms() + interval_ms;
} else {
std::this_thread::sleep_for(std::chrono::milliseconds(250));
}
}
}
void ServerState::unload_idle_models() {
std::vector<LoadedModel *> resident;
{
std::lock_guard<std::mutex> state_lock(models_mutex_);
for (const auto & model : models_) {
if (model->session != nullptr) {
resident.push_back(model.get());
}
}
}
int unloaded = 0;
for (LoadedModel * model : resident) {
// Never unload a model mid-inference; a busy model keeps its slot and the
// next idle pass retries it.
const auto lock = model->busy.try_acquire();
if (!lock.has_value()) {
continue;
}
model->unload();
++unloaded;
}
if (unloaded > 0) {
const auto idle_ms = steady_now_ms() - last_activity_ms_.load(std::memory_order_relaxed);
std::cerr << "[server] idle " << idle_ms << " ms: unloaded " << unloaded << " model(s)\n";
// Restart the idle clock so we do not spin on unload attempts every interval.
last_activity_ms_.store(steady_now_ms(), std::memory_order_relaxed);
}
}
namespace {
std::string format_bytes(size_t bytes) {
const double gib = static_cast<double>(bytes) / (1024.0 * 1024.0 * 1024.0);
std::ostringstream out;
out << std::fixed << std::setprecision(2) << gib << " GiB";
return out.str();
}
} // namespace
void ServerState::ensure_model_fits_memory(const ServerModelConfig & model) {
// The guard is opt-in: 0 disables it entirely so existing deployments see no
// behavior change. This also keeps lazy loads unserialized (see the call site)
// when neither this guard nor max_loaded_models is active.
if (config_.min_free_memory_mb <= 0) {
return;
}
const auto estimate = estimate_model_memory_bytes(model);
if (!estimate.has_value()) {
// Indeterminate footprint (an ambiguous multi-GGUF model directory): the
// loader rejects the spec-driven case with its own error and loads
// family-specific layouts, so the guard has no basis to refuse and must
// not mask the real outcome with a 503. Say so instead of skipping silently.
std::cerr << "[server] memory guard skipped for model '" << model.id
<< "': indeterminate footprint (ambiguous model directory)\n";
return;
}
const size_t headroom = static_cast<size_t>(config_.min_free_memory_mb) * 1024ull * 1024ull;
const size_t host_available = engine::core::available_host_memory_bytes();
if (host_available > 0 && *estimate + headroom > host_available) {
throw InsufficientMemoryError(
"cannot load model '" + model.id + "': estimated " + format_bytes(*estimate) +
" + " + std::to_string(config_.min_free_memory_mb) + " MiB headroom exceeds available host memory (" +
format_bytes(host_available) + ")");
}
// ggml backend registries may not be loaded yet on the very first request
// (init_backend happens inside a session load, after this pre-check), which
// would make query_backend_memory() report "unknown" and silently skip the
// GPU check. Loading them here is idempotent and cheap once done.
engine::core::ensure_backends_loaded();
const engine::core::BackendMemorySnapshot device =
engine::core::query_backend_memory(engine::core::BackendConfig{
config_.backend, model.device.value_or(config_.device), config_.threads});
if (device.available && *estimate + headroom > static_cast<size_t>(device.free_bytes)) {
throw InsufficientMemoryError(
"cannot load model '" + model.id + "': estimated " + format_bytes(*estimate) +
" + " + std::to_string(config_.min_free_memory_mb) + " MiB headroom exceeds available " +
backend_name(config_.backend) + " memory (" +
format_bytes(static_cast<size_t>(device.free_bytes)) + ")");
}
}
HttpResponse ServerState::handle_unload_models(const std::string & body_text) {
const auto body = engine::io::json::parse(body_text);
const auto * ids = body.find("model_ids");
if (ids == nullptr || !ids->is_array()) {
return error_response(400, "request requires a 'model_ids' string array", "invalid_request_error");
}
std::vector<std::string> unloaded;
std::vector<std::string> not_found;
for (const auto & id_val : ids->as_array()) {
if (!id_val.is_string()) {
return error_response(400, "each element of 'model_ids' must be a string", "invalid_request_error");
}
const std::string id = id_val.as_string();
const auto it = model_index_.find(id);
if (it == model_index_.end()) {
not_found.push_back(id);
continue;
}
LoadedModel & model = *models_.at(it->second);
// Only unload if the model is currently loaded in memory. Acquire the busy
// lock for the duration of the unload so no inference starts mid-operation.
if (model.session != nullptr) {
[[maybe_unused]] BusyGuard::Lock lock = model.busy.acquire(0, model.config.id);
model.unload();
unloaded.push_back(id);
}
}
std::ostringstream out;
out << "{\"unloaded\":[";
for (size_t i = 0; i < unloaded.size(); ++i) {
if (i != 0) { out << ","; }
out << json_quote(unloaded[i]);
}
out << "],\"not_found\":";
out << "[";
for (size_t i = 0; i < not_found.size(); ++i) {
if (i != 0) { out << ","; }
out << json_quote(not_found[i]);
}
out << "]}";
return json_response(out.str());
}
HttpResponse ServerState::handle_unload_all_models() {
std::vector<std::string> unloaded;
for (auto & model : models_) {
if (model->session != nullptr) {
[[maybe_unused]] BusyGuard::Lock lock = model->busy.acquire(0, model->config.id);
model->unload();
unloaded.push_back(model->config.id);
}
}
std::ostringstream out;
out << "{\"unloaded\":[";
for (size_t i = 0; i < unloaded.size(); ++i) {
if (i != 0) { out << ","; }
out << json_quote(unloaded[i]);
}
out << "]}";
return json_response(out.str());
}
} // namespace minitts::server