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

464 lines
21 KiB
C++

#include "config.h"
#include "../cli/args.h"
#include "../cli/request.h"
#include "engine/framework/io/json.h"
#include <cmath>
#include <cstdint>
#include <limits>
#include <stdexcept>
#include <utility>
namespace minitts::server {
// The request reader caps the artifact payloads of one request at the default
// body limit, so the two have to move together.
static_assert(minitts::cli::kMaxRequestArtifactBytes == kDefaultMaxRequestBodyBytes,
"request artifact payload limit differs from the default request body limit");
namespace {
std::filesystem::path resolve_path(const std::filesystem::path & base, const std::filesystem::path & path) {
return path.is_absolute() ? path : base / path;
}
std::unordered_map<std::string, std::string> options_from_object(const engine::io::json::Value * value) {
return minitts::cli::json_options_map(value);
}
uint64_t parse_max_request_body_bytes(const engine::io::json::Value & value) {
if (!value.is_number()) {
throw std::runtime_error("server max_request_body_bytes must be a number");
}
const double parsed = value.as_number();
constexpr double kMaxSafeJsonInteger = 9007199254740991.0; // 2^53 - 1
if (parsed < 0.0) {
throw std::runtime_error("server max_request_body_bytes must be non-negative");
}
if (std::floor(parsed) != parsed) {
throw std::runtime_error("server max_request_body_bytes must be an integer");
}
if (parsed > kMaxSafeJsonInteger) {
throw std::runtime_error("server max_request_body_bytes must be <= 2^53 - 1");
}
return static_cast<uint64_t>(parsed);
}
ServerModelConfig::VoicePreset parse_voice_preset(
const std::filesystem::path & base,
const engine::io::json::Value & value,
const std::string & context) {
if (!value.is_object()) {
throw std::runtime_error(context + " must be an object");
}
ServerModelConfig::VoicePreset preset;
if (const auto * voice_id = value.find("voice_id")) {
preset.voice_id = voice_id->as_string();
}
if (const auto * voice_ref = value.find("voice_ref")) {
preset.voice_ref = resolve_path(base, voice_ref->as_string());
}
if (const auto * reference_text = value.find("reference_text")) {
preset.reference_text = reference_text->as_string();
}
if (const auto * options = value.find("options")) {
if (!options->is_object()) {
throw std::runtime_error(context + " options must be an object");
}
preset.options = options_from_object(options);
}
if (!preset.voice_id.has_value() && !preset.voice_ref.has_value() && !preset.reference_text.has_value() &&
preset.options.empty()) {
throw std::runtime_error(context + " must set voice_id, voice_ref, reference_text, or options");
}
return preset;
}
// Every live-ingest bound uses 0 to mean "disabled", matching busy_timeout_ms, so
// only a negative value is malformed. Rejected at parse time rather than clamped:
// a negative deadline is a typo, and silently treating it as "no bound" would
// remove a guard the operator believed they had set.
//
// Read and validated by hand rather than through optional_i32/optional_i64, both
// of which are wrong here in two ways. They return the supplied fallback for a
// present-but-wrong-typed field, so `"max_body_bytes": "oops"` in a MODEL override
// would silently record the compiled default as a deliberate override and widen a
// stricter server policy. And optional_i32 narrows to int before anything checks
// the range, so on a 32-bit int a value of 4294967296 becomes 0 — which here means
// "disabled", quietly removing the bound the operator was trying to set.
double live_ingest_number(
const engine::io::json::Value & value,
const char * key,
const std::string & context) {
const auto * field = value.find(key);
if (field == nullptr || !field->is_number()) {
throw std::runtime_error(context + " " + key + " must be a number");
}
const double parsed = field->as_number();
constexpr double kMaxSafeJsonInteger = 9007199254740991.0; // 2^53 - 1
if (std::floor(parsed) != parsed) {
throw std::runtime_error(context + " " + key + " must be an integer");
}
if (parsed < 0.0) {
throw std::runtime_error(context + " " + key + " must be >= 0 (0 disables the bound)");
}
if (parsed > kMaxSafeJsonInteger) {
throw std::runtime_error(context + " " + key + " must be <= 2^53 - 1");
}
return parsed;
}
int live_ingest_ms(
const engine::io::json::Value & value,
const char * key,
int fallback,
const std::string & context) {
if (value.find(key) == nullptr) {
return fallback;
}
const double parsed = live_ingest_number(value, key, context);
if (parsed > static_cast<double>(std::numeric_limits<int>::max())) {
throw std::runtime_error(
context + " " + key + " must be <= " + std::to_string(std::numeric_limits<int>::max()) + " ms");
}
return static_cast<int>(parsed);
}
size_t live_ingest_bytes(
const engine::io::json::Value & value,
const char * key,
size_t fallback,
const std::string & context) {
if (value.find(key) == nullptr) {
return fallback;
}
const double parsed = live_ingest_number(value, key, context);
// Range-checked against size_t rather than cast blindly: on a 32-bit target a
// legal-looking 4294967296 would otherwise truncate to 0 and disable the bound.
if (parsed > static_cast<double>(std::numeric_limits<size_t>::max())) {
throw std::runtime_error(context + " " + key + " is too large for this platform");
}
return static_cast<size_t>(parsed);
}
LiveIngestLimits parse_live_ingest_limits(
const engine::io::json::Value & value,
const LiveIngestLimits & fallback,
const std::string & context) {
if (!value.is_object()) {
throw std::runtime_error(context + " must be an object");
}
LiveIngestLimits limits;
limits.idle_timeout_ms = live_ingest_ms(value, "idle_timeout_ms", fallback.idle_timeout_ms, context);
limits.total_timeout_ms = live_ingest_ms(value, "total_timeout_ms", fallback.total_timeout_ms, context);
limits.max_body_bytes = live_ingest_bytes(value, "max_body_bytes", fallback.max_body_bytes, context);
limits.max_chunk_bytes = live_ingest_bytes(value, "max_chunk_bytes", fallback.max_chunk_bytes, context);
// The one bound that cannot be disabled. A chunk is materialized in memory
// before it is served, so "unbounded" is not implementable — and a 0 here would
// underflow the overflow guard in the chunk-size parser, re-admitting a declared
// size of SIZE_MAX. Rejected rather than quietly substituted.
if (limits.max_chunk_bytes == 0) {
throw std::runtime_error(
context + " max_chunk_bytes must be > 0: a chunk is held in memory, so it cannot be unbounded");
}
limits.send_timeout_ms = live_ingest_ms(value, "send_timeout_ms", fallback.send_timeout_ms, context);
return limits;
}
LiveIngestOverrides parse_live_ingest_overrides(
const engine::io::json::Value & value,
const std::string & context) {
if (!value.is_object()) {
throw std::runtime_error(context + " must be an object");
}
// Parsed against the compiled-in defaults purely to reuse the validation; only
// the keys actually present are recorded, so the rest still fall through to
// whatever server policy is at the time the override is applied.
const LiveIngestLimits defaults;
const auto parsed = parse_live_ingest_limits(value, defaults, context);
LiveIngestOverrides overrides;
if (value.find("idle_timeout_ms") != nullptr) {
overrides.idle_timeout_ms = parsed.idle_timeout_ms;
}
if (value.find("total_timeout_ms") != nullptr) {
overrides.total_timeout_ms = parsed.total_timeout_ms;
}
if (value.find("max_body_bytes") != nullptr) {
overrides.max_body_bytes = parsed.max_body_bytes;
}
if (value.find("max_chunk_bytes") != nullptr) {
overrides.max_chunk_bytes = parsed.max_chunk_bytes;
}
if (value.find("send_timeout_ms") != nullptr) {
overrides.send_timeout_ms = parsed.send_timeout_ms;
}
return overrides;
}
} // namespace
LiveIngestLimits resolve_live_ingest_limits(
const LiveIngestLimits & base,
const LiveIngestOverrides & overrides) {
LiveIngestLimits limits = base;
if (overrides.idle_timeout_ms.has_value()) {
limits.idle_timeout_ms = *overrides.idle_timeout_ms;
}
if (overrides.total_timeout_ms.has_value()) {
limits.total_timeout_ms = *overrides.total_timeout_ms;
}
if (overrides.max_body_bytes.has_value()) {
limits.max_body_bytes = *overrides.max_body_bytes;
}
if (overrides.max_chunk_bytes.has_value()) {
limits.max_chunk_bytes = *overrides.max_chunk_bytes;
}
if (overrides.send_timeout_ms.has_value()) {
limits.send_timeout_ms = *overrides.send_timeout_ms;
}
return limits;
}
engine::core::BackendType parse_server_backend(const std::string & value) {
auto backend = minitts::cli::parse_backend(value);
if (backend == engine::core::BackendType::BestAvailable) {
throw std::runtime_error("unsupported server backend: " + value);
}
return backend;
}
ServerConfig load_server_config(const std::filesystem::path & path) {
return load_server_config(path, false);
}
ServerConfig load_server_config(const std::filesystem::path & path, bool parallel_jobs) {
const auto root = engine::io::json::parse_file(path);
const auto base = path.parent_path();
ServerConfig config;
config.host = engine::io::json::optional_string(root, "host", config.host);
config.port = engine::io::json::optional_i32(root, "port", config.port);
config.cors_origins = engine::io::json::optional_string(root, "cors_origins", config.cors_origins);
config.ui_enabled = engine::io::json::optional_bool(root, "ui", config.ui_enabled);
config.ui_requested = engine::io::json::optional_bool(root, "ui", false);
config.ui_management = engine::io::json::optional_bool(root, "ui_management", config.ui_management);
config.backend = parse_server_backend(engine::io::json::optional_string(root, "backend", "cuda"));
config.backend_impl = engine::io::json::optional_string(root, "backend_impl", "");
config.device = engine::io::json::optional_i32(root, "device", config.device);
config.threads = engine::io::json::optional_i32(root, "threads", config.threads);
config.lazy_load = engine::io::json::optional_bool(root, "lazy_load", config.lazy_load);
config.log_request_body = engine::io::json::optional_bool(root, "log_request_body", config.log_request_body);
if (const auto * value = root.find("max_request_body_bytes")) {
config.max_request_body_bytes = parse_max_request_body_bytes(*value);
}
config.busy_timeout_ms = engine::io::json::optional_i32(root, "busy_timeout_ms", config.busy_timeout_ms);
config.max_loaded_models = engine::io::json::optional_i32(root, "max_loaded_models", config.max_loaded_models);
config.idle_unload_ms = engine::io::json::optional_i32(root, "idle_unload_ms", config.idle_unload_ms);
config.min_free_memory_mb = engine::io::json::optional_i32(root, "min_free_memory_mb", config.min_free_memory_mb);
if (const auto * value = root.find("resample_kernel_cache_mb")) {
// Not require_i32: it narrows first, so 4294967296 would become 0 and disable the cache.
const double mb = value->is_number() ? value->as_number() : -1.0;
if (mb < 0.0 || mb > std::numeric_limits<int>::max() || std::floor(mb) != mb) {
throw std::runtime_error(
"server resample_kernel_cache_mb must be an integer in 0.." +
std::to_string(std::numeric_limits<int>::max()) + " (0 disables the cache)");
}
config.resample_kernel_cache_mb = static_cast<int>(mb);
}
if (const auto * value = root.find("live_ingest")) {
config.live_ingest = parse_live_ingest_limits(*value, config.live_ingest, "server live_ingest");
}
if (const auto * value = root.find("model_spec_override")) {
const auto & selection = value->as_string();
config.model_spec_override = selection == "embedded"
? std::filesystem::path(selection) : resolve_path(base, selection);
}
if (const auto * value = root.find("default_session_options")) {
if (!value->is_object()) {
throw std::runtime_error("server default_session_options must be an object");
}
config.default_session_options = options_from_object(value);
for (const auto & [key, option] : config.default_session_options) {
(void) option;
const auto dot = key.find('.');
if (dot == std::string::npos || dot == 0 || dot + 1 == key.size()) {
throw std::runtime_error(
"server default_session_options key must be <family>.<option>: " + key);
}
}
}
if (const auto * value = root.find("voice_dir")) {
if (!value->is_string()) {
throw std::runtime_error("server voice_dir must be a string");
}
config.voice_dir = resolve_path(base, value->as_string());
}
config.frontend_listener = engine::io::json::optional_string(root, "frontend_listener", config.frontend_listener);
if (const auto * value = root.find("frontend_options")) {
if (!value->is_object()) {
throw std::runtime_error("server frontend_options must be an object");
}
for (const auto & [key, option] : value->as_object()) {
if (!option.is_string()) {
throw std::runtime_error("server frontend_options values must be strings");
}
config.frontend_options[key] = option.as_string();
}
}
if (config.port <= 0 || config.port > 65535) {
throw std::runtime_error("server port must be in 1..65535");
}
if (config.busy_timeout_ms < 0) {
throw std::runtime_error("server busy_timeout_ms must be >= 0 (0 disables the guard)");
}
if (config.max_loaded_models < 0) {
throw std::runtime_error("server max_loaded_models must be >= 0 (0 disables the limit)");
}
if (config.idle_unload_ms < 0) {
throw std::runtime_error("server idle_unload_ms must be >= 0 (0 disables idle unload)");
}
if (config.min_free_memory_mb < 0) {
throw std::runtime_error("server min_free_memory_mb must be >= 0 (0 disables the memory guard)");
}
if (config.frontend_listener.empty() && !config.frontend_options.empty()) {
throw std::runtime_error("server frontend_options requires frontend_listener");
}
if (config.threads <= 0) {
throw std::runtime_error("server threads must be positive");
}
const auto * models = root.find("models");
if (models == nullptr || !models->is_array()) {
throw std::runtime_error("server config requires a models array");
}
if (models->as_array().empty() && !config.ui_management) {
throw std::runtime_error("server config requires a non-empty models array unless ui_management is enabled");
}
for (const auto & item : models->as_array()) {
ServerModelConfig model;
model.id = engine::io::json::require_string(item, "id");
model.family = engine::io::json::require_string(item, "family");
const auto raw_path = engine::io::json::require_string(item, "path");
model.path = resolve_path(base, raw_path);
if (const auto * value = item.find("model_spec_override")) {
const auto & selection = value->as_string();
model.model_spec_override = selection == "embedded"
? std::filesystem::path(selection) : resolve_path(base, selection);
}
model.task = engine::io::json::optional_string(item, "task", model.task);
model.mode = engine::io::json::optional_string(item, "mode", model.mode);
model.lazy = engine::io::json::optional_bool(item, "lazy", config.lazy_load);
if (item.find("device") != nullptr) {
const int device = engine::io::json::optional_i32(item, "device", config.device);
if (device < 0) {
throw std::runtime_error("device for model " + model.id + " must be >= 0");
}
model.device = device;
}
if (!parallel_jobs && item.find("slots") != nullptr) {
throw std::runtime_error("slots requires --parallel-jobs at server startup");
}
if (parallel_jobs) {
if (const auto * value = item.find("slots")) {
if (!value->is_number() || !std::isfinite(value->as_number()) ||
std::floor(value->as_number()) != value->as_number() ||
value->as_number() < 1 || value->as_number() > 16) {
throw std::runtime_error("model slots must be an integer between 1 and 16");
}
model.slots = static_cast<int>(value->as_number());
}
}
if (item.find("busy_timeout_ms") != nullptr) {
const auto busy_timeout_ms = engine::io::json::optional_i32(item, "busy_timeout_ms", 0);
if (busy_timeout_ms < 0) {
throw std::runtime_error(
"busy_timeout_ms for model " + model.id + " must be >= 0 (0 disables the guard)");
}
model.busy_timeout_ms = busy_timeout_ms;
}
if (const auto * value = item.find("live_ingest")) {
model.live_ingest = parse_live_ingest_overrides(*value, "live_ingest for model " + model.id);
}
if (const auto * value = item.find("config")) {
model.config_id = value->as_string();
}
if (const auto * value = item.find("weight")) {
model.weight_id = value->as_string();
}
model.load_options = options_from_object(item.find("load_options"));
model.session_options = options_from_object(item.find("session_options"));
model.default_request_options = options_from_object(item.find("default_request_options"));
if (const auto * value = item.find("instruct")) {
if (model.default_request_options.find("instruct") != model.default_request_options.end() ||
model.default_request_options.find("instruction") != model.default_request_options.end()) {
throw std::runtime_error(
"instruct for model " + model.id
+ " conflicts with an instruction in default_request_options");
}
model.default_request_options.emplace("instruct", value->as_string());
}
if (const auto * voice_presets = item.find("voice_presets")) {
if (!voice_presets->is_object()) {
throw std::runtime_error("voice_presets for model " + model.id + " must be an object");
}
for (const auto & [name, preset_value] : voice_presets->as_object()) {
if (name.empty()) {
throw std::runtime_error("voice_presets for model " + model.id + " cannot use an empty preset name");
}
auto [it, inserted] = model.voice_presets.emplace(
name,
parse_voice_preset(base, preset_value, "voice preset " + name + " for model " + model.id));
if (!inserted) {
throw std::runtime_error("duplicate voice preset for model " + model.id + ": " + name);
}
(void) it;
}
}
if (const auto * default_voice_preset = item.find("default_voice_preset")) {
if (default_voice_preset->is_string()) {
model.default_voice_preset_id = default_voice_preset->as_string();
if (model.default_voice_preset_id->empty()) {
throw std::runtime_error("default_voice_preset for model " + model.id + " cannot be empty");
}
if (model.voice_presets.find(*model.default_voice_preset_id) == model.voice_presets.end()) {
throw std::runtime_error(
"default_voice_preset for model " + model.id +
" does not match a configured voice_presets entry: " +
*model.default_voice_preset_id);
}
} else {
model.default_voice_preset =
parse_voice_preset(base, *default_voice_preset, "default_voice_preset for model " + model.id);
}
}
config.models.push_back(std::move(model));
}
for (auto & model : config.models) {
apply_default_session_options(config, model);
}
return config;
}
void apply_default_session_options(const ServerConfig & config, ServerModelConfig & model) {
const std::string prefix = model.family + ".";
for (const auto & [key, value] : config.default_session_options) {
if (key.rfind(prefix, 0) == 0) {
model.session_options.emplace(key, value);
}
}
}
void inherit_configured_model_fields(const ServerModelConfig & configured, ServerModelConfig & model) {
if (configured.family != model.family || configured.task != model.task) {
return;
}
model.busy_timeout_ms = configured.busy_timeout_ms;
model.live_ingest = configured.live_ingest;
model.default_request_options = configured.default_request_options;
model.voice_presets = configured.voice_presets;
model.default_voice_preset = configured.default_voice_preset;
model.default_voice_preset_id = configured.default_voice_preset_id;
}
} // namespace minitts::server