From bbdc17c4d83e589cb3a42bdd9dc3c047d2ed1234 Mon Sep 17 00:00:00 2001 From: Michal Kulakowski Date: Tue, 11 Aug 2026 11:20:48 +0200 Subject: [PATCH 1/3] 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/3] 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/3] 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"(