Show a Delay node as a device with its echoes over time (#393)
After the Chip, the Envelope, the Reverb and the one-knob devices, the Delay is drawn as a device of its own size too. Its screen shows what time, feedback, damping, wet and ping-pong make of a sound, and a light runs over the echoes at the beat they come at. - DelayPanel: a 680x280 plate in the Compressor's brushed aluminium, now exported as ALUMINIUM. A screen, levers for Sync and Ping-pong, knobs for Time, Feedback, Damping and Wet, and an Echo lamp. - The screen draws the sound as a white bar and each echo as a bar one step later. A bar is as tall as the echo is loud, evenly in dB to -60. It turns from warm white to rust as the damping takes the highs. With ping-pong the bars alternate above and below, the first left. - At most 24 bars are drawn, and the screen says how many more are heard. Under it stand the step and the end of the tail, above it where the highs of the first echo end. - The running light: DelayFace.tick returns true while the node is in the picture, so the editor keeps drawing, as for the Particles tab. The light goes round the span in real time, a bar glows as it is passed, and the lamp blinks with every echo but not with the sound. - On beats the screen counts at 120 BPM, as the tempo of the voice is not known in the graph. The time knob then names the note, dotted and triplet ones included. In seconds, 2 s and up reads "2 s max". - delayView holds the rules apart from the drawing: layout, tapers, echoTrain, echoCutoff for k rounds of the Butterworth low-pass, sweepAt and the texts. At wet 0 the rest is drawn faint. - delayEchoes in the core counts the echoes heard. The tail of a voice and the screen use it both. DELAY_ROWS, DELAY_SYNC and PING_PONG are exported. - Knob.reformat writes the number anew when its format changes, here when Sync switches between seconds and beats. - The test hook __gm.delay.panel returns knobs, levers, idle settings, the echoes drawn, the texts and where the light is. - Instrument presets give the Delay 4 columns. Echo Lead and Bell use one. In the picture of Echo Lead nothing overlaps. Found on the way: a node with a face draws no setting rows. The e2e check of a bypassed Delay (#354) read "Bypass: true" from them. It now checks the faded node and the stages heard, as before. Tests: - Unit delay-view: 17 tests, new. Counter-check: the first ping-pong echo on the right turns the side test red. - e2e 34 (melektaus-atx): 136 checks, 1 new. - Gate (melektaus-atx): 288 files, 4416 tests. - Pictures looked at: default, ping-pong, beats with damping, 83 echoes at 50 ms, wet 0, and the preset Echo Lead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
389d9555fa
commit
d4a45c127b
12 files changed
+1082
-17
No files matched your search
@@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
|||||||
The browser suite and the three example games need a machine with a GPU:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1967 checks (GM_STEPS=20 for one step)
|
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1968 checks (GM_STEPS=20 for one step)
|
||||||
# every tool signs in first – see the Accounts page
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -395,10 +395,12 @@ const COLUMN = 240;
|
|||||||
const ROW = 340;
|
const ROW = 340;
|
||||||
/**
|
/**
|
||||||
* Columns a node takes that is drawn as its editor in the graph (#385): a Filter's curve is wide,
|
* Columns a node takes that is drawn as its editor in the graph (#385): a Filter's curve is wide,
|
||||||
* a Chip's device (#386) wider, an Envelope's (#388) a little less, a Pitch's and a Mix's (#390) two.
|
* a Chip's device (#386) wider, an Envelope's (#388) a little less, a Pitch's and a Mix's (#390) two,
|
||||||
|
* a Delay's (#393) four.
|
||||||
*/
|
*/
|
||||||
const COLUMNS_OF: Readonly<Record<string, number>> = {
|
const COLUMNS_OF: Readonly<Record<string, number>> = {
|
||||||
[SoundFxFilter.type]: 4,
|
[SoundFxFilter.type]: 4,
|
||||||
|
[SoundFxDelay.type]: 4,
|
||||||
[SoundFxPitch.type]: 2,
|
[SoundFxPitch.type]: 2,
|
||||||
[SoundFxMix.type]: 2,
|
[SoundFxMix.type]: 2,
|
||||||
[INSTRUMENT_CHIP]: 5,
|
[INSTRUMENT_CHIP]: 5,
|
||||||
|
|||||||
@@ -803,7 +803,7 @@ export const SoundFxMix: SoundFxNodeDef = {
|
|||||||
export const DELAY_MAX = 2;
|
export const DELAY_MAX = 2;
|
||||||
/** Up to two bars of 4/4 in beats; whatever it comes to is held to `DELAY_MAX` seconds. */
|
/** Up to two bars of 4/4 in beats; whatever it comes to is held to `DELAY_MAX` seconds. */
|
||||||
const DELAY_TIME = { ...numberRow('time', 'Time', 0.01, 8, 0.01), default: 0.25 };
|
const DELAY_TIME = { ...numberRow('time', 'Time', 0.01, 8, 0.01), default: 0.25 };
|
||||||
const DELAY_SYNC = {
|
export const DELAY_SYNC = {
|
||||||
key: 'sync',
|
key: 'sync',
|
||||||
label: 'Sync',
|
label: 'Sync',
|
||||||
kind: 'enum' as const,
|
kind: 'enum' as const,
|
||||||
@@ -818,13 +818,30 @@ const DELAY_FEEDBACK = { ...numberRow('feedback', 'Feedback', 0, 0.95, 0.01), de
|
|||||||
const DELAY_DAMPING = { ...numberRow('damping', 'Damping', 0, 1, 0.01), default: 0.3 };
|
const DELAY_DAMPING = { ...numberRow('damping', 'Damping', 0, 1, 0.01), default: 0.3 };
|
||||||
/** Wet of a Delay (#354): 0 the sound alone, 0.5 the sound and its echoes in full, 1 the echoes alone. */
|
/** Wet of a Delay (#354): 0 the sound alone, 0.5 the sound and its echoes in full, 1 the echoes alone. */
|
||||||
const DELAY_MIX = { ...numberRow('mix', 'Wet', 0, 1, 0.01), default: 0.5 };
|
const DELAY_MIX = { ...numberRow('mix', 'Wet', 0, 1, 0.01), default: 0.5 };
|
||||||
const PING_PONG = { key: 'pingPong', label: 'Ping-pong', kind: 'boolean' as const, default: false };
|
export const PING_PONG = { key: 'pingPong', label: 'Ping-pong', kind: 'boolean' as const, default: false };
|
||||||
|
/** A Delay's numbers, for its device in the graph (#393). */
|
||||||
|
export const DELAY_ROWS = {
|
||||||
|
time: DELAY_TIME,
|
||||||
|
feedback: DELAY_FEEDBACK,
|
||||||
|
damping: DELAY_DAMPING,
|
||||||
|
mix: DELAY_MIX,
|
||||||
|
};
|
||||||
|
|
||||||
/** How loud a Delay at `wet` lets the sound itself through, and how loud its echoes (#354). */
|
/** How loud a Delay at `wet` lets the sound itself through, and how loud its echoes (#354). */
|
||||||
export function delayShares(wet: number): { dry: number; echo: number } {
|
export function delayShares(wet: number): { dry: number; echo: number } {
|
||||||
return { dry: Math.min(1, 2 * (1 - wet)), echo: Math.min(1, 2 * wet) };
|
return { dry: Math.min(1, 2 * (1 - wet)), echo: Math.min(1, 2 * wet) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many echoes of a Delay are heard (#324): the echo `k` is `echo * feedback^(k-1)` as loud as
|
||||||
|
* the sound, and the last one counted is still above silence. Its tail, and its device's screen (#393).
|
||||||
|
*/
|
||||||
|
export function delayEchoes(feedback: number, mix: number): number {
|
||||||
|
const { echo } = delayShares(mix);
|
||||||
|
if (!(echo >= SILENT)) return 0;
|
||||||
|
return feedback > 0 ? 1 + Math.floor(Math.log(SILENT / echo) / Math.log(feedback)) : 1;
|
||||||
|
}
|
||||||
|
|
||||||
export const SoundFxDelay: SoundFxNodeDef = effect(
|
export const SoundFxDelay: SoundFxNodeDef = effect(
|
||||||
{
|
{
|
||||||
type: 'SoundFxDelay',
|
type: 'SoundFxDelay',
|
||||||
@@ -1675,8 +1692,5 @@ export function fxLatencyOf(chain: FxChain, sampleRate: number): number {
|
|||||||
|
|
||||||
/** The echo `k` is `echo * feedback^(k-1)` as loud as the sound; the last one still heard sets the tail. */
|
/** The echo `k` is `echo * feedback^(k-1)` as loud as the sound; the last one still heard sets the tail. */
|
||||||
function delayTail({ time, feedback, mix }: FxDelay): number {
|
function delayTail({ time, feedback, mix }: FxDelay): number {
|
||||||
const { echo } = delayShares(mix);
|
return time > 0 ? delayEchoes(feedback, mix) * time : 0;
|
||||||
if (!(echo >= SILENT) || !(time > 0)) return 0;
|
|
||||||
const echoes = feedback > 0 ? 1 + Math.floor(Math.log(SILENT / echo) / Math.log(feedback)) : 1;
|
|
||||||
return echoes * time;
|
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ import { ChipPanel } from '../soundfx/ChipPanel';
|
|||||||
import { EnvelopePanel } from '../soundfx/EnvelopePanel';
|
import { EnvelopePanel } from '../soundfx/EnvelopePanel';
|
||||||
import { CompressorPanel } from '../soundfx/CompressorPanel';
|
import { CompressorPanel } from '../soundfx/CompressorPanel';
|
||||||
import { KnobPanel } from '../soundfx/KnobPanel';
|
import { KnobPanel } from '../soundfx/KnobPanel';
|
||||||
|
import { DelayPanel } from '../soundfx/DelayPanel';
|
||||||
import { ReverbPanel } from '../soundfx/ReverbPanel';
|
import { ReverbPanel } from '../soundfx/ReverbPanel';
|
||||||
import { CurvePanel } from '../soundfx/CurvePanel';
|
import { CurvePanel } from '../soundfx/CurvePanel';
|
||||||
import type { EqEditor } from '../soundfx/EqEditor';
|
import type { EqEditor } from '../soundfx/EqEditor';
|
||||||
@@ -417,6 +418,58 @@ export function graphHooks({ state, editor }: HookContext) {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* The panel a Delay node shows (#393), in the sound FX graph or the instrument graph: whether it
|
||||||
|
* is shown and where, its knobs and levers, which settings are idle, the echoes the screen draws,
|
||||||
|
* what it says, where the running light is, and the node's ports.
|
||||||
|
*/
|
||||||
|
delay: {
|
||||||
|
panel: (nodeId: string) => {
|
||||||
|
const canvas = [editor.soundFxEditor.canvas, editor.instrumentEditor.canvas].find(
|
||||||
|
(c) => c.faceOf(nodeId)?.widget instanceof DelayPanel,
|
||||||
|
);
|
||||||
|
const panel = canvas?.faceOf(nodeId)?.widget;
|
||||||
|
if (!canvas || !(panel instanceof DelayPanel)) return null;
|
||||||
|
const node = canvas.nodeScreen(nodeId);
|
||||||
|
const ports = (keys: string[], isOutput: boolean) =>
|
||||||
|
Object.fromEntries(keys.map((key) => [key, canvas.portGlobal(nodeId, key, isOutput)]));
|
||||||
|
const lever = (l: DelayPanel['syncLever']) => ({
|
||||||
|
lit: l.lit,
|
||||||
|
says: l.says.text,
|
||||||
|
at: screenCentre(l),
|
||||||
|
});
|
||||||
|
const train = panel.scope.train;
|
||||||
|
return {
|
||||||
|
shown: shownOnScreen(panel),
|
||||||
|
rect: screenRect(panel),
|
||||||
|
size: { w: panel.w, h: panel.h },
|
||||||
|
node: node?.rect ?? null,
|
||||||
|
inputs: ports(node?.inputs ?? [], false),
|
||||||
|
outputs: ports(node?.outputs ?? [], true),
|
||||||
|
knobs: Object.fromEntries(
|
||||||
|
Object.entries(panel.knobs).map(([key, knob]) => [
|
||||||
|
key,
|
||||||
|
{ value: knob.value, number: knob.shown, enabled: knob.enabled, dial: knob.dialGlobal() },
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
idle: [...panel.idle],
|
||||||
|
sync: lever(panel.syncLever),
|
||||||
|
pingPong: lever(panel.pingPongLever),
|
||||||
|
highs: panel.highs.text,
|
||||||
|
scope: {
|
||||||
|
every: train?.every ?? null,
|
||||||
|
heard: train?.heard ?? 0,
|
||||||
|
/** The echoes drawn: when, how loud, on which side, how bright. */
|
||||||
|
echoes: train?.echoes ?? [],
|
||||||
|
says: panel.scope.texts,
|
||||||
|
/** The running light, in seconds after the sound; how lit each bar is; the lamp. */
|
||||||
|
at: panel.scope.at,
|
||||||
|
glow: panel.scope.glow,
|
||||||
|
lamp: panel.scope.lamp,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
instrument: {
|
instrument: {
|
||||||
current: () => editor.instrumentEditor.currentAssetId,
|
current: () => editor.instrumentEditor.currentAssetId,
|
||||||
info: () => editor.instrumentEditor.shownInfo,
|
info: () => editor.instrumentEditor.shownInfo,
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ export interface PlateLook {
|
|||||||
grain: number;
|
grain: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Brushed aluminium, the Compressor's. */
|
/** Brushed aluminium, the Compressor's and the Delay's (#393). */
|
||||||
const ALUMINIUM: PlateLook = {
|
export const ALUMINIUM: PlateLook = {
|
||||||
stops: [
|
stops: [
|
||||||
[0, 0xe2e4e7],
|
[0, 0xe2e4e7],
|
||||||
[0.5, 0xcfd2d6],
|
[0.5, 0xcfd2d6],
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
|
||||||
|
import type { GraphNode } from '../../core/graph';
|
||||||
|
import { findGraphAsset } from '../../core/model/Project';
|
||||||
|
import { DELAY_SYNC, PING_PONG, SoundFxDelay } from '../../core/soundfx/nodes';
|
||||||
|
import type { NodeFace } from '../graph/nodeFace';
|
||||||
|
import type { EditorState } from '../state';
|
||||||
|
import { compressorWired } from './CompressorEditor';
|
||||||
|
import { type DelayHost, DelayPanel } from './DelayPanel';
|
||||||
|
import { type DelayKey, delayTexts, delayValues, echoTrain, idleDelayKeys } from './delayView';
|
||||||
|
import { NodeParamDrag } from './nodeParamDrag';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Delay node drawn as its device (#393): its echoes drawn from its settings – at 120 BPM where
|
||||||
|
* they count beats, as the voice's own tempo is not known here – and a light that runs over them
|
||||||
|
* at the beat they come at for as long as the node is in the picture. It listens to nothing.
|
||||||
|
*/
|
||||||
|
export class DelayFace implements NodeFace, DelayHost {
|
||||||
|
readonly widget: DelayPanel;
|
||||||
|
private readonly drag: NodeParamDrag;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly state: EditorState,
|
||||||
|
private readonly assetId: string,
|
||||||
|
private readonly nodeId: string,
|
||||||
|
) {
|
||||||
|
this.widget = new DelayPanel(this);
|
||||||
|
this.drag = new NodeParamDrag(state, 'delay-face');
|
||||||
|
}
|
||||||
|
|
||||||
|
private node(): GraphNode | null {
|
||||||
|
const node = findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find(
|
||||||
|
(n) => n.id === this.nodeId,
|
||||||
|
);
|
||||||
|
return node?.type === SoundFxDelay.type ? node : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private wired = (key: string): boolean =>
|
||||||
|
compressorWired(this.state.project, this.assetId, this.nodeId, key);
|
||||||
|
|
||||||
|
sync(): void {
|
||||||
|
const node = this.node();
|
||||||
|
if (!node) return;
|
||||||
|
const values = delayValues(node.params);
|
||||||
|
const train = echoTrain(values);
|
||||||
|
this.widget.sync({
|
||||||
|
values,
|
||||||
|
wired: this.wired,
|
||||||
|
idle: idleDelayKeys(values),
|
||||||
|
train,
|
||||||
|
texts: delayTexts(values, train),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The light runs on by the page's clock, so it keeps the beat however the frames come. */
|
||||||
|
tick(): boolean {
|
||||||
|
this.widget.step(performance.now() / 1000);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
hide(): void {}
|
||||||
|
|
||||||
|
preview(key: DelayKey, value: number): void {
|
||||||
|
const node = this.node();
|
||||||
|
if (node) this.drag.preview(node, key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
commit(key: DelayKey, value: number): void {
|
||||||
|
const node = this.node();
|
||||||
|
if (node) this.drag.commit(this.assetId, node, key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
switchSync(beats: boolean): void {
|
||||||
|
this.set(DELAY_SYNC.key, beats ? 'beats' : 'seconds');
|
||||||
|
}
|
||||||
|
|
||||||
|
switchPingPong(on: boolean): void {
|
||||||
|
this.set(PING_PONG.key, on);
|
||||||
|
}
|
||||||
|
|
||||||
|
private set(key: string, value: string | boolean): void {
|
||||||
|
if (this.node()) this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, key, value));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
/**
|
||||||
|
* The Delay as a device (#393), what its node shows in the graph: the Compressor's brushed
|
||||||
|
* aluminium (#379), a screen that draws the echoes as bars over time – as tall as they are loud,
|
||||||
|
* left and right taking turns with ping-pong, warm while they keep their highs – a light that runs
|
||||||
|
* over them at the beat they come at, a lamp that blinks with it, two levers and four knobs.
|
||||||
|
*/
|
||||||
|
import { Graphics, type Text, type TextStyle } from 'pixi.js';
|
||||||
|
import { DELAY_ROWS } from '../../core/soundfx/nodes';
|
||||||
|
import { mixColor } from '../../core/util/color';
|
||||||
|
import { crispText, Knob, textStyle, Widget } from '../../ui';
|
||||||
|
import { ALUMINIUM, drawPlate, engrave, INK, Lever, placeEngraving } from './CompressorPanel';
|
||||||
|
import {
|
||||||
|
barHeight,
|
||||||
|
DELAY_KEYS,
|
||||||
|
DELAY_KNOBS,
|
||||||
|
DELAY_LAYOUT,
|
||||||
|
DELAY_PANEL,
|
||||||
|
type DelayKey,
|
||||||
|
type DelayTexts,
|
||||||
|
type DelayValues,
|
||||||
|
type EchoTrain,
|
||||||
|
sweepAt,
|
||||||
|
TEXT_ROW,
|
||||||
|
timeText,
|
||||||
|
} from './delayView';
|
||||||
|
import { type Box, KNOB_CAPTION } from './hardwareView';
|
||||||
|
import { percentText } from './reverbView';
|
||||||
|
|
||||||
|
/** What the panel asks of the node it sits in. */
|
||||||
|
export interface DelayHost {
|
||||||
|
preview(key: DelayKey, value: number): void;
|
||||||
|
commit(key: DelayKey, value: number): void;
|
||||||
|
switchSync(beats: boolean): void;
|
||||||
|
switchPingPong(on: boolean): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the panel shows of a node. */
|
||||||
|
export interface DelayShown {
|
||||||
|
values: DelayValues;
|
||||||
|
wired: (key: DelayKey) => boolean;
|
||||||
|
/** The settings that do nothing as things stand. */
|
||||||
|
idle: ReadonlySet<DelayKey | 'sync' | 'pingPong'>;
|
||||||
|
train: EchoTrain;
|
||||||
|
texts: DelayTexts;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BEZEL = 0x2a2c30;
|
||||||
|
const BEZEL_TEXT = 0xc9ccd2;
|
||||||
|
/** Smoked glass; an echo with its highs is warm white, one that lost them rust, as tape goes. */
|
||||||
|
const SCOPE = {
|
||||||
|
glass: 0x0e0c0a,
|
||||||
|
grid: 0x2c2722,
|
||||||
|
text: 0xffc98a,
|
||||||
|
dim: 0x7a6650,
|
||||||
|
bright: 0xfff1d0,
|
||||||
|
dull: 0xb8442a,
|
||||||
|
sweep: 0xffffff,
|
||||||
|
};
|
||||||
|
const LAMP = 0x4dff5a;
|
||||||
|
const IDLE_ALPHA = 0.4;
|
||||||
|
/** The sound's bar and the echoes', and the room left round the bars. */
|
||||||
|
const BAR = 4;
|
||||||
|
const PAD = { left: 18, right: 22, top: 12 };
|
||||||
|
|
||||||
|
function scopeStyle(size: number, fill: number): TextStyle {
|
||||||
|
const style = textStyle('small').clone();
|
||||||
|
style.fill = fill;
|
||||||
|
style.fontFamily = 'monospace';
|
||||||
|
style.fontSize = size;
|
||||||
|
style.fontWeight = 'bold';
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bezelStyle(): TextStyle {
|
||||||
|
const style = textStyle('small').clone();
|
||||||
|
style.fill = BEZEL_TEXT;
|
||||||
|
style.fontSize = 9;
|
||||||
|
style.fontWeight = 'bold';
|
||||||
|
style.letterSpacing = 1;
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bar as drawn: where it stands and how far up and down from the middle it goes. */
|
||||||
|
interface DrawnBar {
|
||||||
|
x: number;
|
||||||
|
top: number;
|
||||||
|
bottom: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The screen: the sound as a white bar at the left, its echoes after it as bars on a line of time,
|
||||||
|
* a faint line at every step, and a light that runs over them. Under it the step and the end.
|
||||||
|
*/
|
||||||
|
class DelayScope extends Widget {
|
||||||
|
private readonly g = new Graphics();
|
||||||
|
private readonly sweep = new Graphics();
|
||||||
|
private readonly sides: Record<'left' | 'right', Text>;
|
||||||
|
private readonly under: Record<'every' | 'ends', Text>;
|
||||||
|
private readonly moreText: Text;
|
||||||
|
private bars: DrawnBar[] = [];
|
||||||
|
train: EchoTrain | null = null;
|
||||||
|
texts = { every: '', ends: '', highs: '', more: '' };
|
||||||
|
pingPong = false;
|
||||||
|
/** Where the light is, in seconds after the sound, and how lit each bar is (checks). */
|
||||||
|
at = 0;
|
||||||
|
glow: number[] = [];
|
||||||
|
lamp = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.addChild(this.g, this.sweep);
|
||||||
|
this.sides = { left: this.made('L', 9, SCOPE.dim, 0, 0), right: this.made('R', 9, SCOPE.dim, 0, 1) };
|
||||||
|
this.under = { every: this.made('', 9, SCOPE.text, 0, 0), ends: this.made('', 9, SCOPE.text, 1, 0) };
|
||||||
|
this.moreText = this.made('', 9, SCOPE.dim, 1, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
private made(text: string, size: number, fill: number, ax: number, ay: number): Text {
|
||||||
|
const t = crispText({ text, style: scopeStyle(size, fill) });
|
||||||
|
t.anchor.set(ax, ay);
|
||||||
|
t.eventMode = 'none';
|
||||||
|
this.addChild(t);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
show(train: EchoTrain, texts: DelayScope['texts'], pingPong: boolean): void {
|
||||||
|
this.train = train;
|
||||||
|
this.texts = texts;
|
||||||
|
this.pingPong = pingPong;
|
||||||
|
this.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a time sits across the screen. */
|
||||||
|
private xOf(seconds: number): number {
|
||||||
|
const span = this.train?.span ?? 1;
|
||||||
|
return PAD.left + (seconds / span) * (this.w - PAD.left - PAD.right);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get middle(): { y: number; half: number } {
|
||||||
|
const y = (this.h - TEXT_ROW) / 2;
|
||||||
|
return { y, half: y - PAD.top };
|
||||||
|
}
|
||||||
|
|
||||||
|
override redraw(): void {
|
||||||
|
const g = this.g.clear();
|
||||||
|
const { y: cy, half } = this.middle;
|
||||||
|
g.roundRect(0, 0, this.w, this.h - TEXT_ROW, 4).fill(SCOPE.glass);
|
||||||
|
const train = this.train;
|
||||||
|
this.bars = [];
|
||||||
|
if (train) {
|
||||||
|
// A faint line at every step, as far as the screen goes, and the levels 20 and 40 dB down.
|
||||||
|
for (let t = train.every; t < train.span - 1e-9 && train.every > 0; t += train.every)
|
||||||
|
g.moveTo(this.xOf(t), PAD.top - 4).lineTo(this.xOf(t), cy + half + 4);
|
||||||
|
for (const share of [1 / 3, 2 / 3])
|
||||||
|
g.moveTo(PAD.left - 6, cy - share * half)
|
||||||
|
.lineTo(this.w - 8, cy - share * half)
|
||||||
|
.moveTo(PAD.left - 6, cy + share * half)
|
||||||
|
.lineTo(this.w - 8, cy + share * half);
|
||||||
|
g.stroke({ color: SCOPE.grid, width: 1 });
|
||||||
|
g.moveTo(PAD.left - 6, cy)
|
||||||
|
.lineTo(this.w - 8, cy)
|
||||||
|
.stroke({ color: SCOPE.dim, width: 1, alpha: 0.6 });
|
||||||
|
|
||||||
|
const bar = (x: number, h: number, side: 'both' | 'left' | 'right', color: number) => {
|
||||||
|
const top = side === 'right' ? cy + 1 : cy - h * half;
|
||||||
|
const bottom = side === 'left' ? cy - 1 : cy + h * half;
|
||||||
|
if (bottom - top >= 1) g.roundRect(x - BAR / 2, top, BAR, bottom - top, 1.5).fill(color);
|
||||||
|
this.bars.push({ x, top, bottom });
|
||||||
|
};
|
||||||
|
bar(this.xOf(0), barHeight(train.dry), 'both', 0xffffff);
|
||||||
|
for (const echo of train.echoes)
|
||||||
|
bar(
|
||||||
|
this.xOf(echo.t),
|
||||||
|
barHeight(echo.level),
|
||||||
|
echo.side,
|
||||||
|
mixColor(SCOPE.dull, SCOPE.bright, echo.bright),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const [side, t] of Object.entries(this.sides)) {
|
||||||
|
t.visible = this.pingPong;
|
||||||
|
t.position.set(4, side === 'left' ? 3 : this.h - TEXT_ROW - 3);
|
||||||
|
}
|
||||||
|
this.moreText.text = this.texts.more;
|
||||||
|
this.moreText.position.set(this.w - 3, cy - 8);
|
||||||
|
const y = this.h - TEXT_ROW + 3;
|
||||||
|
this.under.every.text = this.texts.every;
|
||||||
|
this.under.every.position.set(0, y);
|
||||||
|
this.under.ends.text = this.texts.ends;
|
||||||
|
this.under.ends.position.set(this.w, y);
|
||||||
|
this.drawSweep();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `seconds` on a clock of the page's: the light where it is, the bars it passed glowing. */
|
||||||
|
step(seconds: number): void {
|
||||||
|
if (!this.train) return;
|
||||||
|
const sweep = sweepAt(this.train, seconds);
|
||||||
|
this.at = sweep.at;
|
||||||
|
this.glow = sweep.glow;
|
||||||
|
this.lamp = sweep.lamp;
|
||||||
|
this.drawSweep();
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawSweep(): void {
|
||||||
|
const s = this.sweep.clear();
|
||||||
|
if (!this.train || this.w <= 0) return;
|
||||||
|
const { y: cy, half } = this.middle;
|
||||||
|
const x = this.xOf(this.at);
|
||||||
|
s.rect(x - 0.5, cy - half - 6, 1, 2 * half + 12).fill({ color: SCOPE.sweep, alpha: 0.35 });
|
||||||
|
this.bars.forEach((bar, i) => {
|
||||||
|
const glow = this.glow[i] ?? 0;
|
||||||
|
if (glow <= 0) return;
|
||||||
|
s.roundRect(
|
||||||
|
bar.x - BAR,
|
||||||
|
Math.min(bar.top, cy - 2) - 2,
|
||||||
|
BAR * 2,
|
||||||
|
Math.max(bar.bottom, cy + 2) - Math.min(bar.top, cy - 2) + 4,
|
||||||
|
3,
|
||||||
|
).fill({
|
||||||
|
color: SCOPE.sweep,
|
||||||
|
alpha: 0.55 * glow,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DelayPanel extends Widget {
|
||||||
|
private readonly plate = new Graphics();
|
||||||
|
private readonly bezel = new Graphics();
|
||||||
|
private readonly rules = new Graphics();
|
||||||
|
private readonly lampLight = new Graphics();
|
||||||
|
readonly scope = new DelayScope();
|
||||||
|
readonly syncLever: Lever;
|
||||||
|
readonly pingPongLever: Lever;
|
||||||
|
readonly knobs = {} as Record<DelayKey, Knob>;
|
||||||
|
private readonly captions = {} as Record<DelayKey, { ink: Text; edge: Text }>;
|
||||||
|
private readonly groupNames: { ink: Text; edge: Text }[];
|
||||||
|
private readonly title: { ink: Text; edge: Text };
|
||||||
|
private readonly lampCaption: { ink: Text; edge: Text };
|
||||||
|
private readonly screenName: Text;
|
||||||
|
/** Where the echoes' highs end, above the screen on the right. */
|
||||||
|
readonly highs: Text;
|
||||||
|
/** Whether the time knob counts beats now: its number reads as a note then. */
|
||||||
|
private beats = false;
|
||||||
|
/** The settings shown idle now (checks). */
|
||||||
|
idle: ReadonlySet<DelayKey | 'sync' | 'pingPong'> = new Set();
|
||||||
|
private lampLit: boolean | null = null;
|
||||||
|
|
||||||
|
constructor(host: DelayHost) {
|
||||||
|
super();
|
||||||
|
this.minWidth = DELAY_PANEL.w;
|
||||||
|
this.minHeight = DELAY_PANEL.h;
|
||||||
|
this.addChild(this.plate, this.rules, this.bezel, this.scope, this.lampLight);
|
||||||
|
this.title = engrave(this, 'DELAY', 14);
|
||||||
|
this.lampCaption = engrave(this, 'ECHO', 9);
|
||||||
|
this.screenName = crispText({ text: 'ECHOES OVER TIME', style: bezelStyle() });
|
||||||
|
this.screenName.eventMode = 'none';
|
||||||
|
this.highs = crispText({ text: '', style: bezelStyle() });
|
||||||
|
this.highs.anchor.set(1, 0);
|
||||||
|
this.highs.eventMode = 'none';
|
||||||
|
this.addChild(this.screenName, this.highs);
|
||||||
|
this.groupNames = DELAY_LAYOUT.groups.map((group) => engrave(this, group.label, 9));
|
||||||
|
this.syncLever = new Lever('SYNC', (on) => host.switchSync(on));
|
||||||
|
this.pingPongLever = new Lever('PING-PONG', (on) => host.switchPingPong(on));
|
||||||
|
this.addChild(this.syncLever, this.pingPongLever);
|
||||||
|
for (const key of DELAY_KEYS) {
|
||||||
|
const row = DELAY_ROWS[key];
|
||||||
|
const look = DELAY_KNOBS[key];
|
||||||
|
const knob = new Knob('', row.default, {
|
||||||
|
min: row.min ?? 0,
|
||||||
|
max: row.max ?? 1,
|
||||||
|
step: row.step ?? 0.01,
|
||||||
|
taper: look.taper,
|
||||||
|
default: row.default,
|
||||||
|
decimals: look.decimals,
|
||||||
|
size: DELAY_LAYOUT.knobs[key].size,
|
||||||
|
look: 'chrome',
|
||||||
|
typed: false,
|
||||||
|
format: key === 'time' ? (value) => timeText(value, this.beats) : percentText,
|
||||||
|
ink: INK,
|
||||||
|
onInput: (value) => host.preview(key, value),
|
||||||
|
onChange: (value) => host.commit(key, value),
|
||||||
|
});
|
||||||
|
this.knobs[key] = knob;
|
||||||
|
this.captions[key] = engrave(this, look.label.toUpperCase(), 9);
|
||||||
|
this.addChild(knob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override measure(): { w: number; h: number } {
|
||||||
|
return { ...DELAY_PANEL };
|
||||||
|
}
|
||||||
|
|
||||||
|
sync(shown: DelayShown): void {
|
||||||
|
const v = shown.values;
|
||||||
|
if (this.beats !== v.beats) {
|
||||||
|
this.beats = v.beats;
|
||||||
|
// The number under the time knob reads otherwise now, though its value stays.
|
||||||
|
this.knobs.time.reformat();
|
||||||
|
}
|
||||||
|
for (const key of DELAY_KEYS) {
|
||||||
|
const knob = this.knobs[key];
|
||||||
|
knob.setValue(v[key]);
|
||||||
|
knob.setEnabled(!shown.wired(key));
|
||||||
|
knob.alpha = shown.idle.has(key) ? IDLE_ALPHA : 1;
|
||||||
|
this.captions[key].ink.alpha = knob.alpha;
|
||||||
|
}
|
||||||
|
this.syncLever.set(v.beats, v.beats ? 'beats' : 'seconds');
|
||||||
|
this.syncLever.alpha = shown.idle.has('sync') ? IDLE_ALPHA : 1;
|
||||||
|
this.pingPongLever.set(v.pingPong, v.pingPong ? 'L · R' : 'off');
|
||||||
|
this.pingPongLever.alpha = shown.idle.has('pingPong') ? IDLE_ALPHA : 1;
|
||||||
|
this.idle = shown.idle;
|
||||||
|
this.scope.show(shown.train, shown.texts, v.pingPong);
|
||||||
|
this.highs.text = shown.texts.highs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A frame on, `seconds` on the page's clock: the light runs on, the lamp blinks with the echoes. */
|
||||||
|
step(seconds: number): void {
|
||||||
|
this.scope.step(seconds);
|
||||||
|
if (this.scope.lamp !== this.lampLit) {
|
||||||
|
this.lampLit = this.scope.lamp;
|
||||||
|
this.drawLamp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawLamp(): void {
|
||||||
|
const { x, y, r } = DELAY_LAYOUT.lamp;
|
||||||
|
const lit = this.lampLit === true;
|
||||||
|
const g = this.lampLight.clear();
|
||||||
|
if (lit) g.circle(x, y, r + 4).fill({ color: LAMP, alpha: 0.25 });
|
||||||
|
g.circle(x, y, r)
|
||||||
|
.fill(lit ? LAMP : 0x1f4a22)
|
||||||
|
.stroke({ color: 0x2a2c30, width: 1.5 });
|
||||||
|
g.circle(x - 2, y - 2, 2).fill({ color: 0xffffff, alpha: lit ? 0.7 : 0.2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
override layout(): void {
|
||||||
|
const L = DELAY_LAYOUT;
|
||||||
|
const put = (widget: Widget, box: Box) => {
|
||||||
|
widget.position.set(Math.round(box.x), Math.round(box.y));
|
||||||
|
widget.resize(box.w, box.h);
|
||||||
|
};
|
||||||
|
put(this.scope, { ...L.screen, h: L.screen.h + TEXT_ROW });
|
||||||
|
this.screenName.position.set(L.screen.x, L.bezel.y + 3);
|
||||||
|
this.highs.position.set(L.screen.x + L.screen.w, L.bezel.y + 3);
|
||||||
|
put(this.syncLever, L.sync);
|
||||||
|
put(this.pingPongLever, L.pingPong);
|
||||||
|
placeEngraving(this.lampCaption, L.lampCaption.x, L.lampCaption.y);
|
||||||
|
for (const key of DELAY_KEYS) {
|
||||||
|
const spot = L.knobs[key];
|
||||||
|
const knob = this.knobs[key];
|
||||||
|
const size = knob.measure();
|
||||||
|
knob.position.set(Math.round(spot.x - size.w / 2), Math.round(spot.y - spot.size / 2));
|
||||||
|
knob.resize(size.w, size.h);
|
||||||
|
placeEngraving(this.captions[key], spot.x, spot.y - spot.size / 2 - KNOB_CAPTION);
|
||||||
|
}
|
||||||
|
L.groups.forEach((group, i) =>
|
||||||
|
placeEngraving(this.groupNames[i]!, group.box.x + group.box.w / 2, group.box.y),
|
||||||
|
);
|
||||||
|
placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
override redraw(): void {
|
||||||
|
const L = DELAY_LAYOUT;
|
||||||
|
drawPlate(this.plate.clear(), DELAY_PANEL, ALUMINIUM, L.screws);
|
||||||
|
const b = L.bezel;
|
||||||
|
this.bezel
|
||||||
|
.clear()
|
||||||
|
.roundRect(b.x, b.y, b.w, b.h, 8)
|
||||||
|
.fill(BEZEL)
|
||||||
|
.roundRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1, 8)
|
||||||
|
.stroke({ color: 0x1b1c20, width: 1 });
|
||||||
|
// A rule each side of a group's name, engraved as the names are.
|
||||||
|
const r = this.rules.clear();
|
||||||
|
L.groups.forEach((group, i) => {
|
||||||
|
const name = this.groupNames[i]!.ink;
|
||||||
|
const mid = group.box.y + 6;
|
||||||
|
const left = group.box.x + 6;
|
||||||
|
const right = group.box.x + group.box.w - 6;
|
||||||
|
const gap = name.width / 2 + 6;
|
||||||
|
const centre = group.box.x + group.box.w / 2;
|
||||||
|
if (centre - gap > left) r.moveTo(left, mid).lineTo(centre - gap, mid);
|
||||||
|
if (centre + gap < right) r.moveTo(centre + gap, mid).lineTo(right, mid);
|
||||||
|
});
|
||||||
|
r.stroke({ color: INK, width: 1, alpha: 0.5 });
|
||||||
|
this.drawLamp();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
/**
|
||||||
|
* What a Delay's node draws (#393), as numbers: where the parts of its device sit, how its knobs
|
||||||
|
* turn, its echoes as bars over time – the screen – and the beat a light runs over them at. Kept
|
||||||
|
* apart from the drawing so it is checked without a browser.
|
||||||
|
*/
|
||||||
|
import type { PropertyBag } from '../../core/model/Properties';
|
||||||
|
import { DEFAULT_TEMPO, secondsOfQuarters } from '../../core/music/time';
|
||||||
|
import {
|
||||||
|
DELAY_MAX,
|
||||||
|
DELAY_ROWS,
|
||||||
|
DELAY_SYNC,
|
||||||
|
dampingCutoff,
|
||||||
|
delayEchoes,
|
||||||
|
delayShares,
|
||||||
|
PING_PONG,
|
||||||
|
} from '../../core/soundfx/nodes';
|
||||||
|
import { formatNumber } from '../../ui/math';
|
||||||
|
import { secondsText } from './chipView';
|
||||||
|
import { type Box, type KnobSpot, KNOB_CAPTION, screwsOf, SMALL_DIAL } from './hardwareView';
|
||||||
|
import { brightness, hzText } from './reverbView';
|
||||||
|
|
||||||
|
export type DelayKey = keyof typeof DELAY_ROWS;
|
||||||
|
export const DELAY_KEYS = Object.keys(DELAY_ROWS) as DelayKey[];
|
||||||
|
|
||||||
|
export interface DelayValues extends Record<DelayKey, number> {
|
||||||
|
/** Whether the time counts quarter notes at the tempo of the voice. */
|
||||||
|
beats: boolean;
|
||||||
|
pingPong: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberOf = (params: PropertyBag, key: DelayKey): number => {
|
||||||
|
const value = params[key];
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : DELAY_ROWS[key].default;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The numbers a Delay plays with, each its row's default where none is typed in. */
|
||||||
|
export function delayValues(params: PropertyBag): DelayValues {
|
||||||
|
return {
|
||||||
|
time: numberOf(params, 'time'),
|
||||||
|
feedback: numberOf(params, 'feedback'),
|
||||||
|
damping: numberOf(params, 'damping'),
|
||||||
|
mix: numberOf(params, 'mix'),
|
||||||
|
beats: (params[DELAY_SYNC.key] ?? DELAY_SYNC.default) === 'beats',
|
||||||
|
pingPong: (params[PING_PONG.key] ?? PING_PONG.default) === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The settings that do nothing as things stand, greyed as the Reverb's are: at wet 0 there is no
|
||||||
|
* echo, so neither its time, its feedback, its damping nor its sides are heard.
|
||||||
|
*/
|
||||||
|
export function idleDelayKeys(values: DelayValues): Set<DelayKey | 'sync' | 'pingPong'> {
|
||||||
|
return values.mix > 0 ? new Set() : new Set(['time', 'feedback', 'damping', 'sync', 'pingPong']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seconds between the echoes: the time, or its quarter notes at `tempo`, never past the line. */
|
||||||
|
export function echoSeconds(values: DelayValues, tempo = DEFAULT_TEMPO): number {
|
||||||
|
const seconds = values.beats ? secondsOfQuarters(values.time, tempo) : values.time;
|
||||||
|
return Math.max(0, Math.min(DELAY_MAX, seconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Note values a time in quarters is named by: whole, dotted and triplet ones. */
|
||||||
|
const NOTE_NAMES: readonly [quarters: number, name: string][] = [
|
||||||
|
[8, '2/1'],
|
||||||
|
[6, '1/1.'],
|
||||||
|
[4, '1/1'],
|
||||||
|
[3, '1/2.'],
|
||||||
|
[8 / 3, '1/1T'],
|
||||||
|
[2, '1/2'],
|
||||||
|
[1.5, '1/4.'],
|
||||||
|
[4 / 3, '1/2T'],
|
||||||
|
[1, '1/4'],
|
||||||
|
[0.75, '1/8.'],
|
||||||
|
[2 / 3, '1/4T'],
|
||||||
|
[0.5, '1/8'],
|
||||||
|
[0.375, '1/16.'],
|
||||||
|
[1 / 3, '1/8T'],
|
||||||
|
[0.25, '1/16'],
|
||||||
|
[1 / 6, '1/16T'],
|
||||||
|
[0.125, '1/32'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A time in quarter notes as a musician names it – `1/8.` a dotted eighth, `1/8T` an eighth
|
||||||
|
* triplet – within half a step of the knob; else its number of beats.
|
||||||
|
*/
|
||||||
|
export function beatText(quarters: number): string {
|
||||||
|
const named = NOTE_NAMES.find(([q]) => Math.abs(q - quarters) <= 0.006);
|
||||||
|
if (named) return named[1];
|
||||||
|
return `${formatNumber(quarters, 2)} beats`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The time knob's number: a note value on beats, else seconds – past the line no longer than it. */
|
||||||
|
export function timeText(time: number, beats: boolean): string {
|
||||||
|
if (beats) return beatText(time);
|
||||||
|
return time >= DELAY_MAX ? `${secondsText(DELAY_MAX)} max` : secondsText(time);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DelayKnob {
|
||||||
|
label: string;
|
||||||
|
taper: number;
|
||||||
|
decimals: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How each knob turns: the time gives its short end most of the turn – a quarter note, or a
|
||||||
|
* second, in the middle, a quarter second at a third – so slap-backs are as easy to set as bars.
|
||||||
|
*/
|
||||||
|
export const DELAY_KNOBS: Record<DelayKey, DelayKnob> = {
|
||||||
|
time: { label: 'Time', taper: 3, decimals: 2 },
|
||||||
|
feedback: { label: 'Feedback', taper: 1, decimals: 2 },
|
||||||
|
damping: { label: 'Damping', taper: 1, decimals: 2 },
|
||||||
|
mix: { label: 'Wet', taper: 1, decimals: 2 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- The plate. ---
|
||||||
|
|
||||||
|
/** The plate: as the Compressor's (#379), the panel never takes another size. */
|
||||||
|
export const DELAY_PANEL = { w: 680, h: 280 };
|
||||||
|
|
||||||
|
const KNOB_SLOT = 64;
|
||||||
|
const GROUP_GAP = 16;
|
||||||
|
/** The height of a group's name and its rule. */
|
||||||
|
const GROUP_LINE = 16;
|
||||||
|
/** A lever's box, as the Compressor's auto-gain (#379). */
|
||||||
|
const LEVER = { w: 118, h: 40 };
|
||||||
|
/** The extra the strip of text under the screen takes. */
|
||||||
|
export const TEXT_ROW = 14;
|
||||||
|
|
||||||
|
export interface DelayLayout {
|
||||||
|
title: Box;
|
||||||
|
/** The dark frame round the screen, and the screen in it. */
|
||||||
|
bezel: Box;
|
||||||
|
screen: Box;
|
||||||
|
sync: Box;
|
||||||
|
pingPong: Box;
|
||||||
|
/** The light that blinks with every echo, beside the device's name and away from the levers' lamps; its own name after it. */
|
||||||
|
lamp: { x: number; y: number; r: number };
|
||||||
|
lampCaption: { x: number; y: number };
|
||||||
|
groups: { label: string; box: Box }[];
|
||||||
|
knobs: Record<DelayKey, KnobSpot>;
|
||||||
|
screws: { x: number; y: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function delayLayout(): DelayLayout {
|
||||||
|
const { w } = DELAY_PANEL;
|
||||||
|
const screen = { x: 34, y: 60, w: 300, h: 176 };
|
||||||
|
const bezel = { x: 24, y: 44, w: screen.w + 20, h: screen.h + 16 + TEXT_ROW + 6 };
|
||||||
|
// The right of the plate: the two levers on top, the knobs below.
|
||||||
|
const left = bezel.x + bezel.w + 24;
|
||||||
|
const right = w - 24;
|
||||||
|
const sync = { x: left, y: 52, ...LEVER };
|
||||||
|
const pingPong = { x: right - LEVER.w, y: 52, ...LEVER };
|
||||||
|
const groupTop = 130;
|
||||||
|
const dialY = groupTop + GROUP_LINE + KNOB_CAPTION + SMALL_DIAL / 2;
|
||||||
|
const groups: { label: string; box: Box }[] = [];
|
||||||
|
const knobs = {} as Record<DelayKey, KnobSpot>;
|
||||||
|
let x = left;
|
||||||
|
for (const [label, keys] of [
|
||||||
|
['TIME', ['time']],
|
||||||
|
['ECHOES', ['feedback', 'damping']],
|
||||||
|
['MIX', ['mix']],
|
||||||
|
] as const) {
|
||||||
|
groups.push({ label, box: { x, y: groupTop, w: keys.length * KNOB_SLOT, h: GROUP_LINE } });
|
||||||
|
for (const key of keys) {
|
||||||
|
knobs[key] = { x: x + KNOB_SLOT / 2, y: dialY, size: SMALL_DIAL };
|
||||||
|
x += KNOB_SLOT;
|
||||||
|
}
|
||||||
|
x += GROUP_GAP;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: { x: 34, y: 14, w: 80, h: 24 },
|
||||||
|
bezel,
|
||||||
|
screen,
|
||||||
|
sync,
|
||||||
|
pingPong,
|
||||||
|
lamp: { x: 128, y: 26, r: 6 },
|
||||||
|
lampCaption: { x: 156, y: 21 },
|
||||||
|
groups,
|
||||||
|
knobs,
|
||||||
|
screws: screwsOf(DELAY_PANEL),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DELAY_LAYOUT: DelayLayout = delayLayout();
|
||||||
|
|
||||||
|
// --- The screen: the echoes as bars over time. ---
|
||||||
|
|
||||||
|
/** The most echoes the screen draws; past them it says how many more there are. */
|
||||||
|
export const SHOWN_ECHOES = 24;
|
||||||
|
|
||||||
|
/** One bar: when it comes, how loud, on which side, and how much of the highs is left. */
|
||||||
|
export interface Echo {
|
||||||
|
/** Seconds after the sound. */
|
||||||
|
t: number;
|
||||||
|
/** As loud as the sound, 1 the same. */
|
||||||
|
level: number;
|
||||||
|
side: 'both' | 'left' | 'right';
|
||||||
|
/** 1 all the highs to 0 down to 500 Hz. */
|
||||||
|
bright: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EchoTrain {
|
||||||
|
/** Seconds between the echoes. */
|
||||||
|
every: number;
|
||||||
|
/** How loud the sound itself goes through. */
|
||||||
|
dry: number;
|
||||||
|
/** The echoes drawn, at most `SHOWN_ECHOES`. */
|
||||||
|
echoes: Echo[];
|
||||||
|
/** All the echoes heard, drawn or not. */
|
||||||
|
heard: number;
|
||||||
|
/** The seconds the screen spans: the bars drawn and one step after the last. */
|
||||||
|
span: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the highs of the echo `k` end: every round goes through the damping's low-pass (a
|
||||||
|
* Butterworth, as the Delay's is) once more, and `k` of them fall by 3 dB at `fc·(2^(1/k)−1)^(1/4)`.
|
||||||
|
*/
|
||||||
|
export function echoCutoff(damping: number, k: number): number {
|
||||||
|
return dampingCutoff(damping) * (2 ** (1 / Math.max(1, k)) - 1) ** 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Delay's echoes as they come (#393): the echo `k` after `k` steps, `echo · feedback^(k-1)` as
|
||||||
|
* loud as the sound, duller with every round – and with ping-pong the first on the left, the next
|
||||||
|
* on the right, as the line takes the sound in on its left and swaps the sides each round.
|
||||||
|
*/
|
||||||
|
export function echoTrain(values: DelayValues, tempo = DEFAULT_TEMPO): EchoTrain {
|
||||||
|
const every = echoSeconds(values, tempo);
|
||||||
|
const { dry, echo } = delayShares(values.mix);
|
||||||
|
const heard = every > 0 ? delayEchoes(values.feedback, values.mix) : 0;
|
||||||
|
const echoes: Echo[] = [];
|
||||||
|
for (let k = 1; k <= Math.min(heard, SHOWN_ECHOES); k++)
|
||||||
|
echoes.push({
|
||||||
|
t: k * every,
|
||||||
|
level: echo * values.feedback ** (k - 1),
|
||||||
|
side: values.pingPong ? (k % 2 === 1 ? 'left' : 'right') : 'both',
|
||||||
|
bright: brightness(echoCutoff(values.damping, k)),
|
||||||
|
});
|
||||||
|
// Without an echo the screen still spans a few steps, so the sound sits where it always does.
|
||||||
|
const span = Math.max(echoes.length + 1, 4) * Math.max(every, 0.01);
|
||||||
|
return { every, dry, echoes, heard, span };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How tall a bar stands, 1 as loud as the sound to 0 at 60 dB down – evenly in dB, as the Reverb's (#389). */
|
||||||
|
export function barHeight(level: number): number {
|
||||||
|
return level > 0 ? Math.max(0, Math.min(1, 1 + (20 * Math.log10(level)) / 60)) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long a bar glows after the light has run over it, and the lamp stays lit. */
|
||||||
|
export const GLOW_SECONDS = 0.18;
|
||||||
|
const LAMP_SECONDS = 0.08;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The light that runs over the screen (#393): it goes from the sound to the end of the span in
|
||||||
|
* real time and round again, so it passes the bars at the very beat the echoes come at. `glow` is
|
||||||
|
* how much each bar is still lit by it, the sound first; the lamp blinks with each echo.
|
||||||
|
*/
|
||||||
|
export function sweepAt(train: EchoTrain, seconds: number): { at: number; glow: number[]; lamp: boolean } {
|
||||||
|
const at = ((seconds % train.span) + train.span) % train.span;
|
||||||
|
const times = [0, ...train.echoes.map((e) => e.t)];
|
||||||
|
const since = times.map((t) => at - t);
|
||||||
|
const glow = since.map((s) => (s >= 0 && s < GLOW_SECONDS ? 1 - s / GLOW_SECONDS : 0));
|
||||||
|
const lampFor = Math.min(LAMP_SECONDS, train.every / 2);
|
||||||
|
return { at, glow, lamp: since.slice(1).some((s) => s >= 0 && s < lampFor) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the screen writes; `more` the echoes heard past the bars drawn. */
|
||||||
|
export interface DelayTexts {
|
||||||
|
every: string;
|
||||||
|
ends: string;
|
||||||
|
highs: string;
|
||||||
|
more: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the screen writes: the step between the echoes, when the last has faded, where the highs end. */
|
||||||
|
export function delayTexts(values: DelayValues, train: EchoTrain, tempo = DEFAULT_TEMPO): DelayTexts {
|
||||||
|
const step = secondsText(train.every);
|
||||||
|
return {
|
||||||
|
every: values.beats
|
||||||
|
? `EVERY ${beatText(values.time)} · ${step} AT ${formatNumber(tempo, 0)} BPM`
|
||||||
|
: `EVERY ${step}`,
|
||||||
|
ends: train.heard > 0 ? `ENDS ${secondsText(train.heard * train.every)}` : 'NO ECHOES',
|
||||||
|
highs: train.heard > 0 ? `HIGHS ${hzText(dampingCutoff(values.damping))}` : '',
|
||||||
|
more: train.heard > train.echoes.length ? `+${train.heard - train.echoes.length}` : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type { GraphNode } from '../../core/graph';
|
|||||||
import { InstrumentChip, InstrumentEnvelope } from '../../core/instrument/nodes';
|
import { InstrumentChip, InstrumentEnvelope } from '../../core/instrument/nodes';
|
||||||
import {
|
import {
|
||||||
SoundFxCompressor,
|
SoundFxCompressor,
|
||||||
|
SoundFxDelay,
|
||||||
SoundFxFilter,
|
SoundFxFilter,
|
||||||
SoundFxParametricEq,
|
SoundFxParametricEq,
|
||||||
SoundFxReverb,
|
SoundFxReverb,
|
||||||
@@ -11,6 +12,7 @@ import type { EditorState } from '../state';
|
|||||||
import { ChipFace } from './ChipFace';
|
import { ChipFace } from './ChipFace';
|
||||||
import { CompressorFace } from './CompressorFace';
|
import { CompressorFace } from './CompressorFace';
|
||||||
import { CurveFace } from './CurveFace';
|
import { CurveFace } from './CurveFace';
|
||||||
|
import { DelayFace } from './DelayFace';
|
||||||
import { EnvelopeFace } from './EnvelopeFace';
|
import { EnvelopeFace } from './EnvelopeFace';
|
||||||
import { EqPanel } from './EqPanel';
|
import { EqPanel } from './EqPanel';
|
||||||
import { FilterPanel } from './FilterPanel';
|
import { FilterPanel } from './FilterPanel';
|
||||||
@@ -18,7 +20,7 @@ import { KnobFace } from './KnobFace';
|
|||||||
import { GAIN_DEVICE, MIX_DEVICE, PAN_DEVICE, PITCH_DEVICE } from './KnobPanel';
|
import { GAIN_DEVICE, MIX_DEVICE, PAN_DEVICE, PITCH_DEVICE } from './KnobPanel';
|
||||||
import { ReverbFace } from './ReverbFace';
|
import { ReverbFace } from './ReverbFace';
|
||||||
|
|
||||||
/** The nodes drawn as a face of their own instead of rows (#380, #381, #384–#386, #388–#390, #392), by type. */
|
/** The nodes drawn as a face of their own instead of rows (#380, #381, #384–#386, #388–#390, #392, #393), by type. */
|
||||||
export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode): NodeFace | null {
|
export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode): NodeFace | null {
|
||||||
if (node.type === InstrumentChip.type) return new ChipFace(state, assetId, node.id);
|
if (node.type === InstrumentChip.type) return new ChipFace(state, assetId, node.id);
|
||||||
if (node.type === InstrumentEnvelope.type) return new EnvelopeFace(state, assetId, node.id);
|
if (node.type === InstrumentEnvelope.type) return new EnvelopeFace(state, assetId, node.id);
|
||||||
@@ -31,5 +33,6 @@ export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode
|
|||||||
);
|
);
|
||||||
if (knobbed) return new KnobFace(state, assetId, node.id, knobbed);
|
if (knobbed) return new KnobFace(state, assetId, node.id, knobbed);
|
||||||
if (node.type === SoundFxReverb.type) return new ReverbFace(state, assetId, node.id);
|
if (node.type === SoundFxReverb.type) return new ReverbFace(state, assetId, node.id);
|
||||||
|
if (node.type === SoundFxDelay.type) return new DelayFace(state, assetId, node.id);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -257,6 +257,11 @@ export class Knob extends VStack {
|
|||||||
this.show(value);
|
this.show(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Writes the number below anew, for a panel whose `format` reads otherwise now (#393). */
|
||||||
|
reformat(): void {
|
||||||
|
if (!(this.field instanceof NumberField)) this.field.setText(this.text(this.current));
|
||||||
|
}
|
||||||
|
|
||||||
preview(value: number): void {
|
preview(value: number): void {
|
||||||
this.show(value);
|
this.show(value);
|
||||||
this.options.onInput?.(value);
|
this.options.onInput?.(value);
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* A Delay's node as a device (#393): every part on the plate and none over another, the time knob
|
||||||
|
* giving its short end the turn and naming notes on beats, the echoes on the screen as the line
|
||||||
|
* plays them, and the light that runs over them at their beat.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { DELAY_MAX, DELAY_ROWS, dampingCutoff, delayEchoes } from '../../src/core/soundfx/nodes';
|
||||||
|
import {
|
||||||
|
barHeight,
|
||||||
|
beatText,
|
||||||
|
DELAY_KEYS,
|
||||||
|
DELAY_KNOBS,
|
||||||
|
DELAY_LAYOUT,
|
||||||
|
DELAY_PANEL,
|
||||||
|
type DelayValues,
|
||||||
|
delayTexts,
|
||||||
|
delayValues,
|
||||||
|
echoCutoff,
|
||||||
|
echoSeconds,
|
||||||
|
echoTrain,
|
||||||
|
GLOW_SECONDS,
|
||||||
|
idleDelayKeys,
|
||||||
|
SHOWN_ECHOES,
|
||||||
|
sweepAt,
|
||||||
|
timeText,
|
||||||
|
} from '../../src/editor/soundfx/delayView';
|
||||||
|
import { type Box, knobBox } from '../../src/editor/soundfx/hardwareView';
|
||||||
|
import { knobPosition } from '../../src/ui/knob';
|
||||||
|
|
||||||
|
const overlap = (a: Box, b: Box) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
||||||
|
const inside = (a: Box) => a.x >= 0 && a.y >= 0 && a.x + a.w <= DELAY_PANEL.w && a.y + a.h <= DELAY_PANEL.h;
|
||||||
|
|
||||||
|
const values = (over: Partial<DelayValues> = {}): DelayValues => ({ ...delayValues({}), ...over });
|
||||||
|
|
||||||
|
describe('the Delay plate', () => {
|
||||||
|
const L = DELAY_LAYOUT;
|
||||||
|
const screws = L.screws.map((s) => ({ x: s.x - 7, y: s.y - 7, w: 14, h: 14 }));
|
||||||
|
|
||||||
|
it('keeps the screen, the levers and the knobs on the plate, clear of each other and the screws', () => {
|
||||||
|
const parts: Box[] = [L.bezel, L.sync, L.pingPong, ...DELAY_KEYS.map((key) => knobBox(L.knobs[key]))];
|
||||||
|
for (const part of parts) {
|
||||||
|
expect(inside(part)).toBe(true);
|
||||||
|
for (const screw of screws) expect(overlap(part, screw)).toBe(false);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < parts.length; i++)
|
||||||
|
for (let j = i + 1; j < parts.length; j++) expect(overlap(parts[i]!, parts[j]!)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets the echo lamp beside the name, away from the lamps of the levers', () => {
|
||||||
|
const lamp = { x: L.lamp.x - L.lamp.r, y: L.lamp.y - L.lamp.r, w: 2 * L.lamp.r, h: 2 * L.lamp.r };
|
||||||
|
expect(lamp.y + lamp.h).toBeLessThan(L.bezel.y);
|
||||||
|
expect(lamp.x + lamp.w).toBeLessThan(L.sync.x);
|
||||||
|
expect(overlap(lamp, L.title)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the Delay knobs', () => {
|
||||||
|
const turn = (value: number) => {
|
||||||
|
const row = DELAY_ROWS.time;
|
||||||
|
return knobPosition(value, { min: row.min!, max: row.max!, step: row.step!, ...DELAY_KNOBS.time });
|
||||||
|
};
|
||||||
|
|
||||||
|
it('puts a quarter note or a second in the middle of the time and a quarter second at a third', () => {
|
||||||
|
expect(turn(1)).toBeCloseTo(0.5, 2);
|
||||||
|
expect(turn(0.25)).toBeGreaterThan(0.28);
|
||||||
|
expect(turn(0.25)).toBeLessThan(0.34);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names a time on beats as a note, dotted and triplet ones too, within the knob’s step', () => {
|
||||||
|
expect(beatText(1)).toBe('1/4');
|
||||||
|
expect(beatText(0.75)).toBe('1/8.');
|
||||||
|
expect(beatText(0.33)).toBe('1/8T');
|
||||||
|
expect(beatText(0.38)).toBe('1/16.');
|
||||||
|
expect(beatText(0.6)).toBe('0.6 beats');
|
||||||
|
expect(timeText(0.5, true)).toBe('1/8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds a time in seconds to the line, and says so', () => {
|
||||||
|
expect(timeText(0.25, false)).toBe('250 ms');
|
||||||
|
expect(timeText(5, false)).toBe('2 s max');
|
||||||
|
expect(echoSeconds(values({ time: 5 }))).toBe(DELAY_MAX);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts beats at the tempo, never past the line', () => {
|
||||||
|
expect(echoSeconds(values({ time: 0.75, beats: true }))).toBeCloseTo(0.375, 9);
|
||||||
|
expect(echoSeconds(values({ time: 1, beats: true }), 60)).toBeCloseTo(1, 9);
|
||||||
|
expect(echoSeconds(values({ time: 8, beats: true }))).toBe(DELAY_MAX);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('greys all but the wet while the wet leaves no echo', () => {
|
||||||
|
expect([...idleDelayKeys(values({ mix: 0 }))].sort()).toEqual(
|
||||||
|
['damping', 'feedback', 'pingPong', 'sync', 'time'].sort(),
|
||||||
|
);
|
||||||
|
expect(idleDelayKeys(values({ mix: 0.01 })).size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the echoes on the screen', () => {
|
||||||
|
it('draws an echo every step, each feedback times as loud, as many as are heard', () => {
|
||||||
|
const train = echoTrain(values({ time: 0.3, feedback: 0.5, mix: 0.5 }));
|
||||||
|
expect(train.heard).toBe(delayEchoes(0.5, 0.5));
|
||||||
|
expect(train.echoes).toHaveLength(train.heard);
|
||||||
|
train.echoes.forEach((echo, i) => {
|
||||||
|
expect(echo.t).toBeCloseTo(0.3 * (i + 1), 9);
|
||||||
|
expect(echo.level).toBeCloseTo(0.5 ** i, 9);
|
||||||
|
});
|
||||||
|
expect(train.dry).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws at most so many and says how many more there are', () => {
|
||||||
|
const v = values({ time: 0.05, feedback: 0.92, mix: 0.8 });
|
||||||
|
const train = echoTrain(v);
|
||||||
|
expect(train.echoes).toHaveLength(SHOWN_ECHOES);
|
||||||
|
expect(delayTexts(v, train).more).toBe(`+${train.heard - SHOWN_ECHOES}`);
|
||||||
|
expect(delayTexts(v, train).ends).toBe(`ENDS ${(train.heard * 0.05).toFixed(2)} s`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes turns left and right with ping-pong, the first on the left', () => {
|
||||||
|
const sides = (pingPong: boolean) =>
|
||||||
|
echoTrain(values({ pingPong }))
|
||||||
|
.echoes.slice(0, 4)
|
||||||
|
.map((e) => e.side);
|
||||||
|
expect(sides(true)).toEqual(['left', 'right', 'left', 'right']);
|
||||||
|
expect(sides(false)).toEqual(['both', 'both', 'both', 'both']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes every echo duller than the last, the first as dull as the damping', () => {
|
||||||
|
expect(echoCutoff(0.4, 1)).toBeCloseTo(dampingCutoff(0.4), 6);
|
||||||
|
const bright = echoTrain(values({ damping: 0.4, feedback: 0.8 })).echoes.map((e) => e.bright);
|
||||||
|
for (let i = 1; i < bright.length; i++) expect(bright[i]!).toBeLessThan(bright[i - 1]!);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stands a bar as tall as its level evenly in dB, 60 dB down at the floor', () => {
|
||||||
|
expect(barHeight(1)).toBe(1);
|
||||||
|
expect(barHeight(0.1)).toBeCloseTo(2 / 3, 9);
|
||||||
|
expect(barHeight(0.001)).toBeCloseTo(0, 9);
|
||||||
|
expect(barHeight(0)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws the sound alone, and says so, where the wet is 0', () => {
|
||||||
|
const v = values({ mix: 0 });
|
||||||
|
const train = echoTrain(v);
|
||||||
|
expect(train.echoes).toHaveLength(0);
|
||||||
|
expect(delayTexts(v, train)).toMatchObject({ ends: 'NO ECHOES', highs: '', more: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes the note and the tempo it counts at on beats', () => {
|
||||||
|
const v = values({ time: 0.75, beats: true });
|
||||||
|
expect(delayTexts(v, echoTrain(v)).every).toBe('EVERY 1/8. · 375 ms AT 120 BPM');
|
||||||
|
expect(delayTexts(values(), echoTrain(values())).every).toBe('EVERY 250 ms');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the running light', () => {
|
||||||
|
const train = echoTrain(values({ time: 0.3, feedback: 0.5, mix: 0.5 }));
|
||||||
|
|
||||||
|
it('lights a bar the moment the light reaches it, at the beat of the echoes, and lets it fade', () => {
|
||||||
|
const at = sweepAt(train, 0.6);
|
||||||
|
expect(at.glow[2]).toBeCloseTo(1, 9);
|
||||||
|
expect(at.glow[1]).toBe(0);
|
||||||
|
expect(at.lamp).toBe(true);
|
||||||
|
expect(sweepAt(train, 0.6 + GLOW_SECONDS / 2).glow[2]).toBeCloseTo(0.5, 9);
|
||||||
|
expect(sweepAt(train, 0.45).lamp).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs round the span again and again', () => {
|
||||||
|
expect(sweepAt(train, train.span + 0.6).at).toBeCloseTo(0.6, 9);
|
||||||
|
expect(sweepAt(train, -0.1).at).toBeCloseTo(train.span - 0.1, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blinks the lamp with the echoes only, not with the sound', () => {
|
||||||
|
expect(sweepAt(train, 0).lamp).toBe(false);
|
||||||
|
expect(sweepAt(echoTrain(values({ mix: 0 })), 0).lamp).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+62
-6
@@ -500,7 +500,8 @@ export async function sound(t) {
|
|||||||
cut.stopped === beforeLetRing.stopped + 2,
|
cut.stopped === beforeLetRing.stopped + 2,
|
||||||
JSON.stringify({ before: beforeLetRing.stopped, letRing, cut }),
|
JSON.stringify({ before: beforeLetRing.stopped, letRing, cut }),
|
||||||
);
|
);
|
||||||
// #354: the Delay bypassed is drawn faded and says so, and the voice plays as if it were not there.
|
// #354: the Delay bypassed is drawn faded, and the voice plays as if it were not there. Since
|
||||||
|
// #393 the Delay is a device, which writes no setting rows, so "Bypass: true" is not read.
|
||||||
await mutate(`
|
await mutate(`
|
||||||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||||||
graph.nodes.find((n) => n.id === 'fx-delay').params.bypass = true;`);
|
graph.nodes.find((n) => n.id === 'fx-delay').params.bypass = true;`);
|
||||||
@@ -512,11 +513,8 @@ export async function sound(t) {
|
|||||||
const heardBypass = await waitFor(stats, (s) => s.started > beforeBypass.started);
|
const heardBypass = await waitFor(stats, (s) => s.started > beforeBypass.started);
|
||||||
await evaluate('__gm.sound.stopAll()');
|
await evaluate('__gm.sound.stopAll()');
|
||||||
check(
|
check(
|
||||||
'a bypassed Delay is drawn faded with "Bypass: true", and the voice passes only the gain',
|
'a bypassed Delay is drawn faded, and the voice passes only the gain',
|
||||||
bypassed.alpha < 0.5 &&
|
bypassed.alpha < 0.5 && bypassed.gain === 1 && heardBypass.last?.stages === 1,
|
||||||
bypassed.gain === 1 &&
|
|
||||||
bypassed.settings?.includes('Bypass: true') &&
|
|
||||||
heardBypass.last?.stages === 1,
|
|
||||||
JSON.stringify({ bypassed, last: heardBypass.last }),
|
JSON.stringify({ bypassed, last: heardBypass.last }),
|
||||||
);
|
);
|
||||||
await mutate(`
|
await mutate(`
|
||||||
@@ -1387,6 +1385,64 @@ export async function sound(t) {
|
|||||||
await mutate(`
|
await mutate(`
|
||||||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||||||
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-pn');`);
|
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-pn');`);
|
||||||
|
// #393: a Delay is a device of its own size with its echoes on a screen and a light running over
|
||||||
|
// them at their beat. Ping-pong puts them left and right by turns, Sync names the time as a
|
||||||
|
// note, the time knob turns it; each is one undo step.
|
||||||
|
await mutate(`
|
||||||
|
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||||||
|
graph.nodes.push(${JSON.stringify(node('fx-dl', 'SoundFxDelay', 0, 400, { time: 0.5, feedback: 0.5, mix: 0.5 }))});`);
|
||||||
|
await evaluate('__gm.soundfx.fit()');
|
||||||
|
await sleep(100);
|
||||||
|
const dlShown = await json("__gm.delay.panel('fx-dl')");
|
||||||
|
await sleep(150);
|
||||||
|
const dlLater = (await json("__gm.delay.panel('fx-dl')")).scope.at;
|
||||||
|
await click(dlShown.pingPong.at.x, dlShown.pingPong.at.y);
|
||||||
|
const dlPingPong = { params: await knobParams('fx-dl'), panel: await json("__gm.delay.panel('fx-dl')") };
|
||||||
|
await click(dlShown.sync.at.x, dlShown.sync.at.y);
|
||||||
|
const dlBeats = { params: await knobParams('fx-dl'), panel: await json("__gm.delay.panel('fx-dl')") };
|
||||||
|
const dlTime = dlShown.knobs.time.dial;
|
||||||
|
await drag(dlTime.x, dlTime.y, dlTime.x, dlTime.y - 20);
|
||||||
|
const dlTurned = (await knobParams('fx-dl')).time;
|
||||||
|
for (let i = 0; i < 3; i++) await press('z', { ctrl: true });
|
||||||
|
const dlUndone = { params: await knobParams('fx-dl'), panel: await json("__gm.delay.panel('fx-dl')") };
|
||||||
|
const dlSides = (panel) =>
|
||||||
|
panel.scope.echoes
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((e) => e.side)
|
||||||
|
.join(' ');
|
||||||
|
check(
|
||||||
|
'a Delay node is a device with its echoes on a screen and a running light; ping-pong, sync and the time knob are one undo step each',
|
||||||
|
dlShown.size.w === 680 &&
|
||||||
|
dlShown.size.h === 280 &&
|
||||||
|
Object.values(dlShown.inputs).length === 5 &&
|
||||||
|
Object.values(dlShown.inputs).every((p) => p.x < dlShown.rect.x) &&
|
||||||
|
Object.values(dlShown.outputs).every((p) => p.x > dlShown.rect.x + dlShown.rect.w) &&
|
||||||
|
dlShown.knobs.time.number === '500 ms' &&
|
||||||
|
dlShown.scope.heard === 10 &&
|
||||||
|
dlShown.scope.echoes.length === 10 &&
|
||||||
|
dlShown.scope.says.every === 'EVERY 500 ms' &&
|
||||||
|
dlShown.scope.says.ends === 'ENDS 5 s' &&
|
||||||
|
dlSides(dlShown) === 'both both both' &&
|
||||||
|
// The light runs on by itself.
|
||||||
|
dlLater !== dlShown.scope.at &&
|
||||||
|
dlPingPong.params.pingPong === true &&
|
||||||
|
dlPingPong.panel.pingPong.lit &&
|
||||||
|
dlSides(dlPingPong.panel) === 'left right left' &&
|
||||||
|
dlBeats.params.sync === 'beats' &&
|
||||||
|
dlBeats.panel.sync.says === 'beats' &&
|
||||||
|
dlBeats.panel.knobs.time.number === '1/8' &&
|
||||||
|
dlBeats.panel.scope.says.every === 'EVERY 1/8 · 250 ms AT 120 BPM' &&
|
||||||
|
dlTurned > 0.5 &&
|
||||||
|
dlUndone.params.time === 0.5 &&
|
||||||
|
dlUndone.params.sync !== 'beats' &&
|
||||||
|
dlUndone.params.pingPong !== true &&
|
||||||
|
dlUndone.panel.knobs.time.number === '500 ms' &&
|
||||||
|
dlSides(dlUndone.panel) === 'both both both',
|
||||||
|
JSON.stringify({ dlShown, dlLater, dlPingPong, dlBeats, dlTurned, dlUndone }),
|
||||||
|
);
|
||||||
|
await mutate(`
|
||||||
|
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||||||
|
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-dl');`);
|
||||||
await showTab('scene');
|
await showTab('scene');
|
||||||
await mutate(`
|
await mutate(`
|
||||||
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
|
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
|
||||||
|
|||||||
Reference in new issue
Block a user