Skip to content

feat(model): add Confucius4-R2T2 real-time streaming ASR (r2t2_asr) - #604

Open
davidxifeng wants to merge 1 commit into
0xShug0:mainfrom
davidxifeng:r2t2
Open

davidxifeng wants to merge 1 commit into
0xShug0:mainfrom
davidxifeng:r2t2

Conversation

@davidxifeng

@davidxifeng davidxifeng commented Sep 18, 2026

Copy link
Copy Markdown

Port Confucius4-R2T2 (NetEase Youdao, netease-youdao/Confucius4-R2T2) as a community model family.
R2T2 is a true streaming ASR model — a Qwen3-ASR-1.7B fine-tune that keeps the same audio tower and
thinker and adds Longest Stable Prefix (LSP) decoding: every chunk re-decodes the accumulated
audio with the already-recognized text as a continuation prompt, and only the stable prefix is
committed. Emitted text is never revised, which is what live captioning and downstream agents need.

Why a separate family instead of extending qwen3_asr

qwen3_asr streams by decoding independent windows and concatenating results — every window is
self-contained and the text can be re-derived. R2T2's contract is the opposite: one stateful
accumulated-audio session with token-level prefix rollback and an append-only commit rule. Sharing
the family would have meant two different streaming state machines behind one family name, so the
port keeps its own session while reusing framework infrastructure (below).

What's in the PR

Runtimesrc/community_models/r2t2_asr/, include/engine/community_models/r2t2_asr/

  • assets/loader resolution (HF safetensors, symlinked HF cache snapshots, and GGUF), Whisper-style
    128-bin log-mel frontend, windowed audio tower, tokenizer.
  • session.cpp: offline transcription and the LSP streaming state machine (accumulate audio,
    prefix rollback with a U+FFFD-safe loop, stable-prefix extraction, finish_stream flush).
  • text_postprocess.cpp: punctuation-by-context normalization, repetition repair,
    language X<asr_text> parsing, Chinese spacing, | truncation, and ISO-639 → canonical
    language-name mapping.

Spec and layoutmodel_specs/r2t2_asr.json is schema v1 (status: community), so the family
uses the framework's spec-backed loader: metadata, capabilities, options and packages are
derived from the spec, the loader factory (make_r2t2_asr_loader) lives next to the session, and
there is no per-model loader.{h,cpp}.

Packaging — published self-contained GGUFs at
davidxifeng/Confucius4-R2T2-gguf, wired
into the spec as r2t2_asr_q8_0 (default) and r2t2_asr_f16, plus the upstream safetensors package.
Each GGUF embeds its sidecars and the schema-v1 option contract, so option validation comes from
the file itself (audio.cpp emits a [warning][model_spec] and falls back to the installed runtime
contract when a package still embeds a legacy spec; these do not).

UI — catalog entry (models/Confucius4-R2T2-GGUF/r2t2-q8_0.gguf, install choices from the spec),
r2t2_asr session controls in model_params.json, live microphone transcription (R2T2 is in the
live-ASR family set), and a language dropdown covering the model's 30 languages.

Docsdocs/community_models/r2t2.md (architecture, streaming contract, options, GGUF and
verification recipe) and the ASR model table in docs/asr.md.

