Skip to content
Open
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
24 changes: 23 additions & 1 deletion src/audio/audio_utils.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<size_t>::max() / bytesPerSample) {
throw std::runtime_error("Synthesized audio buffer size overflows maximum representable value");
}
validateAudioFileSizeAgainstMaxValue(speechSize * bytesPerSample);
Comment on lines +231 to +235
enum : unsigned int {
OUTPUT_PREPARATION,
TIMER_END
Expand All @@ -246,10 +253,25 @@ 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 {
validateAudioFileSizeAgainstMaxValue(pDataSize);
} catch (...) {
drwav_free(*ppData, nullptr);
*ppData = nullptr;
pDataSize = 0;
throw;
}
Comment on lines +267 to +274

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Doesn't validateAudioFileSizeAgainstMaxValue also need a try catch?
  2. What is the result of rethrowing here regarding what user - client and admin - see in the response message and logs.

timer.stop(OUTPUT_PREPARATION);
auto outputPreparationTime = (timer.elapsed<std::chrono::microseconds>(OUTPUT_PREPARATION)) / 1000;
SPDLOG_LOGGER_DEBUG(t2s_calculator_logger, "Output preparation time: {} ms", outputPreparationTime);
Expand Down
18 changes: 18 additions & 0 deletions src/audio/text_to_speech/t2s_calculator.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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<T2sCalculatorOptions>();
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();
}

Expand Down Expand Up @@ -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<T2sCalculatorOptions>();
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");
Expand Down
10 changes: 10 additions & 0 deletions src/audio/text_to_speech/t2s_calculator.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
86 changes: 86 additions & 0 deletions src/test/audio/audio_utils_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<float> 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).c_str());
std::vector<float> 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
142 changes: 142 additions & 0 deletions src/test/audio/text2speech_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -322,6 +382,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_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"(
Expand Down