From 71c6564b46f8ebf7818d25c905797c121f35f10c Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Mon, 27 Jul 2026 01:33:43 +0000 Subject: [PATCH] backend(audio-cpp): refuse the unsupported RPCs with a reason AudioEncode, AudioDecode, AudioTransformStream, AudioToAudioStream and VoiceEmbed have no counterpart in audio.cpp's VoiceTaskKind. Each now returns UNIMPLEMENTED naming the loaded family, what that family does support, and the upstream limitation, instead of the generated base class's bare status. The reasons live in a table in capability_routing.cpp so they are data rather than literals copied into five handlers, and so a test can assert every one of them. The five claims this was planned against were re-read at the pinned upstream e800d435d130dc776baf6f3e6129bb62b1495c89, and one did not hold. "audio.cpp streams tts and asr only" is false: silero_vad advertises vad with RunMode::Streaming. The refusal stands on the narrower claim that survives, that no family advertises streaming for any task AudioTransform routes to, and a test asserts the refuted wording does not come back. VoiceEmbed is the one refusal whose request carries a ModelIdentity, so it runs the #10952 check before answering: a stale route must get NOT_FOUND and the router's sentinel, not "audio.cpp cannot embed speakers" about a model that is not loaded here. It cannot use snapshot_for, whose no-model branch would tell the caller to load a model when no model can help, so it takes the reference through snapshot_unchecked and checks identity itself. That function's comment now names three classes of caller instead of two. The two bidirectional surfaces refuse without reading their stream, verified with a client that writes a config and eight frames first and gets the status rather than hanging. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto --- backend/cpp/audio-cpp/capability_routing.cpp | 88 +++++++++ backend/cpp/audio-cpp/capability_routing.h | 46 ++++- .../cpp/audio-cpp/capability_routing_test.cpp | 174 ++++++++++++++++++ backend/cpp/audio-cpp/grpc-server.cpp | 146 +++++++++++++-- 4 files changed, 440 insertions(+), 14 deletions(-) diff --git a/backend/cpp/audio-cpp/capability_routing.cpp b/backend/cpp/audio-cpp/capability_routing.cpp index e80663aa7..803eeff00 100644 --- a/backend/cpp/audio-cpp/capability_routing.cpp +++ b/backend/cpp/audio-cpp/capability_routing.cpp @@ -109,6 +109,46 @@ std::vector task_candidates(Rpc rpc, const RequestShape &shape) { return {}; } +// The reasons behind unsupported_surfaces(), spelled once because AudioEncode +// and AudioDecode share theirs. Each is phrased in terms of what upstream does +// and does not have, so a reader can check it against the pinned checkout +// rather than take it on trust. Every one of them was checked against +// audio.cpp e800d435d130dc776baf6f3e6129bb62b1495c89, and one of the four +// claims this backend was planned against did not survive that check: see +// kTransformStreamReason. +// +// A latent upstream inconsistency worth knowing about but deliberately NOT put +// on the wire, because it would mislead: model_spec/schema.cpp's task-string +// whitelist does accept "codec" (and "dialogue"), while +// model_spec/metadata.cpp's parse_task_kind has no branch for either and +// throws "unknown model spec task". So a spec declaring "codec" validates and +// then fails to load. That is a hole in upstream's own validation, not a codec +// task this backend could reach. +const char *const kCodecReason = + "audio.cpp's VoiceTaskKind has no codec entry, so no family can be asked to " + "turn PCM into codec frames or back; miocodec carries a Codec tag in " + "upstream's README but its loader advertises only vc and s2s"; +// NOT "streaming exists for tts and asr only", which is what this backend was +// planned to say and is false: silero_vad advertises vad with RunMode::Streaming +// (src/models/silero_vad/session.cpp). The claim that actually holds is the +// narrower one below, about the four tasks AudioTransform routes to. +const char *const kTransformStreamReason = + "no audio.cpp family advertises streaming for any task AudioTransform routes " + "to (sep, vc, svc, s2s); upstream advertises RunMode::Streaming for tts, asr " + "and vad only, and no conversion or separation family even implements its " + "IStreamingVoiceTaskSession interface"; +const char *const kAudioToAudioReason = + "LocalAI's contract here is OpenAI-Realtime shaped, an audio conversation " + "emitting audio, transcript and tool-call deltas from a system prompt and a " + "tool list; audio.cpp's s2s is offline voice conversion, declared only by " + "miocodec and vevo2, which converts one clip into another speaker's voice " + "and is a different thing"; +const char *const kVoiceEmbedReason = + "no audio.cpp family advertises the spk (SpeakerRecognition) task, so " + "nothing in the engine can produce a speaker embedding; the task kind " + "itself exists upstream, and TitaNet and ECAPA-TDNN exist as internal " + "conditioning encoders, but neither is registered as a loadable family"; + std::string join_attempts(const std::vector &tasks, const std::vector &modes) { std::string out; @@ -198,6 +238,54 @@ std::string describe_capabilities(const Capabilities &caps) { return out; } +const std::vector &unsupported_surfaces() { + // Ordered as UnsupportedRpc declares them, which unsupported_surface() + // relies on to index straight in. + static const std::vector kSurfaces = { + {"AudioEncode", kCodecReason}, + {"AudioDecode", kCodecReason}, + {"AudioTransformStream", kTransformStreamReason}, + {"AudioToAudioStream", kAudioToAudioReason}, + {"VoiceEmbed", kVoiceEmbedReason}, + }; + return kSurfaces; +} + +const UnsupportedSurface &unsupported_surface(UnsupportedRpc rpc) { + const std::vector &surfaces = unsupported_surfaces(); + const size_t index = static_cast(rpc); + // The binding between the enum and the table is positional, so a sixth + // enumerator added without a row indexes past the end. operator[] would make + // that undefined behaviour, i.e. a crash or a garbage string on the wire, in + // the one code path whose entire job is to be diagnosable. This turns it + // into a message that says what happened. It is not reachable today, and the + // test that would notice it going stale is in capability_routing_test.cpp. + if (index >= surfaces.size()) { + static const UnsupportedSurface kMissingRow{ + "unknown", + "this backend declared an UnsupportedRpc with no matching row in " + "unsupported_surfaces(), which is a bug in capability_routing.cpp " + "and not a statement about audio.cpp"}; + return kMissingRow; + } + return surfaces[index]; +} + +std::string unsupported_surface_message(const Capabilities &caps, const char *rpc, + const char *reason) { + return std::string("audio-cpp: the ") + rpc + + " RPC is not available through this backend because " + reason + + ". Loaded family '" + caps.family + + "' supports: " + describe_capabilities(caps); +} + +std::string unsupported_surface_message(const char *rpc, const char *reason) { + return std::string("audio-cpp: the ") + rpc + + " RPC is not available through this backend because " + reason + + ". No model is loaded, so there is no family to list; loading one " + "would not change this answer"; +} + Route resolve_route(Rpc rpc, const RequestShape &shape, const Capabilities &caps) { Route route; diff --git a/backend/cpp/audio-cpp/capability_routing.h b/backend/cpp/audio-cpp/capability_routing.h index e486b7cc5..9ba9c9aca 100644 --- a/backend/cpp/audio-cpp/capability_routing.h +++ b/backend/cpp/audio-cpp/capability_routing.h @@ -40,8 +40,9 @@ struct Capabilities { std::vector tasks; }; -// The LocalAI RPCs this backend serves. The unimplemented ones are not listed: -// they never reach routing. +// The LocalAI RPCs this backend serves. The ones it cannot serve at all are in +// UnsupportedRpc below rather than here: they never reach routing, because no +// family could satisfy them. enum class Rpc { Tts, TtsStream, @@ -86,4 +87,45 @@ bool parse_task_name(const std::string &value, Task &out); // "asr/offline, asr/streaming", for error messages. std::string describe_capabilities(const Capabilities &caps); +// The RPCs in LocalAI's backend contract that audio.cpp has no counterpart for, +// as opposed to the ones in Rpc above, which a particular family may or may not +// be able to serve. Nothing routes to these: the refusal is a property of the +// engine, not of the loaded model, so loading a different family cannot change +// it. +// +// This is deliberately NOT deferred work. Each entry names the upstream +// limitation that keeps it out of Rpc, and each becomes an ordinary routing +// entry the day upstream lifts that limitation. +enum class UnsupportedRpc { + AudioEncode, + AudioDecode, + AudioTransformStream, + AudioToAudioStream, + VoiceEmbed, +}; + +struct UnsupportedSurface { + // The RPC's name as backend.proto spells it, for the message. + const char *rpc; + // Why audio.cpp cannot serve it, in terms of what upstream does and does + // not have. Stated so a caller can tell "not built yet" from "not possible". + const char *reason; +}; + +// The table behind the five refusals. Exposed whole so a test can assert every +// entry rather than the one somebody remembered to cover, and so the reasons +// are data in one place instead of string literals hand-copied into handlers. +const std::vector &unsupported_surfaces(); +const UnsupportedSurface &unsupported_surface(UnsupportedRpc rpc); + +// Message for an RPC this backend cannot serve at all, as opposed to one this +// particular family cannot serve. `reason` states the upstream limitation. +std::string unsupported_surface_message(const Capabilities &caps, const char *rpc, + const char *reason); + +// Same, for the no-model-loaded case. Says so explicitly rather than naming an +// empty family, and says that loading one would not help, because the caller's +// obvious next move otherwise is to load a model and try again. +std::string unsupported_surface_message(const char *rpc, const char *reason); + } // namespace audiocpp_backend diff --git a/backend/cpp/audio-cpp/capability_routing_test.cpp b/backend/cpp/audio-cpp/capability_routing_test.cpp index 02eaeef10..8569d0536 100644 --- a/backend/cpp/audio-cpp/capability_routing_test.cpp +++ b/backend/cpp/audio-cpp/capability_routing_test.cpp @@ -306,6 +306,175 @@ static void test_empty_capabilities() { check(r.error.find("mystery") != std::string::npos, "error names the family"); } +// The table is indexed by UnsupportedRpc's underlying value, so a reordering of +// either list silently pairs an RPC with another's reason. Nothing else would +// catch that: both sides still compile and every message still reads plausibly. +static void test_unsupported_surface_table_matches_the_enum() { + check(unsupported_surfaces().size() == 5, + "all five unsupported surfaces are tabulated"); + check(std::string(unsupported_surface(UnsupportedRpc::AudioEncode).rpc) == + "AudioEncode", + "UnsupportedRpc::AudioEncode indexes AudioEncode"); + check(std::string(unsupported_surface(UnsupportedRpc::AudioDecode).rpc) == + "AudioDecode", + "UnsupportedRpc::AudioDecode indexes AudioDecode"); + check(std::string( + unsupported_surface(UnsupportedRpc::AudioTransformStream).rpc) == + "AudioTransformStream", + "UnsupportedRpc::AudioTransformStream indexes AudioTransformStream"); + check(std::string( + unsupported_surface(UnsupportedRpc::AudioToAudioStream).rpc) == + "AudioToAudioStream", + "UnsupportedRpc::AudioToAudioStream indexes AudioToAudioStream"); + check(std::string(unsupported_surface(UnsupportedRpc::VoiceEmbed).rpc) == + "VoiceEmbed", + "UnsupportedRpc::VoiceEmbed indexes VoiceEmbed"); + + // Two entries may share a reason (the codec pair does), but two entries + // naming the same RPC would mean one of the five is unreachable. + for (size_t i = 0; i < unsupported_surfaces().size(); ++i) { + for (size_t j = i + 1; j < unsupported_surfaces().size(); ++j) { + check(std::string(unsupported_surfaces()[i].rpc) != + unsupported_surfaces()[j].rpc, + std::string("no duplicate RPC name at ") + std::to_string(i) + + "/" + std::to_string(j)); + } + } +} + +// An enumerator with no table row must not read past the end. Casting an +// out-of-range value into a scoped enum whose underlying type is int is +// well-defined, so this test itself is legal; what it exercises is the guard, +// which is the only thing standing between a future sixth enumerator and +// undefined behaviour on the one path whose job is to be diagnosable. +static void test_unsupported_surface_out_of_range_is_diagnosable() { + const auto beyond = + static_cast(unsupported_surfaces().size()); + const auto &surface = unsupported_surface(beyond); + check(std::string(surface.rpc) == "unknown", + "an enumerator with no table row does not read past the end"); + check(std::string(surface.reason).find("bug in capability_routing.cpp") != + std::string::npos, + "the missing-row reason blames this backend, not audio.cpp"); + // And it still formats, rather than dereferencing something that is not + // there. + const std::string message = + unsupported_surface_message(nemotron(), surface.rpc, surface.reason); + check(message.find("nemotron_asr") != std::string::npos, + "the missing-row surface still produces a whole message"); +} + +// Every entry, not just the one somebody remembered to cover. A refusal that +// drops the family, the RPC or the reason is a refusal the caller cannot act +// on, which is the entire point of this surface existing. +static void test_every_unsupported_surface_message_is_diagnosable() { + for (const auto &surface : unsupported_surfaces()) { + const std::string label = std::string(" [") + surface.rpc + "]"; + const std::string loaded = + unsupported_surface_message(nemotron(), surface.rpc, surface.reason); + + check(loaded.find(surface.rpc) != std::string::npos, + "message names the RPC" + label); + check(std::string(surface.reason).size() > 20 && + loaded.find(surface.reason) != std::string::npos, + "message gives a substantive upstream reason" + label); + check(loaded.find("nemotron_asr") != std::string::npos, + "message names the loaded family" + label); + check(loaded.find("asr/offline") != std::string::npos && + loaded.find("asr/streaming") != std::string::npos, + "message lists what the family does support" + label); + + // The no-model form keeps the two facts that do not depend on a model + // and drops only the one that does, so the caller still learns why. + const std::string unloaded = + unsupported_surface_message(surface.rpc, surface.reason); + check(unloaded.find(surface.rpc) != std::string::npos, + "no-model message names the RPC" + label); + check(unloaded.find(surface.reason) != std::string::npos, + "no-model message gives the upstream reason" + label); + check(unloaded.find("nemotron_asr") == std::string::npos, + "no-model message names no family" + label); + // Without this the caller's obvious next move is to load a model and + // retry, which cannot work: the refusal is a property of the engine. + check(unloaded.find("would not change this answer") != std::string::npos, + "no-model message says loading a model would not help" + label); + } +} + +// The reasons are the load-bearing half of this feature and each was checked +// against the pinned upstream checkout. Pinning the distinguishing phrase here +// means a later edit that guts one into a generic "not supported" fails rather +// than passes quietly. +static void test_unsupported_reasons_name_the_upstream_limitation() { + const auto &encode = unsupported_surface(UnsupportedRpc::AudioEncode); + const auto &decode = unsupported_surface(UnsupportedRpc::AudioDecode); + check(std::string(encode.reason).find("VoiceTaskKind") != std::string::npos && + std::string(encode.reason).find("codec") != std::string::npos, + "the AudioEncode reason names the missing VoiceTaskKind entry"); + // miocodec is the family a reader will reach for first, because upstream's + // README tags it Codec. Naming it and its actual advertised tasks is what + // stops the next person re-deriving the same dead end. + check(std::string(encode.reason).find("miocodec") != std::string::npos, + "the AudioEncode reason disposes of miocodec's README Codec tag"); + check(std::string(encode.reason) == decode.reason, + "AudioEncode and AudioDecode refuse for the same reason"); + + const auto &transform = + unsupported_surface(UnsupportedRpc::AudioTransformStream); + // The reason must be scoped to the tasks AudioTransform routes to. The + // broader claim, "upstream streams tts and asr only", is FALSE: silero_vad + // advertises vad with RunMode::Streaming. A refusal resting on a false + // premise is worse than a bare UNIMPLEMENTED, because it will be believed. + check(std::string(transform.reason).find("sep, vc, svc, s2s") != + std::string::npos, + "the AudioTransformStream reason is scoped to the routed tasks"); + check(std::string(transform.reason).find("tts, asr and vad") != + std::string::npos, + "the AudioTransformStream reason counts vad among the streaming tasks"); + check(std::string(transform.reason).find("tts and asr only") == + std::string::npos, + "the AudioTransformStream reason does not repeat the refuted claim"); + + const auto &s2s = unsupported_surface(UnsupportedRpc::AudioToAudioStream); + check(std::string(s2s.reason).find("Realtime") != std::string::npos && + std::string(s2s.reason).find("voice conversion") != std::string::npos, + "the AudioToAudioStream reason contrasts the two contracts"); + // Naming both s2s families makes the claim checkable: a reader can open + // those two loaders and see offline voice conversion rather than a + // conversation. + check(std::string(s2s.reason).find("miocodec and vevo2") != std::string::npos, + "the AudioToAudioStream reason names the only two s2s families"); + + const auto &embed = unsupported_surface(UnsupportedRpc::VoiceEmbed); + check(std::string(embed.reason).find("spk") != std::string::npos, + "the VoiceEmbed reason names the task no family advertises"); + // spk IS a VoiceTaskKind upstream; what is missing is any family that + // advertises it. Saying the kind does not exist would be false, and would + // send a reader looking in the wrong place. + check(std::string(embed.reason).find("no audio.cpp family") != + std::string::npos, + "the VoiceEmbed reason blames the families, not the enum"); + // The speaker encoders DO exist upstream, as conditioning modules inside + // TTS and VC families. Not saying so invites "but audio.cpp ships TitaNet". + check(std::string(embed.reason).find("TitaNet") != std::string::npos, + "the VoiceEmbed reason disposes of the internal speaker encoders"); + Task parsed = Task::Vad; + check(parse_task_name("spk", parsed) && parsed == Task::SpeakerRecognition, + "spk is a real task kind, so the reason must not claim otherwise"); +} + +// A family advertising nothing still gets a message that reads, rather than one +// trailing off after "supports: ". +static void test_unsupported_surface_message_with_empty_capabilities() { + const auto &embed = unsupported_surface(UnsupportedRpc::VoiceEmbed); + const std::string message = unsupported_surface_message( + Capabilities{"mystery", {}}, embed.rpc, embed.reason); + check(message.find("mystery") != std::string::npos, + "empty-capability message still names the family"); + check(message.find("supports: nothing") != std::string::npos, + "empty-capability message says the family supports nothing"); +} + int main() { test_plain_tts(); test_tts_with_voice_reference_prefers_cloning(); @@ -325,6 +494,11 @@ int main() { test_names_round_trip(); test_describe_capabilities(); test_empty_capabilities(); + test_unsupported_surface_table_matches_the_enum(); + test_unsupported_surface_out_of_range_is_diagnosable(); + test_every_unsupported_surface_message_is_diagnosable(); + test_unsupported_reasons_name_the_upstream_limitation(); + test_unsupported_surface_message_with_empty_capabilities(); if (failures) { fprintf(stderr, "%d check(s) failed\n", failures); return 1; diff --git a/backend/cpp/audio-cpp/grpc-server.cpp b/backend/cpp/audio-cpp/grpc-server.cpp index beefb42ea..91e7a3e9f 100644 --- a/backend/cpp/audio-cpp/grpc-server.cpp +++ b/backend/cpp/audio-cpp/grpc-server.cpp @@ -7,7 +7,10 @@ // // This commit adds LoadModel/Free/Status plus the AudioTranscription, VAD, // Diarize, AudioTransform, TTS, SoundGeneration, TTSStream, -// AudioTranscriptionStream and AudioTranscriptionLive RPCs. +// AudioTranscriptionStream and AudioTranscriptionLive RPCs, and explicit +// refusals for the five audio RPCs audio.cpp has no counterpart for: +// AudioEncode, AudioDecode, AudioTransformStream, AudioToAudioStream and +// VoiceEmbed. #include "backend.pb.h" #include "backend.grpc.pb.h" @@ -83,19 +86,33 @@ std::shared_ptr g_model; // use-after-free. // // _unchecked because it performs NO request-level validation. There are exactly -// two legitimate classes of caller, and both are "there is no identity to -// check" rather than "the check was skipped": +// three legitimate classes of caller, and none of them is "the check was +// skipped": // // 1. Status, which takes a HealthMessage. It carries no ModelIdentity field. -// 2. An RPC whose request message has no ModelIdentity field either, which -// today is AudioTranscriptionLive: TranscriptLiveRequest is a oneof of -// TranscriptLiveConfig and TranscriptLiveAudio and neither carries one, so -// snapshot_for does not even instantiate for it. The stale-route hazard -// #10952 describes is therefore unguarded on that RPC, and the fix has to -// be in backend.proto rather than here: nothing this process can read off -// the request says which model the caller thought it was reaching. When -// that field lands, these handlers move to snapshot_for and this clause -// goes away. +// 2. An RPC whose request message has no ModelIdentity field either, so +// snapshot_for does not even instantiate for it. AudioTranscriptionLive is +// the routed one: TranscriptLiveRequest is a oneof of TranscriptLiveConfig +// and TranscriptLiveAudio and neither carries an identity. The four +// unroutable surfaces refuse_surface answers are the rest of the class: +// AudioEncodeRequest, AudioDecodeRequest, AudioTransformFrameRequest and +// AudioToAudioRequest carry no identity either. The stale-route hazard +// #10952 describes is therefore unguarded on these RPCs, and the fix has +// to be in backend.proto rather than here: nothing this process can read +// off the request says which model the caller thought it was reaching. +// When that field lands, the routed handlers move to snapshot_for and this +// clause shrinks to the refusals. It costs nothing on the four refusals, +// which answer the same UNIMPLEMENTED whatever model is loaded. +// 3. VoiceEmbed, which is the one refusal whose request DOES carry a +// ModelIdentity. It cannot use snapshot_for, because snapshot_for's +// no-model branch is FAILED_PRECONDITION "call LoadModel first" and that +// is the wrong instruction here: no model can make this backend serve +// VoiceEmbed, so the caller has to be told the reason instead. It +// therefore takes the reference here and runs check_model_identity itself, +// which keeps the ordering rule snapshot_for exists to enforce: identity +// before UNIMPLEMENTED, or a stale route gets "audio.cpp cannot embed +// speakers" when the model it actually asked for lives on a backend that +// can. // // Every OTHER RPC must call snapshot_for; the name is deliberately unpleasant so // that reaching for it is a visible decision rather than an omission. @@ -340,6 +357,42 @@ snapshot_for(const Request *request, GStatus &out) { return model; } +// The single body behind the five refusals in the class below. +// +// UNIMPLEMENTED is the code by contract, not by taste: +// pkg/grpc/grpcerrors/errors.go degrades to an alternative path on +// UNIMPLEMENTED and on nothing else, so a caller that has a fallback keeps it. +// +// The loaded family is read only to ENRICH the message. The refusal itself is a +// property of the engine rather than of the model, which is why the no-model +// case is the same UNIMPLEMENTED and not the FAILED_PRECONDITION every routed +// handler returns: "call LoadModel first" would send the operator to do work +// that cannot help. The message says so in as many words. +// +// It does not read the stream, and the two bidirectional surfaces must not +// start: returning before the first Read means a client that opened the stream +// and is writing frames gets the status on its next operation instead of +// blocking on a response that would never come. +GStatus refuse_surface(audiocpp_backend::UnsupportedRpc rpc, + const audiocpp_backend::LoadedModel *model) { + const auto &surface = audiocpp_backend::unsupported_surface(rpc); + if (model == nullptr) { + return GStatus(grpc::StatusCode::UNIMPLEMENTED, + audiocpp_backend::unsupported_surface_message( + surface.rpc, surface.reason)); + } + return GStatus(grpc::StatusCode::UNIMPLEMENTED, + audiocpp_backend::unsupported_surface_message( + model->capabilities(), surface.rpc, surface.reason)); +} + +// Overload for the four surfaces whose request carries no ModelIdentity: there +// is nothing to check, so the reference is taken here. See case 2 at +// snapshot_unchecked. +GStatus refuse_surface(audiocpp_backend::UnsupportedRpc rpc) { + return refuse_surface(rpc, snapshot_unchecked().get()); +} + // Builds the TaskRequest for a transcription-shaped RPC. `task` is the ROUTED // audio.cpp task, not a guess from the request: it decides how // TranscriptRequest.prompt is used, and routing has already decided it. @@ -1872,6 +1925,75 @@ public: return to_status(err); } } + + // The five surfaces this backend cannot serve at all. + // + // They are overridden rather than left to the generated base class on + // purpose. The base returns UNIMPLEMENTED with an empty message, which + // tells the caller nothing: it cannot distinguish "audio-cpp will never do + // this" from "the model you loaded cannot, try another one" from "this + // build is old". Each override names the loaded family, what that family + // does support, and the upstream limitation, so the answer to "why" is on + // the wire rather than in someone's head. capability_routing.cpp holds the + // reasons; every one of them was checked against the pinned checkout. + // + // None of these is deferred work. Each is an RPC whose LocalAI contract has + // no counterpart in audio.cpp's VoiceTaskKind, and each becomes an ordinary + // Rpc routing entry the day upstream grows one. + + GStatus AudioEncode(ServerContext *, const backend::AudioEncodeRequest *, + backend::AudioEncodeResult *) override { + return refuse_surface(audiocpp_backend::UnsupportedRpc::AudioEncode); + } + + GStatus AudioDecode(ServerContext *, const backend::AudioDecodeRequest *, + backend::AudioDecodeResult *) override { + return refuse_surface(audiocpp_backend::UnsupportedRpc::AudioDecode); + } + + // Refused WITHOUT reading the stream. A bidirectional handler that returns + // a status closes the call, and gRPC delivers that status to the client on + // its next Read or Finish, so the client learns why whether or not it has + // already written frames. Draining first would only delay the same answer + // for as long as the client keeps talking. + GStatus AudioTransformStream( + ServerContext *, + grpc::ServerReaderWriter *) override { + return refuse_surface( + audiocpp_backend::UnsupportedRpc::AudioTransformStream); + } + + // Same, and see the note above about not reading the stream. + GStatus AudioToAudioStream( + ServerContext *, + grpc::ServerReaderWriter *) override { + return refuse_surface( + audiocpp_backend::UnsupportedRpc::AudioToAudioStream); + } + + // The one refusal whose request carries a ModelIdentity, so it is the one + // that has to run the #10952 check before answering. The order is the same + // one snapshot_for enforces everywhere else and for the same reason: a + // request that names a DIFFERENT model must get NOT_FOUND plus the router's + // sentinel, not this UNIMPLEMENTED. Otherwise a stale route pointed at this + // process answers "audio.cpp cannot embed speakers" about a model that is + // not loaded here and may well live on a backend that can, and the router + // retires a working capability on the strength of it. Case 3 at + // snapshot_unchecked explains why snapshot_for cannot be used instead. + GStatus VoiceEmbed(ServerContext *, const backend::VoiceEmbedRequest *request, + backend::VoiceEmbedResponse *) override { + const auto model = snapshot_unchecked(); + if (model != nullptr) { + const GStatus identity = check_model_identity(*model, request); + if (!identity.ok()) { + return identity; + } + } + return refuse_surface(audiocpp_backend::UnsupportedRpc::VoiceEmbed, + model.get()); + } }; void RunServer(const std::string &addr) {