Reuse instead of duplication (issue #54 guidance)

Area Used
Thinker / decode loop runtime::GreedyQwenDecoderRuntime (graph lifetimes, audio-embedding injection via set_rows, static KV cache, greedy sampling)
Long-audio chunking engine::audio::plan_audio_chunks + slice_audio_buffer (no hand-rolled slicing)
Tower ops and layout modules::Conv2dModule/GeluModule/LinearModule/LayerNormModule/ScaledDotProductAttentionModule/TransposeModule, core::ensure_backend_addressable_layout, core::reshape_tensor
Multichannel / resampling the shared mono conversion + resampling helper inside the frontend
Loading schema-v1 spec-backed loader
Framework changes none — the PR touches no file under src/framework/, include/engine/framework/, or external/

Verification

Reference: the official implementation in the upstream repository (PyTorch + MPS,
R2T2ASRModel). Goldens are produced by tests/r2t2_asr/make_golden.py and compared with
tests/r2t2_asr/compare.py, which checks the offline transcript, the committed delta stream
(the upstream WebSocket integrator's slice-by-length rule), every per-chunk committed prefix, and the
final transcript. Repo-native smoke test: test_r2t2_asr_transcription (skips with 125 when weights
are absent).

Checkpoint Offline Committed stream Final transcript Per-chunk prefixes
model.safetensors bf16 — Chinese clip, auto language exact exact exact 21/21
model.safetensors bf16 — same clip, --language Chinese exact exact exact 21/21
r2t2-q8_0.gguf exact exact exact 21/21
r2t2-f16.gguf exact exact exact 21/21
English clip (assets/resources/sample_16k.wav) exact exact exact 41/43

Everything a client observes is identical to the reference on every clip. On the 14 s English clip
two internal prefixes differ by one token of a trailing numeric span, which is reference-side greedy
sensitivity, not port logic: the difference is present in the raw decoded text before any R2T2
post-processing, the reference disagrees with itself at the same order of magnitude between its
own fp16 and bf16 runs (chunks 13 and 37), and it never reaches the wire because finish_stream
publishes no delta.

Other checks:

  • Multichannel / off-rate input — the reference clip re-encoded to 48 kHz stereo transcribes
    identically in offline and streaming mode (shared conversion/resampling helper).
  • Server pathsPOST /v1/audio/transcriptions (offline), stream=true (SSE deltas +
    transcript.text.done), and /v1/audio/transcriptions/live (raw chunked PCM) all return the
    expected text; Q8_0 offline RTF ≈ 0.13 on an M3 (≈ 8× real time) with 320 ms streaming chunks.
  • Memory — with a lazy-load server, RSS goes 0.08 GiB (start) → 6.77 GiB (model loaded) →
    0.20 GiB after POST /v1/tasks/unload_all_models, so weights and graphs are session-owned and
    unload actually releases them.
  • GGUF self-containment — loading a single .gguf (renamed, alone in an isolated directory, no
    model_specs/, CWD outside the repository) works for CLI offline, CLI streaming, and the server.
    Hub-reported SHA-256 matches the local files:
    • r2t2-q8_0.gguf — 2 477 512 032 B, 433abb3de9dd42d0093b18835d882c7706a77bb2cacb626c44a515cc3afe2a9f
    • r2t2-f16.gguf — 4 092 155 232 B, 23b73f7739e6eca71fabb38e9ecc394f3c12bc681af7ca2c3d5f9d6d8ccdaa76

Reproduce:

# goldens (in the upstream Confucius4-R2T2 checkout)
PYTHONPATH=. uv run python <audio.cpp>/tests/r2t2_asr/make_golden.py \
  --model_path <audio.cpp>/models/Confucius4-R2T2 --audio resources/test.wav \
  --out <audio.cpp>/tests/r2t2_asr/golden.json

# comparison (audio.cpp)
python3 tests/r2t2_asr/compare.py --cli build/macos-metal-release/bin/audiocpp_cli \
  --model models/Confucius4-R2T2 --audio /tmp/test.wav \
  --golden tests/r2t2_asr/golden.json --backend metal

build/macos-metal-release/bin/test_r2t2_asr_transcription --backend metal

Options

Session options (local names in the spec, public as r2t2_asr.<name>): chunk_size_ms (80–2000,
default 320), unfixed_chunk_num (2), unfixed_token_num (5), rollback_punctuation (false),
max_tokens (32, streaming decode budget), audio_encoder_weight_type, thinker_weight_type
(weight_type alias), and the graph-arena/weight-context MB knobs. Request options: language
(ISO-639 code or canonical name, Auto detects) and max_tokens (offline budget). Weights below
Q8_0 are rejected at load with a clear message, because this graph's kernels are not validated below
Q8_0 (a Q4_K package otherwise decodes to empty text).

Known limitations

  • No timestamps (R2T2 is a streaming transcript model; word alignment would need the aligner route).
  • A single unsegmented stream is bounded by the tower's 1500 positions (≈ 110 s of accumulated
    audio). The upstream server segments utterances with VAD; the rolling-window variant
    (_no_reset: keep 16 s, drop the oldest 8 s and its text) is not ported yet. Both are documented.
  • The committed stream inherits the reference's language metadata-fragment artifact (a rollback can
    leave a partial language fragment in the stable prefix). It is reproduced deliberately for
    fidelity; the authoritative transcript is the final result / transcript.text.done.

