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), typedTrack<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 theserdefeature. MSRV 1.88,unsafe_code = "forbid". keys!lives in cochlea-score, not upstream. fenestra-anim exports thekey()constructor; thekeys![...]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 tofenestra_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 integerfps; automation runs in tick space where “ticks per second” is tempo-dependent and generally non-integer. Rather than evaluate springs subtly wrong, score validation rejectsEase::Springon automation tracks (linear/hold/bezier are fine — they never readfps). Phase 2 can PR fractional-rate sampling upstream. - Tempo is stored as integer nanoseconds per quarter note (
u64), converted fromBpm(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’sprevent_denormals(). - Pins (resolved on crates.io 2026-07-03): fundsp 0.23.0
(default-features off,
stdonly — 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
Instrumentbelongs 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) issynth::Patch. The kickoff sketch usedInstrumentfor 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 viaclippy.tomlbans): voices tick sample-by-sample, never fundspprocess(); fundspfeedback()/fdn()combinators banned (internal thread-global x86 FTZ); ADSR/voice graphs freshly constructed per note, never pooled-and-reset (fundsp ADSR closure state survivesreset());pluckis a hand-rolled Karplus-StrongAudioNode(fundsp’sPluckseeds its excitation from stateful funutd RNG) with counter-RNG excitation; fundspnoise()/hold()unused — all noise is the in-repo counter RNG; analysis FFTs constructFftPlannerScalarexplicitly; fundsp presets use theprelude64constructors (f64 internal state, f32 node interface); ebur128 true/sample peaks return linear amplitude — we convert to dBTP/dBFS via libm, and-infloudness 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 onfdn()internally, which flips thread-global x86 FTZ — and clippy bans can’t see call sites inside fundsp. Thereverbinsert is a Freeverb-style Schroeder (cochlea-synth), pure arithmetic per tick, tail ~2.5 s. Envelopes are likewise our own piecewise-linear ADSR evaluated throughfundsp::envelopeclosures (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.ronfiles).Param(&'static str | owned)newtype with constsParam::CUTOFF_HZ,Param::GAIN,Param::PAN,Param::custom("..."). Snake_case strings in RON.- Automation:
keys![...]expands to fenestra-anim keys; positions arePos/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 keepsPos/Durauthoring 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 implementsscore::Catalogfor lints.Insertpresets: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 viaTempoMap::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_atof 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 isnote span + release tailcomputed statically. Voices tick sample-by-sample (AudioUnit::tick), never fundsp blockprocess()— 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 == serialtested 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 inverify::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. CLIcochlea render score.ron --verifyruns 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)
metronome— click track; unit test asserts scheduled events land sample-exact; probe onsets within 2 ms.chord_pad— chord progression onchord_pad; chroma/key assertions.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, toolchain1.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:
- Determinism. IEEE 754 fully specifies denormal (subnormal) results
for
+ − × ÷andsqrt. 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 theunsafeneeded to touch FPCR ourselves). - 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.
- 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.tomlpins1.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 setRUSTFLAGScodegen options that relax float semantics. - No implicit FMA: Rust never contracts
a * b + cinto fma by default;mul_addis in the clippydisallowed-methodsban list so fusion is always an explicit,#[expect]-documented choice. - Std float transcendentals are banned in this workspace by
clippy.tomldisallowed-methods(delegated to platform libm — different results per OS/libc). DSP code uses thelibmcrate, which is pure-Rust and bit-stable across platforms. Stdsqrtis 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, explicitFloor | Ceil | Round(ties up), panics onc == 0and 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 bypartition_point(integer), progressu = (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* + - /andclamp— no transcendentals. Deterministic everywhere.Ease::Spring(src/spring.rs): std f32exp/cos/sin/sqrt— platform libm, NOT cross-platform bit-stable. v1 rejects springs on automation tracks at validation (also for the seconds-grounding reason indocs/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/processare hardcoded toFrame<f32, _>(audionode.rs:75,79);prelude64only 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, inrender. We use theprelude64node constructors where offered (f64 internal accumulators cost nothing offline). tick()andprocess()provably diverge.Sine::tickcomputessinvialibm::sinf(oscillator.rs:71→lib.rs:480);Sine::processcomputes it viawide’s f32x8 SIMD polynomial (oscillator.rs:82→lib.rs:632) — different algorithms, no bit-equality contract. SIMDfloor/ceilare even approximations ((x ± 0.4999999).round(),lib.rs:326-332). Rule: voices tick sample-by-sample;AudioNode::process/AudioUnit::processare in the clippydisallowed-methodsban list. One code path, one rounding story.- Scalar transcendentals all route through libm — exhaustively
confirmed:
Num/Float/Realimpls for f32/f64 calllibm::{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 overrideprocess()anyway — the divergence risk is concentrated in oscillators likeSine; the tick-only ban covers everything uniformly. - No entropy anywhere. Zero hits for
SystemTime|thread_rng|rand::|Instant|getrandom|OsRngacross the crate; noranddependency. All node seeding derives from the structural graph hash chained throughping()/AttoHash(deterministic function of node IDs and combinator positions), overridable per node viaSetting::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_denormalsin the clippy ban list). Verified consequence: every fundsp stereo reverb constructor is off-limits —reverb_stereobuildsfdn::<U32>directly (prelude.rs:1755) andreverb4_stereo_delaysbuilds twofdns (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 capturesattacked+ twoSharedcells thatreset()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/Sequenceruse 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 staticAn<_>graphs and never usesNetcommit APIs.- funutd (fundsp’s RNG dep) stays out of our signal path: fundsp’s
noise()/pluck()/hold()nodes (funutd-seeded, andPluck’s excitation is a statefulfunutd::Rndreplay) 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 customAudioNode) rather than fundsp’sPluck. 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=nativeanywhere.
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 useMode::I | Mode::TRUE_PEAK(TRUE_PEAK implies SAMPLE_PEAK and M). Feed interleavedadd_frames_f32(&[f32]). Default 2-channel map is[Left, Right]— correct for us, noset_channelneeded. - Readouts:
loudness_global()→ LUFS (needsMode::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 via20·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_*returnOk(-inf), peaksOk(0.0). The features crate maps-infto a JSONnull-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-peakfeature (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 belowf64::EPSILONat 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
FftPlannerScalarexplicitly, everywhere (rustfft::FftPlanner::newis 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
featuresandspectro— 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)
Bpm(f64)→ integer nanoseconds-per-quarter (round), once, at authoring/parse time. The stored score is exact from then on.- 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. - Sample → tick (block starts → automation domain): same segment math with
Rounding::Floor(a block start belongs to the tick it is inside). - 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-flacleft-justifies every sample into 32 bits (its own documented “common denominator”), so the crate divides by2^31unconditionally — exactly equal to the WAV path’s per-depth2^(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 vialibm; the onsets-grade STFT they share comes from the sameFftPlannerScalarpath as everything else; autocorrelation and the novelty kernel use fixed ascending summation order; every float sort usestotal_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_wavrejects non-finite samples at ingestion (NonFiniteSample), so no analyzer ever sees one. Degenerate options (NaN window/frame lengths, non-finite silence floors) are guarded withis_finite()checks — a plain<= 0.0range 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.
| Tool | Arguments | Returns |
|---|---|---|
render_score | score_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_audio | audio_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. |
spectrogram | audio_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_score | score_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_digest | audio_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_diff | audio_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.