Dev tooling & process · Evaluation harness · v0.2.0 · Private
Replicator A sound-to-synth bench with an ear-judged golden set
A bench that measures a short recording (a camera shutter, a click, an interface beep) into a recipe of synth steps, which a procedural Web Audio synth plays in place of the audio file. Changes are checked against a regression set of sounds a person accepted by ear.
Status
Working build. 20 commits on 13–15 Sep 2026, quiet since; 2,384 lines of first-party source.
Version
0.2.0, an annotated tag from 14 Sep 2026. Seven later commits, six of them adding capability, are not yet tagged.
Numbers can’t decide whether a sound is right, so acceptance is a listening test. Everything around that test is scripted: an accepted sound can’t get worse without the next batch run failing.
At a glance
ear-accepted sounds re-ran within 7.4×10−7 dB of their four-week-old baselines, in two independent runs
6/6
more than this much worse on any accepted sound, at any of 4 envelope-simplification levels, fails the run
0.05dB
synth steps for a 372 ms camera shutter, scoring 1.71 dB against a 1.76 dB noise floor
18
runtime dependencies: the FFT, filters and trackers are hand-written
0
How these were measured
6/6: two independent headless runs of the bench’s own batch page in Google Chrome 154 on 12 Oct 2026, served from a byte-identical copy of the bench so nothing was written into the project, then scored with the verdict rule in server/runs.ts against the six baselines accepted on 14 Sep 2026. Largest change: 6.95×10−7 dB in one run and 7.41×10−7 dB in the other.
0.05 dB: REGRESS_DB = 0.05 in server/runs.ts; the verdict checks both the band and the fine score at every rung of EPS_LADDER = [0.5, 1, 2, 4] in src/mimic.ts, and flags a regression only when the worst change is greater than the limit.
18: the shutter’s result from the same runs: 16 bands from 300 Hz to 11.0 kHz, 18 pieces, band score 1.715 dB against a floor of 1.763 dB (the status line rounds these to 1.7 and 1.8), fitted in 2.6–2.7 s on an Apple M5 Max laptop.
0: package.json has no dependencies key, only four dev dependencies (@types/node, playwright-core, typescript, vite); the radix-2 FFT is fft() in src/mimic.ts.
Also measured that day: tsc -p tsconfig.json --noEmit under strict TypeScript 5.9.3 reports 0 errors; git rev-list --count HEAD gives 20 commits; wc -l over the first-party source gives 2,384 lines.
Fig. 1A 372 ms camera shutter (“The MP3”) and the 18-step recipe that replaces it (“The recipe”), each read through the bench’s own filter bank: time runs left to right, low bands at the bottom. The recipe scores 1.71 dB against a 1.76 dB noise floor. Captured from the bench in headless Chrome, 12 Oct 2026.
The short version
Measure the sound; don’t search for it. A calibration loop renders every recipe through the real synth and reads it back through the same filter bank. A 372 ms camera shutter becomes 18 synth steps in under 3 seconds.
Print every score beside its own noise floor. The shutter scores 1.71 dB against a floor of 1.76 dB: the recipe is as close to the recording as it is to itself on fresh noise.
A human-labelled golden set, a deterministic scorer and a hard regression gate. Six ear-accepted sounds re-ran within 7.4×10−7 dB of four-week-old baselines. A change that makes any of them more than 0.05 dB worse fails the run, which is what lets AI coding agents change the fitting code.
Case 1 of 5
Measure the sound, don’t search for it
No optimiser can fit a sound the synth cannot make, so the bench measures the recording and corrects the recipe through the real synth.
A first attempt fitted a handful of short sound pieces by optimisation. It failed on a recording that was mostly flat broadband noise: the synth’s noise burst faded long before the sound did, and searching harder cannot fix a vocabulary that lacks the shape.
I rebuilt it around measurement. The recording passes through a bank of 16 log-spaced, roughly third-octave bands, from 300 Hz up to the recording’s own top frequency. The bank uses the same RBJ biquad coefficients as Web Audio’s bandpass filter, so the analysis matches the synth’s own filters. Each band’s envelope becomes a noise step, each confirmed resonance a sine, and each hard onset a 3 ms click.
Then the loop closes. The recipe is rendered through the real synth class into an OfflineAudioContext, read again through the same bank, and its gains and envelopes are corrected toward the recording; the best pass is kept. The copy button hands out exactly what was scored, to four significant figures and 10 µs.
Fig. 2The calibration loop. The recording is read once; each pass renders the recipe through the real synth on four noise seeds, reads it through the same filter bank, and corrects it toward the recording. Analysis and playback share one filter design, so every comparison is like with like.How we know
Read in src/mimic.ts: bandpass() (Web Audio’s RBJ constant-peak-gain form), makeBank(), render() through the synth in src/synth.ts into an OfflineAudioContext, measure() over SEEDS = [1, 2, 3, 4], and the keep-best calibration in fitOnce(). Timed in two headless runs on 12 Oct 2026: the 372 ms shutter became 18 steps in 2.6–2.7 s.
Case 2 of 5
Cancel the instrument’s error by measuring through it
A narrow filter smears every tone it reads, so the bench reads a probe through the same filter and matches the probe instead; the smear cancels.
Reading a tone through a narrow band gives a false picture. The filter delays and smears the onset (by about 10 ms at 400 Hz) and adds its own ringing to every decay, so a beep that switches on instantly reads as a 40 ms rise, and a resonance seems to ring longer than it does.
Rather than model that bias, the bench cancels it. It renders a solo probe tone, reads it through the identical band in the identical way, and, in two passes, moves the probe’s onset, offset, attack and level onto the recording’s reading. Decay times are corrected the same way. Whatever the filter does, it does to both.
The same idea decides what counts as a resonance: an impulse through that exact band gives the band’s own ring time, and a candidate must ring at least 1.5 times longer, and for at least 20 ms. Peaks are interpolated between FFT bins, because bin centres alone were up to ±11.7 Hz off, which is audibly out of tune at 500 Hz.
How we know
Read in src/mimic.ts: findTones() runs the probe loop twice, moving the −10 dB crossings and the −20 to −3 dB rise onto the recording’s and setting gain from the probe’s peak; findModes() measures the band’s ring time from an impulse (the threshold is the larger of 20 ms and 1.5 times that), interpolates peaks between bins, and corrects each tone’s decay by the probe’s ratio of read to target. In the 12 Oct 2026 runs the shutter’s 1,883 Hz resonance was promoted to a sine and a 9.9 kHz candidate was rejected.
Case 3 of 5
A score that states its own floor
A distance means nothing without a reference, so every headline score is printed beside the distance that noise alone produces.
The floor is the same distance measured between two plays of the same recipe on different noise seeds: how far the reading moves when nothing changes but the noise. On the shutter, in the 12 Oct 2026 runs, noise alone moved the reading 1.76 dB and the recipe sat 1.71 dB from the recording: inside the floor.
The band score had a blind spot. At third-octave resolution, a held tone and a band of noise with the same envelope look alike, so I added a second, 1/12-octave “fine” score. For recordings with a low end it uses an 8,192-point transform below 300 Hz, because 2,048-point bins lumped a 32 Hz buzz in with its harmonics. The fine score reports and gates, but it never steers the calibration.
Two diagnostics show where a recipe differs: a per-band residual for the first 60 ms and for the rest, and an explain mode that mutes each piece in turn to rank which ones make a chosen window too loud. For the listening test itself there is a blind X/Y test: a coin flip hides which button plays the recording, and a tally counts correct guesses.
How we know
Read in src/mimic.ts: score(), measureFloor() (a render on a separate floor seed against the scored one), makeFine() and fineSpectrum() (8,192-point cells below 300 Hz when the bank reaches that low), the residual in present(), whichPiece() for explain mode, and the blind X/Y block. Shutter, 12 Oct 2026: band 1.715 dB against a floor of 1.763; fine 3.62 against 2.71.
Case 4 of 5
Let the measurement decide, with no classifier
Three tests for “is this peak a tone?” let the shutter’s noise through, so the bench fits every file several ways and keeps tones only when they measurably help.
Beeps need sine tones and a shutter needs noise. The obvious design is a classifier that labels each spectral peak, and three were tried. The shutter’s noise passed all three.
So every file is now fitted in full several ways: plain noise bands; short tones from a fast tracker on a 5 ms hop; steady partials from an 8,192-point tracker at two prominence gates; and merges of these. The 8,192-point tracker can hold two tones 12 Hz apart as a beating pair. A tone set survives only if it improves the fine score by at least 0.25 dB and at least 15%, without costing more than 0.25 dB of band score; the closest survivor wins. A file that keeps tones mutes its clicks, then tries removing each noise band, quietest first, and keeps a removal only if the fine score does not rise and the band score stays within 0.25 dB of where the pass began.
In the 12 Oct 2026 runs, 7 of the 10 test recordings kept held tones and 3 did not, each by its own measurement. The shutter rejected all five tone sets: the best moved its fine score about 5%, short of the 15% bar. An interface beep kept six steady tones and finished in 8 steps.
Fig. 3An interface beep (“The MP3”) and its 8-step recipe of 6 sine tones and 2 noise bands (“The recipe”), fine score 0.86. The tones come in close pairs that beat; the 8,192-point steady tracker found them. Headless Chrome, 12 Oct 2026.
Fine score for each set of held tones tried on that beep (lower is closer; tone count in brackets). Every set passed the keep rule; the closest was kept, then its clicks were muted and five noise bands pruned. Headless run, 12 Oct 2026.
Noise bands only
4.05
Short tones (12)
2.35
Steady tones (6), kept
1.80
Short and steady merged (9)
2.36
Steady, lower gate (10)
1.92
Merged, lower gate (18)
2.09
Kept set, clicks muted, 5 noise bands pruned
0.86
How we know
Read in src/mimic.ts: fit() builds the candidate sets and applies the keep rule (TONE_KEEP_DB = 0.25, TONE_KEEP_FRAC = 0.15), then keeps the passing set with the lowest fine score and, when tones are kept, mutes every click before pruning; findTones() tracks on a 5 ms hop; findSteady() runs at prominence gates of 12 and 8 dB; prune() tries every band loud enough to be in the recipe, quietest first, against one band-score limit for the whole pass. Counts and scores from both headless runs on 12 Oct 2026; the shutter’s best set (53 short tones) moved its fine score from 3.62 to 3.43.
Case 5 of 5
A regression set judged by ear, guarded by a deterministic runner
Seeded renders, a runner that drives the bench’s own page, and a gate that treats a missing recording as a regression: a run fails if any accepted sound measures worse than its baseline allows.
Renders are deterministic: Math.random is swapped for a seeded generator around the synchronous play call, so rerunning unchanged code on an unchanged recording reproduces every score to within 10−6 dB.
The batch runner starts its own dev server and drives the bench’s own page in headless Chrome, so no second copy of the measurement can fall out of step. Each recipe is scored at four compaction rungs, the tolerances (0.5 to 4 dB) to which its envelopes are simplified. The run fails if any file fails, or if any accepted file is more than 0.05 dB worse on either score at any rung. A missing recording counts as a regression, so deleting a recording cannot remove its guard. Files join the set only by name, through a script that demands a written reason and, whenever it replaces a baseline, prints the old scores beside the new; there is no accept-all.
That is the shape of an evaluation harness for a generative system: a human-labelled golden set, a deterministic scorer with a stated noise reference, and a hard regression gate. It is also what makes the fitting code safe to hand to the AI coding agents I direct: a change that makes an accepted sound worse fails the run.
Envelope points the shutter’s recipe keeps at each compaction rung (fewer points, smaller recipe), with the band score there. The gate checks all four rungs. 12 Oct 2026, matching the accepted baseline.
0.5 dB · band 1.715
1,707
1 dB, the default · band 1.715
1,353
2 dB · band 1.712
945
4 dB · band 1.763
604
How we know
Read: render() in src/mimic.ts swaps a mulberry32 generator into Math.random; scripts/batch.mjs exits 0 when clean, 1 when a file failed or regressed, and 2 when the runner itself broke; verdict() in server/runs.ts applies the 0.05 dB limit to both scores and treats a missing recording as a regression; scripts/accept.mjs accepts by name, requires a reason, has no --all, and prints old and new scores whenever a baseline is replaced. Re-verified 12 Oct 2026 in two independent headless runs of the bench’s batch page in Chrome 154: 10 of 10 fitted, 0 failures, 0 regressions, all six accepted files within 7.4×10−7 dB of baselines accepted 14 Sep 2026. Those runs applied the same verdict rule to a byte-identical copy of the bench, because the project’s own runner would have written a report into it.
The bench
Fig. 4Basic view on the shutter: the status line, the compaction ladder, both pictures, the blind X/Y test, and the recipe exactly as the copy button hands it out. Opens full size.
Fig. 5Advanced view: the residual (red where the recording is louder, blue where the recipe is), every piece with mute and solo, and the 1,883 Hz resonance promoted beside the candidates it rejected. Opens full size.