mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 09:57:57 -04:00
* backend(audio-cpp): add the native build scaffold Links 0xShug0/audio.cpp engine_runtime through its public framework headers and serves Health/Status. Model loading and the audio RPCs follow. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): keep the build-tree rpath at $ORIGIN Upstream sets CMAKE_BUILD_WITH_INSTALL_RPATH in its own directory scope, so CMake was appending its build-tree library dir to our target and baking an absolute build-host path into the shipped binary. Set BUILD_WITH_INSTALL_RPATH on the target so a package that forgets to bundle libggml*.so fails on the build machine too, instead of only on a user's box. Also document why EXCLUDE_FROM_ALL must stay on the add_subdirectory call, correct the claim that Ubuntu ships no gRPC CMake config, stop the pin comment from repeating the assignment token that bump_deps.sh rewrites, and make test-engine fail rather than pass when no test is registered. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): parse namespaced model options Splits option entries on the first colon so path values survive, and routes load./session. prefixes to the upstream load and session option maps. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): reject out-of-range numeric model options std::atoi is undefined once the digits exceed long and in practice wraps, so device:2147483648 was accepted and handed the ggml backend selector a device index of -2147483648 from a function whose error text promises a non-negative integer. Parse with strtol and reject on ERANGE, on a value above INT_MAX, and on any unconsumed trailing input. The error strings are unchanged. Name the whole entry in the unknown-key error too: an entry like ':value' has an empty key and left the user nothing to grep for in their YAML. Tests look keys up through a helper instead of map::at, so a prefix off-by-one fails one named check rather than aborting the binary and skipping the rest of the suite, and cover the overflow, negative and non-numeric paths. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): route LocalAI RPCs onto audio.cpp tasks Task-major resolution over the family's advertised capability set, with the voice-reference and instructions signals selecting cloning and voice design, and a streaming-to-offline fallback for server-streaming transcription only. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): use upstream's 'spk' task name and pin the preference order The SpeakerRecognition short name was 'spkrec', which audio.cpp neither prints nor parses; a name copied out of audio.cpp was rejected and a pinned 'spkrec' would not survive the engine boundary. Emit 'spk', keep 'spkrec' as an input-only alias, and correct the known-tasks lists. Three assertions were vacuous because their fixtures advertised a single task, so reversing a preference order or dropping the RPC name and the attempted pairs from the capability error all passed. Give them fixtures that can tell the orderings apart. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): convert sample, time and PCM units Integer nanosecond conversion so 44.1 kHz stays exact, float seconds for the VAD and diarization messages, and saturating s16le encode so an overshooting sample cannot wrap to the opposite sign. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): harden seconds_to_samples against NaN and overflow seconds_to_samples is the one entry point fed by untrusted-shaped input: a float-seconds timestamp off the wire, or a boundary from a model that diverged. Its guard covered only the low side, so NaN and out-of-range values fell through to an undefined double-to-int64 cast and came back as INT64_MIN. A hugely negative sample index used later as an offset or a length is a wild pointer rather than merely a wrong timestamp. Reject NaN with the !(x > 0) form and saturate before the cast. Also round instead of truncating there. These functions exist to cross the float seconds boundary the VAD and diarize messages use, and truncation lost a sample about half the time on the samples-to-seconds-and-back round trip, starting at n=1. Pin the decode scale at INT16_MIN, pin nanosecond truncation on a nonzero fraction, and record why the clamp argument order in f32_to_s16le is load-bearing for NaN. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): map NaN PCM samples to silence explicitly f32_to_s16le relied on std::min argument order to keep a NaN sample away from std::lround, whose result is unspecified for NaN. That was too subtle to rest on a comment, and the comment was itself wrong: it warned against a spelling that the outer std::max already catches, while three real spellings leak, including std::clamp, which is the idiomatic C++17 way to write the same clamp and so the likeliest future edit. Divert NaN before the clamp and encode it as 0. A NaN sample rendered as a full-scale click is worse audio than a dropped one, and this unit converts audio that may have originated off the wire. Pin it with an exact-value check rather than a range check, since all three outcomes the plausible spellings produce are finite and inside full scale, plus an invalid-operation check that fails unless the NaN is diverted before any ordered comparison. That second check is what catches modernizing the clamp and dropping the guard together. Also bound the seconds round-trip comment, which claimed unconditionally what holds only below roughly 2^23 samples, and document NaN, saturation and that bound in the header. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): assemble transcripts from runtime spans The top-level transcript text is TaskResult.text_output verbatim. audio.cpp carries text nowhere else: speech_segments, speaker_turns and word_timestamps hold spans and labels only, so deriving the text from them empties the transcript for any producer that omits word timing, VibeVoice diarized ASR included. Fixtures cover every observed producer shape. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): keep a nested speaker turn's own label A segment sourced from speaker_turns re-derived its speaker by greatest overlap. A turn's overlap with its own span is the largest possible, so a turn nested inside another speaker's turn could only tie with the container, and the tie went to whichever came first. sortformer_diar binarizes each speaker independently and sorts by start sample, so the container always comes first and the interjecting speaker was silently erased from DiarizeSegment.speaker. choose_segment_spans now carries the label out with the span. Also pins the nearest-segment fallback against measuring from either endpoint or from segment position, which a trailing-only stray word could not do, and exercises the empty-word guard in join_words. Two fixtures that pin a rule but do not mirror any pinned family are relabelled defensive. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): serialize runs with a wedge-aware guard audio.cpp sessions are not reentrant and a wedged CUDA call cannot be cancelled, so a plain mutex would pile every worker thread behind a stuck GPU. Callers waiting past the configured bound, or arriving while the holder has already overrun it, fail fast instead. A caller that queues behind a healthy run deliberately does not stamp the clock: only the thread that takes the lock does. Stamping on arrival would restart the wedge clock on every request and hide a stuck run from everyone behind it, which is the pile-up this guard exists to prevent. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): serialize inference through an InferenceLane One audio.cpp model is loaded per backend process and its sessions are not reentrant, so concurrent gRPC handlers have to take turns. Serialization alone is not enough: a wedged GPU call cannot be cancelled from userspace, so an unbounded queue behind one stuck run would swallow every gRPC worker thread until the process is useless. InferenceLane gives handlers a lane with room for one runner. LaneEntry occupies it for a scope and gives it back on every exit, including an exception, and is the only way to take the lane at all: occupy/vacate are private with LaneEntry as the sole friend, so a caller cannot acquire without holding something that releases. LaneEntry is immovable on purpose, because a moved-from entry would have to stop releasing while the lane still recorded it as occupied. A caller either waits indefinitely or brings a millisecond budget. A bounded caller that cannot get in fails instead of waiting on, and a bounded caller whose budget is already shorter than the age of the run in the lane fails immediately, which is what stops a queue forming behind a wedged run. The two failures carry different text: one names the wait it exhausted, the other states the measured age of the run without claiming to know why it is long, since a short budget meeting a legitimately long run lands there too. The run's age is stamped only after acquisition. A waiter that published itself as holder would restart the measurement and hide a genuinely stuck holder from every caller behind it. Budget negotiation and the overrun decision are pure functions taking their inputs explicitly, so both are covered without threads or sleeping. The per-model ceiling arrives as an int of milliseconds; a request may tighten it and may never loosen it. Replaces the previous run_guard unit, which was a derivative of an Apache-2.0 file upstream and could not stay in an MIT tree. Written from a behaviour contract with no reference to the removed code. Tests: 65 checks, standard library only, single translation unit, clean under -Wall -Wextra. Mutation tested at 23/23 killed; two of those mutants exposed missing coverage and the tests were extended until they died. ThreadSanitizer clean. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): make the B10 test able to fail, and document LaneEntry Review of the previous commit found the B10 test could not fail for the reason it was named. It aged the in-flight run to about 120 ms and then tried two budgets, 30 ms and 60 ms, both under that age, so both callers took the fail-fast path. "The two failure modes do not share one message" was comparing two fail-fast messages that differ only in the budget they print, and the timeout path was never reached. The second budget is now 400 ms, well over the run's age, so that caller queues and times out, and a new check asserts which path each caller took instead of inferring it from inequality. A mutant that makes the fail-fast path emit the timeout message previously died only on B4 and B8 checks; it now also dies on B10. Comment-only changes elsewhere. LaneEntry now says it is not reentrant and does not detect reentrancy: a second entry on a thread that already holds the lane surfaces as LaneUnavailable with a positive budget, but parks silently in unbounded mode, which matters because a handler may hold one across a whole stream. The immovability note now names the shapes that work, an optional emplaced in place or a unique_ptr, rather than saying to hold the entry indirectly without saying how; all three documented forms were compiled before being written down, which is how the note came to say that an optional of an immovable type cannot itself be returned. The header's explanation of why fail-fast exists is reworded. Two clauses traced back to a specification written after reading the Apache-2.0 upstream header, and while that was judged de minimis, this unit was rewritten precisely to carry no upstream expression at all. The margin table in the report was also wrong about which wall-clock margins are load-sensitive: there are four, not one, and the tightest is the B3 arrival check, which is now flagged at the call site. No margin value changed and none moved across 65 runs. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): gate model loading on the audio.cpp family Refuses any GGUF without an audiocpp.model_spec.family key and any non-GGUF path without an explicit family option, so the model loader's greedy backend probe cannot bind an unrelated llama.cpp GGUF to this backend (#9287). Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): load models and cache sessions per task Loads one ILoadedVoiceModel and creates an IVoiceTaskSession lazily per (task, mode), so the same model serves both the unary and streaming RPCs. LoadModel derives the family from GGUF metadata or an explicit option and fails with INVALID_ARGUMENT otherwise, so a failed load is a gRPC error the backend probe can see. audiocpp_backend::Task mirrors engine::runtime::VoiceTaskKind positionally, and drift there is silent: every unit still compiles and every test still passes while the backend runs a different task. Two mechanisms pin it. The static_asserts in loaded_model.cpp catch an insertion or a reorder, and -Werror=switch on that one file turns an appended upstream enumerator into a build failure rather than a warning in a 600 file log. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): stop aborting the process on SIGTERM The signal handler called grpc::Server::Shutdown directly. Shutdown takes an absl::Mutex, which is not async-signal-safe: the handler can interrupt a thread already holding that mutex, and abseil's deadlock detector responds by aborting. Every SIGTERM therefore ended in exit 134 and a 'dying due to potential deadlock' stack rather than a drained shutdown. The handler now sets a lock-free atomic and returns. Server::Wait moves to a helper thread so the main thread can poll that flag and call Shutdown itself, outside any signal context. A condition variable would not have helped, because notifying one from a handler is not async-signal-safe either. SIGTERM and SIGINT both exit 0 with no stack trace, where both previously exited 134. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): correct the status, lifetime and state contracts of LoadedModel An environment fault during session creation was reported as UNIMPLEMENTED. A missing libggml-cpu-*.so surfaced to the client as 'family silero_vad advertises vad/offline but refused to create the session: Failed to initialize CPU backend', which tells LocalAI the model cannot do this and must never be retried, and sends an operator hunting a capability bug instead of a packaging one. A throw from create_task_session is now a plain runtime_error, so it maps to INTERNAL. Only a null return, where the family genuinely declined, stays a CapabilityError. The model.'s task: option was parsed and then dropped: it lived in a local that died at the end of LoadModel and had no route to RequestShape::pinned_task. LoadedModel now keeps it and exposes pinned_task(). The global model becomes a shared_ptr reached through snapshot(). An audio RPC runs for seconds and cannot hold g_model_mu for its duration, so under a unique_ptr a Free arriving mid-request would destroy the model underneath it. Handlers now take a counted reference and whichever finishes last does the teardown, outside the lock. session_for documents the streaming state contract rather than resetting the session itself. Resetting on a cache hit was tried first and is not possible: silero_vad throws 'session prepare() must be called before Silero VAD reset()', so it would turn an ordinary second fetch into a hard error. start_stream's base implementation is already a reset, so a caller that runs prepare then start_stream per stream gets a clean session; a probe against the bundled silero_vad confirms an identical replay when it does and a carried-over stream when it does not. Also: an unknown backend: name is rejected before the model loads rather than after; MainGPU is parsed instead of passed through std::atoi, which turned 'gpu1' into device 0 silently; and device carries a device_set flag, because 0 is both the default and a real device index, so MainGPU was overriding an explicit device:0 that the neighbouring threads: handling promises will win. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): serve the VAD and Diarize RPCs Both emit float seconds, converted from the runtime's sample-index spans, and both take a counted reference to the loaded model through snapshot() and hold it for the whole call: a Free arriving mid-request drops only the global's reference, so whichever request finishes last destroys the model instead of one of them running on freed weights. An AddressSanitizer build reproduces exactly that heap-use-after-free inside ggml_vec_dot_f32 when the handler keeps a raw pointer instead, which is why the shape is what it is. The inference lane is taken before session_for, not after. session_for reads and writes an unsynchronised session cache and the offline run calls prepare(), which mutates the session, so both belong inside the lane. Diarize routes before it reads the input file, so a family that cannot diarize at all says so rather than complaining about the audio first. Its per-segment text stays empty because audio.cpp's SpeakerTurn carries a span and a speaker label only, and nested or overlapping turns are passed through untouched: a sortformer turn inside another speaker's turn is correct output for overlapped speech, and LocalAI is overlap-tolerant downstream. Duration counts frames rather than floats, so a stereo input does not report twice its length. Verified end to end against upstream's bundled silero_vad, which needs no download, using the bundled 16 kHz speech asset: a synthetic tone returns nothing, correctly, because silero detects speech and a sine is not speech. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): enforce ModelIdentity on VAD and Diarize audio-cpp was the only C++ backend without the model-identity guard, and no later task in the plan added it. pkg/grpc/server.go enforces checkModelIdentity on exactly these two RPCs, for the reason #10952 records: in distributed mode a worker can recycle a stopped backend's gRPC port for another model's backend, and the controller's liveness-only probe cannot tell a stale cached route from a live one. Without this guard a stale route gets a different model's VAD or diarization answer back with a 200. The loaded identity lives on LoadedModel rather than in a separate global, which is where this differs from llama-cpp. A handler holding the model through snapshot() then necessarily judges against the identity that model was loaded with, and a concurrent reload cannot swap one without the other. The refusal is NOT_FOUND carrying the verbatim grpcerrors.ModelMismatchSentinel substring. session_for and run_offline now take a const LaneEntry & proof-of-holding parameter. The rule that both must run under the inference lane was prose, which is exactly how the plan came to specify the inverted order; it is now a compile error. Restoring the inverted order fails to build rather than racing on an unsynchronised session map with a mutating prepare(). Diarize's speaker-hint comment claimed the dropped hints were "not a silent failure". From the caller's side that is what they are, and backend.proto documents num_speakers as forcing, so the comment now says plainly that the forwarding is dead for sortformer and that the family which lands must either honour num_speakers or refuse it. read_audio_file inspects the error_code from exists(), so an unsearchable parent directory no longer reports as a missing file. The VAD handler records the stimulus that actually works, since silero correctly ignores synthetic tones and the next task would otherwise rediscover that. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): make the lane and identity guards structural Two hardenings ahead of the eleven handlers still to be written, both of which get harder to retrofit later. The lane proof-of-holding parameter was a const reference, which binds to a temporary, so session_for(rpc, shape, model->acquire(0)) compiled. Each such temporary dies at the end of its own full-expression, releasing the lane between two calls that must share one: precisely the split the parameter exists to prevent, and the form a future author is most likely to reach for because it reads as tidy. A non-const reference requires an lvalue, so the temporary form now fails to compile while the named-local handlers build unchanged. The header comment no longer implies the check is total either: it proves a lane was taken, not that it is this model's lane. The identity check was two lines each handler had to remember, with nothing failing if a new one forgot them and no C++ equivalent of model_identity_modalities_test.go to notice. snapshot() becomes snapshot_unchecked(), whose only legitimate caller is Status, since HealthMessage carries no ModelIdentity. Handlers go through snapshot_for(), which takes the counted reference, refuses when nothing is loaded, and runs the identity check before anything can route. Every handler already has to call something to obtain the model, so the guarded call is now the shortest path and skipping it means deliberately typing snapshot_unchecked. A convention that has to be remembered can rot; this cannot. Verified: the temporary-argument and inverted-order forms each fail to compile with the expected diagnostic, the real handlers build, and bypassing the guard in Diarize alone turns the identity test red on that RPC while VAD stays green. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): serve the AudioTranscription RPC Adds result_map, the engine-to-proto boundary, and wires the offline transcription RPC. The handler branches on the ROUTED task: for Asr the request's prompt is whisper-style decoding context and becomes a request option, for Alignment the same field IS the transcript to align and becomes the text input. Routing has already decided which. The result text is TaskResult.text_output verbatim and is never derived from the segments. audio.cpp carries transcript text in text_output and nowhere else, so deriving it returns an empty transcript for every producer that reports segments without word timing. transcript_assembly already enforces that; this commit's job is not to undo it at the proto boundary, and result_map_ctest pins it there. read_audio_file now takes the sample rate the caller needs. Both file-fed speech handlers ask for 16 kHz mono, for two reasons: silero_vad and sortformer_diar refuse anything else outright, which turned an ordinary 44.1 kHz upload into INTERNAL, and nemotron_asr emits word timestamps in its own 16 kHz feature domain whatever the input was, so only a 16 kHz buffer makes the emitted nanoseconds right. Zero keeps the file's native rate and channels, which is what source separation will need. LoadedModel::check_can_serve answers a capability refusal before the lane is taken and before the input file is read. Routing is a pure read of the immutable capabilities, so a model that cannot serve an RPC no longer waits out somebody else's run to say so. VAD and Diarize use it too. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): stop linking sentencepiece's vendored protobuf engine_runtime links sentencepiece, whose default SPM_PROTOBUF_PROVIDER builds the protobuf-lite 3.14.0 sources it vendors. The generated backend.pb.cc is built against the toolchain's protobuf 3.21.12. Both ended up in the binary: 476 google::protobuf:: symbols came from the archive, 278 of them also defined by libprotobuf.so, and the archive won, because once ld pulls a member in for sentencepiece's own code every reference binds to the definitions that member carries. The visible symptom is one function. ParseContext::ParseMessage(MessageLite*, const char*) is what a generated _InternalParse calls for a submessage field and for nothing else, so flat messages parsed and nested ones did not: a TranscriptResult carrying segments serialized to correct bytes that the same process could not read back, and TranscriptLiveRequest, a oneof of submessages, could not have been parsed at all. Underneath that, 3.21 generated code was running 3.14 arena, ArenaStringPtr and ExtensionSet code. -Wl,--exclude-libs does not fix it. It makes those symbols LOCAL in .dynsym and the parse still fails, because the binding was decided at static link time and no visibility flag revisits it. Setting SPM_PROTOBUF_PROVIDER to "package" before add_subdirectory points sentencepiece at the protobuf the generated code was already built against. Zero google::protobuf:: definitions remain in the executable afterwards, every nested message round trips, and citrinet_asr, which parses a SentencePiece ModelProto at load time and would break first if this were wrong, still tokenizes and transcribes correctly. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): fix the segment text a transcription response is built from Segment text is not decoration. core/http/endpoints/openai/transcription.go routes response_format text, srt, vtt and lrc through schema.TranscriptionResponse, which builds the entire body out of Segments[].Text and never reads the top-level text. So for those four formats the segment text IS the response. nemotron_asr emits one word_timestamp per SentencePiece token, and the word boundary is carried as a LEADING SPACE on the piece ("So", "me", " call"). join_words inserted a space unconditionally, so response_format=text returned "So me call me na ture ," while the correct sentence sat unread in the top-level field. The separator is now chosen from the words themselves: whole words are space-joined, subword pieces are concatenated, and one leading space anywhere selects the latter. Concatenating the real nemotron pieces reproduces text_output exactly, verified end to end. This does not touch the top-level text, which is still text_output verbatim. The rule that forbids deriving the transcript from the segments is about the direction segments -> text; segment text has no source other than its words. Two smaller corrections in the same area: timestamp_granularities ["word"] set only "word_timestamps", a key no family in the pinned upstream reads. It now sets "return_timestamps", which qwen3_asr does read and which both runs its forced aligner and shortens its chunk window, so asking for word granularity no longer silently returns nothing. The request-option comment claimed more than it delivered. prompt, translate and temperature are read by no ASR family, and are forwarded only so a family adopting them works unchanged; the comment now says so per key, and gives TranscriptRequest.diarize the same explicit treatment threads already had. Also: the shipping target now carries -Wall -Wextra -Wpedantic, which it never did, so "the build is clean" starts meaning something; and fill_transcript_result no longer swallows a null response pointer, since answering OK with an empty transcript is the one failure mode this unit exists to prevent. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * 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 <dst-stem>.<name>.<ext>, 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 <mudler@localai.io> * backend(audio-cpp): refuse an impossible stem early, and stop blaming the caller for a failed write Four fixes from the first review of the AudioTransform RPC. check_can_serve now returns the resolved route, so params[stem] on a route that is not source separation is refused from the route instead of after a full inference: 11 ms rather than the 4.5 s a miocodec conversion costs, and far worse on seed_vc or vevo2. The post-run refusal stays as the backstop for a separation-routed family that returns no stems anyway. The typo'd-stem-name case still needs the run, since no framework header publishes the stem names before one. Stem names carrying control bytes are refused. GGUF strings are length prefixed and demucs reads its sources from JSON, so an embedded NUL survives to here: two names differing only after the NUL are distinct std::strings, so the duplicate check passes them, and then path::c_str() truncates both and they open the same file. That is exactly the silent overwrite the duplicate check exists to prevent, with the .wav lost as well. A failed write is now INTERNAL rather than INVALID_ARGUMENT. The destination is LocalAI's own generated-content directory, not anything the caller named, so a full disk or a permission fault there is a server fault and is worth retrying, which is the opposite of what INVALID_ARGUMENT tells a client. An empty output path stays INVALID_ARGUMENT. Two comment corrections and one clarification: the separators' required rate is their checkpoint's declared samplerate rather than a hardcoded 44100, seed_vc resamples with soxr and falls back to sinc-hann, and the "no files left behind" guarantee covers a refused request, not a write that fails partway through the loop. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(audio-transform): stop folding every upload to 16 kHz mono, and name the separation stems Two defects that made source separation unusable through LocalAI's own API, even though the backend served it correctly over gRPC. /audio/transform normalized every upload to 16 kHz mono s16 through utils.AudioToWav, with no way past it. htdemucs and mel_band_roformer refuse any rate but their checkpoint's own and separate a centred vocal from a wide mix using the stereo image, so every separation request through the HTTP API died with "HTDemucs prepare() sample rate mismatch: expected 44100, got 16000" while the same call over gRPC worked. The fold is not wrong, it is backend-specific: LocalVQE's echo cancellation genuinely wants 16 kHz mono and needs the reference in the same shape. So it becomes a declaration, BackendCapability.AudioTransformInputMono16k, set for localvqe and for nothing else. A backend that declares nothing gets its upload unchanged, which means no backend has to opt in to work. utils.AudioToWavPreservingShape is the non-folding conversion: a 16-bit PCM WAV passes through byte for byte at any rate and channel count, anything else is transcoded to WAV with its rate and channel layout kept. The other defect is that the run-once stem design bought nothing. A separation backend writes every stem beside dst from one inference, but AudioTransformResult carried only dst, so the other three were files no caller could find and a caller wanting all four had to run four separations. AudioTransformResult grows a repeated AudioTransformStem, the backend fills it, core/backend validates that each path really is inside the generated-content directory it handed over, and the endpoint publishes them as an X-Audio-Stems JSON header beside the existing X-Audio-Input-Url. JSON because a stem name is the model's own string and could contain any separator a hand-rolled format would use. Verified end to end through the HTTP endpoint with htdemucs f16 on a 44.1 kHz stereo file: 200 with a 44.1 kHz stereo body, all four stems named and fetchable through /generated-audio/, body byte identical to the selected stem, and params[stem]=drums returning a different one. The same upload sent to a model whose backend is localvqe still reaches the backend as 16 kHz mono, confirmed both by the engine's own rate refusal and by the persisted input file. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(audio-transform): reject extensible WAV from the passthrough, escape stem URLs, convert stems with dst Four fixes from the second review, plus one bug they made visible. isPCM16Wav tested only the bit depth, and go-audio's IsValidFile never looks at the format tag, so a 16-bit WAVE_FORMAT_EXTENSIBLE (0xFFFE) upload was passed through untouched where the old fold would have transcoded it. audio.cpp's WAV reader accepts 16-bit only when the tag is 1, so such a file died with "unsupported WAV encoding". Extensible is what many DAWs and Windows tools write and music files are this endpoint's new headline input, so it is a first-contact failure rather than a corner. The check now requires tag 1, with a spec that fails against the old implementation. Stem URLs are percent-escaped. A stem name is the model's own string and legally contains a space, a '#', a '?' or a '%'; an unescaped '#' truncates the URL before the request is even sent. The name field keeps the raw name. sample_rate and response_format are applied to the stems as well as to dst. Applying beat documenting: dst IS one of those stems, so leaving them alone broke the "dst duplicates the selected stem" invariant the whole design rests on, and both conversions are no-ops when unset. A stem whose conversion fails is dropped from the header rather than advertised in the wrong shape. Verifying that turned up why it had never been noticed: the two fields were never bound at all. The request arrives as multipart/form-data and echo's binder falls back to the FIELD NAME without a form tag, matching only case-insensitively, so "SampleRate" never matched "sample_rate" and "Format" never matched "response_format". Both were documented in the endpoint table and silently ignored. Two form tags fix it, and with them the conversion is observable end to end. Docs: audio-transform.md now documents what LocalAI does to an upload before the backend sees it, which backend gets the 16 kHz mono fold and why, params[stem], and the X-Audio-Stems header with a worked example. Also records the known limitation that the fold lookup is on the bare backend name, so pinned variants (vulkan-localvqe) do not match, and points at IsLlamaCppBackend as the suffix-tolerant precedent. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): serve the TTS and SoundGeneration RPCs TTSRequest.voice is treated as a speaker reference clip when it names an existing regular file, which makes routing prefer VoiceCloning, and as a named preset otherwise, in which case it travels as VoiceReference::cached_voice_id. Both the clip and SoundGenerationRequest.src are read at the file's own rate and channel count: upstream's own CLI and server do exactly that, every consuming family resamples internally and mostly with a better resampler than ours, and ace_step and stable_audio resample their input per channel, so a downmix here would delete the stereo image they are built to consume. The request builders live in their own unit rather than in grpc-server.cpp's anonymous namespace so they can be tested; grpc-server.cpp has a main() and cannot be linked into a test binary. The option keys are the whole point of these functions, so each one was grepped against the pinned upstream and the accounting is written down beside it. instructions maps to "instruct", which is what upstream's own server maps the OpenAI field to and what qwen3_tts and omnivoice read, and to "caption" for irodori_tts; the style tag is spelled "instruct" too, because "instructions" is looked up nowhere. duration maps to "duration_seconds", read by all three generation families, with the proto's own name kept only as a forward-tolerant alias. Keys that no family reads say so. Both handlers answer a capability refusal before taking the lane and before any file read, so a model that cannot synthesise does not queue behind somebody else's run to be told no. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): stop emitting an empty style language, and name the missing clip StyleCondition::language was set whenever has_language() was true, with no !empty() guard, while the language option twelve lines below had one. core/backend/tts.go sets Language unconditionally, so has_language() is true on every request LocalAI sends and carries "" when the caller named none. An engaged-but-empty style language is worse than an absent one: supertonic reads text_input->language behind its own !empty() guard and then overrides it from style->language with no guard at all, so "" replaced its "en" default and its tokenizer threw "invalid Supertonic language: ". Every /v1/audio/speech request that set instructions and no language would have been an INTERNAL against a supertonic model. A plain request never saw it, because the style condition only exists when instructions are non-empty, which is why the chatterbox end to end run did not catch it. TTS also stops discarding the Route that check_can_serve already returns. A family routed to voice cloning without a reference clip used to be refused from inside its own prepare(), which meant an INTERNAL naming neither the RPC nor the field to set; chatterbox advertises clon and no tts, so that was every preset-only request to it. It is now an INVALID_ARGUMENT naming TTSRequest.voice, answered in about 4 ms, and it cannot misfire because has_voice_reference is what selected cloning in the first place. Reading CapabilitySet::supports_speaker_reference to generalise this stays a follow-up. The src read carries a written caveat rather than a family blocklist, because ace_step's editing routes legitimately need src: setting src on a stable_audio model corrupts the heap and aborts the process in the pinned upstream, and the only thing keeping that off the network is that schema.ElevenLabsSoundGenerationRequest has no field for it. Nobody reading that Go schema would know why, so the reason is recorded where the field is read. build_tts_shape is extracted so TTSStream cannot describe the same request differently, and it arrived untested: two mutations of it survived until a test_tts_shape case was added. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): serve the TTSStream and AudioTranscriptionStream RPCs TTSStream leads with a streaming WAV header carrying 0xFFFFFFFF sizes, matching the convention backend/go/vibevoice-cpp established, so an HTTP client can start playback before the full PCM exists. Its chunks are read from StreamEvent::named_audio_outputs and not audio_output: supertonic, omnivoice and voxcpm2 all put their streamed audio there and leave audio_output empty until the very end, so reading the obvious field yields a stream with no audio in it. The finish_stream result is the family's own merged whole rather than a tail, so it is emitted only when nothing was streamed. Streaming transcription sends incremental deltas and degrades to a single delta plus the final result on families that offer no streaming ASR, which is the same message sequence with fewer deltas. The four streaming ASR families disagree on what partial_text means: nemotron_asr, vibevoice_asr and higgs_audio_stt report incremental fragments while voxtral_realtime reports the whole hypothesis and reports it twice, so the reconciliation lives in one tested unit rather than in the handler. nemotron_asr reports only through the stream event sink, and only from inside finalize, so the audio driver installs one and clears it again before returning: the session is cached and a sink left holding the caller's frame is a use after free waiting for the next stream. begin_stream is now the only implementation of the streaming state obligation, prepare then start_stream. Streaming sessions are cached, and what clears the previous stream is start_stream's reset; a family override that dropped it would break every call site with no compile error, so there is one call site. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): keep streaming deltas on UTF-8 boundaries, refuse dtypes that abort TranscriptStreamResponse.delta is a proto3 string, whose wire format requires valid UTF-8. voxtral_realtime reports its hypothesis as a concatenation of raw token BYTES (tokenizer_text.cpp:171-183), so the cumulative difference between two consecutive reports is eventually a lone continuation byte, and the C++ runtime serializes that with only a logged warning while the Go runtime refuses to unmarshal it: the client loses the remaining deltas AND the final_result. Measured on a trace of a non-ASCII sentence, 11 of 31 messages failed to unmarshal and every accented character was lost. TranscriptDeltaTracker now holds back an incomplete trailing sequence and merges it into the next fragment; reconcile flushes it, which it always can because the final text is complete. The same trace now unmarshals in full with zero failures. A streaming buffer whose float count is not a whole number of frames is refused rather than truncated. The integer division dropped the tail floats from the fed audio and therefore from the transcript, with no diagnostic; vibevoice_asr refuses the same thing from the other side of the call. A supertonic GGUF whose weights are not f32 is refused at load. It reaches ggml_concat with mismatched operand types and ggml_abort takes the whole backend process down on the first request, so nothing downstream can report it: the model loads, then every request kills the process. Attributed rather than assumed, the unary TTS path aborts identically, and upstream records that package as untested. The refusal names the orig package and says what to run before deleting the guard. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): stop a repeated lead byte from orphaning the next delta The first UTF-8 fix closed the cumulative half only. Rule 2 discards a fragment the known text already starts with, and when that fragment is the LEAD BYTE of a new character it looks exactly like a repeat of an older character beginning with the same byte. It was discarded rather than held, its continuation bytes then arrived alone and began the next delta, and utf8_complete_prefix_length only ever inspected the trailing sequence, so a delta invalid at the FRONT went out whole. Through a real Go proto.Unmarshal the review's four-character repro gave 3 deltas, 2 unmarshal failures and a lost transcript. Reachable from the incremental families, not only from voxtral: nemotron_asr's decoder cuts at a byte offset and vibevoice_asr's common_prefix_size compares bytes, so both split characters. Measured over 30,000 randomized incremental traces, 53.28% of Japanese traces and 9.52% of French ones carried at least one delta the Go runtime refuses. Two changes. Rule 2 no longer judges a fragment that ends mid-character, so the lead byte is held instead of swallowed and the character survives intact; the cost is a few duplicated bytes in a shrinking cumulative report, which no pinned family produces. release() additionally drops leading orphan continuation bytes, so no delta can begin mid-character whatever the rules above it decide. Losing a byte keeps the stream alive; emitting one ends the RPC and takes the final_result with it. Post-fix all 60,000 traces produce zero unmarshal failures, and the cumulative streams plus both pure-ASCII incremental streams are byte-identical to the previous commit, so nothing changed for the families already working. The weight-dtype allow list moves to family_gate, where it is stdlib-only and pinned by a test rather than only by a comment. Two comment citations corrected. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): read only an exact repeat as a repeat, not any prefix Rule 2 discarded any partial the known text merely started with. For a cumulative family that is a duplicate; for an incremental family it is an ordinary short fragment that happens to coincide with the start of the transcript, and it was dropped, silently corrupting the text. Pure ASCII, no multi-byte character anywhere: the fragments "pure ", "ascii ", "trans", "c", "ri", "p", "t" left the client holding "pure ascii transcrit". Over 5,000 randomized traces per transcript, 9.50% of pure-ASCII and 29.12% of French traces ended with the client holding something other than final_result.text, with a 200 and no diagnostic. Both incremental families emit fragments that small routinely, since nemotron_asr cuts at a byte offset and vibevoice_asr at a common prefix. Narrowing rule 2 to an exact repeat drives that to zero on all six transcripts and changes no cumulative stream at all: 30,000 randomized cumulative traces are byte-identical to the previous commit. What rule 2 guarded was established from upstream rather than from its own comment. The only duplicate any pinned family produces is voxtral_realtime's, where process_available_stream_chunks feeds each event to the sink from inside its loop and returns the last of the batch, so that event arrives twice with byte-equal text. A duplicate is an exact repeat, so equality still covers it. The case given up is a cumulative report that SHRINKS, which no pinned family can produce: voxtral decodes a token vector that is only push_back'ed and cleared by reset(), so within a stream it can only grow. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): serve the AudioTranscriptionLive RPC The one bidirectional stream this backend serves. The client sends a TranscriptLiveConfig, then TranscriptLiveAudio frames; the server acknowledges with ready, emits deltas as the audio arrives, and sends final_result once the read side closes. There is no offline fallback: live transcription has to consume audio incrementally, so a family with no streaming ASR is refused rather than served a batch run, which is what this RPC's Streaming-only mode_candidates list already says. The driver is a new sibling of run_streaming_audio, run_streaming_live, because the audio does not exist yet: instead of slicing a buffer it pulls frames from the caller until the read side closes. It installs the same ScopedStreamSink in the same order, which is not optional, since nemotron_asr returns a bare event from process_audio_chunk and reports every partial through the sink from inside finalize(). It buffers the wire's frames up to the family's own preferred window rather than feeding whatever size the client's audio callback produced, and it does not call finish_stream at all when no audio arrived, because nemotron_asr throws "finalize requires streamed audio" and an empty transcript is the truthful answer to transcribing nothing. Three things the handler had to get right and one it cannot: - The audio contract. A live request carries no samples, but nemotron_asr's streaming prepare() throws without an audio contract, and build_preparation_request derives it from TaskRequest::audio_input, so that field is an EMPTY buffer holding only the rate and the channel count. - 16 kHz or a refusal. The families express their spans in their own 16 kHz feature domain whatever the input was, and live frames cannot be resampled on the way in the way a file can, so an 8 kHz session would return timestamps 2x off with a 200. core/backend hardcodes 16000 anyway. - A mid-stream Config is refused. backend.proto calls it a decoder reset, but deltas already on the wire cannot be retracted, so a reset would leave the final text contradicting the transcript the client assembled. Ignoring the message would hand a client that believes it reset the decoder a transcript that silently continues the audio it thought it discarded. - The stale-route identity check cannot run here: TranscriptLiveRequest carries no ModelIdentity in either arm of its oneof, so snapshot_for does not instantiate for it. snapshot_unchecked's comment now names that as a second legitimate class of caller and says the fix is a proto change. eou and eob stay false. They exist for cache-aware models that emit end-of-utterance and end-of-backchannel tokens; audio.cpp's StreamEvent has no equivalent signal, and a client uses eou to decide the speaker yielded the turn, so a guess inferred from silence cuts people off mid-sentence. The lane is held for the whole stream, which is as long as the user keeps talking: the streaming session is stateful and cached, so a concurrent run would interleave two callers' audio and corrupt both transcripts. Verified against nemotron_asr over a real connection with a 14 s WAV in 512-sample frames: ready first, 59 incremental deltas with no repeated prefix, concat(deltas) equal to final_result.text, word timestamps in nanoseconds, eou and eob false. citrinet_asr answers UNIMPLEMENTED naming the family and listing asr/offline. A config followed by a close returns an empty final_result rather than hanging, and a first message that is not a config is INVALID_ARGUMENT. Two concurrent streams both return the complete transcript. Two cleanups on lines Task 12 touched, folded in. The DtypeAllowList terminator is now asserted at compile time: the reported out-of-bounds read did not exist, the single entry does terminate, but the loops have no other bound and any edit that widened an entry would walk off the end. And the dtype guard now short-circuits on "is there a table entry" through a new predicate rather than on the emptiness of the description string, which would have skipped the check on an entry with an empty allow list, i.e. on precisely the entry that refuses every dtype. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): bound the lane a live stream can hold AudioTranscriptionLive holds the model's inference lane for the whole stream, which is correct (the streaming session is stateful and a concurrent run would interleave two callers' audio) and newly dangerous. Every other RPC holds the lane across compute, or across a write to a slow reader, and both of those terminate on their own. A live stream instead blocks in a client-driven read, and a peer that goes silent WITHOUT closing the stream never terminates anything: the lane stays taken and every other request against that model queues behind a client that stopped speaking. live_watchdog is a one-shot idle timer that ends the stream when no frame has arrived inside a window. It is standard library only, so it is unit tested without an engine. gRPC's synchronous Read has no timeout and cannot be given one, so the only way to unblock it is ServerContext::TryCancel, which decides the wire status itself: the client sees CANCELLED rather than the DEADLINE_EXCEEDED the handler returns, the reason is logged, and the lane coming back is the point. When it fires the read loop throws rather than reporting end-of-input, so the driver does not go on to finalize a decode nobody is waiting for. It is armed only after the lane is taken and disarmed as soon as the read side closes, and both ends matter. Arming earlier would cover acquire(), which legitimately blocks while another live stream runs, so a queued caller would be cancelled for waiting its turn. Disarming later would cover our own decode, where a window overrun is not a peer going quiet and cancelling would throw away the transcript the client is waiting for. The window is the new live_idle_timeout_ms option, 30 s by default, 0 meaning no limit. core/http/endpoints/openai/realtime.go drives a 300 ms ticker and feeds every tick that produced new audio while a turn is open, so 30 s of silence is a hundred ticks that delivered nothing. It is also longer than any pause a speaker takes mid-utterance, which is the case that must never be cut off, and backend.proto lets one stream span many utterances, so a client that pauses longer between them raises the option rather than discovering it. Two smaller corrections in the same handler: - check_can_serve now runs BEFORE the sample rate check. pkg/grpc/grpcerrors/errors.go degrades to the file path on UNIMPLEMENTED and on nothing else, so a live-incapable model asked at a wrong rate was answering INVALID_ARGUMENT and costing the caller its fallback. - a negative sample rate is refused instead of silently becoming 16000. Zero still means 16000, which is what the proto documents; -1 is malformed rather than absent and gets the same refusal every other bad rate gets. And one thing recorded rather than changed, at the handler: "live" here means incremental INPUT, not low latency, and with the pinned families it does not yet mean incremental OUTPUT either. nemotron_asr's process_audio_chunk only appends to its buffer, so its whole decode and every delta happen inside finalize(), after the client closes its send side. The policy-window buffering is inert for that family and matters only for vibevoice_asr and higgs_audio_stt. Verified on the wire with live_idle_timeout_ms:3000. A silent client acked at 371 ms and was cancelled at 3.371 s; a second live stream opened one second later received its ack 2.37 s in, i.e. at the instant the first was cancelled, and then transcribed successfully on the same cached session. Without the watchdog it would still be waiting. Re-ran the live transcription (ready first, 59 incremental deltas, concat equal to the final text, word timestamps in nanoseconds, eou and eob false), the citrinet refusal at both a right and a wrong rate (UNIMPLEMENTED either way now), and Task 12's AudioTranscriptionStream on nemotron_asr, which is unchanged. Mutation testing the watchdog found a weakness in its own test: the destructor test slept past the window inside the watched scope, so a destructor that DETACHED the thread instead of joining it passed unnoticed. The test now uses a window longer than the scope, which kills that mutant, and says why. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * 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 <mudler@localai.io> * backend(audio-cpp): correct the vevo2 clause, and assert the absences Review found a false clause in the AudioToAudioStream refusal. It said s2s is "offline voice conversion ... which converts one clip into another speaker's voice", which is true of miocodec and false of vevo2: vevo2's s2s route is `editing` and only `editing` (default_route_for_task and route_matches_task in src/models/vevo2/session.cpp), documented as "Edit source speech into new target text while using the target voice" and requiring --target-text, so it rewrites what was said. vevo2's voice conversion is its separate vc task. It now reads "offline clip-to-clip processing against a target voice, declared only by miocodec (voice conversion) and vevo2 (speech editing)", and a test asserts the miscast cannot come back. The conclusion is unchanged: neither family converses. That defect was undetectable on the wire, since vevo2 does not load here, which is the argument for upstream_absence_ctest.cpp. It links engine_runtime purely to interrogate make_default_registry() and asserts the five premises the refusal reasons rest on: no codec task kind, no family advertising spk, no streaming for sep/vc/svc/s2s, miocodec advertising exactly vc and s2s, and s2s advertised by exactly miocodec and vevo2. The last two are exact sets, so an addition fails here rather than leaving a message stale. A positive control proves the registry is populated and the query works before any absence is believed, and every assertion has a reproduced negative control. This turns an AUDIO_CPP_VERSION bump from "remember to re-read five prose paragraphs" into a test failure. unsupported_surface now switches over UnsupportedRpc with no default label, so -Wswitch reports a sixth enumerator added without a row at build time; the runtime bounds guard it replaces is deleted. The AudioTransformStream reason had a true premise and an overreaching conclusion: an offline sep family could be buffered into a stream, as other LocalAI backends do. It now says this backend declines to offer a buffered offline call in disguise, rather than implying impossibility. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): make the missing-switch-case diagnostic fatal unsupported_surface() switches UnsupportedRpc onto the table row that explains it, with no default label, so -Wswitch reports an enumerator nobody handled. As a warning that is not enough: adding a sixth enumerator and building the shipping target gives exit 0, a binary and one warning, and the trailing `return surfaces[0];` then answers the new RPC with AudioEncode's codec reason. That is a confident, specific and false statement about audio.cpp on the wire, on the one code path whose entire job is to be truthful about what this backend cannot do, and it is worse than the runtime fallback it replaced, which at least named itself as a bug in this file. capability_routing.cpp therefore joins loaded_model.cpp on the existing -Werror=switch pin, whose comment already made this argument for the engine enum. The comment now covers both files. The pin stays per-file rather than project-wide because upstream's own ace_step/vae_decoder.cpp has unhandled -Wswitch cases of its own. Verified: a sixth enumerator now fails `make grpc-server` with exit 2 and no binary; appending a 14th VoiceTaskKind upstream still fails loaded_model.cpp, so the two pins fire independently; both reverted clean. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): package the backend image Bundles the dependency closure for the from-scratch image, the dlopened ggml CPU-variant shared objects that ldd cannot see, and upstream's bundled silero_vad and marblenet_vad assets so VAD works with no download. The bundled loader sits in the package ROOT rather than at lib/ld.so. run.sh execs it, which makes /proc/self/exe name the loader, and this backend has two consumers of that path: ggml discovers the libggml-cpu-*.so by listing dirname(/proc/self/exe), and resolve_model_path expands bundled:<name> under the same directory. Rooting the loader makes the binary, the ggml objects and assets/ share the one directory all three resolution mechanisms agree on. llama-cpp's lib/ld.so layout would need assets/ moved into lib/ as well. The image builds against apt gRPC and protobuf, like Dockerfile.ds4 and unlike Dockerfile.privacy-filter. The from-source gRPC that install-base-deps.sh and the base-grpc-* images supply vendors protobuf 26, which pulls abseil into message_lite.h; with SPM_PROTOBUF_PROVIDER=package that collides with sentencepiece's vendored mini-abseil and every absl::internal reference becomes ambiguous. Noble's protobuf 3.21.12 predates the abseil dependency and is the pair every earlier verification of this backend ran against. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): exempt the driver libraries from the packaging gate package.sh already left libcuda.so* and libnvidia-* to the host when copying, because the driver has to match the kernel module on whatever host runs the image, but the validation gate had no matching exemption. With BUILD_TYPE=cublas ggml is static and links CUDA::cuda_driver, so grpc-server carries DT_NEEDED libcuda.so.1 and the gate would have rejected the very absence the copy loop created, failing every cublas build in CI. One regex now feeds both. Building a control for that found a second defect: ld.so --list refuses to trace an object with an unresolvable dependency at all, exiting 127 without emitting a per-library line, so the "=> not found" rule was dead code and no exemption could have applied to it. The gate now traces with LD_TRACE_LOADED_OBJECTS and LD_LIBRARY_PATH, which reports the missing name and exits 0, and which is also what run.sh does at run time. Adds a layout assertion so a future move of the loader into lib/ fails the build instead of shipping a package that resolves bundled: models into lib/assets and finds no ggml CPU backend, and records for Task 16 that the Darwin script must not be a straight copy of privacy-filter-darwin.sh, which never calls package.sh and would silently drop assets/. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): register the backend with CI and the gallery Adds the five Linux matrix entries (cpu amd64/arm64 sharing a tag-suffix so the manifest merge fires, cuda 12, cuda 13, vulkan), the path-filter case that keeps later PRs touching backend/cpp/audio-cpp/ from getting zero CI jobs, the bump-bot entry pointing at the AUDIO_CPP_VERSION pin in the backend Makefile, the gallery meta plus its -development variant and the image entries for every variant, and the Makefile docker-build wiring. The matrix entries carry base-image only, with no builder-base-image, unlike the llama-cpp and privacy-filter blocks they sit next to. The prebuilt quay.io/go-skynet/ci-cache:base-grpc-* images ship a from-source gRPC whose protobuf v26 depends on abseil, and this backend's sentencepiece is built with SPM_PROTOBUF_PROVIDER=package, so it sees real abseil's absl::lts_20240116:: internal alongside its own vendored plain absl::internal and every absl::internal:: reference becomes ambiguous. Building against base-grpc-amd64 fails at sentencepiece-static.dir/error.cc.o with "reference to 'internal' is ambiguous". Dockerfile.audio-cpp installs apt's gRPC/protobuf 3.21.12 itself, which is also the pair every unit and end-to-end run of this backend has been verified against, and the CUDA toolkit therefore has to come from base-image. No Darwin matrix entry and no metal gallery entries: the Metal build needs scripts/build/audio-cpp-darwin.sh, a backends/audio-cpp-darwin make target and a routing step in backend_build_darwin.yml, none of which exist yet, so an entry added now would be routed to build-darwin-go-backend and look for backend/go/audio-cpp/. The inferBackendPathDarwin case and the DARWIN_BESPOKE_BUILDERS membership are in place, inert, so that adding the entry later is a one-line change that cannot be claimed by the generic Go path. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): pin the CUDA architectures, drop the vulkan variant Upstream sets CUDA_ARCHITECTURES to `native` on the engine_runtime target whenever CMAKE_CUDA_ARCHITECTURES is unset at root scope, and docs/build/ linux.md says so outright. ggml's own default does not rescue it: it list(APPEND)s in the ggml subdirectory scope, which never reaches the root scope where the engine_runtime property is decided. No CI runner has a GPU for `native` to enumerate, so both cublas entries would have gone red on the very commit that first turns a CUDA build on. Pin the list in backend/cpp/audio-cpp/Makefile, selected by CUDA_MAJOR_VERSION, which Dockerfile.audio-cpp now forwards from the CI build-arg it was previously discarding. The values are copied from ggml's own version guards rather than invented, so engine_runtime and ggml compile for the same set: CUDA 12 keeps the Maxwell/Pascal/Volta virtual archs and stops at 120a-real, CUDA 13 drops them and adds 121a-real. The `a` suffix is used rather than `f` because the latter needs CMake 3.31.8 and Ubuntu Noble ships 3.28.3. Verified by driving CMake 3.28.3's own CUDA architecture validator over both lists, with 120f-virtual as the rejected control. Drop the vulkan matrix entry, its two gallery entries, the vulkan capability key on both metas and the Vulkan tag. Every other vulkan backend gets its Mesa ICD drivers from .docker/install-base-deps.sh, which package-gpu-libs.sh then bundles; Dockerfile.audio-cpp calls neither and installs only libvulkan-dev and glslc, so the image would ship a Vulkan loader that finds no GPU. No CI job runs a vulkan image against real hardware, so that would have passed green and failed in users' hands. BUILD_TYPE=vulkan stays supported for local builds. Also note on the cublas entries that cuda-major-version now selects the architecture list and that cuda-minor-version and the base-image tag encode the same toolkit, and correct the stale entry counts on matrixEntryKey. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): build for Darwin Metal Bespoke C++ Darwin path like ds4 and privacy-filter: an includeDarwin matrix entry, a backends/audio-cpp-darwin make target, a gated workflow step, and the metal image entries plus metal/metal-darwin-arm64 capability keys in the backend gallery. The build script deliberately does NOT reassemble the package the way privacy-filter-darwin.sh does. It runs the backend's own `make package` and copies the result, so the Darwin package keeps the root-level layout the Linux one has: grpc-server, run.sh, the ggml objects and assets/ in one directory, with lib/ for the dylib closure. Hand-assembling would drop assets/, and assets/ is what makes the bundled: model paths resolve with nothing downloaded. The dylib walk is a full transitive closure rather than the single level ds4 and llama-cpp do, because Homebrew's grpc++ pulls libgrpc, abseil, upb, cares and OpenSSL that grpc-server does not link itself, and a level-1 walk ships a package that only works on a machine that already has Homebrew grpc. Two fixes folded in, both in the backend Makefile: - an EMPTY CUDA_MAJOR_VERSION fell through to the CUDA 12 architecture list, which contains 120a-real and so needs nvcc >= 12.8. A local BUILD_TYPE=cublas build on a 12.0-12.7 host failed to compile where upstream's documented default (native) worked. EMPTY now maps to native, 12 and 13 keep their lists, and any other non-empty value is an error on cublas builds. CI always passes a major, so CI is unaffected. - the Darwin branch now points CMake at Homebrew's keg-only libomp. AppleClang ships no OpenMP runtime and nothing is symlinked into /opt/homebrew, so FindOpenMP finds neither the library nor the header, and audio.cpp calls find_package(OpenMP REQUIRED) whenever ENGINE_ENABLE_OPENMP is on. Without the hint the macOS build would have died at configure time. If the keg is absent the build disables OpenMP instead of failing. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): make the Darwin fallbacks loud and the rpath walk complete Review follow-up on the Darwin Metal build. The OpenMP fallback was silent. If brew --prefix libomp ever comes back empty, CI produced a green Metal package with 108 #pragma omp directives across ~30 files compiled out, and clang says nothing about an ignored omp pragma without -Wsource-uses-openmp, so the only trace was one absent flag inside a set -x cmake line. That regression would have been blamed on Metal. It now warns. The @rpath arm of the dylib walk had no live candidate when it was written, on the reasoning that a Metal build links ggml statically. The OpenMP fix in the same commit made libomp.dylib one, and whether Homebrew records it as an absolute opt path or as @rpath/libomp.dylib is not observable from Linux. The walk now expands @rpath, @loader_path and @executable_path against the object's own LC_RPATH entries, and only fails when nothing on disk answers, printing the rpath list with the error so a failure on a machine nobody can attach to explains itself. Also: ADDITIONAL_LIBS now go through the closure rather than a bare cp, so they are deduplicated and their own dependencies bundled; build/darwin/lib is created explicitly instead of relying on package.sh pre-creating it; the libomp probe uses nested ifneq rather than $(and ...), which needs GNU make 3.81 and would otherwise expand empty and take the OFF branch on an older make; and -DOpenMP_ROOT is quoted like its CUDA sibling. Verified with a Linux harness that runs the script verbatim against a stubbed otool: a level-2 transitive dep, an @rpath dep reachable only through LC_RPATH, and an ADDITIONAL_LIBS dep are all bundled, a dependency cycle terminates, system libraries are skipped, the packaged tree has assets/ at the root beside grpc-server with the dylibs in lib/, and both failure paths exit non-zero. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): make bundled: reachable from a model YAML resolve_model_path() tested the bundled: prefix on `candidate`, which prefers ModelFile and falls back to Model. LocalAI fills ModelFile by joining ModelPath onto the configured model string (pkg/model/loader.go, LoadModelWithFile), and only sets it from a managed artifact otherwise, so a model YAML saying `model: bundled:silero_vad` arrives as ModelFile "/models/bundled:silero_vad" and Model "bundled:silero_vad". The prefix therefore never matched through the normal load path: it matched only for a hand-written LoadModel call that left ModelFile empty, which is exactly how task 15 verified it, and every model YAML using the form failed with "model path does not exist: /models/bundled:silero_vad". Both fields are now checked, Model first, so the zero-download VAD path the package ships assets for is reachable the way it is documented. A caller that puts the form in ModelFile still works, so task 15's verification stands. Compiled clean; the runtime check could not run on this host, whose system libprotobuf/libre2 have gone missing (the pre-existing grpc-server binary no longer resolves its libraries either), so it wants a container run. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): advertise the backend and document its options Registers audio-cpp as preference-only in /backends/known: the family lives in GGUF metadata that an importer cannot read from a remote repo, and one repo hosts thirty families, so there is no honest auto-detect signal. Modality is a single string and the import form chips on a fixed key set, so it registers as tts with the other modalities named in the description rather than under an invented key the UI would bucket as "other". Adds a features page covering the option namespacing, the routing table per endpoint, the RPCs this backend declines and why, the bundled VAD path, the separation stem behaviour, and the family gotchas (supertonic needs the orig package; chatterbox advertises cloning and no plain tts; nemotron_asr defers its whole decode to finalize so live transcription emits nothing until the client half-closes, unlike higgs_audio_stt and voxtral_realtime). Every option name and family capability in it was read off the pinned upstream checkout. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * backend(audio-cpp): test resolve_model_path, and correct the family names The bundled: fix in842443cd7shipped without a test, which is how the bug got there: task 15 verified the form with a hand-written LoadModel that left ModelFile empty, and that is the one shape the server never produces. Four cases in streaming_driver_ctest, which already links loaded_model.cpp, pin the PRODUCTION shapes instead. The first fails against the pre-fix source (returns the joined /models/bundled:silero_vad); the other three are the branches the bundled: lookup now runs in front of and must fall through for. Three family names in the docs were the source directory rather than the registered family, on pages whose whole argument is that these names cannot be guessed: demucs is htdemucs (demucs/loader.cpp:22), roformer is mel_band_roformer (roformer/assets.h:15), and moss is TWO families, moss_tts_local and moss_tts_nano. The hyphenated ASR names are underscored to match, here and in the compatibility table. The supertonic dtype note claimed more than the evidence carries. The f16 abort is a local observation, identical through TTS and TTSStream; upstream's docs/gguf.md leaves the 16-bit column untested and records q8_0 as "No (unsupported weight dtype)", which says unusable rather than fatal. Both are still refused, because the allow list is what the family can run. Corrected in family_gate.h, family_gate.cpp and the docs together, since the docs inherited the wording from the code. The importers tripwire says in the file that it is a tripwire: it exercises no audio-cpp behaviour, and the registration assertion lives in backend_test.go. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * gallery: add audio.cpp models covering every served RPC One representative model per RPC group of the audio-cpp backend, plus the two bundled VAD models, which need no download at all because the assets ship inside the backend package. Every hash was computed with sha256sum on the downloaded file. Quantizations come from upstream's tested-status table in docs/gguf.md rather than a default of q8_0: supertonic ships the orig package (its q8_0 is recorded as an unsupported weight dtype and its f16 aborts in ggml_concat), and nemotron_asr and htdemucs ship f16 because their q8_0 builds are recorded with drift while 16-bit is a clean pass. Diarization and separation use the diarization and audio_transform usecases, not transcript: /v1/audio/diarization and /audio/transform filter the default model on FLAG_DIARIZATION and FLAG_AUDIO_TRANSFORM respectively, so a transcript flag would have hidden both models from their own endpoints. The forced aligner sets parameters.language, which the transcription endpoint uses as the fallback when no language form field is sent, because the family requires both a transcript and a language. All ten entries were run twice: once against the raw gRPC server, and once installed with local-ai models install and called through the HTTP endpoint. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * gallery: correct the audio.cpp entries' licenses Swept all ten entries against the real upstream named in audio.cpp's tools/model_manager.py rather than against the audio.cpp repo's own license. Three were wrong: supertonic apache-2.0 -> openrail weights come from mlx-community/supertonic-3-mlx, and both it and Supertone/supertonic are openrail citrinet apache-2.0 -> other pulled from NGC nvidia/nemo/stt_en_citrinet_256, governed by the NGC Terms of Use sortformer other -> cc-by-nc-4.0 nvidia/diar_sortformer_4spk-v1 is CC BY-NC 4.0, and the gallery already uses that exact string, so there is no reason to obscure a non-commercial bar The license field is one word, so citrinet and sortformer also gained a sentence saying why they are restricted. The other seven were confirmed correct against their sources. Also drops an unverified claim from the nemotron description. It said the model drives the realtime transcription session; that endpoint actually calls TranscribeStream, and the live RPC reaches LocalAI only through realtime_semantic_vad.go. Neither path was exercised here, so the description now states only the two calls that were. MarbleNet gains the NeMo upstream under urls: for parity with silero. No sha256, quantization, usecase or model choice changed. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(audio-transform): bound sample_rate, keep same-named uploads apart Four defects the whole-branch review found on the Go side, plus two comment corrections. sample_rate is a disk-exhaustion hazard. The branch added the `form:` tag that makes the field bind for the first time, so the resample path went from dead to live, and utils.AudioResample interpolates the int straight into ffmpeg's -ar with no bound. Measured with ffmpeg 7: -ar 999999999 on a 0.01 s clip writes 20 MB and exits 0, which scales linearly to the reported 3.9 GB for one second, into a GeneratedContentDir nothing sweeps, and convertStems repeats it once per separation stem. Clamped to 8000..192000 in the handler, before the temp dir and before the model is touched, and rejected with a 400 outside it. The low end was reported as "a 0-byte file". It is not: -ar 1 writes a 78-byte header with no audio behind it, whose declared data size still claims 70 bytes, so go-audio parses it as a 35 SECOND file and a size check does not see it. The guard therefore compares the declared data chunk against the bytes actually on disk, and AudioResample now fails rather than returning a WAV carrying nothing. Both parts of a transform request land in one temp dir, and the raw copy was named only after the client's basename, so `-F audio=@mic/clip.wav -F reference=@loopback/clip.wav` wrote "raw-clip.wav" twice. Since AudioToWavPreservingShape hardlinks an already-PCM16 WAV rather than copying it, the reference part's os.Create truncated the inode audio.wav pointed at: mic and reference came out identical, which makes an echo canceller null everything and return near-silence with a 200. The raw copy now carries the form field name. audio-cpp had no BackendCapabilities entry, so VoiceCloningForModel returned nil before it ever consulted the model's tts.voice_cloning override and every `voice: "profile:<id>"` request was refused with a 400, on a backend that ships audio-cpp-chatterbox whose family serves cloning and not plain TTS. Registered with its RPCs, usecases and the reference-audio contract, and deliberately without the 16 kHz mono fold, which its separation families cannot survive. GetBackendCapability was exact-match only, so every pinned gallery variant read as an unknown backend: vulkan-localvqe lost the 16 kHz mono fold that used to be unconditional and started failing inside LocalVQE, and the usecase gate does not stand in for it because BuildFilteredFirstAvailableDefaultModel returns early once the client names a model. Lookup now falls back to the meta name by stripping the gallery's hardware prefix and release-channel suffix, exact match first so nothing can be shadowed. Same class as #10945. Also corrected: the AudioTransformRequest comment claimed echo's binder falls back to the field name, which it does not in either direction (bindData binds ONLY tagged fields and `continue`s otherwise; `model` arrives from setModelNameFromRequest's c.FormValue). And the stable_audio `src` heap corruption caveat now lives on ElevenLabsSoundGenerationRequest, where the Go developer who would add the field can see it, instead of only in C++. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(audio-cpp): refuse a task pin the RPC cannot serve, and stop empty frames holding the lane The model's `task:` option is copied into the request shape by all nine handlers, which is correct, but resolve_route then replaced the RPC's candidate list with the pin WHOLESALE and never asked whether the pin was something that RPC routes to. One pin therefore bled across all nine surfaces, and because the family still supported the pinned task the result was a wrong 200 rather than an error. Reproduced live: nemotron with task:asr made Vad return 200 with zero segments after a full ASR decode, so 14 seconds of speech was reported as silence, and Diarize did the same; silero_vad with task:vad made AudioTranscription return 200 with empty text and four segments whose spans were VAD segments, which combined with response_format in {text,srt,vtt,lrc} building the body solely from Segments[].Text yields a well formed SRT of four timed EMPTY cues. It also contradicted the documented contract, that a family which cannot serve a request is refused rather than rerouted. A pin is now checked against the RPC's admissible task set before it is adopted, and the refusal names both the pin and the RPC. The set is derived from task_candidates with every shape flag set rather than restated, so a task added to an RPC's candidates cannot become inadmissible by omission. Every legitimate pin survives, and the test asserts all fifteen of them alongside the eight crossings that must not. The live watchdog was defeated by empty frames. idle.touch() ran on ANY message, before the has_audio and pcm.empty() filters, so a peer writing unset-oneof or zero-length frames faster than the window held the lane indefinitely while feeding the decoder nothing. There is one lane per model and one model per process, so that is a single client denying the whole backend, which is what the watchdog exists to prevent, and the thrown text already said "no audio frame arrived". The touch moved below the filters, which are now a named predicate so the distinction is testable rather than a call order nobody can see. Three comments corrected against measurement rather than reasoning: - CMakeLists claimed zero google::protobuf:: definitions remain in the executable. nm -C --defined-only reports 2515, and that is expected: they are generated code, sentencepiece::ModelProto's own _InternalParse among them. The claim that holds, and the one the ABI fix is actually about, is that no vendored protobuf RUNTIME is linked and ParseContext::ParseMessage is UNDEFINED in the executable, resolving to libprotobuf.so. - refuse_cloning_without_a_clip's "cannot misfire" paragraph had its reasoning backwards. Routing picks VoiceCloning as the FALLBACK when there is no clip, which is the case being caught; chatterbox, which ships in the gallery, advertises clon and no tts at all, so every voice-less request lands there. - audio_units read "2.1 min at 96 kHz" for index 11289602, which is 1.96 min. 2.1 min is 96 kHz's OWN first failure at 12288002. Both were remeasured and the note is now a per-rate table. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * build(audio-cpp): exclude the upstream checkout from the C++ gate, harden the darwin walk run-unit-tests.sh pruned */llama.cpp/* but not */audio.cpp/*. It is safe today only by luck: upstream's 44 tests all put "test" at the FRONT of the filename (17 test-*.cpp, 27 test_*.cpp, zero *_test.cpp), so the glob misses every one of them, and nothing enforces that. This gate runs on every PR for every backend and compiles each match as a standalone translation unit with nothing but nlohmann/json on the include path, so the day upstream adds or renames one test the gate goes red repo-wide on an Apache-2.0 file nobody here wrote. audio-cpp-darwin.sh now logs the raw otool -L output and the parsed LC_RPATH list unconditionally, before the walk. Both awk filters in that script assume a column layout nobody working on this can observe, since it runs only on the CI Mac, and a green first Darwin run proves nothing about the assumption: an awk that silently matched nothing yields an empty dependency list, which reads exactly like "no non-system dependencies" and packages happily. Both filters otherwise feed process substitutions, so their input never reached the log. It also lists every symlink in the package and fails on one that cannot resolve inside the image. A dangling link does not fail anything else here, because every assertion tests with -e, which follows links; it fails at dlopen on a user's Mac. Links are NOT banned outright, which the review suggested but which would break the libggml.dylib -> libggml.0.dylib chain the `cp -a` above exists to preserve. What is banned is a link that resolves on the build host and will not resolve in the image: a broken one, or an absolute one pointing outside the package. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * style(audio-cpp): drop em dashes from the audio-cpp capability entry Follow-up toa84b3c4b9, no behaviour change. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(config): key the voice-cloning model rule on the resolved backend Making GetBackendCapability strip the gallery hardware prefix and release channel fixed pinned variants of /audio/transform, but VoiceCloningForModel kept keying its per-backend switch on the caller's spelling. A pinned name therefore resolved the capability by stripping and then missed every case in the switch, falling through to the permissive default: cuda12-vibevoice-cpp advertised voice cloning for the realtime 0.5B model, metal-coqui for tacotron2, cuda12-crispasr for a pure ASR model, cpu-qwen3-tts-cpp for CustomVoice. Each of those is a model that cannot clone, so /v1/audio/speech accepted a profile: voice it had to fail on inside the backend rather than rejecting it with a 400, and the UI advertised the capability too. resolveBackendCapability now returns the key the entry was found under, and callers that branch on backend identity use that key instead of the name they were handed. The exact-match-first order is unchanged, so a backend genuinely registered under a variant-looking name still keys on its own name. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-5 [Claude Code] * gallery(audio-cpp): declare audio_transform on the chatterbox entry Chatterbox advertises VoiceCloning AND VoiceConversion (src/models/chatterbox), and the entry's own description already said so, but known_usecases listed only tts. /audio/transform selects its default model by FLAG_AUDIO_TRANSFORM, so voice conversion was reachable only by naming the model explicitly and was invisible to every usecase-driven surface. It is the one audio.cpp task with a shipped gallery model and no way to find it. Verified against the real model rather than inferred from the capability list: AudioTransform with chatterbox-q8_0, speech as audio_path and a speaker clip as reference_path, returns a 5.08 s 24 kHz mono WAV at -25.5 dB mean and zero stems, which is the single-output shape voice conversion should have. The description now says which endpoint reaches that half and warns that installing this next to a source-separation model gives /audio/transform two candidates, so the model should be named rather than defaulted. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-5 [Claude Code] * gallery(audio-cpp): add voice-design and singing-voice-conversion entries Two of the three audio.cpp task kinds that had no gallery model now have one. Both were driven end to end against the real weights through the backend before being written, not inferred from the capability tables. audio-cpp-irodori-voicedesign covers vdes. TTS carrying `instructions` routes to the vdes task, so the voice is described in words rather than supplied as a clip. Verified: "a calm elderly woman speaking slowly with a warm, gentle tone" over an 8.76 s 48 kHz mono render at -16.8 dB mean, and a closed-loop citrinet pass recovers the sentence with the accent drift expected from a Japanese-first model read by an English recogniser. audio-cpp-seedvc-singing covers svc, and pins task:svc because nothing else can reach it. seed_vc advertises svc and ordinary voice conversion, no request signal means "this input is singing", and auto-routing resolves the tie to voice conversion every time. Verified with the pin: 5.04 s 44.1 kHz output whose closed-loop citrinet transcription is exact. s2s deliberately has no entry, and the reason is not effort. miocodec is the only upstream family whose speech-to-speech route needs no text, and it returned audio with correct duration and level but no recoverable speech in four independent attempts: the stale build, v2 q8_0, v2 orig (the variant upstream records as a clean Pass), both tasks, and matched 44.1 kHz inputs on both sides. vevo2's route refuses with "Vevo2 text/prosody route requires text_input or target_text", and session.cpp:897 fills target_text only from request.text_input, which AudioTransform has no field to carry. The same vevo2 weights convert voice correctly through the default route with an exact ASR round trip, so the model and the plumbing are both healthy; it is the s2s route specifically that this RPC cannot express. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-5 [Claude Code] * backend(audio-cpp): carry transform text through params, add the s2s entry AudioTransform is audio-in / audio-out and its proto message has no text field, but not every task it routes to is audio-only. vevo2's speech-to-speech route is a text and prosody route: session.cpp:897 fills refs.target_text from request.text_input and nowhere else, and the run refuses without one with "Vevo2 text/prosody route requires text_input or target_text". The params map is the only channel this RPC has that reaches the engine, so the text travels through it and apply_transform_text_input unpacks it after the params have been copied into task.options. Before this, s2s was not awkward to reach through /audio/transform, it was unreachable, and it was the last audio.cpp task kind with a real model and no way to get to it. target_text is canonical and text is its alias, the order vevo2's own option table declares them in, so a request setting both gets the canonical one rather than whichever the map happened to store first. An empty value falls through to the next candidate instead of ending the search. language rides along only when a text was found: on its own it conditions nothing, and manufacturing a text_input for it would route a plain separation request carrying a language hint through the text path. The keys are left in task.options rather than erased, because vevo2's loader advertises target_text as a request option and a family reading it there keeps working. Nine tests, all confirmed failing on behaviour against a stub that returned false before the implementation was written. Verified end to end afterwards: vevo2-q8_0 with task:s2s and params[text] returns a 5.12 s 24 kHz output whose closed-loop citrinet transcription is exact, and htdemucs separation with no text param still returns its four stems, with and without params[stem]. audio-cpp-vevo2-speech-to-speech ships that route. Every audio.cpp task kind with a loadable family now has a gallery entry; spk remains the only gap and has no family upstream at all. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-5 [Claude Code] * docs(audio-cpp): document params[text] and the pinned transform tasks The text channel and the two task pins are both invisible from the endpoint contract alone: nothing in the AudioTransform form tells a reader that a speech-to-speech model needs the line it is resynthesising, and nothing says that asking for singing voice conversion without task:svc silently gets plain voice conversion instead. Both are the kind of thing a user only discovers from a refusal or, worse, from output that looks right and is not. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-5 [Claude Code] * fix(utils): annotate the two G304 sites this branch introduced gosec flags os.Open on a variable path, and both new call sites in ffmpeg.go are its alerts on this PR. Neither is reachable by an outside caller: isPCM16Wav opens the exact path it is about to hand ffmpeg as input, which in the upload path is a server-created temp file named from path.Base of the client name so no traversal survives, and wavAudioBytes opens AudioResample's own dst, a name this package derives from src and has just had ffmpeg write. Annotated in the repo's existing style rather than restructured, with the reason spelled out, because a bare suppression is worth nothing to the next reader. The three other G304 sites in this file, in passthroughWAV and isTargetWav, predate the branch and are left untouched. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-5 [Claude Code] * fix(audio-cpp): build arm64 with gcc-14 for the armv9.2 SME variants The arm64 CPU image failed to build: cc1: error: invalid feature modifier 'sme' in '-march=armv9.2-a+dotprod+fp16+sve+i8mm+sve2+sme' ggml's CPU_ALL_VARIANTS table includes armv9.2 variants compiled with +sme, and Ubuntu Noble's default gcc-13 rejects that feature modifier. Every entry in the table has to compile even though a host only ever dlopens the one its own CPU supports, so a single unbuildable variant fails the whole image. gcc-14 accepts it, which is exactly the fix llama-cpp already carries in .docker/llama-cpp-compile.sh; this is the same problem reached by a different Dockerfile. Applied to every arm64 BUILD_TYPE rather than to the CPU one alone, and that differs from llama-cpp on purpose. llama-cpp needs it only for its pure-CPU image because its GPU builds run llama-cpp-fallback, which builds no variant table. This backend's Makefile turns ENGINE_ENABLE_CPU_ALL_VARIANTS on for every non-Darwin build, GPU included, so an arm64 GPU image would hit the identical error. The matrix has no arm64 GPU entry today, which is precisely why gating on an empty BUILD_TYPE would leave the trap armed for whoever adds the first one. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-5 [Claude Code] --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2089 lines
110 KiB
C++
2089 lines
110 KiB
C++
// audio.cpp LocalAI gRPC backend.
|
|
//
|
|
// Links 0xShug0/audio.cpp's engine_runtime through its public
|
|
// include/engine/framework/** headers only. Nothing under upstream's app/,
|
|
// 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,
|
|
// Diarize, AudioTransform, TTS, SoundGeneration, TTSStream,
|
|
// 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"
|
|
|
|
#include "audio_io.h"
|
|
#include "audio_units.h"
|
|
#include "capability_routing.h"
|
|
#include "generation_request.h"
|
|
#include "inference_lane.h"
|
|
#include "live_watchdog.h"
|
|
#include "loaded_model.h"
|
|
#include "model_options.h"
|
|
#include "result_map.h"
|
|
#include "stem_selection.h"
|
|
#include "stream_delta.h"
|
|
#include "wav_header.h"
|
|
|
|
#include <grpcpp/grpcpp.h>
|
|
#include <grpcpp/server.h>
|
|
#include <grpcpp/server_builder.h>
|
|
#include <grpcpp/support/sync_stream.h>
|
|
#include <grpcpp/ext/proto_server_reflection_plugin.h>
|
|
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <csignal>
|
|
#include <cstdint>
|
|
#include <cstdlib>
|
|
#include <exception>
|
|
#include <iostream>
|
|
#include <limits>
|
|
#include <memory>
|
|
#include <mutex>
|
|
#include <optional>
|
|
#include <set>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
using grpc::Server;
|
|
using grpc::ServerBuilder;
|
|
using grpc::ServerContext;
|
|
// Do NOT alias grpc::Status as Status: the Status RPC method would shadow the
|
|
// type and break every other method that names it as a return type.
|
|
using GStatus = ::grpc::Status;
|
|
|
|
namespace {
|
|
|
|
// Set by the signal handler, read by the thread that owns the server. The whole
|
|
// point of the indirection is that the handler itself does nothing else: see
|
|
// signal_handler below.
|
|
std::atomic<bool> g_shutdown_requested{false};
|
|
|
|
// One model per process, matching every other LocalAI backend. The mutex guards
|
|
// the pointer itself, not the model: swapping or dropping it races with the
|
|
// handlers that read it, whereas the model's own concurrency is the inference
|
|
// lane's job.
|
|
//
|
|
// shared_ptr, not unique_ptr. An audio RPC runs for seconds and cannot hold
|
|
// g_model_mu for its duration, so it has to work from a reference taken under
|
|
// the lock and used after releasing it. Under a unique_ptr, a Free or a reload
|
|
// arriving mid-request destroys the model out from under that reference. Every
|
|
// handler instead takes a counted reference through snapshot_for(), so Free
|
|
// drops the global's reference and whichever request finishes last destroys the
|
|
// model.
|
|
std::mutex g_model_mu;
|
|
std::shared_ptr<audiocpp_backend::LoadedModel> g_model;
|
|
|
|
// The raw reference-taking primitive. Returns by value, so the caller owns a
|
|
// reference for as long as its local lives, and null when no model is loaded.
|
|
// Never return LoadedModel& from here: that is the shape that reintroduces the
|
|
// use-after-free.
|
|
//
|
|
// _unchecked because it performs NO request-level validation. There are exactly
|
|
// 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, 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.
|
|
std::shared_ptr<audiocpp_backend::LoadedModel> snapshot_unchecked() {
|
|
std::lock_guard<std::mutex> lock(g_model_mu);
|
|
return g_model;
|
|
}
|
|
|
|
// LocalAI's VADRequest carries raw floats and no sample rate at all. Every
|
|
// LocalAI VAD backend treats them as 16 kHz mono (backend/go/silero-vad/vad.go
|
|
// and backend/go/sherpa-onnx/backend.go both hardcode 16000) and core/backend
|
|
// feeds them from a 16 kHz pipeline, so the same assumption is made here rather
|
|
// than left implicit. If the proto ever grows a sample rate field, this is the
|
|
// constant to delete.
|
|
constexpr int kVadSampleRate = 16000;
|
|
|
|
// The rate every file-fed speech route reads its input at, and the rate the
|
|
// spans that come back are therefore interpreted in. Passed to read_audio_file,
|
|
// which resamples only when the file differs.
|
|
//
|
|
// Two independent reasons, and the second is the one that is easy to miss:
|
|
//
|
|
// 1. Some families refuse anything else outright. silero_vad throws
|
|
// "Silero VAD 16k model only supports sample_rate=16000" and
|
|
// sortformer_diar throws "Sortformer diar currently requires 16 kHz input
|
|
// audio". That is what made a 44.1 kHz upload return INTERNAL with an
|
|
// engine-internal message instead of an answer.
|
|
// 2. The families that do NOT refuse still do not all express their result
|
|
// spans in the input's domain. nemotron_asr builds every word timestamp as
|
|
// token_frame * hop_length * subsampling_factor, which is its own 16 kHz
|
|
// feature domain whatever the input was; the handler then converts those
|
|
// spans with the buffer's rate. Feed it 44.1 kHz and every emitted
|
|
// timestamp is 2.76x too small, with a 200 and no diagnostic. Resampling
|
|
// the input to 16 kHz makes the buffer domain and the span domain the same
|
|
// one, which is the only reason the nanoseconds are right.
|
|
//
|
|
// The cost, stated plainly: vibevoice_asr resamples internally to 24 kHz, so a
|
|
// 48 kHz upload now travels 48 -> 16 -> 24 rather than 48 -> 24. That is not
|
|
// merely band limiting. resample_mono_linear is linear interpolation with no
|
|
// anti-alias filter (framework/audio/conversion.cpp), so the content above
|
|
// 8 kHz is ALIASED down on the way in, and the 16 -> 24 step aliases whatever
|
|
// the first step left. It is accepted because a silently wrong timestamp is
|
|
// worse than an aliased band, and because every other LocalAI ASR path already
|
|
// feeds 16 kHz. If the framework ever publishes a per-model preferred input
|
|
// 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 N" (src/models/demucs/session.cpp) and the same shape in
|
|
// src/models/roformer/session.cpp. That N is the CHECKPOINT'S declared rate,
|
|
// read from the packaged config ("samplerate" in demucs/assets.cpp,
|
|
// "sample_rate" in roformer/assets.cpp), not a constant: it is 44100 for every
|
|
// published checkpoint of both, which is why the messages below say 44100, but
|
|
// a checkpoint declaring something else would demand that instead, and only
|
|
// passing the file through unchanged can satisfy either. 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: soxr when it is available, falling back to
|
|
// resample_mono_torchaudio_sinc_hann (seed_vc/audio_features.cpp).
|
|
// vevo2 (vc, s2s, svc) normalize_audio_to_24k_mono converts whatever
|
|
// it is given to 24 kHz mono before anything else runs, linearly.
|
|
// miocodec (vc, s2s) prepare_miocodec_mono_audio mixes down, then
|
|
// resamples to the model's rate with sinc-hann.
|
|
// chatterbox (vc) ChatterboxVcComponent::convert normalizes the source to
|
|
// 16 kHz and the reference to 24 kHz itself, linearly.
|
|
//
|
|
// So every one of them resamples internally, and passing the file through
|
|
// unchanged is better than folding it first. The reason is the DOUBLE
|
|
// conversion, not resampler quality: two of the four resample linearly, exactly
|
|
// as read_audio_file would. Their outputs run at 22.05 to 44.1 kHz, so
|
|
// pre-folding to 16 kHz mono with read_audio_file's LINEAR, unfiltered
|
|
// resampler would band-limit at 8 kHz and alias, and then the family would
|
|
// resample that damaged signal a second time on its way up. One conversion is
|
|
// the floor; this constant is what keeps it at one.
|
|
//
|
|
// 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 what they want is an
|
|
// ordinary music file at its own rate.
|
|
constexpr int kTransformSampleRate = 0;
|
|
|
|
// The rate TTS reads its speaker reference clip at, and the rate
|
|
// SoundGeneration reads its `src` editing clip at. Zero, i.e. the file's own
|
|
// rate and its own channel count, no resample and no downmix.
|
|
//
|
|
// Settled from upstream rather than reasoned about, and upstream settles it
|
|
// twice over:
|
|
//
|
|
// 1. Upstream does exactly this itself. Both its CLI and its HTTP server load
|
|
// a voice reference with minitts::cli::read_audio_buffer (app/cli/request.cpp),
|
|
// which is read_wav_f32 and then {wav.sample_rate, wav.channels,
|
|
// wav.samples} verbatim. app/server/runtime.cpp's build_speech_request
|
|
// puts that buffer straight into voice.speaker->audio, and its
|
|
// audio_input path (line 518) does the same for the editing clip. Every
|
|
// family that consumes a reference has therefore only ever been tested
|
|
// against native-rate, native-channel input.
|
|
// 2. Every consuming family converts it itself, and most of them with a
|
|
// BETTER resampler than read_audio_file's. Checked one at a time:
|
|
// chatterbox to_mono_audio, then 24 kHz, then 16 kHz derived from
|
|
// the 24 kHz path (conditionals.cpp), matching Python's
|
|
// prepare_conditionals ordering.
|
|
// index_tts2 mixdown_interleaved_to_mono_average, then a soxr or
|
|
// torchaudio sinc-hann resample to its mel rate and to
|
|
// 16 kHz (audio_features.cpp, waveform_22k).
|
|
// higgs_audio_tts mixdown, then sinc-hann to 24 kHz and 16 kHz.
|
|
// irodori_tts mixdown, then sinc-hann (codec.cpp).
|
|
// voxcpm2 mixdown, then soxr-or-linear (audiovae.cpp).
|
|
// omnivoice mixdown, then sinc-hann (audio_tokenizer.cpp).
|
|
// qwen3_tts convert_interleaved_audio_to_mono_linear_resampled.
|
|
// For the SoundGeneration side, ace_step resamples the LEFT and RIGHT
|
|
// channels SEPARATELY (pre_dit.cpp), and stable_audio resamples per
|
|
// channel too, so a mono downmix here would destroy input those two are
|
|
// built to consume as stereo.
|
|
//
|
|
// So folding to 16 kHz mono first would band-limit at 8 kHz through
|
|
// read_audio_file's LINEAR, unfiltered resampler, alias what is above it, and
|
|
// then hand that damaged signal to a good resampler for a second conversion.
|
|
// One conversion is the floor, and passing the file through unchanged is what
|
|
// keeps it at one. Same argument as kTransformSampleRate, same value, kept as a
|
|
// separate constant because the routes are different and a future per-model
|
|
// preferred-rate lookup would have to answer them separately.
|
|
constexpr int kVoiceReferenceSampleRate = 0;
|
|
|
|
// Parses ModelOptions.MainGPU into a device index.
|
|
//
|
|
// Not std::atoi: it returns 0 for anything unparseable, so "gpu1" or a device
|
|
// UUID would silently become device 0 and the model would load on the wrong
|
|
// device with no diagnostic anywhere. A refusal the operator can read beats a
|
|
// wrong answer they cannot see.
|
|
int parse_device_index(const std::string &value) {
|
|
size_t consumed = 0;
|
|
long parsed = 0;
|
|
try {
|
|
parsed = std::stol(value, &consumed);
|
|
} catch (const std::exception &) {
|
|
consumed = 0;
|
|
}
|
|
if (consumed != value.size() || parsed < 0 ||
|
|
parsed > std::numeric_limits<int>::max()) {
|
|
throw audiocpp_backend::ConfigError(
|
|
"audio-cpp: main_gpu must be a non-negative device index, got '" +
|
|
value + "'");
|
|
}
|
|
return static_cast<int>(parsed);
|
|
}
|
|
|
|
// Mirrors pkg/grpc/server.go's checkModelIdentity, backend/cpp/llama-cpp,
|
|
// backend/cpp/ds4, backend/cpp/privacy-filter and
|
|
// backend/python/common/model_identity.py.
|
|
//
|
|
// Why every backend has to carry this: in distributed mode a worker can recycle
|
|
// a stopped backend's gRPC port for a different model's backend, and the
|
|
// controller's liveness-only health probe cannot tell a stale cached route from
|
|
// a live one. Only the backend knows which model it actually loaded, so only
|
|
// the backend can catch it (#10952). Without this, an audio-cpp process reached
|
|
// through a stale route answers with a DIFFERENT model's VAD or diarization
|
|
// result and a 200, which is a wrong answer nobody can see.
|
|
//
|
|
// Either side empty means "skip". The request side is empty for a controller
|
|
// that predates the field; the loaded side is empty when such a controller
|
|
// performed the load. Neither can judge the other, and a false rejection is
|
|
// worse than the miss it prevents.
|
|
//
|
|
// Templated over the request type because every guarded message exposes
|
|
// modelidentity(), and one body keeps the rule identical across RPCs rather
|
|
// than letting it drift per handler.
|
|
//
|
|
// The loaded identity is read off the LoadedModel rather than a separate
|
|
// global, which is where this differs from llama-cpp. A handler holding the
|
|
// model through snapshot_for() is then necessarily judging against the identity
|
|
// THAT model was loaded with, and a concurrent reload cannot swap one without
|
|
// the other.
|
|
template <typename Request>
|
|
GStatus check_model_identity(const audiocpp_backend::LoadedModel &model,
|
|
const Request *request) {
|
|
if (request == nullptr || request->modelidentity().empty()) {
|
|
return GStatus::OK;
|
|
}
|
|
const std::string &loaded = model.identity();
|
|
if (loaded.empty() || loaded == request->modelidentity()) {
|
|
return GStatus::OK;
|
|
}
|
|
// NOT_FOUND plus this exact sentinel is the cross-language wire contract
|
|
// the router matches on (grpcerrors.ModelMismatchSentinel). The code alone
|
|
// is not enough, since NOT_FOUND is returned for unrelated reasons
|
|
// elsewhere, so the substring "model identity mismatch" must survive
|
|
// verbatim through any edit to this message.
|
|
return GStatus(grpc::StatusCode::NOT_FOUND,
|
|
"audio-cpp: model identity mismatch: loaded \"" + loaded +
|
|
"\", requested \"" + request->modelidentity() + "\"");
|
|
}
|
|
|
|
// The ONE way an RPC handler should reach the model. Takes the counted
|
|
// reference, refuses when nothing is loaded, and runs the identity check, in
|
|
// that order. Returns null with `out` set to the status to return; on success
|
|
// returns the model and leaves `out` OK.
|
|
//
|
|
// This exists as a structural guarantee rather than a convenience. The identity
|
|
// check used to be two lines every handler had to remember, and nothing failed
|
|
// if a new handler forgot them: there is no C++ equivalent of
|
|
// pkg/grpc/model_identity_modalities_test.go, so the convention could only rot.
|
|
// Every handler already has to call something to obtain the model, so making
|
|
// the guarded call the shortest path means the default is correct and skipping
|
|
// it requires deliberately typing snapshot_unchecked.
|
|
//
|
|
// The order is forced: the identity being compared against belongs to the model
|
|
// this call is about to use, so it cannot precede taking the reference. And it
|
|
// must precede routing, or a mismatched request to a model that cannot serve
|
|
// the RPC leaks UNIMPLEMENTED instead of the NOT_FOUND the router matches on.
|
|
template <typename Request>
|
|
std::shared_ptr<audiocpp_backend::LoadedModel>
|
|
snapshot_for(const Request *request, GStatus &out) {
|
|
auto model = snapshot_unchecked();
|
|
if (model == nullptr) {
|
|
out = GStatus(grpc::StatusCode::FAILED_PRECONDITION,
|
|
"audio-cpp: no model is loaded; call LoadModel first");
|
|
return nullptr;
|
|
}
|
|
out = check_model_identity(*model, request);
|
|
if (!out.ok()) {
|
|
return nullptr;
|
|
}
|
|
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.
|
|
//
|
|
// The audio is taken by value and moved in. A long recording runs to tens of
|
|
// megabytes and the caller has no use for it afterwards; the previous shape,
|
|
// a const reference, copied it.
|
|
engine::runtime::TaskRequest
|
|
build_transcription_request(const backend::TranscriptRequest &request,
|
|
audiocpp_backend::Task task,
|
|
engine::runtime::AudioBuffer audio) {
|
|
engine::runtime::TaskRequest task_request;
|
|
task_request.audio_input = std::move(audio);
|
|
|
|
if (task == audiocpp_backend::Task::Alignment) {
|
|
// For forced alignment the prompt IS the transcript to align, so it
|
|
// becomes the text input rather than a decoding hint. Set even when
|
|
// empty: an aligner given no text should say so itself rather than be
|
|
// handed an audio-only request it cannot describe.
|
|
engine::runtime::Transcript transcript;
|
|
transcript.text = request.prompt();
|
|
transcript.language = request.language();
|
|
task_request.text_input = transcript;
|
|
} else if (!request.prompt().empty()) {
|
|
// For ASR the prompt is decoding context, the whisper meaning.
|
|
task_request.options["prompt"] = request.prompt();
|
|
}
|
|
|
|
if (!request.language().empty()) {
|
|
task_request.options["language"] = request.language();
|
|
}
|
|
if (request.translate()) {
|
|
task_request.options["translate"] = "true";
|
|
}
|
|
if (request.temperature() > 0.0f) {
|
|
task_request.options["temperature"] = std::to_string(request.temperature());
|
|
}
|
|
for (const auto &granularity : request.timestamp_granularities()) {
|
|
if (granularity == "word") {
|
|
// return_timestamps is the key upstream actually reads, and it is
|
|
// the one that does something: qwen3_asr defaults it to false
|
|
// (include/engine/models/qwen3_asr/types.h) and, when set, both
|
|
// runs its forced aligner and shortens its chunk window from 30 s
|
|
// to 15 s (src/models/qwen3_asr/session.cpp). Without it, asking
|
|
// for word granularity silently came back with no word timing.
|
|
//
|
|
// word_timestamps rides along as a forward-tolerant alias. NO
|
|
// family in the pinned upstream reads that key, a grep over src/
|
|
// and include/ returns nothing, so it is sent only so that a family
|
|
// adopting the name later works with no change here.
|
|
task_request.options["return_timestamps"] = "true";
|
|
task_request.options["word_timestamps"] = "true";
|
|
}
|
|
}
|
|
// What lands and what does not. Families look their request options up by
|
|
// name (runtime::find_option) and ignore every key they do not know; the
|
|
// unknown-key refusals upstream does have are on SESSION options, which
|
|
// arrive at load time rather than here. So an unread key cannot turn a
|
|
// valid request into an error, but it is also not a feature, and the
|
|
// honest accounting against the pinned upstream is:
|
|
//
|
|
// language read, by nemotron_asr and vibevoice_asr among others.
|
|
// return_timestamps read, by qwen3_asr.
|
|
// prompt read by NO ASR family. Forwarded because it is the
|
|
// whisper meaning of the field and a family adopting
|
|
// it then works unchanged, not because it does
|
|
// anything today.
|
|
// translate read by nobody anywhere in upstream.
|
|
// temperature read only by TTS and voice conversion families, none
|
|
// of which this RPC can route to.
|
|
//
|
|
// Two request fields are deliberately not forwarded at all:
|
|
//
|
|
// threads thread count is a SessionOptions field fixed when the session
|
|
// was built, so a per-request value has nowhere to go.
|
|
// diarize this RPC routes to Asr or Alignment. A family that diarizes
|
|
// is reached through Diarize, which has its own handler and its
|
|
// own response shape, so forwarding this would imply that
|
|
// setting it turns speaker labels on here, and nothing would.
|
|
return task_request;
|
|
}
|
|
|
|
// Duration of a possibly multi-channel buffer, in seconds. Frames, not floats:
|
|
// a stereo buffer holds two floats per position and would otherwise report
|
|
// twice its real length.
|
|
float audio_duration_seconds(const engine::runtime::AudioBuffer &audio) {
|
|
const std::int64_t frames = audiocpp_backend::interleaved_frame_count(
|
|
audio.samples.size(), audio.channels);
|
|
return audiocpp_backend::samples_to_seconds(frames, audio.sample_rate);
|
|
}
|
|
|
|
// Refuses a request that routing sent to voice cloning with no speaker
|
|
// reference clip. Shared by TTS and TTSStream so the two RPCs cannot drift into
|
|
// refusing the same request differently.
|
|
//
|
|
// Refused FROM THE ROUTE, the same trick AudioTransform uses for params[stem].
|
|
// Voice cloning without a clip is a request the family cannot answer, and every
|
|
// cloning family says so only from inside its own prepare(): chatterbox throws
|
|
// "Chatterbox prepare requires speaker reference audio", which to_status maps to
|
|
// INTERNAL and which names neither the RPC nor the field the caller has to set.
|
|
// chatterbox advertises clon and no tts at all, so before this every preset-only
|
|
// or voice-less request to it got that engine-internal message.
|
|
//
|
|
// The case it catches is the FALLBACK one, which is worth stating the right way
|
|
// round. A clip is what makes routing put VoiceCloning first, but VoiceCloning
|
|
// is also the last candidate a clip-less TTS request falls back to
|
|
// (task_candidates returns {Tts, VoiceCloning, VoiceDesign} when no clip and no
|
|
// instructions are supplied), so a family that advertises clon and no tts at
|
|
// all - chatterbox, which is what ships in the gallery - routes EVERY voice-less
|
|
// request here. A task:clon pin arrives here the same way.
|
|
//
|
|
// It cannot misfire on the legitimate case for the opposite reason: a request
|
|
// that did supply a clip has voice_is_file set and returns on the line above
|
|
// before anything is thrown.
|
|
//
|
|
// Deliberately NOT generalised to "every task whose family declares
|
|
// supports_speaker_reference". That flag lives on the engine's CapabilitySet, is
|
|
// not carried through this backend's Capabilities mirror, and would need its own
|
|
// answer to whether it is advisory or binding before routing could act on it.
|
|
// Tracked as a follow-up.
|
|
void refuse_cloning_without_a_clip(const audiocpp_backend::LoadedModel &model,
|
|
const audiocpp_backend::Route &route,
|
|
bool voice_is_file) {
|
|
if (route.task != audiocpp_backend::Task::VoiceCloning || voice_is_file) {
|
|
return;
|
|
}
|
|
throw audiocpp_backend::ConfigError(
|
|
"audio-cpp: family '" + model.family() + "' routes this request to " +
|
|
audiocpp_backend::task_name(route.task) +
|
|
", which needs a speaker reference clip; set TTSRequest.voice to the "
|
|
"path of a WAV file (a voice that is not a file on disk is treated as a "
|
|
"named preset)");
|
|
}
|
|
|
|
// A live stream whose peer stopped sending without closing, cancelled by the
|
|
// idle watchdog. Its own type rather than a flag, so the read loop can unwind
|
|
// without the driver going on to finalize a decode nobody is waiting for.
|
|
//
|
|
// NOT handled by to_status: it is thrown and caught inside one handler, and
|
|
// giving it a clause there keeps to_status a statement about exceptions that
|
|
// cross unit boundaries.
|
|
class LiveIdleTimeout : public std::runtime_error {
|
|
public:
|
|
using std::runtime_error::runtime_error;
|
|
};
|
|
|
|
// Maps a thrown exception onto the gRPC status the client should see.
|
|
GStatus to_status(const std::exception &err) {
|
|
if (dynamic_cast<const audiocpp_backend::ConfigError *>(&err) != nullptr) {
|
|
return GStatus(grpc::StatusCode::INVALID_ARGUMENT, err.what());
|
|
}
|
|
if (dynamic_cast<const audiocpp_backend::CapabilityError *>(&err) != nullptr) {
|
|
return GStatus(grpc::StatusCode::UNIMPLEMENTED, err.what());
|
|
}
|
|
// The lane was busy and this caller ran out of patience. UNAVAILABLE, not
|
|
// RESOURCE_EXHAUSTED: the request is fine and retrying later is the right
|
|
// response, which is what UNAVAILABLE tells a client.
|
|
if (dynamic_cast<const audiocpp_backend::LaneUnavailable *>(&err) != nullptr) {
|
|
return GStatus(grpc::StatusCode::UNAVAILABLE, err.what());
|
|
}
|
|
return GStatus(grpc::StatusCode::INTERNAL, err.what());
|
|
}
|
|
|
|
class AudioCppBackend final : public backend::Backend::Service {
|
|
public:
|
|
GStatus Health(ServerContext *, const backend::HealthMessage *,
|
|
backend::Reply *reply) override {
|
|
reply->set_message("OK");
|
|
return GStatus::OK;
|
|
}
|
|
|
|
GStatus Status(ServerContext *, const backend::HealthMessage *,
|
|
backend::StatusResponse *response) override {
|
|
// snapshot_unchecked is right here: HealthMessage carries no
|
|
// ModelIdentity, so there is nothing to check. See case 1 at the
|
|
// function.
|
|
response->set_state(snapshot_unchecked()
|
|
? backend::StatusResponse::READY
|
|
: backend::StatusResponse::UNINITIALIZED);
|
|
return GStatus::OK;
|
|
}
|
|
|
|
GStatus LoadModel(ServerContext *, const backend::ModelOptions *request,
|
|
backend::Result *result) override {
|
|
try {
|
|
std::vector<std::string> entries(request->options().begin(),
|
|
request->options().end());
|
|
auto parsed = audiocpp_backend::parse_model_options(entries);
|
|
if (!parsed.error.empty()) {
|
|
throw audiocpp_backend::ConfigError(parsed.error);
|
|
}
|
|
// ModelOptions.Threads is the model YAML's threads setting; the
|
|
// explicit threads: option wins when both are set.
|
|
if (parsed.options.threads == 0 && request->threads() > 0) {
|
|
parsed.options.threads = static_cast<int>(request->threads());
|
|
}
|
|
// MainGPU carries the device index for GPU backends, and is the
|
|
// fallback: an explicit device: option wins, matching threads above.
|
|
// device_set rather than `device == 0`, because 0 is a real device
|
|
// index and the value alone cannot say whether anyone chose it.
|
|
if (!parsed.options.device_set && !request->maingpu().empty()) {
|
|
parsed.options.device = parse_device_index(request->maingpu());
|
|
}
|
|
|
|
const std::string path = audiocpp_backend::resolve_model_path(
|
|
request->modelpath(), request->modelfile(), request->model());
|
|
|
|
// ModelOptions.Model is the UNTRANSLATED controller-side name, and
|
|
// is what every ModelIdentity field on a request is compared
|
|
// against. Passed at construction so the model and the identity it
|
|
// was loaded with are published together.
|
|
auto loaded = std::make_shared<audiocpp_backend::LoadedModel>(
|
|
path, parsed.options, request->model());
|
|
|
|
// Read everything the reply and the log line need while this scope
|
|
// still owns the model. After the move, `loaded` is null and the
|
|
// global is only safe to touch under the mutex.
|
|
const std::string family = loaded->family();
|
|
const std::string variant = loaded->variant();
|
|
const std::string capabilities =
|
|
audiocpp_backend::describe_capabilities(loaded->capabilities());
|
|
std::shared_ptr<audiocpp_backend::LoadedModel> replaced;
|
|
{
|
|
std::lock_guard<std::mutex> lock(g_model_mu);
|
|
replaced = std::move(g_model);
|
|
g_model = std::move(loaded);
|
|
}
|
|
// The previous model, if any, is dropped outside the lock, for the
|
|
// same reason Free does it: teardown must not hold up Status.
|
|
replaced.reset();
|
|
|
|
// Logged once at load time so an operator can see what the model
|
|
// can do without making a request. ModelMetadata is deliberately
|
|
// not implemented: its response message is chat-template oriented
|
|
// and has no field for family, variant, languages or capabilities,
|
|
// so there is nothing truthful to put in it.
|
|
std::cerr << "audio-cpp: loaded family '" << family << "' variant '"
|
|
<< variant << "' capabilities: " << capabilities << "\n";
|
|
|
|
result->set_success(true);
|
|
result->set_message("loaded audio.cpp family " + family);
|
|
return GStatus::OK;
|
|
} catch (const std::exception &err) {
|
|
result->set_success(false);
|
|
result->set_message(err.what());
|
|
// A failed load must also be a gRPC error, or the model loader's
|
|
// backend probe treats this backend as having accepted the model.
|
|
return to_status(err);
|
|
}
|
|
}
|
|
|
|
GStatus Free(ServerContext *, const backend::HealthMessage *,
|
|
backend::Result *result) override {
|
|
// Drops the global's reference. Any handler still running holds its own
|
|
// from snapshot_for(), so the model is destroyed by whichever of them
|
|
// finishes last rather than underneath one of them. Destroying
|
|
// LoadedModel releases its sessions first, then the model.
|
|
std::shared_ptr<audiocpp_backend::LoadedModel> released;
|
|
{
|
|
std::lock_guard<std::mutex> lock(g_model_mu);
|
|
released = std::move(g_model);
|
|
}
|
|
// Released outside the lock: if this is the last reference, the model
|
|
// and its sessions are torn down here, and that must not block Status
|
|
// or a fresh LoadModel behind g_model_mu.
|
|
released.reset();
|
|
result->set_success(true);
|
|
return GStatus::OK;
|
|
}
|
|
|
|
GStatus AudioTranscription(ServerContext *,
|
|
const backend::TranscriptRequest *request,
|
|
backend::TranscriptResult *response) override {
|
|
try {
|
|
GStatus refusal = GStatus::OK;
|
|
const auto model = snapshot_for(request, refusal);
|
|
if (model == nullptr) {
|
|
return refusal;
|
|
}
|
|
|
|
audiocpp_backend::RequestShape shape;
|
|
// has_prompt_text is what lets a family that can only align be
|
|
// reached through this RPC at all, so it is not decoration.
|
|
shape.has_prompt_text = !request->prompt().empty();
|
|
shape.pinned_task = model->pinned_task();
|
|
|
|
// Capability refusal before the lane and before the file read, for
|
|
// the reasons spelled out in Diarize.
|
|
model->check_can_serve(audiocpp_backend::Rpc::AudioTranscription, shape);
|
|
|
|
// TranscriptRequest.dst is the INPUT audio path, despite the field
|
|
// name. The HTTP layer materialises the upload to a temp file and
|
|
// passes the path; nothing is written back.
|
|
auto audio = audiocpp_backend::read_audio_file(request->dst(),
|
|
kSpeechSampleRate);
|
|
// Both read before the buffer is moved into the request below. The
|
|
// rate is the BUFFER's, which after read_audio_file is
|
|
// kSpeechSampleRate and not necessarily the file's, and it is the
|
|
// domain the result spans come back in.
|
|
const int sample_rate = audio.sample_rate;
|
|
const float duration = audio_duration_seconds(audio);
|
|
|
|
audiocpp_backend::LaneEntry lane = model->acquire(0);
|
|
const auto session = model->session_for(
|
|
audiocpp_backend::Rpc::AudioTranscription, shape, lane);
|
|
|
|
// session.task, not the request: for Asr the prompt is decoding
|
|
// context, for Alignment it is the transcript to align, and routing
|
|
// has already decided which of those this is.
|
|
const auto task_request =
|
|
build_transcription_request(*request, session.task, std::move(audio));
|
|
const auto result =
|
|
audiocpp_backend::run_offline(session, task_request, lane);
|
|
|
|
// text comes from result.text_output verbatim, never from the
|
|
// segments. See THE RULE in result_map.h.
|
|
audiocpp_backend::fill_transcript_result(result, sample_rate, duration,
|
|
response);
|
|
// eou stays false. It marks a decode that ended on the model's
|
|
// end-of-utterance token, which is a cache-aware STREAMING concept;
|
|
// an offline run over a whole file has no turn to yield.
|
|
return GStatus::OK;
|
|
} catch (const std::exception &err) {
|
|
return to_status(err);
|
|
}
|
|
}
|
|
|
|
// Verifying this by hand: silero_vad is a SPEECH detector, so a synthetic
|
|
// stimulus does not exercise it. A 220 Hz sine, broadband noise and a
|
|
// harmonic buzz all return zero segments, correctly. Use real speech, which
|
|
// upstream bundles and which therefore needs no download:
|
|
// audio.cpp/assets/resources/sample_16k.wav (16 kHz mono, 14.07 s)
|
|
// Taking 1 s from offset 8000 samples (0.5 s in), padded with 1 s of
|
|
// silence either side, yields exactly one segment at start=0.9940
|
|
// end=2.0460. A different excerpt of the same file shifts the start,
|
|
// because it changes where speech actually begins inside the window.
|
|
GStatus VAD(ServerContext *, const backend::VADRequest *request,
|
|
backend::VADResponse *response) override {
|
|
try {
|
|
// snapshot_for, and the result is held for the whole call. Free may
|
|
// arrive mid-request; it drops the global's reference only, so this
|
|
// one keeps the model alive until the handler returns. It also
|
|
// performs the not-loaded and identity checks, so a stale route is
|
|
// refused before any work happens.
|
|
GStatus refusal = GStatus::OK;
|
|
const auto model = snapshot_for(request, refusal);
|
|
if (model == nullptr) {
|
|
return refusal;
|
|
}
|
|
|
|
audiocpp_backend::RequestShape shape;
|
|
// Without this the `task:` model option is dead: routing is
|
|
// otherwise derived from the RPC alone.
|
|
shape.pinned_task = model->pinned_task();
|
|
|
|
// Before the lane. A model that cannot do VAD at all answers
|
|
// immediately instead of queueing behind somebody else's run only
|
|
// to be refused; routing is pure and needs no lane. session_for
|
|
// routes again below and reaches the same answer.
|
|
model->check_can_serve(audiocpp_backend::Rpc::Vad, shape);
|
|
|
|
engine::runtime::TaskRequest task;
|
|
task.audio_input = audiocpp_backend::buffer_from_mono(
|
|
std::vector<float>(request->audio().begin(), request->audio().end()),
|
|
kVadSampleRate);
|
|
|
|
// The lane is taken BEFORE session_for, not after. session_for
|
|
// reads and writes an unsynchronised session cache and prepare()
|
|
// mutates the session itself, so both belong inside the lane; see
|
|
// the note on session_for in loaded_model.h. Bound to a named local
|
|
// because LaneEntry is immovable and C++17 elides the return.
|
|
audiocpp_backend::LaneEntry lane = model->acquire(0);
|
|
const auto session =
|
|
model->session_for(audiocpp_backend::Rpc::Vad, shape, lane);
|
|
const auto result =
|
|
audiocpp_backend::run_offline(session, task, lane);
|
|
|
|
// VADSegment.start/end are float SECONDS, not sample indices.
|
|
for (const auto &segment : result.speech_segments) {
|
|
auto *out = response->add_segments();
|
|
out->set_start(audiocpp_backend::samples_to_seconds(
|
|
segment.span.start_sample, kVadSampleRate));
|
|
out->set_end(audiocpp_backend::samples_to_seconds(
|
|
segment.span.end_sample, kVadSampleRate));
|
|
}
|
|
return GStatus::OK;
|
|
} catch (const std::exception &err) {
|
|
return to_status(err);
|
|
}
|
|
}
|
|
|
|
GStatus Diarize(ServerContext *, const backend::DiarizeRequest *request,
|
|
backend::DiarizeResponse *response) override {
|
|
try {
|
|
GStatus refusal = GStatus::OK;
|
|
const auto model = snapshot_for(request, refusal);
|
|
if (model == nullptr) {
|
|
return refusal;
|
|
}
|
|
|
|
audiocpp_backend::RequestShape shape;
|
|
shape.pinned_task = model->pinned_task();
|
|
|
|
// Route, then read, then lane, in that order, and every step of it
|
|
// is deliberate.
|
|
//
|
|
// The capability refusal comes first because a family that cannot
|
|
// diarize at all should say so, not complain about the input file:
|
|
// on a VAD-only model, reading the audio first would surface
|
|
// "cannot read /tmp/x.wav" and send the operator hunting a file
|
|
// problem instead of a model choice.
|
|
//
|
|
// It also comes before the lane, which is the part that used to be
|
|
// impossible. Routing needs no lane, so the refusal no longer waits
|
|
// out somebody else's thirty second run to be told no, and neither
|
|
// does the file read.
|
|
model->check_can_serve(audiocpp_backend::Rpc::Diarize, shape);
|
|
|
|
// DiarizeRequest.dst is the INPUT path, despite the field name: the
|
|
// HTTP layer materialises the upload to a temp file and passes the
|
|
// path here. Nothing is written back.
|
|
//
|
|
// Read at 16 kHz mono: sortformer refuses any other rate, and the
|
|
// HTTP layer copies the upload byte for byte, so whatever the user
|
|
// posted is what arrives. See kSpeechSampleRate.
|
|
auto audio = audiocpp_backend::read_audio_file(request->dst(),
|
|
kSpeechSampleRate);
|
|
const int sample_rate = audio.sample_rate;
|
|
// Read off the buffer before it is moved into the request below.
|
|
// Frames, not floats: a stereo input holds two floats per position
|
|
// and would otherwise report twice its real duration.
|
|
const std::int64_t frames = audiocpp_backend::interleaved_frame_count(
|
|
audio.samples.size(), audio.channels);
|
|
|
|
audiocpp_backend::LaneEntry lane = model->acquire(0);
|
|
const auto session =
|
|
model->session_for(audiocpp_backend::Rpc::Diarize, shape, lane);
|
|
|
|
engine::runtime::TaskRequest task;
|
|
// Moved, not copied: a long recording runs to tens of megabytes and
|
|
// this is its only owner.
|
|
task.audio_input = std::move(audio);
|
|
// READ THIS BEFORE WIRING THE FIRST DIARIZATION FAMILY. This
|
|
// forwarding is currently DEAD for sortformer, the only diarizer
|
|
// audio.cpp ships, and from the caller's side that is a silent
|
|
// wrong answer, not a soft hint:
|
|
//
|
|
// - backend.proto documents num_speakers as "exact speaker count
|
|
// if known (>0 forces)". A caller who asks for 2 and gets the
|
|
// model's 4 labels back with a 200 and no diagnostic has been
|
|
// given a wrong answer, so core/backend/diarization.go's
|
|
// "backends ignore what they don't act on" does not cover it.
|
|
// - sortformer takes its speaker count from the model package
|
|
// (assets.model_config.modules.num_speakers; the bundled
|
|
// variant is 4-speaker), and its postprocess config is parsed
|
|
// from runtime::SessionOptions, i.e. load time, not from
|
|
// TaskRequest.options at all.
|
|
// - worse, its unknown-option check only inspects keys prefixed
|
|
// "sortformer_diar.", so a bare "num_speakers" here is not even
|
|
// a recognised key: it is dropped without a trace.
|
|
//
|
|
// The keys are still forwarded, because a family that does read
|
|
// them then works with no change here. But the family that lands
|
|
// MUST either honour num_speakers or refuse it with
|
|
// INVALID_ARGUMENT. No refusal path is added now because there is
|
|
// no diarization family wired yet to test one against, and an
|
|
// untested refusal is its own hazard.
|
|
//
|
|
// Also dropped today, and worth revisiting at the same time:
|
|
// clustering_threshold, min_duration_on and min_duration_off.
|
|
// The two min_duration_* fields are NOT family-specific: they are
|
|
// generic post-filters over the returned turns (drop a turn shorter
|
|
// than min_duration_on, merge two turns of the same speaker
|
|
// separated by less than min_duration_off), so the handler could
|
|
// honour them in about six lines whatever the family does.
|
|
//
|
|
// The knobs that DO reach sortformer (speaker_threshold,
|
|
// speaker_min_frames, speaker_pad_frames, session_len_sec) are
|
|
// session options and arrive through the model YAML's
|
|
// `session.<key>:` entries at load time, not per request.
|
|
if (request->num_speakers() > 0) {
|
|
task.options["num_speakers"] =
|
|
std::to_string(request->num_speakers());
|
|
}
|
|
if (request->min_speakers() > 0) {
|
|
task.options["min_speakers"] =
|
|
std::to_string(request->min_speakers());
|
|
}
|
|
if (request->max_speakers() > 0) {
|
|
task.options["max_speakers"] =
|
|
std::to_string(request->max_speakers());
|
|
}
|
|
|
|
const auto result =
|
|
audiocpp_backend::run_offline(session, task, lane);
|
|
|
|
// Emitted verbatim, in the order the model produced them. NOT
|
|
// sorted, merged or de-overlapped: sortformer binarizes each speaker
|
|
// independently, so a turn nested inside another speaker's turn is
|
|
// correct output for overlapped speech. LocalAI is overlap-tolerant
|
|
// downstream (core/backend/diarization.go passes segments through
|
|
// and the RTTM renderer handles overlap), so smoothing here would
|
|
// destroy real information.
|
|
std::set<std::string> speakers;
|
|
int id = 0;
|
|
for (const auto &turn : result.speaker_turns) {
|
|
auto *out = response->add_segments();
|
|
out->set_id(id++);
|
|
// Seconds, like VADSegment. Only TranscriptSegment/Word are ns.
|
|
//
|
|
// Converted against the INPUT rate, which is correct and has
|
|
// been checked against upstream rather than assumed: every
|
|
// TimeSpan sortformer emits is built as
|
|
// llround(seconds * 16000.0) (postprocess.cpp). The read above
|
|
// guarantees the buffer is 16 kHz, so the span domain and the
|
|
// input domain are the same one, and this is not a place where
|
|
// a resampling family could silently scale every timestamp by a
|
|
// constant.
|
|
out->set_start(audiocpp_backend::samples_to_seconds(
|
|
turn.span.start_sample, sample_rate));
|
|
out->set_end(audiocpp_backend::samples_to_seconds(
|
|
turn.span.end_sample, sample_rate));
|
|
out->set_speaker(turn.speaker_id);
|
|
// text stays EMPTY, including when include_text is set.
|
|
// audio.cpp's SpeakerTurn carries a span and a speaker label
|
|
// only, and TaskResult has no per-segment text anywhere, so
|
|
// there is nothing truthful to put here.
|
|
speakers.insert(turn.speaker_id);
|
|
}
|
|
response->set_num_speakers(static_cast<int>(speakers.size()));
|
|
response->set_duration(
|
|
audiocpp_backend::samples_to_seconds(frames, sample_rate));
|
|
|
|
// Only set when the family bundles transcription, which no
|
|
// diarization-only family does; kept because a combined family
|
|
// would fill it in and the field is documented as optional.
|
|
if (result.text_output.has_value()) {
|
|
response->set_language(result.text_output->language);
|
|
}
|
|
return GStatus::OK;
|
|
} catch (const std::exception &err) {
|
|
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
|
|
// <dst-stem>.<name>.<ext>, 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.
|
|
const audiocpp_backend::Route route =
|
|
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;
|
|
}
|
|
|
|
// AFTER the loop, so it reads exactly what the caller sent. This is
|
|
// what makes a text-conditioned route reachable through an RPC whose
|
|
// message has no text field; see apply_transform_text_input's header
|
|
// for why vevo2's speech-to-speech route is unreachable without it.
|
|
// A request carrying no text key is untouched, so separation and
|
|
// voice conversion pay nothing for this.
|
|
audiocpp_backend::apply_transform_text_input(task);
|
|
|
|
// Refused from the ROUTE, before the file reads and before the run.
|
|
// Only source separation produces named stems, and the route says
|
|
// whether this is separation without running anything: the identical
|
|
// refusal below, taken from the empty named_audio_outputs list,
|
|
// cannot fire until a full conversion has been paid for (measured at
|
|
// 1.6 s on miocodec, far worse on seed_vc or vevo2). The one below
|
|
// stays as the backstop for a separation-routed family that returns
|
|
// no stems anyway.
|
|
if (!requested_stem.empty() &&
|
|
route.task != audiocpp_backend::Task::SourceSeparation) {
|
|
throw audiocpp_backend::ConfigError(
|
|
"audio-cpp: family '" + model->family() + "' routes this " +
|
|
"request to " + audiocpp_backend::task_name(route.task) +
|
|
", which produces a single output with no named stems, so "
|
|
"params[stem]='" + requested_stem + "' cannot be honoured");
|
|
}
|
|
|
|
// 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<std::string> 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.
|
|
//
|
|
// The guarantee is exactly that and no more: a request REFUSED
|
|
// ON ITS ARGUMENTS writes nothing. A write that FAILS partway
|
|
// through the loop below, on a full disk say, still leaves the
|
|
// siblings written before it, with no dst. Nothing is rolled
|
|
// back, because deleting files after a disk error is its own
|
|
// way to lose data, and the caller sees the failure.
|
|
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) {
|
|
const std::string sibling =
|
|
audiocpp_backend::sibling_stem_path(request->dst(), names[i]);
|
|
audiocpp_backend::write_audio_file(
|
|
sibling, result.named_audio_outputs[i].audio);
|
|
// Named in the response, in the model's own order. Without
|
|
// this the siblings are files nobody can find, and a caller
|
|
// that wants the drums as well as the vocals has to run the
|
|
// whole separation again per stem, which is the cost the
|
|
// single run exists to avoid.
|
|
auto *stem = response->add_stems();
|
|
stem->set_name(names[i]);
|
|
stem->set_dst(sibling);
|
|
}
|
|
chosen = &result.named_audio_outputs[static_cast<size_t>(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<int>(
|
|
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);
|
|
}
|
|
}
|
|
|
|
// The first RPC that GENERATES audio from text, together with
|
|
// SoundGeneration below. AudioTransform already wrote files, but it
|
|
// transformed audio it was given; these two have no required input audio at
|
|
// all, which is why both carry a Result with a success flag rather than a
|
|
// response message describing what was found.
|
|
//
|
|
// TTSRequest.voice is overloaded across LocalAI backends, some reading it as
|
|
// a named preset and some as a path to a clip. The rule here is decided from
|
|
// the filesystem: an existing regular file is a speaker reference and
|
|
// routing prefers VoiceCloning, anything else is a preset. Instructions with
|
|
// no clip prefer VoiceDesign, and a clip outranks instructions when both are
|
|
// set. None of that is re-derived here; it is capability_routing's
|
|
// task_candidates, and this handler's job is only to describe the request
|
|
// truthfully in the RequestShape.
|
|
GStatus TTS(ServerContext *, const backend::TTSRequest *request,
|
|
backend::Result *result) override {
|
|
try {
|
|
GStatus refusal = GStatus::OK;
|
|
const auto model = snapshot_for(request, refusal);
|
|
if (model == nullptr) {
|
|
// Answered with the STATUS ALONE, leaving Result at its default.
|
|
// core/backend/tts.go checks the transport error before it looks
|
|
// at res.Success and returns on it, so the Result is never read
|
|
// on this path; and the router matches a stale route on the
|
|
// NOT_FOUND code plus the sentinel in the status message, which
|
|
// is where check_model_identity already put it.
|
|
return refusal;
|
|
}
|
|
|
|
// One description of the request, shared with TTSStream, so the two
|
|
// handlers cannot describe the same request differently.
|
|
// pinned_task stays here on purpose: it comes off the model rather
|
|
// than the request, and it is the field a new handler forgets.
|
|
audiocpp_backend::RequestShape shape =
|
|
audiocpp_backend::build_tts_shape(*request);
|
|
const bool voice_is_file = shape.has_voice_reference;
|
|
shape.pinned_task = model->pinned_task();
|
|
|
|
// FIRST, before the lane and before any file read. A family that
|
|
// cannot synthesise at all is answering a question about itself, so
|
|
// it must not queue behind somebody else's run to be told no, and it
|
|
// must not blame the caller's reference clip for a decision that had
|
|
// nothing to do with it. Same ordering as Diarize and
|
|
// AudioTransform.
|
|
const audiocpp_backend::Route route =
|
|
model->check_can_serve(audiocpp_backend::Rpc::Tts, shape);
|
|
|
|
refuse_cloning_without_a_clip(*model, route, voice_is_file);
|
|
|
|
if (request->dst().empty()) {
|
|
throw audiocpp_backend::ConfigError(
|
|
"audio-cpp: TTS needs a dst output path");
|
|
}
|
|
|
|
std::optional<engine::runtime::AudioBuffer> reference;
|
|
if (voice_is_file) {
|
|
// Native rate, native channels: see kVoiceReferenceSampleRate.
|
|
reference = audiocpp_backend::read_audio_file(
|
|
request->voice(), kVoiceReferenceSampleRate);
|
|
}
|
|
// Read before the lane, like every other file-fed handler here, so
|
|
// a slow or large clip is not decoded while holding it.
|
|
const auto task =
|
|
audiocpp_backend::build_tts_request(*request, std::move(reference));
|
|
|
|
// Named local: LaneEntry is immovable and has to span both
|
|
// session_for and run_offline.
|
|
audiocpp_backend::LaneEntry lane = model->acquire(0);
|
|
const auto session =
|
|
model->session_for(audiocpp_backend::Rpc::Tts, shape, lane);
|
|
const auto task_result =
|
|
audiocpp_backend::run_offline(session, task, lane);
|
|
|
|
if (!task_result.audio_output.has_value()) {
|
|
// A family that routed successfully and then produced no audio
|
|
// is a broken family rather than a wrong request, but from the
|
|
// caller's side the actionable fact is that this model does not
|
|
// do this, so CapabilityError. Same judgement as AudioTransform.
|
|
throw audiocpp_backend::CapabilityError(
|
|
"audio-cpp: family '" + model->family() +
|
|
"' produced no audio for the TTS RPC");
|
|
}
|
|
// Throws a plain runtime_error, i.e. INTERNAL, on a write failure:
|
|
// dst is LocalAI's own generated-content path and not anything the
|
|
// caller named, so a full disk there is a server fault and is worth
|
|
// retrying. Only an empty dst is INVALID_ARGUMENT, and that is
|
|
// already refused above with a message naming the RPC.
|
|
audiocpp_backend::write_audio_file(request->dst(),
|
|
*task_result.audio_output);
|
|
result->set_success(true);
|
|
// The path, because that is what core/backend/tts.go's caller reads
|
|
// back and what every other LocalAI TTS backend puts here.
|
|
result->set_message(request->dst());
|
|
return GStatus::OK;
|
|
} catch (const std::exception &err) {
|
|
// DEFENSIVE, not load-bearing, and worth saying so plainly. gRPC
|
|
// discards the response message entirely when the status is not OK,
|
|
// so a client that checks the status, which core/backend/tts.go
|
|
// does before it ever looks at res.Success, receives a nil Result
|
|
// and never sees these two fields. They are set for a client that
|
|
// ignores the status, and because a half-filled Result is a worse
|
|
// thing to leave behind than a filled one.
|
|
result->set_success(false);
|
|
result->set_message(err.what());
|
|
return to_status(err);
|
|
}
|
|
}
|
|
|
|
GStatus SoundGeneration(ServerContext *,
|
|
const backend::SoundGenerationRequest *request,
|
|
backend::Result *result) override {
|
|
try {
|
|
GStatus refusal = GStatus::OK;
|
|
const auto model = snapshot_for(request, refusal);
|
|
if (model == nullptr) {
|
|
return refusal;
|
|
}
|
|
|
|
audiocpp_backend::RequestShape shape;
|
|
// No request signal chooses between generation tasks: this RPC has
|
|
// exactly one candidate, AudioGeneration. pinned_task is still
|
|
// copied, because without it the model's `task:` option is dead
|
|
// here, which is how a gen family pinned to something else would
|
|
// silently be routed to generation anyway.
|
|
shape.pinned_task = model->pinned_task();
|
|
|
|
model->check_can_serve(audiocpp_backend::Rpc::SoundGeneration, shape);
|
|
|
|
if (request->dst().empty()) {
|
|
throw audiocpp_backend::ConfigError(
|
|
"audio-cpp: SoundGeneration needs a dst output path");
|
|
}
|
|
|
|
// DO NOT WIRE src INTO THE HTTP LAYER UNTIL THE PIN IS BUMPED PAST
|
|
// THE FIX. Setting src on a stable_audio model CORRUPTS THE HEAP AND
|
|
// ABORTS THE PROCESS in the pinned upstream: "free(): invalid size"
|
|
// / "munmap_chunk(): invalid pointer", SIGABRT, backend gone. It is
|
|
// upstream's, not this handler's, and it was attributed rather than
|
|
// assumed: upstream's own audiocpp_cli, built from this same
|
|
// checkout, aborts identically with --audio at exit 134, at both
|
|
// 44.1 kHz stereo and 24 kHz mono, and completes cleanly with no
|
|
// --audio at all. It is therefore neither caused nor worsened by
|
|
// reading the clip at its native rate below.
|
|
//
|
|
// The only thing keeping that off the network today is an OMISSION:
|
|
// core/http/endpoints/elevenlabs/soundgeneration.go passes nil for
|
|
// sourceFile, and schema.ElevenLabsSoundGenerationRequest has no
|
|
// field for it, so the sole caller that can set src is
|
|
// core/cli/soundgeneration.go. Adding the field to that schema turns
|
|
// a local CLI crash into a remotely reachable heap corruption with
|
|
// fully attacker-influenced input. Nobody reading that Go schema
|
|
// would know why the field is missing, which is why this is written
|
|
// here as well as in the task report.
|
|
//
|
|
// No family blocklist is applied: ace_step's editing routes
|
|
// legitimately need src, and a family-specific guard here would rot.
|
|
std::optional<engine::runtime::AudioBuffer> source;
|
|
if (request->has_src() && !request->src().empty()) {
|
|
// Native rate and channels. ace_step resamples left and right
|
|
// separately and stable_audio resamples per channel, so a
|
|
// downmix here would delete the stereo image of the very clip
|
|
// they are editing.
|
|
source = audiocpp_backend::read_audio_file(
|
|
request->src(), kVoiceReferenceSampleRate);
|
|
}
|
|
const auto task = audiocpp_backend::build_sound_generation_request(
|
|
*request, std::move(source));
|
|
|
|
audiocpp_backend::LaneEntry lane = model->acquire(0);
|
|
const auto session = model->session_for(
|
|
audiocpp_backend::Rpc::SoundGeneration, shape, lane);
|
|
const auto task_result =
|
|
audiocpp_backend::run_offline(session, task, lane);
|
|
|
|
if (!task_result.audio_output.has_value()) {
|
|
throw audiocpp_backend::CapabilityError(
|
|
"audio-cpp: family '" + model->family() +
|
|
"' produced no audio for the SoundGeneration RPC");
|
|
}
|
|
audiocpp_backend::write_audio_file(request->dst(),
|
|
*task_result.audio_output);
|
|
result->set_success(true);
|
|
result->set_message(request->dst());
|
|
return GStatus::OK;
|
|
} catch (const std::exception &err) {
|
|
result->set_success(false);
|
|
result->set_message(err.what());
|
|
return to_status(err);
|
|
}
|
|
}
|
|
|
|
// The streaming counterpart of TTS, and the first RPC here that writes to
|
|
// the wire while the model is still generating.
|
|
//
|
|
// THE WIRE CONTRACT, which is the thing most likely to go wrong.
|
|
// pkg/grpc/server.go's TTSStream wrapper puts every chunk in Reply.audio,
|
|
// and core/backend/tts.go's ModelTTSStream forwards those bytes to the HTTP
|
|
// response verbatim. There is no framing and no format negotiation, so THE
|
|
// FIRST CHUNK MUST BE A WAV HEADER or the client receives raw PCM it has no
|
|
// way to interpret; browsers simply refuse the stream. Because the total
|
|
// length is unknown while generating, both size fields carry 0xFFFFFFFF,
|
|
// which is the convention backend/go/vibevoice-cpp established.
|
|
//
|
|
// ModelTTSStream will synthesise a header of its own, but ONLY when the
|
|
// first Reply carries a non-empty `message` holding JSON with a sample_rate.
|
|
// Nothing here ever sets Reply.message, so that branch never fires and there
|
|
// is exactly one header on the wire. Setting `message` on this RPC without
|
|
// deleting the header below would put a second header 44 bytes into the PCM.
|
|
//
|
|
// There is no dst: streaming TTS writes to the response, not to a file, and
|
|
// core/backend/tts.go sends an empty dst on purpose. That is the one place
|
|
// this RPC deliberately diverges from TTS.
|
|
GStatus TTSStream(ServerContext *, const backend::TTSRequest *request,
|
|
grpc::ServerWriter<backend::Reply> *writer) override {
|
|
try {
|
|
GStatus refusal = GStatus::OK;
|
|
const auto model = snapshot_for(request, refusal);
|
|
if (model == nullptr) {
|
|
return refusal;
|
|
}
|
|
|
|
// The SAME shape builder TTS uses, so the two RPCs cannot describe
|
|
// one request differently. pinned_task stays at the call site: it
|
|
// comes off the model rather than the request.
|
|
audiocpp_backend::RequestShape shape =
|
|
audiocpp_backend::build_tts_shape(*request);
|
|
const bool voice_is_file = shape.has_voice_reference;
|
|
shape.pinned_task = model->pinned_task();
|
|
|
|
// FIRST, before the lane and before any file read, exactly as in
|
|
// TTS. Note that this RPC's mode candidates are streaming ONLY: a
|
|
// family that can synthesise but cannot stream is refused here
|
|
// rather than quietly served a whole buffer at the end, because a
|
|
// caller that asked to stream is asking for time to first audio.
|
|
const audiocpp_backend::Route route =
|
|
model->check_can_serve(audiocpp_backend::Rpc::TtsStream, shape);
|
|
refuse_cloning_without_a_clip(*model, route, voice_is_file);
|
|
|
|
std::optional<engine::runtime::AudioBuffer> reference;
|
|
if (voice_is_file) {
|
|
// Native rate, native channels: see kVoiceReferenceSampleRate.
|
|
reference = audiocpp_backend::read_audio_file(
|
|
request->voice(), kVoiceReferenceSampleRate);
|
|
}
|
|
const auto task =
|
|
audiocpp_backend::build_tts_request(*request, std::move(reference));
|
|
|
|
audiocpp_backend::LaneEntry lane = model->acquire(0);
|
|
const auto session =
|
|
model->session_for(audiocpp_backend::Rpc::TtsStream, shape, lane);
|
|
|
|
bool header_sent = false;
|
|
bool client_gone = false;
|
|
int stream_rate = 0;
|
|
int stream_channels = 0;
|
|
const auto write_audio =
|
|
[&](const engine::runtime::AudioBuffer &audio) {
|
|
if (client_gone || audio.samples.empty()) {
|
|
return;
|
|
}
|
|
const int channels = audio.channels > 0 ? audio.channels : 1;
|
|
if (!header_sent) {
|
|
backend::Reply head;
|
|
head.set_audio(audiocpp_backend::streaming_wav_header(
|
|
audio.sample_rate, channels));
|
|
if (!writer->Write(head)) {
|
|
client_gone = true;
|
|
return;
|
|
}
|
|
header_sent = true;
|
|
stream_rate = audio.sample_rate;
|
|
stream_channels = channels;
|
|
} else if (audio.sample_rate != stream_rate ||
|
|
channels != stream_channels) {
|
|
// The header already went out declaring the first
|
|
// chunk's format, and it cannot be taken back. Every
|
|
// later byte would be decoded at the wrong rate or the
|
|
// wrong interleaving, which plays as a speed or channel
|
|
// fault nobody can see in a 200. Fail loudly instead.
|
|
throw std::runtime_error(
|
|
"audio-cpp: family '" + model->family() +
|
|
"' changed its output format mid-stream (" +
|
|
std::to_string(stream_rate) + " Hz x" +
|
|
std::to_string(stream_channels) + " to " +
|
|
std::to_string(audio.sample_rate) + " Hz x" +
|
|
std::to_string(channels) + ")");
|
|
}
|
|
backend::Reply reply;
|
|
reply.set_audio(audiocpp_backend::f32_to_s16le(audio.samples));
|
|
if (!writer->Write(reply)) {
|
|
client_gone = true;
|
|
}
|
|
};
|
|
|
|
// BOTH fields, and named_audio_outputs is the one that carries the
|
|
// audio in practice. All three streaming TTS families put their
|
|
// chunks there ("chunk_0", "chunk_1", ...) and leave audio_output
|
|
// empty until the very end: supertonic and omnivoice build the event
|
|
// in next_stream_event, voxcpm2 replays the ones its start_stream
|
|
// produced. Reading only audio_output, which is the obvious field,
|
|
// yields a stream with no audio in it at all.
|
|
const auto emit = [&](const engine::runtime::StreamEvent &event) {
|
|
if (event.audio_output.has_value()) {
|
|
write_audio(*event.audio_output);
|
|
}
|
|
for (const auto &named : event.named_audio_outputs) {
|
|
write_audio(named.audio);
|
|
}
|
|
};
|
|
|
|
const auto result =
|
|
audiocpp_backend::run_streaming_pull(session, task, emit, lane);
|
|
|
|
// The finish_stream result is the session's own MERGED WHOLE, not a
|
|
// tail the pull loop missed: supertonic returns its accumulated
|
|
// buffer, omnivoice post-processes the merge, voxcpm2 hands back the
|
|
// stored result. Emitting it after the chunks would send the entire
|
|
// utterance twice. It is therefore used only when the family
|
|
// streamed nothing, which is what a family that held everything back
|
|
// to finalize looks like.
|
|
//
|
|
// The cost of that choice, stated plainly: what a client hears is
|
|
// the concatenation of the chunks, and for omnivoice that is not
|
|
// byte-identical to what the unary TTS RPC returns, because its
|
|
// postprocessor runs over the merged buffer at the end. Audio
|
|
// already on the wire cannot be post-processed, so any streaming
|
|
// implementation has to accept this.
|
|
//
|
|
// This guard also depends on the pull loop having DRAINED the
|
|
// session. It does today: no family sets is_final on a pulled
|
|
// event, so the loop runs to nullopt. If one ever does, the loop
|
|
// breaks early, and omnivoice's finish_stream drains and merges the
|
|
// chunks that were never emitted (session.cpp:576) into the result
|
|
// this branch then discards, silently truncating the audio. Whoever
|
|
// teaches a family to end a pull stream early has to revisit the
|
|
// pair together.
|
|
if (!header_sent) {
|
|
if (result.audio_output.has_value()) {
|
|
write_audio(*result.audio_output);
|
|
} else {
|
|
for (const auto &named : result.named_audio_outputs) {
|
|
write_audio(named.audio);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!header_sent && !client_gone) {
|
|
// Routed successfully and produced nothing. Same judgement as
|
|
// TTS: from the caller's side the actionable fact is that this
|
|
// model does not do this.
|
|
throw audiocpp_backend::CapabilityError(
|
|
"audio-cpp: family '" + model->family() +
|
|
"' produced no audio for the TTSStream RPC");
|
|
}
|
|
// client_gone is NOT an error: the client hung up, gRPC already
|
|
// knows, and there is nobody left to tell.
|
|
return GStatus::OK;
|
|
} catch (const std::exception &err) {
|
|
// No Result message to fill in on this RPC. A status mid-stream is
|
|
// what the client sees, and gRPC delivers it after whatever chunks
|
|
// already went out.
|
|
return to_status(err);
|
|
}
|
|
}
|
|
|
|
// Server-streaming transcription: zero or more TranscriptStreamResponse
|
|
// messages carrying `delta`, then exactly one carrying `final_result`.
|
|
//
|
|
// THE DELTA CONTRACT. core/backend/transcript.go documents Delta as "an
|
|
// incremental text fragment" and the HTTP layer appends them, so a client
|
|
// that concatenates every delta must end up with the final text and must
|
|
// never see a prefix repeated. The families do not agree on what they report
|
|
// (nemotron_asr and vibevoice_asr send fragments, voxtral_realtime sends the
|
|
// whole hypothesis and repeats the last event of each batch), and a delta
|
|
// must additionally be valid UTF-8 or the Go client cannot unmarshal it at
|
|
// all, so the reconciliation lives in TranscriptDeltaTracker rather than
|
|
// here. See stream_delta.h.
|
|
//
|
|
// THE OFFLINE FALLBACK. mode_candidates lets this RPC fall back to an
|
|
// offline route, so a family with no streaming ASR still answers rather than
|
|
// returning UNIMPLEMENTED. It then runs once and the reconciliation below
|
|
// emits the whole transcript as a single delta: the same message sequence a
|
|
// streaming family produces, with one delta instead of many. That is a
|
|
// truthful degradation, and it needs no branch of its own, because a tracker
|
|
// that observed nothing reconciles to exactly one fragment.
|
|
GStatus AudioTranscriptionStream(
|
|
ServerContext *, const backend::TranscriptRequest *request,
|
|
grpc::ServerWriter<backend::TranscriptStreamResponse> *writer) override {
|
|
try {
|
|
GStatus refusal = GStatus::OK;
|
|
const auto model = snapshot_for(request, refusal);
|
|
if (model == nullptr) {
|
|
return refusal;
|
|
}
|
|
|
|
audiocpp_backend::RequestShape shape;
|
|
shape.has_prompt_text = !request->prompt().empty();
|
|
shape.pinned_task = model->pinned_task();
|
|
|
|
// Before the lane and before the file read, for the reasons spelled
|
|
// out in Diarize.
|
|
model->check_can_serve(audiocpp_backend::Rpc::AudioTranscriptionStream,
|
|
shape);
|
|
|
|
// TranscriptRequest.dst is the INPUT audio path, despite the field
|
|
// name; nothing is written back. Read at 16 kHz mono for the reasons
|
|
// in kSpeechSampleRate, which apply identically here: the streaming
|
|
// sessions build their spans in the same feature domain their
|
|
// offline halves do.
|
|
auto audio = audiocpp_backend::read_audio_file(request->dst(),
|
|
kSpeechSampleRate);
|
|
const int sample_rate = audio.sample_rate;
|
|
const float duration = audio_duration_seconds(audio);
|
|
|
|
audiocpp_backend::LaneEntry lane = model->acquire(0);
|
|
const auto session = model->session_for(
|
|
audiocpp_backend::Rpc::AudioTranscriptionStream, shape, lane);
|
|
|
|
// session.task, not the request: for Asr the prompt is decoding
|
|
// context, for Alignment it is the transcript to align.
|
|
const auto task = build_transcription_request(*request, session.task,
|
|
std::move(audio));
|
|
if (!task.audio_input.has_value()) {
|
|
// Unreachable through build_transcription_request, which always
|
|
// sets it. Checked because the alternative is dereferencing an
|
|
// empty optional below.
|
|
throw std::runtime_error(
|
|
"audio-cpp: transcription request carries no audio");
|
|
}
|
|
|
|
audiocpp_backend::TranscriptDeltaTracker tracker;
|
|
bool client_gone = false;
|
|
const auto write_delta = [&](const std::string &fragment) {
|
|
if (fragment.empty() || client_gone) {
|
|
return;
|
|
}
|
|
backend::TranscriptStreamResponse response;
|
|
response.set_delta(fragment);
|
|
if (!writer->Write(response)) {
|
|
client_gone = true;
|
|
}
|
|
};
|
|
const auto emit = [&](const engine::runtime::StreamEvent &event) {
|
|
if (!event.partial_text.has_value()) {
|
|
return;
|
|
}
|
|
write_delta(tracker.observe(event.partial_text->text));
|
|
};
|
|
|
|
engine::runtime::TaskResult result;
|
|
if (session.mode == audiocpp_backend::Mode::Streaming) {
|
|
// The buffer inside the request is the chunk source, so the
|
|
// audio is held once rather than copied for the feed.
|
|
result = audiocpp_backend::run_streaming_audio(
|
|
session, task, *task.audio_input, emit, lane);
|
|
} else {
|
|
result = audiocpp_backend::run_offline(session, task, lane);
|
|
}
|
|
|
|
// The reconciliation, and the only place the offline fallback needs
|
|
// to be thought about: with no partials observed this IS the single
|
|
// delta carrying the whole transcript.
|
|
if (result.text_output.has_value()) {
|
|
write_delta(tracker.reconcile(result.text_output->text));
|
|
}
|
|
|
|
backend::TranscriptStreamResponse final_response;
|
|
// sample_rate is the BUFFER's, which after read_audio_file is
|
|
// kSpeechSampleRate and not necessarily the file's; it is the domain
|
|
// the result spans come back in.
|
|
audiocpp_backend::fill_transcript_result(
|
|
result, sample_rate, duration,
|
|
final_response.mutable_final_result());
|
|
if (!client_gone) {
|
|
writer->Write(final_response);
|
|
}
|
|
return GStatus::OK;
|
|
} catch (const std::exception &err) {
|
|
return to_status(err);
|
|
}
|
|
}
|
|
|
|
// The one BIDIRECTIONAL stream this backend serves: live microphone ASR.
|
|
// The client sends a TranscriptLiveConfig, then TranscriptLiveAudio frames;
|
|
// this answers with a ready ack, then deltas and words as the audio
|
|
// arrives, then one message carrying final_result once the read side
|
|
// closes.
|
|
//
|
|
// THERE IS NO OFFLINE FALLBACK, unlike AudioTranscriptionStream. Live
|
|
// transcription has to consume audio incrementally, so a family with no
|
|
// streaming ASR is refused rather than served a batch run at the end; the
|
|
// Streaming-only mode_candidates list for this RPC is where that is
|
|
// expressed, and this handler needs no branch for it.
|
|
//
|
|
// THE READY ACK IS A CONTRACT, not a courtesy. core/backend/transcript_live.go
|
|
// sends the config and then BLOCKS on Recv, treating an Unimplemented there
|
|
// as "this backend cannot do live transcription, degrade to the file path"
|
|
// and a first response carrying data as a broken backend. So routing must be
|
|
// refused before the ack, and the ack must go out before the first audio
|
|
// frame is read, or the client never sends one and both sides wait.
|
|
//
|
|
// eou AND eob STAY FALSE. TranscriptLiveResponse carries them for
|
|
// cache-aware models that emit end-of-utterance and end-of-backchannel
|
|
// tokens; audio.cpp's StreamEvent has no equivalent signal, and inferring
|
|
// one from silence would be a guess wearing a protocol flag's clothes. A
|
|
// client uses eou to decide the speaker yielded the turn, so a wrong one
|
|
// cuts people off mid-sentence. False is not a degradation here, it is the
|
|
// truth: this backend does not know.
|
|
GStatus AudioTranscriptionLive(
|
|
ServerContext *context,
|
|
grpc::ServerReaderWriter<backend::TranscriptLiveResponse,
|
|
backend::TranscriptLiveRequest> *stream) override {
|
|
try {
|
|
// snapshot_unchecked, which every other model-touching handler is
|
|
// forbidden to call. TranscriptLiveRequest carries NO ModelIdentity
|
|
// field, in either arm of its oneof, so snapshot_for does not even
|
|
// instantiate for it: there is nothing to compare. See case 2 at
|
|
// snapshot_unchecked, including why the fix is a proto change.
|
|
const auto model = snapshot_unchecked();
|
|
if (model == nullptr) {
|
|
return GStatus(grpc::StatusCode::FAILED_PRECONDITION,
|
|
"audio-cpp: no model is loaded; call LoadModel first");
|
|
}
|
|
|
|
backend::TranscriptLiveRequest incoming;
|
|
if (!stream->Read(&incoming)) {
|
|
// Opened and closed with nothing said. A clean exit, not an
|
|
// error: there is nothing to transcribe and nobody to tell.
|
|
return GStatus::OK;
|
|
}
|
|
if (!incoming.has_config()) {
|
|
return GStatus(grpc::StatusCode::INVALID_ARGUMENT,
|
|
"audio-cpp: the first AudioTranscriptionLive "
|
|
"message must carry a Config");
|
|
}
|
|
const backend::TranscriptLiveConfig config = incoming.config();
|
|
|
|
audiocpp_backend::RequestShape shape;
|
|
// No request signal chooses the task here: TranscriptLiveConfig has
|
|
// no prompt field, so has_prompt_text stays false and the only
|
|
// candidate is Asr. pinned_task is still copied, because without it
|
|
// the model's `task:` option is dead on this RPC.
|
|
shape.pinned_task = model->pinned_task();
|
|
|
|
// FIRST, before the lane and BEFORE the rate check below, like every
|
|
// other handler here. A family that cannot stream ASR is answering a
|
|
// question about itself, and that answer outranks any complaint
|
|
// about the request: pkg/grpc/grpcerrors/errors.go degrades to the
|
|
// file path on UNIMPLEMENTED and on nothing else, so a live-incapable
|
|
// model asked at a wrong rate must not answer INVALID_ARGUMENT and
|
|
// cost the caller its fallback. Unreachable from LocalAI today,
|
|
// which always sends 16000, and wrong on its own terms regardless.
|
|
model->check_can_serve(audiocpp_backend::Rpc::AudioTranscriptionLive,
|
|
shape);
|
|
|
|
// 16 kHz mono or nothing, and this is a REFUSAL rather than a
|
|
// resample for the reason spelled out at kSpeechSampleRate: the
|
|
// streaming families build their spans in their own 16 kHz feature
|
|
// domain whatever the input rate was, so honouring an 8 kHz session
|
|
// would return word timestamps 2x off with a 200 and no diagnostic.
|
|
// The file-fed handlers resample their input to make the two
|
|
// domains the same one; live audio arrives a frame at a time from a
|
|
// client that already picked a rate, and read_audio_file's
|
|
// resampler cannot be applied to it incrementally, so the only
|
|
// honest answers are this refusal or a wrong timestamp.
|
|
//
|
|
// ZERO means 16000, which is what backend.proto documents. Every
|
|
// OTHER value is refused, including a negative one: -1 is malformed
|
|
// rather than absent, and quietly handing it the default would be
|
|
// this handler inventing a request the caller did not make.
|
|
//
|
|
// Costs nothing today: core/backend/transcript_live.go hardcodes
|
|
// liveSampleRate = 16000.
|
|
if (config.sample_rate() != 0 &&
|
|
config.sample_rate() != kSpeechSampleRate) {
|
|
return GStatus(grpc::StatusCode::INVALID_ARGUMENT,
|
|
"audio-cpp: live transcription accepts " +
|
|
std::to_string(kSpeechSampleRate) +
|
|
" Hz mono PCM only (or 0 to mean it), got " +
|
|
std::to_string(config.sample_rate()) +
|
|
" Hz; resample on the client side");
|
|
}
|
|
const int sample_rate = kSpeechSampleRate;
|
|
|
|
// THE LANE IS HELD FOR THE WHOLE STREAM, which is longer than any
|
|
// other handler holds it: as long as the user keeps talking. The
|
|
// streaming session is stateful and cached, so a concurrent run on
|
|
// the same model would interleave its audio with this one's and
|
|
// corrupt both transcripts. Named local because LaneEntry is
|
|
// immovable and has to span session_for, every chunk and finalize.
|
|
audiocpp_backend::LaneEntry lane = model->acquire(0);
|
|
const auto session = model->session_for(
|
|
audiocpp_backend::Rpc::AudioTranscriptionLive, shape, lane);
|
|
|
|
engine::runtime::TaskRequest task;
|
|
// An EMPTY buffer carrying the CONTRACT only: the rate and the
|
|
// channel count of the frames about to arrive, and no samples,
|
|
// because none exist yet. Not decoration. build_preparation_request
|
|
// derives the audio contract from this field, and nemotron_asr's
|
|
// streaming prepare() throws "Nemotron ASR streaming prepare()
|
|
// requires an audio contract" when it is absent, so a live stream
|
|
// without it fails on the first model that serves it.
|
|
engine::runtime::AudioBuffer contract;
|
|
contract.sample_rate = sample_rate;
|
|
// Mono, from the proto: TranscriptLiveAudio.pcm is documented as
|
|
// "mono PCM in [-1,1] at config.sample_rate" and there is no channel
|
|
// field to say otherwise.
|
|
contract.channels = 1;
|
|
task.audio_input = std::move(contract);
|
|
|
|
if (!config.language().empty()) {
|
|
task.options["language"] = config.language();
|
|
}
|
|
for (const auto ¶m : config.params()) {
|
|
task.options[param.first] = param.second;
|
|
}
|
|
|
|
bool client_gone = false;
|
|
backend::TranscriptLiveResponse ack;
|
|
ack.set_ready(true);
|
|
if (!stream->Write(ack)) {
|
|
// The client hung up between opening the stream and the ack.
|
|
// Nothing was consumed and nobody is listening.
|
|
return GStatus::OK;
|
|
}
|
|
|
|
audiocpp_backend::TranscriptDeltaTracker tracker;
|
|
const auto write_delta = [&](const std::string &fragment) {
|
|
if (fragment.empty() || client_gone) {
|
|
return;
|
|
}
|
|
backend::TranscriptLiveResponse response;
|
|
response.set_delta(fragment);
|
|
if (!stream->Write(response)) {
|
|
client_gone = true;
|
|
}
|
|
};
|
|
|
|
// The SAME reconciliation AudioTranscriptionStream uses, and for the
|
|
// same reason: the families disagree about whether partial_text is a
|
|
// fragment or the whole hypothesis, one of them delivers the same
|
|
// event twice, and a delta that ends inside a UTF-8 sequence makes
|
|
// the Go client's Unmarshal fail and costs the client every
|
|
// remaining message. None of that is re-derived here. See
|
|
// stream_delta.h.
|
|
const auto emit = [&](const engine::runtime::StreamEvent &event) {
|
|
if (event.partial_text.has_value()) {
|
|
write_delta(tracker.observe(event.partial_text->text));
|
|
}
|
|
if (event.word_timestamps.empty() || client_gone) {
|
|
return;
|
|
}
|
|
// FORWARD TOLERANT, and dead against the pinned upstream: no
|
|
// family puts word timestamps on a StreamEvent, only on the
|
|
// TaskResult, so today every word a live client sees arrives in
|
|
// final_result below. Kept because the field exists, a family
|
|
// that fills it then works with no change here, and the
|
|
// conversion is the same one final_result gets.
|
|
backend::TranscriptLiveResponse response;
|
|
for (const auto &word : event.word_timestamps) {
|
|
auto *out = response.add_words();
|
|
// NANOSECONDS. TranscriptWord is the only unit in this proto
|
|
// that is not seconds, and core/backend reads it straight
|
|
// into a time.Duration.
|
|
out->set_start(audiocpp_backend::samples_to_nanoseconds(
|
|
word.span.start_sample, sample_rate));
|
|
out->set_end(audiocpp_backend::samples_to_nanoseconds(
|
|
word.span.end_sample, sample_rate));
|
|
out->set_text(word.word);
|
|
}
|
|
if (!stream->Write(response)) {
|
|
client_gone = true;
|
|
}
|
|
};
|
|
|
|
// ARMED ONLY NOW, which is after the lane was taken and before the
|
|
// first audio frame is read, and DISARMED when the read side closes.
|
|
// Both ends matter:
|
|
//
|
|
// - not earlier, because acquire() above can legitimately block
|
|
// for as long as another live stream is running, and cancelling
|
|
// a caller for waiting its turn would be this backend punishing
|
|
// its own queue. The read of the CONFIG is likewise unwatched:
|
|
// it holds no lane, so a client that stalls there wedges
|
|
// nothing.
|
|
// - not later, because everything past the read loop is OUR
|
|
// compute. A decode that outruns the window is not a peer that
|
|
// went quiet, and cancelling there would throw away the
|
|
// transcript the client is waiting for.
|
|
//
|
|
// The status the client actually receives is CANCELLED, not the
|
|
// DEADLINE_EXCEEDED returned below: TryCancel is the only way to
|
|
// unblock a synchronous Read, and it decides the wire status itself.
|
|
// The point of this is not the status, it is that the lane comes
|
|
// back.
|
|
audiocpp_backend::IdleWatchdog idle(
|
|
std::chrono::milliseconds(model->live_idle_timeout_ms()),
|
|
[context, &model] {
|
|
// Logged, because from the client's side this looks like an
|
|
// unexplained cancellation and there is nowhere else to say
|
|
// why.
|
|
std::cerr << "audio-cpp: live stream idle for "
|
|
<< model->live_idle_timeout_ms()
|
|
<< " ms with the send side still open; cancelling "
|
|
"it to release the model lane\n";
|
|
context->TryCancel();
|
|
});
|
|
|
|
std::int64_t consumed_frames = 0;
|
|
const auto next_frames = [&](std::vector<float> &out) {
|
|
while (stream->Read(&incoming)) {
|
|
if (incoming.has_config()) {
|
|
// backend.proto says a second Config resets the decode
|
|
// session. audio.cpp cannot do that truthfully: a reset
|
|
// would clear the session's audio while the deltas
|
|
// already on the wire cannot be taken back, so the final
|
|
// text would then describe only the audio after the
|
|
// reset and would CONTRADICT the transcript the client
|
|
// assembled. Refused loudly rather than ignored: a
|
|
// client that believes it reset the decoder and did not
|
|
// is handed a transcript that silently continues the
|
|
// audio it thought it discarded.
|
|
throw audiocpp_backend::ConfigError(
|
|
"audio-cpp: family '" + model->family() +
|
|
"' cannot reset a live session mid-stream; close "
|
|
"this stream and open a new one");
|
|
}
|
|
// Touched only for a frame the decoder can actually consume,
|
|
// and touched AFTER the filters rather than before them.
|
|
// Neither arm of the oneof set, or an empty pcm field, is
|
|
// nothing to feed and not worth failing a live session over
|
|
// - but it is also not the peer proving it is still there,
|
|
// and resetting the window for it let one client hold the
|
|
// model's only lane indefinitely by writing empty frames
|
|
// faster than the window. See live_frame_carries_audio.
|
|
const auto &pcm = incoming.audio().pcm();
|
|
if (!audiocpp_backend::live_frame_carries_audio(
|
|
incoming.has_audio(), pcm.empty())) {
|
|
continue;
|
|
}
|
|
idle.touch();
|
|
out.assign(pcm.begin(), pcm.end());
|
|
// Mono, so floats and frames are the same count. Only used
|
|
// for the duration reported in final_result.
|
|
consumed_frames += static_cast<std::int64_t>(pcm.size());
|
|
return true;
|
|
}
|
|
// The read side is closed, one way or the other. Stop the timer
|
|
// BEFORE saying so, since what follows is finalize().
|
|
idle.disarm();
|
|
if (idle.fired()) {
|
|
// Read returned false because we cancelled the RPC, not
|
|
// because the client closed. Thrown rather than returned so
|
|
// the driver does not go on to finalize: there is nobody
|
|
// left to send a transcript to, and the whole point was to
|
|
// stop occupying the lane.
|
|
throw LiveIdleTimeout(
|
|
"audio-cpp: no audio frame arrived within " +
|
|
std::to_string(model->live_idle_timeout_ms()) +
|
|
" ms and the client did not close the stream; cancelled "
|
|
"to release the model");
|
|
}
|
|
return false;
|
|
};
|
|
|
|
// The driver owns the streaming state obligation and the stream
|
|
// event sink: nemotron_asr, the only family that serves this RPC
|
|
// today, reports EVERY partial through the sink from inside
|
|
// finalize(), and its process_audio_chunk returns a bare event. It
|
|
// also buffers the wire's frames up to the family's own preferred
|
|
// window, which is one second for nemotron_asr and nothing like the
|
|
// 512-sample frames a client's audio callback produces.
|
|
//
|
|
// "LIVE" HERE MEANS INCREMENTAL INPUT, NOT LOW LATENCY, and with the
|
|
// families in the pinned checkout it does not yet mean incremental
|
|
// OUTPUT either. nemotron_asr's process_audio_chunk only appends to
|
|
// its buffer (src/models/nemotron_asr/session.cpp:424-436); the whole
|
|
// decode, and therefore every delta, happens inside finalize(). So
|
|
// against nemotron_asr every delta arrives AFTER the client closes
|
|
// its send side, and the driver's policy-window buffering changes
|
|
// nothing measurable. It is not inert for vibevoice_asr or
|
|
// higgs_audio_stt, which decode per chunk. Nobody should benchmark
|
|
// time-to-first-delta against nemotron_asr and conclude this is
|
|
// broken: it is the family, and the fix is a family that streams its
|
|
// decode.
|
|
const auto result = audiocpp_backend::run_streaming_live(
|
|
session, task, next_frames, emit, lane);
|
|
|
|
// Makes concatenating every delta equal final_result's text, which
|
|
// is what the HTTP layer above assembles. Also what flushes a
|
|
// held-back partial UTF-8 sequence.
|
|
if (result.text_output.has_value()) {
|
|
write_delta(tracker.reconcile(result.text_output->text));
|
|
}
|
|
|
|
backend::TranscriptLiveResponse final_response;
|
|
audiocpp_backend::fill_transcript_result(
|
|
result, sample_rate,
|
|
audiocpp_backend::samples_to_seconds(consumed_frames, sample_rate),
|
|
final_response.mutable_final_result());
|
|
if (!client_gone) {
|
|
stream->Write(final_response);
|
|
}
|
|
return GStatus::OK;
|
|
} catch (const LiveIdleTimeout &err) {
|
|
// For the server's own record, and for a client that somehow reads
|
|
// it: TryCancel has already decided the wire status is CANCELLED.
|
|
// The lane was released by unwinding to here, which is the point.
|
|
return GStatus(grpc::StatusCode::DEADLINE_EXCEEDED, err.what());
|
|
} catch (const std::exception &err) {
|
|
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<backend::AudioTransformFrameResponse,
|
|
backend::AudioTransformFrameRequest> *) override {
|
|
return refuse_surface(
|
|
audiocpp_backend::UnsupportedRpc::AudioTransformStream);
|
|
}
|
|
|
|
// Same, and see the note above about not reading the stream.
|
|
GStatus AudioToAudioStream(
|
|
ServerContext *,
|
|
grpc::ServerReaderWriter<backend::AudioToAudioResponse,
|
|
backend::AudioToAudioRequest> *) 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) {
|
|
AudioCppBackend service;
|
|
grpc::EnableDefaultHealthCheckService(true);
|
|
grpc::reflection::InitProtoReflectionServerBuilderPlugin();
|
|
|
|
ServerBuilder builder;
|
|
builder.AddListeningPort(addr, grpc::InsecureServerCredentials());
|
|
builder.RegisterService(&service);
|
|
// Audio payloads (PCM buffers, encoded frames) are far larger than the
|
|
// 4 MiB gRPC default.
|
|
builder.SetMaxReceiveMessageSize(256 * 1024 * 1024);
|
|
builder.SetMaxSendMessageSize(256 * 1024 * 1024);
|
|
|
|
std::unique_ptr<Server> server(builder.BuildAndStart());
|
|
if (!server) {
|
|
std::cerr << "audio-cpp grpc-server: failed to bind " << addr << "\n";
|
|
std::exit(1);
|
|
}
|
|
std::cerr << "audio-cpp grpc-server listening on " << addr << "\n";
|
|
|
|
// Wait() runs off the main thread so the main thread stays free to notice
|
|
// the shutdown flag and call Shutdown itself, outside any signal context.
|
|
std::thread serving([&server] { server->Wait(); });
|
|
|
|
// Polled rather than waited on: notifying a condition variable from a
|
|
// signal handler is not async-signal-safe either, so a condition variable
|
|
// would move the same defect rather than fix it. Ten wakeups a second on an
|
|
// otherwise idle process is not worth a more elaborate scheme.
|
|
while (!g_shutdown_requested.load(std::memory_order_relaxed)) {
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
|
}
|
|
|
|
// In-flight RPCs get three seconds to drain before the server stops hard.
|
|
server->Shutdown(std::chrono::system_clock::now() + std::chrono::seconds(3));
|
|
serving.join();
|
|
}
|
|
|
|
void signal_handler(int) {
|
|
// Sets a lock-free atomic and returns. Nothing else belongs here.
|
|
//
|
|
// Calling grpc::Server::Shutdown directly from a signal handler, which is
|
|
// what this used to do, takes an absl::Mutex. That is not async-signal-safe:
|
|
// the handler can interrupt a thread that already holds the same mutex, and
|
|
// abseil's deadlock detector sees the reentrant acquisition and aborts the
|
|
// process. The observed result was exit 134 and a "dying due to potential
|
|
// deadlock" stack on every SIGTERM, which is exactly the crash signature a
|
|
// real teardown bug would have to compete with.
|
|
g_shutdown_requested.store(true, std::memory_order_relaxed);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main(int argc, char *argv[]) {
|
|
std::string addr = "127.0.0.1:50051";
|
|
for (int i = 1; i < argc; ++i) {
|
|
std::string a = argv[i];
|
|
const std::string addr_flag = "--addr=";
|
|
if (a.rfind(addr_flag, 0) == 0) {
|
|
addr = a.substr(addr_flag.size());
|
|
} else if (a == "--addr" && i + 1 < argc) {
|
|
addr = argv[++i];
|
|
} else if (a == "--help" || a == "-h") {
|
|
std::cout << "Usage: grpc-server --addr=HOST:PORT\n";
|
|
return 0;
|
|
}
|
|
}
|
|
std::signal(SIGINT, signal_handler);
|
|
std::signal(SIGTERM, signal_handler);
|
|
RunServer(addr);
|
|
return 0;
|
|
}
|