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; +}