Files
LocalAI/backend/cpp/audio-cpp/generation_request_ctest.cpp
Ettore Di Giacinto d318b2ea94 backend(audio-cpp): serve the TTS and SoundGeneration RPCs
TTSRequest.voice is treated as a speaker reference clip when it names an
existing regular file, which makes routing prefer VoiceCloning, and as a named
preset otherwise, in which case it travels as VoiceReference::cached_voice_id.
Both the clip and SoundGenerationRequest.src are read at the file's own rate and
channel count: upstream's own CLI and server do exactly that, every consuming
family resamples internally and mostly with a better resampler than ours, and
ace_step and stable_audio resample their input per channel, so a downmix here
would delete the stereo image they are built to consume.

The request builders live in their own unit rather than in grpc-server.cpp's
anonymous namespace so they can be tested; grpc-server.cpp has a main() and
cannot be linked into a test binary. The option keys are the whole point of
these functions, so each one was grepped against the pinned upstream and the
accounting is written down beside it. instructions maps to "instruct", which is
what upstream's own server maps the OpenAI field to and what qwen3_tts and
omnivoice read, and to "caption" for irodori_tts; the style tag is spelled
"instruct" too, because "instructions" is looked up nowhere. duration maps to
"duration_seconds", read by all three generation families, with the proto's own
name kept only as a forward-tolerant alias. Keys that no family reads say so.

Both handlers answer a capability refusal before taking the lane and before any
file read, so a model that cannot synthesise does not queue behind somebody
else's run to be told no.

Assisted-by: Claude:claude-opus-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-29 19:03:33 +00:00

323 lines
14 KiB
C++

