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 <noreply@anthropic.com>
This commit is contained in:
1 parent
742f240a62
commit
2f376262d3
6 files changed
+185
-13
No files matched your search
@@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user