Skip to main content

nam_rs/
wavenet.rs

1//! Real-time WaveNet inference.
2//!
3//! [`WaveNet`] is built once from a parsed [`NamModel`] (which may allocate), then
4//! run on the audio thread via [`WaveNet::process_buffer`], which never allocates.
5//! All scratch buffers are pre-allocated in [`WaveNet::new`].
6//!
7//! The forward pass is a port of NAM's WaveNet, built bottom-up from the `conv`,
8//! `layer`, `array`, and `head` submodules (each unit-tested) and validated end-to-end
9//! against the reference in `tests/parity.rs`.
10//!
11//! Two top-level features are supported beyond the layer-array stack: an optional
12//! **post-stack head** (an `activation → Conv1d` chain run after the arrays, with
13//! `head_scale` scaling its input; its output is the model output) and an optional
14//! **`condition_dsp`** (a nested standalone [`crate::Model`] whose output replaces the
15//! raw mono input as the conditioning fed to every array — the raw input still drives
16//! the first array's layer input, matching NAM Core). The `condition_dsp` may emit
17//! several output channels, producing an N-wide planar conditioning fed to every array
18//! (`condition_size` must equal that channel count); the outer WaveNet stays
19//! mono-output.
20
21use crate::error::Error;
22use crate::model::{GatingMode, LayerArrayConfig, NamModel, WaveNetConfig};
23use crate::reader::Reader;
24
25mod activation;
26mod array;
27mod conv;
28mod film;
29mod gating;
30mod head;
31mod layer;
32
33use activation::Activation;
34use array::LayerArray;
35use conv::{Conv1d, MAX_BLOCK};
36use gating::Gating;
37use head::PostStackHead;
38use layer::{Layer, LayerDims, LayerWeights};
39
40/// A ready-to-run WaveNet, with all scratch buffers pre-allocated.
41#[derive(Debug)]
42pub struct WaveNet {
43    arrays: Vec<LayerArray>,
44    /// Optional post-stack head: an `activation → Conv1d` chain run after the arrays.
45    /// When present, `head_scale` scales the head's *input* and the chain's output is
46    /// the model output; when absent, output = `head_scale · final_head_output`.
47    post_stack_head: Option<Box<PostStackHead>>,
48    /// Pre-allocated `[head_in_channels][MAX_BLOCK]` scratch holding the
49    /// `head_scale`-scaled final head output fed into `post_stack_head`. Keeps the
50    /// hot path allocation-free.
51    head_scale_scratch: Vec<f32>,
52    /// Optional nested `condition_dsp` model. When present, its output replaces the
53    /// raw mono input as the conditioning fed to every array; the raw input still
54    /// drives the first array's layer input (NAMCore semantics). Boxed to break the
55    /// `Model → WaveNet → Model` type cycle. It is mono-in but may emit several output
56    /// channels (`cond_out_ch`), which become the N-wide conditioning fed to the arrays.
57    condition_dsp: Option<Box<crate::Model>>,
58    /// Conditioning width fed to every array: `condition_dsp.num_output_channels()`
59    /// when a `condition_dsp` is present, else `1` (the raw mono input). Every array's
60    /// `condition_size` must equal this (validated in [`WaveNet::new`], mirroring
61    /// NAMCore's assert).
62    cond_out_ch: usize,
63    /// Pre-allocated `cond_out_ch × MAX_BLOCK` planar `[ch][t]` scratch holding the
64    /// conditioning (`condition_dsp(input)`, or a mirror of the raw input when there's
65    /// no `condition_dsp`); reused each chunk so the hot path allocates nothing.
66    cond_dsp_out: Vec<f32>,
67    head_scale: f32,
68    /// Samples of input history the deepest dilated tap reaches back over; equals
69    /// the model's warmup length / processing latency in samples.
70    receptive_field: usize,
71    /// Head-accumulator width of the first array (its incoming head is silence this
72    /// wide).
73    head_in0: usize,
74    /// Head signal carried between arrays (two buffers, ping-ponged).
75    head_a: Vec<f32>,
76    head_b: Vec<f32>,
77    /// Layer signal carried between arrays (two buffers, ping-ponged).
78    sig_a: Vec<f32>,
79    sig_b: Vec<f32>,
80    /// Planar `[width][MAX_BLOCK]` block-path twins of the carry buffers, plus a
81    /// scratch copy of the conditioning chunk. Used by [`WaveNet::process_buffer`].
82    head_a_blk: Vec<f32>,
83    head_b_blk: Vec<f32>,
84    sig_a_blk: Vec<f32>,
85    sig_b_blk: Vec<f32>,
86    cond_blk: Vec<f32>,
87}
88
89impl WaveNet {
90    /// Build a runnable model from a parsed `.nam` file.
91    ///
92    /// All allocation happens here. Fails if the architecture is unsupported, an
93    /// activation is unknown, or the flat weight blob does not match the config.
94    ///
95    /// A top-level model is mono-output: this rejects a config whose forward pass
96    /// would emit more than one channel (a last-array `head_size > 1` with no
97    /// post-stack head), which the mono [`process_buffer`](Self::process_buffer) would
98    /// otherwise silently truncate to row 0. (A *multi-channel* WaveNet is only valid
99    /// nested as a `condition_dsp`; that path is built via the internal
100    /// `new_conditioning`.)
101    pub fn new(model: &NamModel) -> Result<Self, Error> {
102        Self::build(model, false)
103    }
104
105    /// Build a WaveNet for use as a nested `condition_dsp`, where a multi-channel
106    /// output is expected (its N rows become the parent arrays' conditioning). Unlike
107    /// [`Self::new`], this does not require mono output.
108    pub(crate) fn new_conditioning(model: &NamModel) -> Result<Self, Error> {
109        Self::build(model, true)
110    }
111
112    fn build(model: &NamModel, allow_multi_output: bool) -> Result<Self, Error> {
113        let cfg = match &model.config {
114            crate::model::ModelConfig::WaveNet(cfg) => cfg,
115            crate::model::ModelConfig::Lstm(_) | crate::model::ModelConfig::Slimmable(_) => {
116                return Err(Error::UnsupportedArchitecture(model.architecture.clone()))
117            }
118        };
119
120        check_unsupported_features(cfg)?;
121
122        // A top-level model is mono-output. With no post-stack head, a last-array
123        // `head_size > 1` would have the mono `process_buffer` silently emit only row 0;
124        // reject it up front. (`new_conditioning` sets `allow_multi_output` for the
125        // nested condition_dsp path, where the N rows legitimately become the parent's
126        // conditioning.) When a post-stack head IS present its builder enforces
127        // `out_channels == 1`, which covers the head's output width for every path.
128        if !allow_multi_output && cfg.post_stack_head.is_none() {
129            let out_ch = cfg.layers.last().map_or(1, |la| la.head_size);
130            if out_ch != 1 {
131                return Err(Error::UnsupportedFeature(format!(
132                    "top-level WaveNet must be mono-output, but produces {out_ch} channels \
133                     (a multi-channel WaveNet is only valid as a nested condition_dsp)"
134                )));
135            }
136        }
137
138        // Build the nested condition_dsp eagerly (off the audio thread). It carries
139        // its own weights in its nested `.nam` and consumes nothing from the parent
140        // blob, so `expected_weight_count` / the `r.remaining() == 0` assert are
141        // unaffected. A failing nested build fails fast here.
142        let condition_dsp = match &cfg.condition_dsp {
143            Some(nested) => Some(Box::new(crate::Model::from_nam_conditioning(nested)?)),
144            None => None,
145        };
146
147        // Conditioning width: the condition_dsp's output-channel count (else mono).
148        // NAMCore-parity validation (model.cpp ~line 594): every array's
149        // `condition_size` must match the condition_dsp's output channels, else the
150        // mixin conv would read the wrong number of conditioning rows. This is a
151        // build-time check (off the audio thread), mirroring NAMCore's assert.
152        let cond_out_ch = condition_dsp
153            .as_ref()
154            .map_or(1, |m| m.num_output_channels());
155        if let Some(cdsp) = &condition_dsp {
156            let n_out = cdsp.num_output_channels();
157            for (i, la) in cfg.layers.iter().enumerate() {
158                if la.condition_size != n_out {
159                    return Err(Error::UnsupportedFeature(format!(
160                        "condition_size of layer-array {i} ({}) != condition_dsp output \
161                         channels ({n_out})",
162                        la.condition_size
163                    )));
164                }
165            }
166        }
167
168        let expected = expected_weight_count(cfg)?;
169        if expected != model.weights.len() {
170            return Err(Error::WeightCountMismatch {
171                expected,
172                found: model.weights.len(),
173            });
174        }
175
176        let mut r = Reader::new(&model.weights);
177        let mut arrays = Vec::with_capacity(cfg.layers.len());
178        for la in &cfg.layers {
179            arrays.push(build_array(&mut r, la)?);
180        }
181        // Head-carry invariant: each array's head *output* (its `head_size`-wide head
182        // rechannel result) is seeded into the next array's head *accumulator*, whose
183        // width is that array's `head_in` (`head1x1.active ? head1x1_out : bottleneck`).
184        // So the producer's head width must match the consumer's accumulator width:
185        // `arrays[i-1].head_size() == arrays[i].head_in()`. (For A1, head_in == channels
186        // == head_size, so this reduces to the old `head_size == channels` chain.) Guard
187        // it explicitly so a multi-array model chaining mismatched widths fails loudly
188        // here instead of silently reading stale/garbage head rows.
189        for i in 1..arrays.len() {
190            let produced = arrays[i - 1].head_size();
191            let consumed = arrays[i].head_in();
192            if produced != consumed {
193                return Err(Error::UnsupportedFeature(format!(
194                    "layer-array head-carry width mismatch: array {} head_size {produced} \
195                     != array {i} head_in {consumed}",
196                    i - 1
197                )));
198            }
199        }
200
201        let post_stack_head = match &cfg.post_stack_head {
202            Some(hc) => {
203                let in_channels = arrays.last().map_or(0, LayerArray::head_size);
204                Some(Box::new(build_post_stack_head(&mut r, hc, in_channels)?))
205            }
206            None => None,
207        };
208
209        let head_scale = r.take(1)[0];
210        // The up-front check guarantees `expected == weights.len()`; this asserts the
211        // other half of the invariant — that building consumed exactly `expected`, so
212        // `expected_weight_count` and the `build_array` consumption order agree.
213        // A hard assert (not `debug_assert`): if the count formula and the consumption
214        // order ever drift, under-consumption would otherwise leave the model silently
215        // mis-built in release. (Over-consumption already panics in `Reader::take`.)
216        assert_eq!(
217            r.remaining(),
218            0,
219            "build_array consumed fewer weights than expected_weight_count claimed"
220        );
221
222        let max_ch = arrays.iter().map(LayerArray::channels).max().unwrap_or(1);
223        let max_head = arrays.iter().map(LayerArray::head_size).max().unwrap_or(1);
224        let max_head_in = arrays.iter().map(LayerArray::head_in).max().unwrap_or(1);
225        // The head carry buffer holds either a producer's `head_size`-wide output or a
226        // consumer's `head_in`-wide accumulator seed, so size it to the max of both.
227        let head_w = max_ch.max(max_head).max(max_head_in).max(1);
228        let sig_w = max_ch.max(1);
229        // First array's incoming head is silence of its accumulator width (`head_in`).
230        let head_in0 = arrays.first().map_or(0, LayerArray::head_in);
231
232        let head_in_channels = post_stack_head
233            .as_ref()
234            .map_or(0, |h| h.in_channels())
235            .max(1);
236
237        // condition_dsp's prewarm seeds the RF accumulator (else 1). Compute before
238        // moving `condition_dsp` into the struct.
239        let rf_base = condition_dsp.as_ref().map_or(1, |m| m.receptive_field());
240
241        Ok(Self {
242            arrays,
243            post_stack_head,
244            head_scale_scratch: vec![0.0; head_in_channels * MAX_BLOCK],
245            condition_dsp,
246            cond_out_ch,
247            cond_dsp_out: vec![0.0; cond_out_ch * MAX_BLOCK],
248            head_scale,
249            receptive_field: receptive_field(cfg, rf_base),
250            head_in0,
251            head_a: vec![0.0; head_w],
252            head_b: vec![0.0; head_w],
253            sig_a: vec![0.0; sig_w],
254            sig_b: vec![0.0; sig_w],
255            head_a_blk: vec![0.0; head_w * MAX_BLOCK],
256            head_b_blk: vec![0.0; head_w * MAX_BLOCK],
257            sig_a_blk: vec![0.0; sig_w * MAX_BLOCK],
258            sig_b_blk: vec![0.0; sig_w * MAX_BLOCK],
259            cond_blk: vec![0.0; MAX_BLOCK],
260        })
261    }
262
263    /// Receptive field in samples: how far back the deepest dilated tap reaches.
264    ///
265    /// This is the model's warmup length and its processing latency. The first
266    /// `receptive_field()` output samples of a fresh (or freshly [`reset`](Self::reset))
267    /// model are a startup transient computed against zero-filled history, so they
268    /// reflect the streaming zero-init convention (matching NAM Core / NeuralAudio)
269    /// rather than a training-time forward pass that pre-pads the whole input.
270    pub fn receptive_field(&self) -> usize {
271        self.receptive_field
272    }
273
274    #[cfg(test)]
275    pub(super) fn has_condition_dsp(&self) -> bool {
276        self.condition_dsp.is_some()
277    }
278
279    /// Process a buffer of mono samples in place.
280    ///
281    /// Runs the block kernel: each `MAX_BLOCK`-sized chunk is pushed through one
282    /// array (and one layer) at a time, keeping each weight matrix hot across the
283    /// whole chunk. Bit-for-bit equivalent to looping [`Self::process_sample`], and
284    /// it shares the same streaming history, so the two are interchangeable.
285    ///
286    /// **Real-time contract:** no heap allocation, locks, or syscalls. Enforced by
287    /// `tests/rt_safety.rs`.
288    pub fn process_buffer(&mut self, io: &mut [f32]) {
289        if self.arrays.is_empty() {
290            for s in io.iter_mut() {
291                *s *= self.head_scale;
292            }
293            return;
294        }
295        let mut off = 0;
296        while off < io.len() {
297            let n = (io.len() - off).min(MAX_BLOCK);
298            self.process_chunk(&mut io[off..off + n], n);
299            off += n;
300        }
301    }
302
303    /// Number of output channels this WaveNet emits, matching NAMCore
304    /// (`WaveNet::NumOutputChannels`): the post-stack head's `out_channels` when a head
305    /// is present, else the last layer-array's `head_size`. The outer model is mono
306    /// (`1`); this is consulted when this WaveNet is a nested `condition_dsp` whose
307    /// rows become the parent's N-wide conditioning.
308    pub(crate) fn num_output_channels(&self) -> usize {
309        match &self.post_stack_head {
310            Some(h) => h.out_channels(),
311            None => self.arrays.last().map_or(1, LayerArray::head_size),
312        }
313    }
314
315    /// Run one `n <= MAX_BLOCK` chunk through every array via the planar block path,
316    /// leaving the last array's head output in `head_a_blk` (`last_head_size × n`,
317    /// planar). **Requires** `self.cond_blk[..n]` to already hold the mono layer input.
318    /// Shared by the mono `process_chunk` and the multi-channel `process_block_multi`.
319    fn run_arrays_block(&mut self, n: usize) {
320        // The conditioning fed to every array is `condition_dsp(input)` when present,
321        // else the raw input (NAMCore `_process_condition`). Fill it uniformly into
322        // `cond_dsp_out` so the array loop always reads the same `cond_ch × n` planar
323        // slice — the condition_dsp may emit several rows (`cond_ch`).
324        let cond_ch = self.cond_out_ch;
325        if let Some(cdsp) = &mut self.condition_dsp {
326            cdsp.process_block_multi(
327                &self.cond_blk[..n],
328                &mut self.cond_dsp_out[..cond_ch * n],
329                n,
330            );
331        } else {
332            self.cond_dsp_out[..n].copy_from_slice(&self.cond_blk[..n]); // cond_ch == 1
333        }
334
335        // First array: layer input is the raw signal; condition is the `cond_ch × n`
336        // conditioning; the incoming head is silence (`head_in`-wide).
337        self.head_a_blk[..self.head_in0 * n].fill(0.0);
338        {
339            let hin = self.arrays[0].head_in();
340            let ch = self.arrays[0].channels();
341            let hs = self.arrays[0].head_size();
342            self.arrays[0].process_block(
343                &self.cond_blk[..n],
344                &self.cond_dsp_out[..cond_ch * n],
345                &self.head_a_blk[..hin * n],
346                &mut self.head_b_blk[..hs * n],
347                &mut self.sig_b_blk[..ch * n],
348                n,
349            );
350        }
351        std::mem::swap(&mut self.head_a_blk, &mut self.head_b_blk);
352        std::mem::swap(&mut self.sig_a_blk, &mut self.sig_b_blk);
353
354        for i in 1..self.arrays.len() {
355            let in_w = self.arrays[i - 1].channels();
356            let hin = self.arrays[i].head_in();
357            let ch = self.arrays[i].channels();
358            let hs = self.arrays[i].head_size();
359            self.arrays[i].process_block(
360                &self.sig_a_blk[..in_w * n],
361                &self.cond_dsp_out[..cond_ch * n],
362                &self.head_a_blk[..hin * n],
363                &mut self.head_b_blk[..hs * n],
364                &mut self.sig_b_blk[..ch * n],
365                n,
366            );
367            std::mem::swap(&mut self.head_a_blk, &mut self.head_b_blk);
368            std::mem::swap(&mut self.sig_a_blk, &mut self.sig_b_blk);
369        }
370    }
371
372    /// Run one `n <= MAX_BLOCK` chunk through every array via the planar block path.
373    /// `chunk` is the mono input and is overwritten with the (mono) output.
374    ///
375    /// This is the in-place, mono-output specialization of [`Self::process_block_multi`]
376    /// (the outer model's final head is one channel); it shares the array stack via
377    /// [`Self::run_arrays_block`] and only the final emit differs.
378    fn process_chunk(&mut self, chunk: &mut [f32], n: usize) {
379        // The raw mono input drives the first array's *layer input* and (when there's
380        // no condition_dsp) the conditioning; copy it out before we overwrite `chunk`.
381        self.cond_blk[..n].copy_from_slice(chunk);
382        self.run_arrays_block(n);
383
384        // After the final swap, head_a_blk holds the last array's head output.
385        match &mut self.post_stack_head {
386            None => {
387                // No post-stack head: output = head_scale · final head (head_size 1,
388                // so row 0 is the per-sample head signal).
389                for (t, s) in chunk.iter_mut().enumerate() {
390                    *s = self.head_scale * self.head_a_blk[t];
391                }
392            }
393            Some(head) => {
394                // head_scale scales the head's INPUT, then the chain runs and its
395                // output is the model output. The final head output is `in_ch × n`,
396                // planar; scale it into pre-allocated scratch, then run the head.
397                let in_ch = head.in_channels();
398                let scaled = &mut self.head_scale_scratch[..in_ch * n];
399                for (s, &h) in scaled.iter_mut().zip(&self.head_a_blk[..in_ch * n]) {
400                    *s = self.head_scale * h;
401                }
402                let out = head.process_block(scaled, n); // [out_channels=1][n]
403                chunk.copy_from_slice(&out[..n]);
404            }
405        }
406    }
407
408    /// Run one `n <= MAX_BLOCK` chunk through every array, emitting
409    /// `num_output_channels() × n` planar `[ch][t]` into `out` from mono `input[..n]`.
410    ///
411    /// This is the multi-channel-output twin of [`Self::process_chunk`]: used when this
412    /// WaveNet is a nested `condition_dsp` whose rows become the parent's conditioning
413    /// (the outer model emits one channel and uses the mono path). Allocation-free.
414    pub(crate) fn process_block_multi(&mut self, input: &[f32], out: &mut [f32], n: usize) {
415        if self.arrays.is_empty() {
416            // No arrays: output = head_scale · input, mono (n_out == 1).
417            for (o, &x) in out[..n].iter_mut().zip(&input[..n]) {
418                *o = self.head_scale * x;
419            }
420            return;
421        }
422        self.cond_blk[..n].copy_from_slice(&input[..n]);
423        self.run_arrays_block(n);
424
425        // After the final swap, head_a_blk holds the last array's head output.
426        match &mut self.post_stack_head {
427            None => {
428                // No post-stack head: output = head_scale · final head, all `oc` rows.
429                let oc = self.arrays.last().map_or(1, LayerArray::head_size);
430                for (o, &h) in out[..oc * n].iter_mut().zip(&self.head_a_blk[..oc * n]) {
431                    *o = self.head_scale * h;
432                }
433            }
434            Some(head) => {
435                // head_scale scales the head's INPUT; the chain runs and its full
436                // `out_channels × n` output is the conditioning rows.
437                let in_ch = head.in_channels();
438                let scaled = &mut self.head_scale_scratch[..in_ch * n];
439                for (s, &h) in scaled.iter_mut().zip(&self.head_a_blk[..in_ch * n]) {
440                    *s = self.head_scale * h;
441                }
442                let oc = head.out_channels();
443                let produced = head.process_block(scaled, n); // [out_channels][n]
444                out[..oc * n].copy_from_slice(&produced[..oc * n]);
445            }
446        }
447    }
448
449    /// Process a single mono sample, returning one output sample.
450    ///
451    /// Equivalent to a one-element [`Self::process_buffer`]; convenient for
452    /// callers that are not buffer-oriented. Allocation-free.
453    pub fn process_sample(&mut self, x: f32) -> f32 {
454        // `input` is the raw mono sample (first array's layer input). The conditioning
455        // is `condition_dsp(x)` when present, else `x` (NAMCore semantics). Route it
456        // through the same `process_block_multi(n=1)` path the block kernel uses, so
457        // per-sample ≡ block for the condition_dsp too; the (possibly multi-row,
458        // `cond_ch`-wide) result lives in `cond_dsp_out`.
459        let input = [x];
460        let cond_ch = self.cond_out_ch;
461        if let Some(cdsp) = &mut self.condition_dsp {
462            cdsp.process_block_multi(&input, &mut self.cond_dsp_out[..cond_ch], 1);
463        } else {
464            self.cond_dsp_out[0] = x;
465        }
466        let n = self.arrays.len();
467        if n == 0 {
468            return self.head_scale * x;
469        }
470
471        // First array: layer input is the raw sample, condition is the `cond_ch`-wide
472        // conditioning; the incoming head is silence of the array's head-accumulator
473        // width (`head_in`).
474        self.head_a[..self.head_in0].fill(0.0);
475        {
476            let hin = self.arrays[0].head_in();
477            let ch = self.arrays[0].channels();
478            let hs = self.arrays[0].head_size();
479            self.arrays[0].process_sample(
480                &input,
481                &self.cond_dsp_out[..cond_ch],
482                &self.head_a[..hin],
483                &mut self.head_b[..hs],
484                &mut self.sig_b[..ch],
485            );
486        }
487        std::mem::swap(&mut self.head_a, &mut self.head_b);
488        std::mem::swap(&mut self.sig_a, &mut self.sig_b);
489
490        for i in 1..n {
491            let in_w = self.arrays[i - 1].channels();
492            let hin = self.arrays[i].head_in();
493            let ch = self.arrays[i].channels();
494            let hs = self.arrays[i].head_size();
495            self.arrays[i].process_sample(
496                &self.sig_a[..in_w],
497                &self.cond_dsp_out[..cond_ch],
498                &self.head_a[..hin],
499                &mut self.head_b[..hs],
500                &mut self.sig_b[..ch],
501            );
502            std::mem::swap(&mut self.head_a, &mut self.head_b);
503            std::mem::swap(&mut self.sig_a, &mut self.sig_b);
504        }
505
506        // After the final swap, head_a holds the last array's head output.
507        match &mut self.post_stack_head {
508            None => self.head_scale * self.head_a[0],
509            Some(head) => {
510                let in_ch = head.in_channels();
511                let scaled = &mut self.head_scale_scratch[..in_ch];
512                for (s, &h) in scaled.iter_mut().zip(&self.head_a[..in_ch]) {
513                    *s = self.head_scale * h;
514                }
515                head.process_sample(scaled)[0]
516            }
517        }
518    }
519
520    /// Reset all internal state (ring buffers) to silence.
521    pub fn reset(&mut self) {
522        for a in &mut self.arrays {
523            a.reset();
524        }
525        if let Some(h) = &mut self.post_stack_head {
526            h.reset();
527        }
528        if let Some(c) = &mut self.condition_dsp {
529            c.reset();
530        }
531        self.head_a.fill(0.0);
532        self.head_b.fill(0.0);
533        self.sig_a.fill(0.0);
534        self.sig_b.fill(0.0);
535    }
536}
537
538/// Reject WaveNet features whose forward pass is not implemented yet, with a clear
539/// [`Error::UnsupportedFeature`] (rather than silently mis-running). The per-layer A2
540/// features (grouped convs, head1x1, bottleneck≠channels, all 8 FiLM sites, BLENDED
541/// gating, non-sigmoid secondaries, inactive layer1x1) are fully supported by
542/// [`Layer`], and the two top-level features — the post-stack head and the
543/// `condition_dsp` (incl. a multi-channel-output one feeding `condition_size > 1`
544/// arrays) — are now supported (their build paths consume/own their weights and run on
545/// the hot path). The remaining unsupported cases, all rejected here or at their build
546/// site: multi-channel input (`in_channels != 1`); within-array mixed gating; and a
547/// post-stack head with `out_channels != 1` (rejected in its builder). The
548/// `condition_size == condition_dsp` output-channel match is validated post-build in
549/// [`WaveNet::new`] (it needs the built nested model's channel count).
550fn check_unsupported_features(cfg: &WaveNetConfig) -> Result<(), Error> {
551    if cfg.in_channels != 1 {
552        return Err(Error::UnsupportedFeature("in_channels != 1".into()));
553    }
554    // A WaveNet with no layer-arrays would build, but every hot path treats the
555    // empty-array case as a passthrough and ignores a configured post-stack head /
556    // condition_dsp — i.e. silently wrong for a structurally-valid config. Reject it
557    // so the runtime can rely on at least one array.
558    if cfg.layers.is_empty() {
559        return Err(Error::UnsupportedFeature(
560            "WaveNet with no layer-arrays".into(),
561        ));
562    }
563    for la in &cfg.layers {
564        // The array builds one `Gating` from the uniform `gating_mode()`; a layer-array
565        // mixing modes across its layers is still unsupported.
566        let first = la.gating_mode();
567        if la.gating_modes.iter().any(|&g| g != first) {
568            return Err(Error::UnsupportedFeature("mixed gating modes".into()));
569        }
570    }
571    Ok(())
572}
573
574/// Receptive field implied by `config`: per layer `(kernel_size - 1)·dilation`,
575/// plus `(head_kernel_size - 1)` per array for the (possibly multi-tap) head, plus
576/// `Σ(kernel - 1)` for the post-stack head. The accumulator starts at `base`, which
577/// is `1` for a plain model and the nested `condition_dsp`'s receptive field when one
578/// is present (NAMCore: prewarm = `condition_dsp->PrewarmSamples()` instead of 1).
579fn receptive_field(cfg: &WaveNetConfig, base: usize) -> usize {
580    let mut rf = base;
581    for la in &cfg.layers {
582        for (k, &d) in la.kernel_sizes.iter().zip(&la.dilations) {
583            rf += (k - 1) * d;
584        }
585        rf += la.head_kernel_size - 1;
586    }
587    if let Some(head) = &cfg.post_stack_head {
588        for &k in &head.kernel_sizes {
589            rf += k - 1;
590        }
591    }
592    rf
593}
594
595/// Number of weights one layer-array consumes from the flat blob, in exactly
596/// [`build_array`]'s `take` order. This is the **single arithmetic source** for the
597/// weight layout: [`expected_weight_count`] sums it across arrays for the up-front
598/// validation, and [`build_array`] asserts it consumes precisely this many, so the
599/// count formula and the consumption order cannot silently drift apart.
600fn array_weight_count(la: &LayerArrayConfig) -> Result<usize, Error> {
601    let mul = |a: usize, b: usize| a.checked_mul(b).ok_or(Error::ConfigTooLarge);
602    let add = |a: usize, b: usize| a.checked_add(b).ok_or(Error::ConfigTooLarge);
603
604    let gated = la.gating_mode() != GatingMode::None;
605    let mid = if gated {
606        mul(2, la.bottleneck)?
607    } else {
608        la.bottleneck
609    };
610    let head1x1_out = la.head1x1.out_channels.unwrap_or(la.channels);
611    let cond = la.condition_size;
612
613    // Grouped Conv1d weight count: out*in*kernel/groups (compact). Caller adds bias.
614    // The block-diagonal layout needs `out` and `in` each divisible by `groups`
615    // (NAMCore's `% groups` precondition); reject a non-dividing config here as a
616    // clean `Err` rather than letting `Conv1d::new_grouped` assert-panic at build.
617    // (`groups >= 1` is already guaranteed by `normalize`, so no divide-by-zero.)
618    let conv_w = |out: usize, in_ch: usize, k: usize, groups: usize| -> Result<usize, Error> {
619        if out % groups != 0 || in_ch % groups != 0 {
620            return Err(Error::UnsupportedFeature(format!(
621                "grouped conv: out ({out}) and in ({in_ch}) must both be divisible by groups ({groups})"
622            )));
623        }
624        Ok(mul(mul(out, in_ch)?, k)? / groups)
625    };
626    // FiLM: out_rows = (shift?2:1)*input_dim; weights out_rows*cond/groups + out_rows bias.
627    let film = |f: &crate::model::FilmConfig, input_dim: usize| -> Result<usize, Error> {
628        if !f.active {
629            return Ok(0);
630        }
631        let out_rows = if f.shift {
632            mul(2, input_dim)?
633        } else {
634            input_dim
635        };
636        add(conv_w(out_rows, cond, 1, f.groups)?, out_rows)
637    };
638
639    let mut total = mul(la.channels, la.input_size)?; // rechannel (no bias)
640
641    for &k in &la.kernel_sizes {
642        let conv = add(conv_w(mid, la.channels, k, la.groups_input)?, mid)?;
643        let mixin = conv_w(mid, cond, 1, la.groups_input_mixin)?; // no bias
644        let mut layer = add(conv, mixin)?;
645        if la.layer1x1.active {
646            let l = add(
647                conv_w(la.channels, la.bottleneck, 1, la.layer1x1.groups)?,
648                la.channels,
649            )?;
650            layer = add(layer, l)?;
651        }
652        if la.head1x1.active {
653            let h = add(
654                conv_w(head1x1_out, la.bottleneck, 1, la.head1x1.groups)?,
655                head1x1_out,
656            )?;
657            layer = add(layer, h)?;
658        }
659        // 8 FiLMs in NAMCore order.
660        layer = add(layer, film(&la.conv_pre_film, la.channels)?)?;
661        layer = add(layer, film(&la.conv_post_film, mid)?)?;
662        layer = add(layer, film(&la.input_mixin_pre_film, cond)?)?;
663        layer = add(layer, film(&la.input_mixin_post_film, mid)?)?;
664        layer = add(layer, film(&la.activation_pre_film, mid)?)?;
665        layer = add(layer, film(&la.activation_post_film, la.bottleneck)?)?;
666        layer = add(layer, film(&la.layer1x1_post_film, la.channels)?)?;
667        layer = add(layer, film(&la.head1x1_post_film, head1x1_out)?)?;
668        total = add(total, layer)?;
669    }
670
671    // head rechannel reads head_in rows.
672    let head_in = if la.head1x1.active {
673        head1x1_out
674    } else {
675        la.bottleneck
676    };
677    total = add(
678        total,
679        mul(mul(la.head_size, head_in)?, la.head_kernel_size)?,
680    )?;
681    if la.head_bias {
682        total = add(total, la.head_size)?;
683    }
684    Ok(total)
685}
686
687/// Weights one post-stack head consumes from the flat blob, in NAMCore conv-chain
688/// order. Conv `i` is `[out][in][k]` + `[out]` bias; in/out follow the chain
689/// `in_channels → channels → … → channels → out_channels`. `in_channels` is the
690/// last layer-array's `head_size`.
691fn post_stack_head_weight_count(
692    head: &crate::model::PostStackHeadConfig,
693    in_channels: usize,
694) -> Result<usize, Error> {
695    let mul = |a: usize, b: usize| a.checked_mul(b).ok_or(Error::ConfigTooLarge);
696    let add = |a: usize, b: usize| a.checked_add(b).ok_or(Error::ConfigTooLarge);
697    if head.kernel_sizes.is_empty() {
698        return Err(Error::UnsupportedFeature(
699            "post-stack head with no convs".into(),
700        ));
701    }
702    let n = head.kernel_sizes.len();
703    let mut total = 0usize;
704    let mut cin = in_channels;
705    for (i, &k) in head.kernel_sizes.iter().enumerate() {
706        let cout = if i + 1 == n {
707            head.out_channels
708        } else {
709            head.channels
710        };
711        total = add(total, add(mul(mul(cout, cin)?, k)?, cout)?)?; // weights + bias
712        cin = cout;
713    }
714    Ok(total)
715}
716
717/// Number of `f32`s `config` implies in the flat weight blob, including the final
718/// `head_scale`.
719///
720/// Uses checked arithmetic: an absurd or adversarial config whose dimensions overflow
721/// `usize` returns [`Error::ConfigTooLarge`] rather than panicking (debug) or wrapping
722/// to a wrong, small count (release).
723fn expected_weight_count(cfg: &WaveNetConfig) -> Result<usize, Error> {
724    let add = |a: usize, b: usize| a.checked_add(b).ok_or(Error::ConfigTooLarge);
725    let mut total = 0usize;
726    for la in &cfg.layers {
727        total = add(total, array_weight_count(la)?)?;
728    }
729    if let Some(head) = &cfg.post_stack_head {
730        let in_ch = cfg.layers.last().map_or(0, |la| la.head_size);
731        total = add(total, post_stack_head_weight_count(head, in_ch)?)?;
732    }
733    add(total, 1) // head_scale
734}
735
736/// Build the post-stack head, consuming its convs from the flat blob in NAMCore
737/// chain order (matching [`post_stack_head_weight_count`]): conv `i` reads
738/// `[out][in][k]` weights then `[out]` bias, with `in/out` following
739/// `in_channels → channels → … → channels → out_channels`. Each conv has dilation 1
740/// and bias.
741fn build_post_stack_head(
742    r: &mut Reader,
743    hc: &crate::model::PostStackHeadConfig,
744    in_channels: usize,
745) -> Result<PostStackHead, Error> {
746    if hc.out_channels != 1 {
747        return Err(Error::UnsupportedFeature(
748            "post-stack head out_channels != 1".into(),
749        ));
750    }
751    let n = hc.kernel_sizes.len();
752    let activation = Activation::from_spec(&hc.activation)?;
753    let mut convs = Vec::with_capacity(n);
754    let mut cin = in_channels;
755    for (i, &k) in hc.kernel_sizes.iter().enumerate() {
756        let cout = if i + 1 == n {
757            hc.out_channels
758        } else {
759            hc.channels
760        };
761        let w = r.take(cout * cin * k);
762        let b = r.take(cout);
763        convs.push((activation, Conv1d::new(cin, cout, k, 1, w, Some(b))));
764        cin = cout;
765    }
766    Ok(PostStackHead::new(convs, in_channels, hc.out_channels))
767}
768
769fn build_array(r: &mut Reader, la: &LayerArrayConfig) -> Result<LayerArray, Error> {
770    let mode = la.gating_mode();
771    let gated = mode != GatingMode::None;
772    let mid = if gated {
773        2 * la.bottleneck
774    } else {
775        la.bottleneck
776    };
777    let head1x1_out = la.head1x1.out_channels.unwrap_or(la.channels);
778    let cond = la.condition_size;
779
780    // FiLM site descriptors in NAMCore order: (config, input_dim).
781    let film_sites = [
782        (&la.conv_pre_film, la.channels),
783        (&la.conv_post_film, mid),
784        (&la.input_mixin_pre_film, cond),
785        (&la.input_mixin_post_film, mid),
786        (&la.activation_pre_film, mid),
787        (&la.activation_post_film, la.bottleneck),
788        (&la.layer1x1_post_film, la.channels),
789        (&la.head1x1_post_film, head1x1_out),
790    ];
791    let film_shift: [bool; 8] = std::array::from_fn(|i| film_sites[i].0.shift);
792    let film_groups: [usize; 8] = std::array::from_fn(|i| film_sites[i].0.groups);
793
794    let before = r.remaining();
795    let rechannel_w = r.take(la.channels * la.input_size);
796    let mut layers = Vec::with_capacity(la.dilations.len());
797    for (i, &d) in la.dilations.iter().enumerate() {
798        let k = la.kernel_sizes[i];
799        let primary = Activation::from_spec(&la.activations[i])?;
800        let secondary = Activation::from_spec(&la.secondary_activations[i])?;
801
802        // Per-layer weights, consumed in NAMCore `set_weights_` order.
803        let conv_w = r.take(mid * la.channels * k / la.groups_input);
804        let conv_b = r.take(mid);
805        let mix_w = r.take(mid * cond / la.groups_input_mixin);
806        let (layer1x1_w, layer1x1_b) = if la.layer1x1.active {
807            let w = r.take(la.channels * la.bottleneck / la.layer1x1.groups);
808            let b = r.take(la.channels);
809            (Some(w), Some(b))
810        } else {
811            (None, None)
812        };
813        let (head1x1_w, head1x1_b) = if la.head1x1.active {
814            let w = r.take(head1x1_out * la.bottleneck / la.head1x1.groups);
815            let b = r.take(head1x1_out);
816            (Some(w), Some(b))
817        } else {
818            (None, None)
819        };
820        let mut films: [Option<(Vec<f32>, Vec<f32>)>; 8] = Default::default();
821        for (j, (f, input_dim)) in film_sites.iter().enumerate() {
822            if f.active {
823                let out_rows = if f.shift { 2 * input_dim } else { *input_dim };
824                let w = r.take(out_rows * cond / f.groups);
825                let b = r.take(out_rows);
826                films[j] = Some((w, b));
827            }
828        }
829
830        let gating = Gating::new(mode, primary, secondary, la.bottleneck);
831        layers.push(Layer::new(
832            LayerDims {
833                channels: la.channels,
834                bottleneck: la.bottleneck,
835                condition_size: cond,
836                kernel: k,
837                dilation: d,
838                groups_input: la.groups_input,
839                groups_input_mixin: la.groups_input_mixin,
840                layer1x1_groups: la.layer1x1.groups,
841                head1x1_groups: la.head1x1.groups,
842                head1x1_out: if la.head1x1.active {
843                    Some(head1x1_out)
844                } else {
845                    None
846                },
847                film_shift,
848                film_groups,
849            },
850            gating,
851            LayerWeights {
852                conv_w,
853                conv_b,
854                mix_w,
855                layer1x1_w,
856                layer1x1_b,
857                head1x1_w,
858                head1x1_b,
859                films,
860            },
861        ));
862    }
863
864    // Head accumulator / head-rechannel input width: uniform across an array's layers.
865    let head_in = layers[0].head_contrib_width();
866    debug_assert!(
867        layers.iter().all(|l| l.head_contrib_width() == head_in),
868        "layers in one array must share head-contribution width"
869    );
870
871    let head_w = r.take(la.head_size * head_in * la.head_kernel_size);
872    let head_b = if la.head_bias {
873        Some(r.take(la.head_size))
874    } else {
875        None
876    };
877
878    // Self-check: this array must have consumed exactly what the single count source
879    // claims. Fires immediately (and locally) if a future edit changes the `take`
880    // order here without updating `array_weight_count`, or vice versa.
881    debug_assert_eq!(
882        before - r.remaining(),
883        array_weight_count(la)?,
884        "build_array consumption drifted from array_weight_count"
885    );
886
887    Ok(LayerArray::new(
888        la.input_size,
889        la.channels,
890        head_in,
891        la.head_size,
892        la.head_kernel_size,
893        rechannel_w,
894        layers,
895        head_w,
896        head_b,
897    ))
898}
899
900#[cfg(test)]
901mod tests {
902    use super::*;
903
904    // Build a normalized LayerArrayConfig for tests via the parser (keeps it honest).
905    fn mk_layer(json: serde_json::Value) -> crate::model::LayerArrayConfig {
906        let raw: crate::model::RawLayerArrayConfig = serde_json::from_value(json).unwrap();
907        raw.normalize().unwrap()
908    }
909
910    // 1 array, 1 layer, 1 channel, ReLU. Weight order:
911    // rechannel=1, conv_w=2, conv_b=0.5, mix_w=1, one_w=3, one_b=0.1,
912    // head_rechannel=0.5, head_scale=10.
913    const TINY: &str = r#"{
914        "version": "0.5.4",
915        "architecture": "WaveNet",
916        "config": {
917            "layers": [{
918                "input_size": 1, "condition_size": 1, "channels": 1, "head_size": 1,
919                "kernel_size": 1, "dilations": [1], "activation": "ReLU",
920                "gated": false, "head_bias": false
921            }],
922            "head": null, "head_scale": 10.0
923        },
924        "weights": [1.0, 2.0, 0.5, 1.0, 3.0, 0.1, 0.5, 10.0]
925    }"#;
926
927    const TINY_HEAD: &str = r#"{
928        "version":"0.6.0","architecture":"WaveNet","config":{
929            "layers":[{"input_size":1,"condition_size":1,"channels":1,"head_size":1,
930                "kernel_size":1,"dilations":[1],"activation":"ReLU",
931                "gated":false,"head_bias":false}],
932            "head":{"channels":1,"out_channels":1,"kernel_sizes":[1],"activation":"ReLU"},
933            "head_scale":2.0},
934        "weights":[]}"#;
935
936    #[test]
937    fn receptive_field_includes_condition_dsp_prewarm() {
938        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
939            .join("tests/fixtures/condition_dsp_mono.nam");
940        let json = std::fs::read_to_string(path).expect("condition_dsp_mono.nam");
941        let model = NamModel::from_json_str(&json).expect("parse");
942        let cfg = match &model.config {
943            crate::model::ModelConfig::WaveNet(c) => c,
944            _ => unreachable!(),
945        };
946        // Expected: nested condition_dsp rf + Σ array rf-terms (+ head, none here).
947        let nested = crate::Model::from_nam(cfg.condition_dsp.as_ref().unwrap()).unwrap();
948        let mut want = nested.receptive_field();
949        for la in &cfg.layers {
950            for (k, &d) in la.kernel_sizes.iter().zip(&la.dilations) {
951                want += (k - 1) * d;
952            }
953            want += la.head_kernel_size - 1;
954        }
955        assert_eq!(WaveNet::new(&model).unwrap().receptive_field(), want);
956    }
957
958    #[test]
959    fn condition_dsp_block_equals_per_sample() {
960        // The mono condition_dsp fixture: block path must equal the per-sample path,
961        // proving the conditioning replacement is applied consistently across both.
962        // Absolute parity is covered by the parity suite.
963        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
964            .join("tests/fixtures/condition_dsp_mono.nam");
965        let json = std::fs::read_to_string(path).expect("condition_dsp_mono.nam");
966        let model = NamModel::from_json_str(&json).expect("parse");
967
968        let len = MAX_BLOCK + 173;
969        let signal: Vec<f32> = (0..len).map(|i| (i as f32 * 0.017).sin() * 0.4).collect();
970
971        let mut per_sample = WaveNet::new(&model).unwrap();
972        let want: Vec<f32> = signal
973            .iter()
974            .map(|&x| per_sample.process_sample(x))
975            .collect();
976        let mut block = WaveNet::new(&model).unwrap();
977        let mut got = signal.clone();
978        block.process_buffer(&mut got);
979        for (i, (g, w)) in got.iter().zip(&want).enumerate() {
980            assert!(
981                (g - w).abs() < 1e-5,
982                "sample {i}: block {g}, per-sample {w}"
983            );
984        }
985    }
986
987    #[test]
988    fn condition_dsp_model_builds() {
989        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
990            .join("tests/fixtures/condition_dsp_mono.nam");
991        let json = std::fs::read_to_string(path).expect("condition_dsp_mono.nam");
992        let model = NamModel::from_json_str(&json).expect("parse");
993        let wn = WaveNet::new(&model).expect("condition_dsp model builds");
994        assert!(
995            wn.has_condition_dsp(),
996            "nested condition_dsp must be present"
997        );
998    }
999
1000    #[test]
1001    fn multi_channel_condition_dsp_builds_and_block_equals_per_sample() {
1002        // The `wavenet_condition_dsp.nam` example feeds arrays with `condition_size == 3`,
1003        // fed by a nested WaveNet emitting 3 output channels. It MUST build (no rejection)
1004        // and the block path must equal the per-sample path, proving the N-wide
1005        // conditioning is applied consistently across both. Absolute parity vs the oracle
1006        // is covered by the parity suite.
1007        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1008            .join("tests/fixtures/wavenet_condition_dsp.nam");
1009        let json = std::fs::read_to_string(path).expect("wavenet_condition_dsp.nam");
1010        let model = NamModel::from_json_str(&json).expect("parse condition_dsp model");
1011
1012        let len = MAX_BLOCK + 173;
1013        let signal: Vec<f32> = (0..len).map(|i| (i as f32 * 0.017).sin() * 0.4).collect();
1014
1015        let mut per_sample = WaveNet::new(&model).expect("multi-channel condition_dsp builds");
1016        assert!(per_sample.has_condition_dsp());
1017        let want: Vec<f32> = signal
1018            .iter()
1019            .map(|&x| per_sample.process_sample(x))
1020            .collect();
1021        let mut block = WaveNet::new(&model).unwrap();
1022        let mut got = signal.clone();
1023        block.process_buffer(&mut got);
1024        for (i, (g, w)) in got.iter().zip(&want).enumerate() {
1025            assert!(
1026                (g - w).abs() < 1e-5,
1027                "sample {i}: block {g}, per-sample {w}"
1028            );
1029        }
1030    }
1031
1032    #[test]
1033    fn weight_count_includes_post_stack_head() {
1034        let model0 = NamModel::from_json_str(TINY_HEAD).unwrap();
1035        let cfg = match &model0.config {
1036            crate::model::ModelConfig::WaveNet(c) => c,
1037            _ => unreachable!(),
1038        };
1039        assert_eq!(expected_weight_count(cfg).unwrap(), 10);
1040    }
1041
1042    #[test]
1043    fn post_stack_head_no_longer_rejected() {
1044        let model0 = NamModel::from_json_str(TINY_HEAD).unwrap();
1045        let cfg = match &model0.config {
1046            crate::model::ModelConfig::WaveNet(c) => c,
1047            _ => unreachable!(),
1048        };
1049        // Fill exact weight count (computed by expected_weight_count after Task 4).
1050        let n = expected_weight_count(cfg).unwrap();
1051        let model = NamModel {
1052            version: "0.6.0".into(),
1053            architecture: "WaveNet".into(),
1054            config: crate::model::ModelConfig::WaveNet(cfg.clone()),
1055            weights: vec![0.0; n],
1056            sample_rate: None,
1057            metadata: None,
1058        };
1059        // The build must NOT fail with the old post-stack-head guard.
1060        match WaveNet::new(&model) {
1061            Err(Error::UnsupportedFeature(f)) if f.contains("post-stack head") => {
1062                panic!("post-stack head should no longer be guarded");
1063            }
1064            _ => {} // ok (builds, or fails for another reason until Tasks 3-5 land)
1065        }
1066    }
1067
1068    #[test]
1069    fn receptive_field_includes_post_stack_head_kernels() {
1070        // Array: kernel 3, dilations [1,2] -> array rf-term = (3-1)*1+(3-1)*2 = 6.
1071        // head kernel 1 -> +0. Post-stack head kernels [16, 1] -> +15+0 = 15.
1072        // total rf = 1 + 6 + 0 + 15 = 22.
1073        let json = r#"{
1074            "version":"0.6.0","architecture":"WaveNet","config":{
1075                "layers":[{"input_size":1,"condition_size":1,"channels":1,"head_size":1,
1076                    "kernel_size":3,"dilations":[1,2],"activation":"ReLU",
1077                    "gated":false,"head_bias":false}],
1078                "head":{"channels":2,"out_channels":1,"kernel_sizes":[16,1],"activation":"ReLU"},
1079                "head_scale":1.0},
1080            "weights":[]}"#;
1081        let model0 = NamModel::from_json_str(json).unwrap();
1082        let cfg = match &model0.config {
1083            crate::model::ModelConfig::WaveNet(c) => c,
1084            _ => unreachable!(),
1085        };
1086        let n = expected_weight_count(cfg).unwrap();
1087        let model = NamModel {
1088            version: "0.6.0".into(),
1089            architecture: "WaveNet".into(),
1090            config: crate::model::ModelConfig::WaveNet(cfg.clone()),
1091            weights: vec![0.0; n],
1092            sample_rate: None,
1093            metadata: None,
1094        };
1095        assert_eq!(WaveNet::new(&model).unwrap().receptive_field(), 22);
1096    }
1097
1098    #[test]
1099    fn post_stack_head_forward_matches_hand_computed() {
1100        // TINY array weights produce final_head=1.0 for x=0.5 (see TINY test).
1101        // head_scale=2.0 scales head input to 2.0; ReLU head conv w=3,b=0.5 -> 6.5.
1102        // Weight blob order: [array(7), post_head_conv_w(1), post_head_conv_b(1), head_scale(1)]
1103        let json = r#"{
1104            "version":"0.6.0","architecture":"WaveNet","config":{
1105                "layers":[{"input_size":1,"condition_size":1,"channels":1,"head_size":1,
1106                    "kernel_size":1,"dilations":[1],"activation":"ReLU",
1107                    "gated":false,"head_bias":false}],
1108                "head":{"channels":1,"out_channels":1,"kernel_sizes":[1],"activation":"ReLU"},
1109                "head_scale":2.0},
1110            "weights":[1.0, 2.0, 0.5, 1.0, 3.0, 0.1, 0.5, 3.0, 0.5, 2.0]}"#;
1111        let model = NamModel::from_json_str(json).unwrap();
1112        let mut wn = WaveNet::new(&model).unwrap();
1113        let mut buf = [0.5_f32];
1114        wn.process_buffer(&mut buf);
1115        assert!((buf[0] - 6.5).abs() < 1e-5, "got {}", buf[0]);
1116
1117        // And the per-sample path agrees with the block path.
1118        let mut wn2 = WaveNet::new(&model).unwrap();
1119        let got = wn2.process_sample(0.5);
1120        assert!((got - 6.5).abs() < 1e-5, "per-sample got {}", got);
1121    }
1122
1123    #[test]
1124    fn post_stack_head_multichannel_out_rejected() {
1125        let json = r#"{
1126            "version":"0.6.0","architecture":"WaveNet","config":{
1127                "layers":[{"input_size":1,"condition_size":1,"channels":1,"head_size":1,
1128                    "kernel_size":1,"dilations":[1],"activation":"ReLU",
1129                    "gated":false,"head_bias":false}],
1130                "head":{"channels":2,"out_channels":2,"kernel_sizes":[1],"activation":"ReLU"},
1131                "head_scale":1.0},
1132            "weights":[]}"#;
1133        let model0 = NamModel::from_json_str(json).unwrap();
1134        let cfg = match &model0.config {
1135            crate::model::ModelConfig::WaveNet(c) => c,
1136            _ => unreachable!(),
1137        };
1138        let n = expected_weight_count(cfg).unwrap();
1139        let model = NamModel {
1140            version: "0.6.0".into(),
1141            architecture: "WaveNet".into(),
1142            config: crate::model::ModelConfig::WaveNet(cfg.clone()),
1143            weights: vec![0.0; n],
1144            sample_rate: None,
1145            metadata: None,
1146        };
1147        assert!(matches!(
1148            WaveNet::new(&model),
1149            Err(Error::UnsupportedFeature(f)) if f.contains("out_channels != 1")
1150        ));
1151    }
1152
1153    #[test]
1154    fn post_stack_head_builds_and_consumes_exact_weights() {
1155        let model0 = NamModel::from_json_str(TINY_HEAD).unwrap();
1156        let cfg = match &model0.config {
1157            crate::model::ModelConfig::WaveNet(c) => c,
1158            _ => unreachable!(),
1159        };
1160        let n = expected_weight_count(cfg).unwrap(); // 10
1161        let weights: Vec<f32> = (0..n).map(|i| (i as f32 + 1.0) * 0.1).collect();
1162        let model = NamModel {
1163            version: "0.6.0".into(),
1164            architecture: "WaveNet".into(),
1165            config: crate::model::ModelConfig::WaveNet(cfg.clone()),
1166            weights,
1167            sample_rate: None,
1168            metadata: None,
1169        };
1170        assert!(WaveNet::new(&model).is_ok(), "post-stack head model builds");
1171    }
1172
1173    #[test]
1174    fn default_path_unchanged_baseline() {
1175        // No post-stack head, no condition_dsp: output = head_scale * final_head.
1176        // TINY: x=0.5 -> out=10.0 (pinned in tiny_model_matches_hand_computed_forward).
1177        let model = NamModel::from_json_str(TINY).unwrap();
1178        let mut wn = WaveNet::new(&model).unwrap();
1179        let mut buf = [0.5_f32];
1180        wn.process_buffer(&mut buf);
1181        assert!((buf[0] - 10.0).abs() < 1e-5, "got {}", buf[0]);
1182        // And the config really has neither feature.
1183        let cfg = match &model.config {
1184            crate::model::ModelConfig::WaveNet(c) => c,
1185            _ => unreachable!(),
1186        };
1187        assert!(cfg.post_stack_head.is_none());
1188        assert!(cfg.condition_dsp.is_none());
1189    }
1190
1191    #[test]
1192    fn tiny_model_matches_hand_computed_forward() {
1193        let model = NamModel::from_json_str(TINY).unwrap();
1194        let mut wn = WaveNet::new(&model).unwrap();
1195
1196        // x=0.5, cond=0.5: z = 2*0.5 + 0.5 + 1*0.5 = 2.0 ; relu=2.0
1197        // head = 0.5*2.0 = 1.0 ; out = head_scale * 1.0 = 10.0
1198        let mut buf = [0.5_f32];
1199        wn.process_buffer(&mut buf);
1200        assert!((buf[0] - 10.0).abs() < 1e-5, "got {}", buf[0]);
1201    }
1202
1203    #[test]
1204    fn array_weight_count_includes_a2_subblocks() {
1205        // channels=4, bottleneck=2, condition=1, GATED (mid=2*bn=4), kernel 3 x1 layer,
1206        // head_size=1 head_kernel=1 no head_bias, groups all 1, head1x1 active out=3,
1207        // layer1x1 active, conv_post_film (shift) + activation_post_film (no shift) active.
1208        // Per-layer weights (NAMCore order):
1209        //   conv:     mid*channels*k + mid          = 4*4*3 + 4   = 52
1210        //   mixin:    mid*condition                 = 4*1         = 4
1211        //   layer1x1: channels*bottleneck + channels= 4*2 + 4     = 12
1212        //   head1x1:  head1x1_out*bottleneck + out   = 3*2 + 3     = 9
1213        //   conv_post_film: shift -> out_rows=2*mid=8; 8*condition + 8 = 8+8 = 16
1214        //   activation_post_film: no shift -> out_rows=bottleneck=2; 2*1 + 2 = 4
1215        // rechannel: channels*input = 4*1 = 4
1216        // head_rechannel: head_size*head_in*head_k = 1*3*1 = 3 (head_in=head1x1_out=3, no bias)
1217        // array total = 4 + (52+4+12+9+16+4) + 3 = 4 + 97 + 3 = 104
1218        let la = mk_layer(serde_json::json!({
1219            "input_size": 1, "condition_size": 1, "channels": 4, "bottleneck": 2,
1220            "kernel_sizes": [3], "dilations": [1],
1221            "activation": [{"type":"Tanh"}],
1222            "gating_mode": ["gated"],
1223            "layer1x1": {"active": true, "groups": 1},
1224            "head1x1": {"active": true, "out_channels": 3, "groups": 1},
1225            "head": {"out_channels": 1, "kernel_size": 1, "bias": false},
1226            "conv_post_film": {"active": true, "shift": true, "groups": 1},
1227            "activation_post_film": {"active": true, "shift": false, "groups": 1}
1228        }));
1229        assert_eq!(array_weight_count(&la).unwrap(), 104);
1230    }
1231
1232    #[test]
1233    fn receptive_field_sums_dilated_taps() {
1234        // rf = 1 + Σ(k-1)·d + Σ(head_kernel_size-1) over all arrays. Here the head
1235        // kernel is 1 so its term vanishes; mirrors the reference model: kernel 3,
1236        // dilations [1,2] then [8].
1237        let cfg = WaveNetConfig {
1238            layers: vec![
1239                mk_layer(serde_json::json!({
1240                    "input_size": 1, "condition_size": 1, "channels": 1, "head_size": 1,
1241                    "kernel_size": 3, "dilations": [1, 2], "activation": "Tanh",
1242                    "gated": false, "head_bias": false
1243                })),
1244                mk_layer(serde_json::json!({
1245                    "input_size": 1, "condition_size": 1, "channels": 1, "head_size": 1,
1246                    "kernel_size": 3, "dilations": [8], "activation": "Tanh",
1247                    "gated": false, "head_bias": false
1248                })),
1249            ],
1250            post_stack_head: None,
1251            head_scale: 1.0,
1252            in_channels: 1,
1253            condition_dsp: None,
1254        };
1255        // (3-1)*1 + (3-1)*2 + (3-1)*8 = 2 + 4 + 16 = 22, + 1 = 23.
1256        assert_eq!(receptive_field(&cfg, 1), 23);
1257
1258        // TINY (kernel 1, dilation 1) reaches back over no past samples: rf = 1.
1259        let model = NamModel::from_json_str(TINY).unwrap();
1260        assert_eq!(WaveNet::new(&model).unwrap().receptive_field(), 1);
1261    }
1262
1263    #[test]
1264    fn reset_restores_from_fresh_result() {
1265        let model = NamModel::from_json_str(TINY).unwrap();
1266        let mut wn = WaveNet::new(&model).unwrap();
1267        let mut warm = [0.3_f32, -0.7, 0.2];
1268        wn.process_buffer(&mut warm);
1269        wn.reset();
1270        let mut a = [0.5_f32];
1271        wn.process_buffer(&mut a);
1272        assert!((a[0] - 10.0).abs() < 1e-5, "got {}", a[0]);
1273    }
1274
1275    #[test]
1276    fn wrong_weight_count_is_rejected() {
1277        let bad = TINY.replace(
1278            "[1.0, 2.0, 0.5, 1.0, 3.0, 0.1, 0.5, 10.0]",
1279            "[1.0, 2.0, 0.5, 1.0, 3.0, 0.1, 0.5]",
1280        );
1281        let model = NamModel::from_json_str(&bad).unwrap();
1282        match WaveNet::new(&model) {
1283            Err(Error::WeightCountMismatch { expected, found }) => {
1284                assert_eq!(expected, 8);
1285                assert_eq!(found, 7);
1286            }
1287            other => panic!("expected WeightCountMismatch, got {other:?}"),
1288        }
1289    }
1290
1291    /// A structurally valid config whose dimensions overflow `usize` must return
1292    /// `ConfigTooLarge`, not panic (debug) or wrap to a wrong count (release).
1293    #[test]
1294    fn absurd_dimensions_error_instead_of_overflowing() {
1295        let json = TINY.replace("\"channels\": 1", "\"channels\": 4294967296");
1296        let model = NamModel::from_json_str(&json).unwrap();
1297        assert!(matches!(WaveNet::new(&model), Err(Error::ConfigTooLarge)));
1298    }
1299
1300    /// Pins the weight-count invariant `take` relies on: `expected_weight_count` must
1301    /// equal exactly what `build_array` (+ head_scale) consumes, across config shapes.
1302    /// Building with that many weights succeeds (and the `debug_assert` in `new` fires
1303    /// if consumption drifts below it); one fewer / one more is a count mismatch.
1304    #[test]
1305    fn weight_count_matches_consumption_across_shapes() {
1306        let layer_sets: Vec<Vec<crate::model::LayerArrayConfig>> = vec![
1307            vec![mk_layer(serde_json::json!({
1308                "input_size": 1, "condition_size": 1, "channels": 1, "head_size": 1,
1309                "kernel_size": 1, "dilations": [1], "activation": "Tanh",
1310                "gated": false, "head_bias": false
1311            }))],
1312            vec![mk_layer(serde_json::json!({
1313                "input_size": 1, "condition_size": 1, "channels": 2, "head_size": 1,
1314                "kernel_size": 3, "dilations": [1, 2], "activation": "Tanh",
1315                "gated": false, "head_bias": false
1316            }))],
1317            vec![mk_layer(serde_json::json!({
1318                "input_size": 1, "condition_size": 1, "channels": 4, "head_size": 2,
1319                "kernel_size": 3, "dilations": [1, 2, 4], "activation": "Tanh",
1320                "gated": true, "head_bias": false
1321            }))], // gated
1322            vec![mk_layer(serde_json::json!({
1323                "input_size": 1, "condition_size": 1, "channels": 3, "head_size": 1,
1324                "kernel_size": 3, "dilations": [1], "activation": "Tanh",
1325                "gated": false, "head_bias": true
1326            }))], // head_bias
1327            // two arrays: the second takes the first's channels as its input_size,
1328            // and the first's head_size must equal the second's channels (the
1329            // head-carry invariant guarded in `WaveNet::new`).
1330            vec![
1331                mk_layer(serde_json::json!({
1332                    "input_size": 1, "condition_size": 1, "channels": 4, "head_size": 2,
1333                    "kernel_size": 3, "dilations": [1, 2], "activation": "Tanh",
1334                    "gated": false, "head_bias": false
1335                })),
1336                mk_layer(serde_json::json!({
1337                    "input_size": 4, "condition_size": 1, "channels": 2, "head_size": 1,
1338                    "kernel_size": 3, "dilations": [1], "activation": "Tanh",
1339                    "gated": true, "head_bias": true
1340                })),
1341            ],
1342        ];
1343        for layers in layer_sets {
1344            let cfg = WaveNetConfig {
1345                layers,
1346                post_stack_head: None,
1347                head_scale: 1.0,
1348                in_channels: 1,
1349                condition_dsp: None,
1350            };
1351            let n = expected_weight_count(&cfg).unwrap();
1352            let mk_model = |count: usize| NamModel {
1353                version: "0".into(),
1354                architecture: "WaveNet".into(),
1355                config: crate::model::ModelConfig::WaveNet(cfg.clone()),
1356                weights: vec![0.0; count],
1357                sample_rate: None,
1358                metadata: None,
1359            };
1360            // Build via `new_conditioning`: this pins the weight-count *consumption*
1361            // invariant across shapes, including multi-output ones (head_size > 1 with
1362            // no post-stack head), which the mono `new` rightly rejects. Both share the
1363            // same build/weight-accounting path, so the count check is identical.
1364            assert!(
1365                WaveNet::new_conditioning(&mk_model(n)).is_ok(),
1366                "exact count n={n}"
1367            );
1368            assert!(matches!(
1369                WaveNet::new_conditioning(&mk_model(n - 1)),
1370                Err(Error::WeightCountMismatch { .. })
1371            ));
1372            assert!(matches!(
1373                WaveNet::new_conditioning(&mk_model(n + 1)),
1374                Err(Error::WeightCountMismatch { .. })
1375            ));
1376        }
1377    }
1378
1379    /// Build a `NamModel` wrapping a `WaveNetConfig` with the given layers (no
1380    /// post-stack head / condition_dsp), padded with as many zero weights as it claims
1381    /// to need so the build reaches the validation under test rather than failing on a
1382    /// weight-count mismatch first.
1383    fn wavenet_model(layers: Vec<crate::model::LayerArrayConfig>) -> NamModel {
1384        let cfg = WaveNetConfig {
1385            layers,
1386            post_stack_head: None,
1387            head_scale: 1.0,
1388            in_channels: 1,
1389            condition_dsp: None,
1390        };
1391        let count = expected_weight_count(&cfg).unwrap_or(1);
1392        NamModel {
1393            version: "0".into(),
1394            architecture: "WaveNet".into(),
1395            config: crate::model::ModelConfig::WaveNet(cfg),
1396            weights: vec![0.0; count],
1397            sample_rate: None,
1398            metadata: None,
1399        }
1400    }
1401
1402    #[test]
1403    fn empty_layers_is_rejected() {
1404        // A WaveNet with no arrays would build but every hot path is a passthrough
1405        // that ignores a post-stack head / condition_dsp — reject it at build time.
1406        let model = wavenet_model(vec![]);
1407        assert!(matches!(
1408            WaveNet::new(&model),
1409            Err(Error::UnsupportedFeature(f)) if f.contains("no layer-arrays")
1410        ));
1411    }
1412
1413    #[test]
1414    fn top_level_multi_output_is_rejected_but_allowed_when_nested() {
1415        // A single array with head_size 2 and no post-stack head emits 2 channels;
1416        // the mono `process_buffer` would silently keep only row 0. The top-level
1417        // `new` must reject it, while `new_conditioning` (the nested cdsp path) accepts
1418        // it — there the 2 rows become the parent's conditioning.
1419        let layers = vec![mk_layer(serde_json::json!({
1420            "input_size": 1, "condition_size": 1, "channels": 2, "head_size": 2,
1421            "kernel_size": 1, "dilations": [1], "activation": "Tanh",
1422            "gated": false, "head_bias": false
1423        }))];
1424        let model = wavenet_model(layers);
1425        assert!(matches!(
1426            WaveNet::new(&model),
1427            Err(Error::UnsupportedFeature(f)) if f.contains("mono-output")
1428        ));
1429        assert!(
1430            WaveNet::new_conditioning(&model).is_ok(),
1431            "multi-output is valid for a nested condition_dsp"
1432        );
1433    }
1434
1435    #[test]
1436    fn non_divisible_groups_is_rejected_cleanly_not_panicking() {
1437        // groups_input = 2 with channels = 3 (3 % 2 != 0): the block-diagonal layout
1438        // can't be formed. This must surface as a clean `Err`, not a `Conv1d` panic.
1439        let layers = vec![mk_layer(serde_json::json!({
1440            "input_size": 1, "condition_size": 1, "channels": 3, "head_size": 1,
1441            "kernel_size": 1, "dilations": [1], "activation": "Tanh",
1442            "gated": false, "head_bias": false, "groups_input": 2
1443        }))];
1444        let model = wavenet_model(layers);
1445        assert!(matches!(
1446            WaveNet::new(&model),
1447            Err(Error::UnsupportedFeature(f)) if f.contains("divisible by groups")
1448        ));
1449    }
1450
1451    #[test]
1452    fn wavenet_new_rejects_non_wavenet() {
1453        let lstm = r#"{
1454            "version": "0.5.4", "architecture": "LSTM",
1455            "config": { "input_size": 1, "hidden_size": 4, "num_layers": 1 },
1456            "weights": [0.0]
1457        }"#;
1458        let model = NamModel::from_json_str(lstm).unwrap();
1459        assert!(matches!(
1460            WaveNet::new(&model),
1461            Err(Error::UnsupportedArchitecture(_))
1462        ));
1463    }
1464
1465    /// End-to-end on the realistic standard model: the block `process_buffer` must
1466    /// equal a per-sample `process_sample` loop over the same signal, including
1467    /// across `MAX_BLOCK` chunk boundaries. `tests/parity.rs` drives `process_buffer`
1468    /// (the block path) and pins it to the reference NAM oracle within 1e-5; this
1469    /// test additionally ties the block path to the per-sample path, so the two are
1470    /// transitively guaranteed equivalent and both oracle-correct.
1471    #[test]
1472    fn process_buffer_equals_process_sample_loop_on_standard_model() {
1473        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1474            .join("tests/fixtures/reference_standard.nam");
1475        let json = std::fs::read_to_string(path).expect("read standard fixture");
1476        let model = NamModel::from_json_str(&json).expect("parse standard fixture");
1477
1478        // A signal longer than MAX_BLOCK so chunking is exercised.
1479        let len = 2 * MAX_BLOCK + 137;
1480        let signal: Vec<f32> = (0..len)
1481            .map(|i| (i as f32 * 0.013).sin() * 0.5 + (i as f32 * 0.27).sin() * 0.2)
1482            .collect();
1483
1484        let mut per_sample = WaveNet::new(&model).unwrap();
1485        let want: Vec<f32> = signal
1486            .iter()
1487            .map(|&x| per_sample.process_sample(x))
1488            .collect();
1489
1490        let mut block = WaveNet::new(&model).unwrap();
1491        let mut got = signal.clone();
1492        block.process_buffer(&mut got);
1493
1494        for (i, (g, w)) in got.iter().zip(&want).enumerate() {
1495            assert!(
1496                (g - w).abs() < 1e-5,
1497                "sample {i}: block {g}, per-sample {w}"
1498            );
1499        }
1500    }
1501
1502    #[test]
1503    fn a2_leaky_relu_and_conv_head_parse_and_build() {
1504        // LeakyReLU + multi-tap conv head (kernel_size=16) are now fully supported.
1505        // Verify the config parses and builds (weight count determines valid input).
1506        let json = r#"{
1507            "version":"0.7.0","architecture":"WaveNet","config":{
1508                "layers":[{"input_size":1,"condition_size":1,"channels":2,"bottleneck":2,
1509                    "dilations":[1,2],"kernel_sizes":[3,3],
1510                    "activation":[{"type":"LeakyReLU"},{"type":"LeakyReLU"}],
1511                    "head":{"out_channels":1,"kernel_size":16,"bias":true},
1512                    "layer1x1":{"active":true,"groups":1},
1513                    "gating_mode":["none","none"]}],
1514                "head":null,"head_scale":0.5},
1515            "weights":[]}"#;
1516        let model0 = NamModel::from_json_str(json).expect("parses cleanly now");
1517        let cfg = match &model0.config {
1518            crate::model::ModelConfig::WaveNet(c) => c,
1519            _ => unreachable!(),
1520        };
1521        let n = expected_weight_count(cfg).unwrap();
1522        let model = NamModel {
1523            version: "0.7.0".into(),
1524            architecture: "WaveNet".into(),
1525            config: crate::model::ModelConfig::WaveNet(cfg.clone()),
1526            weights: vec![0.0; n],
1527            sample_rate: None,
1528            metadata: None,
1529        };
1530        assert!(
1531            WaveNet::new(&model).is_ok(),
1532            "LeakyReLU + multi-tap conv head should now build"
1533        );
1534    }
1535
1536    #[test]
1537    fn a1_still_builds_and_runs_after_typed_config() {
1538        let model = NamModel::from_json_str(TINY).unwrap();
1539        let mut wn = WaveNet::new(&model).unwrap();
1540        let mut buf = [0.5_f32];
1541        wn.process_buffer(&mut buf);
1542        assert!((buf[0] - 10.0).abs() < 1e-5, "got {}", buf[0]);
1543    }
1544
1545    /// Non-power-of-2 and out-of-order dilations with kernel 6 must size buffers
1546    /// correctly: receptive field is order-independent, and the block path equals the
1547    /// per-sample path. Mirrors A2's `[1,5,29,97,227]`-style dilations.
1548    #[test]
1549    fn non_pow2_out_of_order_dilations_size_correctly() {
1550        let json = r#"{
1551            "version":"0.7.0","architecture":"WaveNet","config":{"layers":[{
1552                "input_size":1,"condition_size":1,"channels":2,"head_size":1,
1553                "kernel_size":6,"dilations":[97,1,227,5,29],"activation":"ReLU",
1554                "gated":false,"head_bias":false}],"head":null,"head_scale":0.5},
1555            "weights":[]}"#;
1556        // Parse the config, then fill weights to the exact expected count so build succeeds.
1557        let model0 = NamModel::from_json_str(json).unwrap();
1558        let cfg = match &model0.config {
1559            crate::model::ModelConfig::WaveNet(c) => c,
1560            _ => unreachable!(),
1561        };
1562        let n = expected_weight_count(cfg).unwrap();
1563        let weights: Vec<f32> = (0..n).map(|i| ((i % 7) as f32 - 3.0) * 0.05).collect();
1564        let model = NamModel {
1565            version: "0.7.0".into(),
1566            architecture: "WaveNet".into(),
1567            config: crate::model::ModelConfig::WaveNet(cfg.clone()),
1568            weights,
1569            sample_rate: None,
1570            metadata: None,
1571        };
1572
1573        // rf = 1 + (k-1)*sum(dilations), order-independent.
1574        let want_rf = 1 + (6 - 1) * (97 + 1 + 227 + 5 + 29);
1575        let mut per_sample = WaveNet::new(&model).unwrap();
1576        assert_eq!(per_sample.receptive_field(), want_rf);
1577
1578        // block path == per-sample path over a signal longer than MAX_BLOCK.
1579        let len = MAX_BLOCK + 200;
1580        let signal: Vec<f32> = (0..len).map(|i| (i as f32 * 0.017).sin() * 0.4).collect();
1581        let want: Vec<f32> = signal
1582            .iter()
1583            .map(|&x| per_sample.process_sample(x))
1584            .collect();
1585        let mut block = WaveNet::new(&model).unwrap();
1586        let mut got = signal.clone();
1587        block.process_buffer(&mut got);
1588        for (i, (g, w)) in got.iter().zip(&want).enumerate() {
1589            assert!(
1590                (g - w).abs() < 1e-5,
1591                "sample {i}: block {g}, per-sample {w}"
1592            );
1593        }
1594    }
1595
1596    #[test]
1597    fn multitap_head_config_now_builds() {
1598        // Weight count: rechannel 2 + 2 layers*(conv 2*2*3+2=14, mix 2, one 2*2+2=6 =>22)
1599        // + head(1*2*4=8 + bias 1 =9) + head_scale 1 = 56.
1600        let json = r#"{
1601            "version":"0.7.0","architecture":"WaveNet","config":{
1602                "layers":[{"input_size":1,"condition_size":1,"channels":2,"bottleneck":2,
1603                    "dilations":[1,2],"kernel_sizes":[3,3],
1604                    "activation":[{"type":"ReLU"},{"type":"ReLU"}],
1605                    "head":{"out_channels":1,"kernel_size":4,"bias":true},
1606                    "layer1x1":{"active":true,"groups":1},
1607                    "gating_mode":["none","none"]}],
1608                "head":null,"head_scale":0.5},
1609            "weights":[]}"#;
1610        let model0 = NamModel::from_json_str(json).unwrap();
1611        let cfg = match &model0.config {
1612            crate::model::ModelConfig::WaveNet(c) => c,
1613            _ => unreachable!(),
1614        };
1615        let n = expected_weight_count(cfg).unwrap();
1616        assert_eq!(n, 56, "expected 56 weights for this conv-head config");
1617        let weights: Vec<f32> = (0..n).map(|i| ((i % 5) as f32 - 2.0) * 0.1).collect();
1618        let model = NamModel {
1619            version: "0.7.0".into(),
1620            architecture: "WaveNet".into(),
1621            config: crate::model::ModelConfig::WaveNet(cfg.clone()),
1622            weights,
1623            sample_rate: None,
1624            metadata: None,
1625        };
1626        let mut wn = WaveNet::new(&model).expect("conv-head model builds");
1627        let signal: Vec<f32> = (0..256).map(|i| (i as f32 * 0.05).sin() * 0.3).collect();
1628        let want: Vec<f32> = {
1629            let mut w = WaveNet::new(&model).unwrap();
1630            signal.iter().map(|&x| w.process_sample(x)).collect()
1631        };
1632        let mut got = signal.clone();
1633        wn.process_buffer(&mut got);
1634        for (i, (g, w)) in got.iter().zip(&want).enumerate() {
1635            assert!(
1636                (g - w).abs() < 1e-5,
1637                "sample {i}: block {g} vs per-sample {w}"
1638            );
1639        }
1640    }
1641
1642    #[test]
1643    fn formerly_guarded_a2_features_now_build() {
1644        // grouped input conv + head1x1 + bottleneck<channels + FiLM + BLENDED + non-sigmoid
1645        // secondary, in one array. Weight count drives the (zeroed) blob length.
1646        let json = r#"{
1647            "version":"0.7.0","architecture":"WaveNet","config":{"layers":[{
1648                "input_size":1,"condition_size":1,"channels":4,"bottleneck":2,
1649                "dilations":[1,2],"kernel_sizes":[3,3],
1650                "activation":[{"type":"Tanh"},{"type":"Tanh"}],
1651                "gating_mode":["blended","blended"],
1652                "secondary_activation":[{"type":"Tanh"},{"type":"Tanh"}],
1653                "groups_input":2,"groups_input_mixin":1,
1654                "layer1x1":{"active":true,"groups":2},
1655                "head1x1":{"active":true,"out_channels":3,"groups":1},
1656                "head":{"out_channels":1,"kernel_size":1,"bias":false},
1657                "conv_post_film":{"active":true,"shift":true,"groups":1},
1658                "activation_post_film":{"active":true,"shift":false,"groups":1},
1659                "layer1x1_post_film":{"active":true,"shift":false,"groups":1}
1660            }],"head":null,"head_scale":0.5},"weights":[]}"#;
1661        let m0 = NamModel::from_json_str(json).unwrap();
1662        let cfg = match &m0.config {
1663            crate::model::ModelConfig::WaveNet(c) => c,
1664            _ => unreachable!(),
1665        };
1666        let n = expected_weight_count(cfg).unwrap();
1667        let weights: Vec<f32> = (0..n).map(|i| ((i % 7) as f32 - 3.0) * 0.02).collect();
1668        let model = NamModel {
1669            version: "0.7.0".into(),
1670            architecture: "WaveNet".into(),
1671            config: crate::model::ModelConfig::WaveNet(cfg.clone()),
1672            weights,
1673            sample_rate: None,
1674            metadata: None,
1675        };
1676        assert!(
1677            WaveNet::new(&model).is_ok(),
1678            "full A2 feature layer must build now"
1679        );
1680
1681        // Inactive-layer1x1 (bottleneck==channels) also builds.
1682        let json2 = r#"{"version":"0.7.0","architecture":"WaveNet","config":{"layers":[{
1683            "input_size":1,"condition_size":1,"channels":2,"bottleneck":2,
1684            "dilations":[1],"kernel_sizes":[3],"activation":[{"type":"ReLU"}],
1685            "gating_mode":["none"],"layer1x1":{"active":false,"groups":1},
1686            "head":{"out_channels":1,"kernel_size":1,"bias":false}}],
1687            "head":null,"head_scale":0.5},"weights":[]}"#;
1688        let m2 = NamModel::from_json_str(json2).unwrap();
1689        let c2 = match &m2.config {
1690            crate::model::ModelConfig::WaveNet(c) => c,
1691            _ => unreachable!(),
1692        };
1693        let n2 = expected_weight_count(c2).unwrap();
1694        let model2 = NamModel {
1695            version: "0.7.0".into(),
1696            architecture: "WaveNet".into(),
1697            config: crate::model::ModelConfig::WaveNet(c2.clone()),
1698            weights: vec![0.0; n2],
1699            sample_rate: None,
1700            metadata: None,
1701        };
1702        assert!(
1703            WaveNet::new(&model2).is_ok(),
1704            "inactive layer1x1 must build now"
1705        );
1706    }
1707}