mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-31 18:38:23 -04:00
StyleCondition::language was set whenever has_language() was true, with no !empty() guard, while the language option twelve lines below had one. core/backend/tts.go sets Language unconditionally, so has_language() is true on every request LocalAI sends and carries "" when the caller named none. An engaged-but-empty style language is worse than an absent one: supertonic reads text_input->language behind its own !empty() guard and then overrides it from style->language with no guard at all, so "" replaced its "en" default and its tokenizer threw "invalid Supertonic language: ". Every /v1/audio/speech request that set instructions and no language would have been an INTERNAL against a supertonic model. A plain request never saw it, because the style condition only exists when instructions are non-empty, which is why the chatterbox end to end run did not catch it. TTS also stops discarding the Route that check_can_serve already returns. A family routed to voice cloning without a reference clip used to be refused from inside its own prepare(), which meant an INTERNAL naming neither the RPC nor the field to set; chatterbox advertises clon and no tts, so that was every preset-only request to it. It is now an INVALID_ARGUMENT naming TTSRequest.voice, answered in about 4 ms, and it cannot misfire because has_voice_reference is what selected cloning in the first place. Reading CapabilitySet::supports_speaker_reference to generalise this stays a follow-up. The src read carries a written caveat rather than a family blocklist, because ace_step's editing routes legitimately need src: setting src on a stable_audio model corrupts the heap and aborts the process in the pinned upstream, and the only thing keeping that off the network is that schema.ElevenLabsSoundGenerationRequest has no field for it. Nobody reading that Go schema would know why, so the reason is recorded where the field is read. build_tts_shape is extracted so TTSStream cannot describe the same request differently, and it arrived untested: two mutations of it survived until a test_tts_shape case was added. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
468 lines
21 KiB
C++
468 lines
21 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);
|
|
}
|
|
|
|
// build_tts_shape is what TTS and TTSStream both hand to routing, so a wrong
|
|
// answer here silently changes which task a request runs as, with a 200 and no
|
|
// diagnostic. Every field is asserted in both directions.
|
|
static void test_tts_shape() {
|
|
const auto dir = std::filesystem::temp_directory_path() /
|
|
"audiocpp-generation-request-ctest-shape";
|
|
std::filesystem::remove_all(dir);
|
|
std::filesystem::create_directories(dir);
|
|
const auto file = dir / "reference.wav";
|
|
{
|
|
std::ofstream out(file, std::ios::binary);
|
|
out << "a regular file";
|
|
}
|
|
|
|
{
|
|
backend::TTSRequest request;
|
|
request.set_text("hello");
|
|
const auto shape = build_tts_shape(request);
|
|
check(!shape.has_voice_reference && !shape.has_instructions,
|
|
"shape: bare request has neither signal");
|
|
// Never filled here: it comes off the LoadedModel, and leaving it empty
|
|
// is what makes the caller's assignment visible at the call site.
|
|
check(shape.pinned_task.empty(), "shape: pinned_task is left to the caller");
|
|
}
|
|
{
|
|
backend::TTSRequest request;
|
|
request.set_voice(file.string());
|
|
const auto shape = build_tts_shape(request);
|
|
check(shape.has_voice_reference,
|
|
"shape: an existing file is a voice reference");
|
|
check(!shape.has_instructions, "shape: a clip is not an instruction");
|
|
}
|
|
{
|
|
backend::TTSRequest request;
|
|
request.set_voice("alloy");
|
|
const auto shape = build_tts_shape(request);
|
|
check(!shape.has_voice_reference,
|
|
"shape: a preset name is not a voice reference");
|
|
}
|
|
{
|
|
backend::TTSRequest request;
|
|
request.set_voice(dir.string());
|
|
const auto shape = build_tts_shape(request);
|
|
check(!shape.has_voice_reference,
|
|
"shape: a directory is not a voice reference");
|
|
}
|
|
{
|
|
backend::TTSRequest request;
|
|
request.set_instructions("a calm older man");
|
|
const auto shape = build_tts_shape(request);
|
|
check(shape.has_instructions, "shape: instructions are seen");
|
|
check(!shape.has_voice_reference,
|
|
"shape: instructions do not imply a reference");
|
|
}
|
|
{
|
|
// The guard that has to match build_tts_request's. An empty
|
|
// instructions string builds no style condition, so telling routing to
|
|
// prefer VoiceDesign for it would route to a task with nothing to
|
|
// design from.
|
|
backend::TTSRequest request;
|
|
request.set_instructions("");
|
|
const auto shape = build_tts_shape(request);
|
|
check(!shape.has_instructions,
|
|
"shape: an empty instructions string is not an instruction");
|
|
}
|
|
{
|
|
backend::TTSRequest request;
|
|
request.set_voice(file.string());
|
|
request.set_instructions("a calm older man");
|
|
const auto shape = build_tts_shape(request);
|
|
check(shape.has_voice_reference && shape.has_instructions,
|
|
"shape: both signals are reported when both are set");
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
// THE EXACT SHAPE LocalAI PUTS ON THE WIRE. core/backend/tts.go's newTTSRequest
|
|
// sets Language: &language UNCONDITIONALLY, so has_language() is true on every
|
|
// request that ever reaches this backend, carrying an empty string whenever the
|
|
// caller named no language.
|
|
//
|
|
// An empty StyleCondition::language is not a harmless default. supertonic reads
|
|
// text_input->language behind a !empty() guard and then OVERRIDES it from
|
|
// style->language whenever that optional is engaged, with no guard at all
|
|
// (supertonic/session.cpp generation_options_from_request), so an empty style
|
|
// language replaces its "en" default (session.h) with "" and
|
|
// tokenizer_text.cpp's preprocess throws "invalid Supertonic language: ".
|
|
// Every /v1/audio/speech request carrying instructions and no language would be
|
|
// an INTERNAL. A plain request never sees it, because the style condition only
|
|
// exists when instructions are non-empty.
|
|
static void test_tts_empty_language_is_not_a_language() {
|
|
backend::TTSRequest request;
|
|
request.set_text("hello");
|
|
request.set_instructions("a calm older man");
|
|
request.set_language("");
|
|
|
|
const auto task = build_tts_request(request, std::nullopt);
|
|
|
|
check(task.voice.has_value() && task.voice->style.has_value(),
|
|
"tts empty language: the style condition still exists");
|
|
check(!task.voice->style->language.has_value(),
|
|
"tts empty language: style language is left unset, not set to empty");
|
|
// The option emission has always guarded on !empty(); this pins the two to
|
|
// the same rule so they cannot drift apart again.
|
|
check(!has_key(task.options, "language"),
|
|
"tts empty language: no language option");
|
|
check(task.text_input.has_value() && task.text_input->language.empty(),
|
|
"tts empty language: transcript language stays empty");
|
|
}
|
|
|
|
// The one shape routing treats specially: a clip outranks instructions, and the
|
|
// VoiceCondition then has to carry BOTH, because the family that wins is chosen
|
|
// on the clip but may still read the style tag.
|
|
static void test_tts_clip_and_instructions() {
|
|
backend::TTSRequest request;
|
|
request.set_text("hello");
|
|
request.set_voice("/tmp/reference.wav");
|
|
request.set_instructions("bright and fast");
|
|
request.set_language("en");
|
|
|
|
const auto task = build_tts_request(request, clip(22050, 1));
|
|
|
|
check(task.voice.has_value(), "tts clip+instructions: voice condition present");
|
|
check(task.voice->speaker.has_value() &&
|
|
task.voice->speaker->audio.has_value() &&
|
|
task.voice->speaker->audio->sample_rate == 22050,
|
|
"tts clip+instructions: speaker carries the clip");
|
|
check(!task.voice->speaker->cached_voice_id.has_value(),
|
|
"tts clip+instructions: the clip path is not also a preset id");
|
|
check(task.voice->style.has_value() &&
|
|
option_or(task.voice->style->tags, "instruct", "") == "bright and fast",
|
|
"tts clip+instructions: style carries the instruct tag");
|
|
check(task.voice->style->language.has_value() &&
|
|
*task.voice->style->language == "en",
|
|
"tts clip+instructions: a real language does reach the style condition");
|
|
check(option_or(task.options, "instruct", "") == "bright and fast",
|
|
"tts clip+instructions: instruct option still emitted");
|
|
check(!has_key(task.options, "voice"),
|
|
"tts clip+instructions: still no voice option for a clip");
|
|
}
|
|
|
|
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_shape();
|
|
test_tts_plain();
|
|
test_tts_named_preset();
|
|
test_tts_reference_clip();
|
|
test_tts_instructions();
|
|
test_tts_empty_instructions_are_not_instructions();
|
|
test_tts_empty_language_is_not_a_language();
|
|
test_tts_clip_and_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;
|
|
}
|