// Tests for the TTS and SoundGeneration request builders, and for the
// filesystem rule that decides whether TTSRequest.voice is a speaker reference
// clip or a named preset.
//
// NAMED _ctest AND NOT _test ON PURPOSE: see the note at the top of
// result_map_ctest.cpp. This file needs the generated protobuf messages and the
// audio.cpp include path, neither of which backend/cpp/run-unit-tests.sh
// provides, so it is built and run by ctest:
//
// make -C backend/cpp/audio-cpp test-engine
//
// The assertions on OPTION KEYS are the point of this file, not decoration.
// Every one of them names a key that was grepped against the pinned upstream:
// "instruct" is read, "instructions" is not; "duration_seconds" is read,
// "duration" is not. A rename that looks harmless is exactly the change that
// silently stops a family honouring the request, so the spellings are pinned
// here rather than left to a comment.
#include "generation_request.h"
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <string>
#include <unordered_map>
static int failures = 0;
static void check(bool ok, const std::string &name) {
if (!ok) {
failures++;
fprintf(stderr, "FAIL: %s\n", name.c_str());
} else {
fprintf(stderr, "ok: %s\n", name.c_str());
}
}
using namespace audiocpp_backend;
static bool has_key(const std::unordered_map<std::string, std::string> &options,
const std::string &key) {
return options.find(key) != options.end();
}
static std::string option_or(
const std::unordered_map<std::string, std::string> &options,
const std::string &key, const std::string &fallback) {
const auto it = options.find(key);
return it == options.end() ? fallback : it->second;
}
static engine::runtime::AudioBuffer clip(int sample_rate, int channels) {
engine::runtime::AudioBuffer buffer;
buffer.sample_rate = sample_rate;
buffer.channels = channels;
// Distinguishable content, so a builder that swapped one buffer for another
// (or default-constructed one) is visible rather than merely a size change.
buffer.samples = {0.25f, -0.5f, 0.75f, -1.0f};
return buffer;
}
static void test_voice_is_reference_file() {
const auto dir = std::filesystem::temp_directory_path() /
"audiocpp-generation-request-ctest";
std::filesystem::remove_all(dir);
std::filesystem::create_directories(dir);
const auto file = dir / "reference.wav";
{
std::ofstream out(file, std::ios::binary);
out << "not really a wav, but a regular file";
}
const auto subdir = dir / "a-directory";
std::filesystem::create_directories(subdir);
check(!voice_is_reference_file(""), "voice_is_reference_file: empty");
check(!voice_is_reference_file("alloy"),
"voice_is_reference_file: bare preset name");
check(!voice_is_reference_file((dir / "absent.wav").string()),
"voice_is_reference_file: missing path");
check(voice_is_reference_file(file.string()),
"voice_is_reference_file: existing regular file");
// A directory is NOT a reference. exists() would say yes here and the read
// would then fail with "cannot read <dir> as WAV", which sends the operator
// after a file problem instead of a preset typo.
check(!voice_is_reference_file(subdir.string()),
"voice_is_reference_file: directory is not a reference");
std::filesystem::remove_all(dir);
}
static void test_tts_plain() {
backend::TTSRequest request;
request.set_text("hello there");
const auto task = build_tts_request(request, std::nullopt);
check(task.text_input.has_value() && task.text_input->text == "hello there",
"tts: text reaches the transcript");
// No voice and no instructions means NO voice condition at all. A builder
// that always emitted one would make every family think a speaker was
// named, and chatterbox in particular refuses a prepare whose voice
// condition carries neither audio nor anything else it can use.
check(!task.voice.has_value(), "tts: no voice condition when nothing is set");
check(task.options.empty(), "tts: no options when nothing is set");
check(!task.audio_input.has_value(), "tts: no audio input");
}
static void test_tts_named_preset() {
backend::TTSRequest request;
request.set_text("hello");
request.set_voice("alloy");
const auto task = build_tts_request(request, std::nullopt);
check(task.voice.has_value() && task.voice->speaker.has_value(),
"tts preset: speaker condition present");
check(task.voice->speaker->cached_voice_id.has_value() &&
*task.voice->speaker->cached_voice_id == "alloy",
"tts preset: lands in cached_voice_id");
// The clip slot must stay empty, or a cloning family would try to prepare
// conditionals from a default-constructed buffer.
check(!task.voice->speaker->audio.has_value(),
"tts preset: no reference audio");
check(!task.voice->style.has_value(), "tts preset: no style condition");
check(option_or(task.options, "voice", "") == "alloy",
"tts preset: forwarded as the voice option too");
}
static void test_tts_reference_clip() {
backend::TTSRequest request;
request.set_text("hello");
request.set_voice("/tmp/reference.wav");
const auto task = build_tts_request(request, clip(44100, 2));
check(task.voice.has_value() && task.voice->speaker.has_value(),
"tts clip: speaker condition present");
check(task.voice->speaker->audio.has_value(),
"tts clip: reference audio present");
// Rate and channels survive untouched. This is the assertion that fails if
// anybody decides to fold the clip to 16 kHz mono on the way in.
check(task.voice->speaker->audio->sample_rate == 44100 &&
task.voice->speaker->audio->channels == 2 &&
task.voice->speaker->audio->samples.size() == 4,
"tts clip: rate, channels and samples pass through unchanged");
// A clip is NOT also a cached voice id, and the path must not travel as a
// preset name: a family reading cached_voice_id would then look up a voice
// called "/tmp/reference.wav".
check(!task.voice->speaker->cached_voice_id.has_value(),
"tts clip: no cached_voice_id");
check(!has_key(task.options, "voice"), "tts clip: no voice option");
}
static void test_tts_instructions() {
backend::TTSRequest request;
request.set_text("hello");
request.set_instructions("a calm older man, speaking slowly");
const auto task = build_tts_request(request, std::nullopt);
check(option_or(task.options, "instruct", "") ==
"a calm older man, speaking slowly",
"tts instructions: instruct option is the one qwen3_tts reads");
check(option_or(task.options, "caption", "") ==
"a calm older man, speaking slowly",
"tts instructions: caption option is the one irodori_tts reads");
check(option_or(task.options, "instructions", "") ==
"a calm older man, speaking slowly",
"tts instructions: proto field name forwarded as an alias");
check(task.voice.has_value() && task.voice->style.has_value(),
"tts instructions: style condition present");
// "instruct", not "instructions". omnivoice and qwen3_tts both look this tag
// up by that exact name and by no other.
check(option_or(task.voice->style->tags, "instruct", "") ==
"a calm older man, speaking slowly",
"tts instructions: style tag is spelled instruct");
check(!has_key(task.voice->style->tags, "instructions"),
"tts instructions: style tag is NOT spelled instructions");
// Instructions alone must not invent a speaker: has_voice_reference is what
// routing keys VoiceCloning off, and a speaker here would make a
// clone-capable family expect a clip it never received.
check(!task.voice->speaker.has_value(),
"tts instructions: no speaker without a voice");
}
static void test_tts_empty_instructions_are_not_instructions() {
backend::TTSRequest request;
request.set_text("hello");
request.set_instructions("");
const auto task = build_tts_request(request, std::nullopt);
// has_instructions() is true here, because the field was set. An empty
// string is still no instruction, and forwarding it would set an empty
// instruct option that qwen3_tts would prefer over its style tag fallback.
check(!has_key(task.options, "instruct"),
"tts: an empty instructions string sets no instruct option");
check(!task.voice.has_value(),
"tts: an empty instructions string sets no voice condition");
}
static void test_tts_language_and_params() {
backend::TTSRequest request;
request.set_text("ciao");
request.set_language("it");
request.set_instructions("warm");
(*request.mutable_params())["exaggeration"] = "0.7";
// An explicit param must win over the value derived from instructions.
(*request.mutable_params())["caption"] = "explicitly chosen caption";
const auto task = build_tts_request(request, std::nullopt);
check(task.text_input.has_value() && task.text_input->language == "it",
"tts language: reaches the transcript");
check(option_or(task.options, "language", "") == "it",
"tts language: forwarded as an option alias");
check(task.voice.has_value() && task.voice->style.has_value() &&
task.voice->style->language.has_value() &&
*task.voice->style->language == "it",
"tts language: reaches the style condition");
check(option_or(task.options, "exaggeration", "") == "0.7",
"tts params: passed through verbatim");
check(option_or(task.options, "caption", "") == "explicitly chosen caption",
"tts params: an explicit param overrides the derived caption");
}
static void test_sound_generation_minimal() {
backend::SoundGenerationRequest request;
request.set_text("a distant thunderstorm");
const auto task = build_sound_generation_request(request, std::nullopt);
check(task.text_input.has_value() &&
task.text_input->text == "a distant thunderstorm",
"sound: text reaches the transcript");
// Unset optionals must emit NOTHING. Emitting a zero for an unset duration
// would make heartmula refuse the request ("duration_seconds must be
// positive") on a request that never mentioned a duration.
check(task.options.empty(), "sound: unset optionals emit no options");
check(!task.audio_input.has_value(), "sound: no audio input without src");
check(!task.voice.has_value(), "sound: no voice condition");
}
static void test_sound_generation_full() {
backend::SoundGenerationRequest request;
request.set_text("a slow blues in E");
request.set_duration(30.0f);
request.set_temperature(0.8f);
request.set_sample(false);
request.set_src_divisor(4);
request.set_think(true);
request.set_caption("smoky bar recording");
request.set_lyrics("first line\nsecond line");
request.set_bpm(72);
request.set_keyscale("E minor");
request.set_language("en");
request.set_timesignature("4/4");
request.set_instrumental(true);
const auto task = build_sound_generation_request(request, clip(48000, 2));
// duration_seconds is the key every AudioGeneration family actually reads;
// "duration" rides along as an alias. Both spellings are pinned so a
// "cleanup" that keeps only the proto's own name is a test failure and not
// a silent loss of the duration.
check(option_or(task.options, "duration_seconds", "").rfind("30.", 0) == 0,
"sound: duration lands as duration_seconds");
check(option_or(task.options, "duration", "").rfind("30.", 0) == 0,
"sound: duration also forwarded under its own name");
check(option_or(task.options, "temperature", "").rfind("0.8", 0) == 0,
"sound: temperature forwarded");
// Set to FALSE, so this also proves the key is written whenever the field is
// present rather than only when the value is truthy.
check(option_or(task.options, "do_sample", "") == "false",
"sound: sample=false is forwarded as do_sample=false");
check(option_or(task.options, "src_divisor", "") == "4",
"sound: src_divisor forwarded");
check(option_or(task.options, "thinking", "") == "true",
"sound: think lands as thinking, the key ace_step reads");
check(option_or(task.options, "think", "") == "true",
"sound: think also forwarded under its own name");
check(option_or(task.options, "caption", "") == "smoky bar recording",
"sound: caption forwarded");
check(option_or(task.options, "lyrics", "") == "first line\nsecond line",
"sound: lyrics forwarded");
check(option_or(task.options, "bpm", "") == "72", "sound: bpm forwarded");
check(option_or(task.options, "keyscale", "") == "E minor",
"sound: keyscale forwarded");
check(option_or(task.options, "timesignature", "") == "4/4",
"sound: timesignature forwarded");
check(option_or(task.options, "instrumental", "") == "true",
"sound: instrumental forwarded");
check(option_or(task.options, "language", "") == "en",
"sound: language forwarded as an option alias");
check(task.text_input->language == "en",
"sound: language reaches the transcript, which is what ace_step reads");
check(task.audio_input.has_value() &&
task.audio_input->sample_rate == 48000 &&
task.audio_input->channels == 2 &&
task.audio_input->samples.size() == 4,
"sound: src passes through at its own rate and channel count");
}
int main() {
test_voice_is_reference_file();
test_tts_plain();
test_tts_named_preset();
test_tts_reference_clip();
test_tts_instructions();
test_tts_empty_instructions_are_not_instructions();
test_tts_language_and_params();
test_sound_generation_minimal();
test_sound_generation_full();
if (failures != 0) {
fprintf(stderr, "%d check(s) failed\n", failures);
return 1;
}
fprintf(stderr, "all checks passed\n");
return 0;
}