feat(nemo-speech-cpp): add the NVIDIA NeMo-Speech.cpp backend - #11406
Merged
Conversation
Adds the backend skeleton and the NeMo-Speech.cpp build, pinned at
2e12e2def8a98ed06666f7ee3ca94e7193e04be4. The Go side is deliberately a stub:
it dlopens the runtime and starts the gRPC server, later work fills in the
symbol table and the model logic.
Three details of the upstream layout differ from what the plan assumed, and the
build reflects the real tree:
* The TTS C ABI ships as libnemo_speech_tts, not libnemo_speech_tts_c. Upstream
compiles c_api.cpp straight into the implementation library and only aliases
the nemo_speech_tts_c CMake target, so no _c object exists on disk. ASR and
NMT do build a real _c shim.
* Shared objects land in build/bin, since upstream points
CMAKE_LIBRARY_OUTPUT_DIRECTORY at ${CMAKE_BINARY_DIR}/bin.
* The ASR and NMT _c shims carry a DT_NEEDED on libnemo_speech_asr and
libnemo_speech_nmt, so those are staged and packaged alongside them.
Otherwise dlopen fails at startup.
The ggml patch step uses an order-only prerequisite. cmake writes into the
checkout and bumps its mtime past the sentinel, which would otherwise re-run
git apply over an already-patched tree and break every incremental build.
Assisted-by: Claude Code:claude-opus-5[1m]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
… ITN stack Addresses the review of the scaffold commit. backend/Dockerfile.golang runs 'make -C backend/go/$(BACKEND) build' and then copies package/ into the final image, so 'build' has to end with a populated package/. It only staged shared objects, which would have shipped an image with no binary and no libraries at all. The old staging recipe is now stage-libs and the chain is stage-libs, nemo-speech-cpp-grpc, package, build, matching every sibling Go backend. Text normalization was packaged incorrectly. nemo_speech_text_normalization is STATIC but links sparrowhawk, fstfar and fst PUBLIC, so they land as DT_NEEDED on libnemo_speech_asr.so, and they live in a project-local prefix that nothing else provides. WITH_NORM stays ON by default on Linux, since normalization is a wanted feature. Instead stage_libs now copies .deps/itn/lib when WITH_NORM=ON, and package.sh bundles it. Staging that prefix is still not enough on its own: Sparrowhawk drags in protobuf, re2 and absl, which neither build_itn_deps.sh nor package-system-libs.sh provides. Rather than hard-code another hand-maintained list, package.sh now walks the DT_NEEDED entries of everything staged and copies whatever is unresolved, skipping the core set and the GPU set that the shared scripts already own. It fails at package time, not at first dlopen, when something cannot be resolved. On a WITH_NORM=OFF build the closure is already complete and it copies nothing. Restore CGO_ENABLED=0 on the Go build to match whisper, parakeet-cpp and omnivoice-cpp. Note that purego reaches dlopen through fakecgo, so the binary is dynamically linked either way; what the flag changes is the NEEDED set, and lib/ld.so routing in run.sh exists precisely because the binary is not static. Replace the hand-rolled .patched sentinel with upstream's scripts/apply-ggml-patches.sh. It applies the series in filename order, exits non-zero when a patch does not apply, and detects "already applied" by comparing the full-series tree hash rather than an mtime, so it is safe to run every time and there is no sentinel left to go stale or to wedge the build when deleted. It is wired as an order-only prerequisite so running it does not force a relink. Also: correct the package.sh header, which claimed three shared objects when there are five and none of the TTS ones carry a _c suffix; give 'make test' the LD_LIBRARY_PATH the dlopen tests will need; document that a NEMO_SPEECH_VERSION bump needs 'make purge'; and extend 'clean' to remove package/ and the ITN libraries. Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
…its toolchain Addresses the second review round. The dependency-closure guard failed open. Its glob expands once per pass, so each pass advanced the closure by exactly one level, and the fixed count of five passes then fell out of the loop without checking whether anything remained. An eight-deep chain packaged six libraries, exited zero and reported success. That is the case the guard was written for: asr to sparrowhawk to protobuf to absl already runs several levels deep, so a WITH_NORM build could ship missing its deepest libraries and fail at first dlopen. The loop now runs until the staged set stops growing, and exhausting the bound is a hard error rather than a silent exit. For the same reason, a build image with neither readelf nor objdump no longer warns and skips. It cannot show the package is complete, so it refuses to ship it. The guard is entered only when there is something to check, so an empty package cannot trip the new error. Dockerfile.golang installed ninja-build only in the Vulkan branch while this Makefile runs cmake -G Ninja unconditionally, so the CPU, cuBLAS and L4T images could not configure at all. ninja-build moves to the common apt list; it does not change CMake's default generator, so it is inert for the other backends. gcc-12 was nowhere in the tree, yet WITH_NORM defaults ON and build_itn_deps.sh needs it, so the committed default was unbuildable in CI. Install it, with the protobuf, absl, re2 and autotools that Sparrowhawk and OpenFST need, gated on BACKEND so the other Go images do not carry it. The list follows upstream's own docker/Dockerfile, trimmed of the gRPC, portaudio and python entries a BUILD_GRPC=OFF build does not use. Text normalization stays ON: downgrading it silently would ship a backend advertising a feature it lacks. Also: make test depend on stage-libs, so LD_LIBRARY_PATH is not an empty directory on a clean tree, and add an engine target so Dockerfile.golang's cacheable prebuild layer is not skipped and a CUDA build stops recompiling all of upstream on every Go-side change. Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
…own target Addresses the third review round. Dockerfile.golang installs protoc 27.1 into /usr/local/bin, ahead of /usr/bin, while libprotobuf-dev is the distro's 3.21 on noble and 3.12 on jammy. Sparrowhawk resolves protoc from PATH at make time (configure.ac uses AC_CHECK_PROG, so PROTOC substitutes to the bare word, and src/proto/Makefile.am invokes it) and commits no pregenerated stubs, so the rule always runs. Code generated by 27.1 includes google/protobuf/runtime_version.h and a PROTOBUF_VERSION guard the older headers lack, so the WITH_NORM build could not complete. Pin PROTOC to the apt one for that step; configure documents that a pre-set value wins. The apt protoc and libprotobuf-dev come from one source package at one version, which is the property that makes this correct. The text-normalization stack is now a target keyed on a file build_itn_deps.sh actually produces, rather than a side effect of the runtime library rule. As a side effect make could not see whether it existed, so once the library was up to date the script could never run again: a tree built WITH_NORM=OFF could not move to ON, and make test hard-failed with no escape but a full 345 MB clean. It is now built on demand and reachable on its own as 'make itn'. Staging keys on the prefix existing rather than on WITH_NORM, so it stages what the tree actually built, and package.sh's closure guard remains the backstop. An already-configured build tree also now wins over the platform default, so a tree built WITH_NORM=OFF is not silently reconfigured to ON by a bare make test, which is what demanded gcc-12 from developers who chose not to have it. An explicit WITH_NORM= on the command line still overrides both, and the ITN rule preflights for gcc-12 with an error that names the alternative. Move ninja-build out of the shared apt layer into the existing BACKEND-gated block. Dockerfile.golang serves 225 matrix entries and only this backend configures with -G Ninja, so the common list is byte-identical to master again and no other image loses its cache. Drop libabsl-dev and correct the comment that justified it. No base image here ships protobuf 25, so nothing needs the absl split, and the cmake glob looks in /usr/lib rather than the multiarch directory Ubuntu actually uses, so the package could never have contributed anything. Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
…ayers Addresses the fourth review round. The nemo-speech-cpp apt block sat immediately after the shared apt layer, above the Vulkan SDK build, the CUDA and ROCm installs, the Go toolchain and the protoc download. Docker keys each layer on its parent, so inserting a step there re-keys everything below it: a byte-identical shared layer is not enough, and merging as it stood would have forced all of those to re-execute once for every Go backend image. Move it down beside the existing opus, crispasr and sherpa-onnx gates, which sit after those layers for the same reason. Checked the ordering both ways before moving. Nothing between the two positions uses these packages: the Vulkan and opus blocks install their own ninja and pkg-config, go install protoc-gen-go needs the Go toolchain rather than protoc, and the protoc 27.1 step is a release-binary download that needs neither protobuf-compiler nor libprotobuf-dev. Nothing in the block needs anything those layers provide; it uses only apt, and the mirror rewrite from the first RUN persists in the image. It also runs no update-alternatives, so the default compiler stays untouched for later layers. The diff against master is now a single additive hunk with no shared layer touched. Also preflight ITN_PROTOC. configure gates a preset PROTOC on test -n alone, so a path that does not exist is accepted and the error surfaces much later as a bare "No such file or directory" from inside make -C src/proto. The pin introduced that failure on a box whose only protoc is in /usr/local/bin, which worked before. Check it alongside the gcc-12 check and name the ITN_PROTOC= override in the message. Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
… gpu index An empty value like "vad_model:" must stay empty, since callers read the empty string as "unset". That branch of resolve() had no spec: dropping the guard left every spec green while parseOptions started returning the models directory itself. Add the spec that fails without the guard. A known key with an unparseable value is a typo, not a config from a newer backend, and "gpu:banna" failed expensively: the model loaded, produced correct output, and ran on CPU with no signal anywhere. Log it. Unknown keys stay silently ignored, which is what keeps configs forward compatible. Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
purego binds by name at runtime and the config structs are passed by pointer, so both a renamed symbol and a mismatched struct layout would otherwise survive a green build. registerSymbols names the failing symbol, and the layout specs compare each Go mirror against the size the library reports for itself, against the offsets a C compiler produces for the installed headers, and against the default values upstream writes into the structs it returns. Two of the bindings differ from the plan because the headers do. The plan's nemo_speech_diar_segments signature omits the segmentation-config pointer that diar.h declares as the second parameter, which would have shifted the output buffer, the capacity and the count pointer one position each. And nemo_speech_diar_stream_push_f32 was missing from the symbol table although standalone diarization cannot work without it. Also close the two panic and equality gaps left in family.go: ValueString panics on a mistyped general.architecture, and the self-codec guard compared a Cleaned candidate path against an uncleaned one, so a doubled separator let the primary GGUF be selected as its own codec. Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
The layout assertions were inert. TEST_PATHS does not cover this backend and the per-backend list in test-extra had no entry for it, so nothing invoked the package's tests. Add it next to depth-anything-cpp, supertonic and vllm-cpp, the group whose own test target carries its build prerequisites; stage-libs already pulls the native build chain, so no prepare-test-extra entry is needed. The skip guard was also loader-inconsistent: librariesPresent stats bare filenames relative to the working directory while openLibraries resolves them through the loader search path, so any invocation other than make test skipped every library-backed spec and still reported green. NEMO_SPEECH_REQUIRE_LIBS=1 turns that into a failure naming the directory and the remedy, and the Makefile test target sets it. Unset, the plain skip survives so a developer without a build can still run the pure-Go layer specs. Trim the default-value fingerprint from roughly forty assertions to eight. It was pinning tunables such as threads and flush_partial_chunk, so a legitimate pin bump would have failed with a message reading like a layout error. What survives is only header-documented contract: the lone non-zero max_alternatives, the run of -1 sentinels and the zero that witnesses where it stops. Verified the narrowed spec still catches a mirror and offset table corrupted in lockstep, which is the one class only this layer sees. Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Load sniffs the GGUF architecture, maps it to a family and dispatches to the family's loader. requireFamily gates every other RPC, returning Unimplemented naming both the loaded and the wanted family so a misconfigured model YAML produces a message a user can act on. The family is committed only once its loader has succeeded. A load that fails part way through would otherwise leave the gate open on a handle that was never created. cstr uses runtime.Pinner rather than an ordinary Go allocation. The address crosses the ABI as a uintptr, which the collector does not trace, so incidental reachability through the release closure is not a guarantee: a caller discarding that closure could have the bytes collected before the create call reads them. Pinning is the sanctioned mechanism, makes the release function do real work, and turns a dropped release into a loud leaked-Pinner panic instead of silent corruption. Free overrides the base no-op to destroy the handle and reset the family. Every family owns C memory only its own destroy entry point can release, so without this an unloaded model leaks an acoustic model. Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Three review items, plus a defect the race detector turned up. The spec covering "no family selected after a failed load" wrote junk to a .gguf, so Load returned at ggufArchitecture before a family was ever chosen and the assertion was vacuous. Generalised the GGUF test helper to take a string architecture, and added a spec that loads a magpietts GGUF with no sibling codec, so familyFor succeeds and discoverTTSAssets then fails. It self-guards on ggufArchitecture so it cannot degrade back into the earlier path. requireFamily read n.fam unlocked while Free wrote it under engineMu, which the race detector confirms is a real race. pkg/grpc/server.go calls Free without the backend lock every other RPC holds, so teardown can land mid-request. withEngine now takes the lock, checks the family and runs the body under one acquisition; two would leave a window for Free to destroy the handle between check and use. The locking protocol is stated in both directions for the RPCs still to be written. Running -race also enables checkptr, which aborts on cstr's pointer being read back by goString: converting a uintptr to a pointer is fatal whenever the address lands in a Go allocation, so a pinned Go buffer can never be dereferenced from Go. The pointer is for C alone. Both helpers now document the one-way contract, and goString is tested against a real C-owned string by rebinding the version symbol to return a raw char*. Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Create the ASR recognizer in loadASR and serve AudioTranscription. Segment times are int64 nanoseconds, not seconds: the proto field is an int64 that core/backend reads straight into a time.Duration, while the runtime reports word offsets in milliseconds. Words are grouped into one segment per consecutive speaker run, with the 1-based speaker tag carried through and 0 (untagged) left unlabelled. The whole RPC body runs inside withEngine so the family check and the C calls happen under one acquisition of engineMu. Free runs without the backend lock, so checking the family and then relocking would let a teardown destroy the handle in the gap. The audio decode is inside the closure too, which costs nothing: base.SingleThread already serialises this backend's RPCs. recognizeF32 guards zero-length PCM. &pcm[0] panics on an empty slice, so Go never reaches the C side's own "empty audio" rejection, and a silent clip or a truncated upload is ordinary input. pkg/utils has no WAV decode helper, only the ffmpeg normalisation, so audio.go pairs AudioToWav with go-audio the way parakeet-cpp does. It returns the sample rate rather than a duration, since the C API resamples off that number. Also closes the write-side half of the race Task 5 fixed on the read side: Load now holds engineMu across the family switch and the n.fam commit, matching Free. The loaders still must not take it. Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
AudioTranscriptionStream drives a whole clip through the cache-aware
streaming API in 100 ms pushes, emitting each finalized utterance as a
delta and closing with the assembled result. AudioTranscriptionLive
serves the bidirectional RPC over the same session: config first, a ready
ack, deltas with word timings as utterances land, and a terminal result
when the caller closes its send side.
Both wrap their body in withEngine, so a stream holds engineMu for its
whole life and Free waits on it rather than destroying the recognizer
underneath a half-finished stream. That makes the way out load-bearing:
the file loop honours the request context between pushes, and the live
loop ends when the host closes the request channel, so a disconnected
client cannot pin the model against unload.
Only finals become deltas. The runtime applies punctuation and inverse
text normalization on finals only, so a final rewrites the utterance
rather than extending its interim, and delta on the wire is
newly-finalized text that consumers concatenate. Forwarding interims
would duplicate and mispunctuate every utterance.
The four streaming entry points sit behind an asrSession interface. No
NeMo GGUF is small enough to keep in the tree, so without that seam the
need-more-audio drain would have no test at all: nemo_speech_asr_stream_next
reports OK with a NULL handle when it wants more audio, which is a pause
rather than an end, and reading it either way round drops results or
spins forever.
Also folds in three items from the offline transcription review:
- empty audio is now refused before anything crosses the ABI, not
inside recognizeF32. The added integration spec caught the old
ordering panicking on an unbound entry point instead of failing;
- an undecodable sample rate is an error rather than 0, which this
runtime reads as "already at the model rate" and would have made a
wrong rate silently pitch-shift the audio;
- AudioTranscription guards its result pointer instead of relying on
an unstated invariant.
Assisted-by: Claude Code:claude-opus-5[1m]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
…ords runLive wrote the inter-utterance separator into the accumulated transcript but emitted the delta without it, so a two-utterance turn sent "one." and "two." while the terminal result read "one. two.". The live consumer is the one that really concatenates: the realtime semantic-VAD path joins the accumulated deltas with the empty string and clears them only at a turn reset, never at an endpoint, so the running caption read "one.two.". The separator now goes into the delta, as it already did on the file path, and the terminal text is the verbatim concatenation rather than a trimmed rebuild. TranscriptSegment.Words was never populated, so a request asking for timestamp_granularities ["word"] came back with no words at all even though the timings were decoded. wordsToSegments now attaches them, gated on the granularity the same way parakeet-cpp gates it, so a transcript that did not ask for word timestamps does not pay for them. Also: the final that comes back from the tail flush no longer claims an end-of-utterance. It is the end of the stream, not a user yielding the turn, and eou is what the realtime turn detector acts on. The comment explaining why interims are suppressed led with the runtime's postprocessing. The wire contract is the stronger reason and now comes first: consumers concatenate deltas, so forwarding a growing hypothesis assembles to "hehellhelloHello.". The postprocessing only explains why no diffing trick would rescue them. It is also ITN and strip_formatting rather than punctuation, which is off by default here. Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
loadDiarizer creates the Sortformer diarizer and Diarize serves the RPC over a diarization stream: decode, chunked push, finish, then the count-then-fill segments protocol. nemo_speech_diar_segment carries start_time and end_time in SECONDS already, not frame indices, so no conversion happens on the way to DiarizeSegment.start/end and the model's seconds-per-frame is not involved at all. The speaker label is the runtime's 1-based tag as a decimal string, matching what wordsToSegments emits on the ASR path, so the same speaker reads the same way whether a caller diarized a file or transcribed it. The six frame-geometry overrides are written as -1 rather than left zero. c_api.cpp applies left_context_frames when it is >= 0 while every other override needs > 0, so a zeroed config would silently pin the left context to zero and change the model's streaming geometry. nemo_speech_diar_segments writes *count before it rejects a buffer that is too small, so a rejected fill still reports the size to retry with. collectSegments uses that rather than truncating, bounded at four attempts because the RPC holds engineMu for its whole body and an unbounded retry would block an unload behind it. Two DiarizeRequest knobs map onto the segmentation config, and the proto and header names cross over: min_duration_on is the C min_duration_sec and min_duration_off is the C min_gap_sec. Six fields have no equivalent in this pipeline and are logged rather than dropped in silence: num_speakers, min_speakers and max_speakers (Sortformer's capacity is fixed by the checkpoint), clustering_threshold (there is no clustering stage), include_text (no ASR here) and threads. The empty-PCM guard fires before the stream is opened, so a silent clip never reaches a purego entry point that would dereference &pcm[0]. Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
… segment buffer The six frame-geometry overrides were written as -1 with nothing asserting it. c_api.cpp applies left_context_frames at >= 0 while the other five need > 0, so a dropped sentinel there pins the model's left context to zero, and the struct keeps exactly the same shape, which is all the layout assertions can see. Extracting diarModelConfig makes the values assertable: five specs now pin all six frame fields, the device index, the declared size and the NULL preset, each frame field on its own line so a missing sentinel names itself. distinctSpeakers had a spec with three segments over three distinct labels, which len(segs) satisfies just as well as the real thing. Four segments over three labels makes it a spec that can fail. collectSegments sized its buffer straight from a count the C side reported, and make() panics rather than erroring on a length it cannot satisfy, so an uninitialised size_t coming back across the ABI killed the backend process instead of failing one request. A ceiling of 2^22 segments, upwards of 93 hours of audio at one 80 ms frame each, turns that into a diagnosable error. Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
The PCM callback is compiled once per process behind a sync.Once, not once per
request and not once per load. purego.NewCallback writes into a fixed table of
2000 entries (purego/syscall_sysv.go) and never releases one, so a per-request
callback panics the backend process on the 2001st synthesis, and a per-load one
reaches the same ceiling on a server that swaps models. Synthesis is routed
through that single callback plus a user_data id: engineMu is per-model, one
process holds several models, so a single current-sink pointer would be
overwritten by two TTS models synthesizing at once.
Deviations from the brief, all verified against the real headers and proto:
- TTS is TTS(*pb.TTSRequest) error and TTSStream is
TTSStream(*pb.TTSRequest, chan []byte) error, per pkg/grpc/interface.go.
The brief's context/pb.Result and server-stream forms do not implement the
interface. The channel is closed on every path, including the family
rejection, because pkg/grpc/server.go blocks on its drain goroutine and an
unclosed channel hangs the RPC with the backend lock held.
- The callback takes unsafe.Pointer, not uintptr. Converting a uintptr
parameter back to a pointer is a checkptr violation that aborts under
-race.
- resolveSpeaker refuses to turn a negative number into a speaker index. -1
is the C API's "use the default" sentinel, so the brief's rule would have
made a request naming an invalid voice synthesize in the default voice
instead of being rejected.
temperature and cfg_scale each write their override flag as well:
magpietts/runtime.cpp reads the float only when the flag is set, so a
temperature without it is silently discarded.
Also folds in Task 8's review finding on asr.go: the six bare -1 sentinels in
loadASR move to an asrDiarConfig builder reusing diarGeometryDefault, with
specs. src/asr/c_api.cpp applies left_context_frames at >= 0, so a dropped
sentinel pins the model geometry to 0 and no layout assertion can see it.
Assisted-by: Claude Code:claude-opus-5[1m]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
nemo_speech_nmt_translate takes explicit source and target languages and has no free-form generation or token-callback entry point, so there is no prompt in the LLM sense. The pair comes from the source_language / target_language model options, with an optional leading [src->tgt] directive as the only per-request override, and PredictStream emits the whole translation as a single chunk because the C API has nothing finer to give it. Both RPCs wrap their body in withEngine so the family check and the C calls that trust the handle share one acquisition of engineMu. PredictStream closes its channel on every path, including the family rejection: this is the legacy streaming contract, and pkg/grpc/server.go blocks on a drain goroutine that only finishes when the channel closes, so leaving it open hangs the RPC rather than failing it. nmtTranslatorConfig is extracted so its four adjacent pointer fields can be asserted against distinct sentinels. Transposing two of them changes neither the struct size nor any field offset, so the layout assertions cannot see it. Also removes goString, which had no production caller: every string-returning symbol in abi.go is bound with a Go string return that purego converts itself. Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
…tive The directive regex allowed an unbounded run of two-letter segments per side, but nothing tested it: narrowing that run back to a single optional segment left every spec green. resolve_tag accepts a ready pair tag in one field with the other empty (src/nmt/langpairs.cc), and those tags run to three segments (en-zh-cn, pt-br-en), so a shorter pattern does not mis-split the tag, it fails to match the directive at all and the whole bracket is handed to the model as text to translate. The justification on the regex was also wrong and is corrected: pt-br and zh-cn are two segments and parse either way. It is the single-field form that needs the run. Renames the NMT handle to n.nmt so it stops sharing a name with the translator interface, following n.synth, which is shortened for the same reason. Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Registers nemo-speech-cpp across every surface .agents/adding-backends.md requires, and adds the CI job its unit suite never had. backend/index.yaml gets the meta backend (capabilities map, no uri), a development meta and 12 image entries. No amd and no intel capability keys: upstream NeMo-Speech.cpp builds ggml with CUDA, Vulkan or Metal only, and SystemState.Capability falls back to "default", so those hosts get the CPU build rather than a tag that does not exist. The nvidia-cuda-* and nvidia-l4t-cuda-* keys are present because getSystemCapabilities() refines an NVIDIA host to them whenever the CUDA directory exists; without them every modern CUDA host and Jetson would miss the map and quietly run on CPU. .github/backend-matrix.yml gets 7 include rows and 1 includeDarwin row. No hipblas and no sycl rows, for the same upstream reason. cpu and vulkan are per-arch pairs sharing a tag-suffix so backend-merge-jobs builds a multi-arch manifest: an ARM host with no NVIDIA GPU reports "default" and the Jetson image does not cover it. The CI job is the substantive part. make test-extra is dead on master, because prepare-test-extra depends on a protogen-python target that does not exist and no workflow invokes it anyway, so the entry added earlier in this series ran nowhere. abi_test.go asserts the size and field offsets of every Go mirror struct against the C ABI it is dlopened into, and those assertions are the only defence against silent memory corruption after a purego symbol rename or an upstream header change. tests-nemo-speech-cpp in test-extra.yml now executes them on pull_request and on master, gated on the backend's own path filter. The recipe sets NEMO_SPEECH_REQUIRE_LIBS=1, so a missing library fails rather than skips. WITH_NORM=OFF skips the OpenFST leg and costs no coverage: nothing in the four C ABI headers is conditional on it, so the layouts are identical. Also registers the upstream pin with the bump bot, which the backend Makefile already claimed but was never wired up, and adds the BackendCapabilities entry so a hand-written model config gets a real usecase surface. PossibleUsecases is the union of the four families and DefaultUsecases is transcript alone, the audio-cpp pattern. No VoiceCloning key: MagpieTTS synthesizes from baked speaker ids, not a reference clip. No gallery entries: publishing converted GGUFs is a follow-up. ModelIdentity needs no work in this backend. main.go serves through grpc.StartServer, so every RPC lands on pkg/grpc's shared server wrapper first, and checkModelIdentity is the first statement of all seven handlers this backend implements. A second check inside NemoSpeech would be unreachable and would risk diverging from the cross-language sentinel the router matches on. AudioTranscriptionLive stays unguarded because TranscriptLiveRequest carries no ModelIdentity field at all, which is a proto-level gap affecting every backend and needs its own change. Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
…key needs The nvidia-l4t-cuda-13 capability pointed at nvidia-l4t-arm64-nemo-speech-cpp, which is built on nvcr.io/nvidia/l4t-jetpack:r36.4.0 and therefore links ggml against CUDA 12. A Jetson whose CUDA 13 runtime is present reports that capability and would have pulled an image with no libcudart.so.12 to dlopen, failing hard at load. That is worse than omitting the key: with no key Capability() falls back to "default" and the host gets a working CPU build. Fixed the way parakeet-cpp and moss-transcribe-cpp already do it, by shipping the second L4T image rather than dropping the key. Nothing prevents building it here: those peers use plain ubuntu:24.04 on ubuntu-24.04-arm with the same Dockerfile.golang as this backend's other rows, and every package in the nemo-speech-cpp apt gate exists on noble arm64. Adds the -nvidia-l4t-cuda-13-arm64-nemo-speech-cpp matrix row and its two index entries, repoints the key on both metas, and rewrites the capability-map comment, which had the reasoning backwards. Also adds the documentary inferBackendPath branch, matching all six sibling *-cpp Go backends. Behaviour is unchanged; the generic golang fallthrough already resolved this backend correctly. The previous commit message said "all seven handlers" of the shared gRPC wrapper. There are eight RPC entry points: seven are guarded by checkModelIdentity and AudioTranscriptionLive is the unguarded eighth, which that message already called out separately. Wording only. Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Adds docs/content/features/nemo-speech-cpp.md, alongside the audio.cpp page that is its closest sibling, and cross-links it from the speech-to-text, diarization, text-to-speech, backend-type and compatibility-table pages so the backend is reachable from every surface that lists its modalities. The page covers the architecture-to-family table, every option key with a model YAML per family, the translation prefix directive, the acceleration matrix, and the four limitations this backend ships with: Linux-only inverse text normalization, suppressed interim streaming results, the library's default translation context and generation limits, and the absence of gallery entries. knownPrefOnlyBackends gains the backend so it appears in the /import-model dropdown. It stays preference-only and AutoDetect=false: general.architecture lives inside the GGUF where no remote-repo probe can read it, and a translation model carries an ordinary LLM architecture with no NeMo-specific marker. Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
…and the TTS conversion Three factual errors found in review, all of them the kind a user would act on. The translation limits were described backwards. Input longer than the 1024-token context is rejected, not truncated: translator.cpp throws "nmt: prompt too long (N tokens) for context 1024", which reaches the caller as a failed request. What is silently cut is the output, by the max_new_tokens loop at 256. The bullet now separates the two and says which one fails quietly. The macOS gap covers TTS text normalization as well. Both directions sit behind the single NEMO_SPEECH_WITH_NORM flag, which the Makefile forces off on Darwin, so tn_dir is as inert there as itn_dir. Neither fails the load: both warn and carry on. pnc_model really is unaffected, since punctuation is compiled in unconditionally. The tn_dir row in the option reference gained the caveat the itn_dir row already had. The TTS conversion procedure produced a model that could not load. It converted MagpieTTS and stopped, leaving no NanoCodec, which the same page lists as required; following it gave "no NanoCodec GGUF found next to ...". Both halves are now there, each with the download that feeds it, so the block runs top to bottom on a clean machine. Also: any negative gpu value pins TTS to the CPU, not only -1, and FLAG_CHAT additionally surfaces the model in the web UI chat picker. Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
INVALID_ARGUMENT was translated to codes.InvalidArgument at exactly one of sixteen C call sites. Everywhere else a non-zero status collapsed to codes.Internal, so the same backend answered an unsupported language pair with HTTP 400 and an unknown TTS voice, which is the same class of caller mistake against the same process, with HTTP 500. Status 4 is CANCELLED on the ASR and TTS surfaces and was reported as a backend failure rather than as the consumer having stopped listening. asr.h, tts.h and nmt.h each declare their own status enum and diar.h reuses the ASR one; the values they share agree, and the single divergence is that NMT declares no CANCELLED because nemo_speech_nmt_translate has no callback for a consumer to stop with. That is an absence, not a disagreement, so one table serves all three. status.go carries it, with the header line numbers and a note that a pin bump has to recheck it: purego binds by name and the status crosses as a bare int32, so nothing in the build or the linker can see a drift. New specs cover the whole enum, unknown values, and one real INVALID_ARGUMENT per family driven through the shared objects rather than through the Go mapping asserting against itself. Also add UsecaseChat to this backend's capability entry, which the docs already told operators to set for translation models. chat is a gallery filter key and completion is not, so GET /api/backends/usecases would have greyed the Chat filter out and hidden a Riva-Translate gallery entry from the one filter that fits it. The flag gates no endpoint; it makes the model eligible as the default chat model and puts it in the web UI chat picker, both of which Predict and PredictStream already serve. Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
gosec flags 13 alerts on this backend: one G304 and twelve G103. Each was checked individually rather than blanket-suppressed, and each annotation states what makes that particular site safe. The G304 at audio.go is a false positive. The opened path is filepath.Join of a directory the function just created with os.MkdirTemp and a constant basename; the request-controlled path is the input to AudioToWav and never reaches the open. The twelve G103 sites are the package's three established shapes, and every one was verified against them: cstr and pinPtr take the address of something pinned on the line above and return it one-way (nothing in the package converts either result back, which is what keeps checkptr out of it under -race), and each *Create hands C a stack-local POD config whose uintptr members are cstr allocations or pinPtr addresses held by a pinner the loader unpins only after the call. The two slice-building sites are bounded by construction: DiarSegments is handed exactly len(buf) with the buffer sized under maxDiarSegments and a reported count larger than it rejected rather than sliced to, and the TTS callback copies out a slice whose length is the length the runtime declared for that buffer. Separately, sampleRateOf gets a real fix rather than an annotation. go-audio reads the WAV header's sample rate from an unsigned 32-bit field into an int, so a header claiming more than 2^31-1 passed the "> 0" test and then narrowed to a NEGATIVE rate, which the runtime would take as a resampling ratio. AudioToWav cannot produce one today, but that is a property of another package and this function exists precisely because the rate is read back rather than assumed, so the bound is enforced here and pinned by a spec. The four remaining integer narrowings are annotated with the bound that makes each safe: the WAV payload length is already checked against maxWAVDataBytes, the speaker count is bounded by maxDiarSegments, and the two segment ids are the proto's own int32 wire type. Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
The macOS backend build died in patch-ggml:
scripts/apply-ggml-patches.sh: line 56: mapfile: command not found
make[1]: *** [patch-ggml] Error 127
mapfile is a bash 4 builtin (and its -d flag needs 4.4). macOS ships bash
3.2.57 as /bin/bash and GitHub's runner images add no newer one, so the
bare `bash` the recipe resolves from PATH cannot run upstream's script.
Rather than hunt for a capable bash that the runner does not have, drop
the step where it does nothing. ggml-patches/ is a CUDA series: every
kernel it adds is under src/ggml-cuda/, and its whole footprint outside
that directory is an op enum plus prototype in include/ggml.h, the
constructor and a name-table entry in src/ggml.c, and two ggml-cpu lines
that make the CUDA-only op report unsupported and abort. Nothing it
touches is compiled into a Metal kernel or changes a CPU one.
The project's own references to patch-only ggml symbols sit behind
NEMO_SPEECH_FUSED_RELPOS_ATTN and NEMO_SPEECH_FASTCONFORMER_CUDA_FUSIONS,
which cmake already forces OFF without GGML_CUDA, or behind
NEMO_SPEECH_GGML_PATCHED itself, which guards a GGML_TENSOR_FLAG_Q8_PLANAR
write that a non-CUDA buffer throws before reaching. So passing
NEMO_SPEECH_GGML_PATCHED=OFF costs the Metal build nothing, and it is
required once the series is skipped: that flag is what stops the ASR
sources referencing a tensor flag stock ggml does not define.
This is upstream's own Metal configuration. Its metal-* and vulkan-*
CMake presets inherit the cpu-* ones, which set NEMO_SPEECH_GGML_PATCHED
to OFF; docker/Dockerfile and scripts/windows/build.ps1 do the same for
their non-CUDA targets. LocalAI's Makefile never passed the flag at all
and so inherited the CUDA default everywhere.
Linux is untouched and keeps applying the series, including its
idempotency and its hard failure on a patch that does not apply. The gate
is the same uname test the WITH_NORM block above already uses, and both
branches keep the order-only clone prerequisite, which on a WITH_NORM=OFF
tree is the only thing that pulls sources/ in.
Assisted-by: Claude Code:claude-opus-5[1m]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
NEMO_SPEECH_TTS_WITH_JA=ON compiles Open JTalk's bundled MeCab, and mecab/src/dictionary.cpp derives a comparator from std::binary_function, which C++17 removed. libstdc++ still ships it as deprecated-but-present under -std=gnu++17, so Linux never notices. libc++ compiles it out and the macOS arm64 build dies with "no template named 'binary_function' in namespace 'std'". This is ours, not an upstream regression: upstream defaults both NEMO_SPEECH_TTS_WITH_JA and NEMO_SPEECH_TTS_WITH_ZH to OFF and the OSS drop carries no CI at all, so that target is never built there. Upstream does already carry the equivalent workaround for MSVC's STL (_HAS_AUTO_PTR_ETC plus /FIfunctional) but has no libc++ branch. libc++ gates the two templates on _LIBCPP_ENABLE_CXX17_REMOVED_UNARY_BINARY_FUNCTION, and has since LLVM 16, older than any clang Xcode still ships. The name is the whole problem: _LIBCPP_ENABLE_CXX17_REMOVED_BINDERS covers bind1st, bind2nd, ptr_fun and mem_fun and not unary_function or binary_function, and the umbrella _LIBCPP_ENABLE_CXX17_REMOVED_FEATURES no longer exists in libcxx at all. A wrong name preprocesses fine and fixes nothing. Applied through CMAKE_CXX_FLAGS rather than to the one target, because the tokenizer CMakeLists is upstream's and sources/ is a pinned checkout. Project-wide is also the safer scope: the macro decides whether libc++'s internal __binary_function alias resolves to std::binary_function or to __binary_function_keep_layout_base, a base class of std::less and friends, so defining it for a subset of translation units would give those class templates two spellings in one binary. Both bases are empty and, at C++17, carry identical members, so the define changes no layout and no ABI. Darwin only. On Linux the branch is unreachable and the macro is not a name libstdc++ knows, so it would be inert even if taken; a Linux configure with the flag forced on puts it on all 23 C++ TUs of nemo_speech_openjtalk_frontend including dictionary.cpp at -std=gnu++17, and on none of the 16 C TUs. Mandarin needs nothing: cppjieba v5.6.7 and limonp have no removed C++17 constructs left (limonp replaced std::not1 and std::bind2nd with lambdas) and cppjieba's own CI builds macos-14 and macos-latest at C++11 through C++20. Assisted-by: Claude Code:claude-opus-5[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
The first WITH_NORM=ON build failed compiling fst_normalizer.cpp against the
installed OpenFST 1.8.3 headers:
fst.h:690:59: error: no match for 'operator=' (operand types are
'std::unique_ptr<fst::SymbolTable, ...>' and 'fst::SymbolTable*')
FstImpl's copy-assignment operator assigns the raw pointer returned by
SymbolTable::Copy() straight to a std::unique_ptr member. No C++ standard
allows that, so the line is ill-formed everywhere; it survived because nothing
instantiates FstImpl::operator= and gcc up to 13 only checks a template
member's body when it is instantiated. gcc 14 resolves non-dependent operator
expressions at template definition time, so it rejects the line in any
translation unit that includes <fst/fst.h>. The CI diagnostic confirms the
phase: it reads "In member function", not "In instantiation of", and carries
no instantiation backtrace.
That is why this surfaces only here. build_itn_deps.sh compiles OpenFST with
gcc-12 and upstream's own images build the runtime with gcc-13, so neither
compiler reaches the check; backend/Dockerfile.golang installs gcc-14 and
promotes it with update-alternatives, and fst_normalizer.cpp is the one
translation unit in this backend that includes OpenFST.
Fix it in the installed ITN prefix, which is the only copy the cmake build
compiles against, using the same .reset() spelling FstImpl::SetInputSymbols
already uses for the identical operation. libfst.so is linked before this runs
and cannot contain the function, since no compiler could ever have emitted it,
so there is no ABI or ODR consequence. The rule is guarded on both sides so a
pin bump to a fixed OpenFST fails loudly rather than silently no-opping.
Verified with a real gcc 14.2: the CI error reproduces byte for byte from a
file whose entire content is '#include <fst/fst.h>', and gcc 14 reports
exactly two errors over the whole OpenFST include closure this backend uses,
both of them these two lines. After the patch that closure compiles clean
under gcc-14 with the target's own flags. The step is reachable only under
WITH_NORM=ON, so 'make -n stage-libs WITH_NORM=OFF' mentions neither it nor
the ITN build, and darwin, which defaults WITH_NORM to OFF, never evaluates it.
Assisted-by: Claude Code:claude-opus-5[1m]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
… 3.26
The JetPack r36.4.0 row dies on the first line of NeMo-Speech.cpp's
CMakeLists.txt:
CMake Error at CMakeLists.txt:3 (cmake_minimum_required):
-- Configuring incomplete, errors occurred!
Upstream opens with cmake_minimum_required(VERSION 3.26). That base image is
Ubuntu 22.04 jammy, whose apt cmake is 3.22.1, so configure aborts before it
reads a single one of the backend's -D flags. Every other Linux row in this
block is noble, which ships 3.28 and clears the bar, so the failure is one
base image wide rather than a code problem. Everything before it on that row
had already worked, including the OpenFST and Sparrowhawk ITN build.
No other Go backend needs this. parakeet-cpp and moss-transcribe-cpp share
the same JetPack base and both declare cmake_minimum_required(VERSION 3.18),
and nothing in the repo installs a cmake newer than the distro's, so there is
no existing pattern to reuse. Nothing depends on jammy's cmake staying 3.22
either: build_itn_deps.sh never invokes cmake at all, since OpenFST and
Sparrowhawk are autotools builds.
Kitware's release tarball rather than their APT repo or pip. The tarball is a
pinned URL with a published checksum, so an upstream release cannot change
what lands here. The APT repo does carry jammy arm64, but it serves a moving
latest that today is CMake 4.4, and 4.x drops compatibility with
cmake_minimum_required below 3.5, which vendored third_party subprojects
still declare; pinning it there would mean tracking Kitware's Debian revision
string instead of an upstream version. pip would drag a Python toolchain into
a backend that has none. 3.31.12 is the last 3.x release, so it clears 3.26
while keeping the CMake 3 policy surface, and it stays close to the 3.28 the
green noble rows already use. The binaries need only glibc 2.17 and carry no
libstdc++ DT_NEEDED, well under jammy's 2.35. doc/, man/, ccmake and cmake-gui
are not extracted; the final image is FROM scratch, but there is no reason to
page 100 MB of Qt GUI and docs through the CI cache.
Gated on the installed cmake actually being older than 3.26, so the rows that
already build green keep configuring with exactly the cmake they use today,
and folded into the existing ${BACKEND} block rather than added as a new
instruction, so no other Go backend image gains a layer and nothing above the
Vulkan SDK, CUDA, Go and protoc layers moves.
The symlink lands in /usr/local/bin and shadows apt's cmake. Unlike the protoc
shadowing that broke Sparrowhawk earlier in this series that is inert: protoc
has to agree with the libprotobuf headers it generates against, whereas cmake
links nothing into the product and has no ABI relationship with anything in
the image, and it resolves the symlink back to /opt to find its own Modules/
tree, so a 3.31 binary can never read 3.22's modules.
The version test avoids $(...) deliberately. BuildKit delivers a RUN heredoc
through an outer shell with an unquoted delimiter, so a command substitution
runs there, too early, in a container where the files it reads do not exist
yet, and its empty output is pasted into the script; the first draft took the
install branch on every row because of it.
Verified by building the block against nvcr.io/nvidia/l4t-jetpack:r36.4.0
arm64 under qemu, the row's actual base image: cmake 3.22.1 detected, tarball
checksum verified, 3.31.12 installed, and a cmake_minimum_required(VERSION
3.26) project configures with -G Ninja and builds, with CMAKE_ROOT resolving
to /opt/cmake/share/cmake-3.31. Same on ubuntu:22.04 amd64 and arm64.
ubuntu:24.04 skips the install, gains no /opt/cmake and still configures on
/usr/share/cmake-3.28. The NeMo-Speech.cpp compile itself on JetPack CUDA 12
is not reproducible here and remains for CI.
Assisted-by: Claude Code:claude-opus-5[1m]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
mudler
approved these changes
Aug 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
nemo-speech-cpp, a backend wrapping NVIDIA NeMo-Speech.cpp (Apache-2.0, C++/ggml), pinned at2e12e2def8a98ed06666f7ee3ca94e7193e04be4. Upstream is developed by NVIDIA, not by the LocalAI project.One Go backend binds three C ABI shared libraries through purego (no cgo), so the binary is identical across every variant image and only the
.soset differs.What it serves
The family is chosen from the GGUF
general.architecturekey at load, then every RPC is gated on it.general.architectureasrAudioTranscription,AudioTranscriptionStream,AudioTranscriptionLivesortformerDiarizemagpiettsTTS,TTSStreamPredict,PredictStreamnemo-nano-codec,vad,pncTranslation matches nothing NeMo-specific on purpose: those GGUFs come from llama.cpp's converter and carry an ordinary LLM architecture such as
qwen3.ASR optionally attaches Silero VAD, punctuation, inverse text normalization and Sortformer diarization through
options:keys. TTS needs a NanoCodec GGUF and an extracted tokenizer directory, auto-discovered from the model's own directory when unset.Hardware: CPU, CUDA 12 and 13, Vulkan, Jetson L4T (CUDA 12 and 13), Apple Metal. There is deliberately no ROCm and no SYCL, because upstream has no such backend. AMD and Intel hosts fall back to the CPU build and should prefer
parakeet-cppfor NeMo ASR.parakeet-cppandmagpie-tts-cppare untouched and coexist with this.Verification state, please read before reviewing
No family has been run against a real model. Transcription, diarization, TTS and translation are all unverified end to end. This is a "the plumbing is right" branch, not an "it works" branch. That is the main thing review should weigh.
What builds and is checked by CI:
WITH_NORM=ON(the ITN stack: OpenFST plus Sparrowhawk built from source) compiles, links, stages and packages on every Linux row.package.sh'sDT_NEEDEDclosure guard passing is what validates droppinglibabsl-devrelative to upstream's own Dockerfile, which was the riskiest assumption in the branch-raceclean, golangci-lint clean, gosec cleantests-nemo-speech-cppCI job runs the suite on changes to this backend, topkg/grpc, to the proto, and on every tag. It setsNEMO_SPEECH_REQUIRE_LIBS=1so a missing shared object fails the run rather than skipping the specsWhat the C ABI verification actually covers, since it is the highest-risk surface: 17 Go mirror structs and 93 field offsets checked against the pinned C headers, twice and independently, each time by compiling a
sizeof/offsetofprogram with gcc rather than by reading. All 54 bound symbols resolve against the built shared objects, and a renamed symbol panics at startup naming the symbol rather than nil-panicking at first inference. Three assertions compare a Go mirror against the size the running library reports for itself, which is the only layer that catches a mirror and its offset table being corrupted in lockstep.What is still not verified: that any real GGUF's architecture key reads what we expect, that a transcript comes back non-empty, that the diarizer's count-then-fill retry behaves on real counts, that MagpieTTS's PCM callback delivers what the WAV framing assumes, or that a Riva-Translate GGUF translates anything.
Not in this PR
convert_model.py; the docs page says so and links the conversion steps.Upstream issues filed
Two portability bugs found while integrating, both reported with reproductions:
apply-ggml-patches.shusesmapfile, a bash 4 builtin, so it fails on macOS bash 3.2. Worked around here by skipping the CUDA-only patch series on Darwin and configuring with-DNEMO_SPEECH_GGML_PATCHED=OFF, which is what upstream's ownmetal-*presets do.unique_ptrinFstImpl::operator=, which is ill-formed in every C++ standard and only diagnosed by gcc-14. Patched in the installed prefix before configuring.Two pre-existing defects found on master, not fixed here
Both deserve their own PRs:
prepare-test-extra(rootMakefile) depends on aprotogen-pythontarget that exists nowhere in the repo, somake test-extradies immediately, and no workflow invokes it anyway. Roughly forty backends are silently untested by that path..agents/adding-backends.mddocuments the same broken snippet.TranscriptLiveRequestcarries noModelIdentityfield, soAudioTranscriptionLiveis unguarded against stale distributed routes for every backend, not just this one. Fixing it needs abackend.protochange.🤖 Generated with Claude Code