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) {