#include "parallel_runtime.h" #include "audited_model_slots.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; 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 ParallelServerState::ParallelServerState( 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()) { #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(&ParallelServerState::idle_unload_loop, this); } } ParallelServerState::~ParallelServerState() { 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 ParallelServerState::handle(const HttpRequest & request) { return handle_request(request, true); } HttpResponse ParallelServerState::forward_to_core(const HttpRequest & request) { return handle_request(request, false); } std::filesystem::path ParallelServerState::resolve_request_path(const std::filesystem::path & path) const { return resolve_path(request_base_, path); } std::filesystem::path ParallelServerState::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 ParallelServerState::make_frontend_listener(std::string_view name) const { return frontends_.make_listener(name); } HttpResponse ParallelServerState::handle_request(const HttpRequest & request, bool use_frontends) { return RequestDispatcher::handle(*this, request, use_frontends); } HttpResponse ParallelServerState::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 ParallelServerState::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, 1.0 once // every eager model is resident. Opt-in (--load-progress 1). if (engine::core::load_progress_enabled()) { engine::debug::trace_log_scalar( "server.load.progress", static_cast(eager_loaded) / static_cast(config_.models.size())); } } } models_.push_back(std::move(loaded)); } } std::unique_ptr ParallelServerState::make_model(ServerModelConfig config) { auto loaded = std::make_unique(); loaded->config = std::move(config); loaded->registered_id = loaded->config.id; loaded->busy.configure(loaded->config.slots); 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 ParallelServerState::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); } void ParallelServerState::validate_replacement_slots( const LoadedModel & existing, LoadedModel & candidate) { if (candidate.config.slots == 1) { return; } const bool same_weights = existing.config.path == candidate.config.path && existing.config.family == candidate.config.family && existing.config.config_id == candidate.config.config_id && existing.config.weight_id == candidate.config.weight_id && existing.config.load_options == candidate.config.load_options && existing.config.model_spec_override == candidate.config.model_spec_override; const bool same_session = same_weights && existing.config.task == candidate.config.task && existing.config.mode == candidate.config.mode && existing.config.session_options == candidate.config.session_options; size_t capacity = 1; if (same_session && existing.sessions) { capacity = existing.sessions->capacity(); } else { // Capability can depend on task/backend/mode/options. Probe a private // primary, never the live pool. Reuse existing immutable model assets // when possible; a checkpoint change may need temporary extra memory. std::unique_lock load_lock(model_load_mutex_, std::defer_lock); if (config_.max_loaded_models > 0 || config_.min_free_memory_mb > 0) { load_lock.lock(); } std::unique_ptr probe_model; auto * source = same_weights ? existing.model.get() : nullptr; if (!source) { // Do not evict another registration just to validate a replacement. // Insufficient staging memory rejects the operation with old state intact. ensure_model_fits_memory(candidate.config); auto registry = registry_factory_ ? registry_factory_() : engine::runtime::make_default_registry(); engine::runtime::ModelLoadRequest request; request.model_path = candidate.config.path; request.family_hint = candidate.config.family; request.config_id = candidate.config.config_id; request.weight_id = candidate.config.weight_id; request.options = candidate.config.load_options; request.model_spec_override = candidate.config.model_spec_override.has_value() ? candidate.config.model_spec_override : config_.model_spec_override; probe_model = registry.load(request); source = probe_model.get(); } engine::runtime::SessionOptions options; options.backend.type = config_.backend; options.backend.impl = config_.backend_impl; options.backend.device = candidate.config.device.value_or(config_.device); options.backend.threads = config_.threads; options.options = candidate.config.session_options; auto primary = source->create_task_session(candidate.task, options); if (!primary) { throw std::runtime_error("model returned a null task session: " + candidate.registered_id); } const auto audited = audited_slot_capacity( primary->family(), primary->task_kind(), config_.backend, primary->run_mode()); engine::runtime::VoiceTaskSessionPool::SessionFactory factory; if (audited > 1) { factory = [&] { return source->create_task_session(candidate.task, options); }; } // The pool's capability precedence is also used for this one-primary // probe. No clones are created, and the probe dies before its weights. engine::runtime::VoiceTaskSessionPool probe(std::move(primary), 1, std::move(factory), audited); capacity = probe.capacity(); } if (static_cast(candidate.config.slots) > capacity) { throw std::invalid_argument("model '" + candidate.config.family + "' does not support the requested slots for this backend/task/mode (capacity=" + std::to_string(capacity) + ")"); } } HttpResponse ParallelServerState::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); const auto * slots = body.find("slots"); if (slots != nullptr) { if (!slots->is_number() || !std::isfinite(slots->as_number()) || std::floor(slots->as_number()) != slots->as_number() || slots->as_number() < 1 || slots->as_number() > 16) { throw engine::runtime::CapacityError("model slots must be an integer between 1 and 16"); } } auto requested = model_config_from_json(body, request_base_, false); if (slots != nullptr) { requested.slots = static_cast(slots->as_number()); } 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); } // Validate metadata/presets before replacing any live state. A failed // validation leaves the existing configuration and sessions intact. auto candidate = make_model(std::move(requested)); const std::string id = candidate->registered_id; // Acquire before publication: another request must not start this entry's // first load while registration still owns it. Entry addresses never move. std::optional registration_lease; registration_lease.emplace(candidate->busy.acquire(0, id)); LoadedModel * existing = nullptr; { std::lock_guard state_lock(models_mutex_); const auto found = model_index_.find(id); if (found != model_index_.end()) { existing = models_.at(found->second).get(); } else { // Reserve first so publishing the index cannot be followed by a // throwing vector allocation and leave a dangling registry index. models_.reserve(models_.size() + 1); model_index_.emplace(id, models_.size()); existing = candidate.get(); models_.push_back(std::move(candidate)); } } if (candidate != nullptr) { registration_lease.reset(); ModelSlots::Lock run_lock = acquire_model_run(*existing, std::nullopt, true); if (!candidate->config.device.has_value()) { candidate->config.device = existing->config.device; } const bool changed = existing->config.path != candidate->config.path || existing->config.family != candidate->config.family || existing->config.task != candidate->config.task || existing->config.mode != candidate->config.mode || existing->config.device != candidate->config.device || existing->config.slots != candidate->config.slots || existing->config.config_id != candidate->config.config_id || existing->config.weight_id != candidate->config.weight_id || existing->config.load_options != candidate->config.load_options || existing->config.session_options != candidate->config.session_options || existing->config.model_spec_override != candidate->config.model_spec_override; if (changed) { try { validate_replacement_slots(*existing, *candidate); } catch (const std::invalid_argument & error) { return error_response(400, error.what(), "invalid_request_error"); } // Teardown is protected by the lease, not the metadata lock. Status // and transport policy readers need not wait for GPU destruction. existing->unload(); std::unique_lock metadata_lock(existing->metadata_mutex); existing->busy.configure(candidate->config.slots); std::swap(existing->config, candidate->config); std::swap(existing->task, candidate->task); existing->voice_presets.swap(candidate->voice_presets); existing->default_voice_preset.swap(candidate->default_voice_preset); existing->accepts_reference_text = candidate->accepts_reference_text; existing->accepts_language = candidate->accepts_language; existing->accepts_speed = candidate->accepts_speed; existing->accepts_speaking_rate = candidate->accepts_speaking_rate; existing->request_params_json.swap(candidate->request_params_json); } // On a backend/load failure the complete new configuration remains // registered but unloaded, ready for retry; no partial pool is published. ensure_model_loaded_locked(*existing); return json_response( "{\"id\":" + json_quote(existing->config.id) + ",\"loaded\":true,\"reconfigured\":" + (changed ? "true" : "false") + "}"); } // Include new entries in residency accounting before their first load. The // registration lease excludes inference/management until success or unwind. ensure_model_loaded_locked(*existing); return json_response("{\"id\":" + json_quote(id) + ",\"loaded\":true,\"reconfigured\":false}"); } std::filesystem::path ParallelServerState::resolve_ui_model_path(const std::filesystem::path & path) const { return minitts::server::resolve_ui_model_path(path, request_base_, repository_root_); } HttpResponse ParallelServerState::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(); } ModelSlots::Lock run_lock = acquire_model_run(*model, std::nullopt, true); model->unload(); return json_response("{\"id\":" + json_quote(id) + ",\"loaded\":false}"); } HttpResponse ParallelServerState::handle_path_status(const std::string & body_text) const { return ui_path_status_response( body_text, config_.ui_management, request_base_, repository_root_); } HttpResponse ParallelServerState::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 ParallelServerState::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 ParallelServerState::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 ParallelServerState::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 ParallelServerState::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 ParallelServerState::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 ParallelServerState::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 ParallelServerState::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 ParallelServerState::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 ParallelServerState::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"); } // Construct before committing either member: allocation/catalog/filesystem // failure must not pair a new public root with the old installer instance. auto replacement = std::make_unique(repository_root_, requested); models_root_.swap(requested); model_installer_.swap(replacement); 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 ParallelServerState::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 ParallelServerState::handle_ui_asset() const { return ui_asset_response(config_.ui_enabled); } HttpResponse ParallelServerState::handle_ui_voice_preview(const HttpRequest & request) const { return ui_voice_preview_response(request, config_.ui_enabled, config_.voice_dir); } RuntimeVoicePreset ParallelServerState::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 ParallelServerState::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 ParallelServerState::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->resident()) { 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 ParallelServerState::ensure_model_loaded_locked(LoadedModel & model) { std::lock_guard init_lock(model.initialization_mutex); 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.sessions != 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 = registry_factory_ ? registry_factory_() : 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); if (!session) { throw std::runtime_error("model returned a null task session: " + model.registered_id); } // The weight stores uploaded during session construction: close the // load-progress bracket opened by registry.load (see finish_model_load_trace). // Placed before the slot pool builds any extra sessions so the curve ends // at the first fully resident session. engine::runtime::finish_model_load_trace(); engine::runtime::VoiceTaskSessionPool::SessionFactory slot_factory; const size_t audited_capacity = audited_slot_capacity( session->family(), session->task_kind(), config_.backend, session->run_mode()); if (audited_capacity > 1) { // Construction is sequential; inference owns one independent session // per lease. The callback is consumed during pool construction only. slot_factory = [&] { return loaded_model->create_task_session(model.task, session_options); }; } auto sessions = std::make_unique( std::move(session), static_cast(model.config.slots), std::move(slot_factory), audited_capacity); model.model = std::move(loaded_model); model.parallel_capacity.store(sessions->capacity()); model.sessions = std::move(sessions); model.parallel_resident.store(true); model.loaded.store(true); } LiveIngestLimits ParallelServerState::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; } LoadedModel * selected = nullptr; { std::lock_guard state_lock(models_mutex_); const auto it = model_index_.find(model_id); if (it == model_index_.end()) { return config_.live_ingest; } selected = models_.at(it->second).get(); } // Never hold the registry mutex while waiting for per-model metadata. std::shared_lock metadata_lock(selected->metadata_mutex); return resolve_live_ingest_limits(config_.live_ingest, selected->config.live_ingest); } ParallelServerState::LoadedModel & ParallelServerState::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 ParallelServerState::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 ParallelServerState::TimedTaskResult { engine::runtime::TaskResult result; double wall_ms = 0.0; std::optional ttft_ms; }; engine::runtime::RunMode ParallelServerState::model_run_mode(const LoadedModel & model) const { std::shared_lock metadata_lock(model.metadata_mutex); return model.task.mode; } ModelSlots::Lock ParallelServerState::acquire_model_run( LoadedModel & model, std::optional request_timeout_ms, bool exclusive) { 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 exclusive ? model.busy.acquire(timeout_ms, model_id) : model.busy.acquire_run(timeout_ms, model_id); } ParallelServerState::RequestLease ParallelServerState::bind_request( LoadedModel & model, std::optional timeout) { return std::make_shared(acquire_model_run(model, timeout)); } ParallelServerState::TimedTaskResult ParallelServerState::run_model( LoadedModel & model, const engine::runtime::TaskRequest & request, std::optional busy_timeout_ms, RequestLease request_lease) { std::optional lock; if (!request_lease) { lock.emplace(acquire_model_run(model, busy_timeout_ms)); } const size_t slot = request_lease ? request_lease->slot() : lock->slot(); ensure_model_loaded_locked(model); auto & session = model.leased_session(slot); auto * offline = model.leased_interface(slot); if (offline == nullptr) { throw std::runtime_error("configured model does not provide offline execution: " + model.config.id); } const auto started = Clock::now(); session.prepare(engine::runtime::build_preparation_request(request)); auto result = 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. ParallelServerState::TimedTaskResult ParallelServerState::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, RequestLease request_lease, bool include_artifacts) { std::optional lock; if (!request_lease) { lock.emplace(acquire_model_run(model, busy_timeout_ms)); } const size_t slot = request_lease ? request_lease->slot() : lock->slot(); ensure_model_loaded_locked(model); auto & session = model.leased_session(slot); auto * streaming = model.leased_interface(slot); if (streaming == nullptr) { throw std::runtime_error("configured model does not provide streaming execution: " + model.config.id); } const auto started = Clock::now(); 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(*streaming, request, sink, *audio) : minitts::app::run_streaming_task(*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; } ParallelServerState::TimedTaskResult ParallelServerState::run_streaming_model( LoadedModel & model, const engine::runtime::TaskRequest & request, const std::function & event_sink, std::optional busy_timeout_ms, RequestLease request_lease, bool include_artifacts) { return run_streaming_model_impl(model, request, nullptr, event_sink, busy_timeout_ms, std::move(request_lease), include_artifacts); } ParallelServerState::TimedTaskResult ParallelServerState::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, RequestLease request_lease) { return run_streaming_model_impl(model, request, &audio, event_sink, busy_timeout_ms, std::move(request_lease)); } HttpResponse ParallelServerState::handle_speech(const std::string & body_text) { const auto body = engine::io::json::parse(body_text); auto & model = require_model(body); const auto busy_timeout_ms = parse_busy_timeout_override(body); auto request_lease = bind_request(model, busy_timeout_ms); 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, request_lease); } const auto timed_result = model_run_mode(model) == engine::runtime::RunMode::Streaming ? run_streaming_model(model, request, {}, busy_timeout_ms, request_lease) : run_model(model, request, busy_timeout_ms, request_lease); 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 ParallelServerState::handle_speech_stream( LoadedModel & model, const engine::runtime::TaskRequest & request, const Value & body, RequestLease request_lease) { 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, request_lease](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, request_lease); 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, request_lease](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, request_lease); if (!wrote_audio) { throw std::runtime_error("streaming speech model produced no audio delta events"); } }); } HttpResponse ParallelServerState::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; RequestLease request_lease; 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); 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()); } request_lease = bind_request(model, busy_timeout_ms); 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"); } 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 ServerBusyError &) { throw; } 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, request_lease]( 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, request_lease); 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, request_lease, 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, request_lease); 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 ParallelServerState::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 ParallelServerState::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 busy_timeout_ms = parse_busy_timeout_override(body); auto request_lease = bind_request(model, busy_timeout_ms); const auto request = apply_default_request_options( model.config.default_request_options, build_openai_transcription_request(body, request_base_, model.accepts_language)); 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, request_lease); } return run_transcription(model, request, busy_timeout_ms, detail, request_lease); } // 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 ParallelServerState::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); auto request_lease = bind_request(model, busy_timeout_ms); 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, request_lease); } return run_transcription(model, request, busy_timeout_ms, detail, request_lease); } HttpResponse ParallelServerState::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 ParallelServerState::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); auto request_lease = bind_request(model, busy_timeout_ms); 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))); } { std::optional lock; if (!request_lease) { lock.emplace(acquire_model_run(model, busy_timeout_ms)); } const size_t slot = request_lease ? request_lease->slot() : lock->slot(); ensure_model_loaded_locked(model); if (model.leased_interface(slot) == 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, request_lease](HttpStreamWriter & writer) { std::optional lock; if (!request_lease) { lock.emplace(acquire_model_run(*model_ptr, busy_timeout_ms)); } const size_t slot = request_lease ? request_lease->slot() : lock->slot(); ensure_model_loaded_locked(*model_ptr); auto * batched = model_ptr->leased_interface(slot); 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->leased_session(slot).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 ParallelServerState::run_transcription( LoadedModel & model, const engine::runtime::TaskRequest & request, std::optional busy_timeout_ms, bool detail, RequestLease request_lease) { const auto timed_result = model_run_mode(model) == engine::runtime::RunMode::Streaming ? run_streaming_model(model, request, {}, busy_timeout_ms, request_lease) : run_model(model, request, busy_timeout_ms, request_lease); 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 ParallelServerState::run_transcription_stream( LoadedModel & model, const engine::runtime::TaskRequest & request, std::optional busy_timeout_ms, RequestLease request_lease) { 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, request_lease](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, request_lease); 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 ParallelServerState::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 ParallelServerState::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; auto request_lease = bind_request(model, busy_timeout_ms); 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, request_lease); } HttpResponse ParallelServerState::run_alignment( LoadedModel & model, const engine::runtime::TaskRequest & request, std::optional busy_timeout_ms, RequestLease request_lease) { 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, request_lease); 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 ParallelServerState::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; RequestLease request_lease; 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); model_ptr = &model; // 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()); } request_lease = bind_request(model, busy_timeout_ms); if (model.task.mode != engine::runtime::RunMode::Streaming) { throw std::runtime_error( "live transcription requires a model configured with mode=streaming: " + model.config.id); } { std::shared_lock metadata_lock(model.metadata_mutex); diarization = model.task.task == engine::runtime::VoiceTaskKind::Diarization; } 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 ServerBusyError &) { throw; } 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, request_lease]( 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, request_lease); 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 parallel_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 ParallelServerState::handle_generic_run(const std::string & body_text) { const auto body = engine::io::json::parse(body_text); auto & model = require_model(body); const auto busy_timeout_ms = parse_busy_timeout_override(body); auto request_lease = bind_request(model, busy_timeout_ms); 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, parallel_drop_unsupported_language_option( std::move(parsed_request), effective_json, model.accepts_language)); const auto timed_result = model_run_mode(model) == engine::runtime::RunMode::Streaming ? run_streaming_model(model, request, {}, busy_timeout_ms, request_lease) : run_model(model, request, busy_timeout_ms, request_lease); return json_response(task_result_json(timed_result.result, timed_result.wall_ms)); } HttpResponse ParallelServerState::handle_generic_batch(const std::string & body_text) { const auto body = engine::io::json::parse(body_text); auto & model = require_model(body); const auto busy_timeout_ms = parse_busy_timeout_override(body); auto request_lease = bind_request(model, busy_timeout_ms); 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, parallel_drop_unsupported_language_option( minitts::cli::build_request_from_json(request_json, request_base_), request_json, model.accepts_language))); } { std::optional lock; if (!request_lease) { lock.emplace(acquire_model_run(model, busy_timeout_ms)); } const size_t slot = request_lease ? request_lease->slot() : lock->slot(); ensure_model_loaded_locked(model); if (model.leased_interface(slot) == 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, request_lease](HttpStreamWriter & writer) { std::optional lock; if (!request_lease) { lock.emplace(acquire_model_run(*model_ptr, busy_timeout_ms)); } const size_t slot = request_lease ? request_lease->slot() : lock->slot(); ensure_model_loaded_locked(*model_ptr); auto * batched = model_ptr->leased_interface(slot); 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->leased_session(slot).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 ParallelServerState::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 busy_timeout_ms = parse_busy_timeout_override(body); auto request_lease = bind_request(model, busy_timeout_ms); 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, parallel_drop_unsupported_language_option( minitts::cli::build_request_from_json(effective_json, request_base_), effective_json, model.accepts_language)); if (sse) { 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 lease is bound above, before the headers, // so a busy model is still a 503 here. return sse_response([this, model_ptr, request = std::move(request), busy_timeout_ms, diarization, include_artifacts, request_lease](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, request_lease, 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); }, busy_timeout_ms, request_lease, 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()); } // 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 ParallelServerState::handle_voices(const HttpRequest & request) const { const std::string model_id = query_param(request.query, "model"); std::vector voices; LoadedModel * selected = nullptr; { std::lock_guard state_lock(models_mutex_); 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) { selected = models_.at(model_idx).get(); } } if (selected != nullptr) { const auto & model = *selected; 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 ParallelServerState::models_json(bool include_session_options, bool include_params) const { std::vector registered; { std::lock_guard state_lock(models_mutex_); for (const auto & model : models_) { registered.push_back(model.get()); } } std::ostringstream out; out << "{\"object\":\"list\",\"data\":["; for (size_t i = 0; i < registered.size(); ++i) { if (i != 0) { out << ","; } const auto & model = *registered[i]; std::shared_lock metadata_lock(model.metadata_mutex); const auto slot_state = model.busy.state(); const auto parallel_capacity = model.parallel_capacity.load(); 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) << ",\"slots\":" << model.config.slots << ",\"active_slots\":" << slot_state.active << ",\"queued_requests\":" << slot_state.waiting_requests << ",\"max_parallel_slots\":" << (parallel_capacity ? std::to_string(parallel_capacity) : "null") << ",\"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 ParallelServerState::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 ParallelServerState::LoadedModel::unload() { // Clear logical residency before potentially slow pool/weight teardown. parallel_resident.store(false); loaded.store(false); parallel_capacity.store(0); sessions.reset(); parallel_capacity.store(0); model.reset(); loaded.store(false); } void ParallelServerState::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 ParallelServerState::unload_idle_models() { std::vector resident; { std::lock_guard state_lock(models_mutex_); for (const auto & model : models_) { if (model->resident()) { 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 ParallelServerState::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)) + ")"); } } void ParallelServerState::unload_registered_models( const std::vector> & registered, std::vector & unloaded) { struct Drain { std::string id; LoadedModel * model; std::optional pending; }; std::vector parallel; parallel.reserve(registered.size()); { std::lock_guard admission_lock(bulk_admission_mutex_); for (const auto & entry : registered) { const auto & id = entry.first; auto * model = entry.second; if (std::any_of(parallel.begin(), parallel.end(), [model](const Drain & d) { return d.model == model; })) { continue; } parallel.push_back({id, model, model->busy.queue_management(0, id)}); } } // Every parallel model already has its own ordered barrier. Release ready // idle pools before waiting on a first load or another busy model. for (auto & drain : parallel) { auto lease = drain.pending->try_acquire(); if (!lease) { continue; } drain.model->unload(); unloaded.push_back(drain.id); drain.pending.reset(); } for (auto & drain : parallel) { if (!drain.pending) { continue; } auto lease = drain.pending->acquire(); drain.model->unload(); unloaded.push_back(drain.id); drain.pending.reset(); } } HttpResponse ParallelServerState::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; std::vector> registered; 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(); LoadedModel * model = nullptr; { std::lock_guard state_lock(models_mutex_); const auto it = model_index_.find(id); if (it != model_index_.end()) { model = models_.at(it->second).get(); } } if (model == nullptr) { not_found.push_back(id); continue; } registered.emplace_back(id, model); } unload_registered_models(registered, unloaded); 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 ParallelServerState::handle_unload_all_models() { std::vector unloaded; std::vector> registered; { std::lock_guard state_lock(models_mutex_); for (const auto & model : models_) { // IDs are immutable. Do not wait for model metadata while holding // the global registry lock: unrelated requests need it too. registered.emplace_back(model->registered_id, model.get()); } } unload_registered_models(registered, unloaded); 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