Checklist (CONTRIBUTING / issue #54)

  • Community layout (src/community_models, status: community), schema-v1 spec, no per-model loader
  • Normalized option names (max_tokens, *_ms, *_sec conventions)
  • Existing framework modules, helpers, chunkers and CacheSlot-based graph lifetimes reused
  • No framework/runtime/backend/shared-code changes
  • GGUF preferred and self-contained (embedded sidecars + schema-v1 spec)
  • RTF < 1.0, stable VRAM across requests, unload verified
  • Real runtime validation on offline, streaming, server, GGUF and multichannel paths

Port NetEase Youdao Confucius4-R2T2 (a Qwen3-ASR-1.7B fine-tune with Longest
Stable Prefix decoding) as a community model family.

Runtime
* src/community_models/r2t2_asr + include/engine/community_models/r2t2_asr:
  assets, Whisper log-mel frontend, windowed audio tower, LSP streaming state
  machine, and the R2T2 text pipeline (punctuation-by-context, repetition
  repair, language-tag parsing, "|" truncation).
* Schema-v1 spec-backed loader: model_specs/r2t2_asr.json is the single source
  of truth for metadata, capabilities, options and packages, and the loader
  factory lives next to the session, so the family ships no loader.{h,cpp}.
* The thinker is a thin adapter over the shared
  runtime::GreedyQwenDecoderRuntime (graph lifetimes, audio-embedding
  injection, static KV cache); long audio uses engine::audio::plan_audio_chunks
  and the tower reuses the shared modules plus
  core::ensure_backend_addressable_layout. No framework files are modified.
* Offline and append-only streaming modes. Committed deltas follow the upstream
  WebSocket integrator contract (slice by code-point length, authoritative
  final transcript); chunk sizes 80-2000 ms via r2t2_asr.chunk_size_ms.
* Language accepts ISO-639 codes (zh/en/ja/...) or canonical prompt names
  (Chinese/English/...); Auto detects. Checkpoints below Q8_0 are rejected at
  load time with an actionable error.

Packaging and UI
* model_specs/r2t2_asr.json (schema v1, status community) with the HF
  safetensors package plus published Q8_0 and F16 GGUF packages
  (davidxifeng/Confucius4-R2T2-gguf). Each GGUF embeds its sidecars and the
  schema-v1 option contract, so one file is a fully standalone package.
* webui: catalog entry, r2t2_asr session controls, GGUF install choices, live
  microphone transcription, and the language dropdown.
* docs/community_models/r2t2.md, the ASR model table, app/server/example.json.

Verification (macOS MPS reference from the upstream repository)
* tests/r2t2_asr: MPS golden generator, per-chunk comparison harness, and a
  repo-native offline+streaming smoke test.
* Chinese reference clip: offline text, committed delta stream and final
  transcript are exact, 21/21 committed prefixes identical (auto and forced
  language).
* English clip: offline, committed stream and final transcript exact; two
  internal prefixes differ by one token, which the reference itself reproduces
  between its own fp16 and bf16 runs.
* Q8_0 and F16 GGUF reproduce the same transcripts; 48 kHz stereo input goes
  through the shared mono conversion/resampling helper with identical results;
  server offline, SSE streaming and live-PCM paths verified; model unload
  returns RSS from 6.77 GiB to 0.20 GiB.
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