Skip to main content

cortiq_engine/
egemma2_audio.rs

1//! EmbeddingGemma 2 audio: the `gemma4_audio` tower and the `<|audio|>`
2//! path into the shared 768-d space, read from the same `.cmf` as the text
3//! encoder (`audio_tower.*`, `embed_audio.*`).
4//!
5//! The chain reproduces transformers' `Gemma4AudioFeatureExtractor`,
6//! `Gemma4AudioModel` and `EmbeddingGemma2MultimodalEmbedder`:
7//!
8//! 1. **Waveform.** Mono f32 at 16 kHz ([`decode_audio`] mixes channels down
9//!    and resamples other rates; WAV is decoded natively, anything else goes
10//!    through `ffmpeg` when it is installed). Clips are cut at 30 s
11//!    (480 000 samples), as the feature extractor's default truncation does.
12//! 2. **Log-mel.** 160 zeros in front ("semicausal"), 320-sample frames every
13//!    160 samples, periodic Hann window, |rfft| at 512 points, 128 HTK mel
14//!    bands over 0–8 kHz (no filter norm), `ln(mel + 0.001)`. A frame is
15//!    kept only while its window lies inside the clip, so a clip of `N`
16//!    samples gives `⌈(N − 160)/160⌉` frames; the reference pads the batch
17//!    to a multiple of 128 samples and masks those frames out, which is the
18//!    same as never computing them.
19//! 3. **Subsampling.** Two 3×3 stride-2 convolutions (1→128→32 channels,
20//!    zero padding 1, no bias), each followed by a bias-free LayerNorm over
21//!    the channels and ReLU; the `[T/4, 32 freq, 32 ch]` grid flattens
22//!    (frequency-major) to 1024 and is projected 1024→1024. `⌈frames/4⌉`
23//!    soft tokens: 25 per second.
24//! 4. **12 Conformer layers** (width 1024, 8 heads of 128), each
25//!    `x = FFN₁(x)`, chunked local attention, light conv, `FFN₂`, `RMSNorm`:
26//!    * FFN: `x + ½·N(W₂ silu(W₁ N(x)))`;
27//!    * attention: a token sees itself and the 11 tokens before it (none
28//!      after); `q·(log2 e)/√128·softplus(per_dim_scale)`, `k·log2(1+e)`,
29//!      logits `q·k + q·R[d]` with `R[d]` the relative key of distance `d`
30//!      (a sinusoid through `relative_k_proj`), soft-capped at 50 by tanh;
31//!      `x + N(W_post attn)`;
32//!    * light conv: `x + W_end silu(N(dwconv₅(glu(W_start N(x)))))`, the
33//!      depthwise convolution causal (4 taps back);
34//!    * every projection but `relative_k_proj` clamps its input and output
35//!      to the trained bounds the checkpoint stores (`input_min/max`,
36//!      `output_min/max`).
37//!
38//!    Every RMS-norm multiplies by its weight plainly (not `1 + w`).
39//! 5. **Into the text space.** `W_out x + b` (1024→1536), a weightless
40//!    RMS-norm, then `embed_audio` 1536→512: one row per `<|audio|>`
41//!    placeholder, written into the token sequence unscaled — the text
42//!    encoder ([`EmbeddingGemma2::embed_merged`]) does the rest.
43//!
44//! The sequence of an audio-only input is `[BOS] <|audio> <|audio|>×n
45//! <audio|> [EOS]` (no task prompt: media take none). An input with text
46//! carries one `<|audio|>` per clip, each expanded the same way in place.
47//!
48//! Checked against the float32 reference in `tests/egemma2_audio_parity.rs`.
49
50use crate::egemma2::{EmbeddingGemma2, Mat, TextInput, rms_into, rms_rows, vecf};
51use crate::ltxdit::{Shared, rows};
52use crate::pool::Pool;
53use cortiq_core::CmfModel;
54use std::sync::{Arc, Mutex};
55
56/// The rate every clip is brought to.
57pub const SAMPLE_RATE: u32 = 16_000;
58/// The feature extractor's default truncation: 30 s.
59pub const MAX_SAMPLES: usize = 480_000;
60/// Analysis window and hop, in samples (20 ms / 10 ms).
61pub const FRAME: usize = 320;
62pub const HOP: usize = 160;
63/// FFT size (the frame is zero-padded to it).
64pub const N_FFT: usize = 512;
65/// Mel bands.
66pub const N_MELS: usize = 128;
67/// Added before the log.
68pub const MEL_FLOOR: f64 = 1e-3;
69/// The text placeholder of one clip.
70pub const PLACEHOLDER: &str = "<|audio|>";
71/// Token ids of the release (`config.json`): `audio_token_id`,
72/// `boa_token_id`, `eoa_token_index`.
73pub const AUDIO_TOKEN: u32 = 258_881;
74pub const BOA_TOKEN: u32 = 256_000;
75pub const EOA_TOKEN: u32 = 258_883;
76
77const EPS: f64 = 1e-6;
78/// Soft tokens per packed tower forward.
79const PACK_SOFT: usize = 4096;
80
81/// `CMF_EGEMMA2_PROF=1`: the time split of every audio embedding call on
82/// stderr (front end, subsampling, projections, attention core, text).
83mod prof {
84    use std::sync::atomic::{AtomicU64, Ordering};
85    pub static MEL: AtomicU64 = AtomicU64::new(0);
86    pub static SUB: AtomicU64 = AtomicU64::new(0);
87    pub static LIN: AtomicU64 = AtomicU64::new(0);
88    pub static ATT: AtomicU64 = AtomicU64::new(0);
89    pub fn on() -> bool {
90        static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
91        *ON.get_or_init(|| std::env::var("CMF_EGEMMA2_PROF").is_ok_and(|v| v == "1"))
92    }
93    pub fn add(c: &AtomicU64, t: std::time::Instant) {
94        c.fetch_add(t.elapsed().as_micros() as u64, Ordering::Relaxed);
95    }
96    pub fn take(c: &AtomicU64) -> f64 {
97        c.swap(0, Ordering::Relaxed) as f64 / 1e3
98    }
99}
100
101// ───────────────────────────── sizes ─────────────────────────────
102
103/// Mel frames of a clip of `n` samples (after the 30 s cut).
104pub fn num_frames(n: usize) -> usize {
105    let n = n.min(MAX_SAMPLES);
106    if n <= HOP { 0 } else { (n - HOP).div_ceil(HOP) }
107}
108
109/// Soft tokens of a clip of `n` samples: two stride-2 convolutions over the
110/// frames. 25 per second, at most 750.
111pub fn num_tokens(n: usize) -> usize {
112    num_frames(n).div_ceil(2).div_ceil(2)
113}
114
115// ───────────────────────────── decoding ─────────────────────────────
116
117/// Mono f32 at 16 kHz from an audio file's bytes. WAV (PCM 8/16/24/32,
118/// float 32/64, any channel count and rate) is decoded here: channels are
119/// averaged, then a rate other than 16 kHz goes through [`resample_hq`].
120/// Other containers (mp3, flac, ogg, m4a, …) are decoded by `ffmpeg` to
121/// float WAV at their own rate and come through the same path.
122pub fn decode_audio(bytes: &[u8]) -> Result<Vec<f32>, String> {
123    let wav = if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WAVE" {
124        crate::mimo_audio::decode_wav(bytes)?
125    } else {
126        crate::mimo_audio::decode_wav(&ffmpeg_to_wav(bytes)?)?
127    };
128    Ok(to_mono_16k(&wav))
129}
130
131/// Read and decode an audio file (see [`decode_audio`]).
132pub fn read_audio(path: &std::path::Path) -> Result<Vec<f32>, String> {
133    let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
134    decode_audio(&bytes).map_err(|e| format!("{}: {e}", path.display()))
135}
136
137/// Mono 16 kHz from a request's audio source: a string (a `data:` URL, an
138/// `http(s)://` URL, or a local path, `file://` optional), or an object
139/// `{"data": <base64>, "format"?}`, `{"url": …}`, `{"path": …}`, or the
140/// OpenAI chat shape `{"input_audio": {"data", "format"}}`.
141pub fn load_audio_source(v: &serde_json::Value) -> Result<Vec<f32>, String> {
142    use serde_json::{Value, json};
143    let record = match v {
144        Value::String(s) => json!({ "url": s }),
145        Value::Object(m) => {
146            if let Some(inner) = m.get("input_audio") {
147                return load_audio_source(inner);
148            }
149            if let Some(p) = m.get("path").and_then(Value::as_str) {
150                json!({ "url": p })
151            } else {
152                v.clone()
153            }
154        }
155        _ => return Err("an audio source must be a string or an object".into()),
156    };
157    let bytes = crate::media::load_image_bytes(&record).map_err(|e| e.replace("image", "audio"))?;
158    decode_audio(&bytes)
159}
160
161/// Average the channels, then resample to 16 kHz.
162pub fn to_mono_16k(wav: &crate::mimo_audio::Wav) -> Vec<f32> {
163    let n = wav.frames();
164    let mono: Vec<f32> = match wav.channels.len() {
165        0 => Vec::new(),
166        1 => wav.channels[0].clone(),
167        c => (0..n)
168            .map(|i| wav.channels.iter().map(|ch| ch[i]).sum::<f32>() / c as f32)
169            .collect(),
170    };
171    resample_hq(&mono, wav.sample_rate, SAMPLE_RATE)
172}
173
174/// Zero crossings of the sinc on each side, cut-off as a fraction of the
175/// lower Nyquist, and the Kaiser β of [`resample_hq`].
176const RS_WIDTH: f64 = 64.0;
177const RS_ROLLOFF: f64 = 0.958;
178const RS_BETA: f64 = 12.8;
179/// Above this many table entries the taps are computed on the fly.
180const RS_TABLE_MAX: usize = 1 << 22;
181
182/// Modified Bessel function of the first kind, order 0 (power series).
183fn bessel_i0(x: f64) -> f64 {
184    let q = x * x / 4.0;
185    let (mut term, mut sum) = (1.0f64, 1.0f64);
186    for k in 1..200 {
187        term *= q / (k * k) as f64;
188        sum += term;
189        if term < sum * 1e-17 {
190            break;
191        }
192    }
193    sum
194}
195
196/// Band-limited resampling: a polyphase Kaiser-windowed sinc (64 zero
197/// crossings a side, β 12.8, cut-off at 0.958 of the lower Nyquist),
198/// linear phase, `⌈to·N/from⌉` samples, f64 accumulation.
199///
200/// It is shaped after the resampler the reference ecosystem actually uses —
201/// librosa's default `soxr_hq`, which `transformers.audio_utils.load_audio`
202/// calls — and lands within 0.002 (8 kHz) / 0.0006 (44.1 and 48 kHz) mean
203/// absolute log-mel of it, against 0.35 / 0.013 for torchaudio's default
204/// Hann sinc (width 6), whose images leak into the empty band of an
205/// upsampled clip where the log-mel floor makes every bit of energy count.
206pub fn resample_hq(x: &[f32], from: u32, to: u32) -> Vec<f32> {
207    if from == to || x.is_empty() {
208        return x.to_vec();
209    }
210    let g = {
211        let (mut a, mut b) = (from as u64, to as u64);
212        while b != 0 {
213            (a, b) = (b, a % b);
214        }
215        a
216    };
217    let (o, nw) = ((from as u64 / g) as usize, (to as u64 / g) as usize);
218    let len = x.len();
219    let target = ((nw as u128 * len as u128).div_ceil(o as u128)) as usize;
220    let base = o.min(nw) as f64 * RS_ROLLOFF;
221    let w = (RS_WIDTH * o as f64 / base).ceil() as usize;
222    let taps = 2 * w + o;
223    let i0b = bessel_i0(RS_BETA);
224    let scale = base / o as f64;
225    let coef = |j: usize, i: usize| -> f64 {
226        let t = (-(j as f64) / nw as f64 + (i as f64 - w as f64) / o as f64) * base;
227        if t.abs() >= RS_WIDTH {
228            return 0.0;
229        }
230        let r = t / RS_WIDTH;
231        let win = bessel_i0(RS_BETA * (1.0 - r * r).sqrt()) / i0b;
232        let pt = t * std::f64::consts::PI;
233        let sinc = if pt == 0.0 { 1.0 } else { pt.sin() / pt };
234        sinc * win * scale
235    };
236    // per phase: the span of taps inside the window, and their values
237    let table = nw * taps <= RS_TABLE_MAX;
238    let span = |j: usize| -> (usize, usize) {
239        let c = w as f64 + o as f64 * j as f64 / nw as f64;
240        let h = RS_WIDTH * o as f64 / base;
241        let lo = (c - h).floor().max(0.0) as usize;
242        let hi = ((c + h).ceil() as usize + 1).min(taps);
243        (lo, hi)
244    };
245    let rows: Vec<(usize, Vec<f64>)> = if table {
246        (0..nw)
247            .map(|j| {
248                let (lo, hi) = span(j);
249                (lo, (lo..hi).map(|i| coef(j, i)).collect())
250            })
251            .collect()
252    } else {
253        Vec::new()
254    };
255    let mut out = vec![0f32; target];
256    let threads = std::thread::available_parallelism()
257        .map(|n| n.get())
258        .unwrap_or(1)
259        .clamp(1, 8);
260    let chunk = target.div_ceil(threads).max(4096);
261    std::thread::scope(|sc| {
262        for (ci, dst) in out.chunks_mut(chunk).enumerate() {
263            let (rows, coef, span) = (&rows, &coef, &span);
264            sc.spawn(move || {
265                let mut tmp = Vec::new();
266                for (k, y) in dst.iter_mut().enumerate() {
267                    let n_out = ci * chunk + k;
268                    let (f, j) = (n_out / nw, n_out % nw);
269                    let (lo, ks): (usize, &[f64]) = if table {
270                        (rows[j].0, &rows[j].1)
271                    } else {
272                        let (lo, hi) = span(j);
273                        tmp.clear();
274                        tmp.extend((lo..hi).map(|i| coef(j, i)));
275                        (lo, &tmp)
276                    };
277                    // padded index p = f·o + i holds x[p − w]
278                    let p0 = (f * o + lo) as isize - w as isize;
279                    let mut acc = 0f64;
280                    for (m, &kv) in ks.iter().enumerate() {
281                        let p = p0 + m as isize;
282                        if p >= 0 && (p as usize) < len {
283                            acc += kv * x[p as usize] as f64;
284                        }
285                    }
286                    *y = acc as f32;
287                }
288            });
289        }
290    });
291    out
292}
293
294/// Any container `ffmpeg` reads → 32-bit float WAV at the source rate and
295/// channel count (our own resampler and mixdown run after).
296fn ffmpeg_to_wav(bytes: &[u8]) -> Result<Vec<u8>, String> {
297    // A temp file, not a pipe: mp4/m4a keep their index at the end.
298    let dir = std::env::temp_dir();
299    let src = dir.join(format!(
300        "cortiq-egemma2-audio-{}-{}.in",
301        std::process::id(),
302        std::time::SystemTime::now()
303            .duration_since(std::time::UNIX_EPOCH)
304            .map(|d| d.as_nanos())
305            .unwrap_or(0)
306    ));
307    std::fs::write(&src, bytes).map_err(|e| format!("audio: temp file: {e}"))?;
308    let out = std::process::Command::new("ffmpeg")
309        .args(["-nostdin", "-v", "error", "-i"])
310        .arg(&src)
311        .args(["-vn", "-f", "wav", "-acodec", "pcm_f32le", "pipe:1"])
312        .output();
313    let _ = std::fs::remove_file(&src);
314    match out {
315        Err(_) => Err(
316            "audio: not a WAV file, and ffmpeg (needed for other formats) is not installed".into(),
317        ),
318        Ok(o) if !o.status.success() || o.stdout.len() < 44 => {
319            // the last line says why; the earlier ones name the temp file
320            let err = String::from_utf8_lossy(&o.stderr);
321            let why = err
322                .lines()
323                .rev()
324                .find(|l| !l.trim().is_empty())
325                .unwrap_or("")
326                .trim();
327            Err(format!(
328                "audio: not a WAV file, and ffmpeg could not decode it either ({why})"
329            ))
330        }
331        Ok(o) => Ok(o.stdout),
332    }
333}
334
335// ───────────────────────────── log-mel ─────────────────────────────
336
337/// `np.linspace(a, b, n)`: `a + i·step`, the last point exactly `b`.
338fn linspace(a: f64, b: f64, n: usize) -> Vec<f64> {
339    let step = (b - a) / (n - 1) as f64;
340    let mut v: Vec<f64> = (0..n).map(|i| a + i as f64 * step).collect();
341    v[n - 1] = b;
342    v
343}
344
345/// transformers' `mel_filter_bank(257, 128, 0, 8000, 16000, norm=None,
346/// mel_scale="htk")` in f64, `[257 freq][128 mel]`.
347pub fn mel_filter_bank() -> Vec<f64> {
348    let n_freq = N_FFT / 2 + 1;
349    let hz2mel = |f: f64| 2595.0 * (1.0 + f / 700.0).log10();
350    let mel2hz = |m: f64| 700.0 * (10f64.powf(m / 2595.0) - 1.0);
351    let mels = linspace(hz2mel(0.0), hz2mel(8000.0), N_MELS + 2);
352    let ff: Vec<f64> = mels.iter().map(|&m| mel2hz(m)).collect();
353    let fft = linspace(0.0, (SAMPLE_RATE / 2) as f64, n_freq);
354    let mut out = vec![0f64; n_freq * N_MELS];
355    for (k, &f) in fft.iter().enumerate() {
356        for m in 0..N_MELS {
357            let down = -(ff[m] - f) / (ff[m + 1] - ff[m]);
358            let up = (ff[m + 2] - f) / (ff[m + 2] - ff[m + 1]);
359            out[k * N_MELS + m] = 0f64.max(down.min(up));
360        }
361    }
362    out
363}
364
365/// The feature extractor: Hann window, mel bank, FFT plan.
366pub struct MelFrontend {
367    window: Vec<f32>,
368    /// `[257][128]`
369    filters: Vec<f64>,
370    fft: Arc<dyn rustfft::Fft<f64>>,
371}
372
373impl Default for MelFrontend {
374    fn default() -> Self {
375        Self::new()
376    }
377}
378
379impl MelFrontend {
380    pub fn new() -> Self {
381        // periodic Hann (`window_function(320)`), stored as float32
382        let window = (0..FRAME)
383            .map(|n| {
384                (0.5 - 0.5 * (2.0 * std::f64::consts::PI * n as f64 / FRAME as f64).cos()) as f32
385            })
386            .collect();
387        let fft = rustfft::FftPlanner::<f64>::new().plan_fft_forward(N_FFT);
388        MelFrontend {
389            window,
390            filters: mel_filter_bank(),
391            fft,
392        }
393    }
394
395    /// Log-mel rows `[frames, 128]` of a mono 16 kHz clip, and the frame
396    /// count. The window product is f32 (as the reference's float32
397    /// frames), the FFT f64 with the magnitude rounded to f32 (numpy's
398    /// float32 rfft), the mel sum and log f64.
399    pub fn log_mel(&self, wave: &[f32], pool: Option<&Pool>) -> (Vec<f32>, usize) {
400        let wave = &wave[..wave.len().min(MAX_SAMPLES)];
401        let n = num_frames(wave.len());
402        let mut out = vec![0f32; n * N_MELS];
403        let dst = Shared(out.as_mut_ptr());
404        let n_freq = N_FFT / 2 + 1;
405        rows(pool, n, &|s, e| {
406            let o = unsafe { dst.at(s * N_MELS, (e - s) * N_MELS) };
407            let mut buf = vec![rustfft::num_complex::Complex::<f64>::default(); N_FFT];
408            let mut scratch = vec![
409                rustfft::num_complex::Complex::<f64>::default();
410                self.fft.get_inplace_scratch_len()
411            ];
412            let mut mag = vec![0f64; n_freq];
413            for (i, row) in (s..e).zip(o.chunks_exact_mut(N_MELS)) {
414                for (j, b) in buf.iter_mut().enumerate() {
415                    // padded sample p = i·hop + j is wave[p − 160]
416                    let v = if j < FRAME {
417                        let p = i * HOP + j;
418                        let x = if p >= HOP { wave[p - HOP] } else { 0.0 };
419                        (x * self.window[j]) as f64
420                    } else {
421                        0.0
422                    };
423                    *b = rustfft::num_complex::Complex::new(v, 0.0);
424                }
425                self.fft.process_with_scratch(&mut buf, &mut scratch);
426                for (m, c) in mag.iter_mut().zip(&buf[..n_freq]) {
427                    *m = (c.re.hypot(c.im) as f32) as f64;
428                }
429                for (b, r) in row.iter_mut().enumerate() {
430                    let mut acc = 0f64;
431                    for (k, &m) in mag.iter().enumerate() {
432                        acc += m * self.filters[k * N_MELS + b];
433                    }
434                    *r = (acc + MEL_FLOOR).ln() as f32;
435                }
436            }
437        });
438        (out, n)
439    }
440}
441
442// ───────────────────────────── the tower ─────────────────────────────
443
444/// A projection that clamps its input and output to stored bounds
445/// (`Gemma4ClippableLinear`).
446struct ClipLin {
447    w: Mat,
448    lo_in: f32,
449    hi_in: f32,
450    lo_out: f32,
451    hi_out: f32,
452}
453
454fn scalar(model: &CmfModel, name: &str, default: f32) -> Result<f32, String> {
455    if model.tensor(name).is_none() {
456        return Ok(default);
457    }
458    Ok(vecf(model, name)?.first().copied().unwrap_or(default))
459}
460
461impl ClipLin {
462    fn load(model: &Arc<CmfModel>, p: &str, dequant: bool) -> Result<Self, String> {
463        Ok(ClipLin {
464            w: Mat::load(model, &format!("{p}.linear.weight"), dequant)?,
465            lo_in: scalar(model, &format!("{p}.input_min"), f32::NEG_INFINITY)?,
466            hi_in: scalar(model, &format!("{p}.input_max"), f32::INFINITY)?,
467            lo_out: scalar(model, &format!("{p}.output_min"), f32::NEG_INFINITY)?,
468            hi_out: scalar(model, &format!("{p}.output_max"), f32::INFINITY)?,
469        })
470    }
471
472    fn apply(&self, x: &[f32], n: usize, pool: Option<&Pool>) -> Vec<f32> {
473        let mut xc: Vec<f32>;
474        let x = if self.lo_in.is_finite() || self.hi_in.is_finite() {
475            xc = x.to_vec();
476            let (lo, hi) = (self.lo_in, self.hi_in);
477            par_chunks(&mut xc, pool, &|_, c| {
478                for v in c {
479                    *v = v.clamp(lo, hi);
480                }
481            });
482            &xc[..]
483        } else {
484            x
485        };
486        let t0 = std::time::Instant::now();
487        let mut y = self.w.apply(x, n, pool);
488        prof::add(&prof::LIN, t0);
489        if self.lo_out.is_finite() || self.hi_out.is_finite() {
490            let (lo, hi) = (self.lo_out, self.hi_out);
491            par_chunks(&mut y, pool, &|_, c| {
492                for v in c {
493                    *v = v.clamp(lo, hi);
494                }
495            });
496        }
497        y
498    }
499}
500
501struct Ffn {
502    pre: Vec<f32>,
503    l1: ClipLin,
504    l2: ClipLin,
505    post: Vec<f32>,
506}
507
508struct ALayer {
509    ff1: Ffn,
510    ff2: Ffn,
511    norm_pre_attn: Vec<f32>,
512    norm_post_attn: Vec<f32>,
513    norm_out: Vec<f32>,
514    q: ClipLin,
515    k: ClipLin,
516    v: ClipLin,
517    post: ClipLin,
518    /// `softplus(per_dim_scale)`, `[head_dim]`
519    q_dim: Vec<f32>,
520    /// relative keys by distance, `[distances, heads·head_dim]`
521    rel: Vec<f32>,
522    lc_pre: Vec<f32>,
523    lc_start: ClipLin,
524    /// depthwise taps `[channels][kernel]`
525    lc_dw: Vec<f32>,
526    lc_norm: Vec<f32>,
527    lc_end: ClipLin,
528}
529
530/// The `gemma4_audio` encoder with its projection into the text width.
531pub struct AudioTower {
532    frontend: MelFrontend,
533    /// `[128 out][1 in][3][3]`
534    conv0: Vec<f32>,
535    ln0: Vec<f32>,
536    /// `[32 out][128 in][3][3]`
537    conv1: Vec<f32>,
538    ln1: Vec<f32>,
539    c0: usize,
540    c1: usize,
541    in_proj: Mat,
542    layers: Vec<ALayer>,
543    out_proj: Mat,
544    out_bias: Vec<f32>,
545    embed: Mat,
546    hidden: usize,
547    heads: usize,
548    head_dim: usize,
549    /// a token sees keys at distances `0..left`
550    left: usize,
551    kernel: usize,
552    softcap: f32,
553    residual_weight: f32,
554    q_scale: f32,
555    k_scale: f32,
556    /// the text width the soft tokens land in (512)
557    pub out_dim: usize,
558    pub audio_token: u32,
559    pub boa_token: u32,
560    pub eoa_token: u32,
561    pool: Option<Arc<Pool>>,
562    busy: Mutex<()>,
563}
564
565/// Does this pack carry the audio tower?
566pub fn has_audio(model: &CmfModel) -> bool {
567    model
568        .tensor("audio_tower.subsample_conv_projection.layer0.conv.weight")
569        .is_some()
570        && model
571            .tensor("embed_audio.embedding_projection.weight")
572            .is_some()
573}
574
575impl AudioTower {
576    /// Load the tower from an EmbeddingGemma 2 pack. A quantized pack's
577    /// matrices are widened to f32 at load (~1.2 GB, the f32 GEMM is the
578    /// fast path); `CMF_EGEMMA2_LOWMEM=1` keeps the quantized kernels.
579    pub fn load(model: &Arc<CmfModel>, pool: Option<Arc<Pool>>) -> Result<Self, String> {
580        if !crate::egemma2::is_embedding_gemma2(model) {
581            return Err("not an EmbeddingGemma 2 file".into());
582        }
583        if !has_audio(model) {
584            return Err(
585                "this EmbeddingGemma 2 file has no audio tower (packed with CMF_EGEMMA2_TEXT_ONLY=1?)"
586                    .into(),
587            );
588        }
589        let prov = model
590            .header
591            .provenance
592            .clone()
593            .unwrap_or(serde_json::Value::Null);
594        let eg = &prov["embedding_gemma2"];
595        let cfg = &eg["config"];
596        let ac = &cfg["audio_config"];
597        check_frontend(&eg["preprocessor"])?;
598        let gu = |k: &str, d: u64| ac.get(k).and_then(|v| v.as_u64()).unwrap_or(d) as usize;
599        let gf = |k: &str, d: f64| ac.get(k).and_then(|v| v.as_f64()).unwrap_or(d);
600        let hidden = gu("hidden_size", 1024);
601        let heads = gu("num_attention_heads", 8);
602        let n_layers = gu("num_hidden_layers", 12);
603        let chunk = gu("attention_chunk_size", 12);
604        let ctx_left = gu("attention_context_left", 13);
605        let ctx_right = gu("attention_context_right", 0);
606        if ctx_right != 0 {
607            return Err(format!(
608                "audio: attention_context_right {ctx_right} is not supported (0 in the release)"
609            ));
610        }
611        let head_dim = hidden / heads;
612        let left = ctx_left.saturating_sub(1).max(1);
613        let dequant = !std::env::var("CMF_EGEMMA2_LOWMEM").is_ok_and(|v| v == "1");
614
615        // Sinusoids of the relative distances: row r is distance r, laid
616        // out [sin × hidden/2, cos × hidden/2] (`Gemma4AudioRelPositionalEncoding`,
617        // which lists them from the far end; the shift maps distance d to
618        // its row). f32 throughout, as the reference.
619        let n_ts = hidden / 2;
620        let incr = (10000f64).ln() / (n_ts.max(2) - 1) as f64;
621        let inv: Vec<f32> = (0..n_ts)
622            .map(|i| ((i as f32) * (-incr as f32)).exp())
623            .collect();
624        let n_dist = (chunk + ctx_left - 1 + ctx_right) / 2 + 1;
625        if n_dist < left {
626            return Err("audio: relative position table shorter than the attention span".into());
627        }
628        let mut pe = vec![0f32; n_dist * hidden];
629        for d in 0..n_dist {
630            for (i, &f) in inv.iter().enumerate() {
631                let t = d as f32 * f;
632                pe[d * hidden + i] = t.sin();
633                pe[d * hidden + n_ts + i] = t.cos();
634            }
635        }
636
637        let p0 = "audio_tower.subsample_conv_projection";
638        let conv0 = vecf(model, &format!("{p0}.layer0.conv.weight"))?;
639        let conv1 = vecf(model, &format!("{p0}.layer1.conv.weight"))?;
640        let ln0 = vecf(model, &format!("{p0}.layer0.norm.weight"))?;
641        let ln1 = vecf(model, &format!("{p0}.layer1.norm.weight"))?;
642        let (c0, c1) = (ln0.len(), ln1.len());
643        if conv0.len() != c0 * 9 || conv1.len() != c1 * c0 * 9 {
644            return Err("audio: unexpected subsampling convolution shapes".into());
645        }
646        let in_proj = Mat::load(model, &format!("{p0}.input_proj_linear.weight"), dequant)?;
647        if in_proj.rows() != hidden {
648            return Err("audio: input projection width mismatch".into());
649        }
650
651        let mut layers = Vec::with_capacity(n_layers);
652        for i in 0..n_layers {
653            let p = format!("audio_tower.layers.{i}");
654            let ffn = |f: &str| -> Result<Ffn, String> {
655                Ok(Ffn {
656                    pre: vecf(model, &format!("{p}.{f}.pre_layer_norm.weight"))?,
657                    l1: ClipLin::load(model, &format!("{p}.{f}.ffw_layer_1"), dequant)?,
658                    l2: ClipLin::load(model, &format!("{p}.{f}.ffw_layer_2"), dequant)?,
659                    post: vecf(model, &format!("{p}.{f}.post_layer_norm.weight"))?,
660                })
661            };
662            let pds = vecf(model, &format!("{p}.self_attn.per_dim_scale"))?;
663            if pds.len() != head_dim {
664                return Err(format!(
665                    "audio layer {i}: per_dim_scale has {} values",
666                    pds.len()
667                ));
668            }
669            // softplus as torch: x > 20 passes through
670            let q_dim = pds
671                .iter()
672                .map(|&x| {
673                    if x > 20.0 {
674                        x
675                    } else {
676                        ((x as f64).exp().ln_1p()) as f32
677                    }
678                })
679                .collect();
680            let relk = Mat::load(
681                model,
682                &format!("{p}.self_attn.relative_k_proj.weight"),
683                true,
684            )?;
685            let rel = relk.apply(&pe, n_dist, None);
686            let lc_dw = vecf(model, &format!("{p}.lconv1d.depthwise_conv1d.weight"))?;
687            layers.push(ALayer {
688                ff1: ffn("feed_forward1")?,
689                ff2: ffn("feed_forward2")?,
690                norm_pre_attn: vecf(model, &format!("{p}.norm_pre_attn.weight"))?,
691                norm_post_attn: vecf(model, &format!("{p}.norm_post_attn.weight"))?,
692                norm_out: vecf(model, &format!("{p}.norm_out.weight"))?,
693                q: ClipLin::load(model, &format!("{p}.self_attn.q_proj"), dequant)?,
694                k: ClipLin::load(model, &format!("{p}.self_attn.k_proj"), dequant)?,
695                v: ClipLin::load(model, &format!("{p}.self_attn.v_proj"), dequant)?,
696                post: ClipLin::load(model, &format!("{p}.self_attn.post"), dequant)?,
697                q_dim,
698                rel,
699                lc_pre: vecf(model, &format!("{p}.lconv1d.pre_layer_norm.weight"))?,
700                lc_start: ClipLin::load(model, &format!("{p}.lconv1d.linear_start"), dequant)?,
701                lc_dw,
702                lc_norm: vecf(model, &format!("{p}.lconv1d.conv_norm.weight"))?,
703                lc_end: ClipLin::load(model, &format!("{p}.lconv1d.linear_end"), dequant)?,
704            });
705        }
706        let kernel = layers
707            .first()
708            .map(|l| l.lc_dw.len() / hidden)
709            .unwrap_or(gu("conv_kernel_size", 5));
710        let out_proj = Mat::load(model, "audio_tower.output_proj.weight", dequant)?;
711        let out_bias = vecf(model, "audio_tower.output_proj.bias")?;
712        let embed = Mat::load(model, "embed_audio.embedding_projection.weight", true)?;
713        let tok = |k: &str, d: u32| cfg.get(k).and_then(|v| v.as_u64()).map_or(d, |v| v as u32);
714        Ok(AudioTower {
715            frontend: MelFrontend::new(),
716            conv0,
717            ln0,
718            conv1,
719            ln1,
720            c0,
721            c1,
722            in_proj,
723            layers,
724            out_bias,
725            out_dim: embed.rows(),
726            out_proj,
727            embed,
728            hidden,
729            heads,
730            head_dim,
731            left,
732            kernel,
733            softcap: gf("attention_logit_cap", 50.0) as f32,
734            residual_weight: gf("residual_weight", 0.5) as f32,
735            q_scale: ((head_dim as f64).powf(-0.5) / std::f64::consts::LN_2) as f32,
736            k_scale: ((1.0 + std::f64::consts::E).ln() / std::f64::consts::LN_2) as f32,
737            audio_token: tok("audio_token_id", AUDIO_TOKEN),
738            boa_token: tok("boa_token_id", BOA_TOKEN),
739            eoa_token: tok("eoa_token_index", tok("eoa_token_id", EOA_TOKEN)),
740            pool,
741            busy: Mutex::new(()),
742        })
743    }
744
745    /// Log-mel features of a mono 16 kHz clip (`[frames, 128]`, frames).
746    pub fn log_mel(&self, wave: &[f32]) -> (Vec<f32>, usize) {
747        self.frontend.log_mel(wave, self.pool.as_deref())
748    }
749
750    /// Soft tokens of several clips (mono 16 kHz), `[num_tokens(len), 512]`
751    /// each, in order. Clips are packed through the tower together.
752    pub fn soft_tokens(&self, clips: &[&[f32]]) -> Vec<Vec<f32>> {
753        let _g = self.busy.lock().unwrap_or_else(|e| e.into_inner());
754        let t0 = std::time::Instant::now();
755        let mels: Vec<(Vec<f32>, usize)> = clips.iter().map(|c| self.log_mel(c)).collect();
756        prof::add(&prof::MEL, t0);
757        let mut out = Vec::with_capacity(clips.len());
758        let mut start = 0usize;
759        while start < mels.len() {
760            let mut end = start;
761            let mut toks = 0usize;
762            while end < mels.len() {
763                let t = mels[end].1.div_ceil(4);
764                if end > start && toks + t > PACK_SOFT {
765                    break;
766                }
767                toks += t;
768                end += 1;
769            }
770            let part: Vec<(&[f32], usize)> =
771                mels[start..end].iter().map(|(m, n)| (&m[..], *n)).collect();
772            out.extend(self.encode_locked(&part, None));
773            start = end;
774        }
775        out
776    }
777
778    /// The tower over log-mel features (`[frames, 128]` each): the soft
779    /// tokens of each clip. `trace` collects, per call, the subsampling
780    /// output, every layer's output and the tower output (all packed).
781    pub fn encode_mels(
782        &self,
783        mels: &[(&[f32], usize)],
784        trace: Option<&mut Vec<Vec<f32>>>,
785    ) -> Vec<Vec<f32>> {
786        let _g = self.busy.lock().unwrap_or_else(|e| e.into_inner());
787        self.encode_locked(mels, trace)
788    }
789
790    fn encode_locked(
791        &self,
792        mels: &[(&[f32], usize)],
793        mut trace: Option<&mut Vec<Vec<f32>>>,
794    ) -> Vec<Vec<f32>> {
795        let pool = self.pool.as_deref();
796        let d = self.hidden;
797        // ── subsampling, clip by clip, rows packed back to back
798        let mut x = Vec::new();
799        let mut lens = Vec::with_capacity(mels.len());
800        for &(m, frames) in mels {
801            let t0 = std::time::Instant::now();
802            let (sub, t) = self.subsample(m, frames, pool);
803            prof::add(&prof::SUB, t0);
804            x.extend_from_slice(&sub);
805            lens.push(t);
806        }
807        let n: usize = lens.iter().sum();
808        let out_dim = self.out_dim;
809        if n == 0 {
810            return lens.iter().map(|_| Vec::new()).collect();
811        }
812        // position of every row inside its clip
813        let mut pos = Vec::with_capacity(n);
814        for &l in &lens {
815            pos.extend(0..l);
816        }
817        let t0 = std::time::Instant::now();
818        let mut h = self.in_proj.apply(&x, n, pool);
819        prof::add(&prof::LIN, t0);
820        drop(x);
821        if let Some(t) = trace.as_deref_mut() {
822            t.push(h.clone());
823        }
824        for l in &self.layers {
825            self.ffn(&l.ff1, &mut h, n, pool);
826            // ── attention
827            let a = rms_rows(&h, Some(&l.norm_pre_attn), d, pool);
828            let o = self.attention(l, &a, n, &pos, pool);
829            add_normed(&mut h, &o, &l.norm_post_attn, 1.0, d, pool);
830            // ── light conv
831            let a = rms_rows(&h, Some(&l.lc_pre), d, pool);
832            let mut g = l.lc_start.apply(&a, n, pool);
833            let gl = glu(&mut g, n, d, pool);
834            drop(g);
835            let mut c = self.dwconv(&gl, &l.lc_dw, n, &pos, pool);
836            drop(gl);
837            norm_silu(&mut c, &l.lc_norm, d, pool);
838            let e = l.lc_end.apply(&c, n, pool);
839            par_chunks(&mut h, pool, &|off, c| {
840                for (hv, ev) in c.iter_mut().zip(&e[off..]) {
841                    *hv = ev + *hv;
842                }
843            });
844            self.ffn(&l.ff2, &mut h, n, pool);
845            h = rms_rows(&h, Some(&l.norm_out), d, pool);
846            if let Some(t) = trace.as_deref_mut() {
847                t.push(h.clone());
848            }
849        }
850        let t0 = std::time::Instant::now();
851        let mut o = self.out_proj.apply(&h, n, pool);
852        prof::add(&prof::LIN, t0);
853        let od = self.out_bias.len();
854        let bias = &self.out_bias;
855        par_chunks(&mut o, pool, &|off, c| {
856            for (i, v) in c.iter_mut().enumerate() {
857                *v += bias[(off + i) % od];
858            }
859        });
860        if let Some(t) = trace.as_deref_mut() {
861            t.push(o.clone());
862        }
863        let on = rms_rows(&o, None, od, pool);
864        let t0 = std::time::Instant::now();
865        let e = self.embed.apply(&on, n, pool);
866        prof::add(&prof::LIN, t0);
867        let mut res = Vec::with_capacity(lens.len());
868        let mut off = 0usize;
869        for &l in &lens {
870            res.push(e[off * out_dim..(off + l) * out_dim].to_vec());
871            off += l;
872        }
873        res
874    }
875
876    /// The two stride-2 convolutions (+ LayerNorm, ReLU) over one clip's
877    /// mel rows: `[⌈frames/4⌉, 32·32]` in (frequency, channel) order, before
878    /// the input projection.
879    fn subsample(&self, mel: &[f32], frames: usize, pool: Option<&Pool>) -> (Vec<f32>, usize) {
880        let (c0, c1) = (self.c0, self.c1);
881        let f0 = N_MELS;
882        let t1 = frames.div_ceil(2);
883        let f1 = f0.div_ceil(2);
884        // layer 0: 1 → c0 channels, out[t][f][c] (channels last)
885        let mut y0 = vec![0f32; t1 * f1 * c0];
886        let dst = Shared(y0.as_mut_ptr());
887        rows(pool, t1, &|s, e| {
888            let o = unsafe { dst.at(s * f1 * c0, (e - s) * f1 * c0) };
889            let mut patch = [0f32; 9];
890            for t in s..e {
891                for f in 0..f1 {
892                    for dt in 0..3 {
893                        for df in 0..3 {
894                            let (ti, fi) = ((2 * t + dt) as isize - 1, (2 * f + df) as isize - 1);
895                            patch[dt * 3 + df] = if ti >= 0
896                                && (ti as usize) < frames
897                                && fi >= 0
898                                && (fi as usize) < f0
899                            {
900                                mel[ti as usize * f0 + fi as usize]
901                            } else {
902                                0.0
903                            };
904                        }
905                    }
906                    let row = &mut o[((t - s) * f1 + f) * c0..((t - s) * f1 + f + 1) * c0];
907                    for (c, r) in row.iter_mut().enumerate() {
908                        let w = &self.conv0[c * 9..(c + 1) * 9];
909                        let mut acc = 0f32;
910                        for k in 0..9 {
911                            acc += w[k] * patch[k];
912                        }
913                        *r = acc;
914                    }
915                    layer_norm_relu(row, &self.ln0);
916                }
917            }
918        });
919        // layer 1: c0 → c1 channels, im2col by time blocks + GEMM
920        let t2 = t1.div_ceil(2);
921        let f2 = f1.div_ceil(2);
922        let kk = c0 * 9;
923        let mut y1 = vec![0f32; t2 * f2 * c1];
924        const TB: usize = 32;
925        let mut col = vec![0f32; TB * f2 * kk];
926        for tb in (0..t2).step_by(TB) {
927            let te = (tb + TB).min(t2);
928            let nr = (te - tb) * f2;
929            let cp = Shared(col.as_mut_ptr());
930            rows(pool, te - tb, &|s, e| {
931                let cc = unsafe { cp.at(s * f2 * kk, (e - s) * f2 * kk) };
932                for tt in s..e {
933                    let t = tb + tt;
934                    for f in 0..f2 {
935                        let r = &mut cc[((tt - s) * f2 + f) * kk..((tt - s) * f2 + f + 1) * kk];
936                        for dt in 0..3 {
937                            for df in 0..3 {
938                                let (ti, fi) =
939                                    ((2 * t + dt) as isize - 1, (2 * f + df) as isize - 1);
940                                let inside =
941                                    ti >= 0 && (ti as usize) < t1 && fi >= 0 && (fi as usize) < f1;
942                                for ci in 0..c0 {
943                                    r[ci * 9 + dt * 3 + df] = if inside {
944                                        y0[(ti as usize * f1 + fi as usize) * c0 + ci]
945                                    } else {
946                                        0.0
947                                    };
948                                }
949                            }
950                        }
951                    }
952                }
953            });
954            crate::egemma2::gemm_nt(
955                &col[..nr * kk],
956                &self.conv1,
957                &mut y1[tb * f2 * c1..te * f2 * c1],
958                nr,
959                kk,
960                c1,
961                pool,
962            );
963        }
964        for row in y1.chunks_exact_mut(c1) {
965            layer_norm_relu(row, &self.ln1);
966        }
967        (y1, t2)
968    }
969
970    /// `h += ½·N_post(W₂ silu(W₁ N_pre(h)))`.
971    fn ffn(&self, f: &Ffn, h: &mut [f32], n: usize, pool: Option<&Pool>) {
972        let d = self.hidden;
973        let a = rms_rows(h, Some(&f.pre), d, pool);
974        let mut u = f.l1.apply(&a, n, pool);
975        silu_inplace(&mut u, pool);
976        let y = f.l2.apply(&u, n, pool);
977        add_normed(h, &y, &f.post, self.residual_weight, d, pool);
978    }
979
980    /// Chunked local attention over packed rows (`pos` = index in the clip).
981    fn attention(
982        &self,
983        l: &ALayer,
984        a: &[f32],
985        n: usize,
986        pos: &[usize],
987        pool: Option<&Pool>,
988    ) -> Vec<f32> {
989        let (nh, hd, d) = (self.heads, self.head_dim, self.hidden);
990        let mut q = l.q.apply(a, n, pool);
991        let mut k = l.k.apply(a, n, pool);
992        let v = l.v.apply(a, n, pool);
993        let (qs, ks) = (self.q_scale, self.k_scale);
994        let qd = &l.q_dim;
995        par_chunks(&mut q, pool, &|off, c| {
996            for (i, x) in c.iter_mut().enumerate() {
997                *x = (*x * qs) * qd[(off + i) % hd];
998            }
999        });
1000        par_chunks(&mut k, pool, &|_, c| {
1001            for x in c {
1002                *x *= ks;
1003            }
1004        });
1005        let t_core = std::time::Instant::now();
1006        let mut out = vec![0f32; n * d];
1007        let dst = Shared(out.as_mut_ptr());
1008        let (left, cap) = (self.left, self.softcap);
1009        rows(pool, n, &|s, e| {
1010            let o = unsafe { dst.at(s * d, (e - s) * d) };
1011            let mut w = vec![0f32; left];
1012            for t in s..e {
1013                let span = (pos[t] + 1).min(left);
1014                for hh in 0..nh {
1015                    let qv = &q[t * d + hh * hd..t * d + (hh + 1) * hd];
1016                    let mut mx = f32::NEG_INFINITY;
1017                    for (dist, wd) in w.iter_mut().enumerate().take(span) {
1018                        let kv = &k[(t - dist) * d + hh * hd..(t - dist) * d + (hh + 1) * hd];
1019                        let rv = &l.rel[dist * d + hh * hd..dist * d + (hh + 1) * hd];
1020                        let (mut ac, mut bd) = (0f32, 0f32);
1021                        for j in 0..hd {
1022                            ac += qv[j] * kv[j];
1023                            bd += qv[j] * rv[j];
1024                        }
1025                        let z = ((ac + bd) / cap).tanh() * cap;
1026                        *wd = z;
1027                        mx = mx.max(z);
1028                    }
1029                    let mut den = 0f32;
1030                    for wd in w.iter_mut().take(span) {
1031                        *wd = (*wd - mx).exp();
1032                        den += *wd;
1033                    }
1034                    let ov = &mut o[(t - s) * d + hh * hd..(t - s) * d + (hh + 1) * hd];
1035                    ov.fill(0.0);
1036                    for (dist, &wd) in w.iter().enumerate().take(span) {
1037                        let p = wd / den;
1038                        let vv = &v[(t - dist) * d + hh * hd..(t - dist) * d + (hh + 1) * hd];
1039                        for j in 0..hd {
1040                            ov[j] += p * vv[j];
1041                        }
1042                    }
1043                }
1044            }
1045        });
1046        prof::add(&prof::ATT, t_core);
1047        l.post.apply(&out, n, pool)
1048    }
1049
1050    /// Causal depthwise convolution (`kernel` taps ending at the row).
1051    fn dwconv(
1052        &self,
1053        x: &[f32],
1054        w: &[f32],
1055        n: usize,
1056        pos: &[usize],
1057        pool: Option<&Pool>,
1058    ) -> Vec<f32> {
1059        let (d, kn) = (self.hidden, self.kernel);
1060        let mut out = vec![0f32; n * d];
1061        let dst = Shared(out.as_mut_ptr());
1062        rows(pool, n, &|s, e| {
1063            let o = unsafe { dst.at(s * d, (e - s) * d) };
1064            for t in s..e {
1065                let row = &mut o[(t - s) * d..(t - s + 1) * d];
1066                for (k, back) in (0..kn).zip((0..kn).rev()) {
1067                    if back > pos[t] {
1068                        continue;
1069                    }
1070                    let src = &x[(t - back) * d..(t - back + 1) * d];
1071                    for c in 0..d {
1072                        row[c] += w[c * kn + k] * src[c];
1073                    }
1074                }
1075            }
1076        });
1077        out
1078    }
1079}
1080
1081/// Is the preprocessor the one this front end implements?
1082fn check_frontend(pp: &serde_json::Value) -> Result<(), String> {
1083    if pp.is_null() {
1084        return Ok(());
1085    }
1086    let f = |k: &str| pp.get(k).and_then(|v| v.as_f64());
1087    let want: [(&str, f64); 9] = [
1088        ("feature_size", N_MELS as f64),
1089        ("sampling_rate", SAMPLE_RATE as f64),
1090        ("frame_length", FRAME as f64),
1091        ("hop_length", HOP as f64),
1092        ("fft_length", N_FFT as f64),
1093        ("mel_floor", MEL_FLOOR),
1094        ("preemphasis", 0.0),
1095        ("dither", 0.0),
1096        ("input_scale_factor", 1.0),
1097    ];
1098    for (k, v) in want {
1099        if let Some(got) = f(k) {
1100            if (got - v).abs() > 1e-9 {
1101                return Err(format!(
1102                    "audio: preprocessor {k} = {got}, this build implements {v}"
1103                ));
1104            }
1105        }
1106    }
1107    for k in ["per_bin_mean", "per_bin_stddev"] {
1108        if pp.get(k).is_some_and(|v| !v.is_null()) {
1109            return Err(format!("audio: preprocessor {k} is set; not implemented"));
1110        }
1111    }
1112    Ok(())
1113}
1114
1115/// `f(offset, slice)` over `x` in parallel slices.
1116fn par_chunks(x: &mut [f32], pool: Option<&Pool>, f: &(dyn Fn(usize, &mut [f32]) + Sync)) {
1117    let n = x.len();
1118    let grain = 16384usize;
1119    let dst = Shared(x.as_mut_ptr());
1120    rows(pool, n.div_ceil(grain), &|s, e| {
1121        let (lo, hi) = (s * grain, (e * grain).min(n));
1122        f(lo, unsafe { dst.at(lo, hi - lo) });
1123    });
1124}
1125
1126/// LayerNorm over one channel row (no bias), then ReLU.
1127fn layer_norm_relu(x: &mut [f32], w: &[f32]) {
1128    let n = x.len() as f64;
1129    let mean = x.iter().map(|&v| v as f64).sum::<f64>() / n;
1130    let var = x
1131        .iter()
1132        .map(|&v| {
1133            let c = v as f64 - mean;
1134            c * c
1135        })
1136        .sum::<f64>()
1137        / n;
1138    let inv = 1.0 / (var + EPS).sqrt();
1139    for (v, &g) in x.iter_mut().zip(w) {
1140        *v = (((*v as f64 - mean) * inv) as f32 * g).max(0.0);
1141    }
1142}
1143
1144/// `h += scale · rms_w(y)` row by row.
1145fn add_normed(h: &mut [f32], y: &[f32], w: &[f32], scale: f32, d: usize, pool: Option<&Pool>) {
1146    let n = h.len() / d;
1147    let dst = Shared(h.as_mut_ptr());
1148    rows(pool, n, &|s, e| {
1149        let hh = unsafe { dst.at(s * d, (e - s) * d) };
1150        let mut tmp = vec![0f32; d];
1151        for (hrow, yrow) in hh.chunks_exact_mut(d).zip(y[s * d..e * d].chunks_exact(d)) {
1152            rms_into(yrow, Some(w), &mut tmp);
1153            for (a, &b) in hrow.iter_mut().zip(&tmp) {
1154                *a += b * scale;
1155            }
1156        }
1157    });
1158}
1159
1160#[inline]
1161fn sigmoid(x: f32) -> f32 {
1162    1.0 / (1.0 + (-x).exp())
1163}
1164
1165/// `x = x·σ(x)` across the pool.
1166fn silu_inplace(x: &mut [f32], pool: Option<&Pool>) {
1167    let n = x.len();
1168    let grain = 16384usize;
1169    let dst = Shared(x.as_mut_ptr());
1170    rows(pool, n.div_ceil(grain), &|s, e| {
1171        let (lo, hi) = (s * grain, (e * grain).min(n));
1172        let r = unsafe { dst.at(lo, hi - lo) };
1173        silu_slice(r);
1174    });
1175}
1176
1177#[inline]
1178fn silu_slice(x: &mut [f32]) {
1179    #[allow(unused_mut)]
1180    let mut i = 0usize;
1181    #[cfg(target_arch = "aarch64")]
1182    // SAFETY: every lane read/written is below `x.len()`.
1183    unsafe {
1184        use core::arch::aarch64::*;
1185        let one = vdupq_n_f32(1.0);
1186        while i + 4 <= x.len() {
1187            let v = vld1q_f32(x.as_ptr().add(i));
1188            let e = crate::attention::vexpq_f32(vnegq_f32(v));
1189            vst1q_f32(x.as_mut_ptr().add(i), vdivq_f32(v, vaddq_f32(one, e)));
1190            i += 4;
1191        }
1192    }
1193    for v in x[i..].iter_mut() {
1194        *v *= sigmoid(*v);
1195    }
1196}
1197
1198/// `glu`: `[n, 2d] → [n, d]`, first half times σ(second half).
1199fn glu(x: &mut [f32], n: usize, d: usize, pool: Option<&Pool>) -> Vec<f32> {
1200    let mut out = vec![0f32; n * d];
1201    let dst = Shared(out.as_mut_ptr());
1202    rows(pool, n, &|s, e| {
1203        let o = unsafe { dst.at(s * d, (e - s) * d) };
1204        for t in s..e {
1205            let (a, b) = x[t * 2 * d..(t + 1) * 2 * d].split_at(d);
1206            for ((r, &av), &bv) in o[(t - s) * d..(t - s + 1) * d].iter_mut().zip(a).zip(b) {
1207                *r = av * sigmoid(bv);
1208            }
1209        }
1210    });
1211    out
1212}
1213
1214/// `x = silu(rms_w(x))` row by row.
1215fn norm_silu(x: &mut [f32], w: &[f32], d: usize, pool: Option<&Pool>) {
1216    let n = x.len() / d;
1217    let dst = Shared(x.as_mut_ptr());
1218    rows(pool, n, &|s, e| {
1219        let xx = unsafe { dst.at(s * d, (e - s) * d) };
1220        let mut tmp = vec![0f32; d];
1221        for row in xx.chunks_exact_mut(d) {
1222            rms_into(row, Some(w), &mut tmp);
1223            row.copy_from_slice(&tmp);
1224            silu_slice(row);
1225        }
1226    });
1227}
1228
1229// ───────────────────────────── inputs ─────────────────────────────
1230
1231/// One input that holds audio: clips (mono 16 kHz) and, optionally, text
1232/// with one [`PLACEHOLDER`] per clip (prompt options as for text). Without
1233/// text the clips follow each other between BOS and EOS, space separated
1234/// (the processor's `"<|audio|> <|audio|>"`), unprompted.
1235///
1236/// [`crate::egemma2_mm::MediaEncoder`] is the general path (audio with
1237/// images and video in one input); this one is the audio-only shortcut the
1238/// parity test checks it against.
1239#[derive(Clone, Debug, Default)]
1240pub struct AudioInput {
1241    pub text: Option<TextInput>,
1242    pub clips: Vec<Vec<f32>>,
1243}
1244
1245/// Token ids of an input whose clips give `n_soft[i]` soft tokens: every
1246/// placeholder becomes `<|audio> <|audio|>×n <audio|>`, wrapped in BOS/EOS.
1247pub fn input_ids(
1248    enc: &EmbeddingGemma2,
1249    tower_tokens: (u32, u32, u32),
1250    input: &AudioInput,
1251    n_soft: &[usize],
1252) -> Result<Vec<u32>, String> {
1253    let (audio, boa, eoa) = tower_tokens;
1254    let expand = |out: &mut Vec<u32>, n: usize| {
1255        out.push(boa);
1256        out.extend(std::iter::repeat_n(audio, n));
1257        out.push(eoa);
1258    };
1259    let mut ids = vec![enc.bos];
1260    // without text the processor lays the clips out as "<|audio|> <|audio|>"
1261    let bare = TextInput::plain(vec![PLACEHOLDER; n_soft.len()].join(" "));
1262    let t = input.text.as_ref().unwrap_or(&bare);
1263    let body = enc.tokenizer().encode(&enc.format(t)?);
1264    let found = body.iter().filter(|&&x| x == audio).count();
1265    if found != n_soft.len() {
1266        return Err(format!(
1267            "the text holds {found} {PLACEHOLDER} placeholder(s) for {} audio clip(s)",
1268            n_soft.len()
1269        ));
1270    }
1271    let mut k = 0usize;
1272    for &x in &body {
1273        if x == audio {
1274            expand(&mut ids, n_soft[k]);
1275            k += 1;
1276        } else {
1277            ids.push(x);
1278        }
1279    }
1280    ids.push(enc.eos);
1281    if ids.len() > enc.max_tokens {
1282        return Err(format!(
1283            "{} tokens > the model's {} token context (audio is 25 tokens a second)",
1284            ids.len(),
1285            enc.max_tokens
1286        ));
1287    }
1288    Ok(ids)
1289}
1290
1291/// Embed inputs with audio: unit-length vectors of `enc.dim`, in order.
1292pub fn embed_audio_inputs(
1293    enc: &EmbeddingGemma2,
1294    tower: &AudioTower,
1295    inputs: &[AudioInput],
1296) -> Result<Vec<Vec<f32>>, String> {
1297    let toks = (tower.audio_token, tower.boa_token, tower.eoa_token);
1298    // ids first: a bad input fails before any compute
1299    let mut seqs = Vec::with_capacity(inputs.len());
1300    for (i, inp) in inputs.iter().enumerate() {
1301        if inp.clips.is_empty() && inp.text.is_none() {
1302            return Err(format!("input {i}: no audio and no text"));
1303        }
1304        let n: Vec<usize> = inp.clips.iter().map(|c| num_tokens(c.len())).collect();
1305        seqs.push(input_ids(enc, toks, inp, &n).map_err(|e| format!("input {i}: {e}"))?);
1306    }
1307    let clips: Vec<&[f32]> = inputs
1308        .iter()
1309        .flat_map(|i| i.clips.iter().map(|c| &c[..]))
1310        .collect();
1311    let t_tower = std::time::Instant::now();
1312    let soft = tower.soft_tokens(&clips);
1313    let tower_ms = t_tower.elapsed().as_secs_f64() * 1e3;
1314    // the tower's projections go through the text encoder's `Mat`, whose
1315    // profile counter the text forward reports: leave it the text's own
1316    crate::egemma2::prof::take(&crate::egemma2::prof::LIN);
1317    let t_text = std::time::Instant::now();
1318    let od = tower.out_dim;
1319    let d = enc.hidden();
1320    if od != d {
1321        return Err(format!(
1322            "audio soft tokens are {od}-d, the text encoder is {d}-d"
1323        ));
1324    }
1325    // merged rows per input
1326    let mut merged: Vec<Vec<f32>> = Vec::with_capacity(inputs.len());
1327    let mut next = 0usize;
1328    for (inp, ids) in inputs.iter().zip(&seqs) {
1329        let mut x = enc.embed_rows(std::slice::from_ref(ids));
1330        let mut rows_it = ids
1331            .iter()
1332            .enumerate()
1333            .filter(|(_, t)| **t == tower.audio_token)
1334            .map(|(r, _)| r);
1335        for _ in 0..inp.clips.len() {
1336            let s = &soft[next];
1337            next += 1;
1338            for tok in s.chunks_exact(od) {
1339                let r = rows_it.next().ok_or("audio: placeholder count mismatch")?;
1340                x[r * d..(r + 1) * d].copy_from_slice(tok);
1341            }
1342        }
1343        if rows_it.next().is_some() {
1344            return Err("audio: placeholder count mismatch".into());
1345        }
1346        merged.push(x);
1347    }
1348    // pack up to the context per text forward
1349    let mut out = Vec::with_capacity(inputs.len());
1350    let mut start = 0usize;
1351    while start < seqs.len() {
1352        let mut end = start;
1353        let mut tot = 0usize;
1354        while end < seqs.len()
1355            && (end == start || tot + seqs[end].len() <= crate::egemma2::MAX_TOKENS)
1356        {
1357            tot += seqs[end].len();
1358            end += 1;
1359        }
1360        let mut x = Vec::with_capacity(tot * d);
1361        for m in &merged[start..end] {
1362            x.extend_from_slice(m);
1363        }
1364        let lens: Vec<usize> = seqs[start..end].iter().map(|s| s.len()).collect();
1365        out.extend(enc.embed_merged(x, &lens));
1366        start = end;
1367    }
1368    if prof::on() {
1369        let (mel, sub, lin, att) = (
1370            prof::take(&prof::MEL),
1371            prof::take(&prof::SUB),
1372            prof::take(&prof::LIN),
1373            prof::take(&prof::ATT),
1374        );
1375        let secs: f64 = clips.iter().map(|c| c.len() as f64).sum::<f64>() / SAMPLE_RATE as f64;
1376        let toks: usize = soft.iter().map(|s| s.len() / od).sum();
1377        eprintln!(
1378            "egemma2 audio: {} clip(s), {secs:.2} s, {toks} soft tokens — tower {tower_ms:.1} ms \
1379             (log-mel {mel:.1}, subsampling {sub:.1}, projections {lin:.1}, attention core {att:.1}, \
1380             rest {:.1}); text {:.1} ms",
1381            clips.len(),
1382            tower_ms - mel - sub - lin - att,
1383            t_text.elapsed().as_secs_f64() * 1e3
1384        );
1385    }
1386    Ok(out)
1387}
1388
1389#[cfg(test)]
1390mod tests {
1391    use super::*;
1392
1393    #[test]
1394    fn token_counts_follow_the_processor() {
1395        // (samples, mel frames, soft tokens) from the transformers processor
1396        for (n, f, t) in [
1397            (100, 0, 0),
1398            (161, 1, 1),
1399            (321, 2, 1),
1400            (480, 2, 1),
1401            (1000, 6, 2),
1402            (16000, 99, 25),
1403            (72825, 455, 114),
1404            (480000, 2999, 750),
1405            (480001, 2999, 750),
1406            (560000, 2999, 750),
1407        ] {
1408            assert_eq!(num_frames(n), f, "frames of {n}");
1409            assert_eq!(num_tokens(n), t, "tokens of {n}");
1410        }
1411    }
1412
1413    #[test]
1414    fn mel_bank_is_htk_triangles() {
1415        let m = mel_filter_bank();
1416        let n_freq = N_FFT / 2 + 1;
1417        assert_eq!(m.len(), n_freq * N_MELS);
1418        // DC and Nyquist fall outside every triangle
1419        assert!(m[..N_MELS].iter().all(|&v| v == 0.0));
1420        // every filter peaks at most at 1, all are non-negative
1421        for b in 0..N_MELS {
1422            let col: Vec<f64> = (0..n_freq).map(|k| m[k * N_MELS + b]).collect();
1423            assert!(col.iter().all(|&v| (0.0..=1.0).contains(&v)));
1424        }
1425        // the lowest band falls between bins 0 and 1 and stays empty, as in
1426        // the reference bank; bin 1 (31.25 Hz) splits over bands 1 and 2
1427        // with transformers' own weights (float32 dump of the release)
1428        assert!((0..n_freq).all(|k| m[k * N_MELS] == 0.0));
1429        assert!((m[N_MELS + 1] - 0.766_007_66).abs() < 1e-6);
1430        assert!((m[N_MELS + 2] - 0.233_992_32).abs() < 1e-6);
1431    }
1432
1433    #[test]
1434    fn silence_is_the_mel_floor() {
1435        let fe = MelFrontend::new();
1436        let (mel, n) = fe.log_mel(&vec![0f32; 16000], None);
1437        assert_eq!(n, 99);
1438        let floor = (MEL_FLOOR).ln() as f32;
1439        assert!(mel.iter().all(|&v| (v - floor).abs() < 1e-6));
1440    }
1441
1442    #[test]
1443    fn a_tone_lands_in_its_band() {
1444        let fe = MelFrontend::new();
1445        let f = 1000.0f64;
1446        let wave: Vec<f32> = (0..16000)
1447            .map(|i| (0.5 * (2.0 * std::f64::consts::PI * f * i as f64 / 16000.0).sin()) as f32)
1448            .collect();
1449        let (mel, n) = fe.log_mel(&wave, None);
1450        let row = &mel[(n / 2) * N_MELS..(n / 2 + 1) * N_MELS];
1451        let best = row
1452            .iter()
1453            .enumerate()
1454            .max_by(|a, b| a.1.total_cmp(b.1))
1455            .unwrap()
1456            .0;
1457        // band centres: 700(10^(m/2595)-1) on 130 points from 0 to mel(8000)
1458        let hz2mel = |f: f64| 2595.0 * (1.0 + f / 700.0).log10();
1459        let centre = |b: usize| {
1460            let m = hz2mel(8000.0) * (b + 1) as f64 / 129.0;
1461            700.0 * (10f64.powf(m / 2595.0) - 1.0)
1462        };
1463        assert!(
1464            (centre(best) - f).abs() < 60.0,
1465            "band {best} at {}",
1466            centre(best)
1467        );
1468    }
1469
1470    #[test]
1471    fn stereo_mixes_down_and_rates_resample() {
1472        let wav = crate::mimo_audio::Wav {
1473            sample_rate: 16000,
1474            channels: vec![vec![1.0, 0.0, 0.5], vec![0.0, 1.0, 0.5]],
1475        };
1476        assert_eq!(to_mono_16k(&wav), vec![0.5, 0.5, 0.5]);
1477        let wav = crate::mimo_audio::Wav {
1478            sample_rate: 48000,
1479            channels: vec![vec![0.25; 4800]],
1480        };
1481        let m = to_mono_16k(&wav);
1482        assert_eq!(m.len(), 1600);
1483        assert!((m[800] - 0.25).abs() < 1e-4);
1484    }
1485
1486    #[test]
1487    fn resampling_keeps_a_tone_and_its_length() {
1488        for (from, f0) in [
1489            (8000u32, 1000.0f64),
1490            (44100, 3000.0),
1491            (48000, 7000.0),
1492            (22050, 440.0),
1493        ] {
1494            let n = from as usize; // one second
1495            let x: Vec<f32> = (0..n)
1496                .map(|i| (2.0 * std::f64::consts::PI * f0 * i as f64 / from as f64).sin() as f32)
1497                .collect();
1498            let y = resample_hq(&x, from, SAMPLE_RATE);
1499            assert_eq!(y.len(), 16000, "{from}");
1500            // away from the edges the tone comes out as the ideal one
1501            let err = (2000..14000)
1502                .map(|i| {
1503                    let want = (2.0 * std::f64::consts::PI * f0 * i as f64 / 16000.0).sin();
1504                    (y[i] as f64 - want).abs()
1505                })
1506                .fold(0f64, f64::max);
1507            assert!(err < 1e-4, "{from} Hz: max err {err}");
1508        }
1509        // a tone above the new Nyquist is removed
1510        let x: Vec<f32> = (0..48000)
1511            .map(|i| (2.0 * std::f64::consts::PI * 9000.0 * i as f64 / 48000.0).sin() as f32)
1512            .collect();
1513        let y = resample_hq(&x, 48000, SAMPLE_RATE);
1514        let rms = (y[2000..14000]
1515            .iter()
1516            .map(|v| (*v as f64).powi(2))
1517            .sum::<f64>()
1518            / 12000.0)
1519            .sqrt();
1520        assert!(rms < 1e-4, "alias rms {rms}");
1521        // co-prime rates take the on-the-fly path and agree with the table path
1522        let x: Vec<f32> = (0..44099).map(|i| ((i % 97) as f32 / 97.0) - 0.5).collect();
1523        let y = resample_hq(&x, 44099, SAMPLE_RATE);
1524        assert_eq!(y.len(), (16000u64 * 44099).div_ceil(44099) as usize);
1525    }
1526}