diff --git a/CMakeLists.txt b/CMakeLists.txt index d291073b9..5b240efbe 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3444,6 +3444,18 @@ if (ENGINE_BUILD_TESTS OR ENGINE_BUILD_EXTENDED_TESTS OR ENGINE_BUILD_MODEL_TEST add_test(NAME server_runtime_selection_test COMMAND server_runtime_selection_test) set_tests_properties(server_runtime_selection_test PROPERTIES TIMEOUT 60) + add_executable(server_voice_preset_test + tests/unittests/test_server_voice_preset.cpp ${server_selection_sources}) + target_include_directories(server_voice_preset_test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/app/server + "${AUDIOCPP_SERVER_TEST_GENERATED_DIR}" + "${CMAKE_CURRENT_BINARY_DIR}/generated") + target_link_libraries(server_voice_preset_test PRIVATE engine_runtime ggml Threads::Threads) + if(WIN32) + target_link_libraries(server_voice_preset_test PRIVATE ws2_32) + endif() + add_test(NAME server_voice_preset_test COMMAND server_voice_preset_test) + # Exercise real handlers with controlled, tiny model loaders. No native # installer or backend-specific model admission is needed by this test. get_target_property(server_lifecycle_sources audiocpp_server SOURCES) diff --git a/app/server/README.md b/app/server/README.md index 8e89fbbeb..37479fa81 100644 --- a/app/server/README.md +++ b/app/server/README.md @@ -520,6 +520,28 @@ reference library before passing it through as a model-native voice ID. For built-in speakers, configure `voice_id` using an ID valid for that model, such as `af_heart` for Kokoro; preset names and native voice IDs are not universal. +A preset can also carry request `options` for the model. Explicit request options +override preset options with the same key. Different aliases are not merged; +supplying conflicting aliases may produce a model validation error. The model +validates preset options like any other request option. For example, an +Irodori-TTS speaker embedding selected by name: + +```json +{ + "voice_presets": { + "narrator": { + "options": { + "speaker_embedding_file": "/absolute/path/to/narrator.speaker.safetensors" + } + } + } +} +``` + +A request `{"input": "Hello.", "voice": "narrator"}` then uses that embedding. +Option values are not paths to the server, so a relative path is not resolved +against the config file the way `voice_ref` is; use absolute paths. + ### Voice library (`voice_dir`) Set server-level `"voice_dir"` to a folder of your reference WAV recordings and diff --git a/app/server/api.md b/app/server/api.md index 0aaff1561..1edf318f7 100644 --- a/app/server/api.md +++ b/app/server/api.md @@ -84,6 +84,8 @@ option parsing. If no request voice is provided and the configured model has `default_voice_preset`, the server injects that preset automatically. Request-level `voice`, `voice_ref`, and `reference_text` override the configured default. +A preset's configured `options` fill missing request keys. Explicit request options override preset options with the same key. Different aliases are not merged; supplying conflicting aliases may produce a model validation error. + `voice_ref` accepts either a plain path string (server-side file) or an object with a `type`: ```json diff --git a/app/server/config.cpp b/app/server/config.cpp index ae7f169bc..6e1705ace 100644 --- a/app/server/config.cpp +++ b/app/server/config.cpp @@ -63,8 +63,15 @@ ServerModelConfig::VoicePreset parse_voice_preset( if (const auto * reference_text = value.find("reference_text")) { preset.reference_text = reference_text->as_string(); } - if (!preset.voice_id.has_value() && !preset.voice_ref.has_value() && !preset.reference_text.has_value()) { - throw std::runtime_error(context + " must set voice_id, voice_ref, or reference_text"); + 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; } diff --git a/app/server/config.h b/app/server/config.h index dbe444f99..ff24c52d4 100644 --- a/app/server/config.h +++ b/app/server/config.h @@ -37,6 +37,8 @@ struct ServerModelConfig { std::optional voice_id; std::optional voice_ref; std::optional reference_text; + // Request options the preset supplies, passed to the model as given. + std::unordered_map options; }; std::string id; diff --git a/app/server/parallel_runtime.cpp b/app/server/parallel_runtime.cpp index 247fe5258..5bf23a6e4 100644 --- a/app/server/parallel_runtime.cpp +++ b/app/server/parallel_runtime.cpp @@ -636,6 +636,7 @@ RuntimeVoicePreset ParallelServerState::load_runtime_voice_preset( 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); } diff --git a/app/server/runtime.cpp b/app/server/runtime.cpp index 1ffbf7f34..41862cb61 100644 --- a/app/server/runtime.cpp +++ b/app/server/runtime.cpp @@ -539,6 +539,7 @@ RuntimeVoicePreset ServerState::load_runtime_voice_preset( 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); } diff --git a/app/server/server_protocol.cpp b/app/server/server_protocol.cpp index 3f1c9cc1f..0d76e9c91 100644 --- a/app/server/server_protocol.cpp +++ b/app/server/server_protocol.cpp @@ -121,6 +121,7 @@ build_speech_request(const Value &body, const SpeechRequestContext &context) { request.options.find("reference_text") == request.options.end()) { request.options["reference_text"] = *preset->reference_text; } + request = apply_default_request_options(preset->options, std::move(request)); } const bool has_explicit_voice_ref = body.find("voice_ref") != nullptr; diff --git a/app/server/server_protocol.h b/app/server/server_protocol.h index eadf35153..a1944b078 100644 --- a/app/server/server_protocol.h +++ b/app/server/server_protocol.h @@ -47,6 +47,7 @@ struct RuntimeVoicePreset { std::optional voice_id; std::optional audio; std::optional reference_text; + std::unordered_map options; }; struct SpeechRequestContext { diff --git a/tests/unittests/test_server_config.cpp b/tests/unittests/test_server_config.cpp index d059fefd0..385822e9a 100644 --- a/tests/unittests/test_server_config.cpp +++ b/tests/unittests/test_server_config.cpp @@ -68,6 +68,12 @@ void test_inline_default_and_named_presets() { }, "builtin": { "voice_id": "alba" + }, + "embedding": { + "options": { + "speaker_embedding_file": "voices/embedding.safetensors", + "num_steps": 8 + } } } } @@ -81,10 +87,45 @@ void test_inline_default_and_named_presets() { require(model.default_voice_preset.has_value(), "inline default preset parsed"); require(model.default_voice_preset->voice_ref == root / "voices/default.wav", "default voice_ref path resolved"); require(model.default_voice_preset->reference_text == "default transcript", "default reference_text parsed"); - require(model.voice_presets.size() == 2, "named presets parsed"); + require(model.voice_presets.size() == 3, "named presets parsed"); require(model.voice_presets.at("assistant").voice_ref == root / "voices/assistant.wav", "named voice_ref path resolved"); require(model.voice_presets.at("assistant").reference_text == "assistant transcript", "named reference_text parsed"); require(model.voice_presets.at("builtin").voice_id == "alba", "named voice_id parsed"); + const auto & options = model.voice_presets.at("embedding").options; + require(options.size() == 2, "preset options parsed"); + require(options.at("speaker_embedding_file") == "voices/embedding.safetensors", + "preset option values are kept as given, not resolved as paths"); + require(options.at("num_steps") == "8", "numeric preset option parsed"); + require(model.voice_presets.at("assistant").options.empty(), "presets without options have none"); +} + +void test_bad_preset_options_are_rejected() { + const auto root = make_temp_root(); + const auto not_object = write_config(root, "server_preset_options_type.json", R"JSON({ + "models": [{"id": "tts", "family": "irodori_tts", "path": "models/irodori", + "voice_presets": {"narrator": {"options": "speaker.safetensors"}}}] +})JSON"); + bool rejected = false; + try { + (void) minitts::server::load_server_config(not_object); + } catch (const std::runtime_error & error) { + rejected = std::string(error.what()).find("options must be an object") != std::string::npos; + } + require(rejected, "non-object preset options are rejected"); + + const auto empty = write_config(root, "server_preset_options_empty.json", R"JSON({ + "models": [{"id": "tts", "family": "irodori_tts", "path": "models/irodori", + "voice_presets": {"narrator": {"options": {}}}}] +})JSON"); + rejected = false; + try { + (void) minitts::server::load_server_config(empty); + } catch (const std::runtime_error & error) { + rejected = std::string(error.what()).find("must set voice_id, voice_ref, reference_text, or options") != + std::string::npos; + } + require(rejected, "a preset with only empty options is rejected"); + std::filesystem::remove_all(root); } void test_model_spec_override_selection() { @@ -739,6 +780,7 @@ int main() { test_model_spec_override_selection(); test_builtin_utility_model_paths(); test_default_preset_name(); + test_bad_preset_options_are_rejected(); test_default_request_options(); test_default_session_options(); test_missing_default_preset_name_is_rejected(); diff --git a/tests/unittests/test_server_voice_preset.cpp b/tests/unittests/test_server_voice_preset.cpp new file mode 100644 index 000000000..f11d59892 --- /dev/null +++ b/tests/unittests/test_server_voice_preset.cpp @@ -0,0 +1,99 @@ +// A voice preset's options reach the model as request options: they fill what the +// request leaves unset, for named and default presets alike, and the server never +// interprets them. +#include "server_protocol.h" + +#include "engine/framework/io/json.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +namespace fs = std::filesystem; +using minitts::server::RuntimeVoicePreset; +using minitts::server::SpeechRequestContext; + +void expect(bool condition, const std::string & message) { + if (!condition) { + throw std::runtime_error(message); + } +} + +engine::runtime::TaskRequest speech( + const std::unordered_map & presets, + const std::optional & default_preset, + const std::unordered_map & default_options, + const std::string & body_text) { + const fs::path base = fs::current_path(); + const std::optional voice_dir; + return minitts::server::build_speech_request( + engine::io::json::parse(body_text), + SpeechRequestContext{base, voice_dir, presets, default_preset, true, true, true, default_options}); +} + +} // namespace + +int main() { + try { + RuntimeVoicePreset narrator; + narrator.options = { + {"speaker_embedding_file", "/voices/narrator.speaker.safetensors"}, + {"num_steps", "8"}, + }; + RuntimeVoicePreset with_text; + with_text.reference_text = "preset transcript"; + with_text.options = {{"reference_text", "option transcript"}}; + const std::unordered_map presets{ + {"narrator", narrator}, + {"with_text", with_text}, + }; + const std::optional no_default; + const std::unordered_map no_defaults; + + auto request = speech(presets, no_default, no_defaults, R"({"input":"x","voice":"narrator"})"); + expect(request.options.at("speaker_embedding_file") == "/voices/narrator.speaker.safetensors", + "preset option did not reach the request"); + expect(request.options.at("num_steps") == "8", "second preset option did not reach the request"); + expect(!request.voice.has_value(), "an options-only preset should not add a voice"); + + // The request's own options win over the preset's. + request = speech(presets, no_default, no_defaults, + R"({"input":"x","voice":"narrator","options":{"num_steps":4}})"); + expect(request.options.at("num_steps") == "4", "request option did not override the preset"); + expect(request.options.at("speaker_embedding_file") == "/voices/narrator.speaker.safetensors", + "unrelated preset option was dropped"); + + // The preset wins over the model's default_request_options. + const std::unordered_map defaults{{"num_steps", "32"}, {"cfg_scale", "2"}}; + request = speech(presets, no_default, defaults, R"({"input":"x","voice":"narrator"})"); + expect(request.options.at("num_steps") == "8", "default_request_options overrode the preset"); + expect(request.options.at("cfg_scale") == "2", "default_request_options were dropped"); + + // The dedicated reference_text field keeps its meaning. + request = speech(presets, no_default, no_defaults, R"({"input":"x","voice":"with_text"})"); + expect(request.options.at("reference_text") == "preset transcript", + "preset reference_text should win over the same key in its options"); + + // A default preset carries its options too, until the request names a voice. + const std::optional default_preset = narrator; + request = speech(presets, default_preset, no_defaults, R"({"input":"x"})"); + expect(request.options.at("speaker_embedding_file") == "/voices/narrator.speaker.safetensors", + "default preset options did not reach the request"); + request = speech(presets, default_preset, no_defaults, R"({"input":"x","voice":"alba"})"); + expect(request.options.count("speaker_embedding_file") == 0, + "default preset options leaked into a request that named another voice"); + expect(request.voice.has_value() && request.voice->speaker.has_value() && + request.voice->speaker->cached_voice_id == "alba", + "a voice that is not a preset should stay a model-native voice id"); + } catch (const std::exception & error) { + std::cerr << "voice preset test failed: " << error.what() << "\n"; + return 1; + } + std::cout << "server voice preset passed\n"; + return 0; +}