Let the Reverb's screen ring out and its reflections drift (#401)
game-maker / gate (push) Successful in 4m13s
game-maker / suite (push) Successful in 16m5s

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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-10 23:32:49 +02:00
1 parent 742f240a62
commit 2f376262d3
6 files changed
+185 -13

No files matched your search

+4
View File
@@ -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,
},
};
},
+4 -2
View File
@@ -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 {}
+55 -8
View File
@@ -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<T extends string> 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) => {
+70 -2
View File
@@ -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));
+46
View File
@@ -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 });
+6 -1
View File
@@ -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;