Explore the blurred repeats and moving pitch of bucket-brigade effects, including classic chorus modes.
What it is#
Three surfaces over one circuit layer (engine/src/voice/modfx.rs BBD helpers + engine/src/effect.rs):
sig |> bbd amount— voice-level dark-pedal BBD: mono wet circuit, stereo-preserving dry path.sig |> bbd amount "i"|"ii"|"i+ii"— voice-level Juno chorus (stereo).track |> bbd amount ["mode"]— the same Juno recipe ONCE on the summed bus with a free-running clock (the hardware placement).track |> bbdDelay time feedback [wet]— bucket-brigade analog delay.sig |> ensembleChorus amount/track |> ensembleChorus amount— the Solina/ARP string-machine TRIPLE chorus (see its own section below).
Circuits modeled & committed behaviors#
Clock integration (all paths). A BBD's delay is its clock's integral (∫ c dt = N buckets), so every tap position evolves by the transport ODE D' = 1 − c(t)/c(t−D) (voice/modfx.rs::bbd_clock_step) instead of scrubbing a read pointer. Instantaneous pitch shift is exactly ∝ the clock derivative — the Juno triangle clock still yields the square-wave pitch deviation (measured by the I/Q lock-in test in dsp_validation.rs), now with the transit-time asymmetry a scrubbed pointer misses.
NE570 compander (bbd generic + bbdDelay). Syllabic 2:1 compress-into / expand-out (voice/modfx.rs::ne570_compress/ne570_expand): feedback-sensed compressor, feedforward expander, one shared rectifier time constant (τ = 20 ms — the chip's single-cap design), envelope floor bounding makeup at ≈+22 dB. The detector lag is the audible transient "breathing"; the expander floor ducks line noise with the signal. The Juno paths carry no compander — the hardware omits it (committed behavior, unchanged).
Filters. bbdDelay: anti-alias + reconstruction one-poles at clock/3 (f_clk = N/(2·delay), MN3005-class N=4096, clamped 0.8–12 kHz) — long delays get proportionally darker, realizing the accumulating per-bucket HF loss in feedback form. Generic bbd chorus: fixed ~4.5 kHz pair — the dark-pedal choice, documented deliberately (its MN3007-class clock sits ≈40 kHz, where a tracker would be inaudible; cheap pedals filter conservatively regardless). Juno: fixed 2×8 kHz LP + 10 Hz HP (hardware-style fixed filters). Juno/ensemble self-noise is generated from independently keyed channel/tap streams and injected before these reconstruction banks, so the hiss shares the modeled BBD bandwidth instead of remaining bright, full-bandwidth and locked at the stereo centre.
Clock feedthrough (bbdDelay). A tone at the instantaneous clock frequency once the clock drops into the audible band (long delays), faded out approaching Nyquist so the leak itself cannot alias. The −54 dBFS source level is attenuated by the reconstruction bank's response at the clock frequency (2026-08-03): the hardware's feedthrough passes through the output filter, and since the corner tracks clock/3 a 4th-order Butterworth leaves ≈−38 dB of it — a faint presence under the echo. Injected post-filter at full level it was a constant, clearly audible whine (phosphene's 0.375 s bbdDelay put the clock at 5.46 kHz / ≈−62 dBFS on the track; pinned by bbd_clock_leak_is_filtered_like_the_hardware). Activity-gated (2026-07-26, with the BbdChorus hiss floor): the self-generated leak/hiss is character only while the line carries signal (~0.25 s release envelope on |input| ∨ |stored charge|). Track chains render every block whether or not their track ever ran, so an ungated generator was an immortal ~−62 dBFS tone on a stopped song — machina's ~2.1 kHz post-stop residual came from metalTrack's 1-bar bbdDelay clock. Gate: tape_loop.rs::silent_chains_generate_no_sound (+ the character-preserved counterpart just after signal).
Seeding. Voice-path hiss derives independent channel/tap reads from the engine's coordinate-seeded noise source; the BbdChorus insert hashes its own coordinate (module seed + track name via rng::draw) and separates L/R streams — independent per track and circuit, bit-identical on replay.
The triple ensemble chorus (ensembleChorus)#
The Solina/ARP string-ensemble circuit (Jürgen Haible's analysis; jpcima's open ensemble-chorus is the reference implementation): three BBD taps on one shared line, clocked by two staggered 3-phase LFO families around a slow ~0.6 Hz "chorus" and a fast ~6 Hz "vibrato". Tap k uses each nominal rate divided by (1+k/3) and a base delay multiplied by (1+0.15k), in addition to its phase offset. The trajectories therefore do not collapse back onto the same curve every 1.67 seconds. The hardware runs 100% wet; amount here is the wet crossfade, so 1.0 is the authentic ensemble.
Committed behaviors (voice/modfx.rs::ensemble_sample, shared by the voice node, the EnsembleChorus bus insert, and the analysis helper):
- Sinusoidal LFOs (
sin, not the Juno triangle): the Solina's 3-phase LFOs come off an RC phase-shift oscillator. Clock-integrated taps viabbd_clock_step2(two rates, each phase retarded by its own increment at the entry-time lookup). - Constants: first-tap centre delay 4.0 ms, chorus sweep ±0.9 ms around 0.6 Hz, vibrato sweep ±0.25 ms around 6 Hz, with the per-tap rate/base law above; per-tap band-limiting two ~6 kHz one-pole LPs + ~10 Hz HP (darker than the Juno's 8 kHz). Each tap draws its own deterministic hiss stream before that filter bank; the ensemble has no compander. All by-ear against the Haible schematic values.
- Stereo image: tap 0 → L, tap 1 → centre (both), tap 2 → R, ×⅔ so each channel's 1.5 taps sum to roughly unity. Originates a stereo pair (a mono input comes back wide); mono-fold keeps the centre tap plus the L+R sum of the outer taps — no cancellation trap. At partial wet amounts, the original dry L/R pair remains distinct rather than being replaced by its mono sum.
- Both forms free-run their LFOs across notes (the hardware places the ensemble after the divide-down voice mix, so one LFO pair serves every note). The per-voice node used to restart its phases at note-on; since the 2026-08 audit finding M2 it reads them off the absolute transport clock like the insert (
voice::free_lfo_phase) — see The effects reference §bbd. Insert hiss is activity-gated (a stopped song is silent).
The stdlib solina preset (examples/songs/circuits.volta) previously faked this with additive ensemble + a single chorus; the real triple chorus supersedes that stand-in.
Abstractions (deliberate)#
- Juno mode constants (
bbd_juno_params: 0.5 / 0.85 / 9.5 Hz, ±2.0 / ±0.4 ms, R-inversion per mode) are by-ear — no service manual is available (see Circuit Modeling Adopted constraints). A manual would pin the clock LFO rates/depths and the wet/dry resistor mix. - Compander tracking errors at extreme settings, and bucket-count-resolved transfer (charge-transfer inefficiency per stage) are not modeled.
- No oversampling: the lines run at host rate behind their anti-alias filters (Stage G revisits factors by measurement).
Measured (dsp_validation.rs)#
Square-pitch signature: triangle-clock pitch deviation flatness and 3rd/1st-harmonic ratio asserted by lock-in demodulation; stereo decorrelation L/R Pearson < 0.9 (modes i/ii), > 0.98 (i+ii); torture renders bounded/finite. Golden clips bbd_juno_i, bbd_delay in engine/tests/snapshots/golden_audio.txt.
Cost & backends#
Cost-model weights: voice bbd 4, bbdChorus insert 5, ensembleChorus 6 (node and insert — three taps + three band-limit chains) (compiler/src/cost.rs). The browser runs this exact code in the wasm engine; One audio engine across hosts rows #26 (Juno modes) and #32 (NE570/clock-integration) record what the deleted Web Audio path did instead.