From 2f376262d39257cc91dd2627e377387d8d7a6393 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sat, 10 Oct 2026 23:32:49 +0200 Subject: [PATCH] Let the Reverb's screen ring out and its reflections drift (#401) The round screen of the Reverb stood still. It now moves, only for the look of it, and says nothing it did not say before. - The sound rings out again and again in real time. The dot in the middle flashes, and a thin front runs out over the rings as fast as the tail does. It fades as the tail falls, then 0.8 s of quiet. - A reflection lights up for a moment once the front has passed it. - The reflections turn slowly round the dot, each at its own pace and way (at most 0.16 rad/s). They sway 1.5 px in and out and twinkle. Their drift comes from a noise of its own, so they sit where they always did. - Rings, grid and dashed end stay one drawing. Only the dots, the front and the flash are drawn again each frame. - Nothing moves without a tail to see: no sound picked, or wet at 0. Tests: - reverb-view: 13 tests green (3 new) - e2e 34 on melektaus-atx: ALL PASSED, 144 checks - gate on melektaus-atx: 293 files, 4498 tests Co-Authored-By: Claude Opus 5.5 --- src/editor/debug/graphHooks.ts | 4 ++ src/editor/soundfx/ReverbFace.ts | 6 ++- src/editor/soundfx/ReverbPanel.ts | 63 +++++++++++++++++++++++---- src/editor/soundfx/reverbView.ts | 72 ++++++++++++++++++++++++++++++- tests/editor/reverb-view.test.ts | 46 ++++++++++++++++++++ tools/e2e/34-sound.mjs | 7 ++- 6 files changed, 185 insertions(+), 13 deletions(-) diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts index 64cdf3de..73f74224 100644 --- a/src/editor/debug/graphHooks.ts +++ b/src/editor/debug/graphHooks.ts @@ -520,6 +520,10 @@ export function graphHooks({ state, editor }: HookContext) { rings: panel.scope.rings, kind: panel.scope.tail?.kind ?? null, says: panel.scope.says, + /** The page's clock the motion is drawn at, and where the sound rings out then (#401). */ + seconds: panel.scope.seconds, + at: panel.scope.pulse?.at ?? null, + front: panel.scope.pulse?.front ?? null, }, }; }, diff --git a/src/editor/soundfx/ReverbFace.ts b/src/editor/soundfx/ReverbFace.ts index dac9f6be..82850319 100644 --- a/src/editor/soundfx/ReverbFace.ts +++ b/src/editor/soundfx/ReverbFace.ts @@ -25,7 +25,8 @@ import { /** * A Reverb node drawn as its device (#389): its tail drawn from its settings – or from the peaks of * the sound it takes – every one of them worked right in the graph. A knob its type does not take - * goes over to Custom with the numbers the type had. It listens to nothing while the sound plays. + * goes over to Custom with the numbers the type had. It listens to nothing while the sound plays; + * what moves on its screen moves only for the look of it. */ export class ReverbFace implements NodeFace, ReverbHost { readonly widget: ReverbPanel; @@ -92,8 +93,9 @@ export class ReverbFace implements NodeFace, ReverbHost { }); } + /** The sound rings out by the page's clock, and the reflections drift; nothing moves without a tail to see. */ tick(): boolean { - return false; + return this.widget.step(performance.now() / 1000); } hide(): void {} diff --git a/src/editor/soundfx/ReverbPanel.ts b/src/editor/soundfx/ReverbPanel.ts index cec80577..6c76350f 100644 --- a/src/editor/soundfx/ReverbPanel.ts +++ b/src/editor/soundfx/ReverbPanel.ts @@ -21,6 +21,11 @@ import { type ReverbKey, type ReverbTail, type ReverbValues, + driftAt, + glowOf, + type Pulse, + pulseAt, + type Reflection, reflections, SCOPE_GRID, SCOPE_MARKS, @@ -165,12 +170,15 @@ export class PickButton extends Widget { /** * The round screen: the sound as a dot in the middle, the tail as rings out from it on a scale of - * seconds, scattered reflections over it, and under it the pre-delay and the end. + * seconds, scattered reflections over it, and under it the pre-delay and the end. The sound rings + * out again and again as a front running over the rings, and the reflections drift. */ class ReverbScope extends Widget { private readonly g = new Graphics(); + private readonly motion = new Graphics(); private readonly marks: Text[]; private readonly under: Record<'pre' | 'ends', Text>; + private dots: Reflection[] = []; tail: ReverbTail | null = null; mix = 0; /** What the texts under the screen say now, and the highs above it on the bezel (checks). */ @@ -178,10 +186,13 @@ class ReverbScope extends Widget { /** How far out the tail reaches and the rings drawn (checks). */ reach = 0; rings = 0; + /** The page's clock the motion is drawn at, and where the sound is ringing out then (checks). */ + seconds = 0; + pulse: Pulse | null = null; constructor() { super(); - this.addChild(this.g); + this.addChild(this.g, this.motion); this.marks = SCOPE_MARKS.map((seconds) => this.made(markText(seconds), 8, SCOPE.dim, 0, 1)); this.under = { pre: this.made('', 9, SCOPE.text, 0, 0), @@ -225,6 +236,7 @@ class ReverbScope extends Widget { const shares = wetShares(this.mix); this.reach = 0; this.rings = 0; + this.dots = tail ? reflections(tail, R) : []; if (tail) { const rings = tailRings(tail, R, RING_STEP); for (const ring of rings) @@ -233,11 +245,6 @@ class ReverbScope extends Widget { width: RING_STEP, alpha: ring.level * shares.tail, }); - for (const dot of reflections(tail, R)) - g.circle(cx + Math.cos(dot.angle) * dot.r, cy + Math.sin(dot.angle) * dot.r, 1.2).fill({ - color: tint(dot.bright), - alpha: Math.min(1, dot.level * shares.tail * 1.4), - }); // Where it has fallen by 60 dB: a dashed ring, as there is nothing left of it to see. this.reach = scopeRadius(tail.predelay + tailSeconds(tail)); const end = this.reach * R; @@ -253,7 +260,6 @@ class ReverbScope extends Widget { g.stroke({ color: SCOPE.text, width: 1, alpha: 0.6 * Math.max(0.3, shares.tail) }); this.rings = rings.length; } - g.circle(cx, cy, 3.5).fill({ color: 0xffffff, alpha: shares.dry }); // The scale up the middle, every other mark on the other side of the line, outer ones first: // one that would still run into the last on its side is left out. @@ -277,6 +283,42 @@ class ReverbScope extends Widget { this.under.pre.position.set(0, y); this.under.ends.text = this.says.ends; this.under.ends.position.set(this.w, y); + this.drawMotion(); + } + + /** `seconds` on a clock of the page's; whether anything moves, which it does not without a tail to see. */ + step(seconds: number): boolean { + if (!this.tail || wetShares(this.mix).tail <= 0) return false; + this.seconds = seconds; + this.drawMotion(); + return true; + } + + /** The reflections where they have drifted to, the front running out and the sound flashing. */ + private drawMotion(): void { + const m = this.motion.clear(); + if (this.w <= 0) return; + const { x: cx, y: cy, r: R } = this.disc; + const shares = wetShares(this.mix); + const pulse = this.tail ? pulseAt(this.tail, this.seconds) : null; + this.pulse = pulse; + if (pulse?.front != null && shares.tail > 0) { + const color = tint(pulse.bright); + const alpha = (0.25 + 0.75 * pulse.level) * shares.tail; + m.circle(cx, cy, pulse.front * R).stroke({ color, width: 6, alpha: 0.12 * alpha }); + m.circle(cx, cy, pulse.front * R).stroke({ color, width: 1.5, alpha: 0.7 * alpha }); + } + for (const dot of this.dots) { + const at = driftAt(dot, this.seconds); + const glow = pulse ? glowOf(dot, pulse) : 0; + m.circle(cx + Math.cos(at.angle) * at.r, cy + Math.sin(at.angle) * at.r, 1.2 + 0.5 * glow).fill({ + color: glow > 0 ? mixColor(tint(dot.bright), 0xffffff, glow * 0.6) : tint(dot.bright), + alpha: Math.min(1, (dot.level * at.shine + glow * 0.5) * shares.tail * 1.4), + }); + } + const flash = pulse && shares.tail > 0 ? pulse.flash : 0; + if (flash > 0) m.circle(cx, cy, 3.5 + 5 * flash).fill({ color: 0xffffff, alpha: 0.35 * flash }); + m.circle(cx, cy, 3.5).fill({ color: 0xffffff, alpha: Math.min(1, shares.dry + flash * 0.5) }); } } @@ -372,6 +414,11 @@ export class ReverbPanel extends Widget { this.highs.text = this.scope.says.highs; } + /** Moves the screen on to `seconds` on the page's clock; whether anything on it moves. */ + step(seconds: number): boolean { + return this.scope.step(seconds); + } + override layout(): void { const L = REVERB_LAYOUT; const put = (widget: Widget, box: Box) => { diff --git a/src/editor/soundfx/reverbView.ts b/src/editor/soundfx/reverbView.ts index 1e45b361..b3a3f04d 100644 --- a/src/editor/soundfx/reverbView.ts +++ b/src/editor/soundfx/reverbView.ts @@ -269,10 +269,19 @@ export function tailRings(tail: ReverbTail, radius: number, step = 1.5): TailRin export interface Reflection extends TailPoint { r: number; angle: number; + /** When it comes, in seconds after the sound. */ + t: number; + /** How it drifts: radians a second round the sound, and the rate and start of its sway. */ + spin: number; + rate: number; + phase: number; } /** Reflections scattered over the tail, as many as a dense tail has – the same ones each time. */ const REFLECTIONS = 90; +/** How fast a reflection turns at most, in radians a second, and how far it sways in and out. */ +const DRIFT_SPIN = 0.16; +export const DRIFT_SWAY = 1.5; /** * Dots over the tail, at angles of their own and more of them the further it has gone, as the @@ -280,18 +289,77 @@ const REFLECTIONS = 90; */ export function reflections(tail: ReverbTail, radius: number): Reflection[] { const next = seededNoise(389); + // Their drift from a noise of its own, so the dots sit where they always did. + const drift = seededNoise(3890); const length = tailSeconds(tail); const dots: Reflection[] = []; for (let i = 0; i < REFLECTIONS; i++) { const along = Math.sqrt((next() + 1) / 2); const angle = next() * Math.PI; - const point = tailAt(tail, tail.predelay + along * length); + const spin = drift() * DRIFT_SPIN; + const rate = 0.6 + (drift() + 1) * 0.5; + const phase = (drift() + 1) * Math.PI; + const t = tail.predelay + along * length; + const point = tailAt(tail, t); if (point && point.level > 0) - dots.push({ r: scopeRadius(tail.predelay + along * length) * radius, angle, ...point }); + dots.push({ r: scopeRadius(t) * radius, angle, t, spin, rate, phase, ...point }); } return dots; } +/** + * Where a reflection has drifted to `seconds` on the page's clock, and how much it shines: turning + * slowly round the sound, swaying a little in and out, twinkling – only for the look of it, it + * stays at the time it comes. + */ +export function driftAt(dot: Reflection, seconds: number): { r: number; angle: number; shine: number } { + const wave = seconds * dot.rate + dot.phase; + return { + r: Math.max(0, dot.r + Math.sin(wave) * DRIFT_SWAY), + angle: dot.angle + dot.spin * seconds, + shine: 0.7 + 0.3 * Math.sin(wave * 1.9 + dot.phase), + }; +} + +/** The quiet between the end of one tail and the next sound, and how long a flash and a glow last. */ +const PULSE_REST = 0.8; +const FLASH_SECONDS = 0.25; +const GLOW_SECONDS = 0.35; + +/** Where the sound is ringing out, `at` seconds into a round of it. */ +export interface Pulse { + at: number; + /** The front's radius, 0 to 1 of the outer ring; null between tails. */ + front: number | null; + level: number; + bright: number | null; + /** The sound coming, 1 as it does to 0 a moment later. */ + flash: number; +} + +/** + * The sound again and again in real time: its front running out over the scale as fast as the + * tail does, fading as it falls, then a short quiet before the next. + */ +export function pulseAt(tail: ReverbTail, seconds: number): Pulse { + const round = tail.predelay + tailSeconds(tail) + PULSE_REST; + const at = ((seconds % round) + round) % round; + const point = tailAt(tail, at); + return { + at, + front: point ? scopeRadius(at) : null, + level: point?.level ?? 0, + bright: point?.bright ?? null, + flash: Math.max(0, 1 - at / FLASH_SECONDS), + }; +} + +/** How a reflection glows as the front has just gone by it, 1 then to 0. */ +export function glowOf(dot: Reflection, pulse: Pulse): number { + const since = pulse.at - dot.t; + return since >= 0 && since < GLOW_SECONDS ? 1 - since / GLOW_SECONDS : 0; +} + /** How strongly the tail is drawn, and the sound at its middle: the wet against the dry. */ export function wetShares(mix: number): { tail: number; dry: number } { const wet = Math.max(0, Math.min(1, mix)); diff --git a/tests/editor/reverb-view.test.ts b/tests/editor/reverb-view.test.ts index d696e996..de768884 100644 --- a/tests/editor/reverb-view.test.ts +++ b/tests/editor/reverb-view.test.ts @@ -9,7 +9,11 @@ import { REVERB_PRESETS } from '../../src/core/soundfx/reverb'; import { type Box, knobBox } from '../../src/editor/soundfx/hardwareView'; import { customFrom, + DRIFT_SWAY, + driftAt, + glowOf, idleReverbKeys, + pulseAt, reflections, REVERB_KEYS, REVERB_KNOBS, @@ -154,6 +158,48 @@ describe('the round screen', () => { expect(reflections(tail, radius)).toEqual(dots); }); + it('lets the reflections drift round the sound, slowly, staying at the time they come (#401)', () => { + const tail = made(REVERB_PRESETS.hall); + const dots = reflections(tail, 78); + const spins = dots.map((dot) => dot.spin); + expect(spins.some((s) => s > 0) && spins.some((s) => s < 0)).toBe(true); + for (const dot of dots) { + expect(Math.abs(dot.spin)).toBeLessThanOrEqual(0.16); + for (const seconds of [0, 1.3, 7.9, 1000]) { + const at = driftAt(dot, seconds); + expect(Math.abs(at.r - dot.r)).toBeLessThanOrEqual(DRIFT_SWAY + 1e-9); + expect(at.angle).toBeCloseTo(dot.angle + dot.spin * seconds, 9); + expect(at.shine).toBeGreaterThanOrEqual(0.4); + expect(at.shine).toBeLessThanOrEqual(1); + } + } + }); + + it('rings the sound out again and again in real time, the front fading as the tail falls', () => { + const tail = made({ predelay: 0.1, decay: 2, damping: 1 }); + expect(pulseAt(tail, 0)).toMatchObject({ at: 0, front: null, flash: 1 }); + const half = pulseAt(tail, 1.1); + expect(half.front).toBeCloseTo(scopeRadius(1.1), 9); + expect(half.level).toBeCloseTo(0.5, 9); + expect(half.flash).toBe(0); + // The tail ends at 2.1 s; after a short quiet the sound comes again. + expect(pulseAt(tail, 2.5).front).toBeNull(); + const again = pulseAt(tail, 2.9 + 1.1); + expect(again.at).toBeCloseTo(1.1, 9); + expect(again.front).toBeCloseTo(half.front!, 9); + expect(pulseAt(tail, -0.5).at).toBeCloseTo(2.4, 9); + }); + + it('lights a reflection as the front goes by it, and not before', () => { + const tail = made(REVERB_PRESETS.hall); + const dot = reflections(tail, 78)[0]!; + expect(glowOf(dot, pulseAt(tail, dot.t - 0.01))).toBe(0); + expect(glowOf(dot, pulseAt(tail, dot.t))).toBeCloseTo(1, 9); + expect(glowOf(dot, pulseAt(tail, dot.t + 0.1))).toBeGreaterThan(0); + expect(glowOf(dot, pulseAt(tail, dot.t + 0.1))).toBeLessThan(1); + expect(glowOf(dot, pulseAt(tail, dot.t + 0.5))).toBe(0); + }); + it('draws no tail at a wet of 0, and the sound faint but there at 1', () => { expect(wetShares(0)).toEqual({ tail: 0, dry: 1 }); expect(wetShares(1)).toEqual({ tail: 1, dry: 0.2 }); diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index a09272dc..fe339127 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -1215,6 +1215,7 @@ export async function sound(t) { // #389: a Reverb node is drawn as a device with a round screen of its tail. A knob its type does // not take goes over to Custom with the type's numbers, as one undo step; a sound picked on // it makes the type Sound and the screen draws that sound's tail. It is wired to nothing. + // #401: the screen moves by itself. await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.nodes.push(${JSON.stringify(node('fx-rv', 'SoundFxReverb', 0, 400, { room: 'hall' }))});`); @@ -1226,6 +1227,8 @@ export async function sound(t) { `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes.find((n) => n.id === 'fx-rv').params`, ); const rvShown = await reverbPanel(); + await sleep(300); + const rvLater = await reverbPanel(); const rvRect = rvShown.rect; const rvBeside = (p) => p.y > rvRect.y && p.y < rvRect.y + rvRect.h; const rvDial = rvShown.knobs.decay.dial; @@ -1261,6 +1264,8 @@ export async function sound(t) { rvShown.scope.reach < 0.5 && rvShown.scope.says.ends === 'ENDS 2.52 s' && rvShown.highs === 'HIGHS 5.5 kHz' && + rvLater.scope.seconds > rvShown.scope.seconds && + rvLater.scope.at !== rvShown.scope.at && rvTurned.params.room === 'custom' && rvTurned.params.decay > 2.5 && rvTurned.params.damping === 0.35 && @@ -1280,7 +1285,7 @@ export async function sound(t) { rvSound.panel.scope.rings > 0 && rvSoundUndone.room === 'cave' && rvSoundUndone.impulse === undefined, - JSON.stringify({ rvShown, rvTurned, rvUndone, rvCave, rvOffered, rvSound, rvSoundUndone }), + JSON.stringify({ rvShown, rvLater, rvTurned, rvUndone, rvCave, rvOffered, rvSound, rvSoundUndone }), ); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;