Skip to content

feat: nemotron-mlx ASR transducer backend (depends on #423) - #426

Open
clkao wants to merge 34 commits into
QuentinFuxa:mainfrom
clkao:spacedock-ensign/nemotron-mlx-asr-backend-rebased
Open

feat: nemotron-mlx ASR transducer backend (depends on #423)#426
clkao wants to merge 34 commits into
QuentinFuxa:mainfrom
clkao:spacedock-ensign/nemotron-mlx-asr-backend-rebased

Conversation

@clkao

@clkao clkao commented Aug 30, 2026

Copy link
Copy Markdown

Stacked on #423 (which is stacked on #422). Review the incremental diff against #423's head.

Adds a pure-MLX Nemotron transducer ASR backend (mlx-audio package) with native per-token timestamps for the AlignAtt time-based simul-MT frontier.

Backend (asr_nemotron_mlx.py)

  • Pure MLX streaming via mlx-audio (NemotronMLXASR + NemotronMLXOnlineProcessor)
  • Adapts the transducer's streaming API to WLK's insert_audio_chunk / process_iter / get_buffer / finish contract
  • Native per-token audio timestamps — the transducer emits AlignedTokens with real start/end audio time (append-only, no revision). This is the time-based source frontier AlignAtt commits against — qwen3-asr's stable_text is a text-position proxy with no timestamps.
  • No torch, no transformers version conflict (coexists with mlx-lm on transformers 5.x)

Why native timestamps matter (measured)

E2E A/B (ASR + MT, thermal-safe interleaved, 3 measured trials each):

Combo firstProv (qwen3) firstProv (nemotron)
en→zh 2.0s 0.6s
zh→en 2.2s 1.1s

nemotron's native timestamps give 2-3x earlier simul-MT provisionals on both directions. qwen3's stable_text proxy commits coarser and later. Full baseline: _work/en_zh_e2e_baseline.md.

Config + args

  • --backend nemotron-mlx-asr choice
  • --nemotron-mlx-asr-model (default mlx-community/nemotron-3.5-asr-streaming-0.6b)
  • [tool.uv].conflicts for nemotron-mlx-asr vs qwen3-streaming / qwen3-vllm / qwen3-vllm-metal + diarization-diart vs diarization-sortformer (numpy/onnx win32 split)

Tests

12 tests (test_asr_nemotron_mlx.py): streaming, finalization, timestamp monotonicity, state reset. All pass with and without MLX installed (pytest --collect-only safe).

Dependencies

  • mlx-audio>=0.4.4,<0.5 (macOS arm64 only)
  • Conflicts with qwen3-streaming / qwen3-vllm / qwen3-vllm-metal (transformers version pin)

clkao added 11 commits August 29, 2026 09:38
The model occasionally emits <|hy_place▁holder▁no▁2|> (U+FF5C pipes) and
hallucinates free text after it. Display layers (tui, overlay) stripped
the token, but the terminal transcript and file writer still showed the
hallucinated tail.

Port livecaption's source-level fix: _strip_hy_placeholder truncates at
the FIRST placeholder occurrence (cut everything from it onward), applied
to _translate_text output.
The model occasionally emits <|hy_place▁holder▁no▁2|> and hallucinates
free text after it. The previous fix truncates the string after decode —
correct output, but the hallucinated tail is still fully decoded.

Now the stream_generate loop stops in-loop the moment the placeholder is
emitted. The stop condition is resolved from the tokenizer at call time
(_placeholder_stop_check): a single-id placeholder (Hy-MT2-1.8B: id
120020) stops exactly on that id; a fragmented placeholder (Hunyuan-MT-7B:
~13 byte-level BPE ids) uses a rolling id window. Tokenizers that
fragment it beyond a 16-id window get no in-loop stop and keep the
post-hoc string strip as the only mitigation.

_strip_hy_placeholder stays as the defensive fallback for all consumers.
The helper is module-level so the simul-MT subclass (PR QuentinFuxa#423 branch) can
reuse the same mechanism at its commit/release sites.

Tests: 4 new stub-stream tests (single-id stop, fragmented stop,
no-stop-id fallback to string strip, clean output unaffected). 16/16 in
tests/test_mlx_llm_mt.py.
- MlxLlmTranslation.new_session(target_language) creates a per-session
  client that shares the model cache but has fresh buffer/pending/metrics
  state; online_translation_factory and session_translation_factory both
  route through it so concurrent sessions don't cross-contaminate.
- Define [tool.uv].conflicts for mlx-llm-mt vs qwen3-streaming/vllm
  (transformers>=5 vs ==4.57.6 pin); refresh uv.lock.
- BenchmarkReport.has_wer is a @Property (was a method — 'if report.has_wer'
  was always truthy because the method object is truthy).
- Remove mlx-qwen3-asr auto-detection from benchmark/compat.py
  (no such backend in this repo).
- Ruff clean.
Add MlxLlmTranslationSimul subclass that drafts translation over the
unstable ASR tail and commits target tokens via attention alignment
(calibrated zh→en Hunyuan heads, top head L9/H5). Held tokens release
from cached attention without a new MT call when ASR commits the tail.

- simul_mt_capture.py: MLX Q/K capture (CapturedAttention) + commit policy
- translation_mlx_llm_mt_simul.py: subclass with provisional draft + release
- audio_processor.py: forward provisional buffer when no final translation
- config.py: mlx_llm_mt_simultaneous flag
- core.py: factory routes to Simul when flag set
- parse_args.py: --simultaneous flag
- cli.py: improved --simultaneous help text for wlk bench
- test_mlx_llm_mt_simul.py: 21 tests (subclass, tail, commit, release, wiring)

Benchmark (faster-whisper, zh_long, real-time):
  Base first_final=13.77s  Simul first_provisional=10.48s
  Simul provisional EN arrives ~3.3s before base's first final.
  32 tests pass (11 existing + 21 new).
…ivation

Add a CALIBRATION_REGISTRY keyed by (model_repo, source_lang, target_lang)
in simul_mt_capture.py. MlxLlmTranslationSimul looks up its tuple at init:
found → install capture with calibrated heads; not found → silently
deactivate (wants_hypothesis_tail=False, delegate to base class, log
warning naming the missing tuple). 4bit zh→en is NOT seeded (calibration
probe showed 48.9% argmax match vs 8bit; formal promotion gate could not
run on MLX-format repo), so 4bit deactivates (translation still works via
base). 10 new tests cover the 3-tuple matrix: calibrated (activates),
uncalibrated (deactivates), 4bit (deactivates).
…lptext scope leak

- CALIBRATION_REGISTRY key changed from fully-qualified repo
  (mlx-community/Hy-MT2-1.8B-8bit) to normalized model id (hy-mt2-1.8b)
  by stripping org prefix and quant suffix, so calibration entries are
  shareable across implementations (MLX, vLLM) and quantizations.
- 4bit deactivation moved from missing-key to disabled_quants field on
  CalibrationEntry (model id matches, but quant is disabled).
- Revert --target-language and --reference-translation helptext to PR1
  wording; PR2 cli.py diff is now strictly the --simultaneous flag.
- Add TODO comment for external-heads-loading refactor (AlignAtt4LLM
  translation_heads_<model>_<direction>.json pattern).
- Add test for _normalize_model_id; update registry tests for new key shape.
The model occasionally emits <|hy_place▁holder▁no▁2|> (U+FF5C pipes) and
hallucinates free text after it. Display layers (tui, overlay) stripped
the token, but the terminal transcript and file writer still showed the
hallucinated tail — and the simul commit policy committed draft tokens
that included it.

Port livecaption's source-level fix: _strip_hy_placeholder truncates at
the FIRST placeholder occurrence (cut everything from it onward), applied
to _translate_text, _translate_simul, and _release_held outputs.
Rebase result: the simul branch now sits on PR QuentinFuxa#422 head (f923d78) and
inherits the module-level _placeholder_stop_check + the base engine's
in-loop stop; the simul-side calibration/registry commits are intact.

Wire the same stop predicate into the simul decode paths:
- _translate_simul: the commit stream breaks the moment the placeholder
  is emitted (single-id exact stop / rolling id window, tokenizer-resolved
  at call time), and the token stream itself is truncated at the first
  placeholder id sequence so the commit policy and the stashed draft the
  release path reads never contain placeholder tokens; committed_len is
  clamped to the truncated stream.
- _release_held: reads the clean stash (no new code needed); the post-hoc
  _strip_hy_placeholder calls at both sites stay as the fallback for
  tokenizers that fragment beyond the window cap.

Tests: 3 new stub-stream tests (single-id stop, fragmented stop, clean
stash release). Focused: 35 simul + 16 base = 51 passed. Full suite:
only pre-existing qwen3-backend-shim failures (verified identical on the
unmodified branch).
- simul_mt_capture.py: move mlx.core/mlx.nn imports from module level to
  inside CapturedAttention.__call__ and install_capture. CapturedAttention
  no longer inherits from nn.Module at class definition time —
  install_capture dynamically creates an nn.Module-backed subclass when
  MLX is available. Module is now collectable without MLX (Linux CI).
- tests/test_simul_mt_capture_no_mlx.py: regression test importing
  simul_mt_capture with mlx modules removed from sys.modules.
- Remove unused 'released' assignment (F841) in test_mlx_llm_mt_simul.py.
- Fix import sort (I001) in translation_mlx_llm_mt_simul.py.

ruff clean, 37/37 tests pass.
…-session

The rebase onto PR QuentinFuxa#422 introduced new_session() per-session isolation
in online_translation_factory. MlxLlmTranslationSimul inherited the base
new_session which returned MlxLlmTranslation, losing the simul type and
state. Override new_session to return MlxLlmTranslationSimul so each
session gets fresh simul state (tail, committed tokens, draft) sharing
the cached model. Update the factory test to check isinstance instead
of identity (per-session creates a new instance, not the same object).
@clkao
clkao requested a review from QuentinFuxa as a code owner August 30, 2026 05:25
@clkao
clkao marked this pull request as draft August 30, 2026 17:55
@clkao
clkao force-pushed the spacedock-ensign/nemotron-mlx-asr-backend-rebased branch from 2671610 to 04cbe9c Compare August 30, 2026 23:34
clkao added 4 commits August 30, 2026 16:36
Mass commit mode: commit target tokens whose accessible attention mass
on committed source tokens >= threshold (default 0.5), replacing the
brittle argmax-only check. Measured best in livecaption A/B (more
provisional content + less final lag).

Token hysteresis: the MT-call hysteresis threshold is now in source BPE
tokens (the MT's own unit) instead of chars, with a rolling chars-per-token
ratio to estimate token growth without re-tokenizing. CJK and Latin
converge to the same token budget (15 tokens ≈ one short sentence in
both). For Latin source (en→zh), this dramatically reduces MT calls
(13 vs 30 on a 30s demo) since the old 15-char threshold fired too
often for English's ~5 chars/token ratio.

Wired commit_mode + mass_threshold through config and core.
…/H5 top)

en→zh: calibrated tencent/Hy-MT2-1.8B on 1138 Mxode en-zh pairs. Top
head L9/H5 TS=0.86, 3/3 stability splits stable (max delta 0.0086).
ja→zh: calibrated on 219 WikiMatrix ja-zh pairs. Top head L9/H5 TS=0.89,
3/3 stability splits stable (max delta 0.0244).

All three directions (zh→en, en→zh, ja→zh) share L9/H5 as top head —
strong evidence these are general alignment heads for hunyuan_v1_dense.
5/8 heads shared across all three directions. 4bit disabled for both
(same attention-divergence reasoning as zh→en).

With all three directions seeded, the simul-MT variant now covers the
directions the eval harness needs (IWSLT en→zh anchor + zh→en/ja→zh
flagship).
new_session dropped commit_mode/mass_threshold when constructing the
per-session client, so server-context sessions defaulted to argmax even
when mass was configured. Thread them so the configured policy reaches
per-session clients.
@clkao
clkao marked this pull request as ready for review August 30, 2026 23:40
…aft starvation fixed

Two sources produced fragment finals and starved MT drafts (measured on
zh_long: 12 fragment finals vs 6 sentence finals; 42/42 MT drafts
released with an empty committed source):

1. Every short clause pause flushed the open utterance as a final — the
   translation loop flushed on Silence.is_starting regardless of pause
   length. The flush now fires only when a pause crosses
   pause_segmentation_seconds, checked at the pause's END, when the
   duration is known.

2. Every punctuation token closed a simul segment (a final per clause).
   Endpointing owns closure now (rule2-softmax / rule3): punctuation
   closes a segment only once it has run simul_soft_max_s (default 4.0);
   simul_hard_max_s (default 20.0) force-cuts a run-on utterance
   regardless of punctuation. New config knobs
   mlx_llm_mt_simul_soft_max_s / mlx_llm_mt_simul_hard_max_s.

Measured: 12 fragment finals -> 4 coherent multi-sentence finals, each
carrying complete sentences; starved MT drafts 42/42 -> 4/20 (early
drafts only).

The simul tests that encoded the old contract (a final at any
punctuation) are updated to the new one.
@clkao
clkao force-pushed the spacedock-ensign/nemotron-mlx-asr-backend-rebased branch from 04cbe9c to 0126dc9 Compare September 1, 2026 16:35
…from the MT source

CL: 'hyperopia flashed into provisional' — after the soft_max close, the
closed segment's draft cache survived; the next process() ran the release
path against the STALE cached draft with a SHORTER new source and
re-emitted the pre-final translation, so the display regressed: the final
showed the complete sentence (with 'hyperopia'), the next draft reverted
to it minus the last clause. The close now resets the draft cache like
the silence-boundary flush does. Verified on the zh-en capture: the
post-final drafts no longer revert (the pre-fix capture's drafts at
+16.1s reverted; now they hold the full text).

Also: a stale hypothesis tail (text already inside the committed prefix,
or its audio range predating the commit boundary — variant spellings
defeat exact containment) is dropped from the MT source; feeding it
doubled the source (both script variants of the same sentence) and the
draft translated the doubled text into a one-word fragment. Translation
provisional events are deduped (identical consecutive drafts were
re-emitted 5x by the release path).
…when the committed outgrows the cached span

CL: 'the first sentence has no translation draft' — two starvation
mechanisms, both verified with instrumented production runs:

1. The base warmup only covers _translate_text; the first simul draft
   paid the Metal kernel compile + capture install inside the translation
   loop, blocking every draft for the first sentence (~9s). The simul
   warmup now runs one draft at init and resets the state.

2. The release path maps the committed text against the CACHED source
   span — only valid while the committed text is a prefix of it. When the
   ASR committed words beyond the cached draft's span (the tail grew with
   new source words), the boundary mapping fails and the release emits
   nothing: the display starved until the segment's final. A fresh draft
   is now required whenever the committed text outgrows the cached span.

Measured on zh_long (canonical capture): the first draft lands at 2.27s
(was: nothing until the first final at 9.1s), drafts grow with the
speech ('In eye surgery, lasers...' -> the full sentence; the last
sentence drafts 'Future applications will be more widespread.' instead
of the one-word 'Future' fragment).
…n subsystem

The live pipeline is not reproducible (ASR/VAD timing jitter), so fixes to
the commit policy and the accessible frontier had no deterministic gate.

scripts/simul_fixture.py records the translation engine's full input
interface from one live run (insert_tokens / insert_silence /
validate_buffer_and_reset / process, with exact tokens and timestamps)
into tests/golden/simul_zh_long_calls.jsonl, then replays it through a
fresh engine with greedy sampling.

Verified: zero output diffs across two in-process replays AND across
separate processes; the 4 replayed finals match the live captures.
Baseline on the shipped policy: draft coverage 0.27 (pass >= 0.6) — the
number the frontier + policy fixes must move.
…argmax commit policy

Two fixes for simul-MT draft starvation, measured on the deterministic
replay fixture (tests/golden/simul_zh_long_calls.jsonl, baseline 0.27):

1. committed_src_end_from_text froze the commit frontier at the first
   byte-split BPE token: decoding a token carrying half a UTF-8 character
   yields U+FFFD, which failed the startswith test and stopped the scan.
   cend stuck at 3 of 21 tokens while the committed text grew 17 -> 37
   chars. U+FFFD-tolerant prefix matching unfreezes it; still rounds down
   to the last complete token. Coverage 0.27 -> 0.54.

2. mode="paper" implements the paper's decision rule (arxiv 2606.03967
   §4.4): head-averaged rows over the calibrated head set, per-head
   prefix-online Welford z-normalization, width-7 median filter along the
   source axis, stabilized argmax vs the accessible frontier (argmax <
   cend + b, b=1), mass gates off (tau=0) as in the official operating
   points. First-failure scan emits the longest accepting prefix.
   Coverage 0.59 vs 0.54 for the mass gate; final 3 improved 0.10 -> 0.30.

scripts/simul_fixture.py replay gains --commit-mode / --min-source-tokens
for A/Bs. Lowering the re-draft hysteresis does not help (0.57): the
residual gap is the last segment's tail sentence, which the ASR has not
committed when the segment closes — the final's quality pass is by
construction its first release.

Tests: 58 passing (simul + caption events + overlay replay).
config, core wiring, and the simul engine default now use the paper's
stabilized-argmax commit policy; argmax and mass remain selectable for
comparison. The lc_terminal CLI knob lands with the PR that introduces
that script.
The mlx-qwen3-asr backend (pure-MLX Qwen3-ASR, no torch/transformers/WebSocket
sidecar) built through a new generalized wrapper layer. The wrapper factors
the two jobs every non-transducer ASR duplicates: Job 1 (stable/unstable
split for models that revise) in asr_commit.py, Job 2 (timestamp manufacture
for forward-emit models) in asr_timestamps.py, composed via asr_wrapper.py.
A second provider (Voxtral-MLX) shares the timestamp module.
…guage

Finding 1 (AlignAtt seam): get_buffer returned the full rolling text\n(including the committed stable prefix), double-counting the prefix for\nconsumers that read get_buffer (display, and the AlignAtt translator\nwhich drafts over the tail via HypothesisTail). Return text[len(stable):]\n(the unstable tail only), matching WLK's contract (process_iter returns\nthe committed ASRToken list; get_buffer returns the unstable tail).

Finding 2 (per-session language): SessionASRProxy delegates __getattr__\nto the shared ASR, so getattr(asr, 'language') returned the server-wide\ndefault, ignoring the per-session ?language= override. Prefer the proxy's\n_session_language when present so a multi-language WLK server transcribes\neach session in its own language.
…n wiring

Blocker 1 — get_buffer contract conflict:
StableCommitTransform read inner.get_buffer() as the full rolling hypothesis,
but get_buffer returns only the unstable tail (WLK contract). The transform
could not compute a stable prefix and emitted garbage deltas omitting the
committed prefix. Add get_hypothesis() to MlxQwen3AsrOnlineProcessor returning
the full rolling text (Transcript with text=self._text); update
StableCommitTransform to call inner.get_hypothesis() for the full hypothesis.
get_buffer stays the tail (WLK contract, for AlignAtt/HypothesisTail). Add a
falsifiable test exercising the transform + get_hypothesis seam (fails when the
transform reads get_buffer).

Blocker 2 — out-of-scope translation wiring:
Re-carve config.py/core.py/parse_args.py to drop the translation-backend
wiring that belonged to a different PR (model field, import,
translation-backend choice, flag, qwen3+NLLB guard removal). Restore the
qwen3+NLLB guard. The PR3 diff is now ASR-only.
_finalize_utterance emitted the full re-decoded text at finalization, but
the stable prefix was already emitted during streaming by StableCommitTransform
→ text appeared twice. Fix: StableCommitTransform now tracks the cumulative
committed text on inner._emitted_stable; _finalize_utterance uses
_compute_finalize_delta to emit only the uncommitted suffix (or full text
when no streaming commit happened, or full corrected text when the re-decode
corrected the prefix). 7 new tests including an integration test that
exercises the transform + dedup path end-to-end and would fail on pre-fix
code (verified: pre-fix produces 'alpha beta alpha beta gamma delta').
… override tests

- Add [tool.uv].conflicts for mlx-qwen3-asr vs qwen3-streaming/qwen3-vllm/
  qwen3-vllm-metal (transformers>=5 vs ==4.57.6), matching the mlx-llm-mt
  pattern. Update uv.lock conflicts section to match.
- Add two-session ASR isolation regression test: two MlxQwen3AsrOnlineProcessor
  instances from shared model cache have independent per-session state
  (_state, _text, _stable_text, _emitted_stable, _utt_audio); model object
  is shared (loaded once).
- Add per-session language override regression test: SessionASRProxy with
  language='ja' causes the processor to resolve to Japanese, not the
  server-wide default.
…P fallback

The StableCommitTransform re-derived stability from the rolling hypothesis
via LCP on split_text_units, which splits on whitespace only. CJK has no
whitespace, so a 30s zh transcript collapsed into ONE unit, LCP was always 0,
and the transform never committed during streaming (zh->en: 0 provisionals,
1 final at finish).

Three changes:

1. Prefer the backend's NATIVE stable prefix (prefer_native_stable=True,
   default). Backends that expose a monotonic stable field (mlx-qwen3-asr's
   _stable_text) commit it directly — no LCP re-derivation. This is
   language-agnostic (the model's own field works on CJK and Latin alike).
   zh->en: 0 -> 11 streaming commits, first at 6s. en->zh: 7 streaming,
   first at 6s.

2. split_text_units: CJK char-split fallback. When native stable_text is
   unavailable, the LCP path now splits CJK runs into character units (one
   char = one unit ≈ one BPE token) instead of collapsing them. Detection is
   by Unicode range (not a lang switch), applying to zh/ja/ko equally.

3. Token-LCP variant (tokenize_fn): commit on the model's own token strings
   instead of text units. Normalizes CJK (1 char ≈ 1 token) and Latin
   (subword) to the same token budget. Faster first commit (4s) but fragments
   mid-word; kept as an option, not the default.

No language-specific switches. The only lang-aware code is CJK Unicode-range
detection in the fallback char-split.
- Pure MLX streaming via mlx-audio (Nemotron transducer, native per-token timestamps)
- Adapts the transducer's streaming API to WLK's insert_audio_chunk/process_iter contract
- Native audio-time boundary for AlignAtt time-based simul-MT frontier
- Normalizes language tag before prompt_dictionary validation
- uv conflicts for nemotron-mlx-asr vs qwen3-streaming/qwen3-vllm/qwen3-vllm-metal
  + diarization-diart vs diarization-sortformer (numpy/onnx win32 split)
@clkao
clkao force-pushed the spacedock-ensign/nemotron-mlx-asr-backend-rebased branch from 0126dc9 to d7c1e6d Compare September 2, 2026 05:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant