Skip to main content

nam_rs/
model.rs

1//! Parsing of the on-disk `.nam` file format.
2//!
3//! A `.nam` file is a JSON object. The fields here mirror NAM's
4//! `export_config()` / `export_weights()` output (see crate-level attribution).
5//! WaveNet, LSTM, and SlimmableContainer architectures are parsed here (see
6//! [`ModelConfig`]);
7//! the runtime forward passes live in their own modules.
8
9use serde::de::{self, Deserializer};
10use serde::Deserialize;
11
12use crate::error::Error;
13
14/// How a layer-array's `activation` field was specified in the `.nam`.
15///
16/// NAM A1 writes a bare string (`"Tanh"`); A2 may write a dict
17/// (`{"type": "LeakyReLU", "negative_slope": 0.01}`). A per-layer *list* (a
18/// distinct activation per layer) is not modeled and is captured as
19/// [`ActivationSpec::Unsupported`], which the runtime rejects with
20/// [`crate::Error::UnsupportedFeature`] rather than silently mis-running.
21#[derive(Debug, Clone, PartialEq)]
22pub enum ActivationSpec {
23    /// A single named activation, with an optional negative slope (LeakyReLU).
24    Named {
25        /// Activation name, e.g. `"Tanh"`, `"ReLU"`, `"LeakyReLU"`.
26        name: String,
27        /// LeakyReLU negative slope, if the file specified one. `None` → the
28        /// runtime applies NAM's default of `0.01`.
29        negative_slope: Option<f32>,
30    },
31    /// A shape this crate does not model (e.g. a per-layer activation list).
32    Unsupported(serde_json::Value),
33}
34
35impl<'de> Deserialize<'de> for ActivationSpec {
36    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
37    where
38        D: Deserializer<'de>,
39    {
40        let v = serde_json::Value::deserialize(deserializer)?;
41        Ok(match &v {
42            serde_json::Value::String(s) => ActivationSpec::Named {
43                name: s.clone(),
44                negative_slope: None,
45            },
46            serde_json::Value::Object(map) => match map.get("type") {
47                Some(serde_json::Value::String(t)) => match map.get("negative_slope") {
48                    // Absent or explicit-null slope → runtime default (0.01).
49                    None | Some(serde_json::Value::Null) => ActivationSpec::Named {
50                        name: t.clone(),
51                        negative_slope: None,
52                    },
53                    // Present and numeric → use it.
54                    Some(slope) if slope.as_f64().is_some() => ActivationSpec::Named {
55                        name: t.clone(),
56                        negative_slope: slope.as_f64().map(|x| x as f32),
57                    },
58                    // Present but not a number → malformed; reject rather than silently
59                    // defaulting (a corrupt/upstream-format error must not pass silently).
60                    Some(_) => ActivationSpec::Unsupported(v.clone()),
61                },
62                _ => ActivationSpec::Unsupported(v),
63            },
64            _ => ActivationSpec::Unsupported(v),
65        })
66    }
67}
68
69/// Sample rate assumed when a `.nam` file omits the `sample_rate` field.
70///
71/// Matches NAM's documented default.
72pub const DEFAULT_SAMPLE_RATE: f64 = 48_000.0;
73
74/// A parsed `.nam` model file.
75///
76/// This is the *file representation* — the raw config + flat weight blob. To run
77/// inference, build a [`crate::WaveNet`] from it.
78#[derive(Debug, Clone)]
79pub struct NamModel {
80    /// `.nam` format version string (e.g. `"0.5.4"`).
81    pub version: String,
82    /// Model architecture, e.g. `"WaveNet"`.
83    pub architecture: String,
84    /// Architecture-specific configuration (dispatched on [`Self::architecture`]).
85    pub config: ModelConfig,
86    /// Flat weight blob. The final element is `head_scale` (see NAM
87    /// `export_weights`). Stored as `f32` to match NAM Core's inference precision.
88    pub weights: Vec<f32>,
89    /// Training sample rate. Absent in older files; see [`Self::expected_sample_rate`].
90    pub sample_rate: Option<f64>,
91    /// Opaque training/gear metadata. Not used for inference.
92    pub metadata: Option<serde_json::Value>,
93}
94
95/// LSTM configuration (NAM `_export_config`).
96#[derive(Debug, Clone, Deserialize)]
97pub struct LstmConfig {
98    /// Input width (1 for mono amp models).
99    pub input_size: usize,
100    /// Hidden state dimension `H`.
101    pub hidden_size: usize,
102    /// Number of stacked LSTM layers `L`.
103    pub num_layers: usize,
104}
105
106/// One entry in a [`SlimmableConfig`]: a complete standalone submodel plus the
107/// width-dial threshold at which it becomes active.
108#[derive(Debug, Clone, Deserialize)]
109pub struct SlimmableSubmodel {
110    /// Upper width-dial value this submodel covers (NAM Core `max_value`).
111    pub max_value: f32,
112    /// The submodel itself — a full standalone `.nam` of any architecture.
113    pub model: NamModel,
114}
115
116/// `SlimmableContainer` configuration: an ordered list of standalone submodels
117/// selected at runtime by a width dial. The container holds no weights of its own.
118#[derive(Debug, Clone, Deserialize)]
119pub struct SlimmableConfig {
120    /// Submodels in ascending `max_value` order; the last is the full-width model.
121    pub submodels: Vec<SlimmableSubmodel>,
122}
123
124/// Architecture-specific configuration, tagged by `NamModel.architecture`.
125#[derive(Debug, Clone)]
126pub enum ModelConfig {
127    /// WaveNet: a stack of dilated-convolution layer-arrays. Runnable via
128    /// [`crate::WaveNet`].
129    WaveNet(WaveNetConfig),
130    /// LSTM: stacked recurrent layers plus a linear head.
131    Lstm(LstmConfig),
132    /// SlimmableContainer: a width-selectable set of standalone submodels.
133    Slimmable(SlimmableConfig),
134}
135
136impl<'de> Deserialize<'de> for NamModel {
137    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
138    where
139        D: Deserializer<'de>,
140    {
141        // Parse the file shape with `config` left raw, then dispatch on
142        // `architecture` to type it. This reads the sibling `architecture` field,
143        // which `#[serde(deserialize_with)]` on a single field cannot do.
144        #[derive(Deserialize)]
145        struct Raw {
146            version: String,
147            architecture: String,
148            config: serde_json::Value,
149            weights: Vec<f32>,
150            #[serde(default)]
151            sample_rate: Option<f64>,
152            #[serde(default)]
153            metadata: Option<serde_json::Value>,
154        }
155
156        let raw = Raw::deserialize(deserializer)?;
157        let config = match raw.architecture.as_str() {
158            "WaveNet" => {
159                let raw_wn: RawWaveNetConfig =
160                    serde_json::from_value(raw.config).map_err(de::Error::custom)?;
161                ModelConfig::WaveNet(raw_wn.normalize().map_err(de::Error::custom)?)
162            }
163            "LSTM" => {
164                ModelConfig::Lstm(serde_json::from_value(raw.config).map_err(de::Error::custom)?)
165            }
166            "SlimmableContainer" => ModelConfig::Slimmable(
167                serde_json::from_value(raw.config).map_err(de::Error::custom)?,
168            ),
169            other => {
170                return Err(de::Error::custom(format!(
171                    "unsupported model architecture: {other:?}"
172                )))
173            }
174        };
175
176        Ok(NamModel {
177            version: raw.version,
178            architecture: raw.architecture,
179            config,
180            weights: raw.weights,
181            sample_rate: raw.sample_rate,
182            metadata: raw.metadata,
183        })
184    }
185}
186
187/// Deserialize a field without letting its failure sink the whole struct.
188///
189/// [`Metadata`] is parsed all-or-nothing by `serde_json::from_value`, so one field
190/// with an unexpected shape would otherwise discard *every* other field along with
191/// it (see [`NamModel::metadata_typed`], which falls back to `Default`). Each field
192/// therefore absorbs its own error and yields `None` instead.
193///
194/// This covers a value of the wrong *shape* — `"date": "last Tuesday"`, a numeric
195/// `"gear_type"` — which is the case that would otherwise cost us the calibration
196/// numbers the DSP path depends on. It is not needed for an explicit `null` or for
197/// an integer where the schema says float (real files write `"input_level_dbu": 15`):
198/// plain `#[serde(default)] Option<T>` already handles both, and did before this
199/// existed.
200fn lenient<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
201where
202    D: Deserializer<'de>,
203    T: for<'a> Deserialize<'a>,
204{
205    // Buffer into a `Value` first: that always succeeds for well-formed JSON, and
206    // lets the fallible typed conversion happen where we can swallow the error.
207    let value = serde_json::Value::deserialize(deserializer)?;
208    Ok(serde_json::from_value(value).ok())
209}
210
211/// The calendar timestamp NAM stamps into `metadata.date` when the model is
212/// exported. No timezone is recorded, so treat it as a naive local timestamp.
213///
214/// Field order makes the derived [`Ord`] chronological, so a list of models sorts
215/// newest-last by date. Across authors that ordering is approximate — each stamp is
216/// local wall-clock time in whatever zone the trainer ran in.
217///
218/// The ranges below are what NAM writes; values are taken verbatim from the file and
219/// are not range-checked.
220#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize)]
221#[non_exhaustive]
222pub struct Date {
223    /// Calendar year, e.g. `2026`.
224    pub year: i32,
225    /// Month of year, `1..=12`.
226    pub month: u32,
227    /// Day of month, `1..=31`.
228    pub day: u32,
229    /// Hour on a 24-hour clock, `0..=23`.
230    pub hour: u32,
231    /// Minute of the hour, `0..=59`.
232    pub minute: u32,
233    /// Second of the minute, `0..=59`.
234    pub second: u32,
235}
236
237/// The fields NAM may write into a `.nam` file's `metadata` block.
238///
239/// **None of this reaches the forward pass** — it is descriptive only, for display
240/// and routing decisions. All fields are optional: older or minimal files omit the
241/// block entirely, and each field is parsed leniently, so one malformed entry never
242/// costs you the others. Unknown keys are ignored.
243///
244/// For comparison, NAM's C++ `NeuralAmpModelerCore` types only the three
245/// calibration numbers (`loudness`, `input_level_dbu`, `output_level_dbu`) and
246/// leaves the rest as an untyped JSON blob for each host to re-derive.
247#[derive(Debug, Clone, PartialEq, Default, Deserialize)]
248#[non_exhaustive]
249pub struct Metadata {
250    /// How loud the model is against NAM's standardized input, in dBFS (NAM's
251    /// `loudness`).
252    ///
253    /// This is plain RMS — the trainer computes `20·log10(sqrt(mean(y²)))` — not a
254    /// perceptual measure. It is **not** LUFS: no K-weighting, no gating. Two models
255    /// with the same `loudness` but different spectra will not match perceptually, so
256    /// don't feed this number to a loudness target that expects ITU-R BS.1770.
257    #[serde(default, deserialize_with = "lenient")]
258    pub loudness: Option<f32>,
259    /// Analog level (dBu) corresponding to 0 dBFS at the model input.
260    #[serde(default, deserialize_with = "lenient")]
261    pub input_level_dbu: Option<f32>,
262    /// Analog level (dBu) corresponding to 0 dBFS at the model output.
263    #[serde(default, deserialize_with = "lenient")]
264    pub output_level_dbu: Option<f32>,
265    /// NAM's own estimate, in `0.0..=1.0`, of "how much gain / compression does the
266    /// model seem to have" — derived by the trainer from the model's response across
267    /// a sweep of input levels, not a knob position on the modelled gear.
268    #[serde(default, deserialize_with = "lenient")]
269    pub gain: Option<f32>,
270    /// Human-readable model name chosen by its author. Often more descriptive than
271    /// the filename, which is whatever the file happens to have been saved as.
272    #[serde(default, deserialize_with = "lenient")]
273    pub name: Option<String>,
274    /// Who captured the model.
275    #[serde(default, deserialize_with = "lenient")]
276    pub modeled_by: Option<String>,
277    /// Manufacturer of the modelled gear, e.g. `"Marshall"`.
278    #[serde(default, deserialize_with = "lenient")]
279    pub gear_make: Option<String>,
280    /// Model of the modelled gear, e.g. `"JMP-50"`.
281    #[serde(default, deserialize_with = "lenient")]
282    pub gear_model: Option<String>,
283    /// What kind of gear was captured, and — crucially — how much of the signal
284    /// chain the capture covers.
285    ///
286    /// This is a `String` rather than an enum on purpose: there is no single
287    /// vocabulary, and the vocabularies move. NAM's trainer writes `amp`, `pedal`,
288    /// `pedal_amp`, `amp_cab`, `amp_pedal_cab`, `preamp`, `studio`; TONE3000 writes
289    /// `amp`, `amp-cab`, `pedal`, `outboard`, `cab`, `space`, `experimental`, plus
290    /// the deprecated-but-permanently-accepted `full-rig` and `ir`. Only `amp` and
291    /// `pedal` are common to both, and TONE3000 has already retired values that
292    /// exist in files on disk. An enum over either set would reject real files.
293    /// Use [`Metadata::includes_cab`] rather than matching by hand.
294    #[serde(default, deserialize_with = "lenient")]
295    pub gear_type: Option<String>,
296    /// Character of the captured tone. NAM's vocabulary is `clean`, `overdrive`,
297    /// `crunch`, `hi_gain`, `fuzz`; a `String` for the same reason as
298    /// [`Self::gear_type`] (values like `"bass"` and `"T3K-Null"` occur in the wild).
299    #[serde(default, deserialize_with = "lenient")]
300    pub tone_type: Option<String>,
301    /// Which trainer produced the file, when it says, e.g. `"TONE3000"`.
302    #[serde(default, deserialize_with = "lenient")]
303    pub trainer: Option<String>,
304    /// When the model was exported.
305    #[serde(default, deserialize_with = "lenient")]
306    pub date: Option<Date>,
307    /// The trainer's own record of the training run — latency calibration, data
308    /// checks, settings. Deliberately left untyped: the shape is trainer- and
309    /// version-specific and carries no stability guarantee.
310    #[serde(default)]
311    pub training: Option<serde_json::Value>,
312}
313
314impl Metadata {
315    /// Whether the capture includes a speaker cabinet, as far as
316    /// [`Self::gear_type`] says.
317    ///
318    /// This is the question that matters for signal routing: stacking an impulse
319    /// response on top of a model that already has a cab in it means two cabs in
320    /// series, which sounds wrong. `Some(true)` says the cab is already baked in, so
321    /// don't add an IR. `Some(false)` says only that this capture stops before the
322    /// speaker — whether an IR belongs *directly* after it still depends on the rest
323    /// of your chain, since a `pedal` or `outboard` capture wants an amp next, not a
324    /// cab.
325    ///
326    /// Recognized are the values NAM's trainer and TONE3000 actually write, plus any
327    /// value naming `cab` as one of its `_`-separated components (so a future
328    /// `"pedal_amp_cab"` classifies without a code change). `cab` is matched as a
329    /// whole token, never a substring, so `"cable"` and `"cabless"` don't read as
330    /// cab-inclusive. Matching ignores case, surrounding whitespace, and `-` vs `_`,
331    /// so TONE3000's `"full-rig"` and a hypothetical `"full_rig"` agree.
332    ///
333    /// Anything else returns `None` — callers get "unknown" rather than a guess.
334    ///
335    /// The token rule has a known limit: a *negated* spelling like `"amp_no_cab"`
336    /// still reads as cab-inclusive, since `cab` is one of its tokens. Neither
337    /// vocabulary forms values that way, and detecting negation in free text is
338    /// guesswork of the kind this function exists to avoid — so it is documented
339    /// rather than defended against.
340    ///
341    /// The answer is only ever as good as the file: `gear_type` is author-supplied
342    /// and real captures do mislabel themselves (a file named `...-FullRig.nam`
343    /// tagged `gear_type: "amp"`). Treat it as a default to offer, not a fact to act
344    /// on silently.
345    ///
346    /// NAM's `"studio"` is deliberately `None`: NAM documents the value nowhere, and
347    /// it reads equally well as "studio outboard gear" (no speaker — which is what
348    /// TONE3000 calls `outboard`) or "studio-recorded chain" (speaker included).
349    /// Guessing wrong would silently route audio through one cab too many, or none
350    /// at all, so we decline to guess.
351    ///
352    /// ```
353    /// # use nam_rs::Metadata;
354    /// let md = Metadata::default();
355    /// assert_eq!(md.includes_cab(), None); // no gear_type recorded
356    /// ```
357    #[must_use]
358    pub fn includes_cab(&self) -> Option<bool> {
359        let normalized = self
360            .gear_type
361            .as_deref()?
362            .trim()
363            .to_ascii_lowercase()
364            .replace('-', "_");
365        match normalized.as_str() {
366            // Everything that stops at or before the power amp's output jack.
367            // `outboard` is TONE3000's term for rack/desk gear, which has no speaker;
368            // `space` is its room/reverb category, which likewise isn't a guitar cab.
369            "amp" | "preamp" | "pedal" | "pedal_amp" | "outboard" | "space" => Some(false),
370            // A whole recorded chain, speaker (and mic) baked in. TONE3000 documents
371            // `full-rig` as a deprecated alias for `amp-cab`, so this is their
372            // classification, not our inference. Their `ir` category (also
373            // deprecated, superseded by `format=ir`) is a cab response captured
374            // directly — a cab by definition.
375            "full_rig" | "ir" => Some(true),
376            // `amp_cab`/`amp-cab`, `cab`, `amp_pedal_cab`, and any future spelling
377            // that names a cab as one of its components. Match `cab` as a whole
378            // `_`-separated token, never a substring: `"cable"` and `"cabless"` both
379            // contain "cab" while meaning something else entirely, and a false
380            // `Some(true)` tells the caller to drop an IR the model needs.
381            other if other.split('_').any(|token| token == "cab") => Some(true),
382            // Unknown: NAM's undocumented `studio` (see above) and TONE3000's
383            // `experimental`, which promises nothing about the signal chain.
384            _ => None,
385        }
386    }
387}
388
389impl NamModel {
390    /// Read and parse a `.nam` model from a file on disk.
391    ///
392    /// Convenience over [`std::fs::read_to_string`] + [`Self::from_json_str`].
393    /// Returns [`Error::Io`] if the file can't be read, or [`Error::Json`] if its
394    /// contents aren't valid `.nam` JSON.
395    pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self, Error> {
396        Self::from_json_str(&std::fs::read_to_string(path)?)
397    }
398
399    /// Parse a `.nam` model from a JSON string already in memory.
400    pub fn from_json_str(json: &str) -> Result<Self, Error> {
401        Ok(serde_json::from_str(json)?)
402    }
403
404    /// The sample rate, in Hz, the model expects its input to be at — falling back
405    /// to [`DEFAULT_SAMPLE_RATE`] when the file does not specify one.
406    ///
407    /// **You must feed the model audio at this rate.** `nam-rs` runs the forward pass
408    /// at whatever rate you hand it and does *not* resample. A model captured at one
409    /// rate fed audio at another produces silently wrong output: its dilations and
410    /// recurrence are defined in samples, not seconds. If your host runs at a
411    /// different rate, resample to this rate before [`crate::Model::process_buffer`]
412    /// and back afterwards — resampling is the caller's responsibility. Mirrors NAM
413    /// Core's `GetExpectedSampleRate()`.
414    #[must_use]
415    pub fn expected_sample_rate(&self) -> f64 {
416        self.sample_rate.unwrap_or(DEFAULT_SAMPLE_RATE)
417    }
418
419    /// The file's descriptive [`Metadata`] — name, gear, tone, trainer, date, loudness
420    /// and calibration levels — parsed from the raw `metadata` block in one shot.
421    ///
422    /// Returns defaults (every field `None`) when the file has no metadata block.
423    /// Fields the block omits are `None`; keys we don't type are ignored, and remain
424    /// reachable on [`Self::metadata`]. Parsing is per-field lenient, so a single
425    /// malformed entry costs only itself.
426    ///
427    /// Prefer this over the single-field accessors ([`Self::loudness`], etc.) when you
428    /// want several fields: each single-field accessor re-clones and re-parses the raw
429    /// JSON, whereas this parses once. (All are cold-path / load-time, so neither is on
430    /// the audio thread.)
431    #[must_use]
432    pub fn metadata_typed(&self) -> Metadata {
433        match &self.metadata {
434            Some(v) => serde_json::from_value(v.clone()).unwrap_or_default(),
435            None => Metadata::default(),
436        }
437    }
438
439    /// Output loudness in dBFS RMS, if the file records it — see
440    /// [`Metadata::loudness`], and note it is not LUFS.
441    #[must_use]
442    pub fn loudness(&self) -> Option<f32> {
443        self.metadata_typed().loudness
444    }
445
446    /// Input calibration level in dBu (analog level at 0 dBFS in), if present.
447    #[must_use]
448    pub fn input_level_dbu(&self) -> Option<f32> {
449        self.metadata_typed().input_level_dbu
450    }
451
452    /// Whether this capture already includes a speaker cabinet — see
453    /// [`Metadata::includes_cab`] for the details and the `None` case.
454    ///
455    /// Use this to decide whether to put an impulse response after the model: a
456    /// cab-inclusive capture followed by an IR puts two cabs in series.
457    #[must_use]
458    pub fn includes_cab(&self) -> Option<bool> {
459        self.metadata_typed().includes_cab()
460    }
461
462    /// Output calibration level in dBu (analog level at 0 dBFS out), if present.
463    #[must_use]
464    pub fn output_level_dbu(&self) -> Option<f32> {
465        self.metadata_typed().output_level_dbu
466    }
467}
468
469/// Activation gating mode for a WaveNet layer (NAMCore `GatingMode`).
470#[derive(Debug, Clone, Copy, PartialEq, Eq)]
471pub enum GatingMode {
472    /// No gating: `out = activation(z)`.
473    None,
474    /// Gated: `out = primary(z_a) * secondary(z_b)` (classic `tanh*sigmoid`).
475    Gated,
476    /// Blended: `out = α·primary(z_a) + (1-α)·z_a`, `α = secondary(z_b)`.
477    Blended,
478}
479
480impl GatingMode {
481    /// Parse a NAMCore gating-mode name (`"none"`/`"gated"`/`"blended"`).
482    pub(crate) fn from_name(s: &str) -> Result<Self, String> {
483        match s {
484            "none" => Ok(Self::None),
485            "gated" => Ok(Self::Gated),
486            "blended" => Ok(Self::Blended),
487            other => Err(format!("unknown gating_mode: {other:?}")),
488        }
489    }
490}
491
492/// A layer's residual 1×1 (`layer1x1`): maps the activated bottleneck back to
493/// `channels`. Active by default (the A1 `_1x1`).
494#[derive(Debug, Clone, Copy, PartialEq, Eq)]
495pub struct Layer1x1Config {
496    /// Whether the 1×1 is present (inactive ⇒ identity residual, needs bottleneck==channels).
497    pub active: bool,
498    /// Grouped-conv group count (1 = dense).
499    pub groups: usize,
500}
501
502/// Read an optional unsigned-int field off a JSON object as `usize`. `None` when
503/// the key is absent or not a non-negative integer. Centralizes the
504/// `get(key).and_then(as_u64).map(as usize)` shape used across the config decoders.
505fn opt_usize(o: &serde_json::Value, key: &str) -> Option<usize> {
506    o.get(key).and_then(|x| x.as_u64()).map(|x| x as usize)
507}
508
509impl Layer1x1Config {
510    pub(crate) fn from_json(v: Option<&serde_json::Value>) -> Self {
511        match v {
512            None => Self {
513                active: true,
514                groups: 1,
515            },
516            Some(o) => Self {
517                active: o.get("active").and_then(|x| x.as_bool()).unwrap_or(true),
518                groups: opt_usize(o, "groups").unwrap_or(1),
519            },
520        }
521    }
522}
523
524/// A layer's head 1×1 (`head1x1`): an optional 1×1 producing this layer's head
525/// contribution. Inactive by default (then the head contribution is the activated
526/// bottleneck directly).
527#[derive(Debug, Clone, Copy, PartialEq, Eq)]
528pub struct Head1x1Config {
529    /// Whether the head 1×1 is present.
530    pub active: bool,
531    /// Output channels (defaults to `channels` when active and unspecified).
532    pub out_channels: Option<usize>,
533    /// Grouped-conv group count.
534    pub groups: usize,
535}
536
537impl Head1x1Config {
538    pub(crate) fn from_json(v: Option<&serde_json::Value>) -> Self {
539        match v {
540            None => Self {
541                active: false,
542                out_channels: None,
543                groups: 1,
544            },
545            Some(o) => Self {
546                active: o.get("active").and_then(|x| x.as_bool()).unwrap_or(false),
547                out_channels: opt_usize(o, "out_channels"),
548                groups: opt_usize(o, "groups").unwrap_or(1),
549            },
550        }
551    }
552}
553
554/// One FiLM block (`*_pre_film` / `*_post_film`): conditions a scale (+ optional
555/// shift) from the conditioning signal. Absent or `false` ⇒ inactive.
556#[derive(Debug, Clone, Copy, PartialEq, Eq)]
557pub struct FilmConfig {
558    /// Whether this FiLM site is applied.
559    pub active: bool,
560    /// Whether it adds a shift term (else scale-only).
561    pub shift: bool,
562    /// Grouped-conv group count for the conditioning 1×1.
563    pub groups: usize,
564}
565
566impl FilmConfig {
567    /// The inactive default (absent key or explicit `false`).
568    pub const INACTIVE: Self = Self {
569        active: false,
570        shift: false,
571        groups: 1,
572    };
573
574    pub(crate) fn from_json(v: Option<&serde_json::Value>) -> Self {
575        match v {
576            None => Self::INACTIVE,
577            Some(serde_json::Value::Bool(false)) => Self::INACTIVE,
578            Some(o) => Self {
579                active: o.get("active").and_then(|x| x.as_bool()).unwrap_or(true),
580                shift: o.get("shift").and_then(|x| x.as_bool()).unwrap_or(true),
581                groups: opt_usize(o, "groups").unwrap_or(1),
582            },
583        }
584    }
585}
586
587/// Post-stack head (`config.head`): a stack of `activation → Conv1D` applied after
588/// the layer-arrays. `None` for A1 / current A2 defaults.
589#[derive(Debug, Clone)]
590pub struct PostStackHeadConfig {
591    /// Hidden channel count between head convs.
592    pub channels: usize,
593    /// Final output channels.
594    pub out_channels: usize,
595    /// Per-conv kernel sizes (one conv per entry).
596    pub kernel_sizes: Vec<usize>,
597    /// Activation applied before each head conv.
598    pub activation: ActivationSpec,
599}
600
601/// WaveNet configuration: layer-arrays, optional post-stack head + condition DSP,
602/// and the output scale. Per-layer quantities are normalized into `Vec`s.
603#[derive(Debug, Clone)]
604pub struct WaveNetConfig {
605    /// One config per layer-array.
606    pub layers: Vec<LayerArrayConfig>,
607    /// Optional post-stack head (`config.head`).
608    pub post_stack_head: Option<PostStackHeadConfig>,
609    /// Output gain (note: the runtime value is the trailing weight).
610    pub head_scale: f32,
611    /// Input channels (default 1).
612    pub in_channels: usize,
613    /// Optional nested conditioning DSP.
614    pub condition_dsp: Option<Box<NamModel>>,
615}
616
617#[derive(serde::Deserialize)]
618struct RawWaveNetConfig {
619    layers: Vec<RawLayerArrayConfig>,
620    #[serde(default)]
621    head: Option<serde_json::Value>,
622    head_scale: f32,
623    #[serde(default)]
624    in_channels: Option<usize>,
625    #[serde(default)]
626    condition_dsp: Option<serde_json::Value>,
627}
628
629impl RawWaveNetConfig {
630    fn normalize(self) -> Result<WaveNetConfig, String> {
631        let layers = self
632            .layers
633            .into_iter()
634            .map(RawLayerArrayConfig::normalize)
635            .collect::<Result<Vec<_>, _>>()?;
636
637        let post_stack_head = match self.head {
638            Some(h) if !h.is_null() => {
639                let channels =
640                    h.get("channels")
641                        .and_then(|x| x.as_u64())
642                        .ok_or("post-stack head missing channels")? as usize;
643                let out_channels = h
644                    .get("out_channels")
645                    .and_then(|x| x.as_u64())
646                    .ok_or("post-stack head missing out_channels")?
647                    as usize;
648                let kernel_sizes: Vec<usize> = h
649                    .get("kernel_sizes")
650                    .and_then(|x| x.as_array())
651                    .ok_or("post-stack head missing kernel_sizes")?
652                    .iter()
653                    .map(|k| {
654                        k.as_u64()
655                            .map(|v| v as usize)
656                            .ok_or("kernel_sizes entry not an int".to_string())
657                    })
658                    .collect::<Result<_, _>>()?;
659                let activation = serde_json::from_value::<ActivationSpec>(
660                    h.get("activation")
661                        .cloned()
662                        .unwrap_or(serde_json::Value::Null),
663                )
664                .map_err(|e| e.to_string())?;
665                Some(PostStackHeadConfig {
666                    channels,
667                    out_channels,
668                    kernel_sizes,
669                    activation,
670                })
671            }
672            _ => None,
673        };
674
675        let condition_dsp = match self.condition_dsp {
676            Some(v) if !v.is_null() => {
677                let m = serde_json::from_value::<NamModel>(v).map_err(|e| e.to_string())?;
678                Some(Box::new(m))
679            }
680            _ => None,
681        };
682
683        Ok(WaveNetConfig {
684            layers,
685            post_stack_head,
686            head_scale: self.head_scale,
687            in_channels: self.in_channels.unwrap_or(1),
688            condition_dsp,
689        })
690    }
691}
692
693/// Configuration for one WaveNet layer-array, normalized so every per-layer
694/// quantity is a `Vec` of length `dilations.len()`. Built from the on-disk JSON by
695/// the internal `RawLayerArrayConfig::normalize`; A1 files fill the A2 fields with
696/// defaults.
697#[derive(Debug, Clone)]
698pub struct LayerArrayConfig {
699    /// Input channels into the array (1 for the first array).
700    pub input_size: usize,
701    /// Conditioning signal width.
702    pub condition_size: usize,
703    /// Hidden channel count between layers.
704    pub channels: usize,
705    /// Internal per-layer width (defaults to `channels`).
706    pub bottleneck: usize,
707    /// Per-layer dilation factors; its length defines the number of layers.
708    pub dilations: Vec<usize>,
709    /// Per-layer dilated-conv kernel sizes (length == `dilations.len()`).
710    pub kernel_sizes: Vec<usize>,
711    /// Per-layer primary activations (length == `dilations.len()`).
712    pub activations: Vec<ActivationSpec>,
713    /// Per-layer gating modes (length == `dilations.len()`).
714    pub gating_modes: Vec<GatingMode>,
715    /// Per-layer secondary activations (for gating); element may be the default
716    /// (a `Named{"Sigmoid"}`) where unspecified. Length == `dilations.len()`.
717    pub secondary_activations: Vec<ActivationSpec>,
718    /// Grouped-conv groups for the dilated conv.
719    pub groups_input: usize,
720    /// Grouped-conv groups for the input mixer.
721    pub groups_input_mixin: usize,
722    /// Head rechannel output width.
723    pub head_size: usize,
724    /// Head rechannel kernel size (1 for A1; e.g. 16 for A2 conv heads).
725    pub head_kernel_size: usize,
726    /// Whether the head rechannel has a bias.
727    pub head_bias: bool,
728    /// Residual 1×1 config.
729    pub layer1x1: Layer1x1Config,
730    /// Head 1×1 config.
731    pub head1x1: Head1x1Config,
732    /// FiLM: applied to the layer input before the dilated conv.
733    pub conv_pre_film: FilmConfig,
734    /// FiLM: applied to the dilated-conv output.
735    pub conv_post_film: FilmConfig,
736    /// FiLM: applied to the conditioning before the input mixer.
737    pub input_mixin_pre_film: FilmConfig,
738    /// FiLM: applied to the input-mixer output.
739    pub input_mixin_post_film: FilmConfig,
740    /// FiLM: applied to the conv+mixin sum before activation.
741    pub activation_pre_film: FilmConfig,
742    /// FiLM: applied to the activation output.
743    pub activation_post_film: FilmConfig,
744    /// FiLM: applied to the layer1x1 output (BLENDED branch only, per NAMCore).
745    pub layer1x1_post_film: FilmConfig,
746    /// FiLM: applied to the head1x1 output.
747    pub head1x1_post_film: FilmConfig,
748}
749
750impl LayerArrayConfig {
751    /// The array's uniform gating mode.
752    ///
753    /// `normalize()` produces one `gating_modes` entry per layer, and the runtime
754    /// guards that they are all equal before building (mixed modes are an
755    /// `UnsupportedFeature`). This accessor encapsulates that post-guard invariant
756    /// — the single uniform mode — instead of indexing `gating_modes[0]` at each use
757    /// site (which would panic on a directly-constructed empty-vec config, since the
758    /// struct is `pub`). Returns [`GatingMode::None`] for an empty list.
759    pub fn gating_mode(&self) -> GatingMode {
760        self.gating_modes
761            .first()
762            .copied()
763            .unwrap_or(GatingMode::None)
764    }
765}
766
767/// On-disk shape of a layer-array config: optional / either-or fields exactly as
768/// NAM writes them. Converted to [`LayerArrayConfig`] by [`Self::normalize`].
769#[derive(Debug, Clone, serde::Deserialize)]
770pub(crate) struct RawLayerArrayConfig {
771    input_size: usize,
772    condition_size: usize,
773    channels: usize,
774    #[serde(default)]
775    bottleneck: Option<usize>,
776    dilations: Vec<usize>,
777    #[serde(default)]
778    kernel_size: Option<usize>,
779    #[serde(default)]
780    kernel_sizes: Option<Vec<usize>>,
781    activation: serde_json::Value,
782    #[serde(default)]
783    gating_mode: Option<serde_json::Value>,
784    #[serde(default)]
785    gated: Option<bool>,
786    #[serde(default)]
787    secondary_activation: Option<serde_json::Value>,
788    #[serde(default)]
789    groups_input: Option<usize>,
790    #[serde(default)]
791    groups_input_mixin: Option<usize>,
792    #[serde(default)]
793    head: Option<serde_json::Value>,
794    #[serde(default)]
795    head_size: Option<usize>,
796    #[serde(default)]
797    head_bias: Option<bool>,
798    #[serde(default)]
799    layer1x1: Option<serde_json::Value>,
800    #[serde(default)]
801    head1x1: Option<serde_json::Value>,
802    #[serde(default)]
803    conv_pre_film: Option<serde_json::Value>,
804    #[serde(default)]
805    conv_post_film: Option<serde_json::Value>,
806    #[serde(default)]
807    input_mixin_pre_film: Option<serde_json::Value>,
808    #[serde(default)]
809    input_mixin_post_film: Option<serde_json::Value>,
810    #[serde(default)]
811    activation_pre_film: Option<serde_json::Value>,
812    #[serde(default)]
813    activation_post_film: Option<serde_json::Value>,
814    #[serde(default)]
815    layer1x1_post_film: Option<serde_json::Value>,
816    #[serde(default)]
817    head1x1_post_film: Option<serde_json::Value>,
818}
819
820impl RawLayerArrayConfig {
821    pub(crate) fn normalize(self) -> Result<LayerArrayConfig, String> {
822        let n = self.dilations.len();
823        if n == 0 {
824            return Err("layer-array has no dilations".into());
825        }
826
827        let kernel_sizes = match (self.kernel_size, self.kernel_sizes) {
828            (Some(_), Some(_)) => {
829                return Err("layer-array specifies both kernel_size and kernel_sizes".into())
830            }
831            (Some(k), None) => vec![k; n],
832            (None, Some(ks)) => {
833                if ks.len() != n {
834                    return Err(format!(
835                        "kernel_sizes length {} != number of layers {n}",
836                        ks.len()
837                    ));
838                }
839                ks
840            }
841            (None, None) => {
842                return Err("layer-array specifies neither kernel_size nor kernel_sizes".into())
843            }
844        };
845
846        let activations = broadcast_activations(&self.activation, n)?;
847
848        // `gating_mode` (A2, per-layer enum) supersedes the legacy boolean `gated`
849        // (A1) when both are present: the richer field wins silently. In practice a
850        // file carries one or the other, so the conflict is theoretical.
851        let gating_modes = match (&self.gating_mode, self.gated) {
852            (Some(v), _) => broadcast_gating(v, n)?,
853            (None, Some(true)) => vec![GatingMode::Gated; n],
854            (None, _) => vec![GatingMode::None; n],
855        };
856
857        let secondary_activations = match &self.secondary_activation {
858            Some(v) => broadcast_secondary(v, n)?,
859            None => vec![default_sigmoid(); n],
860        };
861
862        let (head_size, head_kernel_size, head_bias) = match &self.head {
863            Some(h) if !h.is_null() => {
864                let out = h
865                    .get("out_channels")
866                    .and_then(|x| x.as_u64())
867                    .ok_or("layer head missing out_channels")? as usize;
868                let k = h
869                    .get("kernel_size")
870                    .and_then(|x| x.as_u64())
871                    .ok_or("layer head missing kernel_size")? as usize;
872                // NAMCore requires `bias` on a nested head object (`.at("bias")`
873                // throws if absent). We're leniently defaulting to `true` — the value
874                // every real exporter writes — so a hand-edited file missing it still
875                // loads with the NAMCore-default behavior rather than erroring.
876                let bias = h.get("bias").and_then(|x| x.as_bool()).unwrap_or(true);
877                (out, k, bias)
878            }
879            _ => {
880                let hs = self
881                    .head_size
882                    .ok_or("layer-array missing head_size (and no head object)")?;
883                (hs, 1, self.head_bias.unwrap_or(false))
884            }
885        };
886
887        // Reject zero/degenerate dimensions before they reach the runtime: a
888        // `head_kernel_size == 0` underflows `head_kernel_size - 1` and overflows
889        // the `Conv1d` ring buffer; zero kernel/dilation/channel counts likewise
890        // produce nonsense buffers. NAMCore rejects `head_kernel_size < 1`
891        // (`wavenet/model.cpp`); mirror that here as a clean `Err`, not a panic.
892        if head_kernel_size == 0 {
893            return Err("layer-array head_kernel_size must be >= 1".into());
894        }
895        if self.channels == 0 {
896            return Err("layer-array channels must be >= 1".into());
897        }
898        if head_size == 0 {
899            return Err("layer-array head_size must be >= 1".into());
900        }
901        if kernel_sizes.contains(&0) {
902            return Err("layer-array kernel_sizes entries must be >= 1".into());
903        }
904        if self.dilations.contains(&0) {
905            return Err("layer-array dilations entries must be >= 1".into());
906        }
907        let bottleneck = self.bottleneck.unwrap_or(self.channels);
908        if bottleneck == 0 {
909            return Err("layer-array bottleneck must be >= 1".into());
910        }
911
912        let groups_input = self.groups_input.unwrap_or(1);
913        let groups_input_mixin = self.groups_input_mixin.unwrap_or(1);
914        let layer1x1 = Layer1x1Config::from_json(self.layer1x1.as_ref());
915        let head1x1 = Head1x1Config::from_json(self.head1x1.as_ref());
916        let films = [
917            FilmConfig::from_json(self.conv_pre_film.as_ref()),
918            FilmConfig::from_json(self.conv_post_film.as_ref()),
919            FilmConfig::from_json(self.input_mixin_pre_film.as_ref()),
920            FilmConfig::from_json(self.input_mixin_post_film.as_ref()),
921            FilmConfig::from_json(self.activation_pre_film.as_ref()),
922            FilmConfig::from_json(self.activation_post_film.as_ref()),
923            FilmConfig::from_json(self.layer1x1_post_film.as_ref()),
924            FilmConfig::from_json(self.head1x1_post_film.as_ref()),
925        ];
926        // Grouped-conv group counts must be >= 1: a zero divides by zero when the
927        // runtime lays out the block-diagonal weight tensor. (Divisibility of the
928        // channel dims by the group count is checked in `array_weight_count`, which
929        // knows every dim.) Reject here as a clean `Err`, not a panic — mirroring
930        // NAMCore's `% groups` precondition.
931        let group_counts = [
932            ("groups_input", groups_input),
933            ("groups_input_mixin", groups_input_mixin),
934            ("layer1x1.groups", layer1x1.groups),
935            ("head1x1.groups", head1x1.groups),
936            (
937                "film.groups",
938                films.iter().map(|f| f.groups).min().unwrap_or(1),
939            ),
940        ];
941        for (name, g) in group_counts {
942            if g == 0 {
943                return Err(format!("layer-array {name} must be >= 1"));
944            }
945        }
946        let [conv_pre_film, conv_post_film, input_mixin_pre_film, input_mixin_post_film, activation_pre_film, activation_post_film, layer1x1_post_film, head1x1_post_film] =
947            films;
948
949        Ok(LayerArrayConfig {
950            input_size: self.input_size,
951            condition_size: self.condition_size,
952            channels: self.channels,
953            bottleneck,
954            dilations: self.dilations,
955            kernel_sizes,
956            activations,
957            gating_modes,
958            secondary_activations,
959            groups_input,
960            groups_input_mixin,
961            head_size,
962            head_kernel_size,
963            head_bias,
964            layer1x1,
965            head1x1,
966            conv_pre_film,
967            conv_post_film,
968            input_mixin_pre_film,
969            input_mixin_post_film,
970            activation_pre_film,
971            activation_post_film,
972            layer1x1_post_film,
973            head1x1_post_film,
974        })
975    }
976}
977
978/// A `Named{"Sigmoid"}` activation, the gating secondary default.
979fn default_sigmoid() -> ActivationSpec {
980    ActivationSpec::Named {
981        name: "Sigmoid".into(),
982        negative_slope: None,
983    }
984}
985
986/// Broadcast a single activation or expand a per-layer list to length `n`.
987/// Expand a per-layer field to length `n`: a JSON array must already be exactly
988/// `n` long (each element parsed by `parse`); any other (scalar) value is parsed
989/// once and broadcast to all `n` layers. `kind` names the field in length errors.
990fn broadcast<T: Clone>(
991    v: &serde_json::Value,
992    n: usize,
993    kind: &str,
994    parse: impl Fn(&serde_json::Value) -> Result<T, String>,
995) -> Result<Vec<T>, String> {
996    match v {
997        serde_json::Value::Array(items) => {
998            if items.len() != n {
999                return Err(format!(
1000                    "{kind} list length {} != number of layers {n}",
1001                    items.len()
1002                ));
1003            }
1004            items.iter().map(&parse).collect()
1005        }
1006        other => Ok(vec![parse(other)?; n]),
1007    }
1008}
1009
1010fn parse_activation(e: &serde_json::Value) -> Result<ActivationSpec, String> {
1011    serde_json::from_value::<ActivationSpec>(e.clone()).map_err(|e| e.to_string())
1012}
1013
1014fn broadcast_activations(v: &serde_json::Value, n: usize) -> Result<Vec<ActivationSpec>, String> {
1015    broadcast(v, n, "activation", parse_activation)
1016}
1017
1018/// Broadcast/expand `secondary_activation`; JSON `null` elements become the
1019/// Sigmoid default (NAMCore's default secondary).
1020fn broadcast_secondary(v: &serde_json::Value, n: usize) -> Result<Vec<ActivationSpec>, String> {
1021    broadcast(v, n, "secondary_activation", |e| {
1022        if e.is_null() {
1023            Ok(default_sigmoid())
1024        } else {
1025            parse_activation(e)
1026        }
1027    })
1028}
1029
1030/// Broadcast a single gating name or expand a per-layer list to length `n`.
1031fn broadcast_gating(v: &serde_json::Value, n: usize) -> Result<Vec<GatingMode>, String> {
1032    broadcast(v, n, "gating_mode", |e| {
1033        e.as_str()
1034            .ok_or_else(|| "gating_mode entry is not a string".to_string())
1035            .and_then(GatingMode::from_name)
1036    })
1037}
1038
1039#[cfg(test)]
1040mod layer_array_normalize_tests {
1041    use super::*;
1042
1043    fn norm(v: serde_json::Value) -> LayerArrayConfig {
1044        let raw: RawLayerArrayConfig = serde_json::from_value(v).unwrap();
1045        raw.normalize().unwrap()
1046    }
1047
1048    #[test]
1049    fn a1_layer_broadcasts_scalar_kernel_and_string_activation() {
1050        let la = norm(serde_json::json!({
1051            "input_size": 1, "condition_size": 1, "channels": 2, "head_size": 1,
1052            "kernel_size": 3, "dilations": [1, 2, 4], "activation": "Tanh",
1053            "gated": false, "head_bias": false
1054        }));
1055        assert_eq!(la.channels, 2);
1056        assert_eq!(la.bottleneck, 2);
1057        assert_eq!(la.kernel_sizes, vec![3, 3, 3]);
1058        assert_eq!(la.gating_modes, vec![GatingMode::None; 3]);
1059        assert_eq!(la.head_size, 1);
1060        assert_eq!(la.head_kernel_size, 1);
1061        assert!(!la.head_bias);
1062        assert!(la.layer1x1.active);
1063        assert!(!la.head1x1.active);
1064        assert_eq!(la.groups_input, 1);
1065        assert_eq!(la.activations.len(), 3);
1066        assert!(matches!(&la.activations[0], ActivationSpec::Named { name, .. } if name == "Tanh"));
1067        let g = norm(serde_json::json!({
1068            "input_size": 1, "condition_size": 1, "channels": 2, "head_size": 1,
1069            "kernel_size": 3, "dilations": [1], "activation": "Tanh",
1070            "gated": true, "head_bias": true
1071        }));
1072        assert_eq!(g.gating_modes, vec![GatingMode::Gated]);
1073    }
1074
1075    #[test]
1076    fn a2_flexible_layer_parses_per_layer_vectors_and_nested_head() {
1077        let la = norm(serde_json::json!({
1078            "input_size": 1, "condition_size": 1, "channels": 3, "bottleneck": 3,
1079            "dilations": [1, 3, 7],
1080            "kernel_sizes": [6, 6, 15],
1081            "activation": [
1082                {"type": "LeakyReLU", "negative_slope": 0.01},
1083                {"type": "LeakyReLU", "negative_slope": 0.01},
1084                {"type": "LeakyReLU", "negative_slope": 0.01}
1085            ],
1086            "head": {"out_channels": 1, "kernel_size": 16, "bias": true},
1087            "head1x1": {"active": false, "out_channels": 1, "groups": 1},
1088            "layer1x1": {"active": true, "groups": 1},
1089            "groups_input": 1, "groups_input_mixin": 1,
1090            "gating_mode": ["none", "none", "none"],
1091            "secondary_activation": [null, null, null],
1092            "conv_pre_film": {"active": false, "shift": true, "groups": 1}
1093        }));
1094        assert_eq!(la.kernel_sizes, vec![6, 6, 15]);
1095        assert_eq!(la.gating_modes, vec![GatingMode::None; 3]);
1096        assert_eq!(la.head_size, 1);
1097        assert_eq!(la.head_kernel_size, 16);
1098        assert!(la.head_bias);
1099        assert_eq!(la.bottleneck, 3);
1100        assert_eq!(la.activations.len(), 3);
1101        assert!(!la.conv_pre_film.active);
1102    }
1103
1104    #[test]
1105    fn both_kernel_forms_is_an_error() {
1106        let raw: RawLayerArrayConfig = serde_json::from_value(serde_json::json!({
1107            "input_size": 1, "condition_size": 1, "channels": 1, "head_size": 1,
1108            "kernel_size": 3, "kernel_sizes": [3], "dilations": [1],
1109            "activation": "Tanh", "gated": false, "head_bias": false
1110        }))
1111        .unwrap();
1112        assert!(raw.normalize().is_err());
1113    }
1114
1115    #[test]
1116    fn kernel_sizes_length_mismatch_is_an_error() {
1117        let raw: RawLayerArrayConfig = serde_json::from_value(serde_json::json!({
1118            "input_size": 1, "condition_size": 1, "channels": 1, "head_size": 1,
1119            "kernel_sizes": [3, 3], "dilations": [1],
1120            "activation": "Tanh", "gated": false, "head_bias": false
1121        }))
1122        .unwrap();
1123        assert!(raw.normalize().is_err());
1124    }
1125
1126    #[test]
1127    fn activation_list_length_mismatch_is_an_error() {
1128        let raw: RawLayerArrayConfig = serde_json::from_value(serde_json::json!({
1129            "input_size": 1, "condition_size": 1, "channels": 1, "head_size": 1,
1130            "kernel_size": 3, "dilations": [1, 2],
1131            "activation": ["Tanh"], "gated": false, "head_bias": false
1132        }))
1133        .unwrap();
1134        assert!(raw.normalize().is_err());
1135    }
1136
1137    /// Build a minimal-but-valid raw layer-array, then apply `mutate` so each
1138    /// degenerate-dimension test only has to express the one field it breaks.
1139    fn raw_layer_array(mutate: impl FnOnce(&mut serde_json::Value)) -> RawLayerArrayConfig {
1140        let mut v = serde_json::json!({
1141            "input_size": 1, "condition_size": 1, "channels": 1, "head_size": 1,
1142            "kernel_size": 3, "dilations": [1],
1143            "activation": "Tanh", "gated": false, "head_bias": false
1144        });
1145        mutate(&mut v);
1146        serde_json::from_value(v).unwrap()
1147    }
1148
1149    #[test]
1150    fn baseline_raw_layer_array_normalizes() {
1151        // Guard: the helper's unmutated form must be valid, else the negative
1152        // tests below would pass for the wrong reason.
1153        assert!(raw_layer_array(|_| {}).normalize().is_ok());
1154    }
1155
1156    #[test]
1157    fn zero_channels_is_an_error() {
1158        let raw = raw_layer_array(|v| v["channels"] = serde_json::json!(0));
1159        assert!(raw.normalize().is_err());
1160    }
1161
1162    #[test]
1163    fn zero_head_size_is_an_error() {
1164        let raw = raw_layer_array(|v| v["head_size"] = serde_json::json!(0));
1165        assert!(raw.normalize().is_err());
1166    }
1167
1168    #[test]
1169    fn zero_kernel_size_is_an_error() {
1170        let raw = raw_layer_array(|v| v["kernel_size"] = serde_json::json!(0));
1171        assert!(raw.normalize().is_err());
1172    }
1173
1174    #[test]
1175    fn zero_dilation_is_an_error() {
1176        let raw = raw_layer_array(|v| v["dilations"] = serde_json::json!([0]));
1177        assert!(raw.normalize().is_err());
1178    }
1179
1180    #[test]
1181    fn zero_bottleneck_is_an_error() {
1182        // An explicit `bottleneck == 0` yields `mid == 0` and degenerate conv buffers;
1183        // reject it like the other zero dims (it is otherwise unguarded since it
1184        // defaults to `channels` when absent).
1185        let raw = raw_layer_array(|v| v["bottleneck"] = serde_json::json!(0));
1186        assert!(raw.normalize().is_err());
1187    }
1188
1189    #[test]
1190    fn zero_groups_is_an_error() {
1191        // `groups == 0` would divide-by-zero in the block-diagonal weight layout.
1192        for field in ["groups_input", "groups_input_mixin"] {
1193            let raw = raw_layer_array(|v| v[field] = serde_json::json!(0));
1194            assert!(raw.normalize().is_err(), "{field} == 0 must error");
1195        }
1196        let raw = raw_layer_array(|v| {
1197            v["layer1x1"] = serde_json::json!({ "active": true, "groups": 0 });
1198        });
1199        assert!(raw.normalize().is_err(), "layer1x1.groups == 0 must error");
1200    }
1201
1202    #[test]
1203    fn zero_head_kernel_size_is_an_error() {
1204        let raw = raw_layer_array(|v| {
1205            v.as_object_mut().unwrap().remove("head_size");
1206            v["head"] = serde_json::json!({
1207                "out_channels": 1, "kernel_size": 0, "activation": "ReLU"
1208            });
1209        });
1210        assert!(raw.normalize().is_err());
1211    }
1212}
1213
1214#[cfg(test)]
1215mod a2_subconfig_tests {
1216    use super::*;
1217
1218    #[test]
1219    fn gating_mode_from_str() {
1220        assert_eq!(GatingMode::from_name("none").unwrap(), GatingMode::None);
1221        assert_eq!(GatingMode::from_name("gated").unwrap(), GatingMode::Gated);
1222        assert_eq!(
1223            GatingMode::from_name("blended").unwrap(),
1224            GatingMode::Blended
1225        );
1226        assert!(GatingMode::from_name("wat").is_err());
1227    }
1228
1229    #[test]
1230    fn film_absent_or_false_is_inactive() {
1231        assert_eq!(FilmConfig::from_json(None), FilmConfig::INACTIVE);
1232        assert_eq!(
1233            FilmConfig::from_json(Some(&serde_json::json!(false))),
1234            FilmConfig::INACTIVE
1235        );
1236    }
1237
1238    #[test]
1239    fn film_object_defaults_active_shift_groups() {
1240        let v = serde_json::json!({});
1241        let f = FilmConfig::from_json(Some(&v));
1242        assert_eq!(
1243            f,
1244            FilmConfig {
1245                active: true,
1246                shift: true,
1247                groups: 1
1248            }
1249        );
1250        let v = serde_json::json!({"active": false, "shift": false, "groups": 2});
1251        assert_eq!(
1252            FilmConfig::from_json(Some(&v)),
1253            FilmConfig {
1254                active: false,
1255                shift: false,
1256                groups: 2
1257            }
1258        );
1259    }
1260
1261    #[test]
1262    fn layer1x1_defaults_active_true_groups_1() {
1263        assert_eq!(
1264            Layer1x1Config::from_json(None),
1265            Layer1x1Config {
1266                active: true,
1267                groups: 1
1268            }
1269        );
1270        let v = serde_json::json!({"active": true, "groups": 1});
1271        assert_eq!(
1272            Layer1x1Config::from_json(Some(&v)),
1273            Layer1x1Config {
1274                active: true,
1275                groups: 1
1276            }
1277        );
1278    }
1279
1280    #[test]
1281    fn head1x1_defaults_inactive() {
1282        let h = Head1x1Config::from_json(None);
1283        assert_eq!(
1284            h,
1285            Head1x1Config {
1286                active: false,
1287                out_channels: None,
1288                groups: 1
1289            }
1290        );
1291        let v = serde_json::json!({"active": false, "out_channels": 1, "groups": 1});
1292        assert_eq!(
1293            Head1x1Config::from_json(Some(&v)),
1294            Head1x1Config {
1295                active: false,
1296                out_channels: Some(1),
1297                groups: 1
1298            }
1299        );
1300    }
1301}
1302
1303#[cfg(test)]
1304mod wavenet_config_tests {
1305    use super::*;
1306
1307    fn parse(json: &str) -> WaveNetConfig {
1308        match NamModel::from_json_str(json).unwrap().config {
1309            ModelConfig::WaveNet(c) => c,
1310            other => panic!("expected WaveNet, got {other:?}"),
1311        }
1312    }
1313
1314    #[test]
1315    fn a1_config_parses_unchanged() {
1316        let c = parse(
1317            r#"{
1318            "version":"0.5.4","architecture":"WaveNet","config":{
1319                "layers":[{"input_size":1,"condition_size":1,"channels":2,"head_size":1,
1320                    "kernel_size":3,"dilations":[1,2],"activation":"Tanh",
1321                    "gated":false,"head_bias":false}],
1322                "head":null,"head_scale":2.0},
1323            "weights":[]}"#,
1324        );
1325        assert_eq!(c.layers.len(), 1);
1326        assert_eq!(c.head_scale, 2.0);
1327        assert!(c.post_stack_head.is_none());
1328        assert!(c.condition_dsp.is_none());
1329        assert_eq!(c.layers[0].kernel_sizes, vec![3, 3]);
1330    }
1331
1332    #[test]
1333    fn a2_flexible_container_submodel_config_parses() {
1334        let c = parse(
1335            r#"{
1336            "version":"0.7.0","architecture":"WaveNet","config":{
1337                "layers":[{"input_size":1,"condition_size":1,"channels":3,"bottleneck":3,
1338                    "dilations":[1,3,7],"kernel_sizes":[6,6,15],
1339                    "activation":[{"type":"LeakyReLU"},{"type":"LeakyReLU"},{"type":"LeakyReLU"}],
1340                    "head":{"out_channels":1,"kernel_size":16,"bias":true},
1341                    "head1x1":{"active":false},"layer1x1":{"active":true,"groups":1},
1342                    "gating_mode":["none","none","none"]}],
1343                "head":null,"head_scale":0.5},
1344            "weights":[]}"#,
1345        );
1346        assert_eq!(c.layers[0].head_kernel_size, 16);
1347        assert_eq!(c.layers[0].kernel_sizes, vec![6, 6, 15]);
1348        assert!(c.post_stack_head.is_none());
1349    }
1350
1351    #[test]
1352    fn post_stack_head_parses() {
1353        let c = parse(
1354            r#"{
1355            "version":"0.6.0","architecture":"WaveNet","config":{
1356                "layers":[{"input_size":1,"condition_size":1,"channels":2,"head_size":2,
1357                    "kernel_size":3,"dilations":[1],"activation":"Tanh",
1358                    "gated":false,"head_bias":false}],
1359                "head":{"channels":4,"out_channels":1,"kernel_sizes":[1,1],"activation":"ReLU"},
1360                "head_scale":1.0},
1361            "weights":[]}"#,
1362        );
1363        let h = c.post_stack_head.expect("post-stack head present");
1364        assert_eq!(h.channels, 4);
1365        assert_eq!(h.out_channels, 1);
1366        assert_eq!(h.kernel_sizes, vec![1, 1]);
1367    }
1368
1369    #[test]
1370    fn condition_dsp_parses_as_nested_model() {
1371        let c = parse(
1372            r#"{
1373            "version":"0.6.0","architecture":"WaveNet","config":{
1374                "layers":[{"input_size":1,"condition_size":1,"channels":2,"head_size":1,
1375                    "kernel_size":3,"dilations":[1],"activation":"Tanh",
1376                    "gated":false,"head_bias":false}],
1377                "head":null,"head_scale":1.0,
1378                "condition_dsp":{"version":"0.5.4","architecture":"WaveNet","config":{
1379                    "layers":[{"input_size":1,"condition_size":1,"channels":1,"head_size":1,
1380                        "kernel_size":1,"dilations":[1],"activation":"Tanh",
1381                        "gated":false,"head_bias":false}],
1382                    "head":null,"head_scale":1.0},"weights":[]}},
1383            "weights":[]}"#,
1384        );
1385        let dsp = c.condition_dsp.expect("condition_dsp present");
1386        assert_eq!(dsp.architecture, "WaveNet");
1387    }
1388}