# src
## Purpose
All engine, terminal UI, and live-control code for the nooise binary.
## Ownership
- `main.rs` — binary entry point: CLI parsing (`run`/`version`/`update`/`render`/`auto`/song code), wires up terminal + audio engine.
- `update_check.rs` — passive crates.io update notification helper; checks in the background and exposes a short TUI-safe message.
- `audio.rs` — cpal/audio-backend plumbing, sample callback wiring.
- `fluid/` — the core engine module:
- `mod.rs` — crate-facing glue: `run()` (TUI + live audio), `run_auto()` (TUI + live audio, slow-morph mode), `render_wav()` (headless wav render), `FluidTelemetry`.
- `auto.rs` — `nooise auto [BARS]`: `AUTO_STATES` baked-in share codes decode to a `Vec<SongState>` at startup (fatal on a bad code); `MorphState` holds the decoded endpoints — each a full `SongState` (controls + automation) — plus bars-per-leg and derives leg index/from/to/t purely from the live beat clock (no stored progress, so it stays render-deterministic); `MorphWriter` throttles the engine's control- and automation-reload tick to one recompute-and-store of both per 1/8 note.
- `controls.rs` — `FluidControls`, `MasterControls`, and per-voice control structs with defaults.
- `registry.rs` — the control registry: one `ControlSpec` table per tab (stable ID, label, kind, range, step, entry semantics, reset, accessors, display). `tab_controls`/`apply_delta`/`apply_min`/`apply_value` all derive from it. Rows matching a common archetype use the one-line macros `gain_pct!`/`time_secs!`/`time_ms!`/`beat_interval!`/`beat_offset!`; a row deviating from its archetype in any field stays a plain `ControlSpec`. Per-tab metadata (name, mute-target level id, control table) lives in the single `TAB_META` const, in discriminant order (test-enforced).
- `automation.rs` — modulation routes keyed by stable control ID. A control can carry an independent LFO route (`f` submenu: shape/amount/interval/offset) and/or a one-shot envelope route (`e` submenu: amount/attack/decay/trigger); `modulated_control_value_full` sums both, clamps, then snaps. LFO field specs own slider ranges, steps, reset targets, and numeric entry; envelope field behavior lives on `EnvelopeRoute`. LFO shapes cover sine/triangle/ramp/square plus seeded random drift and sample & hold (pure `(seed, cycle index)` hash — no RNG state — reseedable via `LfoRoute::reseed`). Every shape must be value-continuous across its cycle wrap (sine/triangle by construction, square via `SQUARE_SMOOTH`'s tanh curve, ramp via `RAMP_WRAP_EASE` easing the last 2% of phase toward the next cycle's start value in `ease_ramp_wrap`) — modulated values are applied straight to live-read controls every sample with no additional smoothing, so a raw sawtooth wrap clicks audibly on gain/cutoff-style controls. Routes drive runtime modulation; song-code persists LFO routes (incl. shape), envelope routes are not persisted on the experiment branch.
- `song.rs` — versioned binary song-code export/import for controls plus automation records. `Ctrl+S` copies `nooise <code>` and shows a short confirmation; `nooise <code>` applies the decoded song state before audio/TUI startup.
- `ui.rs` — TUI event loop, tab rendering, fluid visualizer.
- `engine.rs` — `FluidEngine` (voice mixer), gain smoothers, tempo clock, grid triggers, ambient reverb send, master bus.
- `voice/` — one module per voice (pad, bass, perc, kick, tonal, clap, arp) plus shared helpers (`midi_to_hz`, `tune_ratio`, `soft_clip`, `normalized_lfo`, `mix_and_retain`, `noise_filter_smoothing`) in `voice/mod.rs`.
- `fx/` — shared DSP building blocks (LFO, panner, reverb) consumed by voices. See `fx/AGENTS.md`.
- `synth/` — shared synthesis primitives (envelope, oscillator, noise) consumed by voices. See `synth/AGENTS.md`.
## Local Contracts
- The `ControlSpec` tables in `fluid/registry.rs` are the single source of truth for every control row and stable song snapshot ID. Adding a control = adding one table entry with a durable ID; never reintroduce per-function match arms for control rows. The `control_registry_specs_are_internally_consistent` test enforces table sanity.
- `song.rs`'s snapshot codec is fully generic over `all_specs()`: any state expressible as `ControlSpec` rows persists and round-trips automatically, and old codes missing a newer id simply decode that field to its default — no `song.rs` change needed when adding controls this way. Only reach for a new versioned payload/section in `song.rs` for state that cannot be expressed as a flat control value (e.g. automation routes).
- Control rows carry `ControlKind`; use it as the source of truth for gain/continuous/timing/discrete semantics.
- Every control's value↔dial-position mapping is its `Taper` (registry.rs): `Linear`, `Log2` (frequencies/octave ranges — e.g. `bass.cutoff`), or `Exp(n)` (power-law, resolution concentrated at the low end, handles a zero min). One taper drives both the visual ratio bar (`item_ratio`, marker math) and h/l stepping, so a dial's feel lives in exactly one field. `ControlItem` carries real value/min/max plus the taper; never bake the forward transform into the stored value. A **continuous tapered** dial — non-`Linear` taper with a plain `Step::Linear` (`ControlSpec::is_continuous_tapered`) — steps in position space (`apply_delta` moves `1/TAPER_STEPS_PER_SWEEP` of the throw, not a fixed value delta) and `quantize` stores it at full precision (no value grid, exact song round-trip); its `Step` value is inert. A `Log2` taper with a discrete `Step` (`PowerOfTwo`/`BeatGrid`, e.g. `pad.chord_bars`) keeps that musical grid stepping instead.
- Every time control reads through the shared `secs(seconds)` formatter (whole ms below 1 s, 2-dp seconds above) — ms-stored controls pass `ms/1000.0` — and uses `Taper::Exp(TIME_TAPER)` so envelope/decay dials read and step consistently regardless of whether the field is stored in seconds or milliseconds. Retune all time-dial feel from the single `TIME_TAPER` constant. Beat-grid/power-of-two musical controls keep their musical stepping and are left off the exp taper.
- Continuous LFO submenu rows derive from `LfoFieldSpec` (range/step/reset/display/entry); the discrete LFO shape field and all envelope fields are handled on `LfoRoute`/`EnvelopeRoute` instead of per-key UI branches.
- Per-slider `f` (LFO) and `e` (envelope) automation are the user-facing modulation paths; do not add voice-specific LFO/envelope rate/depth controls to core slider tabs.
- New LFO routes start at 0% amount and new envelope routes at 0 amount; opening either editor is audible-neutral until the user raises amount, and `close_editor` drops a still-neutral route.
- Every modulated value shown or heard must come from `modulated_control_value_full` (LFO + envelope summed, clamped, snapped); never add divergent UI-only modulation math.
- Grid-timing controls carry `LfoSnap` in their `ControlSpec` (intervals snap modulation to power-of-two subdivisions, offsets to their step grid); every modulated value — engine and UI marker alike — must come from `modulated_control_value` so what is shown matches what is heard. `GridTrigger` may pull a scheduled hit earlier when a reshaped grid lands sooner (so a denser grid never starves), but never within half an interval of the hit already emitted (`earliest_hit`, floored on `last_hit_beat`); a reshape moves any slot by at most half an interval, so this guard is what stops a live swing/offset/rate change from re-firing the slot that just sounded (an audible double-trigger). Grids that move later latch at the next fire.
- Chord length (`pad.chord_bars`) stores bars for the pad/bass engines but displays and accepts numeric entry in beats; typed values convert to bars and snap to the existing power-of-two grid.
- Live-read gain controls are ramped by registry-derived `GainSmoothers` in `FluidEngine`; every unique `ControlKind::Gain` spec must get a smoother automatically.
- TUI automation edits must go through `PublishedAutomation` so the shared audio-thread snapshot is stored on every mutation.
- Pitched voices (Pad, Bass, Tonal) route note numbers through `midi_to_hz` and respect master tune; unpitched voices (Perc, Kick, Clap) do not.
- Tonal separates trigger density from phrase shape: `tonal.rate_beats` controls note trigger spacing; `tonal.step_interval_beats` is the stable cycle-length ID for phrase wrapping and evolution boundaries.
- Tonal synth selection lives in `tonal.synth_type`; it changes the voice created for new tonal notes while preserving the shared phrase/randomness/timing/master-tune/reverb path. Exploration variants may differ in harmonic tables, spectral tilt, and pitch-scaled harmonic decay; keep piano-family profiles warm and non-metallic unless the user explicitly asks for FM-like brightness.
- `tonal.octave` (`TonalControls::octave`, default 0) shifts the note selected from `TONAL_SCALE_MIDI`/the evolved phrase by whole octaves (`± round(octave) * 12` semitones) right before `tonal_note_hz` in `TonalEngine::next` (`voice/tonal.rs`) — it never touches `TONAL_SCALE_MIDI` itself, so the minor-pentatonic interval set is preserved at every octave. Arp reuses Tonal's piano synthesis but derives its own pitches from pad chords via a separate `midi_to_hz`/`tune_ratio` call in `arp.rs`, so `tonal.octave` affects only the Tonal voice.
- Every tonal/arp note is a single attack+decay shape via the shared `attack_decay_gain` helper (Sine and all piano-family profiles): the gain ramps in over `attack` seconds, then falls from the peak to silence over `decay` seconds, so a note's whole sounding life is `attack + decay` and the envelope reaches exactly 0 there (no click at voice death). The step grid (`tonal.rate_beats`/`arp.rate_beats`) only spaces triggers — it never bounds a note's length — so notes overlap freely when `decay` outlasts the step (both engines accumulate polyphonic voices). `decay` is floored at `TONAL_DECAY_MIN` (10 ms) so the UI can't request a hard, clicking cut; only the decay curve's shape exponent (`PianoProfile::body_power`, or the sine voice's fixed sqrt taper) stays profile-owned character. Clap and perc already embody this decay-to-zero model (their inline `decay_ms` fades) and are the reference the tonal/arp envelope was unified to. Pad and Bass are deliberately separate (long sustained chords; mono voice-stealing) and keep their own envelopes.
- Tonal owns a slight fixed low cut before engine mixing so its low notes sit above sub/bass energy without requiring a user-facing control.
- Pad, Tonal, and Arp emit dry voice output; `FluidEngine` owns their shared ambient reverb send/return so reverb mix changes do not add an uncontrolled per-voice wet gain boost. All three expose a user-facing `reverb_mix` gain control (`pad.reverb_mix`, `tonal.reverb_mix`, `arp.reverb_mix`) following the same idiom; only the per-voice send level (`AMBIENT_REVERB_ARP_SEND`, etc. in `engine.rs`) stays a fixed constant. `arp.reverb_mix` defaults to 0.5, matching the former fixed mix, so the default sound is unchanged.
- Arp follows the Pad's current chord without reaching into `PadEngine` directly: it keeps its own `chord_trigger`/`step_index` synced to the same `pad.chord_bars` grid (same pattern as Bass), reads chord tones via `pad_chord_midi`, and cycles them (Up/Down/Up-Down/Random, 1–3 octave span) on its own `arp.rate_beats` grid, phase-shifted by `arp.offset_beats` (default 0, matching the Level/Interval/Offset idiom shared with Perc/Bass/Kick/Tonal/Clap) — only `note_trigger` reads the offset; `chord_trigger` stays tied to `pad.chord_bars` unshifted. Its synth character lives in `arp.type` (`ArpControls::voice_type`), reusing Tonal's `TonalVoice`/`tonal_synth_type_index`/`piano_profile` path (same Sine + 9 piano-profile set as `tonal.synth_type`, same labels via `tonal_synth_type_label`); default `6.0` ("Pluck") matches the arp's former hardcoded profile byte-for-byte. `arp.gain` defaults to 0 (silent) so adding the voice never changes an existing song or default startup. When the chord or octave span changes mid-cycle, the cycle position is clamped into the new tone list rather than reset, avoiding a click.
- A ninth `pad.progression` value (`voice::CUSTOM_PROGRESSION_INDEX`) selects a user-built progression instead of the 8 built-in tables: 8 chord slots (`PadControls::chord_slots`, `ControlSpec` rows `pad.chordN_degree/_accidental/_extension/_inversion`, visible directly on the Chords tab) each define a tonic-relative root degree, semitone accidental, extension (triad/6th/7th/9th-flavor top voice), and inversion; `pad.chord_count` (1–8) sets how many slots loop. `voice::pad_chord_tones`/`voice::pad_chord_count` are the single chord-source path Pad, Bass, and Arp all resolve "what chord is at this step" through — a custom progression drives all three identically without any voice reaching into another's state. Built-in progressions are untouched; the custom path is fully inert unless progression selects it.
- Bass character lives in `bass.type` (`BassControls::voice_type`): index 0 (`Sub`) is the legacy voice and the default — its DSP path must stay byte-identical to a pre-`bass.type` render. Index 1 (`Saw`) is a brighter additive-harmonic character, index 2 (`Pluck`) a shorter character with an attack transient; all three share the same trigger/rhythm, pitch (`midi_to_hz` + master tune), and drive/panner tail, and are gain-authored to a comparable perceived level.
- Bass is monophonic (`BassEngine::voice: Option<BassVoice>` in `bass.rs`): each rhythm-grid hit hard-cuts whatever is currently sounding and starts the new note immediately, regardless of `decay_time`. The replaced voice isn't dropped instantly (that clicks) — it rings down over a fixed short (`BASS_MONO_FADE_SECONDS`, ~3ms) `fading_voice` slot, independent of the voice's own envelope. This applies identically to all three `bass.type` characters. No user-facing control; this is not a voice pool.
- `bass.cutoff` (`BassLowPass` in `bass.rs`) is a one-pole lowpass applied above the `bass.type` dispatch, to `BassEngine`'s summed stereo output (including the mono fade tail), so it affects all three bass characters identically. Its coefficient is recomputed every sample from the live modulated value (it can carry an LFO/envelope route), unlike `TonalLowCut`'s fixed cached coefficient. `BASS_CUTOFF_MAX_HZ` (the default) is a true bypass — `BassEngine::next` skips the filter call rather than relying on a wide-open coefficient, since a one-pole pass at any finite max cutoff still audibly attenuates.
- Pad character lives in `pad.type` (`PadControls::voice_type`, `PadTone` enum over `WarmPadTone`/`DarkPadTone`/`GlassPadTone`): index 0 (`Warm`) is the legacy tone and the default — its DSP path must stay byte-identical to a pre-`pad.type` render. Index 1 (`Dark`) adds a fixed one-pole lowpass before soft-clipping; index 2 (`Glass`) adds a quiet fixed shimmer oscillator two octaves up; all three share the unchanged chord/progression logic (`pad_chord`), trigger timing, attack/release, and pans, are gain-authored to a comparable perceived level, and keep the pad's dry-output/engine-owned-reverb-send contract unchanged.
- Voice RNGs must stay reseedable via `FluidEngine::reseed` so `nooise render --seed` stays byte-reproducible.
- Passive update checks must never block the TUI or audio callback; keep crates.io/network work off the main loop and show no message on failure.
- `nooise update` checks crates.io before invoking Cargo; do not force reinstall when the installed version is already current.
- `nooise auto` morphs by owning and rewriting both the live `FluidControls` and the live `AutomationState` (LFO/envelope/macro routes), same clone-modify-store path the UI uses for each. Controls are driven by registry `ControlKind`: each leg holds steady for `HOLD_FRACTION` (2/3) of its length, then transitions in the final third — `Gain`/`Continuous` glide across the transition window (levels included, so a morph can never dip below the quieter endpoint — proven by `morph_never_dips_below_the_quieter_endpoint`); `Timing`/`Discrete` snap through `spec.quantize`, never interpolated. The `STRUCTURAL_SNAP_IDS` group (progression, chord count/bars, arp pattern) jumps atomically on the transition downbeat; other grid params stagger at `STAGGER_STEP_BARS` (8-bar) offsets after it. `AutomationState::morph` mirrors this for modulation routes: each route's level field (`LfoRoute::depth_ratio`, `EnvelopeRoute::amount`, every `MacroRoute` amount) glides across the same transition window while its other fields snap together at the single transition downbeat (no per-field staggering); a route present on only one endpoint fades in/out via its level field rather than popping, and needs no explicit removal — it's simply absent once this leg's `to` becomes the next leg's `from`. Manual UI edits during a morph are overwritten on the next tick — pressing `a`, touching any param, or touching any modulator (`f`/`e`/`v`/`x`/`X`/`r`/`R`) exits auto instead (`AutoControls`). Add a morph target by appending an `n1_…` share code to `auto::AUTO_STATES` (see the doc-comment there for the copy-from-session steps); that list is the seam a future TOML mixtape loader replaces.
## Verification
- `just test` (cargo test) covers engine logic and the control registry; tests live in `fluid/tests.rs`.
- `just check` runs clippy across all targets.
- `nooise render --seconds N --seed N --out X.wav` (or `just render`) renders audio headlessly for DSP verification — same seed must produce byte-identical output.
## Child DOX Index
- `fx/AGENTS.md` — shared DSP effects (LFO, panner, reverb)
- `synth/AGENTS.md` — shared synthesis primitives (envelope, oscillator, noise)