Skip to main content

cortiq_engine/
ltxaudio.rs

1//! The LTX-2.5 audio path: the spectrogram VAE decoder and the BigVGAN
2//! vocoder that turns its output into a waveform.
3//!
4//! The transformer denoises sound in the same 48 blocks as the picture, so
5//! the soundtrack arrives as a `[8, T, 16]` latent. Turning that into audio
6//! is two models:
7//!
8//! 1. **The audio VAE decoder** — 2-D convolutions over (time, mel bin) with
9//!    PixelNorm and *height-causal* padding, so a frame never sees the
10//!    future. Two mid blocks, then three levels of three residual blocks
11//!    with a nearest ×2 between them; the first row after each upsample is
12//!    dropped, because the causal padding on the following convolution
13//!    already accounts for it. Out comes a 64-bin log-mel spectrogram.
14//! 2. **BigVGAN v2 with bandwidth extension** — `conv_pre`, six transposed
15//!    convolutions each followed by three anti-aliased multi-receptive-field
16//!    blocks whose outputs are averaged, and `conv_post`. Every activation is
17//!    a SnakeBeta sandwiched between a ×2 sinc upsample and a ×2 sinc
18//!    downsample, which is what keeps the harmonics it generates from
19//!    aliasing. That gives 16 kHz stereo; a second generator predicts a
20//!    residual from its mel spectrogram and adds it to a sinc-resampled copy
21//!    at 48 kHz.
22//!
23//! Everything is f32 throughout: the reference notes that bf16 accumulation
24//! through 108 sequential convolutions costs 40-90 % on spectral metrics.
25
26use crate::pool::Pool;
27use cortiq_core::CmfModel;
28use std::sync::Arc;
29
30fn tensor_f32(model: &Arc<CmfModel>, name: &str) -> Result<(Vec<f32>, Vec<usize>), String> {
31    let e = model
32        .tensor(name)
33        .ok_or_else(|| format!("missing tensor {name}"))?;
34    let mut out = vec![0.0f32; e.n_elems()];
35    cortiq_core::quant::dequant_tensor(e, model.entry_bytes(e), &mut out)?;
36    Ok((out, e.shape.clone()))
37}
38
39fn silu(v: f32) -> f32 {
40    v / (1.0 + (-v).exp())
41}
42
43/// A `[C, H, W]` plane: channels, time, mel bin.
44#[derive(Clone)]
45pub struct Grid {
46    pub c: usize,
47    pub h: usize,
48    pub w: usize,
49    pub data: Vec<f32>,
50}
51
52impl Grid {
53    fn zeros(c: usize, h: usize, w: usize) -> Grid {
54        Grid {
55            c,
56            h,
57            w,
58            data: vec![0.0; c * h * w],
59        }
60    }
61    fn n(&self) -> usize {
62        self.h * self.w
63    }
64}
65
66/// 2-D convolution, padded symmetrically on the mel axis and **causally on
67/// the time axis** — the whole kernel extent on the left, nothing on the
68/// right.
69struct Conv2d {
70    w: Vec<f32>,
71    b: Vec<f32>,
72    c_out: usize,
73    c_in: usize,
74    kh: usize,
75    kw: usize,
76}
77
78impl Conv2d {
79    fn load(model: &Arc<CmfModel>, name: &str) -> Result<Conv2d, String> {
80        let (w, s) = tensor_f32(model, &format!("{name}.weight"))?;
81        let (b, _) = tensor_f32(model, &format!("{name}.bias"))?;
82        Ok(Conv2d {
83            w,
84            b,
85            c_out: s[0],
86            c_in: s[1],
87            kh: s[2],
88            kw: s[3],
89        })
90    }
91
92    fn forward(&self, x: &Grid, pool: Option<&Pool>) -> Grid {
93        let (h, w) = (x.h, x.w);
94        let npos = h * w;
95        let k = self.c_in * self.kh * self.kw;
96        let mut out = Grid::zeros(self.c_out, h, w);
97        let pad_h = self.kh - 1; // causal: all of it on the left
98        let pad_w = (self.kw - 1) / 2;
99        const CHUNK: usize = 8192;
100        let mut patches = vec![0f32; CHUNK.min(npos) * k];
101        let mut ys = vec![0f32; CHUNK.min(npos) * self.c_out];
102        let mut p0 = 0usize;
103        while p0 < npos {
104            let n = CHUNK.min(npos - p0);
105            patches[..n * k].fill(0.0);
106            for i in 0..n {
107                let p = p0 + i;
108                let (pwi, phi) = (p % w, p / w);
109                for ci in 0..self.c_in {
110                    for a in 0..self.kh {
111                        let sh = phi as isize + a as isize - pad_h as isize;
112                        if sh < 0 || sh >= h as isize {
113                            continue;
114                        }
115                        for bb in 0..self.kw {
116                            let sw = pwi as isize + bb as isize - pad_w as isize;
117                            if sw < 0 || sw >= w as isize {
118                                continue;
119                            }
120                            patches[i * k + (ci * self.kh + a) * self.kw + bb] =
121                                x.data[(ci * h + sh as usize) * w + sw as usize];
122                        }
123                    }
124                }
125            }
126            crate::fcd_ops::gemm_nt(
127                &patches[..n * k],
128                &self.w,
129                &mut ys[..n * self.c_out],
130                n,
131                k,
132                self.c_out,
133                pool,
134            );
135            for i in 0..n {
136                for co in 0..self.c_out {
137                    out.data[co * npos + p0 + i] = ys[i * self.c_out + co] + self.b[co];
138                }
139            }
140            p0 += n;
141        }
142        out
143    }
144}
145
146/// RMS across channels at each (time, mel) location — no learned weight.
147fn pixel_norm(x: &mut Grid) {
148    let n = x.n();
149    for p in 0..n {
150        let mut ss = 0f64;
151        for c in 0..x.c {
152            let v = x.data[c * n + p] as f64;
153            ss += v * v;
154        }
155        let inv = 1.0 / (ss / x.c as f64 + 1e-6).sqrt();
156        for c in 0..x.c {
157            x.data[c * n + p] = (x.data[c * n + p] as f64 * inv) as f32;
158        }
159    }
160}
161
162struct ResnetBlock {
163    conv1: Conv2d,
164    conv2: Conv2d,
165    shortcut: Option<Conv2d>,
166}
167
168impl ResnetBlock {
169    fn load(model: &Arc<CmfModel>, p: &str) -> Result<ResnetBlock, String> {
170        Ok(ResnetBlock {
171            conv1: Conv2d::load(model, &format!("{p}.conv1.conv"))?,
172            conv2: Conv2d::load(model, &format!("{p}.conv2.conv"))?,
173            shortcut: match model.tensor(&format!("{p}.nin_shortcut.conv.weight")) {
174                Some(_) => Some(Conv2d::load(model, &format!("{p}.nin_shortcut.conv"))?),
175                None => None,
176            },
177        })
178    }
179
180    fn forward(&self, x: &Grid, pool: Option<&Pool>) -> Grid {
181        let mut h = x.clone();
182        pixel_norm(&mut h);
183        h.data.iter_mut().for_each(|v| *v = silu(*v));
184        let mut h = self.conv1.forward(&h, pool);
185        pixel_norm(&mut h);
186        h.data.iter_mut().for_each(|v| *v = silu(*v));
187        let mut h = self.conv2.forward(&h, pool);
188        let res = match &self.shortcut {
189            Some(c) => c.forward(x, pool),
190            None => x.clone(),
191        };
192        for (v, &r) in h.data.iter_mut().zip(&res.data) {
193            *v += r;
194        }
195        h
196    }
197}
198
199/// Nearest ×2 on both axes, a convolution, then the first time row dropped
200/// — the causal padding on the convolution has already reproduced it.
201fn upsample2(x: &Grid, conv: &Conv2d, pool: Option<&Pool>) -> Grid {
202    let (h2, w2) = (x.h * 2, x.w * 2);
203    let mut up = Grid::zeros(x.c, h2, w2);
204    for c in 0..x.c {
205        for y in 0..h2 {
206            for z in 0..w2 {
207                up.data[(c * h2 + y) * w2 + z] = x.data[(c * x.h + y / 2) * x.w + z / 2];
208            }
209        }
210    }
211    let conved = conv.forward(&up, pool);
212    let mut out = Grid::zeros(conved.c, h2 - 1, w2);
213    for c in 0..conved.c {
214        for y in 1..h2 {
215            for z in 0..w2 {
216                out.data[(c * (h2 - 1) + y - 1) * w2 + z] = conved.data[(c * h2 + y) * w2 + z];
217            }
218        }
219    }
220    out
221}
222
223pub struct AudioVaeDecoder {
224    conv_in: Conv2d,
225    mid: Vec<ResnetBlock>,
226    levels: Vec<(Vec<ResnetBlock>, Option<Conv2d>)>,
227    conv_out: Conv2d,
228    mean: Vec<f32>,
229    std: Vec<f32>,
230}
231
232impl AudioVaeDecoder {
233    pub fn from_cmf(model: &Arc<CmfModel>) -> Result<AudioVaeDecoder, String> {
234        let mut levels = Vec::new();
235        let mut lv = 0usize;
236        while model
237            .tensor(&format!("avae.decoder.up.{lv}.block.0.conv1.conv.weight"))
238            .is_some()
239        {
240            let mut blocks = Vec::new();
241            let mut bi = 0usize;
242            while model
243                .tensor(&format!(
244                    "avae.decoder.up.{lv}.block.{bi}.conv1.conv.weight"
245                ))
246                .is_some()
247            {
248                blocks.push(ResnetBlock::load(
249                    model,
250                    &format!("avae.decoder.up.{lv}.block.{bi}"),
251                )?);
252                bi += 1;
253            }
254            let up = match model.tensor(&format!("avae.decoder.up.{lv}.upsample.conv.conv.weight"))
255            {
256                Some(_) => Some(Conv2d::load(
257                    model,
258                    &format!("avae.decoder.up.{lv}.upsample.conv.conv"),
259                )?),
260                None => None,
261            };
262            levels.push((blocks, up));
263            lv += 1;
264        }
265        Ok(AudioVaeDecoder {
266            conv_in: Conv2d::load(model, "avae.decoder.conv_in.conv")?,
267            mid: vec![
268                ResnetBlock::load(model, "avae.decoder.mid.block_1")?,
269                ResnetBlock::load(model, "avae.decoder.mid.block_2")?,
270            ],
271            levels,
272            conv_out: Conv2d::load(model, "avae.decoder.conv_out.conv")?,
273            mean: tensor_f32(model, "avae.per_channel_statistics.mean-of-means")?.0,
274            std: tensor_f32(model, "avae.per_channel_statistics.std-of-means")?.0,
275        })
276    }
277
278    /// `[8, T, 16]` latent → `[2, 4T-3, 64]` log-mel spectrogram.
279    pub fn decode(&self, latent: &Grid, pool: Option<&Pool>) -> Grid {
280        // The statistics are per *patchified* channel (channel-major over mel
281        // bins), which is how the transformer sees them.
282        let mut x = latent.clone();
283        let n = x.n();
284        for c in 0..x.c {
285            for wi in 0..x.w {
286                let idx = c * x.w + wi;
287                let (m, s) = (self.mean[idx], self.std[idx]);
288                for hi in 0..x.h {
289                    let o = (c * x.h + hi) * x.w + wi;
290                    x.data[o] = x.data[o] * s + m;
291                }
292            }
293        }
294        let _ = n;
295        let mut h = self.conv_in.forward(&x, pool);
296        for b in &self.mid {
297            h = b.forward(&h, pool);
298        }
299        for (blocks, up) in self.levels.iter().rev() {
300            for b in blocks {
301                h = b.forward(&h, pool);
302            }
303            if let Some(c) = up {
304                h = upsample2(&h, c, pool);
305            }
306        }
307        pixel_norm(&mut h);
308        h.data.iter_mut().for_each(|v| *v = silu(*v));
309        let out = self.conv_out.forward(&h, pool);
310        // the causal decoder produces 4T-3 frames; crop to that
311        let target = (latent.h * 4).saturating_sub(3).max(1);
312        if out.h == target {
313            return out;
314        }
315        let mut cropped = Grid::zeros(out.c, target.min(out.h), out.w);
316        for c in 0..out.c {
317            for y in 0..cropped.h {
318                for z in 0..out.w {
319                    cropped.data[(c * cropped.h + y) * out.w + z] =
320                        out.data[(c * out.h + y) * out.w + z];
321                }
322            }
323        }
324        cropped
325    }
326}
327
328// ------------------------------------------------------------- 1-D layers
329
330/// A multi-channel signal `[C, T]`.
331#[derive(Clone)]
332pub struct Sig {
333    pub c: usize,
334    pub t: usize,
335    pub data: Vec<f32>,
336}
337
338impl Sig {
339    fn zeros(c: usize, t: usize) -> Sig {
340        Sig {
341            c,
342            t,
343            data: vec![0.0; c * t],
344        }
345    }
346}
347
348struct Conv1d {
349    w: Vec<f32>,
350    b: Option<Vec<f32>>,
351    c_out: usize,
352    c_in: usize,
353    k: usize,
354    dilation: usize,
355    pad: usize,
356}
357
358impl Conv1d {
359    fn load(model: &Arc<CmfModel>, name: &str, dilation: usize) -> Result<Conv1d, String> {
360        let (w, s) = tensor_f32(model, &format!("{name}.weight"))?;
361        let b = tensor_f32(model, &format!("{name}.bias")).ok().map(|x| x.0);
362        let k = s[2];
363        Ok(Conv1d {
364            w,
365            b,
366            c_out: s[0],
367            c_in: s[1],
368            k,
369            dilation,
370            pad: (k - 1) * dilation / 2,
371        })
372    }
373
374    fn forward(&self, x: &Sig, pool: Option<&Pool>) -> Sig {
375        let t = x.t;
376        let kk = self.c_in * self.k;
377        let mut patches = vec![0f32; t * kk];
378        for p in 0..t {
379            for ci in 0..self.c_in {
380                for a in 0..self.k {
381                    let s = p as isize + (a * self.dilation) as isize - self.pad as isize;
382                    if s >= 0 && s < t as isize {
383                        patches[p * kk + ci * self.k + a] = x.data[ci * t + s as usize];
384                    }
385                }
386            }
387        }
388        let mut ys = vec![0f32; t * self.c_out];
389        crate::fcd_ops::gemm_nt(&patches, &self.w, &mut ys, t, kk, self.c_out, pool);
390        let mut out = Sig::zeros(self.c_out, t);
391        for p in 0..t {
392            for co in 0..self.c_out {
393                out.data[co * t + p] =
394                    ys[p * self.c_out + co] + self.b.as_ref().map_or(0.0, |b| b[co]);
395            }
396        }
397        out
398    }
399}
400
401/// `ConvTranspose1d(in, out, k, stride, padding)`, weights `[in, out, k]`.
402struct ConvT1d {
403    w: Vec<f32>,
404    b: Option<Vec<f32>>,
405    c_in: usize,
406    c_out: usize,
407    k: usize,
408    stride: usize,
409    pad: usize,
410}
411
412impl ConvT1d {
413    fn load(model: &Arc<CmfModel>, name: &str, stride: usize) -> Result<ConvT1d, String> {
414        let (w, s) = tensor_f32(model, &format!("{name}.weight"))?;
415        let b = tensor_f32(model, &format!("{name}.bias")).ok().map(|x| x.0);
416        let k = s[2];
417        Ok(ConvT1d {
418            w,
419            b,
420            c_in: s[0],
421            c_out: s[1],
422            k,
423            stride,
424            pad: (k - stride) / 2,
425        })
426    }
427
428    fn forward(&self, x: &Sig) -> Sig {
429        let t_out = (x.t - 1) * self.stride + self.k - 2 * self.pad;
430        let mut out = Sig::zeros(self.c_out, t_out);
431        for ci in 0..self.c_in {
432            for p in 0..x.t {
433                let v = &x.data[ci * x.t + p];
434                if *v == 0.0 {
435                    continue;
436                }
437                let base = p * self.stride;
438                for a in 0..self.k {
439                    let o = base + a;
440                    if o < self.pad || o - self.pad >= t_out {
441                        continue;
442                    }
443                    let oo = o - self.pad;
444                    for co in 0..self.c_out {
445                        out.data[co * t_out + oo] +=
446                            v * self.w[(ci * self.c_out + co) * self.k + a];
447                    }
448                }
449            }
450        }
451        if let Some(b) = &self.b {
452            for co in 0..self.c_out {
453                for v in out.data[co * t_out..(co + 1) * t_out].iter_mut() {
454                    *v += b[co];
455                }
456            }
457        }
458        out
459    }
460}
461
462/// The anti-aliasing pair around every activation: a ×2 sinc upsample, the
463/// nonlinearity, a ×2 sinc downsample. Both filters ship in the checkpoint.
464struct Aliasing {
465    up: Vec<f32>,
466    down: Vec<f32>,
467    ratio: usize,
468}
469
470impl Aliasing {
471    fn load(model: &Arc<CmfModel>, p: &str) -> Result<Aliasing, String> {
472        Ok(Aliasing {
473            up: tensor_f32(model, &format!("{p}.upsample.filter"))?.0,
474            down: tensor_f32(model, &format!("{p}.downsample.lowpass.filter"))?.0,
475            ratio: 2,
476        })
477    }
478
479    fn upsample(&self, x: &Sig) -> Sig {
480        let k = self.up.len();
481        let stride = self.ratio;
482        let pad = k / stride - 1;
483        let pad_left = pad * stride + (k - stride) / 2;
484        let pad_right = pad * stride + (k - stride).div_ceil(2);
485        // replicate-pad, transposed convolution, then the same trim the
486        // reference takes
487        let tp = x.t + 2 * pad;
488        let full = (tp - 1) * stride + k;
489        let mut out = Sig::zeros(x.c, full);
490        for c in 0..x.c {
491            for p in 0..tp {
492                let src = (p as isize - pad as isize).clamp(0, x.t as isize - 1) as usize;
493                let v = x.data[c * x.t + src] * self.ratio as f32;
494                if v == 0.0 {
495                    continue;
496                }
497                for a in 0..k {
498                    out.data[c * full + p * stride + a] += v * self.up[a];
499                }
500            }
501        }
502        let (lo, hi) = (pad_left, full - pad_right);
503        let t2 = hi - lo;
504        let mut trimmed = Sig::zeros(x.c, t2);
505        for c in 0..x.c {
506            trimmed.data[c * t2..(c + 1) * t2]
507                .copy_from_slice(&out.data[c * full + lo..c * full + hi]);
508        }
509        trimmed
510    }
511
512    fn downsample(&self, x: &Sig) -> Sig {
513        let k = self.down.len();
514        let pad_left = k / 2 - if k % 2 == 0 { 1 } else { 0 };
515        let pad_right = k / 2;
516        let tp = x.t + pad_left + pad_right;
517        let t2 = (tp - k) / self.ratio + 1;
518        let mut out = Sig::zeros(x.c, t2);
519        for c in 0..x.c {
520            for p in 0..t2 {
521                let mut acc = 0f32;
522                for a in 0..k {
523                    let s = (p * self.ratio + a) as isize - pad_left as isize;
524                    let s = s.clamp(0, x.t as isize - 1) as usize;
525                    acc += x.data[c * x.t + s] * self.down[a];
526                }
527                out.data[c * t2 + p] = acc;
528            }
529        }
530        out
531    }
532}
533
534/// `x + sin(αx)² / β`, with α and β kept in log space.
535struct SnakeBeta {
536    alpha: Vec<f32>,
537    beta: Vec<f32>,
538    aa: Aliasing,
539}
540
541impl SnakeBeta {
542    fn load(model: &Arc<CmfModel>, p: &str) -> Result<SnakeBeta, String> {
543        Ok(SnakeBeta {
544            alpha: tensor_f32(model, &format!("{p}.act.alpha"))?.0,
545            beta: tensor_f32(model, &format!("{p}.act.beta"))?.0,
546            aa: Aliasing::load(model, p)?,
547        })
548    }
549
550    fn forward(&self, x: &Sig) -> Sig {
551        let mut up = self.aa.upsample(x);
552        for c in 0..up.c {
553            let a = self.alpha[c].exp();
554            let b = self.beta[c].exp();
555            for v in up.data[c * up.t..(c + 1) * up.t].iter_mut() {
556                let s = (*v * a).sin();
557                *v += s * s / (b + 1e-9);
558            }
559        }
560        self.aa.downsample(&up)
561    }
562}
563
564/// One multi-receptive-field block: three dilated conv pairs, each wrapped
565/// in its own anti-aliased activation, summed into the residual.
566struct AmpBlock {
567    convs1: Vec<Conv1d>,
568    convs2: Vec<Conv1d>,
569    acts1: Vec<SnakeBeta>,
570    acts2: Vec<SnakeBeta>,
571}
572
573impl AmpBlock {
574    fn load(model: &Arc<CmfModel>, p: &str, dil: &[usize]) -> Result<AmpBlock, String> {
575        let mut convs1 = Vec::new();
576        let mut convs2 = Vec::new();
577        let mut acts1 = Vec::new();
578        let mut acts2 = Vec::new();
579        for (i, &d) in dil.iter().enumerate() {
580            convs1.push(Conv1d::load(model, &format!("{p}.convs1.{i}"), d)?);
581            convs2.push(Conv1d::load(model, &format!("{p}.convs2.{i}"), 1)?);
582            acts1.push(SnakeBeta::load(model, &format!("{p}.acts1.{i}"))?);
583            acts2.push(SnakeBeta::load(model, &format!("{p}.acts2.{i}"))?);
584        }
585        Ok(AmpBlock {
586            convs1,
587            convs2,
588            acts1,
589            acts2,
590        })
591    }
592
593    fn forward(&self, x: &Sig, pool: Option<&Pool>) -> Sig {
594        let mut x = x.clone();
595        for i in 0..self.convs1.len() {
596            let h = self.acts1[i].forward(&x);
597            let h = self.convs1[i].forward(&h, pool);
598            let h = self.acts2[i].forward(&h);
599            let h = self.convs2[i].forward(&h, pool);
600            for (v, &y) in x.data.iter_mut().zip(&h.data) {
601                *v += y;
602            }
603        }
604        x
605    }
606}
607
608/// BigVGAN v2: `conv_pre`, then per level a transposed convolution and three
609/// receptive-field blocks whose outputs are averaged, then `conv_post`.
610pub struct Vocoder {
611    conv_pre: Conv1d,
612    ups: Vec<ConvT1d>,
613    blocks: Vec<AmpBlock>,
614    per_level: usize,
615    act_post: SnakeBeta,
616    conv_post: Conv1d,
617    tanh_final: bool,
618    apply_final: bool,
619}
620
621impl Vocoder {
622    fn from_cmf(
623        model: &Arc<CmfModel>,
624        p: &str,
625        rates: &[usize],
626        dils: &[Vec<usize>],
627        apply_final: bool,
628    ) -> Result<Vocoder, String> {
629        let mut ups = Vec::new();
630        for (i, &r) in rates.iter().enumerate() {
631            ups.push(ConvT1d::load(model, &format!("{p}.ups.{i}"), r)?);
632        }
633        let mut blocks = Vec::new();
634        let mut i = 0usize;
635        while model
636            .tensor(&format!("{p}.resblocks.{i}.convs1.0.weight"))
637            .is_some()
638        {
639            blocks.push(AmpBlock::load(
640                model,
641                &format!("{p}.resblocks.{i}"),
642                &dils[i % dils.len()],
643            )?);
644            i += 1;
645        }
646        let per_level = blocks.len() / rates.len().max(1);
647        Ok(Vocoder {
648            conv_pre: Conv1d::load(model, &format!("{p}.conv_pre"), 1)?,
649            ups,
650            blocks,
651            per_level,
652            act_post: SnakeBeta::load(model, &format!("{p}.act_post"))?,
653            conv_post: Conv1d::load(model, &format!("{p}.conv_post"), 1)?,
654            tanh_final: false,
655            apply_final,
656        })
657    }
658
659    /// `[2, T, mel]` log-mel → `[2, T·∏rates]` waveform.
660    fn forward(&self, mel: &Grid, pool: Option<&Pool>) -> Sig {
661        // (channels, time, mel) → (channels·mel, time)
662        let mut x = Sig::zeros(mel.c * mel.w, mel.h);
663        for s in 0..mel.c {
664            for m in 0..mel.w {
665                let c = s * mel.w + m;
666                for t in 0..mel.h {
667                    x.data[c * mel.h + t] = mel.data[(s * mel.h + t) * mel.w + m];
668                }
669            }
670        }
671        let dbg = std::env::var("CMF_LTX_VOC_DBG").is_ok();
672        let rms = |name: &str, s: &Sig| {
673            let n = s.data.len().max(1) as f64;
674            let r = (s.data.iter().map(|&x| (x as f64) * (x as f64)).sum::<f64>() / n).sqrt();
675            println!("  voc {name:<10} [{}, {}] rms {r:.6}", s.c, s.t);
676        };
677        let mut h = self.conv_pre.forward(&x, pool);
678        if dbg {
679            rms("in", &x);
680            rms("conv_pre", &h);
681        }
682        for (i, up) in self.ups.iter().enumerate() {
683            h = up.forward(&h);
684            let mut acc: Option<Sig> = None;
685            for j in 0..self.per_level {
686                let b = &self.blocks[i * self.per_level + j];
687                let o = b.forward(&h, pool);
688                match &mut acc {
689                    None => acc = Some(o),
690                    Some(a) => {
691                        for (v, &y) in a.data.iter_mut().zip(&o.data) {
692                            *v += y;
693                        }
694                    }
695                }
696            }
697            h = acc.unwrap();
698            let inv = 1.0 / self.per_level as f32;
699            h.data.iter_mut().for_each(|v| *v *= inv);
700            if dbg {
701                rms(&format!("level{i}"), &h);
702            }
703        }
704        let h = self.act_post.forward(&h);
705        let mut out = self.conv_post.forward(&h, pool);
706        if self.apply_final {
707            out.data.iter_mut().for_each(|v| {
708                *v = if self.tanh_final {
709                    v.tanh()
710                } else {
711                    v.clamp(-1.0, 1.0)
712                }
713            });
714        }
715        out
716    }
717}
718
719/// The causal log-mel the bandwidth extender is conditioned on: an STFT
720/// carried out as a convolution with the checkpoint's own DFT × Hann bases,
721/// so the numbers match what the extender was trained against.
722struct MelStft {
723    forward_basis: Vec<f32>,
724    mel_basis: Vec<f32>,
725    n_freqs: usize,
726    filter_len: usize,
727    hop: usize,
728    n_mels: usize,
729}
730
731impl MelStft {
732    fn load(model: &Arc<CmfModel>, p: &str, hop: usize) -> Result<MelStft, String> {
733        let (fb, fs) = tensor_f32(model, &format!("{p}.stft_fn.forward_basis"))?;
734        let (mb, ms) = tensor_f32(model, &format!("{p}.mel_basis"))?;
735        Ok(MelStft {
736            n_freqs: fs[0] / 2,
737            filter_len: fs[2],
738            forward_basis: fb,
739            mel_basis: mb,
740            n_mels: ms[0],
741            hop,
742        })
743    }
744
745    /// `[C, T]` waveform → `[C, frames, n_mels]` log-mel.
746    fn forward(&self, x: &Sig) -> Grid {
747        let left = self.filter_len.saturating_sub(self.hop);
748        let padded = x.t + left;
749        let frames = if padded >= self.filter_len {
750            (padded - self.filter_len) / self.hop + 1
751        } else {
752            0
753        };
754        let mut out = Grid::zeros(x.c, frames, self.n_mels);
755        let rows = 2 * self.n_freqs;
756        let mut mag = vec![0f32; self.n_freqs];
757        for c in 0..x.c {
758            for f in 0..frames {
759                let start = f * self.hop;
760                for r in 0..self.n_freqs {
761                    let mut re = 0f32;
762                    let mut im = 0f32;
763                    for j in 0..self.filter_len {
764                        let idx = start + j;
765                        let v = if idx < left {
766                            0.0
767                        } else {
768                            let s = idx - left;
769                            if s < x.t { x.data[c * x.t + s] } else { 0.0 }
770                        };
771                        re += v * self.forward_basis[r * self.filter_len + j];
772                        im += v * self.forward_basis[(self.n_freqs + r) * self.filter_len + j];
773                    }
774                    mag[r] = (re * re + im * im).sqrt();
775                }
776                for m in 0..self.n_mels {
777                    let mut acc = 0f32;
778                    for r in 0..self.n_freqs {
779                        acc += self.mel_basis[m * self.n_freqs + r] * mag[r];
780                    }
781                    out.data[(c * frames + f) * self.n_mels + m] = acc.max(1e-5).ln();
782                }
783            }
784        }
785        let _ = rows;
786        out
787    }
788}
789
790/// A Hann-windowed sinc resampler, the ×3 skip path from 16 kHz to 48 kHz.
791/// The reference does not store this filter, so it is rebuilt here.
792fn hann_sinc_upsample(x: &Sig, ratio: usize) -> Sig {
793    let rolloff = 0.99f64;
794    let lpw = 6f64;
795    let width = (lpw / rolloff).ceil() as usize;
796    let k = 2 * width * ratio + 1;
797    let pad = width;
798    let pad_left = 2 * width * ratio;
799    let pad_right = k - ratio;
800    let filt: Vec<f32> = (0..k)
801        .map(|i| {
802            let ta = (i as f64 / ratio as f64 - width as f64) * rolloff;
803            let tc = ta.clamp(-lpw, lpw);
804            let win = (tc * std::f64::consts::PI / lpw / 2.0).cos().powi(2);
805            let s = if ta == 0.0 {
806                1.0
807            } else {
808                (std::f64::consts::PI * ta).sin() / (std::f64::consts::PI * ta)
809            };
810            (s * win * rolloff / ratio as f64) as f32
811        })
812        .collect();
813    let tp = x.t + 2 * pad;
814    let full = (tp - 1) * ratio + k;
815    let mut acc = Sig::zeros(x.c, full);
816    for c in 0..x.c {
817        for p in 0..tp {
818            let src = (p as isize - pad as isize).clamp(0, x.t as isize - 1) as usize;
819            let v = x.data[c * x.t + src] * ratio as f32;
820            if v == 0.0 {
821                continue;
822            }
823            for a in 0..k {
824                acc.data[c * full + p * ratio + a] += v * filt[a];
825            }
826        }
827    }
828    let (lo, hi) = (pad_left, full - pad_right);
829    let t2 = hi - lo;
830    let mut out = Sig::zeros(x.c, t2);
831    for c in 0..x.c {
832        out.data[c * t2..(c + 1) * t2].copy_from_slice(&acc.data[c * full + lo..c * full + hi]);
833    }
834    out
835}
836
837/// The whole audio tail: latent → spectrogram → 16 kHz → 48 kHz.
838pub struct AudioStack {
839    pub decoder: AudioVaeDecoder,
840    vocoder: Vocoder,
841    bwe: Vocoder,
842    mel: MelStft,
843    hop: usize,
844    in_rate: usize,
845    pub out_rate: usize,
846}
847
848impl AudioStack {
849    pub fn from_cmf(model: &Arc<CmfModel>) -> Result<AudioStack, String> {
850        let cfg: serde_json::Value = ["avae.config_json"]
851            .iter()
852            .filter_map(|n| model.tensor(n).map(|e| model.entry_bytes(e)))
853            .filter_map(|b| serde_json::from_slice(b).ok())
854            .next()
855            .unwrap_or(serde_json::Value::Null);
856        let voc = cfg.pointer("/vocoder/vocoder").cloned().unwrap_or_default();
857        let bwe = cfg.pointer("/vocoder/bwe").cloned().unwrap_or_default();
858        let rates = |v: &serde_json::Value, d: Vec<usize>| -> Vec<usize> {
859            v.get("upsample_rates")
860                .and_then(|a| a.as_array())
861                .map(|a| {
862                    a.iter()
863                        .filter_map(|x| x.as_u64())
864                        .map(|x| x as usize)
865                        .collect()
866                })
867                .unwrap_or(d)
868        };
869        let dils = vec![vec![1usize, 3, 5], vec![1, 3, 5], vec![1, 3, 5]];
870        let hop = bwe.get("hop_length").and_then(|v| v.as_u64()).unwrap_or(80) as usize;
871        Ok(AudioStack {
872            decoder: AudioVaeDecoder::from_cmf(model)?,
873            // The first generator clamps its output; the bandwidth extender
874            // does not, because its output is a residual that is added to a
875            // resampled copy of the first one and clamped after the sum.
876            vocoder: Vocoder::from_cmf(
877                model,
878                "avae.vocoder.vocoder",
879                &rates(&voc, vec![5, 2, 2, 2, 2, 2]),
880                &dils,
881                voc.get("apply_final_activation")
882                    .and_then(|v| v.as_bool())
883                    .unwrap_or(true),
884            )?,
885            bwe: Vocoder::from_cmf(
886                model,
887                "avae.vocoder.bwe_generator",
888                &rates(&bwe, vec![6, 5, 2, 2, 2]),
889                &dils,
890                bwe.get("apply_final_activation")
891                    .and_then(|v| v.as_bool())
892                    .unwrap_or(true),
893            )?,
894            mel: MelStft::load(model, "avae.vocoder.mel_stft", hop)?,
895            hop,
896            in_rate: bwe
897                .get("input_sampling_rate")
898                .and_then(|v| v.as_u64())
899                .unwrap_or(16000) as usize,
900            out_rate: bwe
901                .get("output_sampling_rate")
902                .and_then(|v| v.as_u64())
903                .unwrap_or(48000) as usize,
904        })
905    }
906
907    /// `[8, T, 16]` latent → stereo waveform at `out_rate`.
908    pub fn decode(&self, latent: &Grid, pool: Option<&Pool>) -> Sig {
909        let mel = self.decoder.decode(latent, pool);
910        self.decode_from_mel(&mel, pool)
911    }
912
913    /// The vocoder half on its own, from a `[2, frames, mel]` log-mel.
914    pub fn decode_from_mel(&self, mel: &Grid, pool: Option<&Pool>) -> Sig {
915        let low = self.vocoder.forward(mel, pool);
916        // the 16 kHz stage on its own, for bisecting the two generators
917        if let Ok(p) = std::env::var("CMF_LTX_LOW_WAV") {
918            let _ = write_wav(std::path::Path::new(&p), &low, self.in_rate);
919        }
920        let out_len = low.t * self.out_rate / self.in_rate;
921        // pad to a whole number of hops so the mel frame count is exact
922        let rem = low.t % self.hop;
923        let padded = if rem == 0 {
924            low.clone()
925        } else {
926            let t2 = low.t + self.hop - rem;
927            let mut p = Sig::zeros(low.c, t2);
928            for c in 0..low.c {
929                p.data[c * t2..c * t2 + low.t]
930                    .copy_from_slice(&low.data[c * low.t..(c + 1) * low.t]);
931            }
932            p
933        };
934        let m = self.mel.forward(&padded);
935        let residual = self.bwe.forward(&m, pool);
936        let skip = hann_sinc_upsample(&padded, self.out_rate / self.in_rate);
937        let t = residual.t.min(skip.t).min(out_len);
938        let mut out = Sig::zeros(skip.c, t);
939        for c in 0..skip.c {
940            for i in 0..t {
941                out.data[c * t + i] = (residual.data[c * residual.t + i]
942                    + skip.data[c * skip.t + i])
943                    .clamp(-1.0, 1.0);
944            }
945        }
946        out
947    }
948}
949
950/// 16-bit PCM WAV — the one container every tool and browser reads.
951pub fn write_wav(path: &std::path::Path, sig: &Sig, rate: usize) -> std::io::Result<()> {
952    use std::io::Write;
953    let n = sig.t;
954    let ch = sig.c as u16;
955    let bytes = (n * sig.c * 2) as u32;
956    let mut f = std::io::BufWriter::new(std::fs::File::create(path)?);
957    f.write_all(b"RIFF")?;
958    f.write_all(&(36 + bytes).to_le_bytes())?;
959    f.write_all(b"WAVEfmt ")?;
960    f.write_all(&16u32.to_le_bytes())?;
961    f.write_all(&1u16.to_le_bytes())?;
962    f.write_all(&ch.to_le_bytes())?;
963    f.write_all(&(rate as u32).to_le_bytes())?;
964    f.write_all(&((rate * sig.c * 2) as u32).to_le_bytes())?;
965    f.write_all(&((sig.c * 2) as u16).to_le_bytes())?;
966    f.write_all(&16u16.to_le_bytes())?;
967    f.write_all(b"data")?;
968    f.write_all(&bytes.to_le_bytes())?;
969    for i in 0..n {
970        for c in 0..sig.c {
971            let v = (sig.data[c * n + i].clamp(-1.0, 1.0) * 32767.0) as i16;
972            f.write_all(&v.to_le_bytes())?;
973        }
974    }
975    Ok(())
976}
977
978// ------------------------------------------------------------ the encoder
979
980/// A slaney mel filterbank — the one `torchaudio.transforms.MelSpectrogram`
981/// builds with `mel_scale="slaney", norm="slaney"`. Not in the checkpoint,
982/// so it is rebuilt from the same formula the reference's preprocessing used.
983fn mel_filterbank(sr: f64, n_fft: usize, n_mels: usize, fmin: f64, fmax: f64) -> Vec<f32> {
984    let n_freqs = n_fft / 2 + 1;
985    let hz_to_mel = |f: f64| 3.0 * f / 200.0;
986    let mel_to_hz = |m: f64| m * 200.0 / 3.0;
987    // slaney is linear below 1 kHz and logarithmic above it
988    let (f_min_log, min_log_mel) = (1000.0f64, 15.0f64);
989    let logstep = (6.4f64).ln() / 27.0;
990    let hz_to_mel_s = |f: f64| {
991        if f >= f_min_log {
992            min_log_mel + (f / f_min_log).ln() / logstep
993        } else {
994            hz_to_mel(f)
995        }
996    };
997    let mel_to_hz_s = |m: f64| {
998        if m >= min_log_mel {
999            f_min_log * ((m - min_log_mel) * logstep).exp()
1000        } else {
1001            mel_to_hz(m)
1002        }
1003    };
1004    let (m0, m1) = (hz_to_mel_s(fmin), hz_to_mel_s(fmax));
1005    let pts: Vec<f64> = (0..n_mels + 2)
1006        .map(|i| mel_to_hz_s(m0 + (m1 - m0) * i as f64 / (n_mels + 1) as f64))
1007        .collect();
1008    let freqs: Vec<f64> = (0..n_freqs).map(|i| sr * i as f64 / n_fft as f64).collect();
1009    let mut fb = vec![0f32; n_mels * n_freqs];
1010    for m in 0..n_mels {
1011        let (lo, ctr, hi) = (pts[m], pts[m + 1], pts[m + 2]);
1012        // slaney normalization: unit area per filter
1013        let enorm = 2.0 / (hi - lo);
1014        for (k, &f) in freqs.iter().enumerate() {
1015            let v = if f >= lo && f <= ctr {
1016                (f - lo) / (ctr - lo).max(1e-12)
1017            } else if f > ctr && f <= hi {
1018                (hi - f) / (hi - ctr).max(1e-12)
1019            } else {
1020                0.0
1021            };
1022            fb[m * n_freqs + k] = (v * enorm) as f32;
1023        }
1024    }
1025    fb
1026}
1027
1028/// Waveform → log-mel in the layout the audio VAE encodes: `[C, frames, mel]`.
1029/// Centered STFT with a Hann window and reflect padding, magnitude (not
1030/// power), then the mel projection and a log with the reference's floor.
1031pub fn waveform_to_mel(x: &Sig, sr: usize, n_fft: usize, hop: usize, n_mels: usize) -> Grid {
1032    let n_freqs = n_fft / 2 + 1;
1033    let fb = mel_filterbank(sr as f64, n_fft, n_mels, 0.0, sr as f64 / 2.0);
1034    let win: Vec<f32> = (0..n_fft)
1035        .map(|i| {
1036            let a = std::f64::consts::PI * 2.0 * i as f64 / n_fft as f64;
1037            (0.5 - 0.5 * a.cos()) as f32
1038        })
1039        .collect();
1040    let pad = n_fft / 2;
1041    let frames = x.t / hop + 1;
1042    let mut out = Grid::zeros(x.c, frames, n_mels);
1043    let mut re = vec![0f32; n_freqs];
1044    let mut im = vec![0f32; n_freqs];
1045    for c in 0..x.c {
1046        for f in 0..frames {
1047            let start = f as isize * hop as isize - pad as isize;
1048            re.iter_mut().for_each(|v| *v = 0.0);
1049            im.iter_mut().for_each(|v| *v = 0.0);
1050            for j in 0..n_fft {
1051                // reflect padding at both ends
1052                let mut s = start + j as isize;
1053                if s < 0 {
1054                    s = -s;
1055                }
1056                if s >= x.t as isize {
1057                    s = 2 * (x.t as isize - 1) - s;
1058                }
1059                let v = if s >= 0 && s < x.t as isize {
1060                    x.data[c * x.t + s as usize]
1061                } else {
1062                    0.0
1063                };
1064                let v = v * win[j];
1065                if v == 0.0 {
1066                    continue;
1067                }
1068                for (k, (rr, ii)) in re.iter_mut().zip(im.iter_mut()).enumerate() {
1069                    let a = -2.0 * std::f64::consts::PI * (k * j) as f64 / n_fft as f64;
1070                    *rr += v * a.cos() as f32;
1071                    *ii += v * a.sin() as f32;
1072                }
1073            }
1074            for m in 0..n_mels {
1075                let mut acc = 0f32;
1076                for k in 0..n_freqs {
1077                    acc += fb[m * n_freqs + k] * (re[k] * re[k] + im[k] * im[k]).sqrt();
1078                }
1079                out.data[(c * frames + f) * n_mels + m] = acc.max(1e-5).ln();
1080            }
1081        }
1082    }
1083    out
1084}
1085
1086struct Downsample2 {
1087    conv: Conv2d,
1088}
1089
1090impl Downsample2 {
1091    /// Stride-2 convolution with the encoder's asymmetric padding: two rows
1092    /// of history on the causal (time) axis, one column on the right.
1093    fn forward(&self, x: &Grid, pool: Option<&Pool>) -> Grid {
1094        let (h, w) = (x.h + 2, x.w + 1);
1095        let mut p = Grid::zeros(x.c, h, w);
1096        for c in 0..x.c {
1097            for y in 0..x.h {
1098                for z in 0..x.w {
1099                    p.data[(c * h + y + 2) * w + z] = x.data[(c * x.h + y) * x.w + z];
1100                }
1101            }
1102        }
1103        self.conv.forward_strided(&p, 2, pool)
1104    }
1105}
1106
1107impl Conv2d {
1108    /// The same convolution with an explicit stride and *no* padding of its
1109    /// own — the caller has already padded.
1110    fn forward_strided(&self, x: &Grid, stride: usize, pool: Option<&Pool>) -> Grid {
1111        let (oh, ow) = ((x.h - self.kh) / stride + 1, (x.w - self.kw) / stride + 1);
1112        let npos = oh * ow;
1113        let k = self.c_in * self.kh * self.kw;
1114        let mut patches = vec![0f32; npos * k];
1115        for i in 0..npos {
1116            let (pw, ph) = (i % ow, i / ow);
1117            for ci in 0..self.c_in {
1118                for a in 0..self.kh {
1119                    for b in 0..self.kw {
1120                        patches[i * k + (ci * self.kh + a) * self.kw + b] =
1121                            x.data[(ci * x.h + ph * stride + a) * x.w + pw * stride + b];
1122                    }
1123                }
1124            }
1125        }
1126        let mut ys = vec![0f32; npos * self.c_out];
1127        crate::fcd_ops::gemm_nt(&patches, &self.w, &mut ys, npos, k, self.c_out, pool);
1128        let mut out = Grid::zeros(self.c_out, oh, ow);
1129        for i in 0..npos {
1130            for co in 0..self.c_out {
1131                out.data[co * npos + i] = ys[i * self.c_out + co] + self.b[co];
1132            }
1133        }
1134        out
1135    }
1136}
1137
1138/// The audio VAE's encoder half: log-mel in, latent out.
1139pub struct AudioVaeEncoder {
1140    conv_in: Conv2d,
1141    levels: Vec<(Vec<ResnetBlock>, Option<Downsample2>)>,
1142    mid: Vec<ResnetBlock>,
1143    conv_out: Conv2d,
1144    mean: Vec<f32>,
1145    std: Vec<f32>,
1146    z: usize,
1147}
1148
1149impl AudioVaeEncoder {
1150    pub fn from_cmf(model: &Arc<CmfModel>) -> Result<AudioVaeEncoder, String> {
1151        let mut levels = Vec::new();
1152        let mut lv = 0usize;
1153        while model
1154            .tensor(&format!("avae.encoder.down.{lv}.block.0.conv1.conv.weight"))
1155            .is_some()
1156        {
1157            let mut blocks = Vec::new();
1158            let mut bi = 0usize;
1159            while model
1160                .tensor(&format!(
1161                    "avae.encoder.down.{lv}.block.{bi}.conv1.conv.weight"
1162                ))
1163                .is_some()
1164            {
1165                blocks.push(ResnetBlock::load(
1166                    model,
1167                    &format!("avae.encoder.down.{lv}.block.{bi}"),
1168                )?);
1169                bi += 1;
1170            }
1171            let down = match model.tensor(&format!("avae.encoder.down.{lv}.downsample.conv.weight"))
1172            {
1173                // the downsample holds a plain Conv2d, not the causal wrapper
1174                // the residual blocks use, so it is one `.conv` shallower
1175                Some(_) => Some(Downsample2 {
1176                    conv: Conv2d::load(model, &format!("avae.encoder.down.{lv}.downsample.conv"))?,
1177                }),
1178                None => None,
1179            };
1180            levels.push((blocks, down));
1181            lv += 1;
1182        }
1183        let out = Conv2d::load(model, "avae.encoder.conv_out.conv")?;
1184        let z = out.c_out / 2;
1185        Ok(AudioVaeEncoder {
1186            conv_in: Conv2d::load(model, "avae.encoder.conv_in.conv")?,
1187            levels,
1188            mid: vec![
1189                ResnetBlock::load(model, "avae.encoder.mid.block_1")?,
1190                ResnetBlock::load(model, "avae.encoder.mid.block_2")?,
1191            ],
1192            conv_out: out,
1193            mean: tensor_f32(model, "avae.per_channel_statistics.mean-of-means")?.0,
1194            std: tensor_f32(model, "avae.per_channel_statistics.std-of-means")?.0,
1195            z,
1196        })
1197    }
1198
1199    /// `[2, frames, 64]` log-mel → `[8, frames/4, 16]` latent.
1200    pub fn encode(&self, mel: &Grid, pool: Option<&Pool>) -> Grid {
1201        let mut h = self.conv_in.forward(mel, pool);
1202        for (blocks, down) in &self.levels {
1203            for b in blocks {
1204                h = b.forward(&h, pool);
1205            }
1206            if let Some(d) = down {
1207                h = d.forward(&h, pool);
1208            }
1209        }
1210        for b in &self.mid {
1211            h = b.forward(&h, pool);
1212        }
1213        pixel_norm(&mut h);
1214        h.data.iter_mut().for_each(|v| *v = silu(*v));
1215        let out = self.conv_out.forward(&h, pool);
1216        // means only, then the per-channel statistics of the *patchified*
1217        // layout (channel-major over mel bins)
1218        let npos = out.n();
1219        let mut lat = Grid::zeros(self.z, out.h, out.w);
1220        for c in 0..self.z {
1221            for hi in 0..out.h {
1222                for wi in 0..out.w {
1223                    let idx = c * out.w + wi;
1224                    let v = out.data[(c * out.h + hi) * out.w + wi];
1225                    lat.data[(c * out.h + hi) * out.w + wi] = (v - self.mean[idx]) / self.std[idx];
1226                }
1227            }
1228        }
1229        lat
1230    }
1231}
1232
1233/// A 16-bit PCM WAV back into a signal in `[-1, 1]`.
1234pub fn read_wav(path: &std::path::Path) -> Result<(Sig, usize), String> {
1235    let raw = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
1236    if raw.len() < 44 || &raw[..4] != b"RIFF" || &raw[8..12] != b"WAVE" {
1237        return Err(format!("{}: not a RIFF/WAVE file", path.display()));
1238    }
1239    let mut i = 12usize;
1240    let (mut ch, mut rate, mut bits) = (2usize, 48000usize, 16usize);
1241    let mut data: Option<(usize, usize)> = None;
1242    while i + 8 <= raw.len() {
1243        let id = &raw[i..i + 4];
1244        let len = u32::from_le_bytes(raw[i + 4..i + 8].try_into().unwrap()) as usize;
1245        let body = i + 8;
1246        if id == b"fmt " && body + 16 <= raw.len() {
1247            ch = u16::from_le_bytes(raw[body + 2..body + 4].try_into().unwrap()) as usize;
1248            rate = u32::from_le_bytes(raw[body + 4..body + 8].try_into().unwrap()) as usize;
1249            bits = u16::from_le_bytes(raw[body + 14..body + 16].try_into().unwrap()) as usize;
1250        } else if id == b"data" {
1251            data = Some((body, len.min(raw.len() - body)));
1252            break;
1253        }
1254        i = body + len + (len & 1);
1255    }
1256    let (off, len) = data.ok_or_else(|| format!("{}: no data chunk", path.display()))?;
1257    if bits != 16 {
1258        return Err(format!("{}: only 16-bit PCM is read", path.display()));
1259    }
1260    let n = len / 2 / ch.max(1);
1261    let mut sig = Sig::zeros(ch, n);
1262    for i in 0..n {
1263        for c in 0..ch {
1264            let o = off + (i * ch + c) * 2;
1265            let v = i16::from_le_bytes([raw[o], raw[o + 1]]) as f32 / 32768.0;
1266            sig.data[c * n + i] = v;
1267        }
1268    }
1269    Ok((sig, rate))
1270}
1271
1272/// Resample by linear interpolation — good enough for conditioning input,
1273/// which the mel transform is about to smear across 64 bands anyway.
1274pub fn resample(x: &Sig, from: usize, to: usize) -> Sig {
1275    if from == to {
1276        return x.clone();
1277    }
1278    let n = (x.t as f64 * to as f64 / from as f64).round() as usize;
1279    let mut out = Sig::zeros(x.c, n);
1280    for c in 0..x.c {
1281        for i in 0..n {
1282            let p = i as f64 * from as f64 / to as f64;
1283            let j = p.floor() as usize;
1284            let f = (p - j as f64) as f32;
1285            let a = x.data[c * x.t + j.min(x.t - 1)];
1286            let b = x.data[c * x.t + (j + 1).min(x.t - 1)];
1287            out.data[c * n + i] = a + (b - a) * f;
1288        }
1289    }
1290    out
1291}