From 6170e03f1b69f7f215a0323015e3e74ab9b97408 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sun, 26 Jul 2026 17:31:01 +0000 Subject: [PATCH] backend(audio-cpp): serve the AudioTransform RPC Covers voice conversion, singing voice conversion, speech to speech and source separation, the four tasks LocalAI's AudioTransform can represent. AudioTransformResult carries one dst while htdemucs and mel_band_roformer produce several named stems from a single run, so inference runs ONCE, every stem is written as a sibling file .., and params[stem] selects which one dst receives, defaulting to vocals and falling back to the first output. An unknown stem name is INVALID_ARGUMENT listing the real stem names rather than a silent substitution, and the selection happens before the first write so a refused request leaves no files behind. params[stem] is consumed here and is not forwarded into the engine's request options. The stem decision lives in stem_selection, which is stdlib only and therefore tested by backend/cpp/run-unit-tests.sh. It also validates the names, because they come from the model (htdemucs reads them from the GGUF's config.sources) and each becomes a component of a path this backend writes: a name carrying a path separator would escape the caller's output directory, and two stems sharing a name would silently overwrite one another. Both files are read at their native rate and channel count. Separation forces it, since demucs and roformer refuse any rate but 44.1 kHz and lose the stereo image that separates a centred vocal from a wide mix. The conversion families all resample internally (seed_vc, vevo2, miocodec, chatterbox were each checked), so passing the file through unchanged is also strictly better than band limiting it to 16 kHz first. Verified end to end against htdemucs f16 on a 44.1 kHz stereo mix: four stems plus dst, dst byte identical to the selected stem, params[stem] selecting a different one, an unknown stem refused with no files written, and mono input preserved as mono output. Also against miocodec for the single output path, where params[stem] is refused rather than ignored. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto --- backend/cpp/audio-cpp/CMakeLists.txt | 1 + backend/cpp/audio-cpp/grpc-server.cpp | 202 ++++++++++++++++- backend/cpp/audio-cpp/stem_selection.cpp | 113 ++++++++++ backend/cpp/audio-cpp/stem_selection.h | 63 ++++++ backend/cpp/audio-cpp/stem_selection_test.cpp | 213 ++++++++++++++++++ 5 files changed, 590 insertions(+), 2 deletions(-) create mode 100644 backend/cpp/audio-cpp/stem_selection.cpp create mode 100644 backend/cpp/audio-cpp/stem_selection.h create mode 100644 backend/cpp/audio-cpp/stem_selection_test.cpp diff --git a/backend/cpp/audio-cpp/CMakeLists.txt b/backend/cpp/audio-cpp/CMakeLists.txt index d5da508a0..b4ecb28b4 100644 --- a/backend/cpp/audio-cpp/CMakeLists.txt +++ b/backend/cpp/audio-cpp/CMakeLists.txt @@ -130,6 +130,7 @@ add_executable(${TARGET} audio_units.cpp transcript_assembly.cpp result_map.cpp + stem_selection.cpp inference_lane.cpp ) diff --git a/backend/cpp/audio-cpp/grpc-server.cpp b/backend/cpp/audio-cpp/grpc-server.cpp index 6133a3127..8daefeeee 100644 --- a/backend/cpp/audio-cpp/grpc-server.cpp +++ b/backend/cpp/audio-cpp/grpc-server.cpp @@ -5,8 +5,9 @@ // src/ or tests/ is used: those are application internals, they are where // upstream expects churn, and upstream is Apache-2.0 while LocalAI is MIT. // -// This commit adds LoadModel/Free/Status plus the AudioTranscription, VAD and -// Diarize RPCs. The remaining audio RPCs land in later commits. +// This commit adds LoadModel/Free/Status plus the AudioTranscription, VAD, +// Diarize and AudioTransform RPCs. The remaining audio RPCs land in later +// commits. #include "backend.pb.h" #include "backend.grpc.pb.h" @@ -18,6 +19,7 @@ #include "loaded_model.h" #include "model_options.h" #include "result_map.h" +#include "stem_selection.h" #include #include @@ -123,6 +125,51 @@ constexpr int kVadSampleRate = 16000; // rate, this constant is what should become that lookup. constexpr int kSpeechSampleRate = 16000; +// The rate AudioTransform reads BOTH its input and its reference clip at. Zero +// means "the file's own rate and its own channel count", i.e. this handler does +// no resampling and no downmix at all, and it is the only value that is correct +// for all four tasks this RPC can route to. +// +// Separation forces it. htdemucs and mel_band_roformer refuse anything but +// their own rate outright, from prepare(): "HTDemucs prepare() sample rate +// mismatch: expected 44100" (src/models/demucs/session.cpp) and the same shape +// in src/models/roformer/session.cpp. Passing kSpeechSampleRate here would +// therefore turn every separation request into an INTERNAL, which is a loud +// failure. The downmix half is the quiet one: both models declare two channels +// and ACCEPT mono, by duplicating it across both, so a mono read would be taken +// and would merely delete the stereo image, which is the cue that separates a +// centred vocal from a wide mix. Verified rather than reasoned: a mono input +// comes back as mono stems, a stereo input as stereo ones. +// +// The three conversion tasks do not force it, and were checked one family at a +// time rather than assumed, because "it happens to work" and "it is documented +// to work" are different claims: +// +// seed_vc (vc, svc) seed_vc_prepare_audio_for_sample_rate takes the +// buffer's rate and channel count and resamples to its own mel +// rate with resample_mono_torchaudio_sinc_hann. +// vevo2 (vc, s2s, svc) normalize_audio_to_24k_mono converts whatever +// it is given to 24 kHz mono before anything else runs. +// miocodec (vc, s2s) prepare_miocodec_mono_audio mixes down, then +// resamples to the model's rate, again with sinc-hann. +// chatterbox (vc) ChatterboxVcComponent::convert normalizes the source to +// 16 kHz and the reference to 24 kHz itself. +// +// So every one of them resamples internally, and passing the file through +// unchanged is not merely tolerated but strictly better: their outputs run at +// 22.05 to 44.1 kHz, and pre-folding the input to 16 kHz mono with +// read_audio_file's LINEAR resampler would band-limit at 8 kHz and alias, then +// hand the family a signal it would upsample again. Two of the four use a +// windowed-sinc resampler, which is exactly the quality this constant would +// throw away before they ever saw the samples. +// +// The cost, stated plainly: a family that cannot handle the file's rate reports +// it from inside the engine as a plain runtime_error, which to_status maps to +// INTERNAL rather than INVALID_ARGUMENT. That is the accepted trade, since the +// only families that refuse are the separators and their requirement is +// 44.1 kHz stereo, i.e. an ordinary music file. +constexpr int kTransformSampleRate = 0; + // Parses ModelOptions.MainGPU into a device index. // // Not std::atoi: it returns 0 for anything unparseable, so "gpu1" or a device @@ -713,6 +760,157 @@ public: return to_status(err); } } + + // Serves the four tasks LocalAI's AudioTransform can represent: voice + // conversion, singing voice conversion, speech to speech and source + // separation. Which one a request becomes is routing's decision, not this + // handler's; see task_candidates for Rpc::AudioTransform, and note that svc + // is reachable only through an explicit task: pin because no request signal + // means "this input is singing". + // + // THE STEM COMPROMISE. AudioTransformResult carries a single dst, while + // htdemucs and mel_band_roformer produce four and two named stems from one + // run. Running inference once per stem would cost four full separations of + // the same file, so this runs ONCE, writes every stem to a sibling file + // .., and puts the selected one in dst. Those siblings + // are real files in the caller's output directory that LocalAI's caller + // does not know about and will not clean up: a documented trade, not an + // oversight, and the reason params["stem"] exists to say which one dst gets. + GStatus AudioTransform(ServerContext *, + const backend::AudioTransformRequest *request, + backend::AudioTransformResult *response) override { + try { + GStatus refusal = GStatus::OK; + const auto model = snapshot_for(request, refusal); + if (model == nullptr) { + return refusal; + } + + const bool has_reference = !request->reference_path().empty(); + audiocpp_backend::RequestShape shape; + // Unused by AudioTransform's own routing today, since none of its + // four candidate tasks is chosen by the presence of a reference + // clip. Set anyway, because leaving a shape field stale is how a + // later routing rule silently reads the wrong thing. + shape.has_voice_reference = has_reference; + shape.pinned_task = model->pinned_task(); + + // First, before the lane, before the file reads, and before the + // argument check below. A family that cannot transform at all is + // answering a question about ITSELF, so it must not first queue + // behind somebody else's thirty second run, and it must not blame + // the caller's paths for a decision that had nothing to do with + // them. Same ordering as Diarize and AudioTranscription. + model->check_can_serve(audiocpp_backend::Rpc::AudioTransform, shape); + + if (request->audio_path().empty() || request->dst().empty()) { + throw audiocpp_backend::ConfigError( + "audio-cpp: AudioTransform needs both audio_path and dst"); + } + + engine::runtime::TaskRequest task; + std::string requested_stem; + for (const auto ¶m : request->params()) { + if (param.first == "stem") { + // CONSUMED here and deliberately NOT forwarded into + // task.options: it selects which output the caller + // receives, it does not tune the model. Forwarding it would + // put a key no family reads into every request and imply + // the model had been asked to produce only that stem. + requested_stem = param.second; + continue; + } + task.options[param.first] = param.second; + } + + // Native rate, native channels, for both files. See + // kTransformSampleRate: separation is destroyed by a downmix and + // every conversion family resamples internally anyway. + task.audio_input = audiocpp_backend::read_audio_file( + request->audio_path(), kTransformSampleRate); + if (has_reference) { + engine::runtime::VoiceReference reference; + reference.audio = audiocpp_backend::read_audio_file( + request->reference_path(), kTransformSampleRate); + engine::runtime::VoiceCondition condition; + condition.speaker = std::move(reference); + task.voice = std::move(condition); + } + + // Named local: LaneEntry is immovable and must span both + // session_for and run_offline, which is the constraint the proof of + // holding parameter exists to enforce. + audiocpp_backend::LaneEntry lane = model->acquire(0); + const auto session = + model->session_for(audiocpp_backend::Rpc::AudioTransform, shape, lane); + const auto result = audiocpp_backend::run_offline(session, task, lane); + + const engine::runtime::AudioBuffer *chosen = nullptr; + if (!result.named_audio_outputs.empty()) { + std::vector names; + names.reserve(result.named_audio_outputs.size()); + for (const auto &named : result.named_audio_outputs) { + names.push_back(named.id); + } + // Selected BEFORE the first write, not after the loop. An + // unknown stem name is a refused request, and a refused request + // must not leave four files in the caller's output directory + // that it then reports nothing about. + const auto choice = + audiocpp_backend::select_named_output(names, requested_stem); + if (!choice.error.empty()) { + throw audiocpp_backend::ConfigError(choice.error); + } + + for (size_t i = 0; i < result.named_audio_outputs.size(); ++i) { + audiocpp_backend::write_audio_file( + audiocpp_backend::sibling_stem_path(request->dst(), names[i]), + result.named_audio_outputs[i].audio); + } + chosen = &result.named_audio_outputs[static_cast(choice.index)] + .audio; + // dst last, so it is the file that exists only once every stem + // beside it does. Its content duplicates the selected sibling + // on purpose: the caller reads dst, an operator reads the + // siblings, and neither should have to know about the other. + audiocpp_backend::write_audio_file(request->dst(), *chosen); + } else if (result.audio_output.has_value()) { + if (!requested_stem.empty()) { + // A conversion family produces one unnamed output, so there + // is no stem to choose. Refused rather than ignored: a + // caller who asked for "vocals" and received the whole + // converted signal has been answered with something else. + throw audiocpp_backend::ConfigError( + "audio-cpp: family '" + model->family() + + "' produces a single output with no named stems, so " + "params[stem]='" + requested_stem + "' cannot be honoured"); + } + chosen = &*result.audio_output; + audiocpp_backend::write_audio_file(request->dst(), *chosen); + } else { + // Reached only if a family routed successfully and then + // returned no audio at all, which is a broken family rather + // than a wrong request. CapabilityError so it reads as "this + // model does not do that" instead of as an internal fault. + throw audiocpp_backend::CapabilityError( + "audio-cpp: family '" + model->family() + + "' produced no audio for the AudioTransform RPC"); + } + + response->set_dst(request->dst()); + response->set_sample_rate(chosen->sample_rate); + // FRAMES, not floats. Separation output is stereo, so reporting + // samples.size() would tell the caller a 3 second stem is 6 seconds + // long. + response->set_samples(static_cast( + audiocpp_backend::interleaved_frame_count(chosen->samples.size(), + chosen->channels))); + response->set_reference_provided(has_reference); + return GStatus::OK; + } catch (const std::exception &err) { + return to_status(err); + } + } }; void RunServer(const std::string &addr) { diff --git a/backend/cpp/audio-cpp/stem_selection.cpp b/backend/cpp/audio-cpp/stem_selection.cpp new file mode 100644 index 000000000..11520b517 --- /dev/null +++ b/backend/cpp/audio-cpp/stem_selection.cpp @@ -0,0 +1,113 @@ +#include "stem_selection.h" + +#include +#include +#include + +namespace audiocpp_backend { +namespace { + +// The stem every separation family this backend can reach names its lead vocal +// track, and the one a caller who names no stem almost always wants: it is what +// the OpenAI-shaped "isolate the voice" request means. htdemucs (drums, bass, +// other, vocals) and mel_band_roformer (vocals, instrumental) both have it, and +// in htdemucs's case it is NOT the first output, which is the whole reason this +// preference is written down rather than left as "take index 0". +const char *const kPreferredStem = "vocals"; + +std::string join_names(const std::vector &names) { + std::string out; + for (const auto &name : names) { + if (!out.empty()) { + out += ", "; + } + out += name; + } + return out; +} + +// Whether a model-supplied stem name can be used as one component of a file +// name. Deliberately a whitelist of refusals rather than a sanitiser: silently +// rewriting "vo/cals" to "cals" would make the file the caller receives +// disagree with the name they would have to ask for. +bool name_is_writable(const std::string &name) { + if (name.empty() || name == "." || name == "..") { + return false; + } + // Both separators, not just the host's. A GGUF is a downloaded file and its + // strings are not this host's to trust, so a name written on Windows must + // not become a directory traversal wherever the check happens to run. + return name.find('/') == std::string::npos && + name.find('\\') == std::string::npos; +} + +} // namespace + +StemChoice select_named_output(const std::vector &names, + const std::string &requested) { + StemChoice choice; + if (names.empty()) { + // Not an error here. The caller distinguishes "this family produces one + // unnamed output" from "this family produced nothing", and only it can + // tell them apart. + return choice; + } + + // Every name is checked, not merely the selected one, because every stem is + // written. A bad name in the fourth output would otherwise be discovered + // only after three files had already been created. + for (std::size_t i = 0; i < names.size(); ++i) { + if (!name_is_writable(names[i])) { + choice.error = "audio-cpp: this model names an output stem '" + + names[i] + + "' that cannot be used as a file name; stems: " + + join_names(names); + return choice; + } + for (std::size_t seen = 0; seen < i; ++seen) { + if (names[seen] == names[i]) { + choice.error = + "audio-cpp: this model produces two output stems both named '" + + names[i] + "'; one would silently overwrite the other"; + return choice; + } + } + } + + if (!requested.empty()) { + for (std::size_t i = 0; i < names.size(); ++i) { + if (names[i] == requested) { + choice.index = static_cast(i); + return choice; + } + } + choice.error = "audio-cpp: no stem named '" + requested + + "' in this model's output; available stems: " + + join_names(names); + return choice; + } + + for (std::size_t i = 0; i < names.size(); ++i) { + if (names[i] == kPreferredStem) { + choice.index = static_cast(i); + return choice; + } + } + choice.index = 0; + return choice; +} + +std::string sibling_stem_path(const std::string &dst, const std::string &name) { + const std::filesystem::path path(dst); + // The dst extension is reused rather than forced to ".wav" so the siblings + // look like the file the caller named. write_audio_file writes WAV bytes + // whatever the extension says, for dst as much as for the siblings, so this + // keeps the set consistent instead of making the siblings honest about a + // format dst is already lying about. + const std::string extension = + path.has_extension() ? path.extension().string() : std::string(".wav"); + return (path.parent_path() / (path.stem().string() + "." + name + extension)) + .string(); +} + +} // namespace audiocpp_backend diff --git a/backend/cpp/audio-cpp/stem_selection.h b/backend/cpp/audio-cpp/stem_selection.h new file mode 100644 index 000000000..f889639c5 --- /dev/null +++ b/backend/cpp/audio-cpp/stem_selection.h @@ -0,0 +1,63 @@ +#pragma once + +// Decides which of a separation model's named stems the AudioTransform response +// carries in `dst`, and names the sibling files every other stem is written to. +// +// It exists because AudioTransformResult carries ONE dst while htdemucs and +// mel_band_roformer produce several named outputs from a single run. Running +// once per stem would cost four full inferences for a four stem model, so the +// handler runs once, writes every stem beside dst, and puts the selected one in +// dst itself. +// +// Standard library only, so backend/cpp/run-unit-tests.sh compiles and runs its +// test without an audio.cpp checkout. grpc-server.cpp flattens the engine's +// NamedAudioBuffer list into the plain name vector taken here; nothing in this +// unit knows about engine::runtime. + +#include +#include + +namespace audiocpp_backend { + +struct StemChoice { + // Index into the `names` vector. -1 means nothing was chosen, which happens + // for an empty list (the family produced a single unnamed output) and for + // every refusal. + // + // THE CONTRACT THE CALLER INDEXES ON: when `error` is empty and `names` was + // not, this is always a valid index into `names`. It is never -1 in that + // case, so a caller that checks `error` first can index without a further + // guard, and a caller that does not check `error` first would index with a + // negative value. Check the error. + int index = -1; + // Non-empty when the request must be refused, and suitable verbatim as an + // INVALID_ARGUMENT message. Two things land here: the caller named a stem + // this model does not produce, and the model named stems that cannot both + // be written (an unusable file name, or two stems sharing one). + std::string error; +}; + +// Picks the stem that goes to dst. Preference order: an explicit `requested`, +// then "vocals", then the first output. +// +// An explicit but unknown `requested` is an ERROR rather than a fallback. A +// caller who asks for "drums" and silently receives "vocals" gets a 200 and a +// wrong file, which is the failure mode nobody can see; the message therefore +// lists the stem names this model really has. +// +// The names are also validated, because they come from the MODEL (htdemucs +// reads them from the GGUF's config.sources) and each one becomes a component +// of a file path this backend writes. A name carrying a path separator would +// write outside the caller's output directory, and two stems sharing a name +// would silently overwrite each other. Both are refused before anything is +// written, which is also why selection has to happen before the first write +// rather than after the loop: a refused request must leave no files behind. +StemChoice select_named_output(const std::vector &names, + const std::string &requested); + +// "/generated/transform-1.wav" + "drums" -> "/generated/transform-1.drums.wav". +// A dst with no extension gets ".wav", since that is what write_audio_file +// produces whatever the caller called the file. +std::string sibling_stem_path(const std::string &dst, const std::string &name); + +} // namespace audiocpp_backend diff --git a/backend/cpp/audio-cpp/stem_selection_test.cpp b/backend/cpp/audio-cpp/stem_selection_test.cpp new file mode 100644 index 000000000..6a857d5d8 --- /dev/null +++ b/backend/cpp/audio-cpp/stem_selection_test.cpp @@ -0,0 +1,213 @@ +// Unit tests for stem_selection. Standard library only. The harness +// (backend/cpp/run-unit-tests.sh) compiles this as a single translation unit, +// so the implementation is included directly. +// +// What is actually at stake here: AudioTransformResult carries one dst, a +// separation model produces several stems, and the caller cannot see which one +// they got. Every check below is about a wrong file arriving with a 200. + +#include "stem_selection.cpp" + +#include +#include +#include + +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()); + } +} + +static void check_equal(const std::string &got, const std::string &want, + const std::string &name) { + check(got == want, name + " (got \"" + got + "\", want \"" + want + "\")"); +} + +using namespace audiocpp_backend; + +// htdemucs's real source order, taken from its GGUF config.sources. vocals is +// LAST, which is why "first output" is not the default. +static const std::vector kDemucs = {"drums", "bass", "other", + "vocals"}; +// mel_band_roformer's, where vocals is first. +static const std::vector kRoformer = {"vocals", "instrumental"}; + +static void test_default_selection() { + const auto demucs = select_named_output(kDemucs, ""); + check(demucs.error.empty(), "an unrequested selection is not an error"); + check(demucs.index == 3, "no stem asked for picks vocals, not the first output"); + + const auto roformer = select_named_output(kRoformer, ""); + check(roformer.index == 0, "vocals is picked when it is already first"); + + // No vocals anywhere: the first output is the documented fallback. + const auto novocals = select_named_output({"accompaniment", "drums"}, ""); + check(novocals.index == 0 && novocals.error.empty(), + "a family with no vocals stem falls back to the first output"); + + // Substring matches must not count: "vocals_2" is a different stem. + const auto near = select_named_output({"vocals_2", "backing"}, ""); + check(near.index == 0 && near.error.empty(), + "'vocals_2' is not 'vocals', so the fallback and not the preference applies"); + const auto near_second = select_named_output({"backing", "vocals_2"}, ""); + check(near_second.index == 0, + "a near miss on the preferred name does not pull it to the front"); +} + +static void test_explicit_selection() { + for (int i = 0; i < 4; ++i) { + const auto choice = select_named_output(kDemucs, kDemucs[static_cast(i)]); + check(choice.index == i && choice.error.empty(), + "explicit '" + kDemucs[static_cast(i)] + "' selects its own index"); + } + // Including the one the default would have chosen anyway: asking for it + // must not be treated as "no request". + const auto vocals = select_named_output(kDemucs, "vocals"); + check(vocals.index == 3, "explicitly asking for vocals still selects vocals"); +} + +static void test_unknown_stem_is_refused() { + const auto choice = select_named_output(kDemucs, "kazoo"); + check(choice.index == -1, "an unknown stem selects nothing"); + check(!choice.error.empty(), "an unknown stem is refused rather than substituted"); + check(choice.error.find("kazoo") != std::string::npos, + "the refusal names the stem that was asked for"); + // The real names, so the caller can fix the request without guessing. + for (const auto &name : kDemucs) { + check(choice.error.find(name) != std::string::npos, + "the refusal lists the real stem '" + name + "'"); + } + check(choice.error.find("drums, bass, other, vocals") != std::string::npos, + "the refusal lists the stems in the model's own order"); + + // Case matters: the engine's ids are exact, so a wrong case is a wrong name + // rather than a near miss to be forgiven. + const auto wrong_case = select_named_output(kDemucs, "Vocals"); + check(wrong_case.index == -1 && !wrong_case.error.empty(), + "stem names are matched case sensitively"); +} + +static void test_no_named_outputs() { + const auto choice = select_named_output({}, ""); + check(choice.index == -1, "an empty output list selects nothing"); + check(choice.error.empty(), + "an empty output list is not an error here: the caller decides"); + const auto requested = select_named_output({}, "vocals"); + check(requested.index == -1 && requested.error.empty(), + "an empty output list stays the caller's decision even when a stem was asked for"); +} + +static void test_unwritable_names_are_refused() { + // Model-supplied names become file path components. A separator would write + // outside the caller's output directory. + const std::vector traversal = {"vocals", "../../etc/passwd"}; + const auto escaped = select_named_output(traversal, "vocals"); + check(escaped.index == -1 && !escaped.error.empty(), + "a stem name containing a path separator is refused"); + check(escaped.error.find("../../etc/passwd") != std::string::npos, + "the refusal names the offending stem"); + + check(!select_named_output({"vo\\cals", "drums"}, "").error.empty(), + "a backslash separator is refused too"); + check(!select_named_output({"drums", ""}, "").error.empty(), + "an empty stem name is refused"); + check(!select_named_output({"drums", "."}, "").error.empty(), + "a stem named '.' is refused"); + check(!select_named_output({"drums", ".."}, "").error.empty(), + "a stem named '..' is refused"); + + // The check covers EVERY name, not only the selected one: all of them are + // written, so a bad fourth name must not be found after three files exist. + const auto late = select_named_output({"vocals", "drums", "bass", "a/b"}, "vocals"); + check(late.index == -1 && !late.error.empty(), + "an unwritable name after the selected one still refuses the whole request"); + + // A leading dot is not a traversal and must stay usable. + const auto dotted = select_named_output({".vocals", "drums"}, ".vocals"); + check(dotted.index == 0 && dotted.error.empty(), + "a leading dot in a stem name is allowed"); +} + +static void test_duplicate_names_are_refused() { + const auto choice = select_named_output({"vocals", "drums", "vocals"}, "vocals"); + check(choice.index == -1 && !choice.error.empty(), + "two stems sharing a name are refused: one file would overwrite the other"); + check(choice.error.find("vocals") != std::string::npos, + "the duplicate refusal names the repeated stem"); +} + +static void test_sibling_paths() { + check_equal(sibling_stem_path("/generated/transform-1.wav", "drums"), + "/generated/transform-1.drums.wav", "sibling beside an absolute dst"); + check_equal(sibling_stem_path("sep.wav", "vocals"), "sep.vocals.wav", + "sibling of a bare file name has no directory"); + check_equal(sibling_stem_path("/out/sep", "vocals"), "/out/sep.vocals.wav", + "an extensionless dst gets .wav"); + check_equal(sibling_stem_path("/out/take.2.wav", "bass"), "/out/take.2.bass.wav", + "only the final extension is treated as the extension"); + check_equal(sibling_stem_path("/out/sep.WAV", "bass"), "/out/sep.bass.WAV", + "the caller's extension spelling is preserved"); + check_equal(sibling_stem_path("/a b/c d.wav", "other"), "/a b/c d.other.wav", + "spaces in the destination survive"); + + // The property that matters: no stem can ever be written over dst itself, + // or the "dst holds the selected stem" contract would depend on write order. + const std::string dst = "/out/sep.wav"; + for (const auto &name : kDemucs) { + check(sibling_stem_path(dst, name) != dst, + "the sibling for '" + name + "' is not dst itself"); + } + // Distinct stems must land in distinct files. + check(sibling_stem_path(dst, "drums") != sibling_stem_path(dst, "bass"), + "two stems get two different sibling paths"); +} + +// The contract grpc-server.cpp indexes on: an accepted choice over a non-empty +// name list is always in range, so the handler needs no bounds guard of its own. +// A -1 reaching the subscript would become a colossal size_t. +static void test_accepted_index_is_always_in_range() { + const std::vector> lists = { + kDemucs, kRoformer, {"solo"}, {"accompaniment", "drums"}, {"a", "b", "c"}}; + const std::vector requests = {"", "vocals", "drums", "solo", "c", + "kazoo", "..", "a/b"}; + for (const auto &names : lists) { + for (const auto &requested : requests) { + const auto choice = select_named_output(names, requested); + if (!choice.error.empty()) { + check(choice.index == -1, + "a refusal never carries an index (request '" + requested + "')"); + continue; + } + check(choice.index >= 0 && + choice.index < static_cast(names.size()), + "an accepted choice is in range (request '" + requested + "')"); + // And the selected name is the one that was asked for, when one was. + if (!requested.empty()) { + check(names[static_cast(choice.index)] == requested, + "an accepted explicit request selects that exact name"); + } + } + } +} + +int main() { + test_accepted_index_is_always_in_range(); + test_default_selection(); + test_explicit_selection(); + test_unknown_stem_is_refused(); + test_no_named_outputs(); + test_unwritable_names_are_refused(); + test_duplicate_names_are_refused(); + test_sibling_paths(); + if (failures != 0) { + fprintf(stderr, "%d check(s) failed\n", failures); + return 1; + } + fprintf(stderr, "all stem_selection checks passed\n"); + return 0; +}