#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 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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; using Stream = std::function; 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(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(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::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( 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 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::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 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 { 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 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(config_.models.size())) { engine::debug::trace_log_scalar( "server.load.progress", static_cast(eager_loaded) / static_cast(config_.models.size())); } } } models_.push_back(std::move(loaded)); } if (engine::core::load_progress_enabled() && !config_.models.empty() && eager_loaded == static_cast(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::make_model(ServerModelConfig config) { auto loaded = std::make_unique(); 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 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 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 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 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 info; { std::lock_guard 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 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 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 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 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 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 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 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 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(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 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 resident; { std::lock_guard 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(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 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(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(session.get()); auto * streaming = dynamic_cast(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 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 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 ttft_ms; }; engine::runtime::RunMode ServerState::model_run_mode(const LoadedModel & model) const { std::shared_lock metadata_lock(model.metadata_mutex); return model.task.mode; } BusyGuard::Lock ServerState::acquire_model_run( LoadedModel & model, std::optional request_timeout_ms) { int timeout_ms = 0; std::string model_id; { std::shared_lock 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 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 & event_sink, std::optional 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 & event_sink, std::optional 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 & event_sink, std::optional 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(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 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(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(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 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(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::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(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(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 first_audio_ms; std::optional 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 & 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 busy_timeout_ms; bool stream = false; for (const auto & part : parts) { if (part.name == "file") { file_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 file_parts; std::string model_id; std::string language; std::string prompt; std::unordered_map options; std::optional 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 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(model.session.get()) == nullptr) { return error_response( 400, "configured model does not provide native offline batching: " + model.config.id, "invalid_request_error"); } } std::vector 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(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 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 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 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 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 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 busy_timeout_ms; for (const auto & part : parts) { if (part.name == "file") { file_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 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 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 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 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(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::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 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(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 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(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( 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 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 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 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 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 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 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/.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= to populate a voice picker // instead of guessing generic names like "alloy"/"nova". HttpResponse ServerState::handle_voices(const HttpRequest & request) const { std::lock_guard state_lock(models_mutex_); const std::string model_id = query_param(request.query, "model"); std::vector 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 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 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 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(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 resident; { std::lock_guard 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(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(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(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(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 unloaded; std::vector 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 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