Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

cochlea

A headless, deterministic audio engine for AI agents. Write a score as data, render it offline to byte-identical PCM, then listen through numbers — loudness, onsets, pitch, key, spectrograms — and assert what you heard. Compose → render → probe → verify, with no human ear (and no audio device) anywhere in the loop.

There is no realtime path in this project, and there never will be one. Offline render is ground truth.

What the agent sees instead of hearing a WAV file.

Why

An agent can’t listen to audio, and it shouldn’t have to read raw PCM either — a few minutes of 48kHz float audio is tens of megabytes, which is a bad way to spend a context window. Cochlea’s answer is a small set of primitives an agent can compose:

  • Score IR — ticks, tempo, tracks, notes, and automation, expressed as data (Rust builder or RON).
  • Deterministic render — the same score renders to the same PCM bytes every time on the pinned CI target, enforced at the toolchain level, not by convention. See Determinism Contract.
  • Feature reports — loudness, true peak, onsets, pitch, key, tempo, stereo width, structure — as a few kilobytes of JSON, or a sub-kilobyte text digest sized for an LLM’s context window.
  • Spectrograms — a small PNG when a report alone doesn’t answer the question.
  • Verify — an assertion DSL over a render (true_peak_below, pitch_matches_score, tempo_is, …), so an agent can retry on a failed assertion instead of asking a human “does that sound right?”

Every crate here works standalone. cochlea probe runs on any WAV or FLAC file with no score in sight — that’s the adoption wedge, and it’s enforced structurally (features/spectro depend on neither score nor synth, checked via cargo tree in CI).

Where to start

  • Building instruments and scores, or wiring cochlea into a bigger system: Design & API Surface.
  • The determinism contract, and the fundsp/rustfft audits behind it: Determinism Contract.
  • Running cochlea as an MCP server so an agent calls render/probe/diff as tools: MCP Server.

Install

cargo install cochlea       # the CLI: render / probe / lint / spectro
cargo install cochlea-mcp   # the MCP stdio server
cargo add cochlea-features  # or any crate, as a library dependency

All 9 crates are on crates.io. Source, issues, and the workspace layout live at github.com/richer-richard/cochlea.

cochlea v1 plan: crate-by-crate public API surface

Written in Phase 0, before implementation. Where implementation reality diverges, this file is updated in the same PR (deviations get a Deviation marker). Determinism decisions and the dependency audits live in docs/determinism.md; the contract summary lives in the README.

Phase 0 decisions (deltas and pins)

  • fenestra-anim 0.1.0 verified on crates.io. It provides everything required: mul_div(u64, u64, u64, Rounding{Floor,Ceil,Round}) -> u64 (u128 intermediate, panics on div-by-zero and u64 overflow), typed Track<T: Interpolate> / Key<T> / key(at, value).ease(e), easing set (linear, hold, ease_in, ease_out, ease_in_out, spring, CubicBezier, Spring), Interpolate (f32 and (f32, f32)), and serde behind the serde feature. MSRV 1.88, unsafe_code = "forbid".
  • keys! lives in cochlea-score, not upstream. fenestra-anim exports the key() constructor; the keys![...] macro from the kickoff sketch is authoring sugar over cochlea tick positions (bar(1).beat(1) etc.), which fenestra-anim knows nothing about. Defining it here is not a fork of easing math — it expands to fenestra_anim::key(ticks, value).ease(e). No upstream PR needed.
  • Spring easing is rejected in v1 automation. Track::sample(frame, fps) grounds springs in seconds via an integer fps; automation runs in tick space where “ticks per second” is tempo-dependent and generally non-integer. Rather than evaluate springs subtly wrong, score validation rejects Ease::Spring on automation tracks (linear/hold/bezier are fine — they never read fps). Phase 2 can PR fractional-rate sampling upstream.
  • Tempo is stored as integer nanoseconds per quarter note (u64), converted from Bpm(f64) once at authoring time (round), like MIDI’s µs-per-quarter but 1000× finer. All tick→sample math after that point is exact rational (see score crate below). Valid BPM range [1.0, 4000.0], validated.
  • Denormal policy: honor denormals everywhere — no FTZ/DAZ, no algorithmic offsets. Full rationale in docs/determinism.md; short form: IEEE 754 fully specifies denormal arithmetic, so not flushing is the most deterministic and the most cross-platform-uniform choice, and offline rendering doesn’t care about the occasional slow filter tail. We never call fundsp’s prevent_denormals().
  • Pins (resolved on crates.io 2026-07-03): fundsp 0.23.0 (default-features off, std only — keeps symphonia and fft-convolver out), ebur128 0.1.10, rustfft 6.4.1, hound 3.5.1, ron 0.12.2, libm 0.2.16, image 0.25.10 (png only), serde 1.0.228, rayon 1.12.0, clap 4.6.1, proptest 1.11. Toolchain pinned 1.95.0; MSRV 1.88 (floor set by fenestra-anim and image).
  • Naming deviation, recorded: the user-facing name Instrument belongs to the score IR (score::Instrument::preset("saw_lead") — what a score references, serializable data). The synth-side trait (how a name becomes sound: param registry + fundsp voice graphs) is synth::Patch. The kickoff sketch used Instrument for both sides; one name for two types in two crates would be permanently confusing at call sites.
  • Stereo throughout v1. Every track renders 2 channels; mono voice graphs are panned center. Analysis that wants mono (YIN) downmixes (l + r) / 2.
  • Audit-driven rules (details in docs/determinism.md, enforced via clippy.toml bans): voices tick sample-by-sample, never fundsp process(); fundsp feedback()/fdn() combinators banned (internal thread-global x86 FTZ); ADSR/voice graphs freshly constructed per note, never pooled-and-reset (fundsp ADSR closure state survives reset()); pluck is a hand-rolled Karplus-Strong AudioNode (fundsp’s Pluck seeds its excitation from stateful funutd RNG) with counter-RNG excitation; fundsp noise()/hold() unused — all noise is the in-repo counter RNG; analysis FFTs construct FftPlannerScalar explicitly; fundsp presets use the prelude64 constructors (f64 internal state, f32 node interface); ebur128 true/sample peaks return linear amplitude — we convert to dBTP/dBFS via libm, and -inf loudness readings mean “silence/no data”, not errors.
  • The reverb insert is in-repo (P2 finding): every fundsp stereo reverb constructor (reverb_stereo, reverb4_stereo_delays) builds on fdn() internally, which flips thread-global x86 FTZ — and clippy bans can’t see call sites inside fundsp. The reverb insert is a Freeverb-style Schroeder (cochlea-synth), pure arithmetic per tick, tail ~2.5 s. Envelopes are likewise our own piecewise-linear ADSR evaluated through fundsp::envelope closures (pure arithmetic; the offline schedule knows note length, so no live gate state exists at all).

