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