Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions app/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions app/server/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions app/server/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions app/server/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ struct ServerModelConfig {
std::optional<std::string> voice_id;
std::optional<std::filesystem::path> voice_ref;
std::optional<std::string> reference_text;
// Request options the preset supplies, passed to the model as given.
std::unordered_map<std::string, std::string> options;
};

std::string id;
Expand Down
1 change: 1 addition & 0 deletions app/server/parallel_runtime.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions app/server/runtime.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions app/server/server_protocol.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions app/server/server_protocol.h
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ struct RuntimeVoicePreset {
std::optional<std::string> voice_id;
std::optional<engine::runtime::AudioBuffer> audio;
std::optional<std::string> reference_text;
std::unordered_map<std::string, std::string> options;
};

struct SpeechRequestContext {
Expand Down
44 changes: 43 additions & 1 deletion tests/unittests/test_server_config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
}
Expand All @@ -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() {
Expand Down Expand Up @@ -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();
Expand Down
99 changes: 99 additions & 0 deletions tests/unittests/test_server_voice_preset.cpp
Original file line number Diff line number Diff line change
@@ -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 <filesystem>
#include <iostream>
#include <optional>
#include <stdexcept>
#include <string>
#include <unordered_map>

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<std::string, RuntimeVoicePreset> & presets,
const std::optional<RuntimeVoicePreset> & default_preset,
const std::unordered_map<std::string, std::string> & default_options,
const std::string & body_text) {
const fs::path base = fs::current_path();
const std::optional<fs::path> 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<std::string, RuntimeVoicePreset> presets{
{"narrator", narrator},
{"with_text", with_text},
};
const std::optional<RuntimeVoicePreset> no_default;
const std::unordered_map<std::string, std::string> 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<std::string, std::string> 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<RuntimeVoicePreset> 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;
}
Loading