Skip to main content

cortiq_engine/
nemotron_asr.rs

1//! NVIDIA Nemotron 3.5 Transcribe (30B-A4B) on CMF tensors: the audio
2//! perception module and the transcription loop around the Nemotron-H
3//! decoder (`crate::nemotron_h`).
4//!
5//! Audio path, as the Transformers reference (`Nemotron3_5TranscribeAudioModel`):
6//!
7//! 1. log-mel front end: pre-emphasis 0.97, centered 512-point STFT with the
8//!    checkpoint's 400-tap window, power spectrum, the checkpoint's 128-bin
9//!    filter bank, `ln(x + 2⁻²⁴)`; frames past the audio are zero and the
10//!    spectrogram is padded to a multiple of the 8× subsampling;
11//! 2. ASR encoder (transformer, 1280 wide, 32 layers, per-head LayerNorm on
12//!    q/k, RoPE on half of each head) over per-feature normalized frames,
13//!    in chunks of 500 encoder frames with 50 frames of context each side;
14//! 3. Sortformer diarization (transformer 512 wide, 31 layers) over the raw
15//!    frames with the arrival-order speaker cache and FIFO of the offline
16//!    mode; its thresholded speaker activities, layer-normed, select a fixed
17//!    sinusoidal speaker embedding added to the layer-normed ASR states;
18//! 4. a linear projector to the language model width.
19//!
20//! The audio module computes in f32 (as the reference keeps it in fp32);
21//! the projected embeddings are rounded to bf16 before they replace the
22//! `<|audio|>` placeholders, as the reference scatters them.
23
24use crate::fcd_ops::gemm_nt;
25use crate::nemotron_h::{NemotronH, SendPtr, dot, par_rows, tensor_f32};
26use crate::pool::Pool;
27use crate::tokenizer::Tokenizer;
28use cortiq_core::CmfModel;
29use rustfft::{FftPlanner, num_complex::Complex};
30use std::path::Path;
31use std::sync::Arc;
32use std::time::Instant;
33
34pub mod words;
35
36pub const ARCH_NAME: &str = "nemotron3_5_transcribe";
37
38const SR: usize = 16_000;
39const HOP: usize = 160;
40const NFFT: usize = 512;
41const LOG_GUARD: f32 = 5.960_464_5e-8; // 2^-24
42
43struct Ln {
44    w: Vec<f32>,
45    b: Vec<f32>,
46}
47
48impl Ln {
49    fn load(m: &CmfModel, p: &str) -> Result<Self, String> {
50        Ok(Self {
51            w: tensor_f32(m, &format!("{p}.weight"))?,
52            b: tensor_f32(m, &format!("{p}.bias"))?,
53        })
54    }
55    fn apply(&self, x: &mut [f32]) {
56        let n = x.len();
57        let mean = x.iter().sum::<f32>() / n as f32;
58        let var = x.iter().map(|v| (v - mean) * (v - mean)).sum::<f32>() / n as f32;
59        let inv = 1.0 / (var + 1e-5).sqrt();
60        for i in 0..n {
61            x[i] = (x[i] - mean) * inv * self.w[i] + self.b[i];
62        }
63    }
64    fn apply_rows(&self, x: &mut [f32]) {
65        for r in x.chunks_exact_mut(self.w.len()) {
66            self.apply(r);
67        }
68    }
69}
70
71/// A dense f32 linear layer (`[out, in]` weights, optional bias).
72struct Lin {
73    w: Vec<f32>,
74    b: Option<Vec<f32>>,
75    out: usize,
76    inp: usize,
77}
78
79impl Lin {
80    fn load(m: &CmfModel, p: &str, bias: bool) -> Result<Self, String> {
81        let name = format!("{p}.weight");
82        let e = m
83            .tensor(&name)
84            .ok_or_else(|| format!("missing tensor {name}"))?;
85        if e.shape.len() != 2 {
86            return Err(format!("{name}: expected 2-D, got {:?}", e.shape));
87        }
88        Ok(Self {
89            w: tensor_f32(m, &name)?,
90            b: if bias {
91                Some(tensor_f32(m, &format!("{p}.bias"))?)
92            } else {
93                None
94            },
95            out: e.shape[0],
96            inp: e.shape[1],
97        })
98    }
99    fn apply(&self, x: &[f32], n: usize, pool: Option<&Pool>) -> Vec<f32> {
100        let mut y = vec![0.0f32; n * self.out];
101        gemm_nt(x, &self.w, &mut y, n, self.inp, self.out, pool);
102        if let Some(b) = &self.b {
103            for r in y.chunks_exact_mut(self.out) {
104                for (v, bb) in r.iter_mut().zip(b) {
105                    *v += bb;
106                }
107            }
108        }
109        y
110    }
111}
112
113struct EncLayer {
114    n1: Ln,
115    qkv: Lin,
116    qn: Option<Ln>,
117    kn: Option<Ln>,
118    out: Lin,
119    n2: Ln,
120    fc1: Lin,
121    fc2: Lin,
122}
123
124/// NeMo `TransformerEncoder` with feature-stacking subsampling, pre-norm
125/// blocks, GELU MLP and RoPE on the first `rot` dims of every head.
126struct Encoder {
127    proj: Lin,
128    embed_norm: Ln,
129    layers: Vec<EncLayer>,
130    final_norm: Ln,
131    d: usize,
132    heads: usize,
133    hd: usize,
134    rot: usize,
135    stack: usize,
136    /// The same tower on the wgpu device (bf16 weights, f32 arithmetic).
137    #[cfg(feature = "gpu")]
138    dev: Option<crate::gpu_wgpu::nemotron_audio::AudioEncoder>,
139}
140
141impl Encoder {
142    #[cfg(feature = "gpu")]
143    fn build_dev(
144        &self,
145        m: &CmfModel,
146        p: &str,
147    ) -> Option<crate::gpu_wgpu::nemotron_audio::AudioEncoder> {
148        use crate::gpu_wgpu::nemotron_audio::{AeDesc, AeLayerDesc, AudioEncoder};
149        if std::env::var("CMF_NEMO_AUDIO_GPU").as_deref() == Ok("0") || !crate::gpu::wgpu_active() {
150            return None;
151        }
152        let bf16 = |name: String| -> Option<&[u8]> {
153            let e = m.tensor(&name)?;
154            (e.dtype == cortiq_core::TensorDtype::Bf16).then(|| m.entry_bytes(e))
155        };
156        let wb = |ln: &Ln| -> Vec<f32> { ln.w.iter().chain(&ln.b).copied().collect() };
157        let mut layers = Vec::with_capacity(self.layers.len());
158        for (i, l) in self.layers.iter().enumerate() {
159            let q = format!("{p}.layers.{i}");
160            layers.push(AeLayerDesc {
161                ln1: wb(&l.n1),
162                qkv: bf16(format!("{q}.attn.w_qkv.weight"))?,
163                qn: l.qn.as_ref().map(wb),
164                kn: l.kn.as_ref().map(wb),
165                out: bf16(format!("{q}.attn.out_proj.weight"))?,
166                out_b: l.out.b.clone()?,
167                ln2: wb(&l.n2),
168                fc1: bf16(format!("{q}.ffn.net.0.weight"))?,
169                fc1_b: l.fc1.b.clone()?,
170                fc2: bf16(format!("{q}.ffn.net.3.weight"))?,
171                fc2_b: l.fc2.b.clone()?,
172            });
173        }
174        AudioEncoder::build(&AeDesc {
175            d: self.d,
176            heads: self.heads,
177            rot: self.rot,
178            inter: self.layers[0].fc1.out,
179            stack_in: self.proj.inp,
180            proj: bf16(format!("{p}.pre_encode.proj.weight"))?,
181            embed_norm: wb(&self.embed_norm),
182            final_norm: wb(&self.final_norm),
183            layers,
184        })
185    }
186
187    fn load(
188        m: &CmfModel,
189        p: &str,
190        heads: usize,
191        rot_frac: f64,
192        stack: usize,
193    ) -> Result<Self, String> {
194        let proj = Lin::load(m, &format!("{p}.pre_encode.proj"), false)?;
195        let d = proj.out;
196        let hd = d / heads;
197        let mut layers = Vec::new();
198        for i in 0.. {
199            let l = format!("{p}.layers.{i}");
200            if m.tensor(&format!("{l}.norm1.weight")).is_none() {
201                break;
202            }
203            let qk = m.tensor(&format!("{l}.attn.q_norm.weight")).is_some();
204            layers.push(EncLayer {
205                n1: Ln::load(m, &format!("{l}.norm1"))?,
206                qkv: Lin::load(m, &format!("{l}.attn.w_qkv"), false)?,
207                qn: if qk {
208                    Some(Ln::load(m, &format!("{l}.attn.q_norm"))?)
209                } else {
210                    None
211                },
212                kn: if qk {
213                    Some(Ln::load(m, &format!("{l}.attn.k_norm"))?)
214                } else {
215                    None
216                },
217                out: Lin::load(m, &format!("{l}.attn.out_proj"), true)?,
218                n2: Ln::load(m, &format!("{l}.norm2"))?,
219                fc1: Lin::load(m, &format!("{l}.ffn.net.0"), true)?,
220                fc2: Lin::load(m, &format!("{l}.ffn.net.3"), true)?,
221            });
222        }
223        if layers.is_empty() {
224            return Err(format!("{p}: no encoder layers"));
225        }
226        #[allow(unused_mut)]
227        let mut enc = Self {
228            embed_norm: Ln::load(m, &format!("{p}.embed_norm"))?,
229            final_norm: Ln::load(m, &format!("{p}.final_norm"))?,
230            proj,
231            layers,
232            d,
233            heads,
234            hd,
235            rot: (hd as f64 * rot_frac) as usize,
236            stack,
237            #[cfg(feature = "gpu")]
238            dev: None,
239        };
240        #[cfg(feature = "gpu")]
241        {
242            enc.dev = enc.build_dev(m, p);
243            // the device holds the tower: release the host f32 copy of its
244            // blocks (2.9 GB for both towers — what decides whether a 24 GB
245            // Mac fits); the small stacking projection stays (the
246            // diarization embeds every frame on the host)
247            if enc.dev.is_some() && std::env::var("CMF_NEMO_KEEP_HOST").as_deref() != Ok("1") {
248                for l in &mut enc.layers {
249                    for lin in [&mut l.qkv, &mut l.out, &mut l.fc1, &mut l.fc2] {
250                        lin.w = Vec::new();
251                    }
252                }
253            }
254        }
255        Ok(enc)
256    }
257
258    /// A tower without the stacking projection (the timestamp head's
259    /// transformer: its input comes from the transposed conv), host only.
260    fn load_tower(m: &CmfModel, p: &str, heads: usize, rot_frac: f64) -> Result<Self, String> {
261        let embed_norm = Ln::load(m, &format!("{p}.embed_norm"))?;
262        let d = embed_norm.w.len();
263        let hd = d / heads;
264        let mut layers = Vec::new();
265        for i in 0.. {
266            let l = format!("{p}.layers.{i}");
267            if m.tensor(&format!("{l}.norm1.weight")).is_none() {
268                break;
269            }
270            let qk = m.tensor(&format!("{l}.attn.q_norm.weight")).is_some();
271            layers.push(EncLayer {
272                n1: Ln::load(m, &format!("{l}.norm1"))?,
273                qkv: Lin::load(m, &format!("{l}.attn.w_qkv"), false)?,
274                qn: if qk { Some(Ln::load(m, &format!("{l}.attn.q_norm"))?) } else { None },
275                kn: if qk { Some(Ln::load(m, &format!("{l}.attn.k_norm"))?) } else { None },
276                out: Lin::load(m, &format!("{l}.attn.out_proj"), true)?,
277                n2: Ln::load(m, &format!("{l}.norm2"))?,
278                fc1: Lin::load(m, &format!("{l}.ffn.net.0"), true)?,
279                fc2: Lin::load(m, &format!("{l}.ffn.net.3"), true)?,
280            });
281        }
282        if layers.is_empty() {
283            return Err(format!("{p}: no layers"));
284        }
285        Ok(Self {
286            proj: Lin {
287                w: Vec::new(),
288                b: None,
289                out: d,
290                inp: 0,
291            },
292            embed_norm,
293            final_norm: Ln::load(m, &format!("{p}.final_norm"))?,
294            layers,
295            d,
296            heads,
297            hd,
298            rot: (hd as f64 * rot_frac) as usize,
299            stack: 1,
300            #[cfg(feature = "gpu")]
301            dev: None,
302        })
303    }
304
305    /// Stacked frames `[n·stack · n_mel]` → encoder states `[n · d]`.
306    fn encode_frames(
307        &self,
308        feats: &[f32],
309        n: usize,
310        valid: &[bool],
311        pool: Option<&Pool>,
312    ) -> Vec<f32> {
313        #[cfg(feature = "gpu")]
314        if let Some(dev) = &self.dev
315            && let Some(h) = dev.run(feats, n, false, valid)
316        {
317            return h;
318        }
319        assert!(
320            !self.layers[0].qkv.w.is_empty(),
321            "audio encoder: the device run failed and the host weights were released (CMF_NEMO_KEEP_HOST=1 keeps them)"
322        );
323        let x = self.embed(feats, n * self.stack, pool);
324        self.run(&x, n, valid, pool)
325    }
326
327    /// Embedded rows `[n · d]` → encoder states `[n · d]`.
328    fn encode_rows(&self, x: &[f32], n: usize, valid: &[bool], pool: Option<&Pool>) -> Vec<f32> {
329        #[cfg(feature = "gpu")]
330        if let Some(dev) = &self.dev
331            && let Some(h) = dev.run(x, n, true, valid)
332        {
333            return h;
334        }
335        assert!(
336            !self.layers[0].qkv.w.is_empty(),
337            "audio encoder: the device run failed and the host weights were released (CMF_NEMO_KEEP_HOST=1 keeps them)"
338        );
339        self.run(x, n, valid, pool)
340    }
341
342    /// Feature stacking: `stack` consecutive frames → one projected row.
343    fn embed(&self, feats: &[f32], frames: usize, pool: Option<&Pool>) -> Vec<f32> {
344        debug_assert_eq!(frames % self.stack, 0);
345        self.proj.apply(feats, frames / self.stack, pool)
346    }
347
348    /// The transformer over `n` embedded rows; `valid[t]` masks keys.
349    fn run(&self, x: &[f32], n: usize, valid: &[bool], pool: Option<&Pool>) -> Vec<f32> {
350        let d = self.d;
351        let mut h = x.to_vec();
352        self.embed_norm.apply_rows(&mut h);
353        let (cos, sin) = rope_table(n, self.rot);
354        for l in &self.layers {
355            let mut a = h.clone();
356            l.n1.apply_rows(&mut a);
357            let mut qkv = l.qkv.apply(&a, n, pool);
358            for t in 0..n {
359                let row = &mut qkv[t * 3 * d..(t + 1) * 3 * d];
360                for hh in 0..self.heads {
361                    let q = &mut row[hh * self.hd..(hh + 1) * self.hd];
362                    if let Some(qn) = &l.qn {
363                        qn.apply(q);
364                    }
365                    rope(
366                        q,
367                        &cos[t * self.rot / 2..],
368                        &sin[t * self.rot / 2..],
369                        self.rot,
370                    );
371                }
372                for hh in 0..self.heads {
373                    let k = &mut row[d + hh * self.hd..d + (hh + 1) * self.hd];
374                    if let Some(kn) = &l.kn {
375                        kn.apply(k);
376                    }
377                    rope(
378                        k,
379                        &cos[t * self.rot / 2..],
380                        &sin[t * self.rot / 2..],
381                        self.rot,
382                    );
383                }
384            }
385            let o = bidir_attention(&qkv, n, self.heads, self.hd, valid, pool);
386            let o = l.out.apply(&o, n, pool);
387            for (v, w) in h.iter_mut().zip(&o) {
388                *v += w;
389            }
390            let mut a = h.clone();
391            l.n2.apply_rows(&mut a);
392            let mut f = l.fc1.apply(&a, n, pool);
393            for v in f.iter_mut() {
394                *v = gelu_erf(*v);
395            }
396            let f = l.fc2.apply(&f, n, pool);
397            for (v, w) in h.iter_mut().zip(&f) {
398                *v += w;
399            }
400        }
401        self.final_norm.apply_rows(&mut h);
402        h
403    }
404}
405
406/// RoPE angles `[n · rot/2]` (positions from 0, θ = 10000).
407fn rope_table(n: usize, rot: usize) -> (Vec<f32>, Vec<f32>) {
408    let half = rot / 2;
409    let inv: Vec<f32> = (0..half)
410        .map(|i| 1.0 / 10000f32.powf((2 * i) as f32 / rot as f32))
411        .collect();
412    let mut cos = vec![0.0; n * half];
413    let mut sin = vec![0.0; n * half];
414    for t in 0..n {
415        for i in 0..half {
416            let a = t as f32 * inv[i];
417            cos[t * half + i] = a.cos();
418            sin[t * half + i] = a.sin();
419        }
420    }
421    (cos, sin)
422}
423
424/// NeoX rotate-half over the first `rot` dims.
425fn rope(x: &mut [f32], cos: &[f32], sin: &[f32], rot: usize) {
426    let half = rot / 2;
427    for i in 0..half {
428        let (a, b) = (x[i], x[i + half]);
429        x[i] = a * cos[i] - b * sin[i];
430        x[i + half] = b * cos[i] + a * sin[i];
431    }
432}
433
434/// Bidirectional attention over the packed `[q | k | v]` rows; keys with
435/// `valid[t] == false` are masked. Output `[n · heads·hd]`.
436fn bidir_attention(
437    qkv: &[f32],
438    n: usize,
439    heads: usize,
440    hd: usize,
441    valid: &[bool],
442    pool: Option<&Pool>,
443) -> Vec<f32> {
444    let d = heads * hd;
445    let scale = 1.0 / (hd as f32).sqrt();
446    let mut o = vec![0.0f32; n * d];
447    let o_addr = SendPtr(o.as_mut_ptr());
448    let run = |r0: usize, r1: usize| {
449        let o = o_addr;
450        let mut sc = vec![0.0f32; n];
451        for r in r0..r1 {
452            let (hh, t) = (r / n, r % n);
453            let q = &qkv[t * 3 * d + hh * hd..t * 3 * d + (hh + 1) * hd];
454            let mut mx = f32::NEG_INFINITY;
455            for j in 0..n {
456                if !valid[j] {
457                    sc[j] = f32::NEG_INFINITY;
458                    continue;
459                }
460                let k = &qkv[j * 3 * d + d + hh * hd..j * 3 * d + d + (hh + 1) * hd];
461                let s = dot(q, k) * scale;
462                sc[j] = s;
463                mx = mx.max(s);
464            }
465            let mut den = 0.0f32;
466            for s in sc.iter_mut() {
467                *s = if s.is_finite() { (*s - mx).exp() } else { 0.0 };
468                den += *s;
469            }
470            let out = unsafe { std::slice::from_raw_parts_mut(o.0.add(t * d + hh * hd), hd) };
471            out.fill(0.0);
472            for j in 0..n {
473                if sc[j] == 0.0 {
474                    continue;
475                }
476                let p = sc[j] / den;
477                let v = &qkv[j * 3 * d + 2 * d + hh * hd..j * 3 * d + 2 * d + (hh + 1) * hd];
478                for (a, &b) in out.iter_mut().zip(v) {
479                    *a += p * b;
480                }
481            }
482        }
483    };
484    par_rows(pool, heads * n, &run);
485    o
486}
487
488fn gelu_erf(x: f32) -> f32 {
489    0.5 * x * (1.0 + crate::inference::erf_f32(x * std::f32::consts::FRAC_1_SQRT_2))
490}
491
492fn sigmoid(x: f32) -> f32 {
493    1.0 / (1.0 + (-x).exp())
494}
495
496/// Offline-mode settings of the Sortformer speaker cache.
497#[derive(Clone, Debug)]
498struct SpkCfg {
499    chunk: usize,
500    left: usize,
501    right: usize,
502    fifo_len: usize,
503    update_period: usize,
504    cache_len: usize,
505    n_spk: usize,
506    sil_frames: usize,
507    pred_thr: f32,
508    latest_boost: f32,
509    min_pos: usize,
510    n_strong: usize,
511    n_weak: usize,
512    activity_thr: f32,
513}
514
515impl SpkCfg {
516    fn from_config(audio: &serde_json::Value) -> Self {
517        let dz = &audio["diarization_config"];
518        let sc = &dz["streaming_config"];
519        let u = |v: &serde_json::Value, k: &str, d: u64| {
520            v.get(k).and_then(|x| x.as_u64()).unwrap_or(d) as usize
521        };
522        let f =
523            |v: &serde_json::Value, k: &str, d: f64| v.get(k).and_then(|x| x.as_f64()).unwrap_or(d);
524        let n_spk = u(sc, "num_speakers", 8);
525        let cache_len = u(sc, "speaker_cache_length", 188);
526        let sil = u(sc, "speaker_cache_silence_frames_per_speaker", 1);
527        let budget = (cache_len / n_spk - sil) as f64;
528        Self {
529            chunk: u(dz, "chunk_length", 500),
530            left: u(dz, "chunk_left_context", 50),
531            right: u(dz, "chunk_right_context", 50),
532            fifo_len: u(dz, "fifo_length", 40),
533            update_period: u(dz, "speaker_cache_update_period", 300),
534            cache_len,
535            n_spk,
536            sil_frames: sil,
537            pred_thr: f(sc, "prediction_score_threshold", 0.25) as f32,
538            latest_boost: f(sc, "latest_frames_score_boost", 0.05) as f32,
539            min_pos: (budget * f(sc, "min_positive_scores_rate", 0.5)).floor() as usize,
540            n_strong: (budget * f(sc, "strong_boost_rate", 0.75)).floor() as usize,
541            n_weak: (budget * f(sc, "weak_boost_rate", 1.5)).floor() as usize,
542            activity_thr: audio
543                .get("speaker_activity_threshold")
544                .and_then(|x| x.as_f64())
545                .unwrap_or(0.5) as f32,
546        }
547    }
548}
549
550/// The arrival-order speaker cache and FIFO of the Sortformer offline mode
551/// (`Nemotron3DiarizationSpeakerCache`), batch 1.
552struct SpkCache {
553    embeds: Vec<f32>,
554    probs: Vec<f32>,
555    fifo: Vec<f32>,
556    compressed: bool,
557}
558
559impl SpkCache {
560    fn n_cache(&self, d: usize) -> usize {
561        self.embeds.len() / d
562    }
563    fn n_fifo(&self, d: usize) -> usize {
564        self.fifo.len() / d
565    }
566
567    #[allow(clippy::too_many_arguments)]
568    fn update(
569        &mut self,
570        c: &SpkCfg,
571        d: usize,
572        input: &[f32],
573        logits: &[f32],
574        mask: &[bool],
575        silence: &[f32],
576        n_chunk: usize,
577        lookback: usize,
578    ) {
579        let ns = c.n_spk;
580        let n_in = input.len() / d;
581        // speaker probabilities at the encoder rate, zero on padding
582        let sub = logits.len() / (n_in * ns);
583        let mut probs = vec![0.0f32; n_in * ns];
584        for t in 0..n_in {
585            if !mask[t] {
586                continue;
587            }
588            for s in 0..ns {
589                let mut acc = 0.0f32;
590                for j in 0..sub {
591                    acc += sigmoid(logits[(t * sub + j) * ns + s]);
592                }
593                probs[t * ns + s] = acc / sub as f32;
594            }
595        }
596        let (nc, nf) = (self.n_cache(d), self.n_fifo(d));
597        let start = nc + nf + lookback;
598        let mut fifo = self.fifo.clone();
599        fifo.extend_from_slice(&input[start * d..(start + n_chunk) * d]);
600        let len = fifo.len() / d;
601        let popped = if len <= c.fifo_len {
602            0
603        } else {
604            c.update_period.max(len - c.fifo_len).min(len)
605        };
606        if popped > 0 {
607            let mut fifo_probs = probs[nc * ns..(nc + nf) * ns].to_vec();
608            fifo_probs.extend_from_slice(&probs[start * ns..(start + n_chunk) * ns]);
609            let mut cache_probs = if self.compressed {
610                self.probs.clone()
611            } else {
612                probs[..nc * ns].to_vec()
613            };
614            let mut cache_embeds = self.embeds.clone();
615            cache_embeds.extend_from_slice(&fifo[..popped * d]);
616            cache_probs.extend_from_slice(&fifo_probs[..popped * ns]);
617            fifo.drain(..popped * d);
618            if cache_embeds.len() / d > c.cache_len {
619                let (e, p) = compress(c, d, &cache_embeds, &cache_probs, silence);
620                cache_embeds = e;
621                cache_probs = p;
622                self.compressed = true;
623            }
624            self.embeds = cache_embeds;
625            self.probs = cache_probs;
626        }
627        self.fifo = fifo;
628    }
629}
630
631/// `_get_frame_scores`: per speaker log-odds against the other speakers,
632/// −∞ for non-speech frames and, where a speaker has enough positive
633/// frames, for its non-positive speech frames.
634fn frame_scores(c: &SpkCfg, probs: &[f32], n: usize) -> Vec<f32> {
635    let ns = c.n_spk;
636    let thr = c.pred_thr;
637    let mut s = vec![0.0f32; n * ns];
638    for t in 0..n {
639        let p = &probs[t * ns..(t + 1) * ns];
640        let lc: Vec<f32> = p.iter().map(|&x| (1.0 - x).max(thr).ln()).collect();
641        let lcs: f32 = lc.iter().sum();
642        for k in 0..ns {
643            let v = p[k].max(thr).ln() - lc[k] + lcs - 0.5f32.ln();
644            s[t * ns + k] = if p[k] > 0.5 { v } else { f32::NEG_INFINITY };
645        }
646    }
647    for k in 0..ns {
648        let positive = (0..n).filter(|&t| s[t * ns + k] > 0.0).count();
649        if positive >= c.min_pos {
650            for t in 0..n {
651                let v = s[t * ns + k];
652                if probs[t * ns + k] > 0.5 && v <= 0.0 {
653                    s[t * ns + k] = f32::NEG_INFINITY;
654                }
655            }
656        }
657    }
658    s
659}
660
661/// Indices of the `k` largest values (torch.topk; ties toward lower index).
662fn topk_idx(v: &[f32], k: usize) -> Vec<usize> {
663    let mut idx: Vec<usize> = (0..v.len()).collect();
664    idx.sort_by(|&a, &b| {
665        v[b].partial_cmp(&v[a])
666            .unwrap_or(std::cmp::Ordering::Equal)
667            .then(a.cmp(&b))
668    });
669    idx.truncate(k);
670    idx
671}
672
673/// `_compress`: keep `cache_len` frames grouped by speaker, in arrival
674/// order within a speaker, with `sil_frames` silence slots per speaker.
675fn compress(
676    c: &SpkCfg,
677    d: usize,
678    embeds: &[f32],
679    probs: &[f32],
680    silence: &[f32],
681) -> (Vec<f32>, Vec<f32>) {
682    let ns = c.n_spk;
683    let n = embeds.len() / d;
684    let mut s = frame_scores(c, probs, n);
685    for t in c.cache_len..n {
686        for k in 0..ns {
687            s[t * ns + k] += c.latest_boost;
688        }
689    }
690    for (count, boost) in [(c.n_strong, -2.0 * 0.5f32.ln()), (c.n_weak, -(0.5f32.ln()))] {
691        for k in 0..ns {
692            let col: Vec<f32> = (0..n).map(|t| s[t * ns + k]).collect();
693            for t in topk_idx(&col, count) {
694                s[t * ns + k] += boost;
695            }
696        }
697    }
698    let scored = n + c.sil_frames;
699    // speaker-major flat scores; silence rows score +∞
700    let mut flat = vec![f32::INFINITY; ns * scored];
701    for k in 0..ns {
702        for t in 0..n {
703            flat[k * scored + t] = s[t * ns + k];
704        }
705    }
706    let sentinel = scored * ns;
707    let mut picked: Vec<usize> = topk_idx(&flat, c.cache_len)
708        .into_iter()
709        .map(|i| {
710            if flat[i] == f32::NEG_INFINITY {
711                sentinel
712            } else {
713                i
714            }
715        })
716        .collect();
717    picked.sort_unstable();
718    let mut out_e = Vec::with_capacity(c.cache_len * d);
719    let mut out_p = Vec::with_capacity(c.cache_len * ns);
720    for i in picked {
721        let f = if i == sentinel {
722            n
723        } else {
724            (i % scored).min(n)
725        };
726        if f < n {
727            out_e.extend_from_slice(&embeds[f * d..(f + 1) * d]);
728            out_p.extend_from_slice(&probs[f * ns..(f + 1) * ns]);
729        } else {
730            out_e.extend_from_slice(silence);
731            out_p.extend(std::iter::repeat_n(0.0, ns));
732        }
733    }
734    (out_e, out_p)
735}
736
737/// The perception module: mel front end, ASR encoder, diarization, projector.
738pub struct Audio {
739    fb: Vec<f32>,
740    window: Vec<f32>,
741    n_mel: usize,
742    asr: Encoder,
743    diar: Encoder,
744    diar_proj: Lin,
745    up_w: Vec<f32>,
746    up_b: Vec<f32>,
747    cls_dense: Lin,
748    cls_out: Lin,
749    silence: Vec<f32>,
750    asr_norm: Ln,
751    spk_norm: Ln,
752    spk_table: Vec<f32>,
753    proj: Lin,
754    spk: SpkCfg,
755}
756
757/// What the audio module hands the decoder (and the parity tools).
758pub struct AudioOut {
759    /// log-mel `[frames · n_mel]`, frames padded to a multiple of 8
760    pub feats: Vec<f32>,
761    pub frames: usize,
762    pub valid_frames: usize,
763    /// ASR encoder states before `asr_norm` `[T · 1280]`
764    pub asr_hidden: Vec<f32>,
765    /// diarization logits `[frames · speakers]`
766    pub speaker_logits: Vec<f32>,
767    /// projected embeddings of the valid audio tokens `[tokens · hidden]`
768    pub embeds: Vec<f32>,
769    pub tokens: usize,
770}
771
772impl Audio {
773    pub fn load(m: &Arc<CmfModel>, audio_cfg: &serde_json::Value) -> Result<Self, String> {
774        let fb = tensor_f32(m, "perception.preprocessor.featurizer.fb")?;
775        let window = tensor_f32(m, "perception.preprocessor.featurizer.window")?;
776        let n_mel = fb.len() / (NFFT / 2 + 1);
777        let asr_cfg = &audio_cfg["asr_config"];
778        let dz = &audio_cfg["diarization_config"]["audio_config"];
779        let heads = |v: &serde_json::Value, d| {
780            v.get("num_attention_heads")
781                .and_then(|x| x.as_u64())
782                .unwrap_or(d) as usize
783        };
784        let rot = |v: &serde_json::Value, d| {
785            v.get("rope_parameters")
786                .and_then(|r| r.get("partial_rotary_factor"))
787                .and_then(|x| x.as_f64())
788                .unwrap_or(d)
789        };
790        let stack = asr_cfg
791            .get("subsampling_factor")
792            .and_then(|x| x.as_u64())
793            .unwrap_or(8) as usize;
794        let asr = Encoder::load(
795            m,
796            "perception.encoder.asr_encoder",
797            heads(asr_cfg, 10),
798            rot(asr_cfg, 0.5),
799            stack,
800        )?;
801        let diar = Encoder::load(
802            m,
803            "perception.encoder.diarization_model.encoder",
804            heads(dz, 8),
805            rot(dz, 1.0),
806            stack,
807        )?;
808        let sm = "perception.encoder.diarization_model.sortformer_modules";
809        let spk = SpkCfg::from_config(audio_cfg);
810        let d = asr.d;
811        // fixed sinusoidal speaker table [n_spk · d]
812        let mut spk_table = vec![0.0f32; spk.n_spk * d];
813        for s in 0..spk.n_spk {
814            for i in 0..d / 2 {
815                let inv = ((2 * i) as f32 * -(10000f32.ln() / d as f32)).exp();
816                spk_table[s * d + 2 * i] = (s as f32 * inv).sin();
817                spk_table[s * d + 2 * i + 1] = (s as f32 * inv).cos();
818            }
819        }
820        Ok(Self {
821            fb,
822            window,
823            n_mel,
824            asr,
825            diar,
826            diar_proj: Lin::load(m, &format!("{sm}.encoder_proj"), true)?,
827            // conv taps [cout, cin, 3] → GEMM layout [cout, 3·cin]
828            up_w: {
829                let name = format!("{sm}.subpixel_upsample.weight");
830                let w = tensor_f32(m, &name)?;
831                let shape = &m.tensor(&name).ok_or("upsampler")?.shape;
832                if shape.len() != 3 || shape[2] != 3 {
833                    return Err(format!("{name}: shape {shape:?}, expected [cout, cin, 3]"));
834                }
835                let (cout, cin) = (shape[0], shape[1]);
836                let mut g = vec![0.0f32; w.len()];
837                for o in 0..cout {
838                    for c in 0..cin {
839                        for j in 0..3 {
840                            g[o * 3 * cin + j * cin + c] = w[(o * cin + c) * 3 + j];
841                        }
842                    }
843                }
844                g
845            },
846            up_b: tensor_f32(m, &format!("{sm}.subpixel_upsample.bias"))?,
847            cls_dense: Lin::load(m, &format!("{sm}.first_hidden_to_hidden"), true)?,
848            cls_out: Lin::load(m, &format!("{sm}.single_hidden_to_spks"), true)?,
849            silence: tensor_f32(m, &format!("{sm}.learnable_sil_emb"))?,
850            asr_norm: Ln::load(m, "perception.encoder.asr_norm")?,
851            spk_norm: Ln::load(m, "perception.encoder.diar_norm")?,
852            spk_table,
853            proj: Lin::load(m, "perception.proj", true)?,
854            spk,
855        })
856    }
857
858    /// Log-mel features, time-major `[frames · n_mel]` padded to a
859    /// multiple of the stacking factor, and the number of valid frames.
860    pub fn log_mel(&self, x: &[f32]) -> (Vec<f32>, usize, usize) {
861        let len = x.len();
862        let mut y = vec![0.0f32; len];
863        if len > 0 {
864            y[0] = x[0];
865            for i in 1..len {
866                y[i] = x[i] - 0.97 * x[i - 1];
867            }
868        }
869        let half = NFFT / 2;
870        let mut padded = vec![0.0f32; len + NFFT];
871        padded[half..half + len].copy_from_slice(&y);
872        let n_frames = 1 + len / HOP;
873        let valid = len / HOP;
874        let frames = n_frames.div_ceil(self.asr.stack) * self.asr.stack;
875        let wl = self.window.len();
876        let off = (NFFT - wl) / 2;
877        let bins = half + 1;
878        let mut feats = vec![0.0f32; frames * self.n_mel];
879        let fft = FftPlanner::<f64>::new().plan_fft_forward(NFFT);
880        let mut buf = vec![Complex::new(0.0f64, 0.0); NFFT];
881        let mut power = vec![0.0f32; bins];
882        for t in 0..valid {
883            for (i, c) in buf.iter_mut().enumerate() {
884                let w = if i >= off && i < off + wl {
885                    self.window[i - off]
886                } else {
887                    0.0
888                };
889                *c = Complex::new((padded[t * HOP + i] * w) as f64, 0.0);
890            }
891            fft.process(&mut buf);
892            for k in 0..bins {
893                let (re, im) = (buf[k].re as f32, buf[k].im as f32);
894                let mag = (re * re + im * im).sqrt();
895                power[k] = mag * mag;
896            }
897            let row = &mut feats[t * self.n_mel..(t + 1) * self.n_mel];
898            for (mi, v) in row.iter_mut().enumerate() {
899                let f = &self.fb[mi * bins..(mi + 1) * bins];
900                *v = (dot(f, &power) + LOG_GUARD).ln();
901            }
902        }
903        (feats, frames, valid)
904    }
905
906    /// Per-feature normalization over the valid frames (unbiased variance,
907    /// epsilon on the standard deviation); padding frames stay zero.
908    fn normalize(&self, feats: &[f32], frames: usize, valid: usize) -> Vec<f32> {
909        let nm = self.n_mel;
910        let mut out = vec![0.0f32; frames * nm];
911        for m in 0..nm {
912            let mean = (0..valid).map(|t| feats[t * nm + m]).sum::<f32>() / valid.max(1) as f32;
913            let var = (0..valid)
914                .map(|t| (feats[t * nm + m] - mean).powi(2))
915                .sum::<f32>()
916                / (valid.max(2) - 1) as f32;
917            let std = var.sqrt() + 1e-5;
918            for t in 0..valid {
919                out[t * nm + m] = (feats[t * nm + m] - mean) / std;
920            }
921        }
922        out
923    }
924
925    /// `_get_chunks`: (context range, chunk range within the context).
926    fn chunks(&self, n: usize) -> Vec<((usize, usize), (usize, usize))> {
927        let c = &self.spk;
928        let mut out = Vec::new();
929        let mut start = 0;
930        while start < n {
931            let end = (start + c.chunk).min(n);
932            let cs = start.saturating_sub(c.left);
933            let ce = (end + c.right).min(n);
934            out.push(((cs, ce), (start - cs, end - cs)));
935            start += c.chunk;
936        }
937        out
938    }
939
940    /// Diarization logits `[frames · n_spk]` (offline mode).
941    fn diarize(&self, feats: &[f32], frames: usize, valid: usize, pool: Option<&Pool>) -> Vec<f32> {
942        let c = &self.spk;
943        let ns = c.n_spk;
944        let sub = self.diar.stack;
945        let d = self.diar.d;
946        let embeds = self.diar.embed(feats, frames, pool);
947        let n_emb = frames / sub;
948        let emask: Vec<bool> = (0..n_emb).map(|t| t * sub < valid).collect();
949        let mut cache = SpkCache {
950            embeds: Vec::new(),
951            probs: Vec::new(),
952            fifo: Vec::new(),
953            compressed: false,
954        };
955        let mut logits_all = Vec::with_capacity(frames * ns);
956        let mut start = 0;
957        while start < n_emb {
958            let end = (start + c.chunk).min(n_emb);
959            let n_chunk = end - start;
960            let cs = start.saturating_sub(c.left);
961            let lookback = start - cs;
962            let ce = (end + c.right).min(n_emb);
963            let mut input = cache.embeds.clone();
964            input.extend_from_slice(&cache.fifo);
965            let cached = input.len() / d;
966            input.extend_from_slice(&embeds[cs * d..ce * d]);
967            let n_in = input.len() / d;
968            let mut mask = vec![true; cached];
969            mask.extend_from_slice(&emask[cs..ce]);
970            let h = self.diar.encode_rows(&input, n_in, &mask, pool);
971            let h = self.diar_proj.apply(&h, n_in, pool);
972            let hu = self.upsample(&h, n_in, pool);
973            // classifier: dense(relu(h)) → out(relu(·))
974            let mut a: Vec<f32> = hu.iter().map(|v| v.max(0.0)).collect();
975            a = self.cls_dense.apply(&a, n_in * sub, pool);
976            for v in a.iter_mut() {
977                *v = v.max(0.0);
978            }
979            let logits = self.cls_out.apply(&a, n_in * sub, pool);
980            cache.update(
981                c,
982                d,
983                &input,
984                &logits,
985                &mask,
986                &self.silence,
987                n_chunk,
988                lookback,
989            );
990            let s0 = (cached + lookback) * sub;
991            logits_all.extend_from_slice(&logits[s0 * ns..(s0 + n_chunk * sub) * ns]);
992            start += c.chunk;
993        }
994        logits_all.truncate(frames * ns);
995        logits_all
996    }
997
998    /// Sub-pixel upsampler: conv1d (k3, pad 1) to `stack ×` channels, then
999    /// every encoder frame becomes `stack` frames.
1000    fn upsample(&self, h: &[f32], n: usize, pool: Option<&Pool>) -> Vec<f32> {
1001        // as a GEMM: row t = [h(t−1) | h(t) | h(t+1)] (zero padded) against
1002        // the taps laid out `[cout, 3·cin]` (`up_w` at load)
1003        let cin = self.diar_proj.out;
1004        let cout = self.up_b.len();
1005        let mut x3 = vec![0.0f32; n * 3 * cin];
1006        for t in 0..n {
1007            for j in 0..3 {
1008                let src = t as isize + j as isize - 1;
1009                if src >= 0 && src < n as isize {
1010                    let s = src as usize;
1011                    x3[t * 3 * cin + j * cin..t * 3 * cin + (j + 1) * cin]
1012                        .copy_from_slice(&h[s * cin..(s + 1) * cin]);
1013                }
1014            }
1015        }
1016        let mut y = vec![0.0f32; n * cout];
1017        gemm_nt(&x3, &self.up_w, &mut y, n, 3 * cin, cout, pool);
1018        for r in y.chunks_exact_mut(cout) {
1019            for (v, b) in r.iter_mut().zip(&self.up_b) {
1020                *v += b;
1021            }
1022        }
1023        y
1024    }
1025
1026    pub fn run(&self, samples: &[f32], pool: Option<&Pool>) -> AudioOut {
1027        let prof = std::env::var("CMF_NEMO_PROF").as_deref() == Ok("1");
1028        let t0 = Instant::now();
1029        let (feats, frames, valid) = self.log_mel(samples);
1030        let t_mel = t0.elapsed();
1031        let norm = self.normalize(&feats, frames, valid);
1032        let stack = self.asr.stack;
1033        let t_enc = frames / stack;
1034        let d = self.asr.d;
1035        let mut hidden = vec![0.0f32; t_enc * d];
1036        for ((cs, ce), (a, b)) in self.chunks(t_enc) {
1037            let valid_mask: Vec<bool> = (cs..ce).map(|t| t * stack < valid).collect();
1038            let h = self.asr.encode_frames(
1039                &norm[cs * stack * self.n_mel..ce * stack * self.n_mel],
1040                ce - cs,
1041                &valid_mask,
1042                pool,
1043            );
1044            hidden[(cs + a) * d..(cs + b) * d].copy_from_slice(&h[a * d..b * d]);
1045        }
1046        let t_asr = t0.elapsed();
1047        let speaker_logits = self.diarize(&feats, frames, valid, pool);
1048        let t_diar = t0.elapsed();
1049        let ns = self.spk.n_spk;
1050        let mut joined = hidden.clone();
1051        self.asr_norm.apply_rows(&mut joined);
1052        for t in 0..t_enc {
1053            let mut act = vec![0.0f32; ns];
1054            for (s, a) in act.iter_mut().enumerate() {
1055                let mut p = 0.0f32;
1056                for j in 0..stack {
1057                    p += sigmoid(speaker_logits[(t * stack + j) * ns + s]);
1058                }
1059                *a = if p / stack as f32 > self.spk.activity_thr {
1060                    1.0
1061                } else {
1062                    0.0
1063                };
1064            }
1065            self.spk_norm.apply(&mut act);
1066            let row = &mut joined[t * d..(t + 1) * d];
1067            for (s, &a) in act.iter().enumerate() {
1068                for (v, &e) in row.iter_mut().zip(&self.spk_table[s * d..(s + 1) * d]) {
1069                    *v += a * e;
1070                }
1071            }
1072        }
1073        let tokens = valid.div_ceil(stack);
1074        let mut embeds = self.proj.apply(&joined[..tokens * d], tokens, pool);
1075        for v in embeds.iter_mut() {
1076            *v = round_bf16(*v);
1077        }
1078        if prof {
1079            let ms = |d: std::time::Duration| d.as_secs_f64() * 1e3;
1080            eprintln!(
1081                "nemotron audio: {frames} frames, mel {:.1} ms, asr encoder {:.1} ms, diarization {:.1} ms, join+projector {:.1} ms",
1082                ms(t_mel),
1083                ms(t_asr - t_mel),
1084                ms(t_diar - t_asr),
1085                ms(t0.elapsed() - t_diar)
1086            );
1087        }
1088        AudioOut {
1089            feats,
1090            frames,
1091            valid_frames: valid,
1092            asr_hidden: hidden,
1093            speaker_logits,
1094            embeds,
1095            tokens,
1096        }
1097    }
1098}
1099
1100fn round_bf16(x: f32) -> f32 {
1101    let b = x.to_bits();
1102    let r = b.wrapping_add(0x7fff + ((b >> 16) & 1)) & 0xffff_0000;
1103    f32::from_bits(r)
1104}
1105
1106/// The full model: perception module + Nemotron-H decoder + tokenizer.
1107pub struct Transcriber {
1108    pub audio: Audio,
1109    pub llm: NemotronH,
1110    /// The word timestamp head, when the file carries it.
1111    pub words: Option<words::WordTimes>,
1112    pub tok: Tokenizer,
1113    pub audio_token: u32,
1114    pub eos: Vec<u32>,
1115    pub default_prompt: String,
1116}
1117
1118/// A transcription and its timings.
1119pub struct Transcript {
1120    pub text: String,
1121    pub ids: Vec<u32>,
1122    pub prompt_tokens: usize,
1123    pub audio_tokens: usize,
1124    pub audio_s: f64,
1125    pub encode_s: f64,
1126    pub prefill_s: f64,
1127    pub decode_s: f64,
1128    /// Word times, when asked for (seconds spent on them in `words_s`).
1129    pub words: Option<Result<Vec<words::WordTime>, String>>,
1130    pub words_s: f64,
1131}
1132
1133impl Transcriber {
1134    pub fn load(model: &Arc<CmfModel>) -> Result<Self, String> {
1135        if model.header.arch.arch_name != ARCH_NAME {
1136            return Err(format!(
1137                "{} is not a Nemotron 3.5 Transcribe CMF",
1138                model.path.display()
1139            ));
1140        }
1141        let prov = model
1142            .header
1143            .provenance
1144            .as_ref()
1145            .and_then(|p| p.get("nemotron_transcribe"))
1146            .ok_or("CMF is missing the nemotron_transcribe provenance")?;
1147        let mut llm = NemotronH::load(model, &prov["llm_config"])?;
1148        // the most used experts of each MoE layer first (a partial device
1149        // bank is filled in this order)
1150        if let Some(h) = prov.get("expert_hot").and_then(|v| v.as_array()) {
1151            llm.hot = h
1152                .iter()
1153                .map(|l| {
1154                    l.as_array()
1155                        .map(|a| a.iter().filter_map(|e| e.as_u64()).map(|e| e as usize).collect())
1156                        .unwrap_or_default()
1157                })
1158                .collect();
1159        }
1160        let audio = Audio::load(model, &prov["audio_config"])?;
1161        let words = words::WordTimes::load(model, prov)?;
1162        let vocab = model
1163            .vocab
1164            .as_deref()
1165            .ok_or("CMF has no embedded tokenizer.json")?;
1166        let tok = Tokenizer::from_bytes(vocab).map_err(|e| format!("tokenizer: {e}"))?;
1167        let audio_token = prov
1168            .get("audio_token_id")
1169            .and_then(|v| v.as_u64())
1170            .unwrap_or(131072) as u32;
1171        let mut eos: Vec<u32> = prov["generation_config"]["eos_token_id"]
1172            .as_array()
1173            .map(|a| {
1174                a.iter()
1175                    .filter_map(|v| v.as_u64())
1176                    .map(|v| v as u32)
1177                    .collect()
1178            })
1179            .unwrap_or_default();
1180        if let Some(id) = tok.token_to_id("<|im_end|>")
1181            && !eos.contains(&id)
1182        {
1183            eos.push(id);
1184        }
1185        // Build the device decode frame now (wgpu only), so the upload is
1186        // load time rather than the first transcription's decode time.
1187        let _ = llm.frame_seed(&llm.new_state());
1188        let default_prompt = prov["processor"]
1189            .get("default_transcription_prompt")
1190            .and_then(|v| v.as_str())
1191            .unwrap_or("Transcribe the following audio.")
1192            .to_string();
1193        Ok(Self {
1194            audio,
1195            llm,
1196            tok,
1197            audio_token,
1198            eos,
1199            default_prompt,
1200            words,
1201        })
1202    }
1203
1204    /// Token ids of the chat prompt with `n_audio` placeholders, as the
1205    /// reference chat template renders a user turn of `prompt` + audio
1206    /// (empty system turn, thinking off).
1207    pub fn prompt_ids(&self, prompt: &str, n_audio: usize, think: bool) -> Vec<u32> {
1208        let head = format!("<|im_start|>system\n<|im_end|>\n<|im_start|>user\n{prompt} ");
1209        let tail = if think {
1210            "<|im_end|>\n<|im_start|>assistant\n<think>\n"
1211        } else {
1212            "<|im_end|>\n<|im_start|>assistant\n<think></think>"
1213        };
1214        let mut ids = self.tok.encode(&head);
1215        ids.extend(std::iter::repeat_n(self.audio_token, n_audio));
1216        ids.extend(self.tok.encode(tail));
1217        ids
1218    }
1219
1220    /// Input rows for `ids`: token embeddings, audio rows at the placeholders.
1221    pub fn input_rows(&self, ids: &[u32], audio: &[f32]) -> Result<Vec<f32>, String> {
1222        let hd = self.llm.g.hidden;
1223        let mut rows = vec![0.0f32; ids.len() * hd];
1224        let mut ai = 0;
1225        for (i, &id) in ids.iter().enumerate() {
1226            let dst = &mut rows[i * hd..(i + 1) * hd];
1227            if id == self.audio_token {
1228                let src = audio
1229                    .get(ai * hd..(ai + 1) * hd)
1230                    .ok_or("more audio placeholders than audio tokens")?;
1231                dst.copy_from_slice(src);
1232                ai += 1;
1233            } else {
1234                self.llm.embed_row(id, dst);
1235            }
1236        }
1237        if ai * hd != audio.len() {
1238            return Err(format!(
1239                "{ai} audio placeholders for {} audio tokens",
1240                audio.len() / hd
1241            ));
1242        }
1243        Ok(rows)
1244    }
1245
1246    pub fn transcribe(
1247        &self,
1248        samples: &[f32],
1249        prompt: Option<&str>,
1250        max_new_tokens: usize,
1251        timestamps: bool,
1252        pool: Option<&Pool>,
1253        mut on_token: impl FnMut(&str),
1254    ) -> Result<Transcript, String> {
1255        let t0 = Instant::now();
1256        let a = self.audio.run(samples, pool);
1257        let encode_s = t0.elapsed().as_secs_f64();
1258        let ids = self.prompt_ids(prompt.unwrap_or(&self.default_prompt), a.tokens, false);
1259        let rows = self.input_rows(&ids, &a.embeds)?;
1260        // Greedy decode. On a wgpu device the decoder runs in the resident
1261        // frame: the prompt is prefilled there (batched passes), then
1262        // batches of greedy tokens ride one submit each and only their ids
1263        // come back. `CMF_NEMO_PREFILL=host` prefills on the host and hands
1264        // the state over; without a frame the host decodes.
1265        let host_prefill = std::env::var("CMF_NEMO_PREFILL").as_deref() == Ok("host");
1266        let t1 = Instant::now();
1267        let hd = self.llm.g.hidden;
1268        let n = rows.len() / hd;
1269        let mut dev_from: Option<Vec<f32>> = None;
1270        let mut host: Option<(super::nemotron_h::State, u32)> = None;
1271        if !host_prefill && max_new_tokens > 0 && self.llm.frame_prefill(&rows[..(n - 1) * hd], Some(&rows[(n - 1) * hd..]), pool) {
1272            dev_from = Some(rows[(n - 1) * hd..].to_vec());
1273        } else {
1274            let mut st = self.llm.new_state();
1275            let logits = self.llm.forward(&mut st, &rows, pool);
1276            let first = argmax(&logits[..self.llm.g.vocab.min(logits.len())]);
1277            if max_new_tokens > 1 && self.llm.frame_seed(&st) {
1278                let mut r = vec![0.0f32; hd];
1279                self.llm.embed_row(first, &mut r);
1280                dev_from = Some(r);
1281            }
1282            host = Some((st, first));
1283        }
1284        let prefill_s = t1.elapsed().as_secs_f64();
1285        let t2 = Instant::now();
1286        let mut out = Vec::new();
1287        let mut printed = 0usize;
1288        let emit = |tok: u32,
1289                    out: &mut Vec<u32>,
1290                    printed: &mut usize,
1291                    on_token: &mut dyn FnMut(&str)|
1292         -> bool {
1293            if self.eos.contains(&tok) || out.len() >= max_new_tokens {
1294                return false;
1295            }
1296            out.push(tok);
1297            let text = self.tok.decode(out);
1298            if text.len() > *printed && text.is_char_boundary(*printed) {
1299                on_token(&text[*printed..]);
1300                *printed = text.len();
1301            }
1302            true
1303        };
1304        let mut go = true;
1305        if let Some((_, first)) = &host {
1306            go = emit(*first, &mut out, &mut printed, &mut on_token);
1307        }
1308        if go {
1309            if let Some(row) = dev_from {
1310                let budget = max_new_tokens - out.len();
1311                self.llm.frame_decode(&row, budget, pool, |ids| {
1312                    ids.iter()
1313                        .all(|&t| emit(t, &mut out, &mut printed, &mut on_token))
1314                })?;
1315            } else if let Some((mut st, first)) = host {
1316                let mut row = vec![0.0f32; hd];
1317                let mut tok = first;
1318                loop {
1319                    self.llm.embed_row(tok, &mut row);
1320                    let logits = self.llm.forward(&mut st, &row, pool);
1321                    tok = argmax(&logits[..self.llm.g.vocab.min(logits.len())]);
1322                    if !emit(tok, &mut out, &mut printed, &mut on_token) {
1323                        break;
1324                    }
1325                }
1326            }
1327        }
1328        let decode_s = t2.elapsed().as_secs_f64();
1329        if std::env::var("CMF_NEMO_MTP_PROBE").as_deref() == Ok("1") && out.len() > 4 {
1330            self.mtp_probe(&rows, &ids, &out, pool);
1331        }
1332        let text = self.tok.decode(&out).trim().to_string();
1333        let t3 = Instant::now();
1334        let words = match (&self.words, timestamps) {
1335            (Some(w), true) => {
1336                let stack = self.audio.asr.stack;
1337                let t_enc = a.frames / stack;
1338                let valid: Vec<bool> = (0..t_enc).map(|t| t * stack < a.valid_frames).collect();
1339                let logits = w.logits(&a.asr_hidden, t_enc, &valid, &self.audio.chunks(t_enc), pool);
1340                // parity tooling: the head's logits as raw f32 `[frames · classes]`
1341                if let Ok(dir) = std::env::var("CMF_NEMO_WORDS_DUMP") {
1342                    let b: Vec<u8> = logits.iter().flat_map(|v| v.to_le_bytes()).collect();
1343                    let _ = std::fs::create_dir_all(&dir);
1344                    let _ = std::fs::write(Path::new(&dir).join(format!("logits-{}.f32", a.tokens)), b);
1345                }
1346                let ns = self.audio.spk.n_spk;
1347                Some(w.align(
1348                    &text,
1349                    &logits,
1350                    a.tokens,
1351                    a.valid_frames,
1352                    Some((&a.speaker_logits[..], ns)),
1353                ))
1354            }
1355            _ => None,
1356        };
1357        let words_s = t3.elapsed().as_secs_f64();
1358        Ok(Transcript {
1359            text,
1360            prompt_tokens: ids.len(),
1361            audio_tokens: a.tokens,
1362            ids: out,
1363            audio_s: samples.len() as f64 / SR as f64,
1364            encode_s,
1365            prefill_s,
1366            decode_s,
1367            words,
1368            words_s,
1369        })
1370    }
1371}
1372
1373impl Transcriber {
1374    /// `CMF_NEMO_MTP_PROBE=1`: teacher-forced acceptance of the draft
1375    /// module on this transcript, for every reading of its inputs (the
1376    /// hidden state before or after the final norm, the concatenation
1377    /// order), depths 1 and 2.
1378    fn mtp_probe(&self, rows: &[f32], ids: &[u32], gen_ids: &[u32], pool: Option<&Pool>) {
1379        let llm = &self.llm;
1380        if llm.mtp.is_none() {
1381            eprintln!("mtp probe: no draft module in this file");
1382            return;
1383        }
1384        let hd = llm.g.hidden;
1385        let mut all = rows.to_vec();
1386        let mut r = vec![0.0f32; hd];
1387        for &t in gen_ids {
1388            llm.embed_row(t, &mut r);
1389            all.extend_from_slice(&r);
1390        }
1391        let toks: Vec<u32> = ids.iter().chain(gen_ids).copied().collect();
1392        let n = toks.len();
1393        let np = ids.len();
1394        let mut st = llm.new_state();
1395        let h = llm.forward_h(&mut st, &all, pool);
1396        let mut hn = vec![0.0f32; h.len()];
1397        for (s, d) in h.chunks_exact(hd).zip(hn.chunks_exact_mut(hd)) {
1398            super::nemotron_h::rms_norm(s, &llm.final_norm, llm.g.eps, d);
1399        }
1400        // the main head on the generated span, as a sanity check
1401        let main = llm.head_argmax(&hn[(np - 1) * hd..(n - 1) * hd], pool);
1402        let ok = main.iter().zip(&toks[np..]).filter(|(a, b)| a == b).count();
1403        eprintln!("mtp probe: main head {ok}/{} of the transcript", main.len());
1404        for (name, src, h_first) in [
1405            ("pre-norm h, [e|h]", &h, false),
1406            ("post-norm h, [e|h]", &hn, false),
1407            ("pre-norm h, [h|e]", &h, true),
1408            ("post-norm h, [h|e]", &hn, true),
1409        ] {
1410            let mut kv = (Vec::new(), Vec::new());
1411            let Some(o1) = llm.mtp_forward(&mut kv, 0, &src[..(n - 1) * hd], &all[hd..], h_first, pool)
1412            else {
1413                return;
1414            };
1415            // depth 1 at t predicts toks[t + 2]: t over the generated span
1416            let a1 = llm.head_argmax(&o1[(np - 1) * hd..(n - 2) * hd], pool);
1417            let ok1 = a1.iter().zip(&toks[np + 1..]).filter(|(a, b)| a == b).count();
1418            let mut kv2 = (Vec::new(), Vec::new());
1419            let o2 = llm
1420                .mtp_forward(&mut kv2, 0, &o1[..(n - 2) * hd], &all[2 * hd..], h_first, pool)
1421                .unwrap_or_default();
1422            let a2 = llm.head_argmax(&o2[(np - 1) * hd..(n - 3) * hd], pool);
1423            let ok2 = a2.iter().zip(&toks[np + 2..]).filter(|(a, b)| a == b).count();
1424            eprintln!(
1425                "mtp probe {name}: depth 1 {ok1}/{} ({:.1}%), depth 2 {ok2}/{} ({:.1}%)",
1426                a1.len(),
1427                100.0 * ok1 as f64 / a1.len().max(1) as f64,
1428                a2.len(),
1429                100.0 * ok2 as f64 / a2.len().max(1) as f64
1430            );
1431            // the same reading with the draft's attention over the generated
1432            // span only, and over the position itself only
1433            let span = (n - 2) - (np - 1);
1434            let mut kv = (Vec::new(), Vec::new());
1435            let o = llm
1436                .mtp_forward(&mut kv, 0, &src[(np - 1) * hd..(n - 2) * hd], &all[np * hd..(n - 1) * hd], h_first, pool)
1437                .unwrap_or_default();
1438            let a = llm.head_argmax(&o, pool);
1439            let ok_g = a.iter().zip(&toks[np + 1..]).filter(|(a, b)| a == b).count();
1440            let mut ok_s = 0;
1441            for t in 0..span {
1442                let p = np - 1 + t;
1443                let mut kv = (Vec::new(), Vec::new());
1444                let o = llm
1445                    .mtp_forward(&mut kv, 0, &src[p * hd..(p + 1) * hd], &all[(p + 1) * hd..(p + 2) * hd], h_first, pool)
1446                    .unwrap_or_default();
1447                ok_s += (llm.head_argmax(&o, pool)[0] == toks[p + 2]) as usize;
1448            }
1449            eprintln!(
1450                "mtp probe {name}: depth 1 with generated-span history {ok_g}/{span}, without history {ok_s}/{span}"
1451            );
1452        }
1453    }
1454
1455    /// Parity dump against the reference: writes the audio-module tensors,
1456    /// the prompt ids and the teacher-forced logits of `forced` (the
1457    /// reference transcript ids) into `dir` as raw little-endian f32 / u32
1458    /// files plus `meta.json` with the shapes.
1459    pub fn dump_parity(
1460        &self,
1461        samples: &[f32],
1462        prompt: Option<&str>,
1463        forced: &[u32],
1464        dir: &Path,
1465        pool: Option<&Pool>,
1466    ) -> Result<(), String> {
1467        std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
1468        let put = |name: &str, v: &[f32]| -> Result<(), String> {
1469            let b: Vec<u8> = v.iter().flat_map(|x| x.to_le_bytes()).collect();
1470            std::fs::write(dir.join(name), b).map_err(|e| format!("{name}: {e}"))
1471        };
1472        let a = self.audio.run(samples, pool);
1473        let ids = self.prompt_ids(prompt.unwrap_or(&self.default_prompt), a.tokens, false);
1474        put("feats.f32", &a.feats)?;
1475        put("asr_hidden.f32", &a.asr_hidden)?;
1476        put("speaker_logits.f32", &a.speaker_logits)?;
1477        put("embeds.f32", &a.embeds)?;
1478        let idb: Vec<u8> = ids.iter().flat_map(|x| x.to_le_bytes()).collect();
1479        std::fs::write(dir.join("input_ids.u32"), idb).map_err(|e| e.to_string())?;
1480        if forced.is_empty() {
1481            let meta = serde_json::json!({
1482                "frames": a.frames, "valid_frames": a.valid_frames, "n_mel": self.audio.n_mel,
1483                "enc_frames": a.asr_hidden.len() / self.audio.asr.d, "enc_d": self.audio.asr.d,
1484                "tokens": a.tokens, "hidden": self.llm.g.hidden, "prompt_len": ids.len(), "forced": 0,
1485            });
1486            return std::fs::write(dir.join("meta.json"), meta.to_string())
1487                .map_err(|e| e.to_string());
1488        }
1489        let rows = self.input_rows(&ids, &a.embeds)?;
1490        let mut st = self.llm.new_state();
1491        let t0 = Instant::now();
1492        let mut logits = self.llm.forward(&mut st, &rows, pool);
1493        let prefill_s = t0.elapsed().as_secs_f64();
1494        let vocab = self.llm.g.vocab;
1495        let mut all = Vec::with_capacity(forced.len() * vocab);
1496        let mut row = vec![0.0f32; self.llm.g.hidden];
1497        let t1 = Instant::now();
1498        for (i, &id) in forced.iter().enumerate() {
1499            all.extend_from_slice(&logits[..vocab]);
1500            if i + 1 < forced.len() {
1501                self.llm.embed_row(id, &mut row);
1502                logits = self.llm.forward(&mut st, &row, pool);
1503            }
1504        }
1505        let decode_s = t1.elapsed().as_secs_f64();
1506        put("logits.f32", &all)?;
1507        let meta = serde_json::json!({
1508            "frames": a.frames, "valid_frames": a.valid_frames, "n_mel": self.audio.n_mel,
1509            "enc_frames": a.asr_hidden.len() / self.audio.asr.d, "enc_d": self.audio.asr.d,
1510            "tokens": a.tokens, "hidden": self.llm.g.hidden, "prompt_len": ids.len(),
1511            "forced": forced.len(), "vocab": vocab, "prefill_s": prefill_s, "decode_s": decode_s,
1512        });
1513        std::fs::write(dir.join("meta.json"), meta.to_string()).map_err(|e| e.to_string())
1514    }
1515}
1516
1517fn argmax(x: &[f32]) -> u32 {
1518    let mut best = 0;
1519    for i in 1..x.len() {
1520        if x[i] > x[best] {
1521            best = i;
1522        }
1523    }
1524    best as u32
1525}
1526
1527/// Read a WAV file as 16 kHz mono f32 (resampled when needed).
1528pub fn read_audio(path: &Path) -> Result<Vec<f32>, String> {
1529    let (mono, rate) = crate::whisper::read_wav_samples(path, false)?;
1530    Ok(if rate == SR {
1531        mono
1532    } else {
1533        crate::whisper::resample_sinc(&mono, rate, SR)
1534    })
1535}
1536
1537#[cfg(test)]
1538mod tests {
1539    use super::*;
1540
1541    #[test]
1542    fn rope_rotates_the_first_half_pairs() {
1543        let (c, s) = rope_table(3, 4);
1544        let mut x = [1.0f32, 2.0, 3.0, 4.0, 5.0];
1545        rope(&mut x, &c[2 * 2..], &s[2 * 2..], 4);
1546        // position 2, pair (0,2) at θ=2, pair (1,3) at θ=2/100
1547        let (a0, a1) = (2.0f32, 2.0f32 / 100.0);
1548        assert!((x[0] - (1.0 * a0.cos() - 3.0 * a0.sin())).abs() < 1e-6);
1549        assert!((x[2] - (3.0 * a0.cos() + 1.0 * a0.sin())).abs() < 1e-6);
1550        assert!((x[1] - (2.0 * a1.cos() - 4.0 * a1.sin())).abs() < 1e-6);
1551        assert_eq!(x[4], 5.0);
1552    }
1553
1554    #[test]
1555    fn bf16_rounding_is_nearest_even() {
1556        assert_eq!(round_bf16(1.0), 1.0);
1557        let x = f32::from_bits(0x3f80_8000); // halfway, even below
1558        assert_eq!(round_bf16(x).to_bits(), 0x3f80_0000);
1559        let y = f32::from_bits(0x3f81_8000); // halfway, odd below
1560        assert_eq!(round_bf16(y).to_bits(), 0x3f82_0000);
1561    }
1562
1563    fn cfg() -> SpkCfg {
1564        SpkCfg {
1565            chunk: 500,
1566            left: 50,
1567            right: 50,
1568            fifo_len: 4,
1569            update_period: 3,
1570            cache_len: 16,
1571            n_spk: 2,
1572            sil_frames: 1,
1573            pred_thr: 0.25,
1574            latest_boost: 0.05,
1575            min_pos: 3,
1576            n_strong: 5,
1577            n_weak: 10,
1578            activity_thr: 0.5,
1579        }
1580    }
1581
1582    #[test]
1583    fn compress_keeps_cache_length_with_silence_slots() {
1584        let c = cfg();
1585        let d = 3;
1586        let n = 20;
1587        let embeds: Vec<f32> = (0..n * d).map(|i| i as f32).collect();
1588        let probs: Vec<f32> = (0..n)
1589            .flat_map(|t| if t < 10 { [0.9f32, 0.1] } else { [0.2, 0.8] })
1590            .collect();
1591        let sil = [-1.0f32; 3];
1592        let (e, p) = compress(&c, d, &embeds, &probs, &sil);
1593        assert_eq!(e.len(), c.cache_len * d);
1594        assert_eq!(p.len(), c.cache_len * c.n_spk);
1595        // two silence slots (one per speaker) ride at the end of each speaker's block
1596        assert_eq!(e.chunks_exact(d).filter(|r| r[0] == -1.0).count(), 2);
1597    }
1598
1599    #[test]
1600    fn fifo_pops_at_least_the_update_period() {
1601        let c = cfg();
1602        let d = 1;
1603        let mut cache = SpkCache {
1604            embeds: vec![],
1605            probs: vec![],
1606            fifo: vec![],
1607            compressed: false,
1608        };
1609        let input: Vec<f32> = (0..6).map(|i| i as f32).collect();
1610        let logits = vec![0.0f32; 6 * 2 * 2];
1611        let mask = vec![true; 6];
1612        cache.update(&c, d, &input, &logits, &mask, &[0.0], 6, 0);
1613        // 6 frames > fifo 4: pop max(3, 2) = 3 into the cache
1614        assert_eq!(cache.embeds, vec![0.0, 1.0, 2.0]);
1615        assert_eq!(cache.fifo, vec![3.0, 4.0, 5.0]);
1616    }
1617}