Skip to main content

cortiq_engine/
lfm3_audio.rs

1//! LFM3-Audio / LFM3-Omni speech input. Liquid's log-mel features (25 ms
2//! Hann window every 10 ms without centering, 128 Slaney mel filters,
3//! log10 clamped at -12, (x + 4) / 4), the stride-8 convolutional
4//! downsampler (three stride-2 3×3 convolutions over the mel × time plane:
5//! 8 frames become one vector), the 17-layer LFM2 encoder (gated short
6//! convolutions and attention causal within a 32-token window) and the
7//! projector into the language model, in f32 on the host from the f16
8//! tensors the converter keeps (`audio_encoder.*`, `audio_projector.*`).
9//! The language model then reads one row per `<|audio|>` placeholder.
10
11use crate::lfm3_vl::{Dense, add_bias, erf, load_proj, load_vec, rms_rows};
12use crate::pipeline::Pipeline;
13use crate::pool::Pool;
14use cortiq_core::CmfModel;
15use serde_json::{Value, json};
16use std::sync::Arc;
17
18/// The feature extractor and the encoder geometry, from the checkpoint's
19/// config (`provenance.lfm3_audio`): the legacy LFM3-Audio layout
20/// (`encoder.{dim_in, dim_conv, dim_out, sliding_window, lfm}`) or the
21/// Transformers one of LFM3-Omni (`audio_encoder_config`).
22pub struct Lfm3AudioConfig {
23    pub n_mels: usize,
24    pub channels: usize,
25    pub hidden: usize,
26    pub window: usize,
27    pub heads: usize,
28    pub kv_heads: usize,
29    pub head_dim: usize,
30    /// True for an attention layer, false for a short convolution.
31    pub attention: Vec<bool>,
32    pub conv_kernel: usize,
33    pub norm_eps: f64,
34    pub rope_theta: f64,
35    pub n_fft: usize,
36    pub hop: usize,
37    pub log_floor: f64,
38    pub norm_offset: f64,
39    pub norm_divisor: f64,
40}
41
42/// Whether the file carries the speech encoder.
43pub fn has_audio(model: &CmfModel) -> bool {
44    model
45        .tensors
46        .iter()
47        .any(|t| t.name == "audio_encoder.downsample.conv_in.weight")
48}
49
50impl Lfm3AudioConfig {
51    pub fn from_model(model: &CmfModel) -> Result<Self, String> {
52        let p = model
53            .header
54            .provenance
55            .as_ref()
56            .and_then(|p| p.get("lfm3_audio"))
57            .ok_or("lfm3_audio: the file has no audio config (provenance.lfm3_audio)")?;
58        let cfg = p.get("config").ok_or("lfm3_audio: provenance has no config")?;
59        Self::from_json(cfg, p.get("features").unwrap_or(&Value::Null))
60    }
61
62    pub fn from_json(cfg: &Value, features: &Value) -> Result<Self, String> {
63        let (enc, lfm, n_mels, channels, hidden) = if let Some(e) = cfg.get("encoder") {
64            let lfm = e.get("lfm").ok_or("lfm3_audio: encoder.lfm missing")?;
65            (e, lfm, e.get("dim_in"), e.get("dim_conv"), e.get("dim_out"))
66        } else {
67            let e = cfg
68                .get("audio_encoder_config")
69                .ok_or("lfm3_audio: no encoder config")?;
70            let lfm = e.get("text_config").ok_or("lfm3_audio: audio_encoder_config.text_config missing")?;
71            (e, lfm, e.get("num_mel_bins"), e.get("conv_channels"), e.get("hidden_size"))
72        };
73        let u = |v: Option<&Value>, what: &str| -> Result<usize, String> {
74            v.and_then(Value::as_u64)
75                .map(|x| x as usize)
76                .ok_or(format!("lfm3_audio: {what} missing"))
77        };
78        let heads = u(lfm.get("num_attention_heads"), "num_attention_heads")?;
79        let hidden = u(hidden, "encoder width")?;
80        let attention: Vec<bool> = lfm
81            .get("layer_types")
82            .and_then(Value::as_array)
83            .ok_or("lfm3_audio: layer_types missing")?
84            .iter()
85            .map(|t| t.as_str() != Some("conv"))
86            .collect();
87        let rope = lfm.get("rope_parameters").unwrap_or(lfm);
88        let pre = |key: &str, legacy: &str, default: f64| -> f64 {
89            features
90                .get(key)
91                .or_else(|| cfg.get("preprocessor").and_then(|p| p.get(legacy)))
92                .and_then(Value::as_f64)
93                .unwrap_or(default)
94        };
95        let norm = cfg.get("preprocessor").and_then(|p| p.get("normalization"));
96        Ok(Self {
97            n_mels: u(n_mels, "mel bins")?,
98            channels: u(channels, "conv channels")?,
99            hidden,
100            window: u(enc.get("sliding_window"), "sliding_window")?,
101            heads,
102            kv_heads: u(lfm.get("num_key_value_heads"), "num_key_value_heads")?,
103            head_dim: hidden / heads.max(1),
104            attention,
105            conv_kernel: u(lfm.get("conv_L_cache"), "conv_L_cache")?,
106            norm_eps: lfm.get("norm_eps").and_then(Value::as_f64).unwrap_or(1e-5),
107            rope_theta: rope.get("rope_theta").and_then(Value::as_f64).unwrap_or(1e6),
108            n_fft: pre("n_fft", "n_fft", 400.0) as usize,
109            hop: pre("hop_length", "hop_length", 160.0) as usize,
110            log_floor: pre("log_floor", "logmel_floor", -12.0),
111            norm_offset: features
112                .get("normalization_offset")
113                .or_else(|| norm.and_then(|n| n.get("offset")))
114                .and_then(Value::as_f64)
115                .unwrap_or(4.0),
116            norm_divisor: features
117                .get("normalization_divisor")
118                .or_else(|| norm.and_then(|n| n.get("divisor")))
119                .and_then(Value::as_f64)
120                .unwrap_or(4.0),
121        })
122    }
123}
124
125// ───────────────────────────── features ─────────────────────────────
126
127/// Mel frequencies on Slaney's scale (linear below 1 kHz, log above).
128fn hz_to_mel(hz: f64) -> f64 {
129    let f_sp = 200.0 / 3.0;
130    let (min_log_hz, logstep) = (1000.0, (6.4f64).ln() / 27.0);
131    if hz < min_log_hz {
132        hz / f_sp
133    } else {
134        min_log_hz / f_sp + (hz / min_log_hz).ln() / logstep
135    }
136}
137
138fn mel_to_hz(m: f64) -> f64 {
139    let f_sp = 200.0 / 3.0;
140    let (min_log_hz, logstep) = (1000.0, (6.4f64).ln() / 27.0);
141    let min_log_mel = min_log_hz / f_sp;
142    if m < min_log_mel {
143        m * f_sp
144    } else {
145        min_log_hz * (logstep * (m - min_log_mel)).exp()
146    }
147}
148
149/// `transformers.audio_utils.mel_filter_bank(n_fft/2+1, n_mels, 0, sr/2,
150/// sr, norm="slaney", mel_scale="slaney")`, row-major `[n_mels][bins]`.
151fn mel_filters(n_mels: usize, n_fft: usize, sr: f64) -> Vec<f64> {
152    let bins = n_fft / 2 + 1;
153    let (lo, hi) = (hz_to_mel(0.0), hz_to_mel(sr / 2.0));
154    let edges: Vec<f64> = (0..n_mels + 2)
155        .map(|i| mel_to_hz(lo + (hi - lo) * i as f64 / (n_mels + 1) as f64))
156        .collect();
157    let mut f = vec![0f64; n_mels * bins];
158    for m in 0..n_mels {
159        let norm = 2.0 / (edges[m + 2] - edges[m]);
160        for k in 0..bins {
161            let hz = (sr / 2.0) * k as f64 / (bins - 1) as f64;
162            let down = (hz - edges[m]) / (edges[m + 1] - edges[m]);
163            let up = (edges[m + 2] - hz) / (edges[m + 2] - edges[m + 1]);
164            f[m * bins + k] = down.min(up).max(0.0) * norm;
165        }
166    }
167    f
168}
169
170/// The processor's frames for `n` samples: right-padded to whole windows.
171pub fn num_frames(n: usize, cfg: &Lfm3AudioConfig) -> usize {
172    let padded = if n < cfg.n_fft {
173        cfg.n_fft
174    } else {
175        n + (cfg.hop - (n - cfg.n_fft) % cfg.hop) % cfg.hop
176    };
177    1 + (padded - cfg.n_fft) / cfg.hop
178}
179
180/// Log-mel features of a 16 kHz mono clip, `[n_mels][frames]` (the
181/// processor's `input_features[0]`).
182pub fn log_mel(wave: &[f32], cfg: &Lfm3AudioConfig) -> (Vec<f32>, usize) {
183    use rustfft::{FftPlanner, num_complex::Complex};
184    let (n_fft, hop, n_mels) = (cfg.n_fft, cfg.hop, cfg.n_mels);
185    let frames = num_frames(wave.len(), cfg);
186    let bins = n_fft / 2 + 1;
187    let filters = mel_filters(n_mels, n_fft, 16_000.0);
188    // torch.hann_window(n_fft) — periodic.
189    let window: Vec<f64> = (0..n_fft)
190        .map(|i| 0.5 - 0.5 * (2.0 * std::f64::consts::PI * i as f64 / n_fft as f64).cos())
191        .collect();
192    let fft = FftPlanner::<f64>::new().plan_fft_forward(n_fft);
193    let mut buf = vec![Complex::new(0.0, 0.0); n_fft];
194    let mut power = vec![0f64; bins];
195    let mut out = vec![0f32; n_mels * frames];
196    for t in 0..frames {
197        for (i, b) in buf.iter_mut().enumerate() {
198            let s = wave.get(t * hop + i).copied().unwrap_or(0.0);
199            *b = Complex::new(s as f64 * window[i], 0.0);
200        }
201        fft.process(&mut buf);
202        for (p, b) in power.iter_mut().zip(&buf[..bins]) {
203            *p = b.norm_sqr();
204        }
205        for m in 0..n_mels {
206            let e: f64 = filters[m * bins..(m + 1) * bins]
207                .iter()
208                .zip(&power)
209                .map(|(f, p)| f * p)
210                .sum();
211            let l = e.log10().max(cfg.log_floor);
212            out[m * frames + t] = ((l + cfg.norm_offset) / cfg.norm_divisor) as f32;
213        }
214    }
215    (out, frames)
216}
217
218// ───────────────────────────── encoder ─────────────────────────────
219
220/// A 3×3 stride-2 convolution, padding 1, over a channel-last map
221/// `[h][w][c_in]`; `groups` = c_in (depthwise) or 1. Returns
222/// `[⌈h/2⌉][⌈w/2⌉][c_out]` and its height and width.
223#[allow(clippy::too_many_arguments)]
224fn conv3x3_s2(
225    x: &[f32],
226    h: usize,
227    w: usize,
228    c_in: usize,
229    weight: &[f32],
230    bias: &[f32],
231    c_out: usize,
232    depthwise: bool,
233    pool: Option<&Pool>,
234) -> (Vec<f32>, usize, usize) {
235    let (ho, wo) = (h.div_ceil(2), w.div_ceil(2));
236    let mut y = vec![0f32; ho * wo * c_out];
237    let dst = crate::pool::SendMut::new(y.as_mut_ptr());
238    let f = |lo: usize, hi: usize| {
239        for oy in lo..hi {
240            for ox in 0..wo {
241                // SAFETY: output rows (oy) are disjoint across workers.
242                let out = unsafe { std::slice::from_raw_parts_mut(dst.at((oy * wo + ox) * c_out), c_out) };
243                out.copy_from_slice(bias);
244                for ky in 0..3 {
245                    let iy = (2 * oy + ky) as isize - 1;
246                    if iy < 0 || iy >= h as isize {
247                        continue;
248                    }
249                    for kx in 0..3 {
250                        let ix = (2 * ox + kx) as isize - 1;
251                        if ix < 0 || ix >= w as isize {
252                            continue;
253                        }
254                        let src = &x[(iy as usize * w + ix as usize) * c_in..][..c_in];
255                        let tap = ky * 3 + kx;
256                        if depthwise {
257                            for (c, o) in out.iter_mut().enumerate() {
258                                *o += weight[c * 9 + tap] * src[c];
259                            }
260                        } else {
261                            for (c, o) in out.iter_mut().enumerate() {
262                                let wk = &weight[c * c_in * 9..];
263                                let mut acc = 0f32;
264                                for (ci, &v) in src.iter().enumerate() {
265                                    acc += wk[ci * 9 + tap] * v;
266                                }
267                                *o += acc;
268                            }
269                        }
270                    }
271                }
272            }
273        }
274    };
275    match pool {
276        Some(p) => p.run_rows(ho, &f),
277        None => f(0, ho),
278    }
279    (y, ho, wo)
280}
281
282fn silu_inplace(x: &mut [f32]) {
283    for v in x.iter_mut() {
284        *v /= 1.0 + (-*v).exp();
285    }
286}
287
288enum Mixer {
289    Conv {
290        in_proj: Dense,
291        /// `[channel][kernel]`, the last tap on the current position.
292        taps: Vec<f32>,
293        out_proj: Dense,
294    },
295    Attn {
296        q: Dense,
297        k: Dense,
298        v: Dense,
299        o: Dense,
300        q_norm: Vec<f32>,
301        k_norm: Vec<f32>,
302    },
303}
304
305struct Layer {
306    op_norm: Vec<f32>,
307    ffn_norm: Vec<f32>,
308    mixer: Mixer,
309    w1: Dense,
310    w3: Dense,
311    w2: Dense,
312}
313
314/// The speech encoder and its projector.
315pub struct Lfm3AudioEncoder {
316    pub cfg: Lfm3AudioConfig,
317    conv_in: (Vec<f32>, Vec<f32>),
318    conv_mid: (Vec<f32>, Vec<f32>),
319    lin_mid: (Dense, Vec<f32>),
320    conv_end: (Vec<f32>, Vec<f32>),
321    lin_end: (Dense, Vec<f32>),
322    lin_out: (Dense, Vec<f32>),
323    layers: Vec<Layer>,
324    final_norm: Vec<f32>,
325    ln: (Vec<f32>, Vec<f32>),
326    l1: (Dense, Vec<f32>),
327    l2: (Dense, Vec<f32>),
328    pub text_hidden: usize,
329    pool: Option<Arc<Pool>>,
330}
331
332impl Lfm3AudioEncoder {
333    pub fn from_model(model: &Arc<CmfModel>, pool: Option<Arc<Pool>>) -> Result<Self, String> {
334        let cfg = Lfm3AudioConfig::from_model(model)?;
335        let (c, d) = (cfg.channels, cfg.hidden);
336        let ds = |n: &str| format!("audio_encoder.downsample.{n}");
337        let v = |name: String, len: usize| load_vec(model, &name, len);
338        let flat = cfg.n_mels.div_ceil(8) * c;
339        let mut layers = Vec::with_capacity(cfg.attention.len());
340        let inter = model
341            .tensors
342            .iter()
343            .find(|t| t.name == "audio_encoder.lfm.layers.0.feed_forward.w1.weight")
344            .and_then(|t| t.shape.first().copied())
345            .ok_or("lfm3_audio: encoder FFN missing")?;
346        let (qd, kvd) = (cfg.heads * cfg.head_dim, cfg.kv_heads * cfg.head_dim);
347        for (i, &attn) in cfg.attention.iter().enumerate() {
348            let p = format!("audio_encoder.lfm.layers.{i}");
349            let mixer = if attn {
350                Mixer::Attn {
351                    q: load_proj(model, &format!("{p}.self_attn.q_proj.weight"), qd, d)?,
352                    k: load_proj(model, &format!("{p}.self_attn.k_proj.weight"), kvd, d)?,
353                    v: load_proj(model, &format!("{p}.self_attn.v_proj.weight"), kvd, d)?,
354                    o: load_proj(model, &format!("{p}.self_attn.out_proj.weight"), d, qd)?,
355                    q_norm: v(format!("{p}.self_attn.q_layernorm.weight"), cfg.head_dim)?,
356                    k_norm: v(format!("{p}.self_attn.k_layernorm.weight"), cfg.head_dim)?,
357                }
358            } else {
359                Mixer::Conv {
360                    in_proj: load_proj(model, &format!("{p}.conv.in_proj.weight"), 3 * d, d)?,
361                    taps: v(format!("{p}.conv.conv.weight"), d * cfg.conv_kernel)?,
362                    out_proj: load_proj(model, &format!("{p}.conv.out_proj.weight"), d, d)?,
363                }
364            };
365            layers.push(Layer {
366                op_norm: v(format!("{p}.operator_norm.weight"), d)?,
367                ffn_norm: v(format!("{p}.ffn_norm.weight"), d)?,
368                mixer,
369                w1: load_proj(model, &format!("{p}.feed_forward.w1.weight"), inter, d)?,
370                w3: load_proj(model, &format!("{p}.feed_forward.w3.weight"), inter, d)?,
371                w2: load_proj(model, &format!("{p}.feed_forward.w2.weight"), d, inter)?,
372            });
373        }
374        let text_hidden = model
375            .tensors
376            .iter()
377            .find(|t| t.name == "audio_projector.linear_2.weight")
378            .and_then(|t| t.shape.first().copied())
379            .ok_or("lfm3_audio: projector missing")?;
380        let l1_rows = model
381            .tensors
382            .iter()
383            .find(|t| t.name == "audio_projector.linear_1.weight")
384            .and_then(|t| t.shape.first().copied())
385            .ok_or("lfm3_audio: projector missing")?;
386        Ok(Self {
387            conv_in: (v(ds("conv_in.weight"), c * 9)?, v(ds("conv_in.bias"), c)?),
388            conv_mid: (v(ds("conv_mid.weight"), c * 9)?, v(ds("conv_mid.bias"), c)?),
389            lin_mid: (load_proj(model, &ds("lin_mid.weight"), c, c)?, v(ds("lin_mid.bias"), c)?),
390            conv_end: (v(ds("conv_end.weight"), c * 9)?, v(ds("conv_end.bias"), c)?),
391            lin_end: (load_proj(model, &ds("lin_end.weight"), c, c)?, v(ds("lin_end.bias"), c)?),
392            lin_out: (load_proj(model, &ds("lin_out.weight"), d, flat)?, v(ds("lin_out.bias"), d)?),
393            layers,
394            final_norm: v("audio_encoder.lfm.embedding_norm.weight".into(), d)?,
395            ln: (
396                v("audio_projector.layer_norm.weight".into(), d)?,
397                v("audio_projector.layer_norm.bias".into(), d)?,
398            ),
399            l1: (
400                load_proj(model, "audio_projector.linear_1.weight", l1_rows, d)?,
401                v("audio_projector.linear_1.bias".into(), l1_rows)?,
402            ),
403            l2: (
404                load_proj(model, "audio_projector.linear_2.weight", text_hidden, l1_rows)?,
405                v("audio_projector.linear_2.bias".into(), text_hidden)?,
406            ),
407            text_hidden,
408            cfg,
409            pool,
410        })
411    }
412
413    /// Encoder rows `[⌈frames/8⌉, hidden]` of log-mel features
414    /// `[n_mels][frames]`, before the projector.
415    pub fn encode(&self, mel: &[f32], frames: usize) -> Result<Vec<f32>, String> {
416        let c = &self.cfg;
417        let pool = self.pool.as_deref();
418        if mel.len() != c.n_mels * frames || frames == 0 {
419            return Err("lfm3_audio: features do not match the encoder".into());
420        }
421        // The (mel, time) plane, channel-last with one input channel.
422        let (h, w) = (c.n_mels, frames);
423        let ch = c.channels;
424        let (mut x, h1, w1) = conv3x3_s2(mel, h, w, 1, &self.conv_in.0, &self.conv_in.1, ch, false, pool);
425        silu_inplace(&mut x);
426        let (x2, h2, w2) = conv3x3_s2(&x, h1, w1, ch, &self.conv_mid.0, &self.conv_mid.1, ch, true, pool);
427        let mut x = vec![0f32; h2 * w2 * ch];
428        self.lin_mid.0.matmat(&x2, h2 * w2, &mut x, pool);
429        add_bias(&mut x, &self.lin_mid.1);
430        silu_inplace(&mut x);
431        let (x3, h3, w3) = conv3x3_s2(&x, h2, w2, ch, &self.conv_end.0, &self.conv_end.1, ch, true, pool);
432        let mut x = vec![0f32; h3 * w3 * ch];
433        self.lin_end.0.matmat(&x3, h3 * w3, &mut x, pool);
434        add_bias(&mut x, &self.lin_end.1);
435        silu_inplace(&mut x);
436        // flatten(channels, mel): feature c·h3 + y of time step t.
437        let n = w3;
438        let flat = ch * h3;
439        let mut f = vec![0f32; n * flat];
440        for y in 0..h3 {
441            for t in 0..n {
442                let src = &x[(y * n + t) * ch..][..ch];
443                for (cc, &val) in src.iter().enumerate() {
444                    f[t * flat + cc * h3 + y] = val;
445                }
446            }
447        }
448        let d = c.hidden;
449        let mut x = vec![0f32; n * d];
450        self.lin_out.0.matmat(&f, n, &mut x, pool);
451        add_bias(&mut x, &self.lin_out.1);
452        let mut xn = vec![0f32; n * d];
453        let mut mix = vec![0f32; n * d];
454        let (hd, half) = (c.head_dim, c.head_dim / 2);
455        let inv: Vec<f32> = (0..half)
456            .map(|i| (1.0 / c.rope_theta.powf((2 * i) as f64 / hd as f64)) as f32)
457            .collect();
458        for layer in &self.layers {
459            rms_rows(&x, &layer.op_norm, c.norm_eps, &mut xn, pool);
460            match &layer.mixer {
461                Mixer::Conv { in_proj, taps, out_proj } => {
462                    let k = c.conv_kernel;
463                    let mut bcx = vec![0f32; n * 3 * d];
464                    in_proj.matmat(&xn, n, &mut bcx, pool);
465                    let mut y = vec![0f32; n * d];
466                    for t in 0..n {
467                        for j in 0..d {
468                            let mut acc = 0f32;
469                            for tap in 0..k {
470                                let back = k - 1 - tap;
471                                if t >= back {
472                                    let r = &bcx[(t - back) * 3 * d..];
473                                    acc += taps[j * k + tap] * (r[j] * r[2 * d + j]);
474                                }
475                            }
476                            y[t * d + j] = bcx[t * 3 * d + d + j] * acc;
477                        }
478                    }
479                    out_proj.matmat(&y, n, &mut mix, pool);
480                }
481                Mixer::Attn { q, k, v, o, q_norm, k_norm } => {
482                    let (qd, kvd) = (c.heads * hd, c.kv_heads * hd);
483                    let mut qb = vec![0f32; n * qd];
484                    let mut kb = vec![0f32; n * kvd];
485                    let mut vb = vec![0f32; n * kvd];
486                    q.matmat(&xn, n, &mut qb, pool);
487                    k.matmat(&xn, n, &mut kb, pool);
488                    v.matmat(&xn, n, &mut vb, pool);
489                    for (buf, heads, w) in [(&mut qb, c.heads, q_norm), (&mut kb, c.kv_heads, k_norm)] {
490                        let mut tmp = vec![0f32; hd];
491                        for t in 0..n {
492                            for hh in 0..heads {
493                                let head = &mut buf[(t * heads + hh) * hd..][..hd];
494                                crate::inference::rms_norm_into(
495                                    head,
496                                    w,
497                                    c.norm_eps,
498                                    cortiq_core::NormStyle::Qwen,
499                                    &mut tmp,
500                                );
501                                head.copy_from_slice(&tmp);
502                                crate::attention::rope_rotate_scaled(head, t, &inv, 1.0);
503                            }
504                        }
505                    }
506                    let group = c.heads / c.kv_heads.max(1);
507                    let scale = (hd as f32).powf(-0.5);
508                    let mut ao = vec![0f32; n * qd];
509                    let mut sc = Vec::with_capacity(c.window);
510                    for t in 0..n {
511                        let lo = (t + 1).saturating_sub(c.window);
512                        for hh in 0..c.heads {
513                            let g = hh / group;
514                            let qv = &qb[(t * c.heads + hh) * hd..][..hd];
515                            sc.clear();
516                            for j in lo..=t {
517                                let kv = &kb[(j * c.kv_heads + g) * hd..][..hd];
518                                sc.push(qv.iter().zip(kv).map(|(a, b)| a * b).sum::<f32>() * scale);
519                            }
520                            let mx = sc.iter().fold(f32::NEG_INFINITY, |m, &s| m.max(s));
521                            let mut sum = 0f32;
522                            for s in sc.iter_mut() {
523                                *s = (*s - mx).exp();
524                                sum += *s;
525                            }
526                            let out = &mut ao[(t * c.heads + hh) * hd..][..hd];
527                            for (j, &p) in (lo..=t).zip(sc.iter()) {
528                                let vv = &vb[(j * c.kv_heads + g) * hd..][..hd];
529                                for (od, &x) in out.iter_mut().zip(vv) {
530                                    *od += p / sum * x;
531                                }
532                            }
533                        }
534                    }
535                    o.matmat(&ao, n, &mut mix, pool);
536                }
537            }
538            for (a, &b) in x.iter_mut().zip(&mix) {
539                *a += b;
540            }
541            rms_rows(&x, &layer.ffn_norm, c.norm_eps, &mut xn, pool);
542            let inter = layer.w1.rows;
543            let mut g = vec![0f32; n * inter];
544            let mut u = vec![0f32; n * inter];
545            layer.w1.matmat(&xn, n, &mut g, pool);
546            layer.w3.matmat(&xn, n, &mut u, pool);
547            for (a, &b) in g.iter_mut().zip(&u) {
548                *a = *a / (1.0 + (-*a).exp()) * b;
549            }
550            layer.w2.matmat(&g, n, &mut mix, pool);
551            for (a, &b) in x.iter_mut().zip(&mix) {
552                *a += b;
553            }
554        }
555        rms_rows(&x, &self.final_norm, c.norm_eps, &mut xn, pool);
556        Ok(xn)
557    }
558
559    /// Projected rows `[tokens, text_hidden]` of encoder rows.
560    pub fn project(&self, enc: &[f32]) -> Vec<f32> {
561        let pool = self.pool.as_deref();
562        let d = self.cfg.hidden;
563        let n = enc.len() / d;
564        let mut x = enc.to_vec();
565        for row in x.chunks_exact_mut(d) {
566            let mean = row.iter().map(|&v| v as f64).sum::<f64>() / d as f64;
567            let var = row.iter().map(|&v| (v as f64 - mean).powi(2)).sum::<f64>() / d as f64;
568            let inv = 1.0 / (var + 1e-5).sqrt();
569            for (j, v) in row.iter_mut().enumerate() {
570                *v = ((*v as f64 - mean) * inv) as f32 * self.ln.0[j] + self.ln.1[j];
571            }
572        }
573        let mut h1 = vec![0f32; n * self.l1.0.rows];
574        self.l1.0.matmat(&x, n, &mut h1, pool);
575        add_bias(&mut h1, &self.l1.1);
576        for v in h1.iter_mut() {
577            *v = (0.5 * *v as f64 * (1.0 + erf(*v as f64 / std::f64::consts::SQRT_2))) as f32;
578        }
579        let mut out = vec![0f32; n * self.text_hidden];
580        self.l2.0.matmat(&h1, n, &mut out, pool);
581        add_bias(&mut out, &self.l2.1);
582        out
583    }
584
585    /// One clip, 16 kHz mono, to the language model's rows.
586    pub fn rows(&self, wave: &[f32]) -> Result<Vec<f32>, String> {
587        let (mel, frames) = log_mel(wave, &self.cfg);
588        let enc = self.encode(&mel, frames)?;
589        let rows = self.project(&enc);
590        if !rows.iter().all(|v| v.is_finite()) {
591            return Err("lfm3_audio: the encoder produced non-finite rows".into());
592        }
593        Ok(rows)
594    }
595}
596
597// ───────────────────────────── prompts ─────────────────────────────
598
599/// Replace every audio part of `messages` with `{"type": "audio"}` (the
600/// template renders `<|audio|>`) and collect the sources in order.
601pub(crate) fn normalize(messages: &[Value]) -> (Vec<Value>, Vec<Value>) {
602    let mut messages = messages.to_vec();
603    let mut sources = Vec::new();
604    for message in &mut messages {
605        let Some(parts) = message.get_mut("content").and_then(Value::as_array_mut) else {
606            continue;
607        };
608        for part in parts {
609            let typ = part.get("type").and_then(Value::as_str).unwrap_or("");
610            if matches!(typ, "audio" | "input_audio" | "audio_url")
611                || part.get("input_audio").is_some()
612                || part.get("audio").is_some()
613            {
614                let src = part
615                    .get("input_audio")
616                    .or_else(|| part.get("audio_url"))
617                    .or_else(|| part.get("audio"))
618                    .cloned()
619                    .unwrap_or_else(|| part.clone());
620                let src = match (&src, src.get("url")) {
621                    (Value::Object(_), Some(u)) if src.get("data").is_none() => u.clone(),
622                    _ => src,
623                };
624                sources.push(src);
625                *part = json!({"type": "audio"});
626            }
627        }
628    }
629    (messages, sources)
630}
631
632/// Whether any message carries an audio part.
633pub fn has_audio_parts(messages: &[Value]) -> bool {
634    !normalize(messages).1.is_empty()
635}
636
637/// Render `messages`, run every clip through the encoder, expand each
638/// `<|audio|>` to one placeholder per encoder token (the processor's
639/// expansion) and assemble the prompt rows. Text-only prompts come back
640/// without rows.
641pub fn prepare(
642    model: &Arc<CmfModel>,
643    pipeline: &Pipeline,
644    messages: &[Value],
645    tools: Option<&[Value]>,
646    thinking: Option<bool>,
647) -> Result<crate::lfm3_vl::PreparedVlPrompt, String> {
648    let (messages, sources) = normalize(messages);
649    let tok = &pipeline.tokenizer;
650    let mut text = tok
651        .render_chat_json(&messages, tools, thinking)
652        .ok_or("LFM3-Audio: the chat template failed to render")?;
653    // LFM3-Audio's template has no thinking switch; Liquid's direct answer
654    // is the assistant turn prefilled with an empty think block.
655    if thinking == Some(false) && text.ends_with("<|im_start|>assistant\n") {
656        text.push_str("<think></think>");
657    }
658    if sources.is_empty() {
659        return Ok(crate::lfm3_vl::PreparedVlPrompt { token_ids: tok.with_bos(tok.encode(&text)), rows: None });
660    }
661    let enc = Lfm3AudioEncoder::from_model(model, crate::pool::Pool::from_env())?;
662    if enc.text_hidden != pipeline.hidden_size {
663        return Err("LFM3-Audio: projector width differs from the text model".into());
664    }
665    let audio_id = tok
666        .token_to_id("<|audio|>")
667        .ok_or("LFM3-Audio: the tokenizer has no <|audio|> token")?;
668    let dump = std::env::var_os("CMF_LFM3_AUDIO_DUMP").map(std::path::PathBuf::from);
669    let mut sources = sources.into_iter();
670    let mut expanded = String::with_capacity(text.len());
671    let mut media_rows: Vec<f32> = Vec::new();
672    let mut rest = text.as_str();
673    let mut k = 0usize;
674    while let Some(at) = rest.find("<|audio|>") {
675        expanded.push_str(&rest[..at]);
676        rest = &rest[at + "<|audio|>".len()..];
677        let src = sources
678            .next()
679            .ok_or("LFM3-Audio: more <|audio|> placeholders than clips")?;
680        let wave = crate::egemma2_audio::load_audio_source(&src)?;
681        let t0 = std::time::Instant::now();
682        let (mel, frames) = log_mel(&wave, &enc.cfg);
683        let er = enc.encode(&mel, frames)?;
684        let rows = enc.project(&er);
685        if let Some(dir) = &dump {
686            let write = |name: String, v: &[f32]| {
687                let bytes: Vec<u8> = v.iter().flat_map(|x| x.to_le_bytes()).collect();
688                std::fs::write(dir.join(name), bytes).map_err(|e| format!("CMF_LFM3_AUDIO_DUMP: {e}"))
689            };
690            write(format!("mel_{k}.f32"), &mel)?;
691            write(format!("enc_{k}.f32"), &er)?;
692            write(format!("rows_{k}.f32"), &rows)?;
693            eprintln!(
694                "lfm3-audio: clip {k}: {} samples, {frames} frames, {} tokens, encoder {:.2} s",
695                wave.len(),
696                rows.len() / enc.text_hidden,
697                t0.elapsed().as_secs_f64()
698            );
699        }
700        for _ in 0..rows.len() / enc.text_hidden {
701            expanded.push_str("<|audio|>");
702        }
703        media_rows.extend(rows);
704        k += 1;
705    }
706    expanded.push_str(rest);
707    if sources.next().is_some() {
708        return Err("LFM3-Audio: the template rendered fewer placeholders than clips".into());
709    }
710    let ids = tok.with_bos(tok.encode(&expanded));
711    let h = pipeline.hidden_size;
712    let mut rows = Vec::with_capacity(ids.len() * h);
713    let mut at = 0usize;
714    for &id in &ids {
715        if id == audio_id {
716            let row = media_rows
717                .get(at..at + h)
718                .ok_or("LFM3-Audio: placeholder and clip row counts differ")?;
719            rows.extend_from_slice(row);
720            at += h;
721        } else {
722            rows.extend(pipeline.embed_id(id));
723        }
724    }
725    if at != media_rows.len() {
726        return Err("LFM3-Audio: unused clip rows".into());
727    }
728    Ok(crate::lfm3_vl::PreparedVlPrompt { token_ids: ids, rows: Some(rows) })
729}
730
731/// Token ids a text answer must not start a speech or mixed turn with
732/// (`suppress_audio_out` / `suppress_mixed`).
733pub fn text_only_suppress(tok: &crate::tokenizer::Tokenizer) -> Vec<u32> {
734    ["<|audio_start|>", "<|mixed_start|>"]
735        .iter()
736        .filter_map(|t| tok.token_to_id(t))
737        .collect()
738}
739
740/// [`prepare`] on the pipeline's own file.
741pub fn prepare_for_pipeline(
742    pipeline: &Pipeline,
743    messages: &[Value],
744    tools: Option<&[Value]>,
745    thinking: Option<bool>,
746) -> Result<crate::lfm3_vl::PreparedVlPrompt, String> {
747    let model = pipeline
748        .model
749        .as_ref()
750        .ok_or("LFM3-Audio clips require a file-backed pipeline")?;
751    prepare(model, pipeline, messages, tools, thinking)
752}
753
754#[cfg(test)]
755mod tests {
756    use super::*;
757
758    fn cfg() -> Lfm3AudioConfig {
759        Lfm3AudioConfig {
760            n_mels: 128, channels: 256, hidden: 768, window: 32, heads: 8, kv_heads: 4,
761            head_dim: 96, attention: vec![], conv_kernel: 3, norm_eps: 1e-5, rope_theta: 1e6,
762            n_fft: 400, hop: 160, log_floor: -12.0, norm_offset: 4.0, norm_divisor: 4.0,
763        }
764    }
765
766    /// The processor's frame count: right-padded to whole hops, at least
767    /// one window.
768    #[test]
769    fn frames_match_the_processor() {
770        let c = cfg();
771        assert_eq!(num_frames(100, &c), 1);
772        assert_eq!(num_frames(400, &c), 1);
773        assert_eq!(num_frames(401, &c), 2);
774        assert_eq!(num_frames(560, &c), 2);
775        // 69,680 past the first window is 435.5 hops: padded to 436.
776        assert_eq!(num_frames(70_080, &c), 437);
777    }
778
779    /// Slaney filters integrate to the band's normalized area: the first
780    /// filter peaks at its centre bin and every filter is non-negative.
781    #[test]
782    fn slaney_filters_are_triangles() {
783        let f = mel_filters(128, 400, 16_000.0);
784        assert!(f.iter().all(|&v| v >= 0.0));
785        assert!(f[..201].iter().any(|&v| v > 0.0));
786    }
787}