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.