Repository navigation
Voice mode: all bundled ASR models fail silently — MultiModalProcessor routing bug for nemotron_speech (RNNT) in Foundry Local Core #4024
Description
Describe the bug
/voice records audio successfully (level meter reacts, mic capture confirmed via raw PulseAudio capture) but every transcription comes back empty, for all three models offered in the /voice model picker:
nemotron-3.5-asr-streaming-0.6bnemotron-speech-streaming-en-0.6bnemotron-speech-streaming-es-0.6b
All three share the same nemotron_speech (RNNT) architecture, so switching models does not help — there is no working model in the current picker.
Root cause (traced to source)
The Foundry Local Core native audio transcription path throws on every call:
Microsoft.ML.OnnxRuntimeGenAI.OnnxRuntimeGenAIException: MultiModalProcessor cannot be created. nemotron_speech is not a registered multi-modal model type.
at Microsoft.ML.OnnxRuntimeGenAI.Result.VerifySuccess(IntPtr)
at Microsoft.ML.OnnxRuntimeGenAI.MultiModalProcessor..ctor(Model)
at Microsoft.AI.Foundry.Local.AudioClient.Transcribe(String, String, Nullable`1)
This is not a stale/outdated onnxruntime-genai engine issue. I downloaded and compared the public onnxruntime-genai v0.14.0 release (published 2026-05-29, after the "Multilingual Streaming Nemotron ASR" PR merged 2026-05-22) against the bundled runtime (labeled 0.14.1 in deps_versions.json, but not a publicly-tagged release). Both contain full, identical NemotronSpeechModel/NemotronSpeechState support (confirmed via symbol inspection of libonnxruntime-genai.so).
Cross-checking against microsoft/onnxruntime-genai's src/models/model.cpp (https://github.com/microsoft/onnxruntime-genai/blob/main/src/models/model.cpp) confirms this is architecturally expected:
nemotron_speechis an RNNT-type model (ModelType::IsRNNT) and is correctly routed toNemotronSpeechModelwhen the model is loaded (CreateModel()inmodel.cpp) — this is whyModel loaded successfully: nemotron-3.5-asr-streaming-0.6b-generic-cpu:3appears in the Foundry log and the model shows as loaded.- However,
MultiModalProcessor's constructor uses a separate, hardcodedprocessor_factory_registry that only contains vision+text chat model types (e.g.phi4mm,gemma4, etc.) — RNNT/TDT/ALM (Whisper) audio model types were never meant to go throughMultiModalProcessorat all.
So the bug is that Microsoft.AI.Foundry.Local.Core's AudioClient.Transcribe/streaming session path unconditionally constructs a MultiModalProcessor for audio transcription, instead of dispatching RNNT/TDT/ALM model types (nemotron_speech, Parakeet TDT, Whisper) through their own dedicated processing path (the same one used successfully at model-load time). Since Microsoft.AI.Foundry.Local.Core.so is a closed-source component, this can't be worked around or patched client-side — no onnxruntime-genai version swap fixes it, since the engine itself already works correctly and simply refuses (correctly, by its own design) to construct a MultiModalProcessor for a non-multimodal-chat model type.
Affected version
GitHub Copilot CLI 1.0.69-0 (also reproduced on 1.0.66-1 — same bundled runtime pins: onnxruntime-genai 0.14.1, foundry-local-core 1.2.3, so the bug is not CLI-version-specific).
Steps to reproduce the behavior
- On Linux x64 (reproduced under WSL2/WSLg, but likely affects any Linux x64 install), run
/voice, enable it, select any of the three offered models. - Press the voice-record shortcut, speak clearly for several seconds (confirmed via raw
pareccapture that real speech is captured at healthy signal levels, and the in-app level meter visibly reacts). - Stop recording.
Expected behavior
Spoken audio is transcribed to text and inserted into the input box.
Actual behavior
No error is shown to the user; the recording UI behaves normally, but the transcript is always empty. ~/.github-copilot-cli/logs/foundry.core*.log shows:
[ERR] Error executing audio_transcribe: Microsoft.ML.OnnxRuntimeGenAI.OnnxRuntimeGenAIException: MultiModalProcessor cannot be created. nemotron_speech is not a registered multi-modal model type.
...
Audio stream stop: session <id>, final flush text: '', full transcript: ''
Additional context
- Confirmed mic capture and PulseAudio pipeline are fully healthy (not a WSLg/audio-hardware issue) via direct raw
parec -d RDPSourcecapture showing strong, correctly-timed speech signal. - Confirmed via a standalone script using the bundled
foundry-local-sdkpackage directly (bypassing the Copilot CLI JS entirely) thataudioClient.transcribe()throws the identicalMultiModalProcessor/nemotron_speecherror against a real captured WAV file, ruling out any Copilot-CLI-specific bug in the JS layer. - Suggested fix: in Foundry Local Core's audio transcription path, dispatch by
ModelType::IsRNNT/IsTDT/IsALM(mirroringCreateModel()'s dispatch inonnxruntime-genai'smodel.cpp) instead of unconditionally constructing aMultiModalProcessor.
Activity
rui-ren commentedon Jul 6, 2026
Thank you for the detailed investigation. We will resolve this issue ASAP.
rui-ren commentedon Jul 6, 2026
@sylvanc after looking into this, I have one question, are you using below models
to do the one-shot ASR or you are doing the streaming process?
sylvanc commentedon Jul 11, 2026
Yes, using those - those are the models available in Copilot CLI.
sylvanc commentedon Jul 11, 2026
Follow-up with a correction to my original diagnosis and the complete WSLg
capture findings.
Important clarification: CLI uses streaming ASR
The Copilot CLI
/voicepath usescreateLiveTranscriptionSession(), not one-shotaudioClient.transcribe().I tested both paths directly against the bundled Foundry runtime:
audioClient.transcribe()still throws theMultiModalProcessor cannot be created ... nemotron_speecherror describedabove. That remains a Foundry Local Core routing bug.
through
createLiveTranscriptionSession()produced the expected transcriptwith both short and long inputs.
Therefore the empty
/voiceresults under WSLg were not solely explained bythe one-shot Foundry bug. The CLI streaming path also had WSLg microphone
lifecycle problems.
Reproduction environment
1.0.71-0unix:/mnt/wslg/PulseServerRDPSource,s16le, mono, native 44.1 kHz@picovoice/pvrecorder-nodeFindings
1. Cold
RDPSourcefails its first PvRecorder readPvRecorder(-1)correctly selectsRDP Source, andstart()succeeds, butthe first
read()after PulseAudio has suspended the source throwsPvRecorderStatusIOError. A second read on the same recorder handle succeeds.Direct
pareccapture also confirms that the physical microphone and Windowspermissions are working.
Recommended WSL-specific handling:
RDP Source.read once on the same handle.
2. Disabling PulseAudio idle suspension is the wrong workaround
Unloading
module-suspend-on-idle, or forcingRDPSourceto remain merelyIDLEwithout a consumer, causes WSLg's virtual source to free-run/build abacklog.
Measured over five wall-clock seconds:
This PCM has plausible non-zero amplitude but is not recognizable speech to
either bundled streaming ASR model. It explains the level meter moving while
the final transcript remains empty.
With normal suspension restored, a true cold eight-second PulseAudio capture
contained 7.14 seconds of correctly clocked audio (the missing ~0.86 seconds
was source wake-up). Once a real consumer kept the source
RUNNING, ten100 ms PvRecorder frames took 1.02 seconds, as expected.
3. Correct WSLg clock/wake handling
The working WSL-specific design is:
consumer for
RDPSource.ring.
failure.
This keeps WSLg's source genuinely
RUNNINGand correctly clocked while voicemode is available. The one-second memory ring also supplies pre-roll when the
operator begins speaking immediately after activating push-to-talk. The ring
is not sent to ASR unless the user explicitly starts a voice session.
4. CLI drops microphone frames while the STT session is opening
The microphone and Foundry session are opened in parallel, but the current PCM
handler returns unless the session status is already
active. Consequently,audio captured while Foundry is opening is discarded.
Source-level fix:
The WSL one-second pre-roll is returned as the first recorder frame and follows
this same opening buffer path, so the first words are preserved.
5. Commit drains only one frame and truncates trailing speech
The current microphone close path uses a boolean
commitDrain. After release,the read loop forwards exactly one additional frame and returns, with a
150 ms safety timeout. In practice this cuts off the last word when the user
releases Space immediately after speaking.
The working change was:
At the current 1,600-sample / 16 kHz frame size, this retains 500 ms of real
microphone tail before finalization.
Confirmed result
With these changes:
/voicetranscribes normally under WSLg.the WSL keeper and ring live entirely inside the Copilot voice process.
This should be implemented in the unbundled recorder/voice-session source
rather than patching the distributed
voice-server.jsbundle.rui-ren commentedon Jul 11, 2026
Thank you so much looking into this.
This will use the API which will
AudioTranscribe()api in theCorecode, which is designed for thenone streamingmodel. As such, we need Nemotron streaming model to support this feature, and simulate thestreamingfor thewhole voice clip.Thanks
sylvanc commentedon Jul 11, 2026
Ok! I have this working locally on my machine with a patch. Due to minimization, it's a 343 kb patch, but I'm happy to post it here if that's helpful, just let me know.
rui-ren commentedon Jul 11, 2026
Sure, or you can send to my
Teams, I continue working on this and testing it during the weekend. Hopefully get back to you by Monday. Thankssylvanc commentedon Jul 12, 2026
Rebased and reliability-corrected for Copilot CLI 1.0.71-3.
Versioned patches are retained together here:
https://gist.github.com/sylvanc/088fbd867e544eb0a5a73044f2bc8b11
1.0.71-3 exact raw patch
https://gist.githubusercontent.com/sylvanc/088fbd867e544eb0a5a73044f2bc8b11/raw/a1335dbf99956ddb3e1516a3ded7d4e1adc956e3/copilot-cli-1.0.71-3-wsl-voice-final-prettified.patch
SHA-256:
cdecabf948edc78509d8ae97124a99f8a3fddf329313e7c9f29bf7d56fba9f2fWhy the reliability revision was necessary
0.24–0.40, applying gains from0.00–0.63, and producing empty/partial transcriptions. Speaker gating is now experimental opt-in only viaCOPILOT_VOICE_SPEAKER_FILTER=1; normal operation is fail-open.RDPSource./voice keeper on,/voice keeper off, and/voice keeper status.COPILOT_VOICE_WSL_KEEPERremains the initial default when no saved preference exists.1.0.71-3 audit and retained behavior
voice-server.jsand PvRecorder are byte-identical to 1.0.71-2./voice devices,listAudioDevices/selectAudioDevice, and persisted{name, occurrence}microphone selection.wslMicKeepervoice-worker RPC and persistedvoice.wslKeepersetting without changing upstream device-selection behavior.deviceId=-1resolves toRDP Source.ask_userprompts remain protected: PTT is disabled whenallowFreeform=false.language=en.Validation
disabled → enabled/active → disabled/inactive./voice keeper off, andCOPILOT_VOICE_WSL_KEEPER=0leave no keeper capture consumer.Dependencies after applying
The SpeakerNet weights remain a separate download; review their applicable NVIDIA model license before redistribution.
sylvanc commentedon Jul 16, 2026
Stable Copilot CLI 1.0.71 voice patch updated with a first-PCM readiness cue.
Versioned patches remain together here:
https://gist.github.com/sylvanc/088fbd867e544eb0a5a73044f2bc8b11
Stable 1.0.71 exact raw patch
https://gist.githubusercontent.com/sylvanc/088fbd867e544eb0a5a73044f2bc8b11/raw/a8f089368671e6ed11ebf7e314d407b2abf7ab14/copilot-cli-1.0.71-wsl-voice-final-prettified.patch
SHA-256:
2528122b5a6da8c921b167f34a306d8e976636d1c5143b8f015b598499b99628Readiness cue
sessionAudioevent, not merely on model/session initialization.Retained behavior
/voice devices, selected-device persistence, settings validation, terminal setup, mouse support, and configurable Ctrl+R history./voice keeper on|off|status;COPILOT_VOICE_WSL_KEEPERis only the default when no saved preference exists.COPILOT_VOICE_SPEAKER_FILTER=1.Validation
Normalize before applying:
Then apply the patch and install dependencies:
The SpeakerNet model remains a separate download because its model license must be reviewed independently.
sylvanc commentedon Jul 16, 2026
Rebased for Copilot CLI 1.0.72-0.
Versioned patches remain together here:
https://gist.github.com/sylvanc/088fbd867e544eb0a5a73044f2bc8b11
1.0.72-0 exact raw patch
https://gist.githubusercontent.com/sylvanc/088fbd867e544eb0a5a73044f2bc8b11/raw/c350ea7c2f4bb423450b43a67b35fb46a2720594/copilot-cli-1.0.72-0-wsl-voice-final-prettified.patch
SHA-256:
34369caa3d93bab184faf0fb21dde0563a8cd97de87007dc9719369b46699e67The 1.0.72 upstream changes are unrelated to voice. The rebased app delta retains the validated 1.0.71 behavior with exactly the same 38-hunk, 262-addition, 45-deletion semantic structure, remapped to the current 1.0.72 identifiers. The unchanged voice worker and PvRecorder portions remain byte-identical to the validated stable implementation.
Retained behavior includes first-Space speculative PTT, quick-Space typing, opening PCM buffering, adaptive tail, RNNoise/SpeexDSP processing, selector voice, choice-only prompt gating, typed prefix/suffix preservation,
/voice devices, persistent/voice keeper on|off|status, and the first-real-PCM Mic warming… → green Speak now readiness cue.Validation
The patch header contains normalization, dependency-install, and separately licensed SpeakerNet download instructions.
Tak2121 commentedon Jul 16, 2026
sylvanc commentedon Jul 17, 2026
Rebased for Copilot CLI 1.0.72-1.
Versioned patches remain together here:
https://gist.github.com/sylvanc/088fbd867e544eb0a5a73044f2bc8b11
1.0.72-1 exact raw patch
https://gist.githubusercontent.com/sylvanc/088fbd867e544eb0a5a73044f2bc8b11/raw/8a532eedfd63bed6285f703654087ae02c534d9d/copilot-cli-1.0.72-1-wsl-voice-final-prettified.patch
SHA-256:
a4fd2f9137748592befb66fcdfd42667e89e7575967d1dbd030f6528e7504accThe 1.0.72-1 app bundle has broad generated-identifier churn, while pristine
voice-server.jsand PvRecorder remain unchanged. The voice behavior was mapped directly onto the pristine 1.0.72-1 owners rather than accepting a fuzzy mixed-identifier merge.In addition to retaining first-Space speculative PTT, quick-Space typing, opening PCM buffering, adaptive tail, RNNoise/SpeexDSP, selector voice, choice-only gating,
/voice devices, persistent/voice keeper on|off|status, typed main-composer prefix/suffix submission, and the first-real-PCM Mic warming… → green Speak now cue, this revision hardens two edge paths:Validation
The patch header contains normalization, dependency-install, and separately licensed SpeakerNet download instructions.
sylvanc commentedon Jul 19, 2026
Corrected the 1.0.72-1 WSL voice patch for a catalog-startup deadlock.
Updated exact patch:
https://gist.githubusercontent.com/sylvanc/088fbd867e544eb0a5a73044f2bc8b11/raw/ebb894006536ec2a7d223f0994867e62f889ed55/copilot-cli-1.0.72-1-wsl-voice-final-prettified.patch
SHA-256:
06832d869c7d65df133552148fcac012d89ecc728e20df9f2ea801779e9367e2Root cause
The live engine remained in
fetching-catalogfor hours, but the Azure catalog and cached models were healthy. Foundry initialization stopped at its firstConfiguring serviceslog line, before telemetry or HTTP. The trigger was the WSL keeper/PvRecorder startup path:module-rdp-source, soRDPSourcewas absent andparec --device=RDPSourceexited immediately.pv_recorder.nodeand Foundry native initialization also proved unable to initialize reliably in the same process: PvRecorder-first froze Foundry; Foundry-first froze PvRecorder.Correction
parecand does not loadpv_recorder.node; non-WSL behavior remains native PvRecorder.RDPSourceis detected with boundedpactl; it reports no device/unsupported keeper and waits without spawning failed capture loops.Validation
readyand returns all three supported ASR variants.RDPSource: device list is empty, keeper reports unsupported/inactive, microphone open returnsno-device, and zeroparecchildren exist after repeated retry intervals.support file-based ASR for nemotron streaming model (#885)