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; v3 split tempo/rhythm — 0.2.0 Deviation ledger below applies):
{
"schema_version": 3,
"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.79, "stability": 1.0,
"candidates": [ { "bpm": 110.3, "salience": 0.79 }, { "bpm": 55.1, "salience": 0.89 } ],
"beat_count": 32, "mean_beat_interval_ms": 545.4 },
"rhythm": { "grid_alignment": 0.98, "offbeat_ratio": 0.56,
"onset_rate_per_s": 2.79, "clear_rhythm": true },
"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.
0.2.0 deviations from this plan (2026-07-21)
- Tempo/rhythm split (schema v3).
tempo.confidencebecame pulse clarity (normalized autocovariance),tempogainedcandidatesandstability, andclear_rhythmmoved to a new grid-basedrhythmsection (grid_alignment,offbeat_ratio). The v2 confidence sketch above is superseded; the drum-groove demo now assertsHasClearRhythm(true). - Verify additions:
GridAlignmentAtLeast,BrightnessRises/BrightnessFalls(render-side sweep verification over the stem’s spectral centroid — the output-side companion toMonotone, which remains authored-curve-only by design). - Synth: eight presets (
kick,snareadded);chord_padis stereo (±0.35 constant-power saw spread). - Self-describing reference:
cochlea_score::authoring_referencefeedscochlea reference, the MCPscore_referencetool, and the book’s Score Format page, pinned together by tests. - MCP: seven tools; inline image content for spectrograms
(
out_pathoptional);--rootconfinement; canonical-path clobber guards. - Structure detection computes a banded similarity matrix with a deterministic frame-count cap (the O(n²) full matrix is gone).
0.3.0 deviations from this plan (2026-07-22)
- Melody + timbre (schema v4).
pitch.melody(quantized note events off the YIN track — the compose loop’s read-back half) and a top-leveltimbreMFCC digest;CompareReportv3 adds a spectral-shape distance andrhythm.grid_changed. - Triplet grids. Rhythm alignment tests straight-16th and
eighth-note-triplet hypotheses and reports the winner (
rhythm.grid) — swing is recognized, not scored sloppy. - The zoom lens.
Audio::window+--from/--toon probe/spectro/ diff (andfrom_s/to_son the MCP tools);source.start_msanchors windowed reports. - Spectro: analysis overlays (beats/onsets/pitch as plain data) and signed A→B difference heat maps.
- Master bus. The score IR gains
Master/Limiter(RONmaster:); the render bus is nowΣ stems → master → f32, byte-inert when absent. “Bus routing” stays out of scope — this is one fixed output stage, not routing. - Lossy probe input. mp3/ogg decode via symphonia (“compressed-
format probe (phase 2)” partially delivered) — analysis input only,
contract documented in
cochlea-decode. - MIDI import (
import_midi/cochlea import/ MCP tool): “MIDI import/export, out of scope for v1” is half-lifted — import only, hand-rolled SMF 0/1, timing exact, instrumentation a labeled guess. Export remains out of scope. - MCP: eight tools (
import_midi), window/annotate params, inline diff heat maps.
Still deliberately out (design questions, not omissions): sampled instruments (external sample bytes vs the score-is-the-audio contract — needs content-hash pinning and a resampling determinism decision before any code), tempo ramps, time-signature changes, sample-accurate automation, bus/send routing, realtime anything.
0.4.0 deviations from this plan (2026-07-24)
- Harmony (schema v5). A top-level
harmonysection: a chord timeline (template-matched from the shared chroma STFT) plus per-section key — the two questions the single globalkeycan’t answer (“what’s the progression”, “what key is the bridge in”). Alsoloudness.short_term_max_lufsand a standaloneloudness_timeline(the dynamics curve), and downbeat fields on the fullTempoReport(beats_per_bar,downbeats_ms,bar_beat_at). - MIDI export. The other half of import:
export_midi/cochlea export/ the MCPexport_miditool. “MIDI import/export, out of scope for v1” is now fully lifted — timing exports exactly, instruments become rough GM labels (the inverse of the importer’s family mapping). - Integer PCM output. 16/24-bit WAV via
--bits(WavBitDepth), for a small ordinary file; float32 stays the lossless ground truth. fm_bellpreset. The palette’s ninth voice and its first non-subtractive one — harmonic FM with an automatablebrightness(modulation index), answering the “rich ears, thin voice” critique that every prior voice was a filtered saw/square.- Python bindings.
bindings/python(pyo3 + maturin): anassert_audiofluent API and a pytest plugin. A detached crate, deliberately outside the determinism build. - Golden-audio eval.
cochlea evalscores a directory of candidate renders against a directory of goldens by filename, exit 1 on any regression — the generative-model regression harness, plus a GitHub composite action. - Hardening (adversarial-review fixes). Authored ticks are bounded at
Ticks::MAX, the read path caps decoded samples and rejects degenerate audio shape, and the MCP server contains any tool panic withcatch_unwindinstead of dying. - MCP: nine tools (
export_midiadded).
0.5.0 (2026-08-06)
- Two non-subtractive voices.
marimba(modal — a struck bar as a fundamental plus tuned octave partials, faded out before retirement so the ring never clicks) andorgan(additive — a drawbar harmonic stack). The palette is now eleven presets, three of them non-subtractive (withfm_bell). - Timeline surfaces.
cochlea probe --loudness/--beatsand the MCPloudness_timeline/beat_gridtools expose the loudness-over-time curve and the full beat grid (every beat, downbeats, candidates, stability) — the per-time detail the compact report summaries drop. MCP is now eleven tools. - Overwrite guard generalized. One shared
same_file(raw-or-canonical) now backs every CLI write guard (probe/diff/import/export/render), closing an aliased-path data-loss hole the old raw-string compare left open.
0.6.0 (2026-08-07)
transcribe: the audio→score arrow.cochlea transcribeand the MCPtranscribe_audiotool turn a rendered or recorded file back into an editable RON score — melody note events read against a detected (or given) tempo, quantized to a note grid, with velocity estimated from peak level. Monophonic by construction, and every guess surfaces as a warning. MCP is now twelve tools.- The conversion lives in
score(transcribe.rs) and takes plainNoteObservationdata, never audio — so the dependency law is untouched:featuresandscorestay independent leaves, and the front ends (CLI, MCP) do the five-line join. This is the same “receives plain data, never the other crate’s types” pattern the spectrogram annotation path already uses, pointed the other way.
- The conversion lives in
- Malformed-MIDI hardening. A time-signature denominator exponent past 31 overflowed an unchecked shift in the importer (panic in debug, garbage in release), reachable by a single byte flip. Now checked, warned, and covered by a dedicated malformed-input suite.
0.7.1 (2026-08-13)
An adversarial pass over 0.7.0’s own fixes.
- Presence, not existence. 0.7.0’s stem-path containment check was
guarded by
if path.exists(), andexists()follows a symlink — so a link whose target did not exist yet reported “nothing here”, skipped the check, and was followed byFile::createregardless, creating the stem outside the stems directory and outside--rootat exit 0. The test is nowsymlink_metadata(lstat), and a link that cannot be resolved is refused rather than guessed at. General lesson for this workspace: when the question is “is something sitting at this path”,exists()is the wrong call — it answers “does the far end exist”. - One same-file rule, and it folds case. 0.7.0 taught the stem set
that
Leadandleadare one file on macOS and Windows, but left the guard between a stem and the mix/report/score comparing paths exactly — so--out d/Lead.wav --stems dwith a track namedleadstill destroyed the mix at exit 0, on the platform most of this project’s development happens on. The rule iscochlea_render::same_target_file, enforced on every platform for the same portability reason the stem-name rule is (seedocs/determinism.md). - The overwrite sweep finally reaches every subcommand.
spectroandevalwere never wired intosame_file—spectro audio.png --out audio.pngdecoded the audio and wrote the PNG over it (the.wavcase is refused by the PNG encoder, which looks like a guard and isn’t), andeval --json d/a.wavreplaced a golden candidate with its own report after reporting that the candidate passed. - Sharing a rule is not sharing a check. The fix above put the
comparison in one place and left the resolving in front of it to each
caller — so the Python
spectrogrambinding, a third front door onto the same call, had no guard at all, and the MCP server’ssame_fileand the CLI’ssame_filewere two functions with one name and different semantics.cochlea_render::same_fileis now the whole predicate, resolving included, and all three front ends call exactly it. This is the same lesson as the one below it, found one level up: a rule applied per-call-site gets missed at the next call site, and “shared helper” is not evidence that it wasn’t. - A fallback that gives up is a guard that answers wrong. The path
resolver canonicalized the parent when the file did not exist yet and
returned the path untouched when the parent did not exist either —
exactly the case where a run creates its own output directory, so
--stems ./new --report new/lead.wavcompared two spellings of one file as different and let the report land on a stem. The fallback folds components lexically now. The direction of a wrong answer is the whole design here: lexical folding can only merge two spellings (refusing a pair that would have been fine), never split one file into two. - The position path is bounded where positions are made.
Pos::resolvemultiplied(bar - 1)byticks_per_barunchecked, and neither factor was bounded —baris au32from the file and the time signature’s beats-per-bar had no ceiling — so a crafted score overflowedu64(panic in debug, silent wrap in release). Separately,Score::resolvenever appliedTicks::MAX, so a far-futureverify:position reachedmul_divat render time and panicked after the mix was written. Every position now resolves through one bounded path — both arms, grid and raw: bounding the grid alone closed the RON route and left the same panic a line above it in the Rust API, sinceTicksis a public newtype over a publicu64andverify(...).silent_after(Ticks(1 << 40))goes straight intoScore::resolve. Bounding the funnel retired the per-buildercheck_tickcalls entirely — one bound, one place — with each builder now passing only the noun for the error message (“tempo change”, “automation key”), since a shared check should not cost a specific message. What stays at the builder is the note end check:at + duris not a position, and a raw-tickDuris unbounded. - Bound the input, don’t clamp the output.
TimeSignature::validateaccepted any nonzero numerator, andexport_midithen squeezed it into the SMF meta event’s single byte withunwrap_or(u8::MAX)— a score that said 300/4 exported a file that said 255/4, silently. The bound belongs at the door (beatsis 1..=255 now, named after the byte that has to hold it): one check replaces a truncation, an unreachable bar 2, and an error message that advertisedu32::MAXas a legal value. - A
pub fnthat hardens its arithmetic has to check what it divides by.Pos::resolvegained checked multiplication and a tick ceiling while still callingticks_per_beat—whole_note_ticks / unit— on a caller-builtTimeSignaturewith public fields, before any validation.unit: 0was a division-by-zero panic in the function that had just been hardened against untrusted numbers.
0.7.0 (2026-08-11)
- Stem names are validated as file names.
write_stems_asderived<dir>/<track>.wavfrom a track name without checking it, and a track name is free-form score data (hand-authored, or lifted verbatim from a MIDI track-name meta event by the importer).Path::joindiscards the base for an absolute argument, so a path-shaped track name wrote outside the stems directory — and, over MCP, outside--rootwhile every path argument stayed inside it. The rule is now one public function,cochlea_render::stem_file_name(one ordinary, portable file name — separators,:,<>"|?*, control characters and the Win32 device names all refused on every platform, so a score means the same thing on every host), applied at the write sink and pre-flighted by both front ends so nothing is written when a name is refused. The same “one public rule, several callers” shape asBpm::validateandvalidate_window_ms. - Containment is checked at the path, not just the name. A code review
of the above found the name rule was necessary but not sufficient: a
symlink already sitting at
<stems>/<track>.wavis followed byFile::create, so a legitimate name still wrote outside the stems directory and outside--root(reproduced).write_stems_asnow canonicalizes the stems directory and resolves any existing stem target before writing, refusing one that lands outside — the same treatmentToolCtx::resolve_writegives the MCP server’s own path arguments, which stem paths never passed through. A link that stays inside is left alone. - The output-collision guards were completed.
same_filecanonicalized both sides, which fails for a path that does not exist yet, so for two outputs it degraded to a raw string compare and--out d/stems/lead.wav --stems d/stems/../stemsdestroyed the mix at exit 0; it now resolves through the canonical parent plus file name. The CLI gained the missing stem-vs-score pair (a score need not be named.ron), and the MCPrender_score, which had no stem-collision guard at all, gained stem-vs-out_pathand stem-vs-score_path.