diff --git a/CHANGELOG.md b/CHANGELOG.md index aae530f..8167734 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,60 @@ All notable changes to **namz** are documented here. The format follows [Semantic Versioning](https://semver.org/). The C++ reference versions independently; each language port carries its own version. +## [2.0.0] — 2026-08-07 — the `.orbitrig` rig layer + +The codec described below packs one model. This layer describes a DEVICE: `rig.json` plus the `.namz` +files it names, so a player can pick a model by a knob and apply the knobs that are not models. + +An earlier draft of it went out under **v1.1.0 / v1.1.1** without a changelog entry — those tags carry +the rig layer with a fitted 1-pole where the curve now is, and `schema` still reading 1. They are +superseded, and this entry describes the layer as it actually stands. The codec itself +(`.namz`, wire format 2) is untouched by all of it. + +### Added +- **`namz_rig.h` / `_load.h` / `_write.h`** — the manifest: a `chain` of stages, each with its `gear`, + its captured `controls` and the files that realise them. +- **`measured`** — a linear control shipped as a dB magnitude table per position, relative to a stated + `reference` the models were all captured at. The player interpolates and builds a filter; the knob it + draws is continuous. +- **`trusted`** — the band where that table was shown to BE a filter, over a stated drive range, with + the number of levels that showed it. Also as grid indices, so two readers cannot disagree about which + point an edge meant. +- **`blend`** — a dry/wet mix: the dry path's own response and level, per-position gains, a polarity + defined against the model's output, and whether those gains were measured or assumed. Costs no extra + captures, because the dry signal is the DI the model is already fed. +- **Per-value `labels`**, a per-position `level_db`, and a `default` position — three things a player + cannot derive and would otherwise invent. +- **Conformance** — `conformance/rig/` pins the selection policy on a sparse matrix; + `conformance/rig-measured/` pins the shape a client is written against. + +### Removed +- **`lp1_hz` / `gain_db` / `residual_db`** — a fitted 1-pole beside the curve, so a simple player could + skip the table. Measured on real hardware the best fit missed a Big Muff tone control by 15 to 65 dB. + Removed rather than deprecated: everything parsed, nothing complained, and the result was confidently + wrong. +- **The clock vocabulary** (`12h`, `07h`) — degrees only. A knob's position is a number, and a filename + means nothing. + +### Changed +- **`schema` 1 → 2.** None of the above is additive: a reader built for schema 1 would parse such a + manifest, recognise the keys it knows, drop the rest and be confidently wrong about every measured + knob. That is the failure the number exists to prevent, and it stayed at 1 through all of it. The + writer now spells it from the constant, so the two ends cannot drift apart again. +- **`input_db` on a file entry** — one setting playing another's model with less signal going *into* + it, which is how the bottom of a gain dial fades instead of becoming a model of hiss. +- **`trusted` ships only when levels were actually compared** — an untested claim now says so by being + absent, instead of by carrying a default that reads like a measurement. + +### Fixed +- **`NAMZ_VERSION` told the truth for the first time since 1.0.0.** The macros in `namz.h` read + `"1.0.0"` while the tags walked to v1.1.1; a header that misreports itself is worse than one that + says nothing. + +### Known consequence +A schema-1 pack that carried a `measured` control reads back with that knob silent — its old shape is +no longer parsed. No such pack was ever published, so this costs nothing today. + ## [1.0.0] — 2026-07-05 First stable release of the C++ reference. @@ -29,4 +83,5 @@ First stable release of the C++ reference. - **Lossless** to float32, **deterministic** (byte-identical across runs, platforms, and — via the conformance vectors — language ports), and **robust** (every malformed input is rejected cleanly). +[2.0.0]: https://github.com/darwinscat/namz/releases/tag/v2.0.0 [1.0.0]: https://github.com/darwinscat/namz/releases/tag/v1.0.0 diff --git a/CMakeLists.txt b/CMakeLists.txt index b0b6db2..7e35426 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ # SPDX-License-Identifier: MIT cmake_minimum_required(VERSION 3.14) -project(namz VERSION 1.0.0 LANGUAGES CXX) +project(namz VERSION 2.0.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) diff --git a/NAMZ-FORMAT.md b/NAMZ-FORMAT.md index 72b1324..65770bb 100644 --- a/NAMZ-FORMAT.md +++ b/NAMZ-FORMAT.md @@ -66,13 +66,41 @@ weight decode. Conventional fields: | `modeled_by` | `Darwin's Cat` | provenance / tooltip | | `gear_make` / `gear_model` | `Two Notes` / `ReVolt Guitar` | a caption | | `gear_type` | `preamp` | — | -| `tone_type` | `clean` / `crunch` / `hi-gain` | channel labels | +| `tone_type` | `clean` / `edge` / `crunch` / `hi-gain` | how much gain this sounds like | +| `voicing` | `od` / `dist` / `fuzz` | what KIND of dirt (absent = unstated) | | `boost` | `true` | a boost indicator | | `device` | `tube:1,pnp:1` | schematic device glyphs | -**`device` spec** — a comma-separated list of `type:count` in signal order; supports hybrids: `tube`, -`pnp`/`npn`/`bjt`/`transistor`, `fet`/`jfet`/`mosfet`, `dsp`/`chip`/`ic`, `diode`. Examples: `tube:4`, -`pnp:1`, `tube:1,pnp:1` (a tube + a transistor). +**`device` spec** — what is in the captured signal path: a comma-separated list of `type:count` in +signal order, so hybrids state both halves. Examples: `tube:4`, `bjt:4`, `tube:1,bjt:1` (a tube AND a +transistor), `ic:1,diode:2` (an op-amp that amplifies + the diodes that clip). The vocabulary is +CLOSED, one spelling each: + +| token | element | +|---|---| +| `tube` | a valve | +| `bjt` | a bipolar transistor (`pnp`/`npn` are read as this too — legacy captures) | +| `fet` | a field-effect transistor | +| `ic` | an ANALOGUE op-amp: it sits in the audio path | +| `dsp` | a digital signal path — an honest warning that this capture is of a model | +| `diode` | the clipper itself | + +`ic` and `dsp` are deliberately distinct: a Tube Screamer's op-amp and a digital multi-effect share +nothing but a DIP package, and which one a pedal has is the most useful thing this row can say. +A player renders one glyph per element; the same string appears in `rig.json` as the stage's +`circuit` (a stage's `device` is already its selectable matrix). + +**`tone_type` scale** — `clean < edge < crunch < hi-gain`, ORDERED, and it measures gain amount only. +A device-level value (in `rig.json`) describes the DEFAULT position — mid-sweep gain, the combination +a player loads — so the label matches what you hear when you pick the device; per-file values may +refine it. Treat the list as closed — players hardcode the order, so a value inserted later silently +changes every existing filter. + +**`voicing`** is the SECOND, independent axis: `od` (overdrive), `dist` (distortion), `fuzz` — what +KIND of dirt, not how much. It exists because a fuzz and a metal distortion sit at the same +`tone_type` and sound nothing alike: folding that into the gain scale would destroy the ordering the +scale exists for AND lose the distinction. Unordered, and absent is a normal answer — a clean preamp +has no voicing. Era words ("modern", "vintage") belong to neither axis and are absent from both. ### Capture-identity keys (knob/switch positions) @@ -82,17 +110,27 @@ instead of parsing filenames. All keys are flat strings (the header's contract): | field | example | meaning | |---|---|---| -| `settings.` | `settings.gain` = `12h` | THIS file's position of one control | -| `controls` | `channel:channel=green\|orange\|red; boost:boost=off\|on; gain:gain=07h\|08h\|…\|17h` | the whole device's control spec | +| `settings.` | `settings.gain` = `150` | THIS file's position of one control | +| `controls` | `channel:channel=green\|orange\|red; boost:boost=off\|on; gain:gain=0\|30\|…\|300` | the whole device's control spec | +| `sweep.` | `sweep.gain` = `300` | how far that DIAL turns, degrees (absent = not a dial) | | `rig_id` | `dc-revolt-guitar` | stable device identity — grouping survives display renames | | `slot` | `preamp` | where the device sits in a player's chain: `pedal` · `preamp` · `amp` · `poweramp` · `rig` | **`controls` spec** — `;`-separated entries of `:=\|\|…` in capture order. -Roles mirror the filename-token grammar: `channel` (colour word or `chN`), `gain` (`NNh` clock -positions), `boost` (presence token; `settings.` is `on`/`off` and the conventional -`boost` bool is stamped alongside), `topology` (`PP`/`SE`), `generic` (a free token). A family of -files sharing `rig_id` (else `gear_model`) + `controls` IS one device; each file's `settings.*` -places it in that device's matrix. +Roles: `channel`, `gain`, `boost` (`settings.` is `on`/`off`, and the conventional +`boost` bool is stamped alongside), `topology`, `generic`. A family of files sharing `rig_id` (else +`gear_model`) + `controls` IS one device; each file's `settings.*` places it in that device's matrix. + +**Knob positions are DEGREES** — a bare integer, `0`…`sweep`. Not clock hours: degrees place a knob +on a dial without a lookup table, and `sweep.` says how far that dial goes, so `150` on a +`300°` dial is unambiguously the middle. A dial's default (what a player loads, and what `tone_type` +describes) is the captured position nearest mid-sweep. + +**A filename means nothing.** It is an id the player echoes back and a caption it may show; nothing +parses it. Two sources of truth for one fact is one too many, and the copy you cannot fix later is +the one already written on someone's disk. Files with no `controls` key are not analysed for hints — +they are simply entries with no knobs. (Earlier versions of this format did parse names for +colour/`chN`, `NNh` gain, `boost` and `PP`/`SE`. That grammar is gone.) ## The `.orbitrig` pack @@ -103,8 +141,8 @@ drag-dropping the zip or the folder, or via an "Import" action. ``` ReVolt Guitar.orbitrig/ (share as ReVolt Guitar.orbitrig.zip) - rig.json the manifest — chain + controls + file index + EQ hints - ReVolt-green-07h.namz models (codec=store), one per knob/switch combination + rig.json the manifest — chain + controls + measured + file index + EQ hints + m01.namz models (codec=store), one per knob/switch combination ... ``` @@ -114,18 +152,27 @@ what it is (a player with no manifest falls back to scanning `.namz` + reading t ```json { - "format": "orbitrig", "schema": 1, - "rig_id": "dc-revolt-guitar", "name": "ReVolt Guitar Stack", "modeled_by": "Darwin's Cat", + "format": "orbitrig", "schema": 2, + "rig_id": "dc-ts9", "name": "Golden Drive", "modeled_by": "Darwin's Cat", "chain": [ - { "kind": "nam", "slot": "preamp", - "gear": { "make": "Two Notes", "model": "ReVolt Guitar", "type": "pedal" }, + { "kind": "nam", "slot": "pedal", + "gear": { "make": "Darwin's Cat", "model": "Golden Drive", "type": "pedal" }, + "tone_type": "crunch", "controls": [ - { "name": "channel", "role": "channel", "values": ["green","orange","red"] }, - { "name": "boost", "role": "boost", "values": ["off","on"] }, - { "name": "gain", "role": "gain", "values": ["07h","...","17h"] } + { "name": "gain", "role": "gain", "sweep": 300, "values": ["0","150","300"] } + ], + "measured": [ + { "name": "tone", "sweep": 300, "placement": "post", "reference": "300", + "operating_point": { "gain": "150" }, + "grid": { "f_lo": 20.0, "f_hi": 20000.0, "points": 8 }, + "trusted": { "lo_hz": 60.0, "hi_hz": 4600.0, "span_db": 24.0, "levels": 5 }, + "positions": [ + { "value": "0", "norm": 0.0, "db": [-0.2, -0.2, -0.4, -0.9, -3.2, -9.4, -16.8, -24.1] }, + { "value": "300", "norm": 1.0, "db": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] } + ] } ], "files": [ - { "file": "ReVolt-green-07h.namz", "settings": {"channel":"green","boost":"off","gain":"07h"} } + { "file": "m01.namz", "settings": { "gain": "0" } } ] } ] @@ -138,11 +185,169 @@ what it is (a player with no manifest falls back to scanning `.namz` + reading t never break old players. `slot` (`pedal`/`preamp`/`amp`/`poweramp`/`rig`) routes the stage. - **`files`** — the authoritative list for a `nam` stage: each entry is a model + its `settings`. (A manifest-less folder is read by scanning `*.namz` instead.) +- **`measured`** — knobs that are NOT model axes: see below. +- **`blend`** — dry/wet mix knobs, the third kind of control: see below. - **`eq` stage** — the tone stack is ALWAYS software; this stage is optional author guidance for the player's EQ, never captured audio. Fields (all optional): `model` (e.g. `fmv`), `defaults` (a starting value for any EQ knob), `hidden` (knobs to hide, e.g. `["hpf","lpf"]`), `tone_only` (a single simplified "TONE" knob instead of the full EQ), `show_curve` (draw the EQ response or not). +### `measured` — linear knobs the capture never turned into axes + +A tone control after the clipper is **linear**: turning it changes the frequency response, not the +distortion. Capturing it as a matrix axis would multiply the take count for nothing — a 6-position +tone knob turns 21 takes into 126. So the capture tool measures it instead: one short sweep per +position at a fixed operating point, deconvolved against a reference position. The pack ships the +recovered response, the player applies it as DSP, and the knob it draws is CONTINUOUS — the whole +point of measuring rather than capturing. + +Format invariant: **every file of the stage was captured with each measured control at its +`reference` position**, and the shipped responses are relative to it. `reference` is a fact about the +capture session, stated by the tool that recorded it — never inferred by a reader. For a device +captured before anyone thought of measuring, it is also the one fact that cannot be recovered later: +the knob has to still be where it was. The reference position is +therefore flat by construction, so a player that ignores `measured` — or a lone `.namz` pulled out +of the pack, which carries no measured block — plays the reference tone, which is exactly what those +weights encode. Nothing is double-filtered and nothing lies. + +| field | meaning | +|---|---| +| `name` / `sweep` | the control and how far it turns, degrees — **`sweep` absent or 0 means a SWITCH**: a player draws a toggle over `positions` and does NOT interpolate between them, because a switch has nothing in between. With a sweep it is a dial: draw a knob and interpolate | +| `placement` | where the filter goes. `post` — after the whole stage, including after a `blend` mix. `wet` — inside the wet path, before the mix, which is where a tone stack in a blended pedal physically sits: applying such a curve `post` would filter the clean signal the blend exists to keep clean. An unknown value: SKIP the control rather than guess | +| `reference` | the position every file was captured at (curves are relative to it). It MUST appear in `positions[]`, and its `db[]` MUST be all zeros — that is what "flat by construction" means, and a reader may assert it. A non-zero reference row is a broken pack: the curve would be applied on top of a model that already contains it | +| `default` | where a player starts the knob. Absent: start at `reference` | +| `operating_point` | the capture axes held while sweeping — provenance, and the honest limit of the measurement | +| `grid` | the log-spaced frequency grid `db` is sampled on. **Endpoints inclusive**: `f[i] = f_lo · (f_hi/f_lo)^(i / (points − 1))`, so `f[0]` is exactly `f_lo` and `f[points − 1]` exactly `f_hi`. The band-centre convention some analysers use would move the top of an 8-point 20 Hz – 20 kHz grid from 20 kHz to 13 kHz, and every reading with it | +| `trusted` | the band where the curve was shown to BE a filter, over what drive range, and how many levels showed it: `lo_hz`, `hi_hz`, `span_db`, `levels`. Outside the band, **hold the curve at the value it has at the nearest band edge** — that is the instruction, and it is one instruction, not two. Absent, or `levels` 1: nothing was tested, trust the whole grid. `hi_hz <= lo_hz` with `levels` 2 or more: tested and **failed everywhere** — do not apply this curve at all. `span_db` is the drive range actually swept (loudest weighed rung minus quietest), and `levels` is the FEWEST any single position was weighed at | +| `positions[]` | per measured position, in dial order | + +Each position carries the **measurement itself**, and nothing else: + +| field | meaning | +|---|---| +| `value` / `norm` | the position, and the same as 0…1 — rotation for a dial, evenly spaced steps for a switch, so a widget maps onto `norm` without knowing which it has. Entries are in the control's own order | +| `db[]` | the measured response relative to `reference`, dB per `grid` point. **`20·log10` of an amplitude ratio**, never a power dB — a reader using `10^(db/10)` halves every deviation and nothing complains. Length MUST equal `grid.points`; a position where it does not is unusable and must be skipped, not truncated or padded. REQUIRED | +| `level_db` | that position's broadband level over the trusted band, dB. Derivable from `db[]`, and shipped anyway so that every player agrees what "the same loudness" means instead of each choosing its own band and weighting | + +A curve, not a filter. There are no bands, no Q, no coefficients here — just a magnitude table, in +physical units, on a stated frequency grid. Baked coefficients would be wrong at every sample rate +but the one they were designed at; a table is right at all of them. The player designs whatever it +likes from it — a minimum-phase FIR folded into the cabinet convolution it is already running costs +nothing at runtime. + +An earlier draft of this format also shipped a fitted 1-pole corner (`lp1_hz`, `gain_db`) with a +`residual_db` to say how far the fit lay from the truth, so that a simple player could skip the +curve. Measuring real pedals killed it. A Big Muff tone control is a bass-cut/treble-boost blend — +about 30 dB of tilt across the band — and the best 1-pole fit missed it by 15 to 65 dB. A player +written against those numbers does not sound approximately right; it sounds like a different pedal. +The fields were removed rather than deprecated, because the failure was silent: everything parsed, +nothing complained, and the result was confidently wrong. + +**Why a band at all.** Each position is swept several times, 6 dB apart. A filter does not care how +hard it is driven, so its curve comes back the same at every level; harmonic products and hiss do not. +Measured on a Big Muff tone control, the curves agreed within 0.8 dB up to 9 kHz and then disagreed by +4-8 dB by 12 kHz — so the +16 dB of treble that control really has is worth shipping, and the +24 dB +the same measurement claimed at 16 kHz is not. The curve is shipped whole either way; the band says +where to stop believing it. Nothing is cut, because a measurement that was thrown away cannot be +reconsidered when somebody understands it better. + +**And why the band comes with a drive range.** Push the same pedal 40 dB below its capture level and it +stops distorting at all — at which point its bass really is a different filter, and no amount of +averaging will make the two agree. That is the pedal telling the truth, not the measurement failing, so +it cannot be rejected as noise. `span_db` is therefore a promise with its terms attached: this curve +held over this much drive range. Verified over 24 dB on the same Big Muff, its tone control is a filter +from 60 Hz up; measured over 42 dB, only from 700 Hz. Both numbers are correct about different +questions, and the one a player needs is the first. + +**Interpolating.** Between positions, per grid point, **in dB, against `norm`** — not against the array +index, which is the same thing only when the swept positions happen to be evenly spaced. `positions[]` +is sorted ascending by `norm`, so a reader may index and interpolate with no search and no sort. Outside +the outermost `norm`, **hold** the nearest swept curve; do not extrapolate, which on a 30 dB tilt invents +decibels. Between grid points, interpolate in **log frequency**. + +**When two fields could disagree**, `norm` wins. It is derivable from `value` and `sweep` for a dial, and +a reader that recomputes it will interpolate differently from one that reads it; the shipped number is +the authority and `value` is the caption a human reads. + +**A control's default position is `default`** when stated, and `reference` otherwise. A player must not +invent one: two readers starting the same pack at different knob positions make it two products. + +Only controls AFTER the non-linearity qualify: a knob before the clipper changes the drive into it, so its +effect is not linear and subtracting it is a lie. + +### `blend` — a dry/wet mix knob + +Neither of the other two roles fits. A blend is not a filter, so `measured` cannot describe it. And it +must not become a captured axis: the dry signal IS the DI the model is already being fed, so eleven +blend positions as an axis would be eleven copies of one model plus arithmetic the player does for free. +A blend costs **zero** extra takes. + +What the pack ships is the mix itself: + +``` +out = polarity · dry_gain(pos) · (DI * dry) + wet_gain(pos) · model(DI) +``` + +```json +"blend": [ + { "name": "blend", "sweep": 300, "reference": "300", "dry_end": "0", + "law": "linear", "polarity": -1, + "grid": { "f_lo": 20.0, "f_hi": 20000.0, "points": 4 }, + "trusted": { "lo_hz": 40.0, "hi_hz": 12000.0, "span_db": 24.0, "levels": 5 }, + "dry": [0.0, -1.0, -9.0, -24.0], "dry_level_db": -9.0, + "positions": [ + { "value": "0", "norm": 0.0, "dry_db": 0.0, "wet_db": -120.0 }, + { "value": "150", "norm": 0.5, "dry_db": -6.0, "wet_db": -6.0 }, + { "value": "300", "norm": 1.0, "dry_db": -120.0, "wet_db": 0.0 } + ] } +] +``` + +| field | meaning | +|---|---| +| `name` / `sweep` | the knob and its rotation; `sweep` absent or 0 means a switch | +| `reference` | the full-WET end — where every model of the stage was captured. Same invariant as `measured`: ignore `blend` and you play full wet, which is exactly what the weights encode | +| `dry_end` | the full-DRY end, where `dry` was measured. At that position the output is the dry path ALONE and therefore linear, which is the whole reason a blend can be characterised from outside the box | +| `dry[]` | the dry path's response SHAPE, dB per `grid` point, with its own broadband level removed. On a bass pedal this is almost never flat — keeping a clean low end out of the distortion is what the circuit is for | +| `dry_level_db` | the level that was removed: the dry path's broadband gain, dB, on the same scale as the model's own output. REQUIRED, and not derivable from anything else in the pack — a reader cannot re-measure it, because the pedal is not in their hands. Omit it and a dry path with 9 dB of insertion loss renders 9 dB too loud at every position that is not an end | +| `trusted` | as in `measured`: where `dry` was shown to be a filter, over what drive range | +| `polarity` | the sign of the dry branch **against the model's output**, `+1` or `-1`. Defined that way and not "the box inverts one path", because a neural model is trained on the waveform and therefore already contains the wet path's own inversion: stating it against the hardware would have producer and consumer applying opposite signs. Measured by deconvolving a sweep at each end and comparing the sign of the impulse response's leading edge — the same recordings the rest of the block comes from | +| `law` | how the gains were derived (`linear`, `equal_power`, …) — PROVENANCE only, never something a reader implements | +| `gains_measured` | whether `dry_db`/`wet_db` were MEASURED or derived from `law`. A derived pair assumes the pot is an amplitude-linear crossfade and a real one often is not, and a reader cannot tell from the numbers. `false` means treat them as an estimate | +| `positions[]` | `value`, `norm` (**rotation**, exactly as for `measured`, ascending), and the two gains `dry_db` / `wet_db`. `-120` means **exactly silent**, not 10⁻⁶: a literal conversion leaks both branches at the ends | + +**Interpolating the gains** between positions is done in **amplitude**, not in dB. Between `wet_db: -6` +and `wet_db: -120` a dB interpolation puts the halfway point at −63 dB — inaudible — where the amplitude +one puts it at −12; both readings are otherwise permitted by the same text, and they are 50 dB apart at +the same knob position. + +**Phase and time.** The dry branch is summed with `model(DI)` in **parallel**, so how a reader realises +`dry[]` decides the result, not just its tone. Build it **minimum phase**, and keep the two branches +sample-aligned: a linear-phase FIR long enough to shape 80 Hz carries about 10 ms of bulk delay, and a +blend with 10 ms between its branches is a flanger. For a series `measured` filter the choice is a +nuance; here it is the whole sound. + +**Order within a stage.** `measured` controls compose in any order among themselves — magnitude-only +linear filters commute. Against a `blend` they do not: apply every `placement: "wet"` filter to the +model's output first, then the mix, then every `placement: "post"` filter to the result. + +A reader that does not implement `blend` skips it and plays the reference, exactly as with `measured`. + +**Control values are JSON strings**, always — `"300"`, not `300`. A degree is written as a bare integer +inside that string, which is a different statement from being a JSON number, and a reader is entitled to +accept only strings. A producer emitting numbers gets every position silently discarded. + +**`schema` is a duty, not a decoration.** A reader MUST refuse a manifest whose `schema` is higher than +the one it implements. Additive keys never bump it, so a bump means something a reader of that vintage +would get wrong — and loading it anyway, recognising what it knows and dropping the rest, is precisely +the silent-and-confident failure the format's removal policy exists to prevent. + +**`family_id`** groups packs that are one physical box cut into several — a device captured as three +packs plus its boost is still one pedal, and a player may offer them together. It is stamped into every +`.namz` header as well as the manifest, so a file separated from its pack can still be recognised as +part of the family. Nothing in this format requires a reader to act on it; it is there so that the fact +survives, because it cannot be reconstructed later. + Additive keys never bump `schema`; a bump is reserved for an incompatible change (avoided by design). The reference reader/selector is `namz::rig`. diff --git a/conformance/rig-measured/README.md b/conformance/rig-measured/README.md new file mode 100644 index 0000000..d676961 --- /dev/null +++ b/conformance/rig-measured/README.md @@ -0,0 +1,34 @@ + +# `rig-measured` — the minimal modern device + +The second golden pack: **one gain dial + one measured knob**. It is the smallest device shape a +player must handle end to end, and it is deliberately the shape a real dirt pedal has. + +``` +Golden Drive pedal, tone_type "crunch" + gain a DIAL: sweep 300°, captured at 0 / 150 / 300 → three models + tone MEASURED: linear, never captured — six swept positions shipped as a filter +``` + +A player does two different things with those two knobs: + +- **gain** is in `controls` — turning it SELECTS a file (`m01…m03`); +- **tone** is in `measured` — turning it changes nothing about which file plays; the player builds a + filter from `positions[]` and applies it after the stage. Interpolating between positions is what + makes it a continuous knob out of six measurements. + +Everything the tone knob needs is the CURVE: `db[]`, one value per `grid` point, in physical +units on a stated frequency grid. An earlier draft of this fixture also carried a fitted 1-pole +corner (`lp1_hz`, `gain_db`) and a `residual_db` saying how far that fit lay from the truth, so +that a simple player could skip the curve. Measuring real pedals killed it: a Big Muff tone +control is a bass-cut/treble-boost blend of about 30 dB across the band, and the best 1-pole fit +missed it by 15 to 65 dB. Those fields are gone from the format and from this pack; a reader +written against them implements a dead design. + +Why this fixture exists separately from `../rig/`: that one pins the SELECTION policy on a sparse +matrix, this one pins the shape a client is actually written against. See `../rig/README.md` for the +three duties, the naming rule (`m01…m03` numbering does not follow settings — names mean nothing), +and regeneration (`NAMZ_REGEN=1`). + +The numbers here are hand-authored and plausible, not a real measurement — this is a contract +fixture, not a device model. What must be exact is their round-trip, not their physics. diff --git a/conformance/rig-measured/expected.json b/conformance/rig-measured/expected.json new file mode 100644 index 0000000..4b938e2 --- /dev/null +++ b/conformance/rig-measured/expected.json @@ -0,0 +1,66 @@ +{ + "rig": { + "rig_id": "namz-golden-drive", + "name": "Golden Drive", + "modeled_by": "namz conformance" + }, + "stage": { + "slot": "pedal", + "gear": { "make": "Darwin's Cat", "model": "Golden Drive", "type": "pedal" }, + "tone_type": "crunch", + "controls": "gain:gain=0|150|300", + "sweep": { "gain": 300 }, + "measured": [ + { + "name": "tone", + "sweep": 300, + "placement": "post", + "reference": "300", + "operating_point": { "gain": "150" }, + "grid": { "f_lo": 20.0, "f_hi": 20000.0, "points": 8 }, + "trusted": { "lo_hz": 40.0, "hi_hz": 9000.0, "lo_index": 1, "hi_index": 6, "span_db": 24.0, "levels": 8 }, + "default": "120", + "positions": [ + { "value": "0", "norm": 0.0, "level_db": -5.15, "db": [-0.2, -0.2, -0.4, -0.9, -3.2, -9.4, -16.8, -24.1] }, + { "value": "60", "norm": 0.2, "level_db": -3.9, "db": [-0.1, -0.2, -0.3, -0.6, -2.1, -6.8, -13.4, -20.2] }, + { "value": "120", "norm": 0.4, "level_db": -2.75, "db": [-0.1, -0.1, -0.2, -0.4, -1.3, -4.4, -10.1, -16.3] }, + { "value": "180", "norm": 0.6, "level_db": -1.78, "db": [0.0, -0.1, -0.1, -0.2, -0.7, -2.6, -7.0, -12.4] }, + { "value": "240", "norm": 0.8, "level_db": -0.93, "db": [0.0, 0.0, -0.1, -0.1, -0.3, -1.2, -3.9, -8.1] }, + { "value": "300", "norm": 1.0, "level_db": 0.0, "db": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] } + ] + } + ], + "blend": [ + { + "name": "blend", + "sweep": 300, + "reference": "300", + "dry_end": "0", + "default": "150", + "law": "linear", + "gains_measured": false, + "polarity": -1, + "grid": { "f_lo": 20.0, "f_hi": 20000.0, "points": 8 }, + "trusted": { "lo_hz": 40.0, "hi_hz": 9000.0, "lo_index": 1, "hi_index": 6, "span_db": 24.0, "levels": 5 }, + "dry": [2.1, 1.4, 0.3, -0.2, -0.6, -2.4, -6.1, -11.8], + "dry_level_db": -8.5, + "positions": [ + { "value": "0", "norm": 0.0, "dry_db": 0.0, "wet_db": -120.0 }, + { "value": "150", "norm": 0.5, "dry_db": -6.02, "wet_db": -6.02 }, + { "value": "300", "norm": 1.0, "dry_db": -120.0, "wet_db": 0.0 } + ] + } + ], + "files": { + "m01.namz": { "gain": "300" }, + "m02.namz": { "gain": "0" }, + "m03.namz": { "gain": "150" } + } + }, + "selection": [ + { "from": "m02.namz", "turn": ["gain", "300"], "expect": "m01.namz", + "why": "every dial position of a one-knob device is captured — the turn is always exact" }, + { "from": "m01.namz", "turn": ["gain", "150"], "expect": "m03.namz", + "why": "and back" } + ] +} diff --git a/conformance/rig-measured/pack/m01.namz b/conformance/rig-measured/pack/m01.namz new file mode 100644 index 0000000..ec71c6d Binary files /dev/null and b/conformance/rig-measured/pack/m01.namz differ diff --git a/conformance/rig-measured/pack/m02.namz b/conformance/rig-measured/pack/m02.namz new file mode 100644 index 0000000..e1e107c Binary files /dev/null and b/conformance/rig-measured/pack/m02.namz differ diff --git a/conformance/rig-measured/pack/m03.namz b/conformance/rig-measured/pack/m03.namz new file mode 100644 index 0000000..3810b8a Binary files /dev/null and b/conformance/rig-measured/pack/m03.namz differ diff --git a/conformance/rig-measured/pack/rig.json b/conformance/rig-measured/pack/rig.json new file mode 100644 index 0000000..d8e7b8b --- /dev/null +++ b/conformance/rig-measured/pack/rig.json @@ -0,0 +1,224 @@ +{ + "chain": [ + { + "blend": [ + { + "default": "150", + "dry": [ + 2.1, + 1.4, + 0.3, + -0.2, + -0.6, + -2.4, + -6.1, + -11.8 + ], + "dry_end": "0", + "dry_level_db": -8.5, + "gains_measured": false, + "grid": { + "f_hi": 20000.0, + "f_lo": 20.0, + "points": 8 + }, + "law": "linear", + "name": "blend", + "polarity": -1, + "positions": [ + { + "dry_db": 0.0, + "norm": 0.0, + "value": "0", + "wet_db": -120.0 + }, + { + "dry_db": -6.02, + "norm": 0.5, + "value": "150", + "wet_db": -6.02 + }, + { + "dry_db": -120.0, + "norm": 1.0, + "value": "300", + "wet_db": 0.0 + } + ], + "reference": "300", + "sweep": 300, + "trusted": { + "hi_hz": 9000.0, + "hi_index": 6, + "levels": 5, + "lo_hz": 40.0, + "lo_index": 1, + "span_db": 24.0 + } + } + ], + "controls": [ + { + "name": "gain", + "role": "gain", + "sweep": 300, + "values": [ + "0", + "150", + "300" + ] + } + ], + "files": [ + { + "file": "m01.namz", + "settings": { + "gain": "300" + } + }, + { + "file": "m02.namz", + "settings": { + "gain": "0" + } + }, + { + "file": "m03.namz", + "settings": { + "gain": "150" + } + } + ], + "gear": { + "make": "Darwin's Cat", + "model": "Golden Drive", + "type": "pedal" + }, + "kind": "nam", + "measured": [ + { + "default": "120", + "grid": { + "f_hi": 20000.0, + "f_lo": 20.0, + "points": 8 + }, + "name": "tone", + "operating_point": { + "gain": "150" + }, + "placement": "post", + "positions": [ + { + "db": [ + -0.2, + -0.2, + -0.4, + -0.9, + -3.2, + -9.4, + -16.8, + -24.1 + ], + "level_db": 0.0, + "norm": 0.0, + "value": "0" + }, + { + "db": [ + -0.1, + -0.2, + -0.3, + -0.6, + -2.1, + -6.8, + -13.4, + -20.2 + ], + "level_db": 0.0, + "norm": 0.2, + "value": "60" + }, + { + "db": [ + -0.1, + -0.1, + -0.2, + -0.4, + -1.3, + -4.4, + -10.1, + -16.3 + ], + "level_db": 0.0, + "norm": 0.4, + "value": "120" + }, + { + "db": [ + 0.0, + -0.1, + -0.1, + -0.2, + -0.7, + -2.6, + -7.0, + -12.4 + ], + "level_db": 0.0, + "norm": 0.6, + "value": "180" + }, + { + "db": [ + 0.0, + 0.0, + -0.1, + -0.1, + -0.3, + -1.2, + -3.9, + -8.1 + ], + "level_db": 0.0, + "norm": 0.8, + "value": "240" + }, + { + "db": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "level_db": 0.0, + "norm": 1.0, + "value": "300" + } + ], + "reference": "300", + "sweep": 300, + "trusted": { + "hi_hz": 9000.0, + "hi_index": 0, + "levels": 8, + "lo_hz": 40.0, + "lo_index": 0, + "span_db": 24.0 + } + } + ], + "slot": "pedal", + "tone_type": "crunch" + } + ], + "format": "orbitrig", + "modeled_by": "namz conformance", + "name": "Golden Drive", + "rig_id": "namz-golden-drive", + "schema": 2 +} diff --git a/conformance/rig/README.md b/conformance/rig/README.md index 14876d6..9c9306e 100644 --- a/conformance/rig/README.md +++ b/conformance/rig/README.md @@ -1,27 +1,37 @@ -# Rig conformance — the golden `.orbitrig` pack +# Rig conformance — the golden `.orbitrig` packs The `.orbitrig` contract has TWO ends — a capture tool that **writes** packs (OrbitNamCapture) and a -player that **reads** them (OrbitCab) — living in different repositories. These fixtures are the one -committed truth both ends test against, so the format can never drift silently: any change that -breaks the contract fails a CI **at PR time**, in whichever repo made it. +player that **reads** them — living in different repositories. These fixtures are the one committed +truth both ends test against, so the format can never drift silently: any change that breaks the +contract fails a CI **at PR time**, in whichever repo made it. + +Two fixtures, one reference test: ``` -pack/ the golden pack, folder shape (zip it for the exchange-shape tests) - rig.json written by the CANONICAL writer (namz::rig::writeManifest) — byte-exact truth - Golden-*.namz tiny models with stamped Capture-identity keys (namz::rig::stampMeta vocabulary) -expected.json machine-readable expectations: rig identity, the device, a selection table +rig/ a sparse channel × gain matrix — pins the SELECTION policy +rig-measured/ one gain dial + a MEASURED (linear) tone knob — the minimal modern device + pack/ the golden pack, folder shape (zip it for the exchange-shape tests) + rig.json written by the CANONICAL writer (namz::rig::writeManifest) — byte-exact truth + m01.namz tiny models with stamped Capture-identity keys (namz::rig::stampMeta vocabulary) + expected.json machine-readable expectations: rig identity, the device, a selection table ``` -The pack is deliberately SPARSE (red-12h was "never captured", 17h is declared but absent) so the -selection table pins the fallback policy, not just the happy path. +`rig/` is deliberately SPARSE (red at 150° was never captured; 300° is declared on the dial but no +file carries it) so the selection table pins the fallback policy, not just the happy path. + +The models are named `m01…m03`, and the numbering deliberately does NOT follow the settings — in +`rig/`, `m01` is the RED file and `m02` is green at 150°. **Filenames carry no information in this +format**, and a fixture is the wrong place to imply otherwise: a reader that infers settings from a +name now fails the selection table here, instead of failing in someone's session six months later. ## What each side must check **The reference implementation** (`tests/rig_conformance.cpp`, runs in this repo's CI): 1. read — `loadRig(rig.json, per-file readMeta)` reproduces `expected.json` (identity, gear, - controls spec, file index), and the manifest-less fallback (`loadRigFromFiles`) builds the SAME - device from the headers alone (spec decision A: every `.namz` is self-sufficient). + controls spec, dial sweeps, measured block, file index), and the manifest-less fallback + (`loadRigFromFiles`) builds the SAME device from the headers alone (spec decision A: every + `.namz` is self-sufficient — minus `measured`, which is device-scoped and manifest-only). 2. select — every `selection` row resolves to its `expect` (null = no file, selection unchanged). 3. write — rebuilding the model from `expected.json` and running it through `writeManifest` / `stampMeta` reproduces `rig.json` and every file's header **byte-for-byte**. @@ -31,8 +41,10 @@ file's metadata must byte-match `pack/`. If it uses `namz_rig_write.h` this hold the check catches accidental bypasses. **A player** (reader): load `pack/` (and its zipped form) — the device, controls and the selection -table must match `expected.json`. +table must match `expected.json`, and the measured knob must be applied as DSP, never offered as a +model-selection axis. -Regeneration: the `.namz` come from `namz encode conformance/vectors/flat.nam --set …` (stamps as in -`expected.json`), `rig.json` from `writeManifest` over the same model — see the reference test, -which IS the regeneration recipe. The bytes are committed; regenerating must be a no-op. +Regeneration: the reference test **is** the recipe — run it with `NAMZ_REGEN=1` and it writes every +`pack/` from `expected.json` (`namz::pack` over `vectors/flat.nam` + `stampMeta`, and `writeManifest` +for the manifest). The bytes are committed; regenerating an unchanged fixture must be a no-op, which +is exactly what duty 3 asserts. diff --git a/conformance/rig/expected.json b/conformance/rig/expected.json index 32d06d7..a586ce8 100644 --- a/conformance/rig/expected.json +++ b/conformance/rig/expected.json @@ -7,21 +7,22 @@ "stage": { "slot": "preamp", "gear": { "make": "Darwin's Cat", "model": "Golden Amp", "type": "preamp" }, - "controls": "channel:channel=green|red; gain:gain=07h|12h|17h", + "controls": "channel:channel=green|red; gain:gain=0|150|300", + "sweep": { "gain": 300 }, "files": { - "Golden-green-07h.namz": { "channel": "green", "gain": "07h" }, - "Golden-green-12h.namz": { "channel": "green", "gain": "12h" }, - "Golden-red-07h.namz": { "channel": "red", "gain": "07h" } + "m01.namz": { "channel": "red", "gain": "0" }, + "m02.namz": { "channel": "green", "gain": "150" }, + "m03.namz": { "channel": "green", "gain": "0" } } }, "selection": [ - { "from": "Golden-green-07h.namz", "turn": ["gain", "12h"], "expect": "Golden-green-12h.namz", + { "from": "m03.namz", "turn": ["gain", "150"], "expect": "m02.namz", "why": "exact captured combination" }, - { "from": "Golden-green-12h.namz", "turn": ["channel", "red"], "expect": "Golden-red-07h.namz", - "why": "red was only captured at 07h — the pinned channel wins, gain falls back" }, - { "from": "Golden-red-07h.namz", "turn": ["gain", "12h"], "expect": "Golden-green-12h.namz", - "why": "12h was only captured on green — the pinned gain wins, channel flips" }, - { "from": "Golden-green-07h.namz", "turn": ["gain", "17h"], "expect": null, - "why": "17h is declared on the dial but NO file carries it — never a contradicting file" } + { "from": "m02.namz", "turn": ["channel", "red"], "expect": "m01.namz", + "why": "red was only captured at 0° — the pinned channel wins, gain falls back" }, + { "from": "m01.namz", "turn": ["gain", "150"], "expect": "m02.namz", + "why": "150° was only captured on green — the pinned gain wins, channel flips" }, + { "from": "m03.namz", "turn": ["gain", "300"], "expect": null, + "why": "300° is declared on the dial but NO file carries it — never a contradicting file" } ] } diff --git a/conformance/rig/pack/Golden-green-07h.namz b/conformance/rig/pack/Golden-green-07h.namz deleted file mode 100644 index eb15467..0000000 Binary files a/conformance/rig/pack/Golden-green-07h.namz and /dev/null differ diff --git a/conformance/rig/pack/Golden-green-12h.namz b/conformance/rig/pack/Golden-green-12h.namz deleted file mode 100644 index 9b27567..0000000 Binary files a/conformance/rig/pack/Golden-green-12h.namz and /dev/null differ diff --git a/conformance/rig/pack/Golden-red-07h.namz b/conformance/rig/pack/Golden-red-07h.namz deleted file mode 100644 index 5eaa792..0000000 Binary files a/conformance/rig/pack/Golden-red-07h.namz and /dev/null differ diff --git a/conformance/rig/pack/m01.namz b/conformance/rig/pack/m01.namz new file mode 100644 index 0000000..686bbf2 Binary files /dev/null and b/conformance/rig/pack/m01.namz differ diff --git a/conformance/rig/pack/m02.namz b/conformance/rig/pack/m02.namz new file mode 100644 index 0000000..bc7ef93 Binary files /dev/null and b/conformance/rig/pack/m02.namz differ diff --git a/conformance/rig/pack/m03.namz b/conformance/rig/pack/m03.namz new file mode 100644 index 0000000..ee595f3 Binary files /dev/null and b/conformance/rig/pack/m03.namz differ diff --git a/conformance/rig/pack/rig.json b/conformance/rig/pack/rig.json index e57908f..2a1afec 100644 --- a/conformance/rig/pack/rig.json +++ b/conformance/rig/pack/rig.json @@ -13,33 +13,34 @@ { "name": "gain", "role": "gain", + "sweep": 300, "values": [ - "07h", - "12h", - "17h" + "0", + "150", + "300" ] } ], "files": [ { - "file": "Golden-green-07h.namz", + "file": "m01.namz", "settings": { - "channel": "green", - "gain": "07h" + "channel": "red", + "gain": "0" } }, { - "file": "Golden-green-12h.namz", + "file": "m02.namz", "settings": { "channel": "green", - "gain": "12h" + "gain": "150" } }, { - "file": "Golden-red-07h.namz", + "file": "m03.namz", "settings": { - "channel": "red", - "gain": "07h" + "channel": "green", + "gain": "0" } } ], @@ -56,5 +57,5 @@ "modeled_by": "namz conformance", "name": "Golden Amp", "rig_id": "namz-golden-rig", - "schema": 1 + "schema": 2 } diff --git a/include/namz.h b/include/namz.h index 964c771..d69608b 100644 --- a/include/namz.h +++ b/include/namz.h @@ -42,10 +42,10 @@ #ifndef NAMZ_H #define NAMZ_H -#define NAMZ_VERSION_MAJOR 1 +#define NAMZ_VERSION_MAJOR 2 #define NAMZ_VERSION_MINOR 0 #define NAMZ_VERSION_PATCH 0 -#define NAMZ_VERSION "1.0.0" +#define NAMZ_VERSION "2.0.0" #include #include diff --git a/include/namz_rig.h b/include/namz_rig.h index 32c91c0..7cd9560 100644 --- a/include/namz_rig.h +++ b/include/namz_rig.h @@ -3,8 +3,10 @@ // controls, and answer "the user turned one knob — which file plays now?". The player-side twin of // the capture-side conventions in NAMZ-FORMAT.md ("Capture-identity keys"): controls come from the // `controls` spec + per-file `settings.*` metadata (read them cheaply with namz::readMeta), with a -// FILENAME-TOKEN FALLBACK for legacy files (the OrbitCab grammar: colour/chN channel, NNh gain, -// `boost`, PP/SE topology) so packs captured before the convention keep working. +// nothing else. A filename is a caption, never evidence: it is an id the caller echoes back, and a +// file with no metadata is a device with no knobs. (Earlier versions parsed names for colour/chN, +// `NNh` gain, `boost`, PP/SE — that grammar is gone. Knob positions are DEGREES of dial rotation, +// see detail::degrees, and they live in `settings.*`.) // Copyright (c) 2026 Darwin's Cat — Oleh Tsymaienko & Alisa Lafoks . // // Header-only, C++17, std-only (no nlohmann, no namz.h dependency — metadata arrives as an @@ -14,7 +16,7 @@ // Selection policy (generalized from OrbitCab's PreampSelector): pin the control the user just // changed, keep every other control where it is; when that exact combination was never captured, // pick the candidate matching the most remaining controls, breaking ties toward each control's -// default (noon gain · boost off · first value) and then capture order. The returned file's actual +// default (mid-sweep gain · boost off · first value) and then capture order. The returned file's actual // settings are written back so the UI always shows a combination that truly exists. #ifndef NAMZ_RIG_H @@ -53,15 +55,33 @@ inline Role roleFromString (const std::string& s) return Role::Generic; } +// A control the player draws and the file index selects on. `values` are the CAPTURED positions — +// for a dial they are degrees of rotation ("0" … "300"), and `sweep` is how far that dial physically +// turns, so a player can place a knob without knowing the device. struct Control { std::string name; Role role = Role::Generic; std::vector values; // capture/dial order + // Optional display names, parallel to `values` ("Clean" over "green"). Empty, or shorter than + // `values`, means show the value itself. This is the one thing about a device a player provably + // cannot derive — the token is the pack's identity, the label is what a human calls it. + std::vector labels; + int sweep = 0; // dial rotation, degrees; 0 = not a dial (switch/selector) }; using Settings = std::map; +// The manifest schema this library speaks. A pack declaring a HIGHER one is refused rather than partly +// understood: additive keys never bump it, so a bump means something a reader of this vintage would get +// wrong. Readers must check it — the field is worthless if nobody does. +// Bumped to 2 by the release that made `measured` ship a CURVE instead of a fitted 1-pole, added the +// `blend` role, and started carrying what a reader cannot derive (dry level, trusted indices, the level +// of each position). A reader of the previous vintage would parse such a manifest, recognise the keys it +// knows, drop the rest and be confidently wrong about every measured knob — which is the exact failure +// this number exists to prevent. See NAMZ-FORMAT.md. +inline constexpr int kRigSchema = 2; + // --------------------------------------------------------------------------------------------- // The `controls` spec string (NAMZ-FORMAT conventional key): "name:role=v1|v2|…; name:role=…" // --------------------------------------------------------------------------------------------- @@ -137,59 +157,30 @@ inline std::vector parseControlsSpec (const std::string& spec) } // --------------------------------------------------------------------------------------------- -// Legacy filename tokens (the OrbitCab grammar) — the fallback for files without metadata +// Value vocabulary // --------------------------------------------------------------------------------------------- namespace detail { - inline bool isColour (const std::string& l) - { - for (const char* c : { "red", "green", "blue", "yellow", "orange", "purple", "white" }) - if (l == c) return true; - return false; - } - inline bool isChN (const std::string& l) - { - return l.size() == 3 && l.rfind ("ch", 0) == 0 && l[2] >= '1' && l[2] <= '4'; - } - inline bool isClock (const std::string& l) + // A dial position is DEGREES of rotation as a bare integer: "0", "150", "300". (The clock + // grammar this format shipped with — "07h".."17h" — is gone: degrees need no lookup table to + // place a knob, and `Control::sweep` says how far the dial goes.) + inline int degrees (const std::string& v) // "150" → 150; not a bare integer → -1 { - if (l.size() < 2 || l.size() > 3 || l.back() != 'h') return false; - for (std::size_t i = 0; i + 1 < l.size(); ++i) - if (! std::isdigit ((unsigned char) l[i])) return false; - return true; + const auto l = trim (v); + if (l.empty() || l.size() > 6) return -1; + for (char ch : l) + if (! std::isdigit ((unsigned char) ch)) return -1; + return std::stoi (l); } - inline int clockHours (const std::string& v) // "07h" → 7; non-clock → -1 - { - const auto l = lower (trim (v)); - return isClock (l) ? std::stoi (l.substr (0, l.size() - 1)) : -1; - } - inline bool isTopology (const std::string& l) { return l == "pp" || l == "se"; } inline bool isFalsy (const std::string& l) { return l == "off" || l == "no" || l == "false" || l == "0" || l.empty(); } - - // Split a basename on the token separators (space/dash/underscore). - inline std::vector tokens (const std::string& base) - { - std::vector out; - std::string cur; - for (char ch : base) - { - if (ch == ' ' || ch == '-' || ch == '_') - { - if (! cur.empty()) { out.push_back (cur); cur.clear(); } - } - else cur += ch; - } - if (! cur.empty()) out.push_back (cur); - return out; - } } // namespace detail // One file going INTO the model: a stable id (path/basename — echoed back by selection), the -// basename for the legacy fallback + display, and the metadata map (empty for legacy files). +// basename for display only, and the metadata map that says what the file actually is. struct FileMeta { std::string id; @@ -201,6 +192,12 @@ struct FileEntry { std::string id; Settings settings; + // HOW MUCH SOFTER the signal reaching this model is, in dB (0 = untouched). A device may point + // two settings at one file — the bottom notches of a gain dial, where the hardware gives hiss and + // little else, played through the neighbour above them with less going in. Into the model, not + // out of it: less signal is also less drive, so the dial fades instead of falling into a hole. + // The capture side decides this and writes it down; a player only applies it. + double inputDb = 0.0; }; struct Device @@ -219,18 +216,25 @@ struct Device } }; -// Per-control default: noon-most gain, falsy boost, first value otherwise. +// Per-control default: the gain dial's middle, falsy boost, first value otherwise. "Middle" is half +// the declared `sweep` when the dial states one, else the midpoint of the captured degrees — the +// generalization of the clock era's "noon", which only ever meant the same thing. inline std::string defaultValue (const Control& c) { if (c.values.empty()) return {}; if (c.role == Role::Gain) { + int lo = 1 << 30, hi = -1; + for (const auto& v : c.values) + if (const int d = detail::degrees (v); d >= 0) { lo = std::min (lo, d); hi = std::max (hi, d); } + if (hi < 0) return c.values.front(); // not a degree dial — capture order decides + const int mid = c.sweep > 0 ? c.sweep / 2 : (lo + hi) / 2; std::string best = c.values.front(); int bestD = 1 << 30; for (const auto& v : c.values) - if (const int h = detail::clockHours (v); h >= 0 && std::abs (h - 12) < bestD) + if (const int d = detail::degrees (v); d >= 0 && std::abs (d - mid) < bestD) { - bestD = std::abs (h - 12); + bestD = std::abs (d - mid); best = v; } return best; @@ -248,61 +252,18 @@ inline Settings defaultSettings (const Device& d) return s; } -namespace detail -{ - // Legacy: synthesize settings from filename tokens; returns the family (leftover words). - inline std::string legacyParse (const std::string& base, Settings& s) - { - std::string family; - bool boostSeen = false; - for (const auto& tok : tokens (base)) - { - const auto l = lower (tok); - if (isColour (l) || isChN (l)) s["channel"] = l; - else if (isClock (l)) s["gain"] = l; - else if (l == "boost") { s["boost"] = "on"; boostSeen = true; } - else if (isTopology (l)) s["topology"] = tok; - else family += (family.empty() ? "" : " ") + tok; - } - if (! boostSeen) s["boost"] = "off"; - return family.empty() ? base : family; - } - - // True when EVERY token of the basename is a recognized control token (no leftover name word) — - // buildDevices then groups such files together instead of making each its own device. - inline bool legacyIsAllTokens (const std::string& base) - { - const auto ts = tokens (base); - if (ts.empty()) return false; - for (const auto& tok : ts) - { - const auto l = lower (tok); - if (! (isColour (l) || isChN (l) || isClock (l) || l == "boost" || isTopology (l))) - return false; - } - return true; - } - - inline void addValue (std::vector& values, const std::string& v) - { - if (std::find (values.begin(), values.end(), v) == values.end()) values.push_back (v); - } -} // namespace detail - -// Build devices from a set of files. Grouping key: `rig_id` when stamped, else the family name -// (metadata `gear_model`, else the filename leftovers). Meta-driven files take their control spec -// verbatim (first spec seen wins); legacy files grow a channel/gain/boost/topology control per -// dimension that shows ≥2 distinct values (single-valued dimensions stay invisible — OrbitCab's -// rule), with gains sorted by the clock and boost normalized to off|on. +// Build devices from a set of files. Grouping key: `rig_id` when stamped, else `gear_model`, else +// the filename. Controls come from the file's `controls` spec VERBATIM (first spec seen wins) — a +// filename is never parsed for them. A file without that key is simply its own device with no +// knobs: no metadata, no device model. (The format shipped with a filename-token grammar — +// colour/chN/NNh/boost/PP-SE — and it is gone. Settings in filenames means two sources of truth, +// and the one you cannot fix later is the one already written on someone's disk.) inline std::vector buildDevices (const std::vector& files) { struct Acc { Device d; - bool metaDriven = false; std::string gearModel; // to re-merge files that omit rig_id - std::vector channels, gains, topos; // legacy dimension unions, insertion order - bool anyBoostOn = false, anyBoostOff = false; }; std::vector accs; @@ -341,34 +302,35 @@ inline std::vector buildDevices (const std::vector& files) const auto family = ! gm.empty() ? gm : f.filenameBase; const auto rid = metaAt ("rig_id"); auto& a = accFor (rid, gm, family); - a.metaDriven = true; if (a.d.rigId.empty() && ! rid.empty()) a.d.rigId = rid; // a later id'd file names the group if (a.gearModel.empty()) a.gearModel = gm; if (a.d.slot.empty()) a.d.slot = ! metaAt ("slot").empty() ? metaAt ("slot") : metaAt ("gear_type"); - if (a.d.controls.empty()) a.d.controls = parseControlsSpec (spec); + if (a.d.controls.empty()) + { + a.d.controls = parseControlsSpec (spec); + // …and each dial's rotation from its own flat key, so a manifest-less folder still + // knows how far the knob turns (the spec string carries values, not the arc). + for (auto& c : a.d.controls) + if (const auto it = f.meta.find ("sweep." + c.name); it != f.meta.end()) + if (const int deg = detail::degrees (it->second); deg > 0) c.sweep = deg; + } a.d.files.push_back ({ f.id, std::move (s) }); continue; } - // legacy filename fallback. When the basename is ALL tokens (no leftover name), group them - // under one key so a nameless legacy pack still forms ONE device (else every combination - // splits into its own control-less device). - Settings s; - std::string family = detail::legacyParse (f.filenameBase, s); - std::string groupFamily = family; - if (family == f.filenameBase && detail::legacyIsAllTokens (f.filenameBase)) - groupFamily = std::string(); // token-only: shared empty key - auto& a = accFor (std::string(), std::string(), groupFamily); - detail::addValue (a.channels, s.count ("channel") ? s["channel"] : std::string()); - detail::addValue (a.gains, s.count ("gain") ? s["gain"] : std::string()); - detail::addValue (a.topos, s.count ("topology") ? s["topology"] : std::string()); - (s["boost"] == "on" ? a.anyBoostOn : a.anyBoostOff) = true; - a.d.files.push_back ({ f.id, std::move (s) }); + // No `controls` key: a foreign or unstamped file. It still PLAYS — it just has no knobs and + // no family to join. Grouped by gear_model when it has one, else standing alone under its + // own name; the name is a caption, never evidence. + const auto gm = metaAt ("gear_model"); + auto& a = accFor (std::string(), gm, ! gm.empty() ? gm : f.filenameBase); + if (a.d.slot.empty()) + a.d.slot = ! metaAt ("slot").empty() ? metaAt ("slot") : metaAt ("gear_type"); + a.d.files.push_back ({ f.id, Settings {} }); } - // The settings-trim keeps files matchable against the visible controls — needed for BOTH paths - // (meta files carry settings.* for controls the group may not expose; without trimming, find() - // and defaults miss and resolve() leaks stray keys). + // The settings-trim keeps files matchable against the visible controls: a file may carry + // settings.* for controls the group does not expose, and without trimming find() and the + // defaults miss while resolve() leaks stray keys. auto trimToControls = [] (Device& d) { for (auto& fe : d.files) { @@ -382,17 +344,6 @@ inline std::vector buildDevices (const std::vector& files) std::vector out; for (auto& a : accs) { - if (! a.metaDriven) - { - auto strip = [] (std::vector& v) { v.erase (std::remove (v.begin(), v.end(), std::string()), v.end()); }; - strip (a.channels); strip (a.gains); strip (a.topos); - std::sort (a.gains.begin(), a.gains.end(), - [] (const std::string& x, const std::string& y) { return detail::clockHours (x) < detail::clockHours (y); }); - if (a.channels.size() > 1) a.d.controls.push_back ({ "channel", Role::Channel, a.channels }); - if (a.topos.size() > 1) a.d.controls.push_back ({ "topology", Role::Topology, a.topos }); - if (a.anyBoostOn && a.anyBoostOff) a.d.controls.push_back ({ "boost", Role::Boost, { "off", "on" } }); - if (a.gains.size() > 1) a.d.controls.push_back ({ "gain", Role::Gain, a.gains }); - } trimToControls (a.d); out.push_back (std::move (a.d)); } @@ -475,13 +426,187 @@ struct EqHints bool showCurve = true; // draw the EQ curve }; +// --------------------------------------------------------------------------------------------- +// MEASURED controls — a linear knob the capture tool did NOT turn into a model axis. +// +// A tone control sitting after the clipper is linear: sweeping it changes the frequency response, +// not the distortion. Capturing it as a matrix axis would multiply the take count for nothing, so +// the capture tool measures it instead (a short sweep per position, deconvolved against the +// reference position) and the pack ships the recovered response. The player applies it as DSP and +// gets a CONTINUOUS knob out of a handful of measured points. +// +// Format invariant: every file of the stage was captured with this control AT `reference`, and the +// responses are relative to it. So the reference position is flat by construction — a player that +// ignores `measured` (or a single .namz pulled out of the pack) plays the reference tone, which is +// exactly what its weights encode. No lie, no double-filtering. +// --------------------------------------------------------------------------------------------- + +struct MeasuredPosition +{ + std::string value; // the dial position, degrees ("0" … "300") + std::string label; // optional display name ("Scooped") — the producer knows it, a reader cannot + double norm = 0.0; // 0..1 across the sweep, ASCENDING across positions[] + double levelDb = 0.0; // the curve's broadband level over the trusted band, dB + std::vector db; // the measured response against the reference, dB per grid point +}; + +// The log-spaced frequency grid `MeasuredPosition::db` is sampled on. points == 0 → the control is +// unusable and a reader must skip it: the curve IS the description, there is nothing else to fall +// back to. Earlier drafts shipped a fitted 1-pole corner beside it (`lp1_hz` + `gain_db`) and a +// residual to say how far the fit lay from the truth. That was withdrawn after measuring real pedals: +// a Big Muff tone control is a bass-cut/treble-boost blend, a 30 dB tilt across the band, and the +// best 1-pole fit missed it by 15-65 dB. A number that wrong is worse than no number, because a +// player written against it produces confident nonsense. The curve costs a few hundred bytes. +struct MeasuredGrid +{ + double fLo = 20.0, fHi = 20000.0; + int points = 0; +}; + +// Where the shipped curve was shown to be a FILTER. Each position is swept at several drive levels; +// a filter comes back the same at all of them, a distortion product does not. Outside this band the +// measurement did not reproduce, so a player must hold the curve at the nearest trusted value rather +// than apply it — on a Big Muff tone control the curve is solid to 9 kHz and disagrees by 4-8 dB by +// 12 kHz, which is hiss and harmonics, not a tone stack. +// +// `levels` == 1 means the control was swept at one level only: nothing was tested, the band spans the +// whole grid, and that is an honest absence of evidence rather than a claim. +// +// `span_db` is what the band is a claim ABOUT: the drive range over which the curve held. Verified over +// 24 dB means a signal swinging 24 dB under the capture level stays inside the promise; a fuzz driven +// 40 dB down goes clean and its bass stops matching, which is the pedal being honest rather than the +// measurement failing. A reader that cares about very quiet playing should treat the band as untested +// there, not as guaranteed. +struct MeasuredTrust +{ + // loHz == hiHz == 0 with levels == 0: not stated — nothing was tested, trust the whole grid. + // hiHz <= loHz with levels >= 2: TESTED AND FAILED EVERYWHERE. The curves disagreed at every + // frequency, so the control is not a filter in any band and its curve must NOT be applied. These + // two cases used to encode identically, because the writer only emitted the block when the band + // was non-empty — so the least reproducible measurement shipped as the most trusted one. + double loHz = 0.0, hiHz = 0.0; + // …and the SAME band as grid indices. The hold rule is applied on the grid, so a player must map Hz + // to an index; two players rounding differently pick neighbouring points, and on a 30 dB tilt + // adjacent 1/12-octave points are decibels apart. The producer knows the grid exactly, so it says + // which points it meant. loIndex > hiIndex carries the same "failed everywhere" meaning as the Hz. + int loIndex = 0, hiIndex = 0; + double spanDb = 0.0; // the drive range ACTUALLY swept, dB between the loudest + // and quietest weighed rung — not a producer's constant + int levels = 0; // the FEWEST levels any position was weighed at: a claim + // is only as strong as its weakest position +}; + +struct Measured +{ + std::string name; // the knob, e.g. "tone" + int sweep = 0; // dial rotation, degrees + std::string placement = "post"; // where the filter goes; unknown value → player SKIPS it + std::string reference; // the position every file of the stage was captured at + Settings operatingPoint; // capture axes held while sweeping (provenance) + std::string defaultValue; // where a player starts the knob; the reference is usually + // an END of travel, so it is the wrong thing to reach for + MeasuredGrid grid; + MeasuredTrust trusted; + // ASCENDING by `norm`. Sorted by the producer so a player can index and lerp with no search, no + // sort and no defence: a declared order like "300,240,…,0" is a normal way to write a knob that + // reads 10 to 0, and it used to ship descending norms straight into readers that assume otherwise. + std::vector positions; +}; + +// --------------------------------------------------------------------------------------------- +// `blend` — a dry/wet mix knob, the third kind of control and neither of the other two. +// +// A blend is not a filter, so it cannot be `measured`; and it must not be a captured axis, because +// capturing it would multiply the take count to ship something the player already has for free. The dry +// signal IS the DI the model is being fed. Eleven blend positions as an axis would be eleven copies of +// one model plus arithmetic. +// +// So the pack describes the mix instead: +// +// out = polarity · dry_gain(pos) · (DI * dry) + wet_gain(pos) · model(DI) +// +// `dry` is the dry path's own response, measured — on a bass pedal it is almost never flat, because the +// whole point of the circuit is to keep a clean low end out of the distortion. It is measured with the +// knob at `dry_end`, where the output is the dry path ALONE and therefore linear; that measurement is +// exact, and it is why a blend can be described from outside the box at all. +// +// `polarity` is the sign of the dry path against the wet one. It matters more than it looks: two paths +// summed out of phase gut the middle of the knob's travel, and a player that assumed + would render the +// centre position hollow. -1 says the box inverts one of them. +// +// `law` names how the two gains were derived (`linear`, `equal_power`, or anything else) — provenance +// only, never something a reader must implement, because the gains themselves are shipped per position. +// A pot with a peculiar taper is expressible by simply carrying its numbers. +// +// Invariant, the same one `measured` has: every model of the stage was captured with the blend at +// `reference` — the full-WET end. So a player that ignores `blend`, or a lone .namz pulled out of the +// pack, plays full wet, which is exactly what those weights encode. Nothing is double-mixed. +struct BlendPosition +{ + std::string value; // the knob position + std::string label; // optional display name + // 0..1 across the sweep — ROTATION, exactly as in MeasuredPosition, ascending across positions[]. + // It used to be the mix fraction here and rotation there: one field name, one pack, two meanings. + // Which end is wet is said by `reference`, which end is dry by `dry_end`; norm says where the knob + // points, and nothing else. + double norm = 0.0; + double dryDb = 0.0; // the dry path's gain here, dB (-120 = silent) + double wetDb = 0.0; // the model's gain here, dB +}; + +struct Blend +{ + std::string name; // the knob, e.g. "blend" + int sweep = 0; // dial rotation, degrees; 0 = a switch + std::string reference; // where the models were captured: the full-wet end + std::string dryEnd; // where the dry curve was measured: the full-dry end + std::string law = "linear"; // how the gains below were derived (provenance) + // Whether those gains were MEASURED or merely derived from `law`. The distinction is not academic: + // a derived pair assumes the pot is an amplitude-linear crossfade, and a real one often is not. A + // reader cannot tell the two apart from the numbers, and a producer that has not measured them must + // not be able to imply that it has. + bool gainsMeasured = false; + int polarity = 1; // +1, or -1 if the box inverts the dry path + std::string defaultValue; // where a player starts the knob + Settings operatingPoint; // capture axes held while sweeping (provenance) + MeasuredGrid grid; + MeasuredTrust trusted; // where `dry` is a filter — same rules as `measured` + // The dry path's response: SHAPE in `dryDb` (its own broadband level removed) and that level in + // `dryLevelDb`. Both are needed and neither can be recovered from the other. Without the level a + // player cannot know how loud the dry path is against the model, and no reader can re-measure it — + // the pedal is not in their hands. An earlier draft shipped the shape alone; a dry path with 9 dB + // of insertion loss then rendered 9 dB too hot at every intermediate position. + std::vector dryDb; // dB per grid point, mean-removed + double dryLevelDb = 0.0; // that removed mean, dB — the dry path's broadband gain + std::vector positions; // knob order +}; + struct Stage { StageKind kind = StageKind::Unknown; - std::string rawKind; // the original string (preserved for unknown kinds) + std::string rawKind; // the original string (preserved for unknown kinds) std::string slot; // pedal | preamp | amp | poweramp | rig std::string make, model, gearType; // gear caption + // What this device sounds like AT ITS DEFAULT POSITION (mid-sweep gain — the combination a + // player loads), on the ordered scale `clean < edge < crunch < hi-gain`. One honest label rather + // than a range: both ends of a range are eyeballed anyway, and a range invites a player to fake + // a per-position character it was never given. Per-FILE nuance stays available as the + // conventional `tone_type` header key. The scale measures gain AMOUNT only — voicing/era words + // belong to a separate dimension, never mixed in, or the ordering it exists for stops working. + std::string toneType; + // What KIND of dirt, independent of how much: `od` | `dist` | `fuzz` (empty = unstated, which is + // the honest answer for a clean preamp). A second axis rather than more `tone_type` values: a + // fuzz and a metal distortion sit at the same gain amount and sound nothing alike, so folding + // them into one ordered scale would destroy the ordering AND lose the distinction. + std::string voicing; + // What is IN the captured signal path: `type:count` in signal order — "tube:4", "ic:1,diode:2", + // "tube:1,bjt:1". Documentation a player renders as a glyph row, not something the DSP reads. + // Per FILE the same string ships under the header's long-standing `device` key; here it is named + // `circuit` because a stage's `device` is already its selectable matrix. + std::string circuit; Device device; // Nam: controls + files (the selectable matrix) + std::vector measured; // Nam: linear knobs shipped as DSP, not as axes + std::vector blend; // Nam: dry/wet mix knobs — the player mixes, see above std::vector irFiles; // Ir: cabinet impulse file names EqHints eq; // Eq: the guidance above }; @@ -489,6 +614,11 @@ struct Stage struct Rig { std::string rigId, name, modeledBy; + // The physical box several packs came from. A three-channel head ships as three packs — each its + // own device, with its own rig_id — and this is the only thing that says they are siblings. It + // GROUPS in a list and does nothing else: sharing a rig_id would merge them back into one device + // with one matrix, which is exactly the pretence splitting them was meant to end. + std::string familyId; std::vector chain; // signal order // First stage the player can actually run (kind != Unknown), or nullptr. diff --git a/include/namz_rig_load.h b/include/namz_rig_load.h index 4792223..bfc2b0a 100644 --- a/include/namz_rig_load.h +++ b/include/namz_rig_load.h @@ -35,6 +35,17 @@ namespace detail if (auto it = j.find (key); it != j.end() && it->is_boolean()) return it->get(); return dflt; } + // Same discipline for numbers: a non-numeric value yields the default instead of throwing. + inline double jnum (const nlohmann::json& j, const char* key, double dflt = 0.0) + { + if (auto it = j.find (key); it != j.end() && it->is_number()) return it->get(); + return dflt; + } + inline int jint (const nlohmann::json& j, const char* key, int dflt = 0) + { + if (auto it = j.find (key); it != j.end() && it->is_number_integer()) return it->get(); + return dflt; + } } // namespace detail // Parse rig.json. `ok` (when given) reports whether the text WAS a valid orbitrig manifest (valid @@ -48,8 +59,16 @@ inline Rig loadRigManifest (const std::string& manifestText, bool* ok = nullptr) nlohmann::json j = nlohmann::json::parse (manifestText, nullptr, /*allow_exceptions=*/false); if (j.is_discarded() || ! j.is_object()) return rig; if (detail::jstr (j, "format") != "orbitrig") return rig; + // THE VERSION GATE, which was written and never read. `schema` is bumped only for a change a reader + // cannot survive, so meeting a higher one means this reader is not equipped — and the honest answer + // is to refuse, not to load whatever it recognises and silently drop the rest. Loading a schema-2 + // pack into a schema-1 reader is exactly the "everything parsed, nothing complained, the result was + // confidently wrong" failure the format's removal policy exists to prevent, and until this was + // checked the field was decoration. + if (detail::jint (j, "schema", 1) > kRigSchema) return rig; rig.rigId = detail::jstr (j, "rig_id"); + rig.familyId = detail::jstr (j, "family_id"); rig.name = detail::jstr (j, "name"); rig.modeledBy = detail::jstr (j, "modeled_by"); @@ -70,6 +89,9 @@ inline Rig loadRigManifest (const std::string& manifestText, bool* ok = nullptr) st.model = detail::jstr (*g, "model"); st.gearType = detail::jstr (*g, "type"); } + st.toneType = detail::jstr (sj, "tone_type"); + st.voicing = detail::jstr (sj, "voicing"); + st.circuit = detail::jstr (sj, "circuit"); if (st.kind == StageKind::Nam) { @@ -92,6 +114,10 @@ inline Rig loadRigManifest (const std::string& manifestText, bool* ok = nullptr) if (vv.is_string()) ctl.values.push_back (vv.get()); else if (! vv.is_structured()) ctl.values.push_back (vv.dump()); } + if (auto l = cj.find ("labels"); l != cj.end() && l->is_array()) + for (const auto& lv : *l) + if (lv.is_string()) ctl.labels.push_back (lv.get()); + ctl.sweep = detail::jint (cj, "sweep"); // dial rotation; absent → not a dial if (! ctl.name.empty() && ! ctl.values.empty()) st.device.controls.push_back (std::move (ctl)); } if (auto f = sj.find ("files"); f != sj.end() && f->is_array()) @@ -108,8 +134,116 @@ inline Rig loadRigManifest (const std::string& manifestText, bool* ok = nullptr) if (it.value().is_string()) fe.settings[it.key()] = it.value().get(); else if (! it.value().is_structured()) fe.settings[it.key()] = it.value().dump(); } + if (auto g = fj.find ("input_db"); g != fj.end() && g->is_number()) + fe.inputDb = g->get(); if (! fe.id.empty()) st.device.files.push_back (std::move (fe)); } + + // `measured` — linear knobs shipped as DSP instead of as model axes. Deliberately NOT in + // `controls`: no file carries them in its settings, so a player that put them on the + // selection dial would build a knob resolve() can never satisfy. + if (auto m = sj.find ("measured"); m != sj.end() && m->is_array()) + for (const auto& mj : *m) + { + if (! mj.is_object()) continue; + Measured me; + me.name = detail::jstr (mj, "name"); + me.sweep = detail::jint (mj, "sweep"); + me.placement = detail::jstr (mj, "placement", "post"); + me.reference = detail::jstr (mj, "reference"); + me.defaultValue = detail::jstr (mj, "default"); + if (auto op = mj.find ("operating_point"); op != mj.end() && op->is_object()) + for (auto it = op->begin(); it != op->end(); ++it) + { + if (it.value().is_string()) me.operatingPoint[it.key()] = it.value().get(); + else if (! it.value().is_structured()) me.operatingPoint[it.key()] = it.value().dump(); + } + if (auto t = mj.find ("trusted"); t != mj.end() && t->is_object()) + { + me.trusted.loHz = detail::jnum (*t, "lo_hz"); + me.trusted.hiHz = detail::jnum (*t, "hi_hz"); + me.trusted.loIndex = detail::jint (*t, "lo_index"); + me.trusted.hiIndex = detail::jint (*t, "hi_index"); + me.trusted.spanDb = detail::jnum (*t, "span_db"); + me.trusted.levels = detail::jint (*t, "levels"); + } + if (auto g = mj.find ("grid"); g != mj.end() && g->is_object()) + { + me.grid.fLo = detail::jnum (*g, "f_lo", 20.0); + me.grid.fHi = detail::jnum (*g, "f_hi", 20000.0); + me.grid.points = detail::jint (*g, "points"); + } + if (auto ps = mj.find ("positions"); ps != mj.end() && ps->is_array()) + for (const auto& pj : *ps) + { + if (! pj.is_object()) continue; + MeasuredPosition p; + p.value = detail::jstr (pj, "value"); + p.label = detail::jstr (pj, "label"); + p.norm = detail::jnum (pj, "norm"); + p.levelDb = detail::jnum (pj, "level_db"); + if (auto db = pj.find ("db"); db != pj.end() && db->is_array()) + for (const auto& v : *db) + if (v.is_number()) p.db.push_back (v.get()); + if (! p.value.empty()) me.positions.push_back (std::move (p)); + } + if (! me.name.empty() && ! me.positions.empty()) st.measured.push_back (std::move (me)); + } + + // `blend` — a dry/wet mix knob. Same reason as `measured` for staying out of `controls`, and + // the same reference invariant: the models were captured at the full-WET end. + if (auto b = sj.find ("blend"); b != sj.end() && b->is_array()) + for (const auto& bj : *b) + { + if (! bj.is_object()) continue; + Blend bl; + bl.name = detail::jstr (bj, "name"); + bl.sweep = detail::jint (bj, "sweep"); + bl.reference = detail::jstr (bj, "reference"); + bl.dryEnd = detail::jstr (bj, "dry_end"); + bl.defaultValue = detail::jstr (bj, "default"); + bl.law = detail::jstr (bj, "law", "linear"); + bl.gainsMeasured = detail::jbool (bj, "gains_measured", false); + bl.polarity = detail::jint (bj, "polarity", 1) < 0 ? -1 : 1; + if (auto op = bj.find ("operating_point"); op != bj.end() && op->is_object()) + for (auto it = op->begin(); it != op->end(); ++it) + { + if (it.value().is_string()) bl.operatingPoint[it.key()] = it.value().get(); + else if (! it.value().is_structured()) bl.operatingPoint[it.key()] = it.value().dump(); + } + if (auto t = bj.find ("trusted"); t != bj.end() && t->is_object()) + { + bl.trusted.loHz = detail::jnum (*t, "lo_hz"); + bl.trusted.hiHz = detail::jnum (*t, "hi_hz"); + bl.trusted.loIndex = detail::jint (*t, "lo_index"); + bl.trusted.hiIndex = detail::jint (*t, "hi_index"); + bl.trusted.spanDb = detail::jnum (*t, "span_db"); + bl.trusted.levels = detail::jint (*t, "levels"); + } + if (auto g = bj.find ("grid"); g != bj.end() && g->is_object()) + { + bl.grid.fLo = detail::jnum (*g, "f_lo", 20.0); + bl.grid.fHi = detail::jnum (*g, "f_hi", 20000.0); + bl.grid.points = detail::jint (*g, "points"); + } + bl.dryLevelDb = detail::jnum (bj, "dry_level_db"); + if (auto d = bj.find ("dry"); d != bj.end() && d->is_array()) + for (const auto& v : *d) + if (v.is_number()) bl.dryDb.push_back (v.get()); + if (auto ps = bj.find ("positions"); ps != bj.end() && ps->is_array()) + for (const auto& pj : *ps) + { + if (! pj.is_object()) continue; + BlendPosition p; + p.value = detail::jstr (pj, "value"); + p.label = detail::jstr (pj, "label"); + p.norm = detail::jnum (pj, "norm"); + p.dryDb = detail::jnum (pj, "dry_db", -120.0); + p.wetDb = detail::jnum (pj, "wet_db"); + if (! p.value.empty()) bl.positions.push_back (std::move (p)); + } + if (! bl.name.empty() && ! bl.positions.empty()) st.blend.push_back (std::move (bl)); + } } else if (st.kind == StageKind::Ir) { diff --git a/include/namz_rig_write.h b/include/namz_rig_write.h index f2d9e6d..7399487 100644 --- a/include/namz_rig_write.h +++ b/include/namz_rig_write.h @@ -22,6 +22,7 @@ #include #include +#include #include namespace namz::rig @@ -57,11 +58,25 @@ inline std::map stampMeta (const Rig& rig, const Stage const auto rid = ! stage.device.rigId.empty() ? stage.device.rigId : rig.rigId; if (! rid.empty()) m["rig_id"] = rid; + // Stage-level facts every file of the stage shares. They are stamped per file so a single .namz + // pulled out of the pack still says what it is — the same reason its settings and controls are + // here. `circuit` ships under the header's long-standing `device` key, which players already read. + if (! rig.familyId.empty()) m["family_id"] = rig.familyId; + if (! stage.toneType.empty()) m["tone_type"] = stage.toneType; + if (! stage.voicing.empty()) m["voicing"] = stage.voicing; + if (! stage.circuit.empty()) m["device"] = stage.circuit; + if (const auto spec = buildControlsSpec (stage.device.controls); ! spec.empty()) m["controls"] = spec; for (const auto& [k, v] : fileSettings) m["settings." + k] = v; + // How far each DIAL turns, in degrees — its `values` are positions on that arc, so a player can + // place the knob without knowing the device. Flat per-control key like settings.*: additive, a + // reader that ignores it still selects correctly. + for (const auto& c : stage.device.controls) + if (c.sweep > 0) m["sweep." + c.name] = std::to_string (c.sweep); + for (const auto& c : stage.device.controls) if (c.role == Role::Boost) { @@ -81,8 +96,9 @@ inline std::string writeManifest (const Rig& rig, int indent = 2) { nlohmann::json j; j["format"] = "orbitrig"; - j["schema"] = 1; + j["schema"] = kRigSchema; // never a literal: the writer and the reader must bump together if (! rig.rigId.empty()) j["rig_id"] = rig.rigId; + if (! rig.familyId.empty()) j["family_id"] = rig.familyId; if (! rig.name.empty()) j["name"] = rig.name; if (! rig.modeledBy.empty()) j["modeled_by"] = rig.modeledBy; @@ -103,6 +119,9 @@ inline std::string writeManifest (const Rig& rig, int indent = 2) if (! st.gearType.empty()) g["type"] = st.gearType; sj["gear"] = std::move (g); } + if (! st.toneType.empty()) sj["tone_type"] = st.toneType; + if (! st.voicing.empty()) sj["voicing"] = st.voicing; + if (! st.circuit.empty()) sj["circuit"] = st.circuit; if (st.kind == StageKind::Nam) { @@ -112,7 +131,9 @@ inline std::string writeManifest (const Rig& rig, int indent = 2) nlohmann::json cj; cj["name"] = c.name; cj["role"] = roleToString (c.role); + if (c.sweep > 0) cj["sweep"] = c.sweep; cj["values"] = c.values; + if (! c.labels.empty()) cj["labels"] = c.labels; controls.push_back (std::move (cj)); } if (! controls.empty()) sj["controls"] = std::move (controls); @@ -125,9 +146,132 @@ inline std::string writeManifest (const Rig& rig, int indent = 2) nlohmann::json s = nlohmann::json::object(); for (const auto& [k, v] : fe.settings) s[k] = v; fj["settings"] = std::move (s); + if (fe.inputDb < -0.0005 || fe.inputDb > 0.0005) + fj["input_db"] = std::round (fe.inputDb * 100.0) / 100.0; files.push_back (std::move (fj)); } if (! files.empty()) sj["files"] = std::move (files); + + // The measured (linear) knobs — see namz_rig.h. Client-ready first: `norm` + a fit in + // PHYSICAL units (Hz / dB), which survives any sample rate, with the raw `db` curve as + // optional provenance a player may upgrade to later. + auto measured = nlohmann::json::array(); + for (const auto& me : st.measured) + { + if (me.name.empty() || me.positions.empty()) continue; + nlohmann::json mj; + mj["name"] = me.name; + if (me.sweep > 0) mj["sweep"] = me.sweep; + if (! me.placement.empty()) mj["placement"] = me.placement; + if (! me.reference.empty()) mj["reference"] = me.reference; + // The loader has always read this; the writer did not emit it, so a pack that stated + // where the knob starts lost that fact the first time anything rewrote it — silently, + // because a missing `default` legally means "start at the reference". Conformance + // missed it for one reason: no fixture set the field. One now does. + if (! me.defaultValue.empty()) mj["default"] = me.defaultValue; + if (! me.operatingPoint.empty()) + { + nlohmann::json op = nlohmann::json::object(); + for (const auto& [k, v] : me.operatingPoint) op[k] = v; + mj["operating_point"] = std::move (op); + } + // The grid and the curves travel together or not at all: a curve without the grid it + // was sampled on cannot be applied to anything. + if (me.grid.points > 0) + { + nlohmann::json g; + g["f_lo"] = me.grid.fLo; + g["f_hi"] = me.grid.fHi; + g["points"] = me.grid.points; + mj["grid"] = std::move (g); + } + // Emitted only when levels were actually COMPARED — two or more. A single sweep per + // position proves nothing about drive, and the block it used to emit then said so in + // six fields that were always the same six values (whole grid, span 0, levels 1). + // Absence is the shorter way to say "never tested"; an empty band with levels >= 2 + // still ships, because "tested and failed everywhere" is a different statement. + if (me.trusted.levels >= 2) + { + nlohmann::json t; + t["lo_hz"] = me.trusted.loHz; + t["hi_hz"] = me.trusted.hiHz; + t["lo_index"] = me.trusted.loIndex; + t["hi_index"] = me.trusted.hiIndex; + t["span_db"] = me.trusted.spanDb; + t["levels"] = me.trusted.levels; + mj["trusted"] = std::move (t); + } + auto positions = nlohmann::json::array(); + for (const auto& p : me.positions) + { + nlohmann::json pj; + pj["value"] = p.value; + if (! p.label.empty()) pj["label"] = p.label; + pj["norm"] = p.norm; + pj["level_db"] = p.levelDb; + pj["db"] = p.db; + positions.push_back (std::move (pj)); + } + mj["positions"] = std::move (positions); + measured.push_back (std::move (mj)); + } + if (! measured.empty()) sj["measured"] = std::move (measured); + + auto blends = nlohmann::json::array(); + for (const auto& bl : st.blend) + { + if (bl.name.empty()) continue; + nlohmann::json bj; + bj["name"] = bl.name; + if (bl.sweep > 0) bj["sweep"] = bl.sweep; + if (! bl.reference.empty()) bj["reference"] = bl.reference; + if (! bl.dryEnd.empty()) bj["dry_end"] = bl.dryEnd; + if (! bl.defaultValue.empty()) bj["default"] = bl.defaultValue; + if (! bl.law.empty()) bj["law"] = bl.law; + bj["gains_measured"] = bl.gainsMeasured; + bj["polarity"] = bl.polarity; + if (! bl.operatingPoint.empty()) + { + nlohmann::json op = nlohmann::json::object(); + for (const auto& [k, v] : bl.operatingPoint) op[k] = v; + bj["operating_point"] = std::move (op); + } + if (bl.grid.points > 0) + { + nlohmann::json g; + g["f_lo"] = bl.grid.fLo; + g["f_hi"] = bl.grid.fHi; + g["points"] = bl.grid.points; + bj["grid"] = std::move (g); + } + if (bl.trusted.levels >= 2) // …and the same for a blend + { + nlohmann::json t; + t["lo_hz"] = bl.trusted.loHz; + t["hi_hz"] = bl.trusted.hiHz; + t["lo_index"] = bl.trusted.loIndex; + t["hi_index"] = bl.trusted.hiIndex; + t["span_db"] = bl.trusted.spanDb; + t["levels"] = bl.trusted.levels; + bj["trusted"] = std::move (t); + } + bj["dry"] = bl.dryDb; + bj["dry_level_db"] = bl.dryLevelDb; + auto bp = nlohmann::json::array(); + for (const auto& p : bl.positions) + { + nlohmann::json pj; + pj["value"] = p.value; + if (! p.label.empty()) pj["label"] = p.label; + pj["norm"] = p.norm; + pj["dry_db"] = p.dryDb; + pj["wet_db"] = p.wetDb; + bp.push_back (std::move (pj)); + } + bj["positions"] = std::move (bp); + blends.push_back (std::move (bj)); + } + if (! blends.empty()) sj["blend"] = std::move (blends); } else if (st.kind == StageKind::Ir) { diff --git a/tests/rig.cpp b/tests/rig.cpp index 881de93..bc01aaf 100644 --- a/tests/rig.cpp +++ b/tests/rig.cpp @@ -1,7 +1,8 @@ // SPDX-License-Identifier: MIT -// namz_rig self-tests: controls-spec roundtrip, meta-driven + legacy-filename device building, -// defaults (noon gain / boost off), and the selection policy (pin the turned control, keep the -// rest, fall back toward defaults; settings snap to the chosen file's real combination). +// namz_rig self-tests: controls-spec roundtrip, meta-driven + filename-token device building, +// defaults (mid-sweep gain / boost off), the selection policy (pin the turned control, keep the +// rest, fall back toward defaults; settings snap to the chosen file's real combination), and the +// measured (linear-knob) block. Knob positions are DEGREES throughout — the clock grammar is gone. #include "../include/namz_rig.h" #include "../include/namz_rig_load.h" @@ -24,11 +25,11 @@ int main() // --- controls spec roundtrip --------------------------------------------------------------- { - const std::string spec = "channel:channel=green|orange|red; boost:boost=off|on; gain:gain=07h|08h|09h"; + const std::string spec = "channel:channel=green|orange|red; boost:boost=off|on; gain:gain=0|30|60"; const auto controls = parseControlsSpec (spec); ok (controls.size() == 3, "3 controls parsed"); ok (controls[0].role == Role::Channel && controls[2].role == Role::Gain, "roles parsed"); - ok (controls[2].values.size() == 3 && controls[2].values[0] == "07h", "values in order"); + ok (controls[2].values.size() == 3 && controls[2].values[0] == "0", "values in order"); ok (buildControlsSpec (controls) == spec, "build(parse(spec)) == spec"); ok (parseControlsSpec (" ultralo : generic = -|off|+ ;; ").size() == 1, "tolerant of spacing/empties"); ok (parseControlsSpec ("garbage").empty(), "junk rejected quietly"); @@ -36,7 +37,7 @@ int main() // --- meta-driven device -------------------------------------------------------------------- { - const std::string spec = "channel:channel=green|red; boost:boost=off|on; gain:gain=07h|12h|17h"; + const std::string spec = "channel:channel=green|red; boost:boost=off|on; gain:gain=0|150|300"; auto mk = [&spec] (std::string id, std::string ch, std::string b, std::string g) { FileMeta f; f.id = id; f.filenameBase = id; @@ -45,8 +46,8 @@ int main() { "settings.channel", ch }, { "settings.boost", b }, { "settings.gain", g } }; return f; }; - const auto devs = buildDevices ({ mk ("a", "green", "off", "07h"), mk ("b", "green", "off", "12h"), - mk ("c", "red", "on", "17h") }); + const auto devs = buildDevices ({ mk ("a", "green", "off", "0"), mk ("b", "green", "off", "150"), + mk ("c", "red", "on", "300") }); ok (devs.size() == 1, "one device grouped by rig_id"); const auto& d = devs.front(); ok (d.family == "ReVolt Guitar" && d.rigId == "dc-revolt" && d.slot == "pedal", @@ -54,61 +55,61 @@ int main() ok (d.controls.size() == 3 && d.files.size() == 3, "controls + files"); auto s = defaultSettings (d); - ok (s["gain"] == "12h" && s["boost"] == "off" && s["channel"] == "green", - "defaults: noon gain, boost off, first channel"); + ok (s["gain"] == "150" && s["boost"] == "off" && s["channel"] == "green", + "defaults: mid-sweep gain, boost off, first channel"); ok (d.find (s) != nullptr && d.find (s)->id == "b", "default combo resolves to file b"); - // Turn gain to 17h from defaults: exact (green,off,17h) was never captured; the policy pins - // gain and picks the only 17h file (red/on) — settings snap to its real combo. - const auto* f = resolve (d, s, "gain", "17h"); - ok (f != nullptr && f->id == "c", "gain=17h resolves to the captured 17h take"); + // Turn gain to 300° from defaults: exact (green,off,300) was never captured; the policy pins + // gain and picks the only 300° file (red/on) — settings snap to its real combo. + const auto* f = resolve (d, s, "gain", "300"); + ok (f != nullptr && f->id == "c", "gain=300 resolves to the captured 300° take"); ok (s.at ("channel") == "red" && s.at ("boost") == "on", "settings snapped to the file's combo"); - // Back to 07h keeps us on green (exact match exists). - const auto* f2 = resolve (d, s, "gain", "07h"); - ok (f2 != nullptr && f2->id == "a", "gain=07h exact match wins"); + // Back to 0° keeps us on green (exact match exists). + const auto* f2 = resolve (d, s, "gain", "0"); + ok (f2 != nullptr && f2->id == "a", "gain=0 exact match wins"); } - // --- legacy filename fallback ---------------------------------------------------------------- + // --- a filename is a caption, never evidence ------------------------------------------------- + // FALSIFICATION of the whole former filename grammar: names that LOOK like the old token + // language ("colour", "boost", "PP", "12h", a bare number) must produce no controls whatsoever. + // A reader that still infers from names would grow a channel/boost/gain dial here and fail. { - auto legacy = [] (std::string base) { return FileMeta { base, base, {} }; }; - const auto devs = buildDevices ({ legacy ("GtrVolt-blue-07h-boost"), legacy ("GtrVolt-green-12h"), - legacy ("GtrVolt-blue-12h"), legacy ("Mark V") }); - ok (devs.size() == 2, "two families (GtrVolt + Mark V)"); - const auto* gv = &devs[devs[0].family == "GtrVolt" ? 0 : 1]; - ok (gv->family == "GtrVolt", "family from leftover tokens"); - bool haveCh = false, haveGain = false, haveBoost = false; - for (const auto& c : gv->controls) - { - if (c.role == Role::Channel) { haveCh = true; ok (c.values.size() == 2, "2 channels"); } - if (c.role == Role::Gain) { haveGain = true; ok (c.values.front() == "07h", "gains clock-sorted"); } - if (c.role == Role::Boost) haveBoost = true; - } - ok (haveCh && haveGain && haveBoost, "channel/gain/boost dimensions detected"); - - auto s = defaultSettings (*gv); - ok (s["gain"] == "12h" && s["boost"] == "off", "legacy defaults"); - const auto* f = resolve (*gv, s, "channel", "blue"); - ok (f != nullptr && f->id == "GtrVolt-blue-12h", "channel turn keeps noon gain"); - const auto* fb = resolve (*gv, s, "boost", "on"); - ok (fb != nullptr && fb->id == "GtrVolt-blue-07h-boost", "boost=on jumps to the only boosted take"); - - const auto* mk = &devs[devs[0].family == "Mark V" ? 0 : 1]; - ok (mk->controls.empty() && mk->files.size() == 1, "single plain file = zero visible controls"); + auto plain = [] (std::string base) { return FileMeta { base, base, {} }; }; + const auto devs = buildDevices ({ plain ("GtrVolt-blue-boost"), plain ("GtrVolt-green"), + plain ("GtrVolt-blue"), plain ("Mark V") }); + ok (devs.size() == 4, "no metadata = no family: every file stands alone"); + for (const auto& d : devs) + ok (d.controls.empty() && d.files.size() == 1, "a nameless file is a device with no knobs"); + + const auto num = buildDevices ({ plain ("Peavey 120"), plain ("6L6 PP 12h") }); + ok (num.size() == 2 && num[0].controls.empty() && num[1].controls.empty(), + "numbers, clock tokens and PP/SE in a name mean nothing"); + ok (num[0].family == "Peavey 120", "the name survives as the caption"); + + // …while the SAME files, once stamped, do form one device. Metadata is the only evidence. + std::vector stamped = { + { "a.namz", "a", { { "controls", "boost:boost=off|on" }, { "gear_model", "GtrVolt" }, + { "settings.boost", "off" } } }, + { "b.namz", "b", { { "controls", "boost:boost=off|on" }, { "gear_model", "GtrVolt" }, + { "settings.boost", "on" } } } }; + const auto real = buildDevices (stamped); + ok (real.size() == 1 && real[0].controls.size() == 1 && real[0].files.size() == 2, + "stamped files build the device the names could not"); } // --- resolve() contract hardening (crew) ----------------------------------------------------- { Device d; - d.controls = { { "gain", Role::Gain, {"07h","12h","17h"} } }; - d.files = { {"a", {{"gain","07h"}}}, {"b", {{"gain","12h"}}} }; // 17h NOT captured + d.controls = { { "gain", Role::Gain, {"0","150","300"}, {}, 300 } }; + d.files = { {"a", {{"gain","0"}}}, {"b", {{"gain","150"}}} }; // 300 NOT captured // CONTRACT: turning gain to an UNCAPTURED value must NOT return a file that contradicts the // pin — it returns nullptr and leaves settings untouched (turned control is law). - Settings s = { {"gain","07h"} }; - const auto* r = resolve (d, s, "gain", "17h"); + Settings s = { {"gain","0"} }; + const auto* r = resolve (d, s, "gain", "300"); ok (r == nullptr, "uncaptured turn -> nullptr (no file violates the pin)"); - ok (s.at ("gain") == "07h", "settings unchanged after a refused turn"); + ok (s.at ("gain") == "0", "settings unchanged after a refused turn"); // CONTRACT: on a score TIE, prefer the file sitting on the default. channel default = first // value "a"; both candidates match the turned control, tie on the rest -> pick the default. @@ -120,14 +121,17 @@ int main() ok (r2 != nullptr && r2->id == "y", "tie broken toward the default channel (green), not first-seen"); } - // --- legacy topology (poweramps) -------------------------------------------------------------- + // --- topology (poweramps) is a stamped control like any other -------------------------------- { - auto legacy = [] (std::string base) { return FileMeta { base, base, {} }; }; - const auto devs = buildDevices ({ legacy ("6L6 PP 12h"), legacy ("6L6 SE 12h") }); - ok (devs.size() == 1, "PP/SE group as one family"); + auto mk = [] (std::string id, std::string topo) { + return FileMeta { id, id, { { "controls", "topology:topology=PP|SE" }, + { "gear_model", "6L6" }, { "settings.topology", topo } } }; + }; + const auto devs = buildDevices ({ mk ("a", "PP"), mk ("b", "SE") }); + ok (devs.size() == 1, "one 6L6 device"); bool topo = false; for (const auto& c : devs[0].controls) topo = topo || c.role == Role::Topology; - ok (topo, "topology control detected from PP/SE tokens"); + ok (topo, "topology control comes from the stamped spec"); } // --- rig.json manifest loading (theory-first falsification of the .orbitrig contracts) ----- @@ -139,11 +143,11 @@ int main() "gear": {"make":"Two Notes","model":"ReVolt Guitar","type":"pedal"}, "controls": [ {"name":"channel","role":"channel","values":["green","red"]}, - {"name":"gain","role":"gain","values":["07h","12h","17h"]} ], + {"name":"gain","role":"gain","sweep":300,"values":["0","150","300"]} ], "files": [ - {"file":"a.namz","settings":{"channel":"green","gain":"07h"}}, - {"file":"b.namz","settings":{"channel":"green","gain":"12h"}}, - {"file":"c.namz","settings":{"channel":"red","gain":"17h"}} ] }, + {"file":"a.namz","settings":{"channel":"green","gain":"0"}}, + {"file":"b.namz","settings":{"channel":"green","gain":"150"}}, + {"file":"c.namz","settings":{"channel":"red","gain":"300"}} ] }, { "kind": "eq", "model": "fmv", "tone_only": true, "show_curve": false, "hidden": ["hpf","lpf"], "defaults": {"tight_hz":"120","bass":"0.5"} } ] })"; @@ -156,6 +160,8 @@ int main() ok (rig.chain[0].kind == StageKind::Nam && rig.chain[0].slot == "preamp", "stage 0 = nam/preamp"); ok (rig.chain[0].device.controls.size() == 2 && rig.chain[0].device.files.size() == 3, "controls + files from manifest"); + ok (rig.chain[0].device.controls[1].sweep == 300 && rig.chain[0].device.controls[0].sweep == 0, + "sweep parsed for the dial, absent for the channel switch"); ok (rig.chain[0].model == "ReVolt Guitar", "gear model parsed"); // CONTRACT: EQ is guidance only — hints round-trip. @@ -166,8 +172,8 @@ int main() // CONTRACT: the selector works on a manifest-loaded Nam stage. auto s = defaultSettings (rig.chain[0].device); - ok (s["gain"] == "12h", "noon-gain default on the manifest device"); - const auto* f = resolve (rig.chain[0].device, s, "gain", "17h"); + ok (s["gain"] == "150", "mid-sweep gain default on the manifest device"); + const auto* f = resolve (rig.chain[0].device, s, "gain", "300"); ok (f != nullptr && f->id == "c.namz", "turning gain resolves the captured file"); } @@ -176,7 +182,7 @@ int main() // file meta claims a THIRD channel the manifest never lists; manifest must dominate. std::vector metas = { { "a.namz", "a", { {"controls","channel:channel=green|red|blue"}, {"rig_id","dc-revolt"}, - {"settings.channel","green"}, {"settings.gain","07h"} } } }; + {"settings.channel","green"}, {"settings.gain","0"} } } }; const auto rig = loadRig (manifest, metas); ok (rig.chain.size() == 2 && rig.chain[0].device.controls[0].values.size() == 2, "manifest controls (2 channels) win over file meta (3 channels)"); @@ -186,8 +192,8 @@ int main() { const std::string withReverb = R"({"format":"orbitrig","chain":[ {"kind":"reverb","mix":0.3}, - {"kind":"nam","slot":"preamp","controls":[{"name":"gain","role":"gain","values":["12h"]}], - "files":[{"file":"x.namz","settings":{"gain":"12h"}}]} ]})"; + {"kind":"nam","slot":"preamp","controls":[{"name":"gain","role":"gain","values":["150"]}], + "files":[{"file":"x.namz","settings":{"gain":"150"}}]} ]})"; const auto rig = loadRigManifest (withReverb); ok (rig.chain.size() == 2, "unknown stage still occupies the chain"); ok (rig.chain[0].kind == StageKind::Unknown && rig.chain[0].rawKind == "reverb", @@ -206,10 +212,10 @@ int main() // fallback path: no manifest, standalone .namz meta rebuilds a Nam stage. std::vector metas = { - { "g.namz", "g", { {"controls","gain:gain=07h|12h"}, {"gear_model","Solo"}, - {"settings.gain","07h"} } }, - { "h.namz", "h", { {"controls","gain:gain=07h|12h"}, {"gear_model","Solo"}, - {"settings.gain","12h"} } } }; + { "g.namz", "g", { {"controls","gain:gain=0|150"}, {"gear_model","Solo"}, + {"settings.gain","0"} } }, + { "h.namz", "h", { {"controls","gain:gain=0|150"}, {"gear_model","Solo"}, + {"settings.gain","150"} } } }; const auto rig = loadRig ("", metas); ok (rig.chain.size() == 1 && rig.chain[0].kind == StageKind::Nam, "fallback wraps a Nam stage"); ok (rig.chain[0].device.files.size() == 2, "fallback carries both files"); @@ -241,8 +247,8 @@ int main() // trigger (decision A). loadRig must return the empty rig, not rebuild from file metas. { std::vector metas = { - { "z.namz", "z", { {"controls","gain:gain=07h"}, {"gear_model","Should Not Appear"}, - {"settings.gain","07h"} } } }; + { "z.namz", "z", { {"controls","gain:gain=0"}, {"gear_model","Should Not Appear"}, + {"settings.gain","0"} } } }; const auto rig = loadRig (R"({"format":"orbitrig","name":"Empty","chain":[]})", metas); ok (rig.chain.empty() && rig.name == "Empty", "valid empty manifest wins over file metas (no spurious fallback)"); @@ -266,22 +272,24 @@ int main() ok (f != nullptr && f->id == "c.namz", "numeric settings resolve (stage is selectable)"); } - // CONTRACT (Opus MED): a legacy pack whose names are ALL tokens still forms ONE device. + // CONTRACT: files with no `controls` spec but a shared gear_model still form ONE entry — the + // grouping key is metadata, so an unstamped family stays together instead of littering the list. { - auto legacy = [] (std::string base) { return FileMeta { base, base, {} }; }; - const auto devs = buildDevices ({ legacy ("blue-07h"), legacy ("red-07h"), - legacy ("blue-12h"), legacy ("red-12h") }); - ok (devs.size() == 1, "token-only legacy files coalesce into one device"); - ok (devs[0].files.size() == 4, "all four combos in the one device"); + auto bare = [] (std::string id) { + return FileMeta { id, id, { { "gear_model", "Nameless" } } }; + }; + const auto devs = buildDevices ({ bare ("a"), bare ("b") }); + ok (devs.size() == 1 && devs[0].files.size() == 2 && devs[0].controls.empty(), + "gear_model groups unstamped files; no spec still means no knobs"); } // CONTRACT (Opus MED): rig_id stamped on only SOME files of a family still groups as one. { std::vector metas = { - { "a", "a", { {"controls","gain:gain=07h|12h"}, {"gear_model","ReVolt"}, {"rig_id","R"}, - {"settings.gain","07h"} } }, - { "b", "b", { {"controls","gain:gain=07h|12h"}, {"gear_model","ReVolt"}, - {"settings.gain","12h"} } } }; // same gear_model, NO rig_id + { "a", "a", { {"controls","gain:gain=0|150"}, {"gear_model","ReVolt"}, {"rig_id","R"}, + {"settings.gain","0"} } }, + { "b", "b", { {"controls","gain:gain=0|150"}, {"gear_model","ReVolt"}, + {"settings.gain","150"} } } }; // same gear_model, NO rig_id const auto devs = buildDevices (metas); ok (devs.size() == 1 && devs[0].files.size() == 2, "partial rig_id stamping merges by gear_model (grouping survives)"); @@ -296,6 +304,76 @@ int main() ok (rig.chain[0].irFiles.size() == 2, "ir files carried"); } + // --- MEASURED controls: a linear knob shipped as DSP, never as a selection axis --------------- + { + const std::string withTone = R"({"format":"orbitrig","chain":[ + {"kind":"nam","slot":"pedal", + "controls":[{"name":"gain","role":"gain","sweep":300,"values":["0","150","300"]}], + "measured":[{"name":"tone","sweep":300,"placement":"post","reference":"300", + "operating_point":{"gain":"150"}, + "grid":{"f_lo":20,"f_hi":20000,"points":4}, + "trusted":{"lo_hz":40,"hi_hz":9000,"span_db":24,"levels":8}, + "positions":[{"value":"0","norm":0.0,"db":[0.0,-0.5,-6.0,-18.0]}, + {"value":"300","norm":1.0,"db":[0.0,0.0,0.0,0.0]}]}], + "blend":[{"name":"blend","sweep":300,"reference":"300","dry_end":"0", + "law":"linear","polarity":-1, + "grid":{"f_lo":20,"f_hi":20000,"points":4}, + "dry":[0.0,-1.0,-9.0,-24.0],"dry_level_db":-9.0, + "positions":[{"value":"0","norm":0.0,"dry_db":0.0,"wet_db":-120.0}, + {"value":"150","norm":0.5,"dry_db":-6.0,"wet_db":-6.0}, + {"value":"300","norm":1.0,"dry_db":-120.0,"wet_db":0.0}]}], + "files":[{"file":"a.namz","settings":{"gain":"0"}}]}]})"; + const auto rig = loadRigManifest (withTone); + ok (rig.chain.size() == 1 && rig.chain[0].measured.size() == 1, "measured block parsed"); + const auto& me = rig.chain[0].measured.front(); + ok (me.name == "tone" && me.sweep == 300 && me.placement == "post" && me.reference == "300", + "measured identity: name / sweep / placement / reference"); + ok (me.operatingPoint.at ("gain") == "150", "the operating point held while sweeping is carried"); + ok (me.grid.points == 4 && me.grid.fLo == 20.0 && me.grid.fHi == 20000.0, "curve grid parsed"); + ok (me.trusted.loHz == 40.0 && me.trusted.hiHz == 9000.0 && me.trusted.spanDb == 24.0 + && me.trusted.levels == 8, + "the band the curve was shown to be a filter in, and how many levels showed it"); + ok (me.positions.size() == 2 && me.positions[0].value == "0" && me.positions[0].norm == 0.0, + "positions parse in the control's own order"); + ok (me.positions[0].db.size() == 4 && me.positions[0].db[3] == -18.0, + "the measured curve is carried — and it is the ONLY description of the control"); + + // A blend is the THIRD kind of control: not a filter (so not `measured`) and not an axis (so not + // in `controls`). What a reader needs is the dry path's response and the two gains per position. + ok (rig.chain[0].blend.size() == 1, "the blend block parsed"); + if (rig.chain[0].blend.size() == 1) + { + const auto& bl = rig.chain[0].blend.front(); + ok (bl.name == "blend" && bl.sweep == 300 && bl.reference == "300" && bl.dryEnd == "0", + "identity: the models were captured at the WET end, the curve measured at the DRY one"); + ok (bl.polarity == -1, "a box that inverts its dry path says so — the centre of the knob " + "depends on it"); + ok (bl.law == "linear", "the law rides along as provenance, never as something to implement"); + ok (bl.dryDb.size() == 4 && bl.dryDb[3] == -24.0, + "the dry path's own response is carried: on a bass pedal it is never flat"); + ok (bl.dryLevelDb == -9.0, + "…and its broadband LEVEL, which no reader could ever re-measure"); + ok (bl.positions.size() == 3 && bl.positions[1].dryDb == -6.0 && bl.positions[1].wetDb == -6.0, + "…and the two gains are shipped per position, so a peculiar pot taper is expressible"); + ok (bl.positions[0].wetDb == -120.0 && bl.positions[2].dryDb == -120.0, + "the ends are silent on one side each"); + } + + // FALSIFICATION: the measured knob must NOT become a selection control. No file carries it in + // `settings`, so a dial built from it would pin a value resolve() can never satisfy — the + // player would show a knob that refuses every turn. + ok (rig.chain[0].device.controls.size() == 1 + && rig.chain[0].device.controls[0].name == "gain", + "measured is NOT a selection control (the axes stay what the files carry)"); + + // Junk entries are dropped, not half-loaded: no name / no positions = nothing to apply. + const auto junk = loadRigManifest (R"({"format":"orbitrig","chain":[{"kind":"nam", + "measured":[{"sweep":300},{"name":"tone"},{"name":"ok","positions":[{"value":"0"}]}]}]})"); + ok (junk.chain.size() == 1 && junk.chain[0].measured.size() == 1 + && junk.chain[0].measured[0].name == "ok", "nameless / empty measured entries dropped"); + ok (junk.chain[0].measured[0].placement == "post", "placement defaults to post"); + } + // --- WRITER (namz_rig_write.h): load(write(rig)) == rig for every carried field ------------- { Rig rig; @@ -305,9 +383,28 @@ int main() nam.kind = StageKind::Nam; nam.rawKind = "nam"; nam.slot = "preamp"; nam.make = "Two Notes"; nam.model = "ReVolt Guitar"; nam.gearType = "pedal"; nam.device.rigId = rig.rigId; nam.device.slot = nam.slot; nam.device.family = nam.model; - nam.device.controls = parseControlsSpec ("channel:channel=green|red; boost:boost=off|on; gain:gain=07h|12h"); - nam.device.files = { { "ReVolt-green-07h.namz", { { "channel", "green" }, { "boost", "off" }, { "gain", "07h" } } }, - { "ReVolt-red-12h.namz", { { "channel", "red" }, { "boost", "off" }, { "gain", "12h" } } } }; + nam.device.controls = parseControlsSpec ("channel:channel=green|red; boost:boost=off|on; gain:gain=0|150"); + nam.device.controls.back().sweep = 300; // the gain dial's rotation + nam.device.files = { { "ReVolt-green-0.namz", { { "channel", "green" }, { "boost", "off" }, { "gain", "0" } } }, + { "ReVolt-red-150.namz", { { "channel", "red" }, { "boost", "off" }, { "gain", "150" } } } }; + + Measured tone; // a linear knob: DSP, not an axis + tone.name = "tone"; tone.sweep = 300; tone.reference = "300"; + tone.operatingPoint = { { "gain", "150" } }; + tone.grid.points = 3; + tone.trusted = { 40.0, 9000.0, 0, 0, 24.0, 8 }; + tone.positions = { { "0", {}, 0.0, 0.0, { 0.0, -6.0, -18.0 } }, + { "300", {}, 1.0, 0.0, { 0.0, 0.0, 0.0 } } }; + nam.measured = { tone }; + + Blend mix; // the third kind of control + mix.name = "blend"; mix.sweep = 300; mix.reference = "300"; mix.dryEnd = "0"; + mix.law = "equal_power"; mix.polarity = -1; + mix.grid.points = 3; + mix.dryDb = { 0.0, -2.0, -12.0 }; + mix.dryLevelDb = -4.5; + mix.positions = { { "0", {}, 0.0, 0.0, -120.0 }, { "300", {}, 1.0, -120.0, 0.0 } }; + nam.blend = { mix }; Stage eq; eq.kind = StageKind::Eq; eq.rawKind = "eq"; @@ -336,8 +433,24 @@ int main() && n.model == "ReVolt Guitar" && n.gearType == "pedal", "nam stage identity round-trips"); ok (buildControlsSpec (n.device.controls) == buildControlsSpec (nam.device.controls), "controls round-trip in order"); - ok (n.device.files.size() == 2 && n.device.files[1].id == "ReVolt-red-12h.namz" + ok (n.device.files.size() == 2 && n.device.files[1].id == "ReVolt-red-150.namz" && n.device.files[1].settings == nam.device.files[1].settings, "file index round-trips"); + ok (n.device.controls.back().sweep == 300, "the dial's sweep round-trips"); + ok (n.measured.size() == 1 && n.measured[0].name == "tone" && n.measured[0].sweep == 300 + && n.measured[0].reference == "300" && n.measured[0].placement == "post" + && n.measured[0].operatingPoint.at ("gain") == "150", + "measured identity round-trips"); + ok (n.measured.size() == 1 && n.measured[0].positions.size() == 2 + && n.measured[0].positions[0].db == std::vector { 0.0, -6.0, -18.0 } + && n.measured[0].grid.points == 3 + && n.measured[0].trusted.hiHz == 9000.0 && n.measured[0].trusted.spanDb == 24.0 + && n.measured[0].trusted.levels == 8, + "the measured curve round-trips, grid and trusted band and all"); + ok (n.blend.size() == 1 && n.blend[0].polarity == -1 && n.blend[0].law == "equal_power" + && n.blend[0].dryEnd == "0" && n.blend[0].dryDb == std::vector { 0.0, -2.0, -12.0 } + && n.blend[0].dryLevelDb == -4.5 + && n.blend[0].positions.size() == 2 && n.blend[0].positions[1].wetDb == 0.0, + "the blend block round-trips: polarity, law, dry curve and both gains"); const auto& e = back.chain[1]; ok (e.kind == StageKind::Eq && e.eq.model == "fmv" && e.eq.toneOnly && ! e.eq.showCurve && e.eq.defaults.at ("tight_hz") == "120" && e.eq.hidden.size() == 2, "eq hints round-trip"); @@ -360,15 +473,17 @@ int main() ok (metas[0].meta.at ("rig_id") == "dc-revolt-guitar" && metas[0].meta.at ("slot") == "preamp" && metas[0].meta.at ("gear_make") == "Two Notes" && metas[0].meta.at ("modeled_by") == "Darwin's Cat", "stamped identity keys"); - ok (metas[0].meta.at ("settings.gain") == "07h" && metas[0].meta.at ("boost") == "false", + ok (metas[0].meta.at ("settings.gain") == "0" && metas[0].meta.at ("boost") == "false", "stamped positions + the conventional boost flag"); + ok (metas[0].meta.at ("sweep.gain") == "300" && metas[0].meta.count ("sweep.channel") == 0, + "the dial's sweep is stamped per control; a switch gets none"); const auto devs = buildDevices (metas); ok (devs.size() == 1 && devs[0].rigId == "dc-revolt-guitar" && devs[0].files.size() == 2 && devs[0].controls.size() == 3, "buildDevices reconstructs the device from stamped meta"); Settings s = devs[0].files[0].settings; - const auto* hit = resolve (devs[0], s, "gain", "12h"); - ok (hit != nullptr && hit->id == "ReVolt-red-12h.namz", "selection works over stamped meta"); + const auto* hit = resolve (devs[0], s, "gain", "150"); + ok (hit != nullptr && hit->id == "ReVolt-red-150.namz", "selection works over stamped meta"); } // WRITER: a boosted file stamps boost=true; a rig with no boost control stamps no boost key. @@ -378,8 +493,8 @@ int main() st.kind = StageKind::Nam; st.device.controls = parseControlsSpec ("boost:boost=off|on"); ok (stampMeta (rig, st, { { "boost", "on" } }).at ("boost") == "true", "truthy boost stamps true"); - st.device.controls = parseControlsSpec ("gain:gain=07h|12h"); - ok (stampMeta (rig, st, { { "gain", "07h" } }).count ("boost") == 0, "no boost control → no boost key"); + st.device.controls = parseControlsSpec ("gain:gain=0|150"); + ok (stampMeta (rig, st, { { "gain", "0" } }).count ("boost") == 0, "no boost control → no boost key"); } std::printf (failures == 0 ? "ALL RIG TESTS PASSED\n" : "%d FAILURE(S)\n", failures); diff --git a/tests/rig_conformance.cpp b/tests/rig_conformance.cpp index 727a01e..3413283 100644 --- a/tests/rig_conformance.cpp +++ b/tests/rig_conformance.cpp @@ -1,9 +1,18 @@ // SPDX-License-Identifier: MIT -// The reference consumer of the RIG conformance fixtures (conformance/rig/) — the executable form -// of the .orbitrig contract between a capture tool (writer) and a player (reader). Three duties: +// The reference consumer of the RIG conformance fixtures — the executable form of the .orbitrig +// contract between a capture tool (writer) and a player (reader). Three duties per fixture: // READ the golden pack into expected.json's device, SELECT per the expectation table (the fallback // policy, not just the happy path), and WRITE the model back byte-exact (writeManifest == rig.json, // stampMeta == each file's header). See conformance/rig/README.md for what consumers must run. +// +// This binary is ALSO the regeneration recipe: run it with NAMZ_REGEN=1 and it writes the packs from +// expected.json instead of comparing against them. Regeneration on an unchanged fixture is a no-op — +// that is exactly what the WRITE duty asserts. +// +// Fixtures: +// conformance/rig/ a sparse channel×gain matrix — pins the SELECTION policy +// conformance/rig-measured/ one gain dial + a MEASURED (linear) tone knob — the minimal shape a +// player must handle: pick a model by the dial, apply the tone as DSP // Copyright (c) 2026 Darwin's Cat — Oleh Tsymaienko & Alisa Lafoks . #define NAMZ_IMPLEMENTATION @@ -15,6 +24,7 @@ #include #include +#include #include #include #include @@ -38,11 +48,219 @@ static std::string readFile (const std::string& path) return { std::istreambuf_iterator (f), std::istreambuf_iterator() }; } -int main() +static void writeFile (const std::string& path, const void* data, std::size_t size) { - std::printf ("namz rig conformance\n"); - const std::string dir = std::string (NAMZ_CONFORMANCE_DIR) + "/rig"; + std::ofstream f (path, std::ios::binary); + f.write (static_cast (data), (std::streamsize) size); +} + +// expected.json → the model. The ONE place a fixture becomes a Rig: the WRITE duty then proves that +// writing this model reproduces the committed bytes, so the fixture and the library cannot drift. +static Rig buildModel (const json& expected) +{ + Rig rig; + rig.rigId = expected["rig"]["rig_id"].get(); + rig.name = expected["rig"]["name"].get(); + rig.modeledBy = expected["rig"]["modeled_by"].get(); + + const auto& sx = expected["stage"]; + Stage st; + st.kind = StageKind::Nam; st.rawKind = "nam"; + st.slot = sx["slot"].get(); + st.make = sx["gear"]["make"].get(); + st.model = sx["gear"]["model"].get(); + st.gearType = sx["gear"]["type"].get(); + if (auto t = sx.find ("tone_type"); t != sx.end()) st.toneType = t->get(); + st.device.rigId = rig.rigId; st.device.slot = st.slot; st.device.family = st.model; + st.device.controls = parseControlsSpec (sx["controls"].get()); + if (auto sw = sx.find ("sweep"); sw != sx.end()) + for (auto& c : st.device.controls) + if (auto it = sw->find (c.name); it != sw->end()) c.sweep = it->get(); + + if (auto ms = sx.find ("measured"); ms != sx.end()) + for (const auto& mx : *ms) + { + Measured me; + me.name = mx["name"].get(); + me.sweep = mx["sweep"].get(); + me.placement = mx["placement"].get(); + me.reference = mx["reference"].get(); + if (auto d = mx.find ("default"); d != mx.end()) me.defaultValue = d->get(); + for (const auto& [k, v] : mx["operating_point"].items()) me.operatingPoint[k] = v.get(); + me.grid.fLo = mx["grid"]["f_lo"].get(); + me.grid.fHi = mx["grid"]["f_hi"].get(); + if (auto t = mx.find ("trusted"); t != mx.end()) + { + me.trusted.loHz = (*t)["lo_hz"].get(); + me.trusted.hiHz = (*t)["hi_hz"].get(); + me.trusted.spanDb = (*t)["span_db"].get(); + me.trusted.levels = (*t)["levels"].get(); + } + me.grid.points = mx["grid"]["points"].get(); + for (const auto& px : mx["positions"]) + { + MeasuredPosition p; + p.value = px["value"].get(); + p.norm = px["norm"].get(); + p.db = px["db"].get>(); + me.positions.push_back (std::move (p)); + } + st.measured.push_back (std::move (me)); + } + + // The blend block, which had no fixture at all until now — the newest and most fragile part of the + // format, and the one where a reader's mistakes are silent: a wrong polarity or a dB-interpolated + // gain produces audio, just not the pedal's. + if (auto bs = sx.find ("blend"); bs != sx.end()) + for (const auto& bx : *bs) + { + Blend bl; + bl.name = bx["name"].get(); + bl.sweep = bx["sweep"].get(); + bl.reference = bx["reference"].get(); + bl.dryEnd = bx["dry_end"].get(); + bl.defaultValue = bx["default"].get(); + bl.law = bx["law"].get(); + bl.gainsMeasured = bx["gains_measured"].get(); + bl.polarity = bx["polarity"].get(); + bl.grid.fLo = bx["grid"]["f_lo"].get(); + bl.grid.fHi = bx["grid"]["f_hi"].get(); + bl.grid.points = bx["grid"]["points"].get(); + bl.trusted.loHz = bx["trusted"]["lo_hz"].get(); + bl.trusted.hiHz = bx["trusted"]["hi_hz"].get(); + bl.trusted.loIndex = bx["trusted"]["lo_index"].get(); + bl.trusted.hiIndex = bx["trusted"]["hi_index"].get(); + bl.trusted.spanDb = bx["trusted"]["span_db"].get(); + bl.trusted.levels = bx["trusted"]["levels"].get(); + bl.dryDb = bx["dry"].get>(); + bl.dryLevelDb = bx["dry_level_db"].get(); + for (const auto& px : bx["positions"]) + { + BlendPosition bp; + bp.value = px["value"].get(); + bp.norm = px["norm"].get(); + bp.dryDb = px["dry_db"].get(); + bp.wetDb = px["wet_db"].get(); + bl.positions.push_back (std::move (bp)); + } + st.blend.push_back (std::move (bl)); + } + + for (const auto& [name, settings] : sx["files"].items()) + { + FileEntry fe; + fe.id = name; + for (const auto& [k, v] : settings.items()) fe.settings[k] = v.get(); + st.device.files.push_back (std::move (fe)); + } + rig.chain = { std::move (st) }; + return rig; +} + +// The measured block a player reads must equal the one the fixture declares — including the curve, +// which is the only thing that lets a better filter design ship later without re-measuring. +static void checkMeasured (const Stage& st, const json& sx, const char* via) +{ + char what[160]; + auto tag = [&what, via] (const char* w) { std::snprintf (what, sizeof (what), "%s (%s)", w, via); return what; }; + const auto ms = sx.find ("measured"); + const std::size_t want = ms == sx.end() ? 0 : ms->size(); + ok (st.measured.size() == want, tag ("measured count")); + if (st.measured.size() != want) return; + for (std::size_t i = 0; i < want; ++i) + { + const auto& me = st.measured[i]; + const auto& mx = (*ms)[i]; + ok (me.name == mx["name"].get() && me.sweep == mx["sweep"].get() + && me.placement == mx["placement"].get() + && me.reference == mx["reference"].get(), tag ("measured identity")); + ok (me.grid.points == mx["grid"]["points"].get(), tag ("measured grid")); + // Where the knob starts. A round trip that drops this is not detectably wrong — the field is + // optional and its absence means "start at the reference" — so only a fixture that sets it can + // catch a writer that forgets it. One did forget. + ok (me.defaultValue == (mx.find ("default") != mx.end() ? mx["default"].get() : std::string{}), + tag ("measured default position survives the round trip")); + if (auto t = mx.find ("trusted"); t != mx.end()) + ok (me.trusted.loHz == (*t)["lo_hz"].get() + && me.trusted.hiHz == (*t)["hi_hz"].get() + && me.trusted.spanDb == (*t)["span_db"].get() + && me.trusted.levels == (*t)["levels"].get(), + tag ("the band the curve may be applied in")); + ok (me.positions.size() == mx["positions"].size(), tag ("measured position count")); + if (me.positions.size() != mx["positions"].size()) continue; + for (std::size_t p = 0; p < me.positions.size(); ++p) + { + const auto& got = me.positions[p]; + const auto& px = mx["positions"][p]; + ok (got.value == px["value"].get() && got.norm == px["norm"].get(), + tag ("measured position identity")); + ok (got.db == px["db"].get>(), tag ("measured curve")); + } + // The knob is DSP, not an axis: it must never appear among the selectable controls. + for (const auto& c : st.device.controls) + ok (c.name != me.name, tag ("measured knob is not a selection control")); + } + + if (auto bs = sx.find ("blend"); bs != sx.end()) + { + ok (st.blend.size() == bs->size(), tag ("blend block count")); + for (std::size_t i = 0; i < st.blend.size() && i < bs->size(); ++i) + { + const auto& bl = st.blend[i]; + const auto& bx = (*bs)[i]; + ok (bl.name == bx["name"].get() + && bl.reference == bx["reference"].get() + && bl.dryEnd == bx["dry_end"].get() + && bl.defaultValue == bx["default"].get(), + tag ("blend identity: both ends named, and where the knob starts")); + // The two facts a reader cannot recover and cannot guess: how loud the dry path is against + // the model, and which way round the two branches sum. + ok (bl.dryLevelDb == bx["dry_level_db"].get(), tag ("blend dry level")); + ok (bl.polarity == bx["polarity"].get(), tag ("blend polarity")); + ok (bl.gainsMeasured == bx["gains_measured"].get(), + tag ("blend gains: measured, or derived from the law")); + ok (bl.dryDb == bx["dry"].get>(), tag ("blend dry curve")); + ok (bl.trusted.loIndex == bx["trusted"]["lo_index"].get() + && bl.trusted.hiIndex == bx["trusted"]["hi_index"].get(), + tag ("blend trusted band, as grid indices")); + ok (bl.positions.size() == bx["positions"].size(), tag ("blend position count")); + for (std::size_t k = 0; k < bl.positions.size() && k < bx["positions"].size(); ++k) + { + const auto& got = bl.positions[k]; + const auto& px = bx["positions"][k]; + ok (got.value == px["value"].get() && got.norm == px["norm"].get() + && got.dryDb == px["dry_db"].get() && got.wetDb == px["wet_db"].get(), + tag ("blend position: rotation and both gains")); + } + // Same rule as measured, and for the same reason: no file carries a blend in its settings. + for (const auto& c : st.device.controls) + ok (c.name != bl.name, tag ("blend knob is not a selection control")); + } + } +} + +static void runFixture (const std::string& dir, const std::string& flatNam, bool regen) +{ + std::printf (" fixture: %s\n", dir.c_str()); const json expected = json::parse (readFile (dir + "/expected.json")); + const Rig model = buildModel (expected); + const Stage& st = model.chain.front(); + + if (regen) + { + for (const auto& fe : st.device.files) + { + namz::PackOptions opts; + opts.metadata = stampMeta (model, st, fe.settings); + const auto packed = namz::pack (flatNam.data(), flatNam.size(), opts); + writeFile (dir + "/pack/" + fe.id, packed.data(), packed.size()); + } + const auto manifest = writeManifest (model); + writeFile (dir + "/pack/rig.json", manifest.data(), manifest.size()); + std::printf (" regenerated %zu models + rig.json\n", st.device.files.size()); + return; + } + const std::string goldenManifest = readFile (dir + "/pack/rig.json"); ok (! goldenManifest.empty(), "golden rig.json present"); @@ -73,6 +291,10 @@ int main() ok (st->slot == expected["stage"]["slot"].get(), tag ("slot")); ok (buildControlsSpec (st->device.controls) == expected["stage"]["controls"].get(), tag ("controls spec")); + if (auto sw = expected["stage"].find ("sweep"); sw != expected["stage"].end()) + for (const auto& c : st->device.controls) + if (auto it = sw->find (c.name); it != sw->end()) + ok (c.sweep == it->get(), tag ("dial sweep")); ok (st->device.files.size() == expected["stage"]["files"].size(), tag ("file count")); for (const auto& fe : st->device.files) { @@ -90,6 +312,7 @@ int main() && fromManifest.firstKnown()->model == expected["stage"]["gear"]["model"].get() && fromManifest.firstKnown()->gearType == expected["stage"]["gear"]["type"].get(), "gear caption (manifest)"); + checkMeasured (*fromManifest.firstKnown(), expected["stage"], "manifest"); Rig fromHeaders = loadRigFromFiles (files); // spec decision A: files alone rebuild the device fromHeaders.name = fromManifest.name; // rig-level display fields live in the manifest… @@ -97,6 +320,12 @@ int main() ok (! files.empty() && files[0].meta.count ("modeled_by") == 1, "headers carry modeled_by per file"); checkRig (fromHeaders, "headers-only fallback"); + // A lone .namz carries no measured block — and that is CORRECT, not a loss: every file was + // captured with the knob at `reference`, and the shipped curves are relative to it. Without the + // manifest a player simply plays the reference tone, which is what those weights encode. + ok (fromHeaders.firstKnown() != nullptr && fromHeaders.firstKnown()->measured.empty(), + "headers-only device has no measured block (it plays the reference position)"); + // --- 2. SELECT: the expectation table pins the policy -------------------------------------- const auto* dev = &fromManifest.firstKnown()->device; for (const auto& row : expected["selection"]) @@ -112,34 +341,26 @@ int main() } // --- 3. WRITE: the model reproduces the golden bytes --------------------------------------- - Rig model; - model.rigId = expected["rig"]["rig_id"].get(); - model.name = expected["rig"]["name"].get(); - model.modeledBy = expected["rig"]["modeled_by"].get(); - Stage st; - st.kind = StageKind::Nam; st.rawKind = "nam"; - st.slot = expected["stage"]["slot"].get(); - st.make = expected["stage"]["gear"]["make"].get(); - st.model = expected["stage"]["gear"]["model"].get(); - st.gearType = expected["stage"]["gear"]["type"].get(); - st.device.rigId = model.rigId; st.device.slot = st.slot; st.device.family = st.model; - st.device.controls = parseControlsSpec (expected["stage"]["controls"].get()); - for (const auto& [name, settings] : expected["stage"]["files"].items()) - { - FileEntry fe; - fe.id = name; - for (const auto& [k, v] : settings.items()) fe.settings[k] = v.get(); - st.device.files.push_back (std::move (fe)); - } - model.chain = { st }; - ok (writeManifest (model) == goldenManifest, "writeManifest reproduces rig.json BYTE-EXACT"); for (const auto& f : files) ok (stampMeta (model, st, [&] { for (const auto& fe : st.device.files) if (fe.id == f.id) return fe.settings; return Settings {}; }()) == f.meta, "stampMeta reproduces the file's header keys exactly"); +} + +int main() +{ + std::printf ("namz rig conformance\n"); + const std::string root = NAMZ_CONFORMANCE_DIR; + const bool regen = std::getenv ("NAMZ_REGEN") != nullptr; + const auto flatNam = readFile (root + "/vectors/flat.nam"); + ok (! flatNam.empty(), "golden base model (vectors/flat.nam) present"); + + runFixture (root + "/rig", flatNam, regen); + runFixture (root + "/rig-measured", flatNam, regen); + if (regen) { std::printf ("REGENERATED (re-run without NAMZ_REGEN to verify)\n"); return 0; } std::printf (failures == 0 ? "ALL RIG CONFORMANCE PASSED\n" : "%d FAILURE(S)\n", failures); return failures == 0 ? 0 : 1; }