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));
|
||||
|
||||
Reference in new issue
Block a user