diff --git a/src/audio/audio_utils.cpp b/src/audio/audio_utils.cpp index 74ac07516c..5c0136c1a6 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 @@ -243,13 +250,28 @@ 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 + // 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 { + 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 21cee3f4e2..7a5a9e46ec 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::InvalidArgumentError( + absl::StrCat("Invalid T2sCalculatorOptions: speed_min (", speedMin, ") must be <= speed_max (", speedMax, ")")); + } return absl::OkStatus(); } @@ -139,6 +147,16 @@ class T2sCalculator : public CalculatorBase { } speed = speedIt->value.GetFloat(); } + // 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(); + // 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, ")")); + } ov::genai::Text2SpeechDecodedResults generatedSpeech; std::unique_lock lock(pipe->ttsPipelineMutex); auto disconnectStatus = checkClientDisconnected(payload, cc->NodeName(), "before generation"); 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/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))); diff --git a/src/test/audio/audio_utils_test.cpp b/src/test/audio/audio_utils_test.cpp index 05b7685ede..ede322830d 100644 --- a/src/test/audio/audio_utils_test.cpp +++ b/src/test/audio/audio_utils_test.cpp @@ -445,4 +445,90 @@ 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, 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)); + 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. + 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/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 + } + } +} diff --git a/src/test/audio/text2speech_test.cpp b/src/test/audio/text2speech_test.cpp index bdb9dcc43e..e158736fb3 100644 --- a/src/test/audio/text2speech_test.cpp +++ b/src/test/audio/text2speech_test.cpp @@ -130,6 +130,120 @@ 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 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 { @@ -322,6 +436,88 @@ 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, 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_EQ(validateText2SpeechGraphConfig(manager, testPbtxt), StatusCode::MEDIAPIPE_GRAPH_CONFIG_FILE_INVALID); +} + +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"(