Reference: transformers.models.lasr.modeling_lasr @ 65dc261512cbdb1ee72b88ae5b222f2605aad8e5 (v5.0.0.dev0)
Closest in-tree analog: src/arch/parakeet/ (Conformer + macaron) + src/causal_lm/ (RoPE) + src/arch/sensevoice/ (CTC head)
MedASR is the first in-tree encoder-ctc Conformer family. It does NOT
fit build_conformer_block from src/conformer/conformer.h because the
upstream block uses load-bearing scaled residuals on every sub-block
(macaron [1.5, 0.5], conv [2.0, 1.0]) rather than the standard 0.5
macaron half-step, and the attention path is RoPE rather than
relative-position. The block is therefore hand-built in
src/arch/medasr/encoder.cpp using the lower-level shared helpers
(layer_norm, feed_forward, conv_1d_dw_f32, fused_batch_norm,
add_conv_bias) plus a per-family rope_mhsa written from
src/causal_lm/'s ggml_rope_ext pattern.
| Stage | Reference location | Output shape | Gate tensor | ggml / C++ pattern | In-tree analog |
|---|---|---|---|---|---|
| log-mel | feature_extraction_lasr.LasrFeatureExtractor.__call__ |
[T_mel=floor((n_pcm-400)/160)+1, 128] f32 |
mel.in |
host-side transcribe::MelFrontend; LASR knobs require center=false, pad_mode="zero", pre_emphasis=0.0, normalize="none", window=ckpt, filterbank=ckpt, log_compression="log_clamp_1e-5". Step 2 brings up the encoder with TRANSCRIBE_MEL_FROM_REF=<ref_dir> so Step 7 (frontend parity) is the first place the C++ mel actually runs. |
src/transcribe-mel.{h,cpp} (extend with center=false knob and log_compression enum) |
For jfk.wav (176 400 samples): T_mel = (176 400 − 400)/160 + 1 = 1 099 frames — reference dump rounds to 1 098 (one trailing frame dropped because LASR's manual unfold does len // win floor on the residual; not centered). The forward-map records the formula; bit-exact frame count is a Step 7 parity check.
Input: mel ne [T_mel, 128, 1, B] f32 (matches MelFrontend row-major output).
| Stage | Reference location | Output shape | Gate tensor | ggml / C++ pattern | In-tree analog |
|---|---|---|---|---|---|
| Subsampling: dense_0 | LasrEncoderSubsampling.dense_0 |
[T_mel, 512] |
— | mul_mat + bias add |
parakeet feed_forward w/o second linear |
| Subsampling: relu0 | F.relu |
[T_mel, 512] |
— | ggml_relu |
n/a |
| Subsampling: conv_0 | Conv1d(512, 512, k=5, s=2, p=0) |
[T1=floor((T_mel-5)/2)+1, 512] |
— | conv_1d_f32 (kernel [KW=5, IC=512, OC=512], stride 2, pad 0) |
conformer conv_1d_f32 |
| Subsampling: conv_0 bias + relu | bias add + F.relu |
[T1, 512] |
— | add_conv_bias + ggml_relu |
conformer |
| Subsampling: conv_1 | Conv1d(512, 256, k=5, s=2, p=0) |
[T_enc=floor((T1-5)/2)+1, 256] |
— | conv_1d_f32 |
conformer |
| Subsampling: conv_1 bias + relu | bias + F.relu |
[T_enc, 256] |
— | add_conv_bias + ggml_relu |
n/a |
| Subsampling: dense_1 | Linear(256, 512) + bias |
[T_enc, 512] |
enc.subsampling.out |
mul_mat + bias add |
n/a |
| Block ×17: norm_ff1 | LayerNorm(512, eps=1e-6, bias=False) |
[T_enc, 512] |
(in post_ff1) |
layer_norm(x, w, nullptr) with eps=1e-6 |
conformer layer_norm (eps hardcoded 1e-5 — see deviation) |
| Block ×17: ff1 | LasrFeedForward = Linear → SiLU → Linear (no biases) |
[T_enc, 512] |
— | feed_forward(x, lin1_w, nullptr, lin2_w, nullptr) |
conformer feed_forward |
| Block ×17: scaled-residual ff1 | x = x*1.5 + ff1*0.5 |
[T_enc, 512] |
enc.block.0.post_ff1 (block 0 only) |
ggml_add(ggml_scale(x, 1.5), ggml_scale(ff1, 0.5)) |
NOT macaron_ff_residual (deviation) |
| Block ×17: norm_attn | LayerNorm no bias |
— | — | layer_norm w/ eps 1e-6 |
conformer |
| Block ×17: self-attn Q/K/V | Linear no bias each → reshape [d_model] → [head_dim, n_head] |
[head_dim=64, n_head=8, T_enc, B] |
— | three mul_mat then reshape_3d+permute for head layout |
causal_lm Q/K/V projection |
| Block ×17: RoPE on Q, K | LasrEncoderRotaryEmbedding (rope_theta=10 000, type=default) applied per-head |
unchanged | — | ggml_rope_ext(Q, positions, nullptr, head_dim, GGML_ROPE_TYPE_NEOX, max_pos=10000, rope_theta=10000, 1,0,1,32,1) and same for K |
causal_lm RoPE call site (deviation: no MRoPE, no extrapolation) |
| Block ×17: scaled-dot attn | softmax(QK/√d_k)·V, no mask | [head_dim, n_head, T_enc, B] |
— | flash_attn_ext or manual mul_mat+soft_max |
causal_lm |
| Block ×17: o_proj | Linear no bias |
[T_enc, 512] |
— | reshape back + mul_mat |
causal_lm |
| Block ×17: residual attn | x = x + attn (UNSCALED) |
[T_enc, 512] |
enc.block.0.post_attn |
ggml_add |
standard |
| Block ×17: norm_conv | LayerNorm no bias |
— | — | layer_norm |
conformer |
| Block ×17: conv module | Conv1d pw1(512→1024,k=1) → GLU → Conv1d dw(512,k=32,p=15,g=512) → BatchNorm1d(512) → SiLU → Conv1d pw2(512→512,k=1) |
[T_enc, 512] |
— | mul_mat + ggml_swiglu + conv_1d_dw_f32 + fused_batch_norm + silu + mul_mat. Depthwise uses non-centered pad=15 (left=15, right=16 to keep T identical with k=32 even kernel) — verify against reference. |
conformer conv_module (cannot reuse — that helper bakes in the unscaled x + conv residual; we need a custom scaled residual outside) |
| Block ×17: scaled-residual conv | x = x*2.0 + conv*1.0 |
[T_enc, 512] |
enc.block.0.post_conv |
ggml_add(ggml_scale(x, 2.0), conv) |
deviation |
| Block ×17: norm_ff2 + ff2 | same shape as FF1 | [T_enc, 512] |
— | mirrors FF1 | conformer |
| Block ×17: scaled-residual ff2 | x = x*1.5 + ff2*0.5 |
[T_enc, 512] |
enc.block.0.post_ff2 |
ggml_add(ggml_scale(x, 1.5), ggml_scale(ff2, 0.5)) |
deviation |
| Block ×17: norm_out | LayerNorm no bias |
[T_enc, 512] |
enc.block.{0,7,8,16}.out |
layer_norm w/ eps 1e-6 |
conformer |
| enc.out_norm | top-level LayerNorm no bias |
[T_enc, 512] |
enc.out_norm.out |
layer_norm |
conformer |
n/a — non-autoregressive CTC head.
| Stage | Reference location | Output shape | Gate tensor | ggml / C++ pattern | In-tree analog |
|---|---|---|---|---|---|
| CTC head | LasrCtcHead.ctc_head = Conv1d(512, 512, k=1) + bias |
[vocab=512, T_enc, 1, B] (note [vocab, T] order) |
enc.ctc_logits |
ggml_mul_mat(W_ctc, x) + b_ctc (k=1 conv = mul_mat) |
sensevoice ctc_head |
| Greedy decode | argmax + collapse-repeats + drop-blanks (blank=0) | [N_tokens] host ints |
— | host-side loop on logits | adapt parakeet decode_ctc_greedy (blank=last-id → blank=0) |
| Detokenize | LasrTokenizer.batch_decode (SP) — keeps </s> id 2 in stream |
string | transcript.json |
shared Tokenizer::decode_ctc strips blanks pre-call |
shared |
n/a — encoder-only CTC; no autoregressive state. run_batch fast path
batches utterances via the existing batch-axis (ne[3]) of the encoder
graph, identical to parakeet's CTC variant.
| Capability | Reference behavior | C++ API behavior | Family-doc Capability Validation row |
|---|---|---|---|
| English transcribe | generate greedy CTC |
same | "Transcribe / explicit en" + "Transcribe / auto" — both MUST PASS |
| Language detection | n/a (English-only) | reject --language != en with OK fallback to en |
SKIP — not advertised |
| Translation | n/a | not exposed | SKIP — not advertised |
| Timestamps | n/a (CTC has none) | not exposed | SKIP — architecture has no timestamp head |
| Streaming | n/a (offline only) | not exposed | SKIP — capabilities.streaming=false |
| Batch (offline) | HF processor pads to common T_mel | implement run_batch via ne[3] batch axis on encoder graph (mirror parakeet CTC pattern); per-utterance valid-frame masks via attn_pad_mask / conv_pad_mask on each block |
MUST PASS — text byte-identical, CPU tensor bit-exact at bs 2/4/8 |
| KenLM beam decode | shipped but not Stage-4 obligation | not exposed | SKIP — OUT OF SCOPE (first ship is greedy) |
- Macaron FF residual scalars are
[1.5, 0.5], NOT the standard0.5. The sharedtranscribe::conformer::macaron_ff_residualhelper hardcodesx + 0.5 * FF(LN(x))and is not reusable here. medasr hand-builds the FF1/FF2 residual asggml_add(ggml_scale(x, 1.5), ggml_scale(FF(LN(x)), 0.5)). - Conv module residual scalars are
[2.0, 1.0].transcribe::conformer::conv_moduleitself is callable for the inner pw1+GLU+dw+BN+SiLU+pw2 sub-block, but the surrounding scaled residual is hand-built (ggml_add(ggml_scale(x, 2.0), conv)). - Attention is RoPE, not relative-position.
transcribe::conformer::rel_pos_mhsaandbuild_conformer_blockare not used. The Q/K projection +ggml_rope_ext(..., GGML_ROPE_TYPE_NEOX, ...)+ softmax(QK/√d)·V + o_proj is implemented per-block insrc/arch/medasr/encoder.cppusing thecausal_lmpattern atsrc/causal_lm/causal_lm.cpp:234-245.rope_theta=10 000,max_position_embeddings=10 000. layer_normeps is1e-6, not the conformer-helper-hardcoded1e-5. The shared helper usesggml_normwith constantkLayerNormEps = 1e-5f. medasr loadsenc.layer_norm_epsfrom the GGUF (stt.medasr.encoder.layer_norm_eps) and passes it through a localmedasr_layer_normshim that takes eps as a runtime argument.- All encoder LayerNorms are
bias=False. Every_bslot on the medasr block view stays nullptr;layer_normis called withbeta=nullptr. Same null-bias treatment as parakeet's FFN/attn linears; just applied to LN this time. - Subsampling is 1-D, not 2-D.
LasrEncoderSubsampling=Linear(128→512) → ReLU → Conv1d(512,512,k=5,s=2) → ReLU → Conv1d(512,256,k=5,s=2) → ReLU → Linear(256→512). The leadingLinear(128→512)reduces the mel dim to d_model BEFORE the convs, so the convs operate on[T, 512]channels (not[F=128, T]2-D as in parakeet's DwStridingSubsampling). Two stride-2 convs → 4x effective downsampling.transcribe::conformer::build_pre_encodeis not reusable; medasr writes its ownbuild_subsamplinginencoder.cppusingconv_1d_f32. - CTC head is
Conv1d(512→512, k=1)with bias, notLinearno-bias. Numerically equivalent to a linear projection but stored asConv1d. The converter normalizes the tensor name toctc.proj.weight(transposed if necessary to mul_mat-friendly layout); C++ runsggml_add(ggml_mul_mat(W, x), b). - CTC blank id = 0 (
<epsilon>), NOTvocab_size - 1. Parakeet/sensevoice convention isblank = vocab_size - 1. medasr readsstt.medasr.ctc.blank_idfrom the GGUF and uses it in greedy collapse. Hard-codedlast_idassumptions in the shared CTC decoder are NOT reused — medasr ships its own greedy collapse indecode_ctc.cpp(or inlined inmodel.cpp::run). - Tokenizer specials match SentencePiece ids 0..3 (
<epsilon>,<s>,</s>,<unk>). The reference WER scorer (scripts/wer/run_reference_medasr_transformers.py) usesprocessor.batch_decode(token_ids, skip_special_tokens=True). The validate.py reference dumper does the same (changed from the README's non-skipping variant, see commit), so both reference paths now agree. The C++ greedy CTC collapse mirrors this by skipping ids 1 (<s>), 2 (</s>), and 3 (<unk>) in addition to the blank id 0. Without this skip, the CTC head's occasional last-frame</s>prediction leaks the literal string into the transcript and lifts dataset WER by ~0.2pp on LibriSpeech test-clean. - Conv-module depthwise dispatch is
direct_dw_in_block=true(NOT the im2col path). LASR'sconv_kernel=32"same" padding is asymmetric(15, 16); the conformer-helper's asymmetric-pad path usesggml_fill + ggml_concatper side, which is NOT batch-stable in ggml CPU (drift ~1.4e-6 at the encoder output between B=1 and B>1). The direct depthwise op (ggml_conv_2d_dw_direct) handles the same kernel + symmetric inner-padding shape and is bit-exact across batch sizes. The trade-off is a Metal note: direct depthwise on k=32 has not been profiled on Apple GPU and may need a fallback there; CPU + Vulkan + CUDA are fine.
medasr: single variant; no per-variant deviations. Thevariantslist inintake.jsonhas length 1.config.varying_across_variantsis empty.