From 8c022a28a861d37cf2f8366f11b3866b7b9960cf Mon Sep 17 00:00:00 2001 From: 0xShug0 <231717474+0xShug0@users.noreply.github.com> Date: Sun, 20 Sep 2026 00:32:18 -0400 Subject: [PATCH] Support speech speed across server and TTS models --- app/server/README.md | 2 ++ app/server/runtime.cpp | 27 ++++++++++++++++++++ app/server/runtime.h | 2 ++ docs/models/kokoro_tts.md | 2 ++ docs/tts.md | 3 ++- model_specs/kokoro_tts.json | 8 ++++++ src/models/kokoro_tts/session.cpp | 42 +++++++++++++++++++++++++------ src/models/supertonic/loader.cpp | 3 ++- src/models/supertonic/session.cpp | 14 ++++++++--- 9 files changed, 89 insertions(+), 14 deletions(-) diff --git a/app/server/README.md b/app/server/README.md index 73595c1d9..e73ff30c3 100644 --- a/app/server/README.md +++ b/app/server/README.md @@ -2,6 +2,8 @@ `audiocpp_server` is an HTTP adapter over the framework runtime registry. It keeps one loaded model and one offline task session per active model id, so repeated HTTP requests reuse the same framework session and model-owned graph/cache state. +`POST /v1/audio/speech` accepts top-level `speed` (or `speaking_rate`) as a positive speech-rate multiplier when the selected model supports speed control. Models without speed control reject the field. + ## Build ```bash diff --git a/app/server/runtime.cpp b/app/server/runtime.cpp index b50656f5f..4f7041b38 100644 --- a/app/server/runtime.cpp +++ b/app/server/runtime.cpp @@ -1312,6 +1312,12 @@ void ServerState::refresh_model_option_flags(LoadedModel & model) { "language", effective_override, model.config.path); + if (model.config.task == "tts") { + 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); + } } HttpResponse ServerState::handle_model_load(const std::string & body_text) { @@ -2098,6 +2104,27 @@ engine::runtime::TaskRequest ServerState::build_speech_request(const LoadedModel if (const auto * value = body.find("reference_text")) { request.options["reference_text"] = value->as_string(); } + const auto * speed = body.find("speed"); + if (speed == nullptr) { + speed = body.find("speaking_rate"); + } + if (speed != nullptr) { + const float rate = static_cast(speed->as_number()); + if (!std::isfinite(rate) || rate <= 0.0f) { + throw std::runtime_error("speed must be a positive finite number"); + } + if (!model.accepts_speed && !model.accepts_speaking_rate && model.config.family != "kokoro_tts") { + throw std::runtime_error("speed is not supported by this model"); + } + if (model.accepts_speed) { + request.options["speed"] = std::to_string(rate); + } else if (model.accepts_speaking_rate) { + request.options["speaking_rate"] = std::to_string(rate); + } + voice.style = engine::runtime::StyleCondition{}; + voice.style->speaking_rate = rate; + has_voice = true; + } if (has_voice) { request.voice = std::move(voice); } diff --git a/app/server/runtime.h b/app/server/runtime.h index 5efc4de67..dd2193198 100644 --- a/app/server/runtime.h +++ b/app/server/runtime.h @@ -81,6 +81,8 @@ class ServerState final : public IHttpHandler, public ServerFrontendContext { // contract omits it would reject the whole request over an option // nobody set. Resolved once at registration for the same cost reason. bool accepts_language = true; + bool accepts_speed = true; + bool accepts_speaking_rate = true; // Serializes runs on this model and bounds how long a caller waits for its // turn; see BusyGuard. BusyGuard busy; diff --git a/docs/models/kokoro_tts.md b/docs/models/kokoro_tts.md index 56bc43991..0cef8c119 100644 --- a/docs/models/kokoro_tts.md +++ b/docs/models/kokoro_tts.md @@ -102,6 +102,7 @@ package. | `--language` | language code | voice prefix | Text frontend language. | | `--voice-id` | voice ID listed above | `af_heart` | Built-in voice pack. | | `--seed` | integer | random | Decoder noise seed. | +| `--speaking-rate` | positive float | `1.0` | Speech speed multiplier. | | `--text-chunk-size` | integer chars | `240` | Long-form chunk size. | ## Request Options (use with `--request-option`) @@ -110,6 +111,7 @@ package. |---|---|---:|---| | `language` | language code | voice prefix | Text frontend language. | | `seed` | integer | random | Decoder noise seed. | +| `speed` | positive float | `1.0` | Speech speed multiplier; `speaking_rate` is also accepted, but conflicting values are rejected. | | `text_chunk_size` | integer chars | `240` | Long-form chunk size. | ## Session Options (use with `--session-option`) diff --git a/docs/tts.md b/docs/tts.md index 6abf67bc2..83d1f3b8e 100644 --- a/docs/tts.md +++ b/docs/tts.md @@ -759,7 +759,8 @@ audiocpp_cli --task tts --family supertonic --model /path/to/supertonic-3 --back | `--voice-id` | `M1`-`M5`, `F1`-`F5` | `M1` | Preset voice. | | `--language` | language code | `en` | Text language. | | `--num-inference-steps` | integer | `8` | Flow denoising steps. | -| `--request-option speaking_rate=` | float | `1.05` | Speech speed multiplier. | +| `--request-option speed=` | float | `1.05` | Speech speed multiplier. | +| `--request-option speaking_rate=` | float | `1.05` | Alias for `speed`. | | `--seed` | integer | `1234` | Noise seed. | | `--text-chunk-size` | characters | `300`, or `120` for `ko`/`ja` | Framework long-form text chunk size. | | `--text-chunk-mode` | `default`, `tag_aware`, `japanese`, `endline` | `default` | Framework long-form text chunking mode. | diff --git a/model_specs/kokoro_tts.json b/model_specs/kokoro_tts.json index f5b49c1bb..b5e1a1296 100644 --- a/model_specs/kokoro_tts.json +++ b/model_specs/kokoro_tts.json @@ -29,6 +29,14 @@ "required": false, "min": 0 }, + { + "name": "speed", + "type": "float", + "description": "Speech speed multiplier; default 1.0.", + "required": false, + "min": 0.01, + "default": 1.0 + }, { "name": "phonemes", "type": "string_list", diff --git a/src/models/kokoro_tts/session.cpp b/src/models/kokoro_tts/session.cpp index a21577a86..12cb41e4b 100644 --- a/src/models/kokoro_tts/session.cpp +++ b/src/models/kokoro_tts/session.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include namespace engine::models::kokoro_tts { @@ -324,23 +325,47 @@ void validate_request_options( const std::unordered_map & options, const std::unordered_map> & option_arrays, const engine::model_spec::ModelContract & contract) { - if (contract.request_option_keys.find(kPhonemesOption) != contract.request_option_keys.end()) { + const bool old_phonemes = contract.request_option_keys.find(kPhonemesOption) == contract.request_option_keys.end(); + const bool old_speed = contract.request_option_keys.find("speed") == contract.request_option_keys.end(); + const bool old_speaking_rate = contract.request_option_keys.find("speaking_rate") == contract.request_option_keys.end(); + if (!old_phonemes && !old_speed && !old_speaking_rate) { runtime::validate_spec_backed_request_options(options, option_arrays, contract, kModelName); return; } - // Keys only: the validator reads names, and copying the map wholesale would copy every - // supplied phoneme string to drop one entry from it. + std::unordered_map validation_options; + for (const auto & [key, _] : options) { + if ((key == "speed" && old_speed) || (key == "speaking_rate" && old_speaking_rate)) continue; + validation_options.emplace(key, std::string{}); + } + // Keys only: the validator reads names, not the supplied values. std::unordered_map> validation_arrays; for (const auto & [key, _] : option_arrays) { - if (key != kPhonemesOption) validation_arrays.emplace(key, std::vector{}); + if ((key == kPhonemesOption && old_phonemes) || + (key == "speed" && old_speed) || (key == "speaking_rate" && old_speaking_rate)) continue; + validation_arrays.emplace(key, std::vector{}); + } + runtime::validate_spec_backed_request_options(validation_options, validation_arrays, contract, kModelName); +} + +std::optional voice_with_request_rate( + const std::optional & voice, + const std::unordered_map & options) { + const auto rate = runtime::parse_positive_finite_float_option(options, {"speed", "speaking_rate"}); + if (!rate.has_value() || (voice.has_value() && voice->style.has_value() && + voice->style->speaking_rate.has_value())) { + return voice; } - runtime::validate_spec_backed_request_options(options, validation_arrays, contract, kModelName); + auto resolved = voice.value_or(runtime::VoiceCondition{}); + if (!resolved.style.has_value()) resolved.style = runtime::StyleCondition{}; + resolved.style->speaking_rate = *rate; + return resolved; } } // namespace void KokoroTTSSession::prepare(const runtime::SessionPreparationRequest & request) { validate_request_options(request.options, request.option_arrays, *contract_); + const auto voice = voice_with_request_rate(request.voice, request.options); if (const auto seed = runtime::parse_u64_option(request.options, {"seed"})) { if (rng_seed_ != *seed) { rng_seed_ = *seed; @@ -365,7 +390,7 @@ void KokoroTTSSession::prepare(const runtime::SessionPreparationRequest & reques runtime::SessionPreparationRequest chunk_request = request; chunk_request.text = runtime::Transcript{chunk, request.text->language}; const auto frontend_state = - resolve_kokoro_frontend_session_state(chunk_request.text, chunk_request.voice, *assets_); + resolve_kokoro_frontend_session_state(chunk_request.text, voice, *assets_); if (prepare_phonemes.empty()) { request_size = std::max( request_size, @@ -397,6 +422,7 @@ runtime::TaskResult KokoroTTSSession::run(const runtime::TaskRequest & request) throw std::runtime_error("Kokoro TTS run requires text_input"); } validate_request_options(request.options, request.option_arrays, *contract_); + const auto voice = voice_with_request_rate(request.voice, request.options); const int64_t text_chunk_size = engine::text::parse_text_chunk_size_override(request.options).value_or(kDefaultTextChunkSize); @@ -432,7 +458,7 @@ runtime::TaskResult KokoroTTSSession::run(const runtime::TaskRequest & request) std::optional shared_state; std::string shared_key_prefix; if (supplied) { - shared_state = resolve_kokoro_frontend_session_state(request.text_input, request.voice, *assets_); + shared_state = resolve_kokoro_frontend_session_state(request.text_input, voice, *assets_); shared_key_prefix = cache_key_prefix(*shared_state, *request.text_input); } for (size_t chunk_index = 0; chunk_index < chunk_count; ++chunk_index) { @@ -442,7 +468,7 @@ runtime::TaskResult KokoroTTSSession::run(const runtime::TaskRequest & request) : std::optional{}; const auto frontend_state = supplied ? *shared_state - : resolve_kokoro_frontend_session_state(chunk_request.text_input, chunk_request.voice, *assets_); + : resolve_kokoro_frontend_session_state(chunk_request.text_input, voice, *assets_); const std::string cache_key = (supplied ? shared_key_prefix : cache_key_prefix(frontend_state, *chunk_request.text_input)) + // Without this, two requests with the same text and different supplied phonemes diff --git a/src/models/supertonic/loader.cpp b/src/models/supertonic/loader.cpp index 4511c2022..e8edbd79c 100644 --- a/src/models/supertonic/loader.cpp +++ b/src/models/supertonic/loader.cpp @@ -35,7 +35,8 @@ runtime::ModelCliInterface cli(const SupertonicAssets &) { out.request_options = { {"voice_id", "M1|M2|M3|M4|M5|F1|F2|F3|F4|F5", "Preset voice style id, default M1; also exposed as --voice-id."}, {"num_inference_steps", "n", "Flow denoising steps, default 8."}, - {"speaking_rate", "float", "Speech speed multiplier, default 1.05."}, + {"speed", "float", "Speech speed multiplier, default 1.05."}, + {"speaking_rate", "float", "Alias for speed."}, {"seed", "n", "Noise seed, default 1234."}, {"text_chunk_mode", "default|tag_aware|japanese|endline", "Long-form text chunking mode."}, }; diff --git a/src/models/supertonic/session.cpp b/src/models/supertonic/session.cpp index 10f6cde0f..1367a865b 100644 --- a/src/models/supertonic/session.cpp +++ b/src/models/supertonic/session.cpp @@ -6,6 +6,7 @@ #include "engine/models/supertonic/runtime.h" #include +#include #include #include #include @@ -212,12 +213,17 @@ SupertonicGenerationOptions SupertonicSession::generation_options_from_request(c } options.num_inference_steps = static_cast(*value); } - if (const auto value = runtime::parse_finite_float_option(request.options, {"speaking_rate"})) { - if (*value <= 0.0F) { - throw std::runtime_error("Supertonic speaking_rate must be positive"); - } + if (const auto value = runtime::parse_positive_finite_float_option(request.options, {"speed", "speaking_rate"})) { options.speaking_rate = *value; } + if (request.voice.has_value() && request.voice->style.has_value() && + request.voice->style->speaking_rate.has_value()) { + const float rate = *request.voice->style->speaking_rate; + if (!std::isfinite(rate) || rate <= 0.0f) { + throw std::runtime_error("Supertonic speaking_rate must be positive and finite"); + } + options.speaking_rate = rate; + } if (const auto value = runtime::parse_u32_option(request.options, {"seed"})) { options.seed = *value; }