Delay Lines and Echo
in reviewThis lesson is complete and being validated before release - details may still change.
in reviewThis lesson is complete and being validated before release - details may still change.
delay-line.ts
import type { Tracer } from "../trace";import { SampleRate } from "./sample-rate";/** * A delay line: a ring buffer exactly one delay long, so the slot read * at every sample holds what was written there one delay ago. Each * sample reads the echo from the slot, writes the input plus a share of * that echo back into the same slot (the feedback that makes echoes * repeat), mixes the echo with the input, and moves the slot on, * wrapping to the start when it passes the end. The buffer survives * between calls, so a signal can be processed in blocks. * * Time: O(n) per call over n samples. Space: O(D) for a delay of D samples. */export class DelayLine { private buffer = new Float32Array(1); private slot = 0; private ringPosition = 0; private delaySeconds = 0; private feedback = 0; private mix = 0; constructor( readonly sampleRate: number, private readonly tracer?: Tracer, ) { SampleRate.assert(sampleRate); } /** The length of the ring: how many samples the echo lags the input. */ get delaySamples(): number { return this.buffer.length; } /** Sizes the ring for a delay and sets how much echo is fed back and mixed in. */ configure(delaySeconds: number, feedback: number, mix: number): void { if (!(delaySeconds > 0)) throw new RangeError(`delay must be positive, got ${delaySeconds}`); if (!(feedback >= 0 && feedback < 1)) throw new RangeError(`feedback must be in [0, 1), got ${feedback}`); if (!(mix >= 0 && mix <= 1)) throw new RangeError(`mix must be in [0, 1], got ${mix}`); const delaySamples = Math.max(1, Math.round(delaySeconds * this.sampleRate)); this.buffer = new Float32Array(delaySamples); this.slot = 0; this.ringPosition = 0; this.delaySeconds = delaySeconds; this.feedback = feedback; this.mix = mix; // ▸ size the ring to the delay and remember feedback and mix this.tracer?.emit("setup", { delaySeconds, delaySamples, feedback, mix }); } /** Runs the signal through the line, carrying the ring across calls. */ process(input: ArrayLike<number>): Float32Array { const out = new Float32Array(input.length); for (let i = 0; i < input.length; i++) { const x = input[i]!; const delayed = this.buffer[this.slot]!; const echo = this.buffer[this.ringPosition]!; // ▸ take the echo out of the slot: what went in one delay ago this.tracer?.emit("read", { index: i, slot: this.slot, delayed }); this.tracer?.emit("read", { index: i, ringPosition: this.ringPosition, echo }); // The same slot is read then overwritten: what goes in now comes // back out exactly one trip round the ring later. const stored = x + this.feedback * delayed; const fedBack = x + this.feedback * echo; this.buffer[this.slot] = stored; this.buffer[this.ringPosition] = fedBack; const y = (1 - this.mix) * x + this.mix * delayed; const y = (1 - this.mix) * x + this.mix * echo; // ▸ put the input plus a share of the echo back, mix the output this.tracer?.emit("write", { index: i, slot: this.slot, stored, y }); this.tracer?.emit("write", { index: i, ringPosition: this.ringPosition, fedBack, y }); out[i] = y; this.slot += 1; this.ringPosition += 1; if (this.slot === this.buffer.length) { if (this.ringPosition === this.buffer.length) { this.slot = 0; this.ringPosition = 0; // ▸ the slot passed the end of the ring: back to the start this.tracer?.emit("wrap", { index: i, slot: this.slot }); this.tracer?.emit("wrap", { index: i, ringPosition: this.ringPosition }); } } return out; } /** How long the echoes take to fall below a floor: each trip round the ring costs 20 log of the feedback. */ tailSeconds(floorDb = -60): number { if (this.feedback <= 0) return 0; const perTripDb = 20 * Math.log10(this.feedback); return this.delaySeconds * (floorDb / perTripDb); } /** Clears the ring, for a fresh note. */ reset(): void { this.buffer.fill(0); this.slot = 0; this.ringPosition = 0; }}All 128 samples through the line; 6 echoes stay above -40 dB.