Files
LocalAI/backend/cpp/audio-cpp/capability_routing_test.cpp
Ettore Di Giacinto 9b893da80f backend(audio-cpp): use upstream's 'spk' task name and pin the preference order
The SpeakerRecognition short name was 'spkrec', which audio.cpp neither prints
nor parses; a name copied out of audio.cpp was rejected and a pinned 'spkrec'
would not survive the engine boundary. Emit 'spk', keep 'spkrec' as an
input-only alias, and correct the known-tasks lists.

Three assertions were vacuous because their fixtures advertised a single task,
so reversing a preference order or dropping the RPC name and the attempted
pairs from the capability error all passed. Give them fixtures that can tell
the orderings apart.

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

335 lines
14 KiB
C++

// Unit tests for capability_routing. Standard library only. The harness
// compiles this as a single translation unit, so the implementation is
// included directly rather than linked.
#include "capability_routing.cpp"
#include <cstdio>
#include <string>
#include <vector>
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;
// Mirrors what supertonic advertises: TTS offline and streaming.
static Capabilities supertonic() {
return Capabilities{"supertonic",
{{Task::Tts, {Mode::Offline, Mode::Streaming}}}};
}
// Mirrors chatterbox: TTS, cloning and voice conversion, offline only.
static Capabilities chatterbox() {
return Capabilities{"chatterbox",
{{Task::Tts, {Mode::Offline}},
{Task::VoiceCloning, {Mode::Offline}},
{Task::VoiceConversion, {Mode::Offline}}}};
}
// Mirrors nemotron_asr: ASR offline and streaming.
static Capabilities nemotron() {
return Capabilities{"nemotron_asr",
{{Task::Asr, {Mode::Offline, Mode::Streaming}}}};
}
// Mirrors qwen3_asr: ASR offline only.
static Capabilities qwen3_asr() {
return Capabilities{"qwen3_asr", {{Task::Asr, {Mode::Offline}}}};
}
// Mirrors qwen3_forced_aligner: alignment only.
static Capabilities aligner() {
return Capabilities{"qwen3_forced_aligner",
{{Task::Alignment, {Mode::Offline}}}};
}
// Mirrors htdemucs: separation only.
static Capabilities htdemucs() {
return Capabilities{"htdemucs", {{Task::SourceSeparation, {Mode::Offline}}}};
}
static void test_plain_tts() {
const auto r = resolve_route(Rpc::Tts, RequestShape{}, chatterbox());
check(r.ok, "plain TTS routes");
check(r.task == Task::Tts, "plain TTS picks Tts, not VoiceCloning");
check(r.mode == Mode::Offline, "TTS runs offline");
}
static void test_tts_with_voice_reference_prefers_cloning() {
RequestShape shape;
shape.has_voice_reference = true;
const auto r = resolve_route(Rpc::Tts, shape, chatterbox());
check(r.ok, "TTS with a voice reference routes");
check(r.task == Task::VoiceCloning, "voice reference prefers VoiceCloning");
}
// supertonic has no VoiceCloning: a voice reference must fall back to Tts
// rather than failing the request.
static void test_tts_voice_reference_falls_back_to_tts() {
RequestShape shape;
shape.has_voice_reference = true;
const auto r = resolve_route(Rpc::Tts, shape, supertonic());
check(r.ok, "voice reference on a clone-less family still routes");
check(r.task == Task::Tts, "falls back to Tts");
}
static void test_tts_instructions_prefer_voice_design() {
RequestShape shape;
shape.has_instructions = true;
Capabilities caps{"qwen3_tts",
{{Task::Tts, {Mode::Offline}},
{Task::VoiceDesign, {Mode::Offline}}}};
const auto r = resolve_route(Rpc::Tts, shape, caps);
check(r.ok, "TTS with instructions routes");
check(r.task == Task::VoiceDesign, "instructions prefer VoiceDesign");
}
// A voice reference is a stronger signal than free-form instructions: cloning
// a specific voice is what the user asked for.
static void test_voice_reference_beats_instructions() {
RequestShape shape;
shape.has_voice_reference = true;
shape.has_instructions = true;
Capabilities caps{"omnivoice",
{{Task::Tts, {Mode::Offline}},
{Task::VoiceCloning, {Mode::Offline}},
{Task::VoiceDesign, {Mode::Offline}}}};
const auto r = resolve_route(Rpc::Tts, shape, caps);
check(r.ok, "both signals present routes");
check(r.task == Task::VoiceCloning, "voice reference outranks instructions");
}
static void test_tts_stream_requires_streaming() {
const auto ok = resolve_route(Rpc::TtsStream, RequestShape{}, supertonic());
check(ok.ok, "streaming TTS routes on supertonic");
check(ok.mode == Mode::Streaming, "TTSStream runs in streaming mode");
const auto bad = resolve_route(Rpc::TtsStream, RequestShape{}, chatterbox());
check(!bad.ok, "streaming TTS is refused on an offline-only family");
check(bad.error.find("chatterbox") != std::string::npos,
"error names the family");
check(bad.error.find("tts/offline") != std::string::npos,
"error lists what the family does support");
check(bad.error.find("TTSStream") != std::string::npos,
"error names the RPC that was refused");
check(bad.error.find("tts/streaming") != std::string::npos,
"error lists the (task, mode) pairs that were tried");
}
static void test_transcription_stream_falls_back_to_offline() {
const auto streaming =
resolve_route(Rpc::AudioTranscriptionStream, RequestShape{}, nemotron());
check(streaming.ok && streaming.mode == Mode::Streaming,
"streaming ASR uses streaming mode when offered");
const auto offline =
resolve_route(Rpc::AudioTranscriptionStream, RequestShape{}, qwen3_asr());
check(offline.ok, "streaming ASR falls back on an offline-only family");
check(offline.mode == Mode::Offline, "fallback mode is offline");
check(offline.task == Task::Asr, "fallback task is still Asr");
}
// Task preference dominates mode preference: it is better to run the right
// task in a fallback mode than the wrong task in the preferred mode. This is
// the only RPC where the two orderings can disagree, because it is the only
// one with more than one acceptable mode.
static void test_task_preference_beats_mode_preference() {
RequestShape shape;
shape.has_prompt_text = true;
Capabilities mixed{"mixed_asr_aligner",
{{Task::Asr, {Mode::Offline}},
{Task::Alignment, {Mode::Streaming}}}};
const auto r = resolve_route(Rpc::AudioTranscriptionStream, shape, mixed);
check(r.ok, "mixed family routes");
check(r.task == Task::Asr,
"the preferred task wins even in its fallback mode");
check(r.mode == Mode::Offline,
"the fallback mode is accepted to keep the preferred task");
}
// Live transcription is bidirectional and cannot be faked from an offline run.
static void test_live_transcription_has_no_offline_fallback() {
const auto r =
resolve_route(Rpc::AudioTranscriptionLive, RequestShape{}, qwen3_asr());
check(!r.ok, "live transcription is refused on an offline-only family");
}
static void test_alignment_needs_prompt_text() {
const auto without =
resolve_route(Rpc::AudioTranscription, RequestShape{}, aligner());
check(!without.ok, "aligner without a transcript is refused");
RequestShape shape;
shape.has_prompt_text = true;
const auto with = resolve_route(Rpc::AudioTranscription, shape, aligner());
check(with.ok, "aligner with a transcript routes");
check(with.task == Task::Alignment, "routes to Alignment");
}
// A real ASR family must not be hijacked to Alignment just because the caller
// passed a prompt: `prompt` is also whisper-style decoding context.
static void test_prompt_does_not_hijack_asr() {
RequestShape shape;
shape.has_prompt_text = true;
const auto r = resolve_route(Rpc::AudioTranscription, shape, nemotron());
check(r.ok, "ASR with a prompt routes");
// nemotron advertises Asr alone, so the assertion has to be made against a
// family that advertises both: otherwise "Asr is preferred" only restates
// that Asr is the only option, and reversing the preference order passes.
Capabilities both{"asr_with_aligner",
{{Task::Asr, {Mode::Offline}},
{Task::Alignment, {Mode::Offline}}}};
const auto pref = resolve_route(Rpc::AudioTranscription, shape, both);
check(pref.ok, "a family offering both routes");
check(pref.task == Task::Asr, "Asr is preferred over Alignment");
}
static void test_audio_transform_prefers_separation() {
const auto sep =
resolve_route(Rpc::AudioTransform, RequestShape{}, htdemucs());
check(sep.ok && sep.task == Task::SourceSeparation, "separation routes");
// htdemucs advertises separation alone, so the check above cannot fail on
// ordering. This family advertises both, which is what pins the preference.
Capabilities sep_and_vc{"sep_and_vc",
{{Task::SourceSeparation, {Mode::Offline}},
{Task::VoiceConversion, {Mode::Offline}}}};
const auto pref =
resolve_route(Rpc::AudioTransform, RequestShape{}, sep_and_vc);
check(pref.ok && pref.task == Task::SourceSeparation,
"separation is preferred over voice conversion");
Capabilities miocodec{"miocodec",
{{Task::VoiceConversion, {Mode::Offline}},
{Task::SpeechToSpeech, {Mode::Offline}}}};
const auto vc = resolve_route(Rpc::AudioTransform, RequestShape{}, miocodec);
check(vc.ok && vc.task == Task::VoiceConversion,
"voice conversion is preferred over speech-to-speech");
}
static void test_pinned_task_overrides_routing() {
RequestShape shape;
shape.pinned_task = "s2s";
Capabilities miocodec{"miocodec",
{{Task::VoiceConversion, {Mode::Offline}},
{Task::SpeechToSpeech, {Mode::Offline}}}};
const auto r = resolve_route(Rpc::AudioTransform, shape, miocodec);
check(r.ok && r.task == Task::SpeechToSpeech, "pinned task wins");
RequestShape bad;
bad.pinned_task = "not-a-task";
const auto e = resolve_route(Rpc::AudioTransform, bad, miocodec);
check(!e.ok, "an unknown pinned task is an error");
check(e.error.find("not-a-task") != std::string::npos,
"error names the bad task");
// A pinned task the family does not offer must fail, not silently reroute.
RequestShape unsupported;
unsupported.pinned_task = "sep";
const auto u = resolve_route(Rpc::AudioTransform, unsupported, miocodec);
check(!u.ok, "a pinned but unsupported task is refused");
}
static void test_vad_and_diarize() {
Capabilities silero{"silero_vad", {{Task::Vad, {Mode::Offline, Mode::Streaming}}}};
const auto v = resolve_route(Rpc::Vad, RequestShape{}, silero);
check(v.ok && v.task == Task::Vad && v.mode == Mode::Offline, "VAD routes offline");
const auto d = resolve_route(Rpc::Diarize, RequestShape{}, silero);
check(!d.ok, "diarization is refused on a VAD-only family");
Capabilities sortformer{"sortformer_diar", {{Task::Diarization, {Mode::Offline}}}};
const auto ok = resolve_route(Rpc::Diarize, RequestShape{}, sortformer);
check(ok.ok && ok.task == Task::Diarization, "diarization routes");
}
static void test_sound_generation() {
Capabilities stable{"stable_audio", {{Task::AudioGeneration, {Mode::Offline}}}};
const auto r = resolve_route(Rpc::SoundGeneration, RequestShape{}, stable);
check(r.ok && r.task == Task::AudioGeneration, "sound generation routes");
}
static void test_names_round_trip() {
const Task all[] = {Task::Vad, Task::Asr, Task::Diarization,
Task::SourceSeparation, Task::AudioGeneration, Task::Tts,
Task::VoiceCloning, Task::VoiceConversion,
Task::SpeechToSpeech, Task::Alignment, Task::VoiceDesign,
Task::SpeakerRecognition, Task::Svc};
for (const Task t : all) {
Task parsed = Task::Vad;
const bool ok = parse_task_name(task_name(t), parsed);
check(ok && parsed == t,
std::string("task name round-trips: ") + task_name(t));
}
check(std::string(mode_name(Mode::Offline)) == "offline", "offline name");
check(std::string(mode_name(Mode::Streaming)) == "streaming", "streaming name");
// The emitted name must be the one audio.cpp itself prints and parses
// (framework/runtime/session.cpp), because `task:` is user-facing: a name
// copied out of audio.cpp has to be accepted here, and a name pinned here
// has to survive conversion at the engine boundary.
check(std::string(task_name(Task::SpeakerRecognition)) == "spk",
"speaker recognition emits upstream's name 'spk'");
Task pinned = Task::Vad;
check(parse_task_name("spk", pinned) && pinned == Task::SpeakerRecognition,
"'spk' parses to SpeakerRecognition");
// Accepted as a legacy alias so configs written against the earlier name
// keep working, but never emitted.
Task alias = Task::Vad;
check(parse_task_name("spkrec", alias) && alias == Task::SpeakerRecognition,
"'spkrec' is still accepted as an alias");
}
static void test_describe_capabilities() {
const std::string described = describe_capabilities(nemotron());
check(described.find("asr/offline") != std::string::npos,
"description lists asr/offline");
check(described.find("asr/streaming") != std::string::npos,
"description lists asr/streaming");
}
static void test_empty_capabilities() {
const auto r = resolve_route(Rpc::Tts, RequestShape{}, Capabilities{"mystery", {}});
check(!r.ok, "a family advertising nothing is refused");
check(r.error.find("mystery") != std::string::npos, "error names the family");
}
int main() {
test_plain_tts();
test_tts_with_voice_reference_prefers_cloning();
test_tts_voice_reference_falls_back_to_tts();
test_tts_instructions_prefer_voice_design();
test_voice_reference_beats_instructions();
test_tts_stream_requires_streaming();
test_transcription_stream_falls_back_to_offline();
test_task_preference_beats_mode_preference();
test_live_transcription_has_no_offline_fallback();
test_alignment_needs_prompt_text();
test_prompt_does_not_hijack_asr();
test_audio_transform_prefers_separation();
test_pinned_task_overrides_routing();
test_vad_and_diarize();
test_sound_generation();
test_names_round_trip();
test_describe_capabilities();
test_empty_capabilities();
if (failures) {
fprintf(stderr, "%d check(s) failed\n", failures);
return 1;
}
fprintf(stderr, "all capability_routing checks passed\n");
return 0;
}