Show Tremolo, Vibrato, Phaser and Flanger as devices that swing (#398)
The four effects that swing were still drawn as rows of numbers. Like
the Delay, the Saturation and the Limiter, each node now shows a device
of a fixed size. Its screen draws what it does over the first seconds
of a sound, and a light plays that in real time, over and over.
- One device for all four (ModulationPanel, ModulationFace and
modulationView), as KnobPanel is for Gain, Pitch, Mix and Pan. A short
description gives each its title, plate, buttons and knobs.
- Tremolo, 640x280 in cream: a wave its volume shapes, the volume as
its outline. It says DOWN TO 50%, EVERY 200 ms and DIPS -6 dB.
- Vibrato, 640x280 in plum: the pitch in semitones, cool below and warm
above. Under a wet below 1 the dry pitch stands beside it. It says
how late the voice is (LATE 0.93 ms).
- Phaser, 760x280 in mustard, and Flanger, 680x280 in steel blue: the
response from 20 Hz to 20 kHz, and under it a lane of the turn or the
delay over time. The response is drawn where the light is, so the
notches swirl and the comb jets. A comb finer than a column fills it.
- The light loops over 1, 2, 4 or 8 s, two LFO swings at least, for a
Flanger as long as its sweep takes to reach 20 ms. The face only
keeps the editor drawing while something swings and is heard.
- A Rate lamp beside each name goes from dark to lit with the swing.
- Nothing is listened to. The pictures use the worklets' own numbers:
lfoAt, vibratoSpeedUp (now exported from lfo.ts), the allpass chain
with its feedback, and the comb, reckoned at 48 kHz.
- Phaser and Flanger take 4 preset columns. Phased Pad was looked at
in a picture, nothing overlaps.
- Exported from nodes.ts: LFO_SHAPE, lfoShapeOf, TREMOLO_ROWS,
VIBRATO_ROWS, PHASER_STAGE_ROW and phaserStagesOf. FLANGER_MAX_MS is
exported from phaser.ts.
Decided here, as the request left the look open:
- A notch counts where the response falls below -3 dB, not some dB
under the loudest point. Feedback rings peaks far above 0 dB, and the
first rule counted 7 notches on a Phaser of 12 stages.
- Rate and Depth of Tremolo and Vibrato are big knobs, Wet is small.
Tests:
Unit modulation-view (stein2): 22 tests, new. Phaser and Flanger
give the dB of their worklets on a sine within 0.005 dB, the
Tremolo its samples, the Vibrato the pitch it reads at.
Counter-check: with the sign of the Phaser's feedback flipped in one
place, exactly the worklet test fails.
e2e 34 (atx): ALL PASSED, 142 checks, 4 new. Each device has its
size, its ports outside, a light that runs by itself, and a button
and a knob as one undo step each. Picking 8 stages on the Phaser
draws 4 notches.
Gate (atx): CHECK EXIT 0, 291 files, 4467 tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
28909f0adf
commit
b6749cc43d
12 files changed
+1860
-11
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:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1970 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1974 checks (GM_STEPS=20 for one step)
|
||||
# every tool signs in first – see the Accounts page
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
|
||||
@@ -396,11 +396,13 @@ const ROW = 340;
|
||||
/**
|
||||
* 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 Delay's (#393) four.
|
||||
* a Delay's (#393), a Phaser's and a Flanger's (#398) four.
|
||||
*/
|
||||
const COLUMNS_OF: Readonly<Record<string, number>> = {
|
||||
[SoundFxFilter.type]: 4,
|
||||
[SoundFxDelay.type]: 4,
|
||||
[SoundFxPhaser.type]: 4,
|
||||
[SoundFxFlanger.type]: 4,
|
||||
[SoundFxPitch.type]: 2,
|
||||
[SoundFxMix.type]: 2,
|
||||
[INSTRUMENT_CHIP]: 5,
|
||||
|
||||
@@ -68,7 +68,7 @@ export function lfoSwing(shape: LfoShape): number {
|
||||
}
|
||||
|
||||
/** How much faster the sound is read at the top of a Vibrato `depth` semitones deep. */
|
||||
const speedUp = (depth: number) => 2 ** (Math.max(0, depth) / 12) - 1;
|
||||
export const vibratoSpeedUp = (depth: number) => 2 ** (Math.max(0, depth) / 12) - 1;
|
||||
|
||||
/** The longest a Vibrato's delay may get, and so what its line holds. */
|
||||
const VIBRATO_MAX_DELAY = 0.5;
|
||||
@@ -79,7 +79,7 @@ const VIBRATO_MAX_DELAY = 0.5;
|
||||
*/
|
||||
export function vibratoDelay(shape: LfoShape, rate: number, depth: number): number {
|
||||
if (!(rate > 0)) return 0;
|
||||
return Math.min(VIBRATO_MAX_DELAY / 2, (speedUp(depth) * lfoSwing(shape)) / rate);
|
||||
return Math.min(VIBRATO_MAX_DELAY / 2, (vibratoSpeedUp(depth) * lfoSwing(shape)) / rate);
|
||||
}
|
||||
|
||||
const at = (values: Float32Array, i: number) => (values.length > 1 ? values[i]! : values[0]!);
|
||||
@@ -134,7 +134,7 @@ export class Vibrato {
|
||||
while (this.lines.length < outputs.length) this.lines.push(new Float32Array(this.size));
|
||||
for (let i = 0; i < length; i++) {
|
||||
const rate = Math.max(1e-3, at(numbers.rate, i));
|
||||
const swing = (speedUp(at(numbers.depth, i)) / rate) * swingAt(this.shape, this.phase);
|
||||
const swing = (vibratoSpeedUp(at(numbers.depth, i)) / rate) * swingAt(this.shape, this.phase);
|
||||
const delay = Math.max(0, Math.min(VIBRATO_MAX_DELAY, this.center + swing)) * this.sampleRate;
|
||||
const read = this.written - delay;
|
||||
const from = Math.floor(read);
|
||||
|
||||
@@ -871,7 +871,7 @@ export const PHASER_ROWS = {
|
||||
feedback: PHASER_FEEDBACK,
|
||||
mix: PHASER_MIX,
|
||||
};
|
||||
const PHASER_STAGE_ROW = {
|
||||
export const PHASER_STAGE_ROW = {
|
||||
key: 'stages',
|
||||
label: 'Stages',
|
||||
kind: 'enum' as const,
|
||||
@@ -879,7 +879,7 @@ const PHASER_STAGE_ROW = {
|
||||
options: PHASER_STAGES.map((n) => ({ value: String(n), label: String(n) })),
|
||||
};
|
||||
|
||||
function phaserStagesOf(settings: PropertyBag): number {
|
||||
export function phaserStagesOf(settings: PropertyBag): number {
|
||||
const stages = Number(settings[PHASER_STAGE_ROW.key] ?? PHASER_STAGE_ROW.default);
|
||||
return PHASER_STAGES.find((n) => n === stages) ?? 4;
|
||||
}
|
||||
@@ -1146,7 +1146,7 @@ export const SoundFxSaturation: SoundFxNodeDef = effect(
|
||||
'own',
|
||||
);
|
||||
|
||||
const LFO_SHAPE = {
|
||||
export const LFO_SHAPE = {
|
||||
key: 'shape',
|
||||
label: 'Shape',
|
||||
kind: 'enum' as const,
|
||||
@@ -1154,7 +1154,7 @@ const LFO_SHAPE = {
|
||||
options: LFO_SHAPES,
|
||||
};
|
||||
|
||||
function lfoShapeOf(settings: PropertyBag): LfoShape {
|
||||
export function lfoShapeOf(settings: PropertyBag): LfoShape {
|
||||
return LFO_SHAPES.find((option) => option.value === settings[LFO_SHAPE.key])?.value ?? 'sine';
|
||||
}
|
||||
|
||||
@@ -1162,6 +1162,8 @@ const TREMOLO_RATE = { ...numberRow('rate', 'Rate (Hz)', 0, 20, 0.01), default:
|
||||
const TREMOLO_DEPTH = { ...numberRow('depth', 'Depth (%)', 0, 100, 1), default: 50 };
|
||||
const VIBRATO_RATE = { ...numberRow('rate', 'Rate (Hz)', 0.5, 20, 0.01), default: 5 };
|
||||
const VIBRATO_DEPTH = { ...numberRow('depth', 'Depth (st)', 0, 4, 0.01), default: 0.5 };
|
||||
export const TREMOLO_ROWS = { rate: TREMOLO_RATE, depth: TREMOLO_DEPTH, wet: WET };
|
||||
export const VIBRATO_ROWS = { rate: VIBRATO_RATE, depth: VIBRATO_DEPTH, wet: WET };
|
||||
|
||||
export const SoundFxTremolo: SoundFxNodeDef = effect(
|
||||
{
|
||||
|
||||
@@ -72,7 +72,7 @@ export class Phaser {
|
||||
}
|
||||
|
||||
/** The longest a Flanger delays its copy, in ms; a delay past it stays there, as sfxr's does at its own end. */
|
||||
const FLANGER_MAX_MS = 20;
|
||||
export const FLANGER_MAX_MS = 20;
|
||||
|
||||
/**
|
||||
* The flanger, which sfxr calls its phaser: the sound and a copy `offset` ms late. `sweep` moves
|
||||
|
||||
@@ -9,6 +9,7 @@ import { CompressorPanel } from '../soundfx/CompressorPanel';
|
||||
import { KnobPanel } from '../soundfx/KnobPanel';
|
||||
import { DelayPanel } from '../soundfx/DelayPanel';
|
||||
import { LimiterPanel } from '../soundfx/LimiterPanel';
|
||||
import { ModulationPanel } from '../soundfx/ModulationPanel';
|
||||
import { SaturationPanel } from '../soundfx/SaturationPanel';
|
||||
import { ReverbPanel } from '../soundfx/ReverbPanel';
|
||||
import { CurvePanel } from '../soundfx/CurvePanel';
|
||||
@@ -46,6 +47,56 @@ export function graphHooks({ state, editor }: HookContext) {
|
||||
: null,
|
||||
};
|
||||
};
|
||||
/**
|
||||
* The panel a Tremolo, a Vibrato, a Phaser or a Flanger node shows (#398), in the sound FX graph
|
||||
* or the instrument graph: whether it is shown and where, its knobs and buttons, which settings
|
||||
* are idle, what the screen draws and says, where its light is, and the node's ports.
|
||||
*/
|
||||
const modulationPanel = (nodeId: string) => {
|
||||
const canvas = [editor.soundFxEditor.canvas, editor.instrumentEditor.canvas].find(
|
||||
(c) => c.faceOf(nodeId)?.widget instanceof ModulationPanel,
|
||||
);
|
||||
const panel = canvas?.faceOf(nodeId)?.widget;
|
||||
if (!canvas || !(panel instanceof ModulationPanel)) 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 scope = panel.scope;
|
||||
const picture = scope.picture;
|
||||
return {
|
||||
kind: panel.device.kind,
|
||||
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],
|
||||
picked: panel.pickButtons.find((b) => b.picked)?.value ?? null,
|
||||
picks: Object.fromEntries(panel.pickButtons.map((b) => [b.value, screenCentre(b)])),
|
||||
side: panel.sideText.text,
|
||||
lamp: panel.lamp,
|
||||
scope: {
|
||||
says: scope.texts,
|
||||
/** The seconds the screen spans, the lowest and highest the light reads, whether it moves. */
|
||||
span: picture?.span ?? null,
|
||||
lowest: picture?.lowest ?? null,
|
||||
highest: picture?.highest ?? null,
|
||||
moving: picture?.moving ?? false,
|
||||
/** Where the light is, in seconds after the sound, and what it reads there. */
|
||||
at: scope.at,
|
||||
reading: scope.reading,
|
||||
/** A Phaser's or a Flanger's notches drawn now, by Hz. */
|
||||
notches: 'notches' in scope ? scope.notches : null,
|
||||
},
|
||||
};
|
||||
};
|
||||
return {
|
||||
graph: {
|
||||
current: () => graphEditor.currentAssetId,
|
||||
@@ -576,6 +627,10 @@ export function graphHooks({ state, editor }: HookContext) {
|
||||
};
|
||||
},
|
||||
},
|
||||
tremolo: { panel: modulationPanel },
|
||||
vibrato: { panel: modulationPanel },
|
||||
phaser: { panel: modulationPanel },
|
||||
flanger: { panel: modulationPanel },
|
||||
instrument: {
|
||||
current: () => editor.instrumentEditor.currentAssetId,
|
||||
info: () => editor.instrumentEditor.shownInfo,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
|
||||
import type { GraphNode } from '../../core/graph';
|
||||
import { findGraphAsset } from '../../core/model/Project';
|
||||
import type { NodeFace } from '../graph/nodeFace';
|
||||
import type { EditorState } from '../state';
|
||||
import { compressorWired } from './CompressorEditor';
|
||||
import { type ModHost, ModulationPanel } from './ModulationPanel';
|
||||
import { idleModKeys, loopAt, type ModDevice, modTexts, modValues, swingPicture } from './modulationView';
|
||||
import { NodeParamDrag } from './nodeParamDrag';
|
||||
|
||||
/**
|
||||
* A Tremolo, a Vibrato, a Phaser or a Flanger node drawn as its device (#398): what it does over
|
||||
* the first seconds of a sound, reckoned from its settings, and a light that plays it in real time
|
||||
* for as long as something swings and the node is in the picture. It listens to nothing.
|
||||
*/
|
||||
export class ModulationFace implements NodeFace, ModHost {
|
||||
readonly widget: ModulationPanel;
|
||||
private readonly drag: NodeParamDrag;
|
||||
|
||||
constructor(
|
||||
private readonly state: EditorState,
|
||||
private readonly assetId: string,
|
||||
private readonly nodeId: string,
|
||||
private readonly device: ModDevice,
|
||||
) {
|
||||
this.widget = new ModulationPanel(device, this);
|
||||
this.drag = new NodeParamDrag(state, `${device.kind}-face`);
|
||||
}
|
||||
|
||||
private node(): GraphNode | null {
|
||||
const node = findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find(
|
||||
(n) => n.id === this.nodeId,
|
||||
);
|
||||
return node?.type === this.device.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 = modValues(this.device, node.params);
|
||||
const picture = swingPicture(this.device, values);
|
||||
this.widget.sync({
|
||||
values,
|
||||
wired: this.wired,
|
||||
idle: idleModKeys(this.device, values),
|
||||
picture,
|
||||
texts: modTexts(this.device, values, picture),
|
||||
});
|
||||
}
|
||||
|
||||
/** The light runs by the page's clock; with nothing that swings there is nothing to move. */
|
||||
tick(): boolean {
|
||||
const picture = this.widget.scope.picture;
|
||||
if (!picture?.moving) return false;
|
||||
this.widget.step(loopAt(picture.span, performance.now() / 1000));
|
||||
return true;
|
||||
}
|
||||
|
||||
hide(): void {}
|
||||
|
||||
preview(key: string, value: number): void {
|
||||
const node = this.node();
|
||||
if (node) this.drag.preview(node, key, value);
|
||||
}
|
||||
|
||||
commit(key: string, value: number): void {
|
||||
const node = this.node();
|
||||
if (node) this.drag.commit(this.assetId, node, key, value);
|
||||
}
|
||||
|
||||
pick(value: string): void {
|
||||
const node = this.node();
|
||||
const pick = this.device.pick;
|
||||
if (node && pick && pick.of(node.params) !== value)
|
||||
this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, pick.key, value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,694 @@
|
||||
/**
|
||||
* The Tremolo, the Vibrato, the Phaser and the Flanger as devices (#398), what their nodes show in
|
||||
* the graph: a plate of their own, a screen, a lamp beside the name that swings with them, the
|
||||
* buttons of a shape or of stages, and their knobs. A Tremolo's screen draws a wave its volume
|
||||
* shapes over the first seconds of a sound, a Vibrato's the pitch; a Phaser's and a Flanger's what
|
||||
* they do to each frequency, and in a lane under it where that is going. A light plays it all in
|
||||
* real time, over and over.
|
||||
*/
|
||||
import { Graphics, type Text, type TextStyle } from 'pixi.js';
|
||||
import { mixColor } from '../../core/util/color';
|
||||
import { crispText, Knob, textStyle, Widget } from '../../ui';
|
||||
import { secondsText } from './chipView';
|
||||
import { drawPlate, engrave, INK, placeEngraving, type PlateLook } from './CompressorPanel';
|
||||
import { TEXT_ROW } from './delayView';
|
||||
import { type Box, KNOB_CAPTION } from './hardwareView';
|
||||
import {
|
||||
heightOf,
|
||||
lampOf,
|
||||
MOD_LAYOUTS,
|
||||
type ModDevice,
|
||||
type ModKind,
|
||||
type ModTexts,
|
||||
type ModValues,
|
||||
notchesOf,
|
||||
RESPONSE_FLOOR,
|
||||
RESPONSE_TOP,
|
||||
responseColumns,
|
||||
responseDb,
|
||||
shareOfHz,
|
||||
type SwingPicture,
|
||||
swingValueAt,
|
||||
wetKey,
|
||||
} from './modulationView';
|
||||
import { PickButton, type PickLook } from './ReverbPanel';
|
||||
|
||||
/** What the panel asks of the node it sits in. */
|
||||
export interface ModHost {
|
||||
preview(key: string, value: number): void;
|
||||
commit(key: string, value: number): void;
|
||||
pick(value: string): void;
|
||||
}
|
||||
|
||||
/** What the panel shows of a node. */
|
||||
export interface ModShown {
|
||||
values: ModValues;
|
||||
wired: (key: string) => boolean;
|
||||
/** The settings that do nothing as things stand. */
|
||||
idle: ReadonlySet<string>;
|
||||
picture: SwingPicture;
|
||||
texts: ModTexts;
|
||||
}
|
||||
|
||||
/** A device's colours: its plate, its buttons and knobs, its screen, and its lamp dark and lit. */
|
||||
interface ModLook {
|
||||
plate: PlateLook;
|
||||
buttons: PickLook;
|
||||
arc: number;
|
||||
glass: number;
|
||||
grid: number;
|
||||
text: number;
|
||||
dim: number;
|
||||
trace: number;
|
||||
/** A Vibrato's pitch goes from this below to the trace above. */
|
||||
under: number;
|
||||
lamp: [off: number, on: number];
|
||||
}
|
||||
|
||||
/**
|
||||
* A Tremolo in the cream and red of an old amp's front, a Vibrato in plum, a Phaser in mustard as
|
||||
* the pedals of the 70s were, a Flanger in steel blue with a violet jet on its screen.
|
||||
*/
|
||||
const LOOKS: Record<ModKind, ModLook> = {
|
||||
tremolo: {
|
||||
plate: {
|
||||
stops: [
|
||||
[0, 0xf4ecd6],
|
||||
[0.5, 0xe6d9b8],
|
||||
[1, 0xcdbb92],
|
||||
],
|
||||
rim: 0x7a6740,
|
||||
grain: 0x5c4a28,
|
||||
},
|
||||
buttons: { face: 0x4a3f2e, hover: 0x5c5040, picked: 0xc0392b, shadow: 0x241d14 },
|
||||
arc: 0x8a2416,
|
||||
glass: 0x110d08,
|
||||
grid: 0x2e2618,
|
||||
text: 0xffd28a,
|
||||
dim: 0x7a6a50,
|
||||
trace: 0xffb347,
|
||||
under: 0xffb347,
|
||||
lamp: [0x5a1a14, 0xff3b2a],
|
||||
},
|
||||
vibrato: {
|
||||
plate: {
|
||||
stops: [
|
||||
[0, 0xf2c6dc],
|
||||
[0.5, 0xe0a6c4],
|
||||
[1, 0xc283a6],
|
||||
],
|
||||
rim: 0x6e2f50,
|
||||
grain: 0x4f1d38,
|
||||
},
|
||||
buttons: { face: 0x4a2a3c, hover: 0x5c3a4e, picked: 0x8e3fc8, shadow: 0x24121c },
|
||||
arc: 0x4a0f30,
|
||||
glass: 0x0e0912,
|
||||
grid: 0x2a1f30,
|
||||
text: 0xf2c6ff,
|
||||
dim: 0x7a6080,
|
||||
trace: 0xffa8d8,
|
||||
under: 0x8fb4ff,
|
||||
lamp: [0x3e1f4a, 0xc56cff],
|
||||
},
|
||||
phaser: {
|
||||
plate: {
|
||||
stops: [
|
||||
[0, 0xf6dc7a],
|
||||
[0.5, 0xe9c552],
|
||||
[1, 0xcfa632],
|
||||
],
|
||||
rim: 0x7a5a10,
|
||||
grain: 0x5c420a,
|
||||
},
|
||||
buttons: { face: 0x3e3520, hover: 0x504530, picked: 0xd9531e, shadow: 0x1e190c },
|
||||
arc: 0x5a3a00,
|
||||
glass: 0x07100f,
|
||||
grid: 0x18302c,
|
||||
text: 0x9ff5e8,
|
||||
dim: 0x4f7a72,
|
||||
trace: 0x5ef0d8,
|
||||
under: 0x5ef0d8,
|
||||
lamp: [0x5a2a10, 0xff6a2a],
|
||||
},
|
||||
flanger: {
|
||||
plate: {
|
||||
stops: [
|
||||
[0, 0xc9d4e2],
|
||||
[0.5, 0xaebdd0],
|
||||
[1, 0x8c9fb8],
|
||||
],
|
||||
rim: 0x3c4a60,
|
||||
grain: 0x2a3648,
|
||||
},
|
||||
buttons: { face: 0x2a3446, hover: 0x3a4456, picked: 0x6a4fd0, shadow: 0x141a24 },
|
||||
arc: 0x1c2a44,
|
||||
glass: 0x0a0a14,
|
||||
grid: 0x22223a,
|
||||
text: 0xd8c8ff,
|
||||
dim: 0x6a6a90,
|
||||
trace: 0xb48cff,
|
||||
under: 0xb48cff,
|
||||
lamp: [0x1f3550, 0x5ab4ff],
|
||||
},
|
||||
};
|
||||
|
||||
const BEZEL = 0x2a2624;
|
||||
const BEZEL_TEXT = 0xe4dccf;
|
||||
const IDLE_ALPHA = 0.4;
|
||||
/** Cycles of the wave a Tremolo's volume shapes, across its screen. */
|
||||
const CARRIER = 48;
|
||||
/** How finely a Vibrato's pitch is coloured: by chunks of so many points. */
|
||||
const PITCH_CHUNK = 20;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** What both screens share: the glass, what they write and where the light is. */
|
||||
abstract class ModScope extends Widget {
|
||||
protected readonly g = new Graphics();
|
||||
protected readonly light = new Graphics();
|
||||
protected readonly under: Record<'left' | 'right', Text>;
|
||||
picture: SwingPicture | null = null;
|
||||
values: ModValues | null = null;
|
||||
texts: ModTexts = { side: '', left: '', right: '' };
|
||||
/** Where the light is, in seconds after the sound, and what it reads there (checks). */
|
||||
at = 0;
|
||||
reading = 0;
|
||||
|
||||
constructor(
|
||||
protected readonly device: ModDevice,
|
||||
protected readonly look: ModLook,
|
||||
) {
|
||||
super();
|
||||
this.addChild(this.g, this.light);
|
||||
this.under = { left: this.made(9, look.text, 0, 0), right: this.made(9, look.text, 1, 0) };
|
||||
}
|
||||
|
||||
protected made(size: number, fill: number, ax: number, ay: number, text = ''): Text {
|
||||
const t = crispText({ text, style: scopeStyle(size, fill) });
|
||||
t.anchor.set(ax, ay);
|
||||
t.eventMode = 'none';
|
||||
this.addChild(t);
|
||||
return t;
|
||||
}
|
||||
|
||||
show(picture: SwingPicture, values: ModValues, texts: ModTexts): void {
|
||||
this.picture = picture;
|
||||
this.values = values;
|
||||
this.texts = texts;
|
||||
if (!picture.moving) this.at = 0;
|
||||
this.reading = swingValueAt(this.device, values, this.at);
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
protected get glassH(): number {
|
||||
return this.h - TEXT_ROW;
|
||||
}
|
||||
|
||||
/** The glass and what is written under it; the rest is each screen's own. */
|
||||
protected drawGlass(): void {
|
||||
this.g.roundRect(0, 0, this.w, this.glassH, 4).fill(this.look.glass);
|
||||
const y = this.glassH + 3;
|
||||
this.under.left.text = this.texts.left;
|
||||
this.under.left.position.set(0, y);
|
||||
this.under.right.text = this.texts.right;
|
||||
this.under.right.position.set(this.w, y);
|
||||
}
|
||||
|
||||
/** `t` seconds after the sound: the light moves there. */
|
||||
step(t: number): void {
|
||||
if (!this.picture || !this.values) return;
|
||||
this.at = t;
|
||||
this.reading = swingValueAt(this.device, this.values, t);
|
||||
this.drawLight();
|
||||
}
|
||||
|
||||
/** A faint line where every swing of the LFO starts, as long as they are not too close. */
|
||||
protected drawSwings(x0: number, w: number, top: number, bottom: number): void {
|
||||
const rate = this.values?.numbers.rate ?? 0;
|
||||
const span = this.picture?.span ?? 1;
|
||||
if (!(rate > 0) || span * rate > 40) return;
|
||||
for (let k = 1; k / rate < span - 1e-9; k++) {
|
||||
const x = x0 + (k / rate / span) * w;
|
||||
this.g.moveTo(x, top).lineTo(x, bottom);
|
||||
}
|
||||
this.g.stroke({ color: this.look.grid, width: 1 });
|
||||
}
|
||||
|
||||
protected abstract drawLight(): void;
|
||||
}
|
||||
|
||||
/** The Tremolo's and the Vibrato's screen: the volume, or the pitch, over the first seconds of a sound. */
|
||||
class SwingScope extends ModScope {
|
||||
private readonly marks: Record<'top' | 'bottom' | 'span', Text>;
|
||||
|
||||
constructor(device: ModDevice, look: ModLook) {
|
||||
super(device, look);
|
||||
this.marks = {
|
||||
top: this.made(8, look.dim, 0, 0),
|
||||
bottom: this.made(8, look.dim, 0, 1),
|
||||
span: this.made(8, look.dim, 1, 1),
|
||||
};
|
||||
}
|
||||
|
||||
private get plot(): Box {
|
||||
return { x: 8, y: 12, w: this.w - 16, h: this.glassH - 28 };
|
||||
}
|
||||
|
||||
private xOf(t: number): number {
|
||||
const p = this.plot;
|
||||
return p.x + (t / (this.picture?.span ?? 1)) * p.w;
|
||||
}
|
||||
|
||||
/** Where a Vibrato's pitch sits up the screen. */
|
||||
private yOf(value: number): number {
|
||||
const p = this.plot;
|
||||
return p.y + (1 - (this.picture ? heightOf(this.picture, value) : 0.5)) * p.h;
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
const g = this.g.clear();
|
||||
this.drawGlass();
|
||||
const picture = this.picture;
|
||||
const p = this.plot;
|
||||
const mid = p.y + p.h / 2;
|
||||
this.marks.top.text = '';
|
||||
this.marks.bottom.text = '';
|
||||
if (picture) {
|
||||
this.drawSwings(p.x, p.w, p.y - 4, p.y + p.h + 4);
|
||||
const trace = picture.trace;
|
||||
const xAt = (i: number) => p.x + (i / (trace.length - 1)) * p.w;
|
||||
if (this.device.kind === 'tremolo') {
|
||||
// Full volume up and down, and the middle the wave swings round.
|
||||
g.moveTo(p.x, p.y)
|
||||
.lineTo(p.x + p.w, p.y)
|
||||
.moveTo(p.x, p.y + p.h)
|
||||
.lineTo(p.x + p.w, p.y + p.h)
|
||||
.stroke({ color: this.look.grid, width: 1 });
|
||||
g.moveTo(p.x, mid)
|
||||
.lineTo(p.x + p.w, mid)
|
||||
.stroke({ color: this.look.dim, width: 1, alpha: 0.5 });
|
||||
// The sound as a wave its volume shapes, and the volume as the wave's outline.
|
||||
trace.forEach((level, i) => {
|
||||
const y = mid - level * (p.h / 2) * Math.sin((2 * Math.PI * CARRIER * i) / (trace.length - 1));
|
||||
if (i === 0) g.moveTo(xAt(i), y);
|
||||
else g.lineTo(xAt(i), y);
|
||||
});
|
||||
g.stroke({ color: this.look.trace, width: 1, alpha: 0.35 });
|
||||
for (const side of [-1, 1]) {
|
||||
trace.forEach((level, i) => {
|
||||
const y = mid + side * level * (p.h / 2);
|
||||
if (i === 0) g.moveTo(xAt(i), y);
|
||||
else g.lineTo(xAt(i), y);
|
||||
});
|
||||
g.stroke({ color: this.look.trace, width: 2, join: 'round' });
|
||||
}
|
||||
} else {
|
||||
const reach = picture.high;
|
||||
for (const share of [-1, -0.5, 0.5, 1])
|
||||
g.moveTo(p.x, this.yOf(share * reach)).lineTo(p.x + p.w, this.yOf(share * reach));
|
||||
g.stroke({ color: this.look.grid, width: 1 });
|
||||
this.marks.top.text = `+${reach} st`;
|
||||
this.marks.top.position.set(p.x + 2, p.y - 10);
|
||||
this.marks.bottom.text = `-${reach} st`;
|
||||
this.marks.bottom.position.set(p.x + 2, p.y + p.h + 10);
|
||||
// The pitch the sound came at; under a wet below 1 it is heard beside the swing.
|
||||
const wet = Math.max(0, Math.min(1, this.values?.numbers[wetKey(this.device)] ?? 1));
|
||||
g.moveTo(p.x, mid)
|
||||
.lineTo(p.x + p.w, mid)
|
||||
.stroke({ color: this.look.dim, width: 1 + (1 - wet) * 2, alpha: 0.5 + 0.5 * (1 - wet) });
|
||||
// The pitch it swings to: cool below, warm above, in chunks so a few strokes do.
|
||||
for (let from = 0; from < trace.length - 1; from += PITCH_CHUNK) {
|
||||
const to = Math.min(trace.length - 1, from + PITCH_CHUNK);
|
||||
const middle = trace[Math.round((from + to) / 2)]!;
|
||||
g.moveTo(xAt(from), this.yOf(trace[from]!));
|
||||
for (let i = from + 1; i <= to; i++) g.lineTo(xAt(i), this.yOf(trace[i]!));
|
||||
g.stroke({
|
||||
color: mixColor(this.look.under, this.look.trace, heightOf(picture, middle)),
|
||||
width: 2,
|
||||
alpha: 0.35 + 0.65 * wet,
|
||||
join: 'round',
|
||||
});
|
||||
}
|
||||
}
|
||||
this.marks.span.text = secondsText(picture.span);
|
||||
this.marks.span.position.set(p.x + p.w, this.glassH - 2);
|
||||
}
|
||||
this.drawLight();
|
||||
}
|
||||
|
||||
protected drawLight(): void {
|
||||
const s = this.light.clear();
|
||||
const picture = this.picture;
|
||||
if (!picture?.moving || this.w <= 0) return;
|
||||
const p = this.plot;
|
||||
const x = this.xOf(this.at);
|
||||
s.rect(x - 0.5, p.y - 6, 1, p.h + 12).fill({ color: 0xffffff, alpha: 0.35 });
|
||||
const dots =
|
||||
this.device.kind === 'tremolo'
|
||||
? [-1, 1].map((side) => p.y + p.h / 2 + side * this.reading * (p.h / 2))
|
||||
: [this.yOf(this.reading)];
|
||||
for (const y of dots) {
|
||||
s.circle(x, y, 6).fill({ color: this.look.trace, alpha: 0.3 });
|
||||
s.circle(x, y, 3).fill(0xffffff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The Hz written under a Phaser's or a Flanger's screen, and the faint ones between them. */
|
||||
const DECADES: readonly [hz: number, text: string][] = [
|
||||
[100, '100'],
|
||||
[1000, '1k'],
|
||||
[10000, '10k'],
|
||||
];
|
||||
const BETWEEN = [50, 200, 500, 2000, 5000];
|
||||
/** The dB written left of the response, 0 first. */
|
||||
const DB_MARKS = [0, -12, -24];
|
||||
|
||||
/**
|
||||
* The Phaser's and the Flanger's screen: what they do to each frequency where the light is – the
|
||||
* notches they cut, and how they ring – the range they sweep shaded behind it, and under it a lane
|
||||
* of where that goes over the first seconds of a sound.
|
||||
*/
|
||||
class ResponseScope extends ModScope {
|
||||
private readonly curve = new Graphics();
|
||||
private readonly hzMarks: Text[];
|
||||
private readonly dbMarks: Text[];
|
||||
private readonly marks: Record<'lane' | 'span', Text>;
|
||||
/** The notches drawn now, by Hz (checks). */
|
||||
notches: number[] = [];
|
||||
|
||||
constructor(device: ModDevice, look: ModLook) {
|
||||
super(device, look);
|
||||
this.addChildAt(this.curve, 1);
|
||||
this.hzMarks = DECADES.map(([, text]) => this.made(8, look.dim, 0.5, 0, text));
|
||||
this.dbMarks = DB_MARKS.map((db) => this.made(8, look.dim, 1, 0.5, String(db)));
|
||||
this.marks = { lane: this.made(8, look.dim, 0, 0), span: this.made(8, look.dim, 1, 0) };
|
||||
}
|
||||
|
||||
/** The response, with room on its left for its dB. */
|
||||
private get response(): Box {
|
||||
return { x: 28, y: 10, w: this.w - 36, h: 100 };
|
||||
}
|
||||
|
||||
private get lane(): Box {
|
||||
return { x: 8, y: 130, w: this.w - 16, h: this.glassH - 136 };
|
||||
}
|
||||
|
||||
private xOfHz(hz: number): number {
|
||||
const r = this.response;
|
||||
return r.x + Math.max(0, Math.min(1, shareOfHz(hz))) * r.w;
|
||||
}
|
||||
|
||||
private yOfDb(db: number): number {
|
||||
const r = this.response;
|
||||
const clamped = Math.max(RESPONSE_FLOOR, Math.min(RESPONSE_TOP, db));
|
||||
return r.y + ((RESPONSE_TOP - clamped) / (RESPONSE_TOP - RESPONSE_FLOOR)) * r.h;
|
||||
}
|
||||
|
||||
/** Where the light's reading is marked on the frequencies: the Phaser's turn, the Flanger's first notch. */
|
||||
private markHz(reading: number): number | null {
|
||||
if (this.device.kind === 'phaser') return reading;
|
||||
return reading > 0 ? 500 / reading : null;
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
const g = this.g.clear();
|
||||
this.drawGlass();
|
||||
const r = this.response;
|
||||
const picture = this.picture;
|
||||
for (const hz of BETWEEN) g.moveTo(this.xOfHz(hz), r.y).lineTo(this.xOfHz(hz), r.y + r.h);
|
||||
g.stroke({ color: this.look.grid, width: 1, alpha: 0.5 });
|
||||
for (const [hz] of DECADES) g.moveTo(this.xOfHz(hz), r.y).lineTo(this.xOfHz(hz), r.y + r.h);
|
||||
for (const db of DB_MARKS.slice(1)) g.moveTo(r.x, this.yOfDb(db)).lineTo(r.x + r.w, this.yOfDb(db));
|
||||
g.stroke({ color: this.look.grid, width: 1 });
|
||||
g.moveTo(r.x, this.yOfDb(0))
|
||||
.lineTo(r.x + r.w, this.yOfDb(0))
|
||||
.stroke({ color: this.look.dim, width: 1, alpha: 0.6 });
|
||||
this.hzMarks.forEach((t, i) => t.position.set(this.xOfHz(DECADES[i]![0]), r.y + r.h + 2));
|
||||
this.dbMarks.forEach((t, i) => t.position.set(r.x - 4, this.yOfDb(DB_MARKS[i]!)));
|
||||
const lane = this.lane;
|
||||
g.moveTo(lane.x, lane.y - 4)
|
||||
.lineTo(lane.x + lane.w, lane.y - 4)
|
||||
.stroke({ color: this.look.grid, width: 1 });
|
||||
this.marks.lane.position.set(lane.x + 2, lane.y);
|
||||
this.marks.span.position.set(lane.x + lane.w, lane.y);
|
||||
this.marks.lane.text = '';
|
||||
this.marks.span.text = '';
|
||||
if (picture) {
|
||||
// The range the light sweeps the mark through, shaded behind the response.
|
||||
const ends = [this.markHz(picture.lowest), this.markHz(picture.highest)].filter(
|
||||
(hz): hz is number => hz !== null,
|
||||
);
|
||||
if (ends.length === 2 && picture.moving) {
|
||||
const a = this.xOfHz(Math.min(...ends));
|
||||
const b = this.xOfHz(Math.max(...ends));
|
||||
g.rect(a, r.y, Math.max(1, b - a), r.h).fill({ color: this.look.trace, alpha: 0.08 });
|
||||
}
|
||||
this.drawSwings(lane.x, lane.w, lane.y, lane.y + lane.h);
|
||||
const trace = picture.trace;
|
||||
trace.forEach((value, i) => {
|
||||
const x = lane.x + (i / (trace.length - 1)) * lane.w;
|
||||
const y = lane.y + lane.h - heightOf(picture, value) * lane.h;
|
||||
if (i === 0) g.moveTo(x, y);
|
||||
else g.lineTo(x, y);
|
||||
});
|
||||
g.stroke({ color: this.look.trace, width: 1.5, alpha: 0.8, join: 'round' });
|
||||
this.marks.lane.text = this.device.kind === 'phaser' ? 'TURN' : `DELAY UP TO ${picture.high} ms`;
|
||||
this.marks.span.text = secondsText(picture.span);
|
||||
}
|
||||
this.drawLight();
|
||||
}
|
||||
|
||||
protected drawLight(): void {
|
||||
const c = this.curve.clear();
|
||||
const s = this.light.clear();
|
||||
const picture = this.picture;
|
||||
const values = this.values;
|
||||
if (!picture || !values || this.w <= 0) return;
|
||||
const r = this.response;
|
||||
const columns = responseColumns(
|
||||
(hz) => responseDb(this.device, values, this.reading, hz),
|
||||
Math.max(1, Math.round(r.w)),
|
||||
);
|
||||
this.notches = notchesOf(columns);
|
||||
const bottom = r.y + r.h;
|
||||
// Under the response a faint fill, the response itself as thin as a line where it is smooth
|
||||
// and as tall as a column holds where a comb runs too fine for them.
|
||||
columns.forEach((column, i) =>
|
||||
c.rect(r.x + i, this.yOfDb(column.high), 1, bottom - this.yOfDb(column.high)),
|
||||
);
|
||||
c.fill({ color: this.look.trace, alpha: 0.12 });
|
||||
columns.forEach((column, i) => {
|
||||
const top = this.yOfDb(column.high);
|
||||
c.rect(r.x + i, top - 0.75, 1, Math.max(1.5, this.yOfDb(column.low) - top + 0.75));
|
||||
});
|
||||
c.fill(this.look.trace);
|
||||
const hz = this.markHz(this.reading);
|
||||
if (hz !== null && shareOfHz(hz) >= 0 && shareOfHz(hz) <= 1)
|
||||
s.rect(this.xOfHz(hz) - 0.5, r.y, 1, r.h).fill({ color: 0xffffff, alpha: 0.3 });
|
||||
if (!picture.moving) return;
|
||||
const lane = this.lane;
|
||||
const x = lane.x + (this.at / picture.span) * lane.w;
|
||||
const y = lane.y + lane.h - heightOf(picture, this.reading) * lane.h;
|
||||
s.rect(x - 0.5, lane.y - 2, 1, lane.h + 4).fill({ color: 0xffffff, alpha: 0.35 });
|
||||
s.circle(x, y, 5).fill({ color: this.look.trace, alpha: 0.3 });
|
||||
s.circle(x, y, 2.5).fill(0xffffff);
|
||||
}
|
||||
}
|
||||
|
||||
export class ModulationPanel extends Widget {
|
||||
private readonly plate = new Graphics();
|
||||
private readonly bezel = new Graphics();
|
||||
private readonly rules = new Graphics();
|
||||
private readonly lampLight = new Graphics();
|
||||
private readonly look: ModLook;
|
||||
readonly scope: SwingScope | ResponseScope;
|
||||
readonly pickButtons: PickButton<string>[];
|
||||
readonly knobs: Record<string, Knob> = {};
|
||||
private readonly captions: Record<string, { ink: Text; edge: Text }> = {};
|
||||
private readonly groupNames: { ink: Text; edge: Text }[];
|
||||
private readonly pickName: { ink: Text; edge: Text } | null;
|
||||
private readonly title: { ink: Text; edge: Text };
|
||||
private readonly lampCaption: { ink: Text; edge: Text };
|
||||
private readonly screenName: Text;
|
||||
/** What the screen sums up, above it on the right. */
|
||||
readonly sideText: Text;
|
||||
/** The settings shown idle now, and how lit the lamp is, 0 to 1 (checks). */
|
||||
idle: ReadonlySet<string> = new Set();
|
||||
lamp = 0;
|
||||
|
||||
constructor(
|
||||
readonly device: ModDevice,
|
||||
host: ModHost,
|
||||
) {
|
||||
super();
|
||||
const L = MOD_LAYOUTS[device.kind];
|
||||
this.look = LOOKS[device.kind];
|
||||
this.minWidth = L.panel.w;
|
||||
this.minHeight = L.panel.h;
|
||||
this.scope =
|
||||
device.kind === 'tremolo' || device.kind === 'vibrato'
|
||||
? new SwingScope(device, this.look)
|
||||
: new ResponseScope(device, this.look);
|
||||
this.addChild(this.plate, this.rules, this.bezel, this.scope, this.lampLight);
|
||||
this.title = engrave(this, device.title, 14);
|
||||
this.lampCaption = engrave(this, 'RATE', 9);
|
||||
this.screenName = crispText({ text: device.screenName, style: bezelStyle() });
|
||||
this.screenName.eventMode = 'none';
|
||||
this.sideText = crispText({ text: '', style: bezelStyle() });
|
||||
this.sideText.anchor.set(1, 0);
|
||||
this.sideText.eventMode = 'none';
|
||||
this.addChild(this.screenName, this.sideText);
|
||||
this.pickName = device.pick ? engrave(this, device.pick.label, 9) : null;
|
||||
this.pickButtons = (device.pick?.options ?? []).map((option) => {
|
||||
const button = new PickButton(option.value, option.caption, this.look.buttons, (picked) =>
|
||||
host.pick(picked),
|
||||
);
|
||||
this.addChild(button);
|
||||
return button;
|
||||
});
|
||||
this.groupNames = L.groups.map((group) => engrave(this, group.label, 9));
|
||||
for (const look of device.knobs) {
|
||||
const row = look.row;
|
||||
const knob = new Knob('', row.default, {
|
||||
min: row.min ?? 0,
|
||||
max: row.max ?? 1,
|
||||
step: row.step ?? 0.01,
|
||||
taper: look.taper,
|
||||
bipolar: look.bipolar,
|
||||
default: row.default,
|
||||
decimals: look.decimals,
|
||||
size: L.knobs[look.key]!.size,
|
||||
look: 'chrome',
|
||||
typed: false,
|
||||
format: look.format,
|
||||
ink: INK,
|
||||
arc: this.look.arc,
|
||||
onInput: (value) => host.preview(look.key, value),
|
||||
onChange: (value) => host.commit(look.key, value),
|
||||
});
|
||||
this.knobs[look.key] = knob;
|
||||
this.captions[look.key] = engrave(this, look.label.toUpperCase(), 9);
|
||||
this.addChild(knob);
|
||||
}
|
||||
}
|
||||
|
||||
override measure(): { w: number; h: number } {
|
||||
return { ...MOD_LAYOUTS[this.device.kind].panel };
|
||||
}
|
||||
|
||||
sync(shown: ModShown): void {
|
||||
for (const look of this.device.knobs) {
|
||||
const knob = this.knobs[look.key]!;
|
||||
knob.setValue(shown.values.numbers[look.key]!);
|
||||
knob.setEnabled(!shown.wired(look.key));
|
||||
knob.alpha = shown.idle.has(look.key) ? IDLE_ALPHA : 1;
|
||||
this.captions[look.key]!.ink.alpha = knob.alpha;
|
||||
}
|
||||
const pickIdle = this.device.pick !== null && shown.idle.has(this.device.pick.key);
|
||||
for (const button of this.pickButtons) {
|
||||
button.pick(button.value === shown.values.picked);
|
||||
button.alpha = pickIdle ? IDLE_ALPHA : 1;
|
||||
}
|
||||
if (this.pickName) this.pickName.ink.alpha = pickIdle ? IDLE_ALPHA : 1;
|
||||
this.idle = shown.idle;
|
||||
this.scope.show(shown.picture, shown.values, shown.texts);
|
||||
this.sideText.text = shown.texts.side;
|
||||
this.lamp = lampOf(shown.picture, this.scope.reading);
|
||||
this.drawLamp();
|
||||
}
|
||||
|
||||
/** Whether anything moves: there is a swing, and it is heard. */
|
||||
get moving(): boolean {
|
||||
return this.scope.picture?.moving === true;
|
||||
}
|
||||
|
||||
/** `t` seconds after the sound: the screen's light goes there, the lamp swings with it. */
|
||||
step(t: number): void {
|
||||
this.scope.step(t);
|
||||
if (this.scope.picture) this.lamp = lampOf(this.scope.picture, this.scope.reading);
|
||||
this.drawLamp();
|
||||
}
|
||||
|
||||
private drawLamp(): void {
|
||||
const { x, y, r } = MOD_LAYOUTS[this.device.kind].lamp;
|
||||
const [off, on] = this.look.lamp;
|
||||
const lit = this.lamp;
|
||||
const g = this.lampLight.clear();
|
||||
if (lit > 0) g.circle(x, y, r + 4).fill({ color: on, alpha: 0.3 * lit });
|
||||
g.circle(x, y, r)
|
||||
.fill(mixColor(off, on, lit))
|
||||
.stroke({ color: 0x2a2c30, width: 1.5 });
|
||||
g.circle(x - 2, y - 2, 2).fill({ color: 0xffffff, alpha: 0.2 + 0.5 * lit });
|
||||
}
|
||||
|
||||
override layout(): void {
|
||||
const L = MOD_LAYOUTS[this.device.kind];
|
||||
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.sideText.position.set(L.screen.x + L.screen.w, L.bezel.y + 3);
|
||||
this.pickButtons.forEach((button, i) => put(button, L.picks[i]!));
|
||||
if (this.pickName && L.pickGroup)
|
||||
placeEngraving(this.pickName, L.pickGroup.x + L.pickGroup.w / 2, L.pickGroup.y);
|
||||
placeEngraving(this.lampCaption, L.lampCaption.x, L.lampCaption.y);
|
||||
for (const look of this.device.knobs) {
|
||||
const spot = L.knobs[look.key]!;
|
||||
const knob = this.knobs[look.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[look.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 = MOD_LAYOUTS[this.device.kind];
|
||||
drawPlate(this.plate.clear(), L.panel, this.look.plate, 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: 0x161412, width: 1 });
|
||||
// A rule each side of a group's name, engraved as the names are.
|
||||
const r = this.rules.clear();
|
||||
const named: [Box, Text][] = L.groups.map((group, i) => [group.box, this.groupNames[i]!.ink]);
|
||||
if (L.pickGroup && this.pickName) named.push([L.pickGroup, this.pickName.ink]);
|
||||
for (const [box, name] of named) {
|
||||
const mid = box.y + 6;
|
||||
const left = box.x + 6;
|
||||
const right = box.x + box.w - 6;
|
||||
const gap = name.width / 2 + 6;
|
||||
const centre = box.x + 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,651 @@
|
||||
/**
|
||||
* What the nodes of the four effects that swing draw (#398) – the Tremolo, the Vibrato, the Phaser
|
||||
* and the Flanger – as numbers: where the parts of their devices sit, how their knobs turn, what
|
||||
* their screens draw of the first seconds of a sound, and the light that plays it over and over.
|
||||
* Kept apart from the drawing so it is checked without a browser.
|
||||
*/
|
||||
import type { PropertyBag } from '../../core/model/Properties';
|
||||
import type { FlatDef } from '../../core/properties/PropertySchema';
|
||||
import { LFO_SHAPES, lfoAt, type LfoShape, vibratoDelay, vibratoSpeedUp } from '../../core/soundfx/lfo';
|
||||
import {
|
||||
FLANGER_ROWS,
|
||||
LFO_SHAPE,
|
||||
lfoShapeOf,
|
||||
PHASER_ROWS,
|
||||
PHASER_STAGE_ROW,
|
||||
phaserStagesOf,
|
||||
SoundFxFlanger,
|
||||
SoundFxPhaser,
|
||||
SoundFxTremolo,
|
||||
SoundFxVibrato,
|
||||
TREMOLO_ROWS,
|
||||
VIBRATO_ROWS,
|
||||
} from '../../core/soundfx/nodes';
|
||||
import { FLANGER_MAX_MS } from '../../core/soundfx/phaser';
|
||||
import { formatNumber } from '../../ui/math';
|
||||
import { secondsText } from './chipView';
|
||||
import { TEXT_ROW } from './delayView';
|
||||
import { BIG_DIAL, type Box, KNOB_CAPTION, type KnobSpot, screwsOf, SMALL_DIAL } from './hardwareView';
|
||||
import { hzText, percentText } from './reverbView';
|
||||
|
||||
export type ModKind = 'tremolo' | 'vibrato' | 'phaser' | 'flanger';
|
||||
|
||||
/** A knob: the row it sets, its name, how it turns and how its number reads. */
|
||||
export interface ModKnob {
|
||||
key: string;
|
||||
row: FlatDef<'number'>;
|
||||
label: string;
|
||||
taper: number;
|
||||
bipolar?: boolean;
|
||||
decimals: number;
|
||||
format: (value: number) => string;
|
||||
}
|
||||
|
||||
/** The buttons that pick one of a setting's options: an LFO's shape, a Phaser's stages. */
|
||||
export interface ModPick {
|
||||
key: string;
|
||||
label: string;
|
||||
options: { value: string; caption: string }[];
|
||||
of: (params: PropertyBag) => string;
|
||||
}
|
||||
|
||||
export interface ModDevice {
|
||||
kind: ModKind;
|
||||
/** The node it is drawn for. */
|
||||
type: string;
|
||||
title: string;
|
||||
/** What its screen is called, above it on the left. */
|
||||
screenName: string;
|
||||
pick: ModPick | null;
|
||||
knobs: ModKnob[];
|
||||
}
|
||||
|
||||
const rateText = (hz: number): string => `${formatNumber(hz, hz < 1 ? 2 : 1)} Hz`;
|
||||
const msText = (ms: number): string => `${formatNumber(ms, ms < 10 ? 2 : 1)} ms`;
|
||||
const stText = (st: number): string => `${formatNumber(st, 2)} st`;
|
||||
const sweepText = (msPerS: number): string => `${msPerS > 0 ? '+' : ''}${formatNumber(msPerS, 1)} ms/s`;
|
||||
|
||||
const SHAPES: ModPick = {
|
||||
key: LFO_SHAPE.key,
|
||||
label: 'SHAPE',
|
||||
options: LFO_SHAPES.map((option) => ({ value: option.value, caption: option.label.toUpperCase() })),
|
||||
of: lfoShapeOf,
|
||||
};
|
||||
|
||||
const wetKnob = (row: FlatDef<'number'>, key = row.key): ModKnob => ({
|
||||
key,
|
||||
row,
|
||||
label: 'Wet',
|
||||
taper: 1,
|
||||
decimals: 2,
|
||||
format: percentText,
|
||||
});
|
||||
|
||||
/**
|
||||
* The four devices. Rates give their slow end more of the turn – 5 Hz sits at the middle of a
|
||||
* Tremolo's – as do the Phaser's frequency, which is heard in octaves, the Flanger's milliseconds
|
||||
* and its sweep either way of 0.
|
||||
*/
|
||||
export const TREMOLO_DEVICE: ModDevice = {
|
||||
kind: 'tremolo',
|
||||
type: SoundFxTremolo.type,
|
||||
title: 'TREMOLO',
|
||||
screenName: 'VOLUME OVER TIME',
|
||||
pick: SHAPES,
|
||||
knobs: [
|
||||
{ key: 'rate', row: TREMOLO_ROWS.rate, label: 'Rate', taper: 2, decimals: 2, format: rateText },
|
||||
{
|
||||
key: 'depth',
|
||||
row: TREMOLO_ROWS.depth,
|
||||
label: 'Depth',
|
||||
taper: 1,
|
||||
decimals: 0,
|
||||
format: (pct) => `${formatNumber(pct, 0)}%`,
|
||||
},
|
||||
wetKnob(TREMOLO_ROWS.wet),
|
||||
],
|
||||
};
|
||||
|
||||
export const VIBRATO_DEVICE: ModDevice = {
|
||||
kind: 'vibrato',
|
||||
type: SoundFxVibrato.type,
|
||||
title: 'VIBRATO',
|
||||
screenName: 'PITCH OVER TIME',
|
||||
pick: SHAPES,
|
||||
knobs: [
|
||||
{ key: 'rate', row: VIBRATO_ROWS.rate, label: 'Rate', taper: 2, decimals: 2, format: rateText },
|
||||
{ key: 'depth', row: VIBRATO_ROWS.depth, label: 'Depth', taper: 2, decimals: 2, format: stText },
|
||||
wetKnob(VIBRATO_ROWS.wet),
|
||||
],
|
||||
};
|
||||
|
||||
export const PHASER_DEVICE: ModDevice = {
|
||||
kind: 'phaser',
|
||||
type: SoundFxPhaser.type,
|
||||
title: 'PHASER',
|
||||
screenName: 'NOTCHES',
|
||||
pick: {
|
||||
key: PHASER_STAGE_ROW.key,
|
||||
label: 'STAGES',
|
||||
options: PHASER_STAGE_ROW.options.map((option) => ({ value: option.value, caption: option.label })),
|
||||
of: (params) => String(phaserStagesOf(params)),
|
||||
},
|
||||
knobs: [
|
||||
{ key: 'frequency', row: PHASER_ROWS.frequency, label: 'Freq', taper: 4, decimals: 0, format: hzText },
|
||||
{
|
||||
key: 'depth',
|
||||
row: PHASER_ROWS.depth,
|
||||
label: 'Depth',
|
||||
taper: 1,
|
||||
decimals: 1,
|
||||
format: (oct) => `${formatNumber(oct, 1)} oct`,
|
||||
},
|
||||
{ key: 'rate', row: PHASER_ROWS.rate, label: 'Rate', taper: 2, decimals: 2, format: rateText },
|
||||
{
|
||||
key: 'feedback',
|
||||
row: PHASER_ROWS.feedback,
|
||||
label: 'Feedback',
|
||||
taper: 1,
|
||||
decimals: 2,
|
||||
format: percentText,
|
||||
},
|
||||
wetKnob(PHASER_ROWS.mix),
|
||||
],
|
||||
};
|
||||
|
||||
export const FLANGER_DEVICE: ModDevice = {
|
||||
kind: 'flanger',
|
||||
type: SoundFxFlanger.type,
|
||||
title: 'FLANGER',
|
||||
screenName: 'COMB',
|
||||
pick: null,
|
||||
knobs: [
|
||||
{ key: 'offset', row: FLANGER_ROWS.offset, label: 'Offset', taper: 2, decimals: 2, format: msText },
|
||||
{
|
||||
key: 'sweep',
|
||||
row: FLANGER_ROWS.sweep,
|
||||
label: 'Sweep',
|
||||
taper: 3,
|
||||
bipolar: true,
|
||||
decimals: 1,
|
||||
format: sweepText,
|
||||
},
|
||||
{ key: 'rate', row: FLANGER_ROWS.rate, label: 'Rate', taper: 2, decimals: 2, format: rateText },
|
||||
{ key: 'depth', row: FLANGER_ROWS.depth, label: 'Depth', taper: 2, decimals: 2, format: msText },
|
||||
{
|
||||
key: 'feedback',
|
||||
row: FLANGER_ROWS.feedback,
|
||||
label: 'Feedback',
|
||||
taper: 1,
|
||||
decimals: 2,
|
||||
format: percentText,
|
||||
},
|
||||
wetKnob(FLANGER_ROWS.mix),
|
||||
],
|
||||
};
|
||||
|
||||
export const MOD_DEVICES: readonly ModDevice[] = [
|
||||
TREMOLO_DEVICE,
|
||||
VIBRATO_DEVICE,
|
||||
PHASER_DEVICE,
|
||||
FLANGER_DEVICE,
|
||||
];
|
||||
|
||||
/** The key of the knob that blends a device's swing with the sound as it came. */
|
||||
export const wetKey = (device: ModDevice): string => device.knobs.at(-1)!.key;
|
||||
|
||||
export interface ModValues {
|
||||
/** Every knob's number, its row's default where none is typed in. */
|
||||
numbers: Record<string, number>;
|
||||
/** The option its buttons have picked, '' without buttons. */
|
||||
picked: string;
|
||||
}
|
||||
|
||||
export function modValues(device: ModDevice, params: PropertyBag): ModValues {
|
||||
const numbers: Record<string, number> = {};
|
||||
for (const knob of device.knobs) {
|
||||
const value = params[knob.key];
|
||||
numbers[knob.key] = typeof value === 'number' && Number.isFinite(value) ? value : knob.row.default;
|
||||
}
|
||||
return { numbers, picked: device.pick?.of(params) ?? '' };
|
||||
}
|
||||
|
||||
const num = (values: ModValues, key: string): number => values.numbers[key] ?? 0;
|
||||
const clamp01 = (value: number): number => Math.max(0, Math.min(1, value));
|
||||
|
||||
/**
|
||||
* The settings that do nothing as things stand, greyed as the Delay's are: none of the swing is
|
||||
* heard at wet 0, nor at depth 0 of a Tremolo or a Vibrato, which leave the sound alone then. A
|
||||
* Phaser or a Flanger without depth has an LFO that moves nothing.
|
||||
*/
|
||||
export function idleModKeys(device: ModDevice, values: ModValues): Set<string> {
|
||||
const keys = [...device.knobs.map((knob) => knob.key), ...(device.pick ? [device.pick.key] : [])];
|
||||
const wet = wetKey(device);
|
||||
if (num(values, wet) <= 0) return new Set(keys.filter((key) => key !== wet));
|
||||
if (num(values, 'depth') > 0) return new Set();
|
||||
if (device.kind === 'tremolo' || device.kind === 'vibrato')
|
||||
return new Set(keys.filter((key) => key !== 'depth'));
|
||||
return new Set(['rate']);
|
||||
}
|
||||
|
||||
// --- What the light plays: the first seconds of a sound. ---
|
||||
|
||||
/** The seconds a screen spans: the first of these that is long enough. */
|
||||
export const SPANS = [1, 2, 4, 8] as const;
|
||||
|
||||
export const spanFor = (seconds: number): number => SPANS.find((s) => s >= seconds - 1e-9) ?? SPANS.at(-1)!;
|
||||
|
||||
/** Two swings at least of an LFO at `rate` Hz; one that stands still is shown a second. */
|
||||
export const lfoSpan = (rate: number): number => (rate > 0 ? spanFor(2 / rate) : SPANS[0]);
|
||||
|
||||
/** Where the light is, `seconds` on the page's clock: through the span in real time and round again. */
|
||||
export const loopAt = (span: number, seconds: number): number => ((seconds % span) + span) % span;
|
||||
|
||||
/** A Phaser's allpasses turn where its LFO has them `t` seconds after the sound starts (#341). */
|
||||
export function phaserTurnAt(values: ModValues, t: number, rate = SHOWN_RATE): number {
|
||||
const swing = Math.sin(2 * Math.PI * Math.max(0, num(values, 'rate')) * t);
|
||||
const hz = num(values, 'frequency') * 2 ** ((num(values, 'depth') / 2) * swing);
|
||||
return Math.max(10, Math.min(rate * 0.45, hz));
|
||||
}
|
||||
|
||||
/**
|
||||
* How late a Flanger's copy is `t` seconds after the sound starts, in ms (#341): the offset moved on
|
||||
* by the sweep and mirrored at 0, the LFO's swing on top, never past its longest.
|
||||
*/
|
||||
export function flangerDelayAt(values: ModValues, t: number): number {
|
||||
const swept = Math.max(-2 * FLANGER_MAX_MS, Math.min(2 * FLANGER_MAX_MS, num(values, 'sweep') * t));
|
||||
const swing = (1 - Math.cos(2 * Math.PI * Math.max(0, num(values, 'rate')) * t)) / 2;
|
||||
return Math.min(
|
||||
FLANGER_MAX_MS,
|
||||
Math.abs(num(values, 'offset') + swept) + Math.max(0, num(values, 'depth')) * swing,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a device's light reads `t` seconds after the sound starts: the volume a Tremolo lets
|
||||
* through (1 in full, its wet blended in), the pitch a Vibrato bends to in semitones, where a
|
||||
* Phaser's allpasses turn in Hz, how late a Flanger's copy is in ms.
|
||||
*/
|
||||
export function swingValueAt(device: ModDevice, values: ModValues, t: number): number {
|
||||
switch (device.kind) {
|
||||
case 'tremolo': {
|
||||
const lfo = lfoAt(values.picked as LfoShape, Math.max(0, num(values, 'rate')) * t);
|
||||
const depth = clamp01(num(values, 'depth') / 100) * clamp01(num(values, 'wet'));
|
||||
return 1 - (depth * (1 - lfo)) / 2;
|
||||
}
|
||||
case 'vibrato': {
|
||||
const lfo = lfoAt(values.picked as LfoShape, Math.max(0, num(values, 'rate')) * t);
|
||||
return 12 * Math.log2(Math.max(1e-3, 1 + vibratoSpeedUp(num(values, 'depth')) * lfo));
|
||||
}
|
||||
case 'phaser':
|
||||
return phaserTurnAt(values, t);
|
||||
case 'flanger':
|
||||
return flangerDelayAt(values, t);
|
||||
}
|
||||
}
|
||||
|
||||
/** How far a Vibrato's screen reaches up and down, in semitones: the first that holds its swing. */
|
||||
const PITCH_REACHES = [0.5, 1, 2, 4, 8] as const;
|
||||
/** How far up a Flanger's lane of delay goes, in ms. */
|
||||
const DELAY_REACHES = [2.5, 5, 10, FLANGER_MAX_MS] as const;
|
||||
|
||||
export const SWING_POINTS = 601;
|
||||
|
||||
export interface SwingPicture {
|
||||
/** The seconds the screen spans. */
|
||||
span: number;
|
||||
/** The light's reading over the span, `SWING_POINTS` of them. */
|
||||
trace: number[];
|
||||
lowest: number;
|
||||
highest: number;
|
||||
/** What the screen's height stands for, bottom to top; `log` for Hz. */
|
||||
low: number;
|
||||
high: number;
|
||||
log: boolean;
|
||||
/** Whether anything moves: there is a swing, and it is heard. */
|
||||
moving: boolean;
|
||||
}
|
||||
|
||||
/** What a device's screen draws over time, its light's readings over the first seconds of a sound. */
|
||||
export function swingPicture(device: ModDevice, values: ModValues, points = SWING_POINTS): SwingPicture {
|
||||
const n = (key: string) => num(values, key);
|
||||
const lfoMoves = n('rate') > 0 && n('depth') > 0;
|
||||
let span: number = lfoSpan(lfoMoves ? n('rate') : 0);
|
||||
if (device.kind === 'flanger' && n('sweep') !== 0) {
|
||||
// Until the sweep rests the delay at its longest: up from the offset, or down through 0 and up again.
|
||||
const resting =
|
||||
n('sweep') > 0
|
||||
? (FLANGER_MAX_MS - n('offset')) / n('sweep')
|
||||
: (n('offset') + FLANGER_MAX_MS) / -n('sweep');
|
||||
span = Math.max(span, spanFor(resting));
|
||||
}
|
||||
const trace = Array.from({ length: points }, (_, i) =>
|
||||
swingValueAt(device, values, (i / (points - 1)) * span),
|
||||
);
|
||||
const lowest = Math.min(...trace);
|
||||
const highest = Math.max(...trace);
|
||||
const heard = n(wetKey(device)) > 0;
|
||||
const moving = heard && highest - lowest > 1e-9;
|
||||
switch (device.kind) {
|
||||
case 'tremolo':
|
||||
return { span, trace, lowest, highest, low: 0, high: 1, log: false, moving };
|
||||
case 'vibrato': {
|
||||
const reach = Math.max(Math.abs(lowest), Math.abs(highest));
|
||||
const r = PITCH_REACHES.find((p) => p >= reach - 1e-9) ?? PITCH_REACHES.at(-1)!;
|
||||
return { span, trace, lowest, highest, low: -r, high: r, log: false, moving };
|
||||
}
|
||||
case 'phaser': {
|
||||
// Half an octave of room either side, so a Phaser that stands still sits in the middle.
|
||||
const room = 2 ** 0.5;
|
||||
return { span, trace, lowest, highest, low: lowest / room, high: highest * room, log: true, moving };
|
||||
}
|
||||
case 'flanger': {
|
||||
const top = DELAY_REACHES.find((ms) => ms >= highest - 1e-9) ?? FLANGER_MAX_MS;
|
||||
return { span, trace, lowest, highest, low: 0, high: top, log: false, moving };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a reading sits on the screen's height, 0 at the bottom to 1 at the top. */
|
||||
export function heightOf(picture: SwingPicture, value: number): number {
|
||||
const share = picture.log
|
||||
? Math.log(value / picture.low) / Math.log(picture.high / picture.low)
|
||||
: (value - picture.low) / (picture.high - picture.low);
|
||||
return clamp01(share);
|
||||
}
|
||||
|
||||
/** How lit the lamp beside the name is at a reading: from dark at the low end of the swing to full at the top. */
|
||||
export function lampOf(picture: SwingPicture, value: number): number {
|
||||
if (!picture.moving) return 0;
|
||||
return clamp01((value - picture.lowest) / (picture.highest - picture.lowest));
|
||||
}
|
||||
|
||||
// --- What a Phaser or a Flanger does to each frequency. ---
|
||||
|
||||
/** The rate the screens reckon at: the Phaser's allpasses differ a little by it near the top. */
|
||||
export const SHOWN_RATE = 48000;
|
||||
export const LOW_HZ = 20;
|
||||
export const HIGH_HZ = 20000;
|
||||
/** The screen's dB, top to bottom: a notch goes deeper than its floor. */
|
||||
export const RESPONSE_TOP = 12;
|
||||
export const RESPONSE_FLOOR = -36;
|
||||
|
||||
/** A frequency at a share of the screen's width, which is in octaves. */
|
||||
export const hzAt = (share: number): number => LOW_HZ * (HIGH_HZ / LOW_HZ) ** share;
|
||||
export const shareOfHz = (hz: number): number => Math.log(hz / LOW_HZ) / Math.log(HIGH_HZ / LOW_HZ);
|
||||
|
||||
/** `|1 - s + s·y|` in dB for the wet share `s` of a copy `y`, as Phaser and Flanger blend (#341). */
|
||||
function blendDb(share: number, yr: number, yi: number): number {
|
||||
const re = 1 - share + share * yr;
|
||||
const im = share * yi;
|
||||
return 10 * Math.log10(Math.max(1e-12, re * re + im * im));
|
||||
}
|
||||
|
||||
/**
|
||||
* A Phaser's response in dB at `hz` with its allpasses turning at `turn`: its stages in a row, what
|
||||
* leaves them fed back a sample later, blended with the sound by half its wet, as its worklet does.
|
||||
*/
|
||||
export function phaserDb(values: ModValues, turn: number, hz: number, rate = SHOWN_RATE): number {
|
||||
const stages = Number(values.picked) || 4;
|
||||
const feedback = Math.max(-0.95, Math.min(0.95, num(values, 'feedback')));
|
||||
const t = Math.tan((Math.PI * turn) / rate);
|
||||
const a = (t - 1) / (t + 1);
|
||||
const w = (2 * Math.PI * hz) / rate;
|
||||
const zr = Math.cos(w);
|
||||
const zi = -Math.sin(w);
|
||||
// One allpass, (a + z⁻¹) / (1 + a·z⁻¹), then all of them.
|
||||
const nr = a + zr;
|
||||
const dr = 1 + a * zr;
|
||||
const di = a * zi;
|
||||
const d = dr * dr + di * di;
|
||||
const ar = (nr * dr + zi * di) / d;
|
||||
const ai = (zi * dr - nr * di) / d;
|
||||
let pr = 1;
|
||||
let pi = 0;
|
||||
for (let s = 0; s < stages; s++) [pr, pi] = [pr * ar - pi * ai, pr * ai + pi * ar];
|
||||
// y = P / (1 − feedback·z⁻¹·P)
|
||||
const fr = 1 - feedback * (zr * pr - zi * pi);
|
||||
const fi = -feedback * (zr * pi + zi * pr);
|
||||
const f = fr * fr + fi * fi;
|
||||
return blendDb(clamp01(num(values, 'mix')) / 2, (pr * fr + pi * fi) / f, (pi * fr - pr * fi) / f);
|
||||
}
|
||||
|
||||
/** A Flanger's response in dB at `hz` with its copy `ms` late, fed back into its line, blended by half its wet. */
|
||||
export function flangerDb(values: ModValues, ms: number, hz: number): number {
|
||||
const feedback = Math.max(0, Math.min(0.95, num(values, 'feedback')));
|
||||
const phase = (-2 * Math.PI * hz * ms) / 1000;
|
||||
const zr = Math.cos(phase);
|
||||
const zi = Math.sin(phase);
|
||||
// y = z / (1 − feedback·z), z the delay
|
||||
const fr = 1 - feedback * zr;
|
||||
const fi = -feedback * zi;
|
||||
const f = fr * fr + fi * fi;
|
||||
return blendDb(clamp01(num(values, 'mix')) / 2, (zr * fr + zi * fi) / f, (zi * fr - zr * fi) / f);
|
||||
}
|
||||
|
||||
/** A Phaser's or a Flanger's response in dB at `hz`, at the light's reading `at` – where it turns, how late it is. */
|
||||
export function responseDb(device: ModDevice, values: ModValues, at: number, hz: number): number {
|
||||
return device.kind === 'phaser' ? phaserDb(values, at, hz) : flangerDb(values, at, hz);
|
||||
}
|
||||
|
||||
/** Readings within each column of the screen: enough that a notch never slips between them. */
|
||||
const PER_COLUMN = 6;
|
||||
|
||||
/**
|
||||
* The response over `columns` columns of the screen, each the lowest and highest it gets inside
|
||||
* it: a comb too fine for the columns fills the ones it runs through instead of aliasing.
|
||||
*/
|
||||
export function responseColumns(
|
||||
db: (hz: number) => number,
|
||||
columns: number,
|
||||
): { low: number; high: number }[] {
|
||||
return Array.from({ length: columns }, (_, c) => {
|
||||
let low = Infinity;
|
||||
let high = -Infinity;
|
||||
for (let k = 0; k < PER_COLUMN; k++) {
|
||||
const value = db(hzAt((c + (k + 0.5) / PER_COLUMN) / columns));
|
||||
low = Math.min(low, value);
|
||||
high = Math.max(high, value);
|
||||
}
|
||||
return { low, high };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* How far under the sound as it came a column has to reach to count as in a notch: half its power.
|
||||
* Below the loudest would not do, as feedback rings peaks far over 0 dB between the notches.
|
||||
*/
|
||||
const NOTCH_DB = -3;
|
||||
|
||||
/** The notches the columns show: each run of columns under `NOTCH_DB`, by the Hz of its deepest. */
|
||||
export function notchesOf(columns: readonly { low: number; high: number }[]): number[] {
|
||||
const notches: number[] = [];
|
||||
let deepest = -1;
|
||||
columns.forEach((column, c) => {
|
||||
const inside = column.low < NOTCH_DB;
|
||||
if (inside && (deepest < 0 || column.low < columns[deepest]!.low)) deepest = c;
|
||||
if (!inside && deepest >= 0) {
|
||||
notches.push(hzAt((deepest + 0.5) / columns.length));
|
||||
deepest = -1;
|
||||
}
|
||||
});
|
||||
if (deepest >= 0) notches.push(hzAt((deepest + 0.5) / columns.length));
|
||||
return notches;
|
||||
}
|
||||
|
||||
// --- What the screens write. ---
|
||||
|
||||
export interface ModTexts {
|
||||
/** Above the screen on the right. */
|
||||
side: string;
|
||||
/** Under the screen, left and right. */
|
||||
left: string;
|
||||
right: string;
|
||||
}
|
||||
|
||||
const pctText = (share: number): string => `${formatNumber(Math.round(share * 100), 0)}%`;
|
||||
const everyText = (rate: number): string => `EVERY ${secondsText(1 / rate)}`;
|
||||
|
||||
export function modTexts(device: ModDevice, values: ModValues, picture: SwingPicture): ModTexts {
|
||||
const n = (key: string) => num(values, key);
|
||||
const lfoMoves = n('rate') > 0 && n('depth') > 0;
|
||||
if (n(wetKey(device)) <= 0) return { side: 'DRY', left: '', right: '' };
|
||||
switch (device.kind) {
|
||||
case 'tremolo': {
|
||||
if (!(picture.lowest < 1)) return { side: 'NO SWING', left: '', right: '' };
|
||||
const db =
|
||||
picture.lowest > 0
|
||||
? `DIPS ${formatNumber(20 * Math.log10(picture.lowest), 1)} dB`
|
||||
: 'DIPS TO SILENCE';
|
||||
return {
|
||||
side: `DOWN TO ${pctText(picture.lowest)}`,
|
||||
left: n('rate') > 0 ? everyText(n('rate')) : 'STANDS STILL',
|
||||
right: db,
|
||||
};
|
||||
}
|
||||
case 'vibrato': {
|
||||
if (!(n('depth') > 0)) return { side: 'NO SWING', left: '', right: '' };
|
||||
const shape = values.picked as LfoShape;
|
||||
return {
|
||||
side: `UP ${stText(picture.highest)} DOWN ${stText(-picture.lowest)}`,
|
||||
left: everyText(n('rate')),
|
||||
right: `LATE ${msText(1000 * vibratoDelay(shape, n('rate'), n('depth')))}`,
|
||||
};
|
||||
}
|
||||
case 'phaser':
|
||||
return {
|
||||
side: `${Number(values.picked) / 2} NOTCHES`,
|
||||
left: picture.moving
|
||||
? `TURNS ${hzText(picture.lowest)} TO ${hzText(picture.highest)}`
|
||||
: `TURNS AT ${hzText(picture.lowest)}`,
|
||||
right: lfoMoves ? everyText(n('rate')) : 'STANDS STILL',
|
||||
};
|
||||
case 'flanger':
|
||||
return {
|
||||
side: picture.moving
|
||||
? `DELAY ${msText(picture.lowest)} TO ${msText(picture.highest)}`
|
||||
: `DELAY ${msText(picture.lowest)}`,
|
||||
left: lfoMoves ? `LFO ${everyText(n('rate'))}` : 'NO LFO',
|
||||
right: n('sweep') !== 0 ? `SWEEP ${sweepText(n('sweep'))}` : 'NO SWEEP',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// --- The plates. ---
|
||||
|
||||
/** The plates: as the Delay's (#393), a panel never takes another size. */
|
||||
export const MOD_PANELS: Record<ModKind, { w: number; h: number }> = {
|
||||
tremolo: { w: 640, h: 280 },
|
||||
vibrato: { w: 640, h: 280 },
|
||||
phaser: { w: 760, h: 280 },
|
||||
flanger: { w: 680, h: 280 },
|
||||
};
|
||||
|
||||
/** The height of a group's name and its rule. */
|
||||
const GROUP_LINE = 16;
|
||||
const GROUP_GAP = 16;
|
||||
const BUTTON = { h: 22, gap: 6 };
|
||||
/** How wide a letter of a device's name is engraved, near enough to place the lamp after it. */
|
||||
const TITLE_LETTER = 13;
|
||||
|
||||
export interface ModLayout {
|
||||
panel: { w: number; h: number };
|
||||
title: Box;
|
||||
/** The lamp that swings with the light, beside the device's name; its own name after it. */
|
||||
lamp: { x: number; y: number; r: number };
|
||||
lampCaption: { x: number; y: number };
|
||||
bezel: Box;
|
||||
screen: Box;
|
||||
/** The name over the buttons, and the buttons in the order of the options; none for a Flanger. */
|
||||
pickGroup: Box | null;
|
||||
picks: Box[];
|
||||
groups: { label: string; box: Box }[];
|
||||
knobs: Record<string, KnobSpot>;
|
||||
screws: { x: number; y: number }[];
|
||||
}
|
||||
|
||||
function modLayout(device: ModDevice): ModLayout {
|
||||
const panel = MOD_PANELS[device.kind];
|
||||
const wide = device.kind === 'phaser' || device.kind === 'flanger';
|
||||
const screen = { x: 34, y: 60, w: wide ? 300 : 260, h: 176 };
|
||||
const bezel = { x: 24, y: 44, w: screen.w + 20, h: screen.h + 16 + TEXT_ROW + 6 };
|
||||
const left = bezel.x + bezel.w + 24;
|
||||
const right = panel.w - 24;
|
||||
const title = { x: 34, y: 14, w: device.title.length * TITLE_LETTER, h: 24 };
|
||||
const lamp = { x: title.x + title.w + 14, y: 26, r: 6 };
|
||||
|
||||
let pickGroup: Box | null = null;
|
||||
let picks: Box[] = [];
|
||||
if (device.pick) {
|
||||
const options = device.pick.options;
|
||||
pickGroup = { x: left, y: 50, w: right - left, h: GROUP_LINE };
|
||||
const w = (pickGroup.w - (options.length - 1) * BUTTON.gap) / options.length;
|
||||
picks = options.map((_, i) => ({ x: left + i * (w + BUTTON.gap), y: 68, w, h: BUTTON.h }));
|
||||
}
|
||||
|
||||
const groups: { label: string; box: Box }[] = [];
|
||||
const knobs: Record<string, KnobSpot> = {};
|
||||
if (device.kind === 'tremolo' || device.kind === 'vibrato') {
|
||||
// Rate and depth big, the wet small, all on one line under the shapes.
|
||||
const y = 180;
|
||||
knobs.rate = { x: left + 54, y, size: BIG_DIAL };
|
||||
knobs.depth = { x: left + 166, y, size: BIG_DIAL };
|
||||
knobs.wet = { x: right - 34, y, size: SMALL_DIAL };
|
||||
} else if (device.kind === 'phaser') {
|
||||
const sets = [
|
||||
['SWEEP', ['frequency', 'depth', 'rate']],
|
||||
['RING', ['feedback']],
|
||||
['MIX', ['mix']],
|
||||
] as const;
|
||||
const slot = (right - left - (sets.length - 1) * GROUP_GAP) / 5;
|
||||
const top = 128;
|
||||
const y = top + GROUP_LINE + KNOB_CAPTION + SMALL_DIAL / 2;
|
||||
let x = left;
|
||||
for (const [label, keys] of sets) {
|
||||
groups.push({ label, box: { x, y: top, w: keys.length * slot, h: GROUP_LINE } });
|
||||
for (const key of keys) {
|
||||
knobs[key] = { x: x + slot / 2, y, size: SMALL_DIAL };
|
||||
x += slot;
|
||||
}
|
||||
x += GROUP_GAP;
|
||||
}
|
||||
} else {
|
||||
// Three columns of two: the delay, its LFO, and how the comb rings and is heard.
|
||||
const sets = [
|
||||
['DELAY', ['offset', 'sweep']],
|
||||
['LFO', ['rate', 'depth']],
|
||||
['COMB', ['feedback', 'mix']],
|
||||
] as const;
|
||||
const gap = 12;
|
||||
const w = (right - left - (sets.length - 1) * gap) / sets.length;
|
||||
const top = 50;
|
||||
const first = top + GROUP_LINE + KNOB_CAPTION + SMALL_DIAL / 2;
|
||||
sets.forEach(([label, keys], i) => {
|
||||
const x = left + i * (w + gap);
|
||||
groups.push({ label, box: { x, y: top, w, h: GROUP_LINE } });
|
||||
keys.forEach((key, row) => {
|
||||
knobs[key] = { x: x + w / 2, y: first + row * 92, size: SMALL_DIAL };
|
||||
});
|
||||
});
|
||||
}
|
||||
return {
|
||||
panel,
|
||||
title,
|
||||
lamp,
|
||||
lampCaption: { x: lamp.x + 28, y: 21 },
|
||||
bezel,
|
||||
screen,
|
||||
pickGroup,
|
||||
picks,
|
||||
groups,
|
||||
knobs,
|
||||
screws: screwsOf(panel),
|
||||
};
|
||||
}
|
||||
|
||||
export const MOD_LAYOUTS: Record<ModKind, ModLayout> = {
|
||||
tremolo: modLayout(TREMOLO_DEVICE),
|
||||
vibrato: modLayout(VIBRATO_DEVICE),
|
||||
phaser: modLayout(PHASER_DEVICE),
|
||||
flanger: modLayout(FLANGER_DEVICE),
|
||||
};
|
||||
@@ -21,10 +21,12 @@ import { FilterPanel } from './FilterPanel';
|
||||
import { KnobFace } from './KnobFace';
|
||||
import { GAIN_DEVICE, MIX_DEVICE, PAN_DEVICE, PITCH_DEVICE } from './KnobPanel';
|
||||
import { LimiterFace } from './LimiterFace';
|
||||
import { ModulationFace } from './ModulationFace';
|
||||
import { MOD_DEVICES } from './modulationView';
|
||||
import { ReverbFace } from './ReverbFace';
|
||||
import { SaturationFace } from './SaturationFace';
|
||||
|
||||
/** The nodes drawn as a face of their own instead of rows (#380, #381, #384–#386, #388–#390, #392, #393, #396, #397), by type. */
|
||||
/** The nodes drawn as a face of their own instead of rows (#380, #381, #384–#386, #388–#390, #392, #393, #396–#398), by type. */
|
||||
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 === InstrumentEnvelope.type) return new EnvelopeFace(state, assetId, node.id);
|
||||
@@ -40,5 +42,7 @@ export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode
|
||||
if (node.type === SoundFxDelay.type) return new DelayFace(state, assetId, node.id);
|
||||
if (node.type === SoundFxSaturation.type) return new SaturationFace(state, assetId, node.id);
|
||||
if (node.type === SoundFxLimiter.type) return new LimiterFace(state, assetId, node.id);
|
||||
const swinging = MOD_DEVICES.find((device) => device.type === node.type);
|
||||
if (swinging) return new ModulationFace(state, assetId, node.id, swinging);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* The Tremolo, the Vibrato, the Phaser and the Flanger as devices (#398): every part on its plate
|
||||
* and none over another, how their knobs turn, and that what their screens draw is what their
|
||||
* worklets do – the volume, the pitch, and what is left of each frequency.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Tremolo, Vibrato, vibratoDelay } from '../../src/core/soundfx/lfo';
|
||||
import { Flanger, Phaser } from '../../src/core/soundfx/phaser';
|
||||
import { type Box, knobBox } from '../../src/editor/soundfx/hardwareView';
|
||||
import {
|
||||
FLANGER_DEVICE,
|
||||
flangerDb,
|
||||
flangerDelayAt,
|
||||
idleModKeys,
|
||||
lampOf,
|
||||
lfoSpan,
|
||||
loopAt,
|
||||
MOD_DEVICES,
|
||||
MOD_LAYOUTS,
|
||||
type ModDevice,
|
||||
modTexts,
|
||||
modValues,
|
||||
notchesOf,
|
||||
PHASER_DEVICE,
|
||||
phaserDb,
|
||||
responseColumns,
|
||||
SHOWN_RATE,
|
||||
swingPicture,
|
||||
swingValueAt,
|
||||
TREMOLO_DEVICE,
|
||||
VIBRATO_DEVICE,
|
||||
} from '../../src/editor/soundfx/modulationView';
|
||||
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 knob = (device: ModDevice, key: string) => device.knobs.find((k) => k.key === key)!;
|
||||
|
||||
const RATE = SHOWN_RATE;
|
||||
const one = (value: number) => new Float32Array([value]);
|
||||
|
||||
/** How loud a sine at `hz` comes out of `render` once it has settled, in dB against what went in. */
|
||||
function measuredDb(render: (input: Float32Array[], output: Float32Array[]) => void, hz: number): number {
|
||||
const length = RATE;
|
||||
const input = Float32Array.from({ length }, (_, i) => Math.sin((2 * Math.PI * hz * i) / RATE));
|
||||
const output = new Float32Array(length);
|
||||
for (let at = 0; at < length; at += 128)
|
||||
render([input.subarray(at, at + 128)], [output.subarray(at, at + 128)]);
|
||||
const half = length / 2;
|
||||
const rms = (a: Float32Array) => Math.sqrt(a.subarray(half).reduce((s, v) => s + v * v, 0) / half);
|
||||
return 20 * Math.log10(rms(output) / rms(input));
|
||||
}
|
||||
|
||||
describe('the plates', () => {
|
||||
for (const device of MOD_DEVICES) {
|
||||
const L = MOD_LAYOUTS[device.kind];
|
||||
it(`keeps the ${device.title.toLowerCase()}'s screen, buttons and knobs on the plate, clear of each other and the screws`, () => {
|
||||
const screws = L.screws.map((s) => ({ x: s.x - 7, y: s.y - 7, w: 14, h: 14 }));
|
||||
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 };
|
||||
const parts: Box[] = [
|
||||
L.bezel,
|
||||
...(L.pickGroup ? [L.pickGroup] : []),
|
||||
...L.picks,
|
||||
...L.groups.map((g) => g.box),
|
||||
...device.knobs.map((k) => knobBox(L.knobs[k.key]!)),
|
||||
lamp,
|
||||
L.title,
|
||||
];
|
||||
for (const part of parts) {
|
||||
expect(part.x >= 0 && part.y >= 0).toBe(true);
|
||||
expect(part.x + part.w <= L.panel.w && part.y + part.h <= L.panel.h).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('has a button for every option it picks from', () => {
|
||||
expect(MOD_LAYOUTS.tremolo.picks).toHaveLength(4);
|
||||
expect(MOD_LAYOUTS.phaser.picks).toHaveLength(5);
|
||||
expect(MOD_LAYOUTS.flanger.picks).toHaveLength(0);
|
||||
expect(PHASER_DEVICE.pick!.options.map((o) => o.caption)).toEqual(['2', '4', '6', '8', '12']);
|
||||
expect(TREMOLO_DEVICE.pick!.options.map((o) => o.caption)).toEqual(['SINE', 'TRIANGLE', 'SQUARE', 'SAW']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the knobs', () => {
|
||||
const position = (device: ModDevice, key: string, value: number) => {
|
||||
const k = knob(device, key);
|
||||
return knobPosition(value, { min: k.row.min!, max: k.row.max!, step: k.row.step!, ...k });
|
||||
};
|
||||
|
||||
it('gives the slow end of a rate and the low end of a frequency most of the turn, and turns a sweep round 0', () => {
|
||||
expect(position(TREMOLO_DEVICE, 'rate', 5)).toBeCloseTo(0.5);
|
||||
expect(position(PHASER_DEVICE, 'frequency', 800)).toBeCloseTo(0.44, 2);
|
||||
expect(position(FLANGER_DEVICE, 'sweep', 0)).toBeCloseTo(0.5);
|
||||
});
|
||||
|
||||
it('names what each number is', () => {
|
||||
expect(knob(TREMOLO_DEVICE, 'rate').format(5)).toBe('5 Hz');
|
||||
expect(knob(FLANGER_DEVICE, 'rate').format(0.25)).toBe('0.25 Hz');
|
||||
expect(knob(TREMOLO_DEVICE, 'depth').format(50)).toBe('50%');
|
||||
expect(knob(VIBRATO_DEVICE, 'depth').format(0.5)).toBe('0.5 st');
|
||||
expect(knob(PHASER_DEVICE, 'frequency').format(1200)).toBe('1.2 kHz');
|
||||
expect(knob(PHASER_DEVICE, 'depth').format(2)).toBe('2 oct');
|
||||
expect(knob(FLANGER_DEVICE, 'sweep').format(5)).toBe('+5 ms/s');
|
||||
expect(knob(FLANGER_DEVICE, 'sweep').format(-2.5)).toBe('-2.5 ms/s');
|
||||
expect(knob(FLANGER_DEVICE, 'offset').format(1)).toBe('1 ms');
|
||||
});
|
||||
|
||||
it('greys all but the wet at wet 0, all but the depth of a Tremolo at depth 0, the rate of a Phaser without depth', () => {
|
||||
const idle = (device: ModDevice, params: Record<string, number | string>) =>
|
||||
[...idleModKeys(device, modValues(device, params))].sort();
|
||||
expect(idle(TREMOLO_DEVICE, {})).toEqual([]);
|
||||
expect(idle(TREMOLO_DEVICE, { wet: 0 })).toEqual(['depth', 'rate', 'shape']);
|
||||
expect(idle(TREMOLO_DEVICE, { depth: 0 })).toEqual(['rate', 'shape', 'wet']);
|
||||
expect(idle(PHASER_DEVICE, { depth: 0 })).toEqual(['rate']);
|
||||
expect(idle(FLANGER_DEVICE, { mix: 0 })).toEqual(['depth', 'feedback', 'offset', 'rate', 'sweep']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the light', () => {
|
||||
it('spans two swings at least, in whole seconds of 1, 2, 4 or 8', () => {
|
||||
expect(lfoSpan(5)).toBe(1);
|
||||
expect(lfoSpan(0.5)).toBe(4);
|
||||
expect(lfoSpan(0.25)).toBe(8);
|
||||
expect(lfoSpan(0.1)).toBe(8);
|
||||
expect(lfoSpan(0)).toBe(1);
|
||||
});
|
||||
|
||||
it('spans a Flanger until its sweep has the delay at its longest', () => {
|
||||
const values = modValues(FLANGER_DEVICE, { offset: 1, sweep: 5, depth: 0 });
|
||||
expect(swingPicture(FLANGER_DEVICE, values).span).toBe(4);
|
||||
expect(swingPicture(FLANGER_DEVICE, values).highest).toBe(20);
|
||||
});
|
||||
|
||||
it('runs round the span in real time', () => {
|
||||
expect(loopAt(4, 9)).toBe(1);
|
||||
expect(loopAt(4, -1)).toBe(3);
|
||||
});
|
||||
|
||||
it('lights the lamp from dark at the bottom of the swing to full at its top, not at all when nothing moves', () => {
|
||||
const picture = swingPicture(TREMOLO_DEVICE, modValues(TREMOLO_DEVICE, {}));
|
||||
expect(lampOf(picture, 0.5)).toBe(0);
|
||||
expect(lampOf(picture, 1)).toBe(1);
|
||||
expect(lampOf(swingPicture(TREMOLO_DEVICE, modValues(TREMOLO_DEVICE, { rate: 0 })), 1)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Tremolo screen', () => {
|
||||
it('draws the volume its worklet lets through', () => {
|
||||
const values = modValues(TREMOLO_DEVICE, { shape: 'triangle', rate: 3, depth: 80 });
|
||||
const tremolo = new Tremolo('triangle', RATE);
|
||||
const input = new Float32Array(RATE).fill(1);
|
||||
const output = new Float32Array(RATE);
|
||||
tremolo.render([input], [output], { rate: one(3), depth: one(0.8) });
|
||||
for (const i of [0, 1000, 8000, 20000, 47000])
|
||||
expect(output[i]).toBeCloseTo(swingValueAt(TREMOLO_DEVICE, values, i / RATE), 4);
|
||||
});
|
||||
|
||||
it('goes down by the depth as far as the wet lets it, and says so', () => {
|
||||
const picture = (params: Record<string, number>) =>
|
||||
swingPicture(TREMOLO_DEVICE, modValues(TREMOLO_DEVICE, params));
|
||||
expect(picture({}).lowest).toBeCloseTo(0.5);
|
||||
expect(picture({ wet: 0.5 }).lowest).toBeCloseTo(0.75);
|
||||
const values = modValues(TREMOLO_DEVICE, {});
|
||||
expect(modTexts(TREMOLO_DEVICE, values, picture({}))).toEqual({
|
||||
side: 'DOWN TO 50%',
|
||||
left: 'EVERY 200 ms',
|
||||
right: 'DIPS -6 dB',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Vibrato screen', () => {
|
||||
it('draws the pitch its worklet reads the sound at', () => {
|
||||
const params = { shape: 'sine', rate: 4, depth: 2 };
|
||||
const values = modValues(VIBRATO_DEVICE, params);
|
||||
const vibrato = new Vibrato('sine', RATE, vibratoDelay('sine', 4, 2));
|
||||
// A ramp in comes out as where the line is read, so its steps are how fast: the pitch.
|
||||
const input = Float32Array.from({ length: RATE }, (_, i) => i);
|
||||
const output = new Float32Array(RATE);
|
||||
vibrato.render([input], [output], { rate: one(4), depth: one(2) });
|
||||
for (const i of [6000, 9000, 15000, 30000]) {
|
||||
const pitch = 12 * Math.log2(output[i + 1]! - output[i]!);
|
||||
expect(pitch).toBeCloseTo(swingValueAt(VIBRATO_DEVICE, values, i / RATE), 1);
|
||||
}
|
||||
});
|
||||
|
||||
it('swings the depth up and a little more down, and says how late the voice is', () => {
|
||||
const values = modValues(VIBRATO_DEVICE, {});
|
||||
const picture = swingPicture(VIBRATO_DEVICE, values);
|
||||
expect(picture.highest).toBeCloseTo(0.5);
|
||||
expect(picture.lowest).toBeCloseTo(12 * Math.log2(2 - 2 ** (0.5 / 12)));
|
||||
expect(picture.high).toBe(1);
|
||||
expect(modTexts(VIBRATO_DEVICE, values, picture)).toEqual({
|
||||
side: 'UP 0.5 st DOWN 0.51 st',
|
||||
left: 'EVERY 200 ms',
|
||||
right: 'LATE 0.93 ms',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Phaser screen', () => {
|
||||
it('draws what its worklet leaves of a sine', () => {
|
||||
const values = modValues(PHASER_DEVICE, { rate: 0, frequency: 800, feedback: 0.6 });
|
||||
const numbers = {
|
||||
frequency: one(800),
|
||||
rate: one(0),
|
||||
depth: one(2),
|
||||
feedback: one(0.6),
|
||||
mix: one(1),
|
||||
};
|
||||
for (const hz of [150, 500, 2000, 6000]) {
|
||||
const phaser = new Phaser(RATE, 4);
|
||||
const db = measuredDb((input, output) => phaser.render(input, output, numbers), hz);
|
||||
expect(db).toBeCloseTo(phaserDb(values, 800, hz), 2);
|
||||
}
|
||||
});
|
||||
|
||||
it('cuts a notch for every two stages', () => {
|
||||
for (const stages of ['2', '4', '6', '8']) {
|
||||
const values = modValues(PHASER_DEVICE, { stages });
|
||||
const columns = responseColumns((hz) => phaserDb(values, 800, hz), 300);
|
||||
expect(notchesOf(columns)).toHaveLength(Number(stages) / 2);
|
||||
}
|
||||
});
|
||||
|
||||
it('says how many notches, where they turn and how often', () => {
|
||||
const values = modValues(PHASER_DEVICE, {});
|
||||
expect(modTexts(PHASER_DEVICE, values, swingPicture(PHASER_DEVICE, values))).toEqual({
|
||||
side: '2 NOTCHES',
|
||||
left: 'TURNS 400 Hz TO 1.6 kHz',
|
||||
right: 'EVERY 2 s',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Flanger screen', () => {
|
||||
it('draws what its worklet leaves of a sine: a notch at 500 Hz for 1 ms, a peak at 1 kHz', () => {
|
||||
const values = modValues(FLANGER_DEVICE, { offset: 1, depth: 0, feedback: 0.5 });
|
||||
const numbers = {
|
||||
offset: one(1),
|
||||
sweep: one(0),
|
||||
rate: one(0),
|
||||
depth: one(0),
|
||||
feedback: one(0.5),
|
||||
mix: one(1),
|
||||
};
|
||||
for (const hz of [300, 500, 1000, 1700]) {
|
||||
const flanger = new Flanger(RATE);
|
||||
const db = measuredDb((input, output) => flanger.render(input, output, numbers), hz);
|
||||
expect(db).toBeCloseTo(flangerDb(values, 1, hz), 2);
|
||||
}
|
||||
expect(flangerDb(values, 1, 1000)).toBeCloseTo(20 * Math.log10(1.5));
|
||||
expect(flangerDb(values, 1, 500)).toBeLessThan(-15);
|
||||
});
|
||||
|
||||
it('sweeps the delay down through 0 and up again, the LFO on top', () => {
|
||||
const values = modValues(FLANGER_DEVICE, { offset: 1, sweep: -2, depth: 0 });
|
||||
expect(flangerDelayAt(values, 0.5)).toBeCloseTo(0);
|
||||
expect(flangerDelayAt(values, 1)).toBeCloseTo(1);
|
||||
const swinging = modValues(FLANGER_DEVICE, { offset: 1, rate: 0.25, depth: 2 });
|
||||
expect(flangerDelayAt(swinging, 2)).toBeCloseTo(3);
|
||||
});
|
||||
|
||||
it('says how late the copy gets, its LFO and its sweep, or that it is dry', () => {
|
||||
const values = modValues(FLANGER_DEVICE, {});
|
||||
expect(modTexts(FLANGER_DEVICE, values, swingPicture(FLANGER_DEVICE, values))).toEqual({
|
||||
side: 'DELAY 1 ms TO 3 ms',
|
||||
left: 'LFO EVERY 4 s',
|
||||
right: 'NO SWEEP',
|
||||
});
|
||||
const dry = modValues(FLANGER_DEVICE, { mix: 0 });
|
||||
expect(modTexts(FLANGER_DEVICE, dry, swingPicture(FLANGER_DEVICE, dry)).side).toBe('DRY');
|
||||
});
|
||||
});
|
||||
@@ -1554,6 +1554,89 @@ export async function sound(t) {
|
||||
into.to = { node: 'fx-out', port: 'in' };
|
||||
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e-lm');
|
||||
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-lm');`);
|
||||
// #398: a Tremolo, a Vibrato, a Phaser and a Flanger are devices of their own size whose screens
|
||||
// draw what they do over the first seconds of a sound, a light playing it by itself. A
|
||||
// button and a knob are one undo step each, and the screen follows them.
|
||||
const swinging = [
|
||||
{
|
||||
kind: 'tremolo',
|
||||
type: 'SoundFxTremolo',
|
||||
size: [640, 280],
|
||||
inputs: 4,
|
||||
pick: 'square',
|
||||
knob: 'depth',
|
||||
shown: (p) => p.knobs.rate.number === '5 Hz' && p.side === 'DOWN TO 50%' && p.picked === 'sine',
|
||||
turned: (p) => p.side !== 'DOWN TO 50%' && p.picked === 'square',
|
||||
},
|
||||
{
|
||||
kind: 'vibrato',
|
||||
type: 'SoundFxVibrato',
|
||||
size: [640, 280],
|
||||
inputs: 4,
|
||||
pick: 'triangle',
|
||||
knob: 'depth',
|
||||
shown: (p) => p.side === 'UP 0.5 st DOWN 0.51 st' && p.scope.says.right === 'LATE 0.93 ms',
|
||||
turned: (p) => p.scope.highest > 0.5 && p.picked === 'triangle',
|
||||
},
|
||||
{
|
||||
kind: 'phaser',
|
||||
type: 'SoundFxPhaser',
|
||||
size: [760, 280],
|
||||
inputs: 6,
|
||||
pick: '8',
|
||||
knob: 'frequency',
|
||||
shown: (p) => p.side === '2 NOTCHES' && p.scope.notches.length === 2,
|
||||
turned: (p) => p.side === '4 NOTCHES' && p.scope.notches.length === 4 && p.scope.highest > 1600,
|
||||
},
|
||||
{
|
||||
kind: 'flanger',
|
||||
type: 'SoundFxFlanger',
|
||||
size: [680, 280],
|
||||
inputs: 7,
|
||||
pick: null,
|
||||
knob: 'sweep',
|
||||
shown: (p) => p.side === 'DELAY 1 ms TO 3 ms' && p.scope.says.right === 'NO SWEEP',
|
||||
turned: (p) => p.scope.says.right.startsWith('SWEEP +'),
|
||||
},
|
||||
];
|
||||
for (const device of swinging) {
|
||||
const id = `fx-${device.kind}`;
|
||||
const panel = () => json(`__gm.${device.kind}.panel('${id}')`);
|
||||
await mutate(`
|
||||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||||
graph.nodes.push(${JSON.stringify(node(id, device.type, 0, 400, {}))});`);
|
||||
await evaluate('__gm.soundfx.fit()');
|
||||
await sleep(100);
|
||||
const shown = await panel();
|
||||
await sleep(150);
|
||||
const later = (await panel()).scope.at;
|
||||
if (device.pick) await click(shown.picks[device.pick].x, shown.picks[device.pick].y);
|
||||
const dial = shown.knobs[device.knob].dial;
|
||||
await drag(dial.x, dial.y, dial.x, dial.y - 20);
|
||||
const turned = { params: await knobParams(id), panel: await panel() };
|
||||
for (let i = 0; i < (device.pick ? 2 : 1); i++) await press('z', { ctrl: true });
|
||||
const undone = { params: await knobParams(id), panel: await panel() };
|
||||
check(
|
||||
`a ${device.kind} node is a device whose screen plays what it does; a button and a knob are one undo step each`,
|
||||
shown.size.w === device.size[0] &&
|
||||
shown.size.h === device.size[1] &&
|
||||
Object.values(shown.inputs).length === device.inputs &&
|
||||
Object.values(shown.inputs).every((p) => p.x < shown.rect.x) &&
|
||||
Object.values(shown.outputs).every((p) => p.x > shown.rect.x + shown.rect.w) &&
|
||||
device.shown(shown) &&
|
||||
shown.scope.moving &&
|
||||
// The light runs on by itself.
|
||||
later !== shown.scope.at &&
|
||||
turned.params[device.knob] !== undefined &&
|
||||
device.turned(turned.panel) &&
|
||||
Object.keys(undone.params).length === 0 &&
|
||||
device.shown(undone.panel),
|
||||
JSON.stringify({ shown, later, turned, undone }),
|
||||
);
|
||||
await mutate(`
|
||||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||||
graph.nodes = graph.nodes.filter((n) => n.id !== ${JSON.stringify(id)});`);
|
||||
}
|
||||
await showTab('scene');
|
||||
await mutate(`
|
||||
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
|
||||
|
||||
Reference in new issue
Block a user