Dependency graph (acyclic, law-checked in CI)

score    → fenestra-anim, ron, serde
synth    → score, fundsp, libm
render   → score, synth, hound, rayon
features → ebur128, rustfft, hound, libm, serde        (NEVER score/synth)
spectro  → rustfft, hound, image, libm                  (NEVER score/synth)
decode   → features, symphonia                          (NEVER score/synth)
verify   → score, features, render
cli      → everything
mcp      → score, synth, render, features, spectro, verify

crates/score

Newtypes: Ticks(pub u64), Ppq(pub u32), SampleRate(pub u32), Bpm(pub f64), Vel(pub u8) (1..=127), Pitch(pub u8) (MIDI number, consts like Pitch::A4 = 69, FromStr for “A4”/“C#3”/“Bb2”, fn hz(self) -> f64 via libm), Dur { num: u32, den: u32 } (fraction of a whole note — Dur::quarter() = 1/4, eighth, half, whole, sixteenth, .dotted() (×3/2), .triplet() (×2/3), Dur::ticks(u64) escape hatch; resolved against PPQ exactly: ticks = ppq * 4 * num / den via mul_div).

Positions: Pos — built by the terse constructors from the sketch:

#![allow(unused)]
fn main() {
pub fn bar(n: u32) -> Pos;              // 1-based
impl Pos {
    pub fn beat(self, b: u32) -> Pos;   // 1-based within the bar
    pub fn plus(self, d: Dur) -> Pos;   // sub-beat offset, exact
}
}

Pos resolves to Ticks against (Ppq, TimeSignature) — 4/4 in v1 but the math takes (num, den) so time-signature support isn’t load-bearing anywhere. Resolution is exact integer math; a Pos that lands on a non-integer tick (impossible at 960 PPQ for any Dur with den | 3840) is a validation error, not a rounding.

Tempo map:

#![allow(unused)]
fn main() {
pub struct TempoMap { /* sorted (Ticks, NanosPerQuarter) steps, ppq, sample_rate */ }
impl TempoMap {
    pub fn sample_at(&self, t: Ticks) -> u64;   // THE conversion — see below
    pub fn tick_at(&self, sample: u64) -> Ticks; // inverse, Floor
    pub fn ns_at(&self, t: Ticks) -> u64;
}
}

sample_at is the one documented rounding rule: per tempo segment i starting at tick T_i with anchor sample S_i,

sample_at(t) = S_i + mul_div(t - T_i, npq_i * sr, ppq * 1_000_000_000, Rounding::Round)

Anchors are computed left-to-right with the same formula, so each tempo change rounds at most once (±0.5 samples, no accumulation) and within a segment there is zero drift. Monotonicity and exactness vs a direct u128 reference are property-tested. u64 range check: worst case npq * sr = 6e10 × 192_000 ≈ 1.2e16 < u64::MAX; enforced by the BPM and sample-rate validation ranges (SR in [8_000, 192_000]).

Score builder (mirrors the sketch):

#![allow(unused)]
fn main() {
let score = Score::new(SampleRate(48_000), Ppq(960))
    .time_signature(4, 4)
    .tempo(Ticks(0), Bpm(120.0))
    .track("lead", Instrument::preset("saw_lead"))
    .insert("lead", Insert::preset("reverb"))      // per-track insert chain
    .note("lead", bar(1).beat(1), Dur::quarter(), Pitch::A4, Vel(96))
    .automate("lead", Param::CUTOFF_HZ,
        keys![(bar(1), 400.0, ease_in_out()), (bar(3), 4_000.0)]);
}
  • score::Instrument — serializable reference: Instrument::preset(&str) | Instrument::custom(&str) (a name resolved from a caller-supplied bank at render time; the closure itself lives in synth — keeps score pure data, and RON containing a custom name fails rendering with a clear error unless the bank supplies it. Code-only, documented as non-serializable in spirit: lint warns on custom refs in .ron files).
  • Param(&'static str | owned) newtype with consts Param::CUTOFF_HZ, Param::GAIN, Param::PAN, Param::custom("..."). Snake_case strings in RON.
  • Automation: keys![...] expands to fenestra-anim keys; positions are Pos/Ticks, values f32, ease defaults linear. Stored per (track, param). Sampled at block starts by the renderer (control rate ≈1.3 ms at 64 samples / 48 kHz — documented); blocks split at event boundaries keep note-ons/offs sample-accurate.
  • Notes: Note { at: Ticks, dur: Ticks, pitch: Pitch, vel: Vel } post- resolution; builder keeps Pos/Dur authoring forms in the RON mirror.

RON data form (version = 1, round-trip tested both directions, one committed example under examples/scores/):

Score(
    version: 1,
    sample_rate: 48000,
    ppq: 960,
    time_signature: (4, 4),
    tempo: [(tick: 0, bpm: 120.0)],
    tracks: [
        Track(
            name: "lead",
            instrument: Preset("saw_lead"),
            inserts: [Preset("reverb")],
            notes: [Note(at: (1, 1), dur: "1/4", pitch: "A4", vel: 96)],
            automation: [
                Auto(param: "cutoff_hz", keys: [
                    Key(at: (1, 1), value: 400.0, ease: EaseInOut),
                    Key(at: (3, 1), value: 4000.0),
                ]),
            ],
        ),
    ],
    verify: [],   // assertion data forms, see crates/verify
)

Durations/pitches are strings in RON (“1/4”, “1/8.”, “A4”, “C#3”); positions are (bar, beat) tuples with an optional third sub-beat element (bar, beat, "1/16"). Ease names: Linear | Hold | EaseIn | EaseOut | EaseInOut | Bezier(x1, y1, x2, y2).

Static validation (score.validate(&impl Catalog) -> Vec<LintFinding>): overlapping notes on a mono instrument, params outside declared ranges, automation targeting unknown params, tracks with no events, BPM/SR out of range, spring ease on automation, custom instrument refs in data form. Catalog is a small trait (param specs + polyphony per instrument name) implemented by synth’s registry — keeps score free of synth while letting lints know param ranges.

Tests: unit (bar/beat/dur resolution exactness, tempo anchors), property (monotone sample_at, zero drift over 1e9 ticks vs u128 reference, tick_at(sample_at(t)) == t where exact), RON round-trip.


crates/synth

#![allow(unused)]
fn main() {
pub trait Patch: Send + Sync {
    fn name(&self) -> &str;
    fn params(&self) -> &[ParamSpec];          // typed registry
    fn polyphony(&self) -> Polyphony;          // Mono | Poly(NonZeroU8)
    fn release_ticks_hint(&self) -> f64;       // release tail in seconds — voice lifetime is static
    fn voice(&self, note: VoiceCtx) -> Box<dyn AudioUnit>;  // fresh fundsp graph per note
}

pub struct ParamSpec { pub param: Param, pub unit: Unit, pub range: RangeInclusive<f32>, pub default: f32 }
pub struct VoiceCtx { pub pitch: Pitch, pub vel: Vel, pub sample_rate: SampleRate,
                      pub note_seed: u64,     // hash(track, note index) — feeds counter RNG
                      pub start_sample: u64 } // absolute, for (seed, sample_index) noise keying
}
  • Param application: each automatable param is a fundsp shared variable (shared()/var()) wired into the voice graph; the renderer sets them at block starts. Settings are plain f32 stores — deterministic.
  • presets() registry: sine, saw_lead (filtered saw + ADSR), square_bass (mono), chord_pad (detuned saws + LPF), noise_hat (counter-RNG noise + fast envelope), pluck (Karplus-Strong, stateful but deterministic; excitation from the counter RNG, not fundsp’s noise). Registry implements score::Catalog for lints.
  • Insert presets: reverb (fundsp reverb, fixed parameters, applied per-track post voice-sum). Registry entry like patches.
  • Counter RNG (in-repo, the only randomness in the workspace):
#![allow(unused)]
fn main() {
pub fn crng(seed: u64, index: u64) -> u64;    // SplitMix64-style finalizer of seed ^ mix(index)
pub fn crng_f32(seed: u64, index: u64) -> f32; // uniform [-1, 1)
}

Noise is a pure function of (seed, sample_index) — random access, no state. fundsp’s noise()/hold()/Pluck are not used (audited: funutd- backed, stateful); the hat’s noise source is a custom AudioNode over crng, and pluck is a hand-rolled Karplus-Strong node with crng excitation.

  • All transcendentals in our DSP code go through libm. fundsp internals are covered by the audit in determinism.md.

Tests: two identical voices produce identical buffers; crng reference vectors; param registry ranges match preset defaults; each preset renders non-silence and decays after release.


crates/render

#![allow(unused)]
fn main() {
pub fn render(score: &Score) -> Result<Rendered, RenderError>;                    // presets only
pub fn render_with(score: &Score, bank: &PatchBank) -> Result<Rendered, RenderError>; // + customs

pub struct Rendered {
    // interleaved stereo f32; mix defined as f64-sum of the f32 stems in
    // fixed track order, then f64→f32 — so mix == sum(stems) byte-exactly
    pub fn mix(&self) -> &[f32];
    pub fn stems(&self) -> impl Iterator<Item = (&str, &[f32])>;
    pub fn sample_rate(&self) -> SampleRate;
    pub fn write_wav(&self, path: &Path) -> Result<...>;      // 32-bit float WAV (hound)
    pub fn write_stems(&self, dir: &Path) -> Result<...>;
}
}
  • Event schedule (pure): score → per-track sorted Vec<Event> with sample indices resolved once via TempoMap::sample_at (the single rounding). Events: NoteOn/NoteOff. Ordering: by sample, then track order, then note index — total and documented.
  • Blocks: fixed 64-sample max, split at event boundaries. Automation sampled at block-start ticks (TempoMap::tick_at of block start) and written to shared params before the block ticks.
  • Voices: per-track pool sized by Patch::polyphony. Allocation and stealing (oldest note) are functions of the schedule alone; voice lifetime is note span + release tail computed statically. Voices tick sample-by-sample (AudioUnit::tick), never fundsp block process() — one code path, one rounding story (see determinism.md on SIMD).
  • Track render: voices sum into an f64 stereo accumulator in voice-index order → insert chain (f32 in fundsp) → f64 track gain/pan → f32 stem.
  • Master: stems summed at f64 in fixed track order → f32 mix.
  • Parallelism: rayon over tracks; each track fully independent; deterministic fold in track order afterward. parallel == serial tested byte-for-byte.

Tests: same score twice → byte-equal; parallel == serial; stems f64-sum == mix byte-equal; voice stealing scenario (poly 2, 3 overlapping notes → oldest stolen, schedule-derived); golden PCM SHA-256 on the Tier 1 target (cfg-gated).


crates/features

Input type (no score, no synth — ever):

#![allow(unused)]
fn main() {
pub struct Audio { pub samples: Vec<f32> /* interleaved */, pub channels: u16, pub sample_rate: u32 }
impl Audio { pub fn from_wav(path: &Path) -> Result<Self, ...>;
             pub fn mono(&self) -> Vec<f32>; }

pub fn probe(audio: &Audio, opts: &ProbeOpts) -> Report;
}

Report (serde, schema_version: 2 — v1 lacked loudness.lra, tempo, stereo, and structure):

{
  "schema_version": 2,
  "source": { "sample_rate": 48000, "channels": 2, "samples": 480000, "duration_ms": 10000.0 },
  "loudness": { "integrated_lufs": -14.2, "momentary_max_lufs": -10.1,
                "true_peak_dbtp": -1.3, "sample_peak_dbfs": -1.5, "lra": 4.2 },
  "tempo":    { "bpm": 110.3, "confidence": 0.01, "clear_rhythm": false,
                "beat_count": 32, "mean_beat_interval_ms": 545.4 },
  "stereo":   { "width": 0.01, "correlation": 1.0, "balance": -0.0 },
  "structure": { "boundaries_ms": [8000.0], "section_count": 2, "confidence": 0.74 },
  "onsets":   { "count": 17, "times_ms": [...] },
  "pitch":    { "voiced_ratio": 0.82, "median_f0_hz": 440.1,
                "segments": [ { "start_ms": 0.0, "end_ms": 500.0, "f0_hz": 440.1, "midi_nearest": 69, "cents_off": 0.4 } ] },
  "key":      { "tonic": "C", "mode": "major", "confidence": 0.87, "chroma": [/* 12 */] },
  "silence":  { "leading_ms": 0.0, "trailing_ms": 812.5, "last_audible_sample": 441000, "floor_dbfs": -60.0 },
  "clipping": { "clipped_samples": 0, "true_peak_over_0dbtp": false }
}
  • Loudness: ebur128 (I | M | TRUE_PEAK | SAMPLE_PEAK), stereo default channel map. Integrated LUFS, momentary max, true peak dBTP.
  • Onsets: STFT (rustfft, 1024/256 hop, Hann via libm), half-wave- rectified spectral flux, median-based adaptive threshold, peak picking with minimum inter-onset gap. Times in ms (sample-derived).
  • Pitch: YIN (difference function + CMNDF, threshold 0.1, parabolic interpolation), 2048 window / 512 hop on mono downmix; per-segment median f0. Run per stem for multi-voice material — the caller (cli) does that; features stays per-buffer.
  • Key: 12-bin chroma from STFT magnitude (log-frequency weighting), Krumhansl-Schmuckler correlation against 24 major/minor templates → (tonic, mode, confidence = top correlation vs runner-up margin folded in).
  • Silence/tail: windowed RMS (50 ms hop) under floor (default −60 dBFS); last-audible sample. Clipping: |sample| ≥ 1.0 count; true peak > 0 dBTP flag.
  • Score-context enrichment (nearest tick for onsets, cents-vs-score) is NOT here — verify/cli join features output against the score.

Tests (fixtures synthesized in-test with libm, no synth dep): 440 Hz sine → A4 within 1 cent, LUFS of a −18 dBFS sine ≈ −18 LUFS (K-weighting at 1 kHz ≈ 0), click track → onsets at known samples ±2 ms, C major triad → C major, tail detection on a gated burst, clipping counter on a driven square.


crates/spectro

#![allow(unused)]
fn main() {
pub struct SpectroOpts { fft: usize /*2048*/, hop: usize /*512*/, mels: usize /*128*/,
                         floor_db: f32 /*-80*/, fmin: f32, fmax: f32 }
pub fn mel_spectrogram(audio: &Audio, opts: &SpectroOpts) -> MelImage;    // matrix + axis metadata
pub fn render_png(img: &MelImage, ruler: Ruler, markers: &[Marker]) -> RgbImage;
pub fn contact_sheet(img: &MelImage, markers: &[Marker], per_tile: usize) -> RgbImage;

pub struct Marker { pub sample: u64, pub label: String }   // bar markers WITHOUT score types
}

Hand-rolled mel filterbank (Slaney-style, documented), log magnitude with dB floor, 256-entry viridis LUT (const table in-repo), time ruler in seconds, caller-supplied bar markers. Contact sheets tile N-bar sections vertically so an agent reviews a whole piece in one vision call. Audio type is re-used from features? — no: spectro must not depend on features either? (law only covers score/synth; a features dep would be harmless but keep spectro standalone: it defines its own tiny input or takes &[f32], sr directly. Decision: functions take (samples: &[f32], channels, sample_rate) plain args; cli/verify adapt.)

Tier 3 sentinels: committed PNGs for fixture signals; image diff with per-pixel tolerance + max-fraction-differing threshold (helper in this crate, used by demo tests).


crates/decode

#![allow(unused)]
fn main() {
pub fn load(path: &Path) -> Result<Audio, DecodeError>;   // Audio = cochlea_features::Audio
}

Wave 2: lossless-only real-world file input. Dispatches on file extension: .wav/.wave delegates straight to cochlea_features::Audio::from_wav (hound); .flac goes through symphonia’s bundled FLAC reader+decoder (the symphonia crate, default-features = false, features = ["flac"] — no other format/codec, so no lossy decode sneaks in via a shared feature). Depends on cochlea-features only for the Audio type; never score/synth, same law as features/spectro.

FLAC is lossless by spec, so a correct decode reproduces the source PCM exactly — but symphonia-bundle-flac left-justifies every sample into the full 32-bit range regardless of the stream’s true bit depth (its own decode_inner comment: “the decoder uses a 32bit sample format as a common denominator”). Normalizing by always dividing by 2^31 (not by a bit-depth- derived scale) is what makes FLAC decode land on the same f32 bits as the WAV twin — verified, not assumed, by tests/sample_exact.rs against tiny committed FLAC fixtures with WAV twins (tests/fixtures/).

mp3/ogg (lossy) are explicitly next, not this round (docs/superpowers/ specs/2026-07-09-agent-audio-v2-design.md §2/§6).


crates/verify

#![allow(unused)]
fn main() {
use cochlea_verify::VerifyExt;   // extension trait on Rendered

let report = rendered.verify(&score)
    .integrated_lufs(-14.0, Tol(0.5))
    .true_peak_below(-1.0)
    .onset_at("drums", bar(17).beat(1), Ms(5.0))
    .pitch_matches_score("lead", Cents(10.0))
    .monotone("lead", Param::CUTOFF_HZ, bar(1)..bar(3), Direction::Rising)
    .no_discontinuity("lead", Db(40.0))
    .silent_after(bar(64))
    .run();                       // -> VerifyReport

pub struct VerifyReport { pub passed: bool, pub failures: Vec<Failure>, ... } // serde JSON
}
  • Newtypes Tol(f32), Ms(f32), Cents(f32), Db(f32).
  • onset_at: nearest detected onset on that track’s stem within tolerance.
  • pitch_matches_score: YIN per note window on the stem vs score pitch.
  • monotone: the authored automation curve sampled at block rate over the range must be monotone in the given direction (validates the authored sweep; spectral verification would conflate instrument response).
  • no_discontinuity: max sample-to-sample jump (in dB of |Δ|) away from note on/off boundaries ± a guard window — click detector.
  • silent_after: windowed RMS below floor for everything after the tick.
  • Wave-2 assertions over the v2 analyzers (same builder + RON dual form): tempo_is(bpm, BpmTol) / TempoIs(bpm, tol_bpm, [min_bpm, max_bpm]) — optional search-range override, the escape hatch for >~170 BPM material where the octave prior favors half-time; has_clear_rhythm(bool); stereo_width_within(min, max); lra_below(lu); section_count(min, max). Undefined-metric policy (stated in verify::checks): bounded-above checks pass on an undefined metric, value assertions fail on one.
  • Every assertion is also a serde data form embeddable in score RON under verify:; Verifier::from_specs(&[VerifySpec]) builds the same run. CLI cochlea render score.ron --verify runs them, writes the JSON failure report to stdout (or --report path), exits nonzero on failure.

crates/cli (cochlea binary)

cochlea render score.ron --out mix.wav [--stems dir/] [--verify] [--report report.json]
cochlea probe input.{wav,flac} [--json report.json] [--spectro spec.png]
                               [--digest] [--segments timeline.json] [--window-ms 1000]
cochlea diff a.{wav,flac} b.{wav,flac} [--json compare.json] [--tier2] [--window-ms 1000]
cochlea lint score.ron
cochlea spectro input.{wav,flac} --out spec.png [--sheet --bars-per-tile 8]

clap derive; probe ships in P3, the rest complete in P4; probe --digest/--segments and diff land in v2 wave 1 (the token-cheap read path: digest text instead of JSON, feature-space diff with a byte-identical / tier2-equivalent / different verdict). probe with no flags prints the JSON report to stdout. Exit codes: 0 ok, 1 verify/lint failures (and diff --tier2 when the verdict is not equivalent), 2 usage/IO errors. --window-ms rejects non-finite or sub-1 ms values at the flag boundary (NaN defeats downstream range checks; sub-millisecond windows would round to one-sample segments and explode the timeline), and distinct output flags pointing at one path are a usage error, not a silent last-write-wins.

The cochlea-mcp sibling binary (crates/mcp, v2 wave 1) serves the same pipeline as MCP tools over newline-delimited JSON-RPC 2.0 on stdio: render_score, probe_audio, spectrogram, lint_score, probe_digest, audio_diff. Hand-rolled protocol, no async runtime; see docs/mcp.md.


Demos (P5, demos/ as workspace tests or examples)

  1. metronome — click track; unit test asserts scheduled events land sample-exact; probe onsets within 2 ms.
  2. chord_pad — chord progression on chord_pad; chroma/key assertions.
  3. title_cue — 10 s cinematic sting: filter sweep (automation), volume envelope; asserts LUFS target, monotone cutoff, silent_after.

Each demo = a .ron score + a test running render → verify → probe, plus a committed spectrogram sentinel.

Determinism: decisions, audits, and rationale

This file records the Phase 0 determinism decisions and the dependency audits behind them. The contract itself (three tiers) is summarized in the README; this is the evidence and the fine print.

The three tiers, restated precisely

  • Tier 1 — byte-identical PCM for identical inputs on the pinned target: x86_64-unknown-linux-gnu, toolchain 1.95.0 (rust-toolchain.toml). Two renders of the same score are byte-equal on any machine (tested on every CI platform); committed golden hashes are asserted on the pinned target (and on aarch64-macos, the bless machine). Empirical note: the first Tier 1 CI run (2026-07-03) matched the aarch64-macos-blessed hash exactly — with every DSP path on libm + pure arithmetic, the render is in practice byte-identical across these architectures, stronger than the tier promises. The contract remains per-pinned-target so a future divergence is a re-bless, not a breach.
  • Tier 2 — feature tolerances across platforms: integrated LUFS within 0.1 LU, onsets within 2 ms, pitch within 5 cents. These absorb the cross-target float differences Tier 1 does not promise away.
  • Tier 3 — spectrogram sentinels: image diff with per-pixel tolerance and a max-fraction-differing threshold.

Why “audio is a fold” shapes everything

Filters, envelopes, delays, and reverbs carry state; sample N depends on all samples before it. So the reproducibility unit is the whole render, not the sample. Consequences: fixed summation order everywhere (voices in index order, stems in track order), one rounding rule at the tick→sample boundary, and stochastic sources that are random-access (counter-based) so they don’t inherit the fold’s ordering sensitivity.

Denormal policy: honor denormals everywhere

Decision: no FTZ/DAZ, no algorithmic offsets. We never call fundsp’s prevent_denormals() (fundsp/src/denormal.rs — it sets x86 MXCSR to 0x9fc0, i.e. FTZ|DAZ, and is a no-op on aarch64). The audit found fundsp calls it itself inside the Feedback/Feedback2/FDN node family — with no restore, so one tick of a feedback() node flips FTZ on thread-globally on x86. Those combinators are therefore banned via clippy disallowed-methods; the stock Reverb doesn’t use them and is fine. (ebur128 also flips FTZ inside its K-weighting loop, but scoped with a restore-on-drop, analysis-side only — accepted, see its audit section.)

Rationale, in order:

  1. Determinism. IEEE 754 fully specifies denormal (subnormal) results for + − × ÷ and sqrt. Honoring them is bit-deterministic on one machine and bit-uniform across architectures. Flushing is neither: the kickoff’s FTZ option can’t even be implemented uniformly (x86 MXCSR vs aarch64 FPCR; fundsp only covers x86, and our workspace forbids the unsafe needed to touch FPCR ourselves).
  2. The performance argument doesn’t apply offline. FTZ exists so realtime reverb/filter tails don’t blow the audio callback budget. We render offline; a rare 10–100× slowdown on a handful of samples in a decaying tail costs milliseconds of wall clock, not glitches.
  3. The f64 master bus makes denormals rarer where it matters. f32 subnormals start at ~1.2e−38; summing at f64 keeps the bus far from its own subnormal range (~2.2e−308).

Trade-off accepted: fundsp’s f32 filter internals may briefly process subnormal state values at tail ends, slower than flushed. If profiling ever shows a pathological case, the phase 2 revisit is an algorithmic fix applied uniformly (documented tiny offset), never an FPU-flag fix.

Toolchain and codegen pins

  • rust-toolchain.toml pins 1.95.0; CI installs exactly that via rustup’s toolchain-file support. Bumping the toolchain is a deliberate commit that re-blesses golden hashes if they move.
  • No fast-math: we never enable -ffast-math-style flags and don’t set RUSTFLAGS codegen options that relax float semantics.
  • No implicit FMA: Rust never contracts a * b + c into fma by default; mul_add is in the clippy disallowed-methods ban list so fusion is always an explicit, #[expect]-documented choice.
  • Std float transcendentals are banned in this workspace by clippy.toml disallowed-methods (delegated to platform libm — different results per OS/libc). DSP code uses the libm crate, which is pure-Rust and bit-stable across platforms. Std sqrt is exempt: IEEE-exact, hardware instruction everywhere.

fenestra-anim 0.1.0 (audited by reading the published source)

What the timebase and automation path actually execute:

  • mul_div(a, b, c, Rounding) -> u64 (src/rational.rs): u128 intermediate, explicit Floor | Ceil | Round (ties up), panics on c == 0 and on u64 overflow of the result. Pure integer math — exact and platform-independent. This is the only tick→sample primitive we use.
  • Track<T>::sample(frame, fps)locate() (src/track.rs): segment lookup by partition_point (integer), progress u = (run as f64 / span as f64) as f32 (exact-division semantics, deterministic), then easing:
    • Ease::Linear, Ease::Hold: pure arithmetic. Deterministic everywhere.
    • Ease::Bezier (src/bezier.rs): fixed 16-iteration Newton solve using only * + - / and clamp — no transcendentals. Deterministic everywhere.
    • Ease::Spring (src/spring.rs): std f32 exp/cos/sin/sqrt — platform libm, NOT cross-platform bit-stable. v1 rejects springs on automation tracks at validation (also for the seconds-grounding reason in docs/plan.md), so no fenestra-anim transcendental runs in any v1 signal path. Springs stay available to non-audio consumers upstream.
  • Interpolate for f32: a + (b - a) * t, pure arithmetic.

Conclusion: cochlea’s automation evaluation (linear/hold/bezier over ticks) is bit-deterministic across platforms, stronger than Tier 1 requires.

fundsp 0.23.0 audit

Manifest facts (verified in the vendored crate): depends on libm directly; wide (SIMD f32x8) is unconditional; funutd is the RNG dependency; default features files (symphonia) and fft (fft-convolver) are disabled in our workspace pin (default-features = false, features = ["std"]), which keeps symphonia out of Cargo.lock entirely (also enforced by a deny.toml ban).

Audit findings (full-source read of the vendored crate; per-node-family detail with file:line citations retained in the audit transcript, digest here):

  • The node interface is f32, full stop. AudioNode::tick/process are hardcoded to Frame<f32, _> (audionode.rs:75,79); prelude64 only upgrades internal state precision (Sine<f64> phase, filter state) and still emits f32 per sample. Consequence: fundsp renders per-voice f32; cochlea’s f64 buses (voice sum, master sum) live outside fundsp’s node boundary, in render. We use the prelude64 node constructors where offered (f64 internal accumulators cost nothing offline).
  • tick() and process() provably diverge. Sine::tick computes sin via libm::sinf (oscillator.rs:71lib.rs:480); Sine::process computes it via wide’s f32x8 SIMD polynomial (oscillator.rs:82lib.rs:632) — different algorithms, no bit-equality contract. SIMD floor/ceil are even approximations ((x ± 0.4999999).round(), lib.rs:326-332). Rule: voices tick sample-by-sample; AudioNode::process/AudioUnit::process are in the clippy disallowed-methods ban list. One code path, one rounding story.
  • Scalar transcendentals all route through libm — exhaustively confirmed: Num/Float/Real impls for f32/f64 call libm::{sinf,cosf,tanf,expf,exp2f,logf,log2f,log10f,tanhf,atanf,powf,...} (lib.rs:168-280,444-594,773-825). No std float intrinsics in the scalar path. Per-sample transcendentals in nodes we use: Sine::tick (sin), Moog::tick/Rez::tick (tanh) — all libm. Biquad/Lowpole/Highpole coefficient math (tan/sin/cos/exp) runs at construction/set-parameter time only. None of Biquad, Moog, Rez, Reverb, WaveSynth, Pluck, ADSR override process() anyway — the divergence risk is concentrated in oscillators like Sine; the tick-only ban covers everything uniformly.
  • No entropy anywhere. Zero hits for SystemTime|thread_rng|rand::|Instant|getrandom|OsRng across the crate; no rand dependency. All node seeding derives from the structural graph hash chained through ping()/AttoHash (deterministic function of node IDs and combinator positions), overridable per node via Setting::Seed(u64). Two structurally identical graphs get identical output. Cochlea presets set explicit seeds/phases anyway so sound identity survives graph refactors.
  • fundsp’s own denormal handling is x86-only, scattered, and side-effectful: prevent_denormals() is called from exactly one node family — Feedback/Feedback2/FDN (feedback.rs:129,136,241,248,357,370) — and sets MXCSR FTZ|DAZ thread-globally with no restore, a no-op on aarch64. Under the honor-denormals policy those combinators are banned (feedback, feedback2, fdn, fdn2, prevent_denormals in the clippy ban list). Verified consequence: every fundsp stereo reverb constructor is off-limitsreverb_stereo builds fdn::<U32> directly (prelude.rs:1755) and reverb4_stereo_delays builds two fdns (prelude.rs:1938-1939); the clippy ban cannot see those internal call sites, so the rule is: no fundsp reverb constructors at all. The reverb insert is instead an in-repo Freeverb-style Schroeder (8 damped combs + 4 allpasses per channel, cochlea-synth/src/nodes.rs), pure arithmetic per tick.
  • ADSR reset is a trap: adsr_live’s closure captures attacked + two Shared cells that reset() cannot see (adsr.rs:29-33). Voices are therefore always freshly constructed per note, never pooled-and-reset — which the render engine does anyway by design.
  • Net/Sequencer use hashmaps only for keyed lookup (execution order is a Vec-based topological sort, net.rs:834-917), but their live-edit frontends exist for realtime use; cochlea builds static An<_> graphs and never uses Net commit APIs.
  • funutd (fundsp’s RNG dep) stays out of our signal path: fundsp’s noise()/pluck()/hold() nodes (funutd-seeded, and Pluck’s excitation is a stateful funutd::Rnd replay) are not used by cochlea presets. Noise and Karplus-Strong excitation come from the in-repo counter RNG keyed (seed, sample_index); the KS voice is hand-rolled (delay line + damping FIR as a custom AudioNode) rather than fundsp’s Pluck. funutd remains in Cargo.lock as an unused-at-runtime transitive dep.
  • Versions that can move last-bit float behavior — fundsp, libm, wide, funutd — are pinned by Cargo.lock (committed); toolchain pinned; no -C target-cpu=native anywhere.

Per-preset Tier 1 verdicts (all under the tick-only rule): sine safe (libm sin); saw_lead/square_bass/chord_pad safe (WaveSynth’s interpolation is pure arithmetic, filters libm-at-construction, ADSR fresh per voice — implemented as our own piecewise-linear closure over note time rather than fundsp’s adsr_live, sidestepping its closure-state reset trap entirely); noise_hat safe (counter-RNG noise + filter); pluck safe (hand-rolled KS, counter-RNG excitation); reverb insert safe (in-repo Schroeder — fundsp’s reverb constructors are all FDN-based and banned, see above).

ebur128 0.1.10 audit

API facts the features crate builds on (file:line citations in the audit transcript):

  • Construct EbuR128::new(channels, rate, mode); we use Mode::I | Mode::TRUE_PEAK (TRUE_PEAK implies SAMPLE_PEAK and M). Feed interleaved add_frames_f32(&[f32]). Default 2-channel map is [Left, Right] — correct for us, no set_channel needed.
  • Readouts: loudness_global() → LUFS (needs Mode::I), loudness_momentary() → LUFS over the last 400 ms (momentary max is ours to track: feed in 100 ms chunks and take the running max of readings), true_peak(ch)/sample_peak(ch)linear amplitude — we convert to dBTP/dBFS via 20·log10 (libm) ourselves.
  • Gating confirmed BS.1770-4: absolute −70 LUFS gate (energies below the first histogram boundary are discarded before recording, history.rs:264-267, boundary derived from the −70 LUFS energy) and relative −10 LU gate (history.rs:320-334), 400 ms blocks at 75% overlap.
  • Silence/not-enough-audio is not an error: loudness_* return Ok(-inf), peaks Ok(0.0). The features crate maps -inf to a JSON null-with-reason, never an error.
  • True peak: rate-dependent oversampling (4× below 96 kHz), 48-tap Hanning-windowed-sinc polyphase FIR with coefficients computed once at construction; FIR math is f32; no SIMD; the precision-true-peak feature (FMA in the FIR) stays off.
  • Internal math is otherwise f64. One arch-conditional path, flagged: the K-weighting filter loop enables scoped hardware FTZ on x86_64 (filter.rs:376-428, restored on drop) and approximates it on other arches by flushing filter state below f64::EPSILON at block boundaries. So loudness readings can differ at ULP level between x86_64 and aarch64. This is analysis-side only (never touches PCM), scoped (does not leak MXCSR state to our thread beyond the call), and absorbed by the Tier 2 0.1 LU tolerance with ~5 orders of magnitude of headroom. Accepted.

rustfft 6.4.1 audit

  • FftPlanner::new() does runtime CPU-feature dispatch (AVX+FMA → SSE4.1 → NEON → WASM → scalar). Same binary, different machine ⇒ different FFT bits; and a cloud CI runner migration could silently flip the path on the “pinned” target.
  • Decision: analysis code constructs FftPlannerScalar explicitly, everywhere (rustfft::FftPlanner::new is in the clippy ban list). One code path on every machine, immune to runner-hardware drift. Our FFTs are small (1024–2048 points at audio hop rates); scalar is more than fast enough offline, and this choice makes features/spectro deterministic per-binary, not just per-platform.
  • Planning is a pure function of (len, direction) for the scalar planner; process() panics on length mismatch (caller invariant); output is unnormalized (we only use magnitudes, and normalize where needed, consistently).
  • rustfft is used only in features and spectro — never in the PCM render path — so even its residual cross-toolchain variance is bounded by Tier 2 tolerances and Tier 3 image diffs by construction. MSRV 1.61, deps purely numeric.

Rounding rules (the complete list)

  1. Bpm(f64) → integer nanoseconds-per-quarter (round), once, at authoring/parse time. The stored score is exact from then on.
  2. Tick → sample: mul_div(Δticks, npq · sr, ppq · 1e9, Rounding::Round) per tempo segment with left-to-right integer anchors; applied once at event-schedule time. Property-tested monotone and drift-free.
  3. Sample → tick (block starts → automation domain): same segment math with Rounding::Floor (a block start belongs to the tick it is inside).
  4. f64 master bus → f32 output samples: default Rust as f32 (round-to-nearest-even), documented here, applied at the very end.

Nothing else in the pipeline rounds between integer domains.

v2 analyzers and decode (wave 2 additions)

The wave-2 surface introduces no new determinism mechanism — it reuses the existing rules — but each addition deserves its line in this ledger:

  • FLAC decode (cochlea-decode, symphonia 0.6, FLAC feature only). FLAC reconstruction is pure integer arithmetic by spec; the only place a decode could diverge from a WAV twin is float normalization. symphonia-bundle-flac left-justifies every sample into 32 bits (its own documented “common denominator”), so the crate divides by 2^31 unconditionally — exactly equal to the WAV path’s per-depth 2^(bits-1) divide (both are the same power-of-two rescale, exact in IEEE 754). Enforced bit-for-bit by committed WAV/FLAC twin fixtures. Zero-packet streams take their shape from STREAMINFO and their sample vector stays empty — no fabricated rates.
  • Tempo (tempo.rs), stereo (stereo.rs), structure (structure.rs), segment timeline (segments.rs). All transcendentals via libm; the onsets-grade STFT they share comes from the same FftPlannerScalar path as everything else; autocorrelation and the novelty kernel use fixed ascending summation order; every float sort uses total_cmp. The analyzers are pure functions of the buffer — probe() computes shared intermediates (STFT, onset report, YIN track) once and fans them out, which is a pure refactor precisely because the passes were deterministic duplicates.
  • Non-finite input policy. Float WAVs can legally encode NaN/±inf; IEEE 754 comparison semantics would let a single NaN sample masquerade as silence in one analyzer and a real peak in another. Audio::from_wav rejects non-finite samples at ingestion (NonFiniteSample), so no analyzer ever sees one. Degenerate options (NaN window/frame lengths, non-finite silence floors) are guarded with is_finite() checks — a plain <= 0.0 range check is NaN-blind.
  • Text outputs (digest, compare). Fixed-precision {:.N} formatting only, stable field order, no wall clock, no hash-map iteration — byte- deterministic per platform for identical input; the numbers inside stay Tier-2 across platforms like every other analysis float.

cochlea-mcp

An MCP (Model Context Protocol) stdio server over the cochlea libraries. Any MCP client — Claude Code, another agent, a script — gets render / probe / spectro / lint / digest / diff as tool calls, so it can compose, render, and “listen” to audio through numbers and images without shelling out to the cochlea binary or reading raw PCM.

The protocol is hand-rolled JSON-RPC 2.0 over newline-delimited stdio: one JSON object per line in, at most one JSON object per line out. No async runtime — offline batch tools (render a score, extract a report, write a PNG) need none. Every response is a pure function of its request: no wall clock, no session state, so identical requests produce identical responses.

Tools

Each tool mirrors the matching cochlea CLI subcommand’s semantics exactly (see crates/cli/src/main.rs) over the same library calls — this is a second front end onto the same offline pipeline, not a reimplementation.

ToolArgumentsReturns
render_scorescore_path (string, required), out_path (string, required), stems_dir (string, optional), verify (bool, default false)Text summary: frame count, duration, sample rate, peak dBFS, stems written; if verify is set, the full verify-report JSON is appended and the call reports isError: true on a failed verification.
probe_audioaudio_path (string, required)The full feature report, schema v2 (loudness/LUFS/true peak/LRA, onsets, YIN pitch, chroma/key, tempo + clear_rhythm, stereo image, structural sections, silence, clipping) as pretty JSON. Works on any WAV or FLAC — no score required.
spectrogramaudio_path (string, required), out_path (string, required), sheet (bool, default false), bars_per_tile (integer, default 8)Text: the written PNG path and its pixel dimensions. Plain spectrogram or, with sheet: true, a tiled contact sheet — no score-aware bar markers yet (those need score context via render_score).
lint_scorescore_path (string, required)Text: "ok: no lint findings", or the JSON list of findings. isError: true iff any finding is Severity::Error, matching cochlea lint’s exit-1 threshold.
probe_digestaudio_path (string, required), window_ms (number, default 1000)A ~40-line deterministic text digest (cochlea_features::digest_text) instead of a full JSON report — the token-cheap way to “listen” to a WAV or FLAC. Prefer this over probe_audio unless the caller needs exact numbers to assert against.
audio_diffaudio_path_a (string, required), audio_path_b (string, required), window_ms (number, default 1000), json (bool, default false)Feature-space comparison text (cochlea_features::compare_text): a verdict (byte-identical / tier2-equivalent / different (dimensions...)) plus per-dimension deltas. json: true appends the full CompareReport as pretty JSON. A different verdict is a normal, successful answer — not isError.

Tool-level failures (a bad path, a render error, a failed verify or lint) come back as a normal tools/call success response with isError: true and the reason in the text content — never a JSON-RPC error. JSON-RPC errors (-32700/-32601/-32602) are reserved for protocol problems: malformed JSON, an unknown method, or missing/malformed arguments on a known tool. audio_diff’s different verdict is not one of these failures — see its row above.

Client setup

Claude Code:

claude mcp add cochlea -- cargo run -p cochlea-mcp --release

or, against an already-built binary:

claude mcp add cochlea -- /path/to/target/release/cochlea-mcp

Any other stdio MCP client: launch cochlea-mcp (or cargo run -p cochlea-mcp) as a subprocess and speak JSON-RPC 2.0 over its stdin/stdout, one object per line. stdout carries only protocol responses; all logging goes to stderr, so it’s safe to leave stderr connected to a terminal or log file without corrupting the transport.

Example

Request (tools/call for probe_audio, sent as one line):

{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"probe_audio","arguments":{"audio_path":"mix.wav"}}}

Response (one line back; the pretty-printed report is escaped into the text field, shown here unescaped for readability):

{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\n  \"schema_version\": 2,\n  \"source\": {\n    \"sample_rate\": 48000,\n    \"channels\": 2,\n    ...\n  },\n  ...\n}"}],"isError":false}}

Testing this crate

crates/mcp/src/lib.rs exists so the dispatch logic (server::Server) can be driven in-process from integration tests — Server::handle_line(&str) -> Option<String> takes one request line and returns at most one response line, with no stdin/stdout/subprocess involved. crates/mcp/src/main.rs is just the framing loop around it. See crates/mcp/tests/protocol.rs for JSON-RPC conformance and crates/mcp/tests/tools_e2e.rs for a real render → probe → spectrogram round trip against examples/scores/first_light.ron.