From bbdc17c4d83e589cb3a42bdd9dc3c047d2ed1234 Mon Sep 17 00:00:00 2001 From: Michal Kulakowski Date: Tue, 11 Aug 2026 11:20:48 +0200 Subject: [PATCH 1/6] Add configurable boundaries for speed parameter --- src/audio/audio_utils.cpp | 7 ++ src/audio/text_to_speech/t2s_calculator.cc | 7 ++ src/audio/text_to_speech/t2s_calculator.proto | 10 +++ src/test/audio/audio_utils_test.cpp | 66 ++++++++++++++ src/test/audio/text2speech_test.cpp | 87 +++++++++++++++++++ 5 files changed, 177 insertions(+) diff --git a/src/audio/audio_utils.cpp b/src/audio/audio_utils.cpp index 74ac07516c..ffcd62ec3b 100644 --- a/src/audio/audio_utils.cpp +++ b/src/audio/audio_utils.cpp @@ -226,6 +226,13 @@ void prepareAudioOutput(void** ppData, size_t& pDataSize, uint32_t sampleRate, u if (waveformPtr == nullptr && speechSize > 0) { throw std::runtime_error("Audio waveform pointer is null"); } + // Guard against oversized synthesized audio buffers — mirrors the decode paths + // (readWav / readMp3) which both call validateAudioFileSizeAgainstMaxValue. + const size_t bytesPerSample = bitsPerSample / 8; + if (bytesPerSample == 0 || speechSize > std::numeric_limits::max() / bytesPerSample) { + throw std::runtime_error("Synthesized audio buffer size overflows maximum representable value"); + } + validateAudioFileSizeAgainstMaxValue(speechSize * bytesPerSample); enum : unsigned int { OUTPUT_PREPARATION, TIMER_END diff --git a/src/audio/text_to_speech/t2s_calculator.cc b/src/audio/text_to_speech/t2s_calculator.cc index 21cee3f4e2..ff0b8eaa24 100644 --- a/src/audio/text_to_speech/t2s_calculator.cc +++ b/src/audio/text_to_speech/t2s_calculator.cc @@ -138,6 +138,13 @@ class T2sCalculator : public CalculatorBase { return absl::InvalidArgumentError("speed field is not a number"); } speed = speedIt->value.GetFloat(); + const auto& calcOptions = cc->Options(); + const float speedMin = calcOptions.speed_min(); + const float speedMax = calcOptions.speed_max(); + if (speed < speedMin || speed > speedMax) { + return absl::InvalidArgumentError( + absl::StrCat("speed must be between ", speedMin, " and ", speedMax)); + } } ov::genai::Text2SpeechDecodedResults generatedSpeech; std::unique_lock lock(pipe->ttsPipelineMutex); diff --git a/src/audio/text_to_speech/t2s_calculator.proto b/src/audio/text_to_speech/t2s_calculator.proto index efea722c3d..4cf9403df0 100644 --- a/src/audio/text_to_speech/t2s_calculator.proto +++ b/src/audio/text_to_speech/t2s_calculator.proto @@ -40,4 +40,14 @@ message T2sCalculatorOptions { required string path = 2; } repeated SpeakerEmbeddings voices = 4; + + // Minimum allowed value for the "speed" request parameter. + // Requests with speed < speed_min are rejected with HTTP 400. + // Default matches the OpenAI TTS API lower bound. + optional float speed_min = 5 [default = 0.25]; + + // Maximum allowed value for the "speed" request parameter. + // Requests with speed > speed_max are rejected with HTTP 400. + // Default matches the OpenAI TTS API upper bound. + optional float speed_max = 6 [default = 4.0]; } diff --git a/src/test/audio/audio_utils_test.cpp b/src/test/audio/audio_utils_test.cpp index 05b7685ede..4ba736e5b3 100644 --- a/src/test/audio/audio_utils_test.cpp +++ b/src/test/audio/audio_utils_test.cpp @@ -445,4 +445,70 @@ TEST_F(AudioUtilsSampleRateTest, wavOneByteOverLimitThrows) { UnSetEnvironmentVar("OVMS_AUDIO_MAX_FILE_SIZE_BYTES"); } +// ---- prepareAudioOutput size-cap tests ---------------------------------------- + +TEST_F(AudioUtilsSampleRateTest, prepareAudioOutputRejectsOversizedSpeech) { + // Simulate what a tiny speed value (e.g. 1e-9) would produce: a speechSize + // that exceeds the 1 GB default cap. The function must throw before any + // allocation attempt. + constexpr uint32_t sampleRate = 24000; + constexpr uint16_t bitsPerSample = 32; // float32 + // 512 Mi samples × 4 bytes = 2 GiB → exceeds DEFAULT_MAX_FILE_SIZE (1 GB) + constexpr size_t oversizedSpeech = 512ull * 1024 * 1024; + // A non-null dummy pointer is enough; prepareAudioOutput throws before + // it dereferences waveformPtr. + const float dummyWaveform = 0.0f; + void* ppData = nullptr; + size_t pDataSize = 0; + EXPECT_THROW( + prepareAudioOutput(&ppData, pDataSize, sampleRate, bitsPerSample, oversizedSpeech, &dummyWaveform), + std::runtime_error); +} + +TEST_F(AudioUtilsSampleRateTest, prepareAudioOutputRejectsOversizedSpeechWithCustomEnvVar) { + // Honour OVMS_AUDIO_MAX_FILE_SIZE_BYTES for the synthesis path too. + constexpr uint32_t sampleRate = 24000; + constexpr uint16_t bitsPerSample = 32; + // 100 samples × 4 bytes = 400 bytes — normally fine, but tiny cap rejects it. + constexpr size_t speechSize = 100; + SetEnvironmentVar("OVMS_AUDIO_MAX_FILE_SIZE_BYTES", "100"); + const float dummyWaveform = 0.0f; + void* ppData = nullptr; + size_t pDataSize = 0; + EXPECT_THROW( + prepareAudioOutput(&ppData, pDataSize, sampleRate, bitsPerSample, speechSize, &dummyWaveform), + std::runtime_error); + UnSetEnvironmentVar("OVMS_AUDIO_MAX_FILE_SIZE_BYTES"); +} + +TEST_F(AudioUtilsSampleRateTest, prepareAudioOutputAcceptsSmallSpeech) { + // A small, realistic synthesis output must pass the cap check. + constexpr uint32_t sampleRate = 24000; + constexpr uint16_t bitsPerSample = 32; + // 1000 samples × 4 bytes = 4000 bytes — well under the 1 GB default. + constexpr size_t speechSize = 1000; + std::vector waveform(speechSize, 0.0f); + void* ppData = nullptr; + size_t pDataSize = 0; + EXPECT_NO_THROW( + prepareAudioOutput(&ppData, pDataSize, sampleRate, bitsPerSample, speechSize, waveform.data())); + if (ppData) { + free(ppData); // drwav allocates via DRWAV_MALLOC + } +} + +TEST_F(AudioUtilsSampleRateTest, prepareAudioOutputRejectsZeroBitsPerSample) { + // bitsPerSample == 0 means bytesPerSample == 0 which would cause a divide- + // by-zero or meaningless size check — must be rejected. + constexpr uint32_t sampleRate = 24000; + constexpr uint16_t bitsPerSample = 0; + constexpr size_t speechSize = 100; + const float dummyWaveform = 0.0f; + void* ppData = nullptr; + size_t pDataSize = 0; + EXPECT_THROW( + prepareAudioOutput(&ppData, pDataSize, sampleRate, bitsPerSample, speechSize, &dummyWaveform), + std::runtime_error); +} + } // namespace diff --git a/src/test/audio/text2speech_test.cpp b/src/test/audio/text2speech_test.cpp index bdb9dcc43e..b28446ea7f 100644 --- a/src/test/audio/text2speech_test.cpp +++ b/src/test/audio/text2speech_test.cpp @@ -130,6 +130,66 @@ TEST_F(Text2SpeechHttpTest, nonExistingVoiceRequested) { ovms::StatusCode::MEDIAPIPE_EXECUTION_ERROR); } +TEST_F(Text2SpeechHttpTest, speedBelowDefaultMinRejected) { + std::string requestBody = R"( + { + "model": ")" + modelName + + R"(", + "input": "hello world", + "voice": "af_alloy", + "speed": 0.1 + } + )"; + ASSERT_EQ( + handler->dispatchToProcessor(endpoint, requestBody, &response, comp, responseComponents, writer, multiPartParser), + ovms::StatusCode::MEDIAPIPE_EXECUTION_ERROR); +} + +TEST_F(Text2SpeechHttpTest, speedAboveDefaultMaxRejected) { + std::string requestBody = R"( + { + "model": ")" + modelName + + R"(", + "input": "hello world", + "voice": "af_alloy", + "speed": 5.0 + } + )"; + ASSERT_EQ( + handler->dispatchToProcessor(endpoint, requestBody, &response, comp, responseComponents, writer, multiPartParser), + ovms::StatusCode::MEDIAPIPE_EXECUTION_ERROR); +} + +TEST_F(Text2SpeechHttpTest, speedAtDefaultLowerBoundAccepted) { + std::string requestBody = R"( + { + "model": ")" + modelName + + R"(", + "input": "hello world", + "voice": "af_alloy", + "speed": 0.25 + } + )"; + ASSERT_EQ( + handler->dispatchToProcessor(endpoint, requestBody, &response, comp, responseComponents, writer, multiPartParser), + ovms::StatusCode::OK); +} + +TEST_F(Text2SpeechHttpTest, speedAtDefaultUpperBoundAccepted) { + std::string requestBody = R"( + { + "model": ")" + modelName + + R"(", + "input": "hello world", + "voice": "af_alloy", + "speed": 4.0 + } + )"; + ASSERT_EQ( + handler->dispatchToProcessor(endpoint, requestBody, &response, comp, responseComponents, writer, multiPartParser), + ovms::StatusCode::OK); +} + class Text2SpeechConfigTest : public ::testing::Test {}; namespace { @@ -322,6 +382,33 @@ TEST_F(Text2SpeechConfigTest, VoiceMissingPath) { ASSERT_EQ(validateText2SpeechGraphConfig(manager, testPbtxt), StatusCode::MEDIAPIPE_GRAPH_CONFIG_FILE_INVALID); } +TEST_F(Text2SpeechConfigTest, CustomSpeedBoundsConfigured) { + ConstructorEnabledModelManager manager; + std::string testPbtxt = R"( + input_stream: "HTTP_REQUEST_PAYLOAD:input" + output_stream: "HTTP_RESPONSE_PAYLOAD:output" + + node { + name: "ttsNode1" + input_side_packet: "TTS_NODE_RESOURCES:t2s_servable" + calculator: "T2sCalculator" + input_stream: "HTTP_REQUEST_PAYLOAD:input" + output_stream: "HTTP_RESPONSE_PAYLOAD:output" + node_options: { + [type.googleapis.com / mediapipe.T2sCalculatorOptions]: { + models_path: "/ovms/src/test/llm_testing/hexgrad/Kokoro-82M" + plugin_config: '{"NUM_STREAMS": "1" }', + target_device: "CPU" + speed_min: 0.5 + speed_max: 2.0 + } + } + } + )"; + + ASSERT_EQ(validateText2SpeechGraphConfig(manager, testPbtxt), StatusCode::OK); +} + TEST_F(Text2SpeechConfigTest, VoiceInvalidFile) { ConstructorEnabledModelManager manager; std::string testPbtxt = R"( From 8828998315c1ce68fd1fbddd5c46ae24bb599361 Mon Sep 17 00:00:00 2001 From: Michal Kulakowski Date: Tue, 11 Aug 2026 15:22:34 +0200 Subject: [PATCH 2/6] update --- src/audio/audio_utils.cpp | 10 ++++++++++ src/audio/text_to_speech/t2s_calculator.cc | 15 ++++++++------- src/test/audio/audio_utils_test.cpp | 20 ++++++++++++++++++++ 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/audio/audio_utils.cpp b/src/audio/audio_utils.cpp index ffcd62ec3b..2ee7d283d0 100644 --- a/src/audio/audio_utils.cpp +++ b/src/audio/audio_utils.cpp @@ -257,6 +257,16 @@ void prepareAudioOutput(void** ppData, size_t& pDataSize, uint32_t sampleRate, u throw std::runtime_error("Failed to write all frames"); } drwav_uninit(&wav); + // Validate the actual WAV container size (includes RIFF/fmt/fact/data header + // overhead that the pre-write check did not account for). + try { + validateAudioFileSizeAgainstMaxValue(pDataSize); + } catch (...) { + drwav_free(*ppData, nullptr); + *ppData = nullptr; + pDataSize = 0; + throw; + } timer.stop(OUTPUT_PREPARATION); auto outputPreparationTime = (timer.elapsed(OUTPUT_PREPARATION)) / 1000; SPDLOG_LOGGER_DEBUG(t2s_calculator_logger, "Output preparation time: {} ms", outputPreparationTime); diff --git a/src/audio/text_to_speech/t2s_calculator.cc b/src/audio/text_to_speech/t2s_calculator.cc index ff0b8eaa24..ed61b41a2c 100644 --- a/src/audio/text_to_speech/t2s_calculator.cc +++ b/src/audio/text_to_speech/t2s_calculator.cc @@ -138,13 +138,14 @@ class T2sCalculator : public CalculatorBase { return absl::InvalidArgumentError("speed field is not a number"); } speed = speedIt->value.GetFloat(); - const auto& calcOptions = cc->Options(); - const float speedMin = calcOptions.speed_min(); - const float speedMax = calcOptions.speed_max(); - if (speed < speedMin || speed > speedMax) { - return absl::InvalidArgumentError( - absl::StrCat("speed must be between ", speedMin, " and ", speedMax)); - } + } + // Validate speed bounds regardless of whether it came from request or default + const auto& calcOptions = cc->Options(); + const float speedMin = calcOptions.speed_min(); + const float speedMax = calcOptions.speed_max(); + if (speed < speedMin || speed > speedMax) { + return absl::InvalidArgumentError( + absl::StrCat("speed must be between speed_min (", speedMin, ") and speed_max (", speedMax, ")")); } ov::genai::Text2SpeechDecodedResults generatedSpeech; std::unique_lock lock(pipe->ttsPipelineMutex); diff --git a/src/test/audio/audio_utils_test.cpp b/src/test/audio/audio_utils_test.cpp index 4ba736e5b3..599606ed4d 100644 --- a/src/test/audio/audio_utils_test.cpp +++ b/src/test/audio/audio_utils_test.cpp @@ -497,6 +497,26 @@ TEST_F(AudioUtilsSampleRateTest, prepareAudioOutputAcceptsSmallSpeech) { } } +TEST_F(AudioUtilsSampleRateTest, prepareAudioOutputRejectsWhenHeaderPushesOverLimit) { + // The cap is set to exactly the raw PCM byte count. The WAV container adds + // RIFF/fmt/fact/data header overhead on top of that, so the final pDataSize + // returned by drwav must exceed the limit and be rejected — even though the + // raw PCM payload alone would have been accepted. + constexpr uint32_t sampleRate = 24000; + constexpr uint16_t bitsPerSample = 32; + constexpr size_t speechSize = 100; + constexpr size_t rawPcmBytes = speechSize * (bitsPerSample / 8); // 400 bytes + // Cap == raw PCM size; the WAV container will be larger, so it must be rejected. + SetEnvironmentVar("OVMS_AUDIO_MAX_FILE_SIZE_BYTES", std::to_string(rawPcmBytes).c_str()); + std::vector waveform(speechSize, 0.0f); + void* ppData = nullptr; + size_t pDataSize = 0; + EXPECT_THROW( + prepareAudioOutput(&ppData, pDataSize, sampleRate, bitsPerSample, speechSize, waveform.data()), + std::runtime_error); + UnSetEnvironmentVar("OVMS_AUDIO_MAX_FILE_SIZE_BYTES"); +} + TEST_F(AudioUtilsSampleRateTest, prepareAudioOutputRejectsZeroBitsPerSample) { // bitsPerSample == 0 means bytesPerSample == 0 which would cause a divide- // by-zero or meaningless size check — must be rejected. From 74c356f017b6bbca33f8dfb0a1bbd168f010100a Mon Sep 17 00:00:00 2001 From: Michal Kulakowski Date: Tue, 11 Aug 2026 16:27:12 +0200 Subject: [PATCH 3/6] fixes --- src/audio/audio_utils.cpp | 7 ++- src/audio/text_to_speech/t2s_calculator.cc | 12 ++++- src/test/audio/text2speech_test.cpp | 55 ++++++++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/audio/audio_utils.cpp b/src/audio/audio_utils.cpp index 2ee7d283d0..30b509aae7 100644 --- a/src/audio/audio_utils.cpp +++ b/src/audio/audio_utils.cpp @@ -253,10 +253,15 @@ void prepareAudioOutput(void** ppData, size_t& pDataSize, uint32_t sampleRate, u throw std::runtime_error("Failed to write all frames"); } drwav_uint64 framesWritten = drwav_write_pcm_frames(&wav, totalSamples, waveformPtr); + // Finalize the WAV container before any cleanup path; drwav_uninit is safe + // to call even when fewer frames than expected were written. + drwav_uninit(&wav); if (framesWritten != totalSamples) { + drwav_free(*ppData, nullptr); + *ppData = nullptr; + pDataSize = 0; throw std::runtime_error("Failed to write all frames"); } - drwav_uninit(&wav); // Validate the actual WAV container size (includes RIFF/fmt/fact/data header // overhead that the pre-write check did not account for). try { diff --git a/src/audio/text_to_speech/t2s_calculator.cc b/src/audio/text_to_speech/t2s_calculator.cc index ed61b41a2c..be90c97e05 100644 --- a/src/audio/text_to_speech/t2s_calculator.cc +++ b/src/audio/text_to_speech/t2s_calculator.cc @@ -83,6 +83,14 @@ class T2sCalculator : public CalculatorBase { absl::Status Open(CalculatorContext* cc) final { SPDLOG_LOGGER_DEBUG(t2s_calculator_logger, "T2sCalculator [Node: {}] Open start", cc->NodeName()); + const auto& calcOptions = cc->Options(); + const float speedMin = calcOptions.speed_min(); + const float speedMax = calcOptions.speed_max(); + // !(speedMin <= speedMax) is true for inverted ranges and for any NaN bound. + if (!(speedMin <= speedMax)) { + return absl::InternalError( + absl::StrCat("Invalid T2sCalculatorOptions: speed_min (", speedMin, ") must be <= speed_max (", speedMax, ")")); + } return absl::OkStatus(); } @@ -143,7 +151,9 @@ class T2sCalculator : public CalculatorBase { const auto& calcOptions = cc->Options(); const float speedMin = calcOptions.speed_min(); const float speedMax = calcOptions.speed_max(); - if (speed < speedMin || speed > speedMax) { + // Use positive-range predicate: NaN speed makes both comparisons + // false, so the negation correctly rejects it. + if (!(speedMin <= speed && speed <= speedMax)) { return absl::InvalidArgumentError( absl::StrCat("speed must be between speed_min (", speedMin, ") and speed_max (", speedMax, ")")); } diff --git a/src/test/audio/text2speech_test.cpp b/src/test/audio/text2speech_test.cpp index b28446ea7f..620a61b51b 100644 --- a/src/test/audio/text2speech_test.cpp +++ b/src/test/audio/text2speech_test.cpp @@ -409,6 +409,61 @@ TEST_F(Text2SpeechConfigTest, CustomSpeedBoundsConfigured) { ASSERT_EQ(validateText2SpeechGraphConfig(manager, testPbtxt), StatusCode::OK); } +TEST_F(Text2SpeechConfigTest, InvertedSpeedBoundsRejected) { + ConstructorEnabledModelManager manager; + std::string testPbtxt = R"( + input_stream: "HTTP_REQUEST_PAYLOAD:input" + output_stream: "HTTP_RESPONSE_PAYLOAD:output" + + node { + name: "ttsNode1" + input_side_packet: "TTS_NODE_RESOURCES:t2s_servable" + calculator: "T2sCalculator" + input_stream: "HTTP_REQUEST_PAYLOAD:input" + output_stream: "HTTP_RESPONSE_PAYLOAD:output" + node_options: { + [type.googleapis.com / mediapipe.T2sCalculatorOptions]: { + models_path: "/ovms/src/test/llm_testing/hexgrad/Kokoro-82M" + plugin_config: '{"NUM_STREAMS": "1" }', + target_device: "CPU" + speed_min: 2.0 + speed_max: 0.5 + } + } + } + )"; + + ASSERT_NE(validateText2SpeechGraphConfig(manager, testPbtxt), StatusCode::OK); +} + +TEST_F(Text2SpeechConfigTest, EqualSpeedBoundsAccepted) { + // speed_min == speed_max is a valid (single-value) range. + ConstructorEnabledModelManager manager; + std::string testPbtxt = R"( + input_stream: "HTTP_REQUEST_PAYLOAD:input" + output_stream: "HTTP_RESPONSE_PAYLOAD:output" + + node { + name: "ttsNode1" + input_side_packet: "TTS_NODE_RESOURCES:t2s_servable" + calculator: "T2sCalculator" + input_stream: "HTTP_REQUEST_PAYLOAD:input" + output_stream: "HTTP_RESPONSE_PAYLOAD:output" + node_options: { + [type.googleapis.com / mediapipe.T2sCalculatorOptions]: { + models_path: "/ovms/src/test/llm_testing/hexgrad/Kokoro-82M" + plugin_config: '{"NUM_STREAMS": "1" }', + target_device: "CPU" + speed_min: 1.0 + speed_max: 1.0 + } + } + } + )"; + + ASSERT_EQ(validateText2SpeechGraphConfig(manager, testPbtxt), StatusCode::OK); +} + TEST_F(Text2SpeechConfigTest, VoiceInvalidFile) { ConstructorEnabledModelManager manager; std::string testPbtxt = R"( From a943471d4cedcdd136b7f6f6f6313c899f0cea20 Mon Sep 17 00:00:00 2001 From: Michal Kulakowski Date: Wed, 12 Aug 2026 10:08:44 +0200 Subject: [PATCH 4/6] fix --- src/audio/text_to_speech/tts_node_initializer.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/audio/text_to_speech/tts_node_initializer.cpp b/src/audio/text_to_speech/tts_node_initializer.cpp index 6fb0bfdc79..32d0a95e6c 100644 --- a/src/audio/text_to_speech/tts_node_initializer.cpp +++ b/src/audio/text_to_speech/tts_node_initializer.cpp @@ -61,6 +61,17 @@ class TtsNodeInitializer : public NodeInitializer { SPDLOG_ERROR("Failed to unpack calculator options"); return StatusCode::MEDIAPIPE_GRAPH_CONFIG_FILE_INVALID; } + const float speedMin = nodeOptions.speed_min(); + const float speedMax = nodeOptions.speed_max(); + // !(speedMin <= speedMax) is true for inverted ranges and any NaN bound. + if (!(speedMin <= speedMax)) { + SPDLOG_ERROR("TextToSpeech node name: {} invalid speed bounds in graph {}: speed_min ({}) must be <= speed_max ({}).", + nodeName, + graphName, + speedMin, + speedMax); + return StatusCode::MEDIAPIPE_GRAPH_CONFIG_FILE_INVALID; + } try { auto servable = std::make_shared(nodeOptions.models_path(), nodeOptions.target_device(), nodeOptions.voices(), nodeOptions.plugin_config(), basePath); ttsServableMap.insert(std::pair>(nodeName, std::move(servable))); From ad85ec9dd189bb44e13282f20177c23178bbdab8 Mon Sep 17 00:00:00 2001 From: Michal Kulakowski Date: Wed, 12 Aug 2026 12:49:16 +0200 Subject: [PATCH 5/6] fix --- src/audio/audio_utils.cpp | 2 +- src/audio/text_to_speech/t2s_calculator.cc | 2 +- src/test/audio/audio_utils_test.cpp | 2 +- src/test/audio/text2speech_test.cpp | 56 +++++++++++++++++++++- 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/audio/audio_utils.cpp b/src/audio/audio_utils.cpp index 30b509aae7..5c0136c1a6 100644 --- a/src/audio/audio_utils.cpp +++ b/src/audio/audio_utils.cpp @@ -250,7 +250,7 @@ void prepareAudioOutput(void** ppData, size_t& pDataSize, uint32_t sampleRate, u auto status = drwav_init_memory_write(&wav, ppData, &pDataSize, &format, nullptr); if (status == DRWAV_FALSE) { - throw std::runtime_error("Failed to write all frames"); + throw std::runtime_error("Failed to initialize WAV memory writer"); } drwav_uint64 framesWritten = drwav_write_pcm_frames(&wav, totalSamples, waveformPtr); // Finalize the WAV container before any cleanup path; drwav_uninit is safe diff --git a/src/audio/text_to_speech/t2s_calculator.cc b/src/audio/text_to_speech/t2s_calculator.cc index be90c97e05..7a5a9e46ec 100644 --- a/src/audio/text_to_speech/t2s_calculator.cc +++ b/src/audio/text_to_speech/t2s_calculator.cc @@ -88,7 +88,7 @@ class T2sCalculator : public CalculatorBase { const float speedMax = calcOptions.speed_max(); // !(speedMin <= speedMax) is true for inverted ranges and for any NaN bound. if (!(speedMin <= speedMax)) { - return absl::InternalError( + return absl::InvalidArgumentError( absl::StrCat("Invalid T2sCalculatorOptions: speed_min (", speedMin, ") must be <= speed_max (", speedMax, ")")); } return absl::OkStatus(); diff --git a/src/test/audio/audio_utils_test.cpp b/src/test/audio/audio_utils_test.cpp index 599606ed4d..ede322830d 100644 --- a/src/test/audio/audio_utils_test.cpp +++ b/src/test/audio/audio_utils_test.cpp @@ -507,7 +507,7 @@ TEST_F(AudioUtilsSampleRateTest, prepareAudioOutputRejectsWhenHeaderPushesOverLi constexpr size_t speechSize = 100; constexpr size_t rawPcmBytes = speechSize * (bitsPerSample / 8); // 400 bytes // Cap == raw PCM size; the WAV container will be larger, so it must be rejected. - SetEnvironmentVar("OVMS_AUDIO_MAX_FILE_SIZE_BYTES", std::to_string(rawPcmBytes).c_str()); + SetEnvironmentVar("OVMS_AUDIO_MAX_FILE_SIZE_BYTES", std::to_string(rawPcmBytes)); std::vector waveform(speechSize, 0.0f); void* ppData = nullptr; size_t pDataSize = 0; diff --git a/src/test/audio/text2speech_test.cpp b/src/test/audio/text2speech_test.cpp index 620a61b51b..e158736fb3 100644 --- a/src/test/audio/text2speech_test.cpp +++ b/src/test/audio/text2speech_test.cpp @@ -190,6 +190,60 @@ TEST_F(Text2SpeechHttpTest, speedAtDefaultUpperBoundAccepted) { ovms::StatusCode::OK); } +class Text2SpeechHttpCustomBoundsTest : public V3HttpTest { +protected: + std::string modelName = "text2speech"; + std::string endpoint = "/v1/audio/speech"; + static std::unique_ptr t; + +public: + static void SetUpTestSuite() { + std::string port = "9174"; + std::string configPath = getGenericFullPathForSrcTest("/ovms/src/test/audio/config_tts_custom_speed_bounds.json"); + SetUpSuite(port, configPath, t); + } + + void SetUp() { + V3HttpTest::SetUp(); + ASSERT_EQ(handler->parseRequestComponents(comp, "POST", endpoint, headers), ovms::StatusCode::OK); + } + + static void TearDownTestSuite() { + TearDownSuite(t); + } +}; +std::unique_ptr Text2SpeechHttpCustomBoundsTest::t; + +TEST_F(Text2SpeechHttpCustomBoundsTest, speedBelowCustomMinRejected) { + std::string requestBody = R"( + { + "model": ")" + modelName + + R"(", + "input": "hello world", + "voice": "af_alloy", + "speed": 0.25 + } + )"; + ASSERT_EQ( + handler->dispatchToProcessor(endpoint, requestBody, &response, comp, responseComponents, writer, multiPartParser), + ovms::StatusCode::MEDIAPIPE_EXECUTION_ERROR); +} + +TEST_F(Text2SpeechHttpCustomBoundsTest, speedAtCustomLowerBoundAccepted) { + std::string requestBody = R"( + { + "model": ")" + modelName + + R"(", + "input": "hello world", + "voice": "af_alloy", + "speed": 0.5 + } + )"; + ASSERT_EQ( + handler->dispatchToProcessor(endpoint, requestBody, &response, comp, responseComponents, writer, multiPartParser), + ovms::StatusCode::OK); +} + class Text2SpeechConfigTest : public ::testing::Test {}; namespace { @@ -433,7 +487,7 @@ TEST_F(Text2SpeechConfigTest, InvertedSpeedBoundsRejected) { } )"; - ASSERT_NE(validateText2SpeechGraphConfig(manager, testPbtxt), StatusCode::OK); + ASSERT_EQ(validateText2SpeechGraphConfig(manager, testPbtxt), StatusCode::MEDIAPIPE_GRAPH_CONFIG_FILE_INVALID); } TEST_F(Text2SpeechConfigTest, EqualSpeedBoundsAccepted) { From bedecf426007e3ca9c660bd841a0f1481249419a Mon Sep 17 00:00:00 2001 From: Michal Kulakowski Date: Wed, 12 Aug 2026 13:12:36 +0200 Subject: [PATCH 6/6] Add missing files --- .../audio/config_tts_custom_speed_bounds.json | 10 ++++++ .../audio/graph_tts_custom_speed_bounds.pbtxt | 34 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 src/test/audio/config_tts_custom_speed_bounds.json create mode 100644 src/test/audio/graph_tts_custom_speed_bounds.pbtxt diff --git a/src/test/audio/config_tts_custom_speed_bounds.json b/src/test/audio/config_tts_custom_speed_bounds.json new file mode 100644 index 0000000000..b2d17f2b42 --- /dev/null +++ b/src/test/audio/config_tts_custom_speed_bounds.json @@ -0,0 +1,10 @@ +{ + "model_config_list": [], + "mediapipe_config_list": [ + { + "name":"text2speech", + "base_path":"/ovms/src/test/audio/", + "graph_path":"/ovms/src/test/audio/graph_tts_custom_speed_bounds.pbtxt" + } + ] +} diff --git a/src/test/audio/graph_tts_custom_speed_bounds.pbtxt b/src/test/audio/graph_tts_custom_speed_bounds.pbtxt new file mode 100644 index 0000000000..28148f8fb1 --- /dev/null +++ b/src/test/audio/graph_tts_custom_speed_bounds.pbtxt @@ -0,0 +1,34 @@ +# Copyright 2026 Intel Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +input_stream: "HTTP_REQUEST_PAYLOAD:input" +output_stream: "HTTP_RESPONSE_PAYLOAD:output" + +node { + name: "ttsNode1" + input_side_packet: "TTS_NODE_RESOURCES:t2s_servable" + calculator: "T2sCalculator" + input_stream: "HTTP_REQUEST_PAYLOAD:input" + output_stream: "HTTP_RESPONSE_PAYLOAD:output" + node_options: { + [type.googleapis.com / mediapipe.T2sCalculatorOptions]: { + models_path: "/ovms/src/test/llm_testing/hexgrad/Kokoro-82M" + plugin_config: '{"NUM_STREAMS": "1" }', + target_device: "CPU" + speed_min: 0.5 + speed_max: 2.0 + } + } +}