Show an Envelope node as a device with its ADSR curve (#388)
The Compressor, the Gain and the Chip are devices in the graph. The Envelope was still a list of rows, and its shape was left to the imagination of whoever read the numbers. - The node is a plate of 520x280 in pale sea green. Its inputs are on the left, out and value on the right. - A dark screen draws the curve over a note in amber. A letter sits above each stage, its time below it, and the sustain in percent above its line. - The time on the screen is squeezed. Each stage is as wide as the root of its seconds, at least 3 %, and the hold has a fixed fifth. So 5 ms of attack still shows beside a release of 3 s. - The falls use SETTLE, as the player does. With sustain 0 there is no hold and no release, and the screen says NOTE ENDS. - Attack, Decay, Sustain and Release are chrome knobs. The times use taper 4, so 5 ms sits an eighth of the way and 1 s not quite half. - From and To have no range a knob could turn through. They are NumberFields on the plate, typed in or scrubbed. - The decay at sustain 1 and From/To while nothing takes value are faded. While a link takes value, the bezel says what it moves between, as VALUE 400 TO 3500. The UI font has no arrow. - Instrument presets give an Envelope 3 columns of 240. - Test hook __gm.envelope.panel(nodeId). Tests: - Unit envelope-view: 6 new tests for the plate, the knob turn, the idle settings and the curve. A linear decay turns two of them red. - e2e 34 (atx): 1 new check on the Laser preset (device between its ports, curve ends with the decay, decay knob and scrubbed To field each one undo step). - Probe pictures on stein2: Synth Brass preset, pad, pluck, organ and an Envelope whose value moves a Chip. - Gate (atx): CHECK EXIT 0, 286 files, 4383 tests. The first run was red on the font test (#214) for an arrow in the bezel text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ab37d3423a
commit
c75e72d4ee
9 files changed
+887
-5
No files matched your search
@@ -395,9 +395,13 @@ const COLUMN = 240;
|
||||
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.
|
||||
* a Chip's device (#386) wider, an Envelope's (#388) a little less.
|
||||
*/
|
||||
const COLUMNS_OF: Readonly<Record<string, number>> = { [SoundFxFilter.type]: 4, [INSTRUMENT_CHIP]: 5 };
|
||||
const COLUMNS_OF: Readonly<Record<string, number>> = {
|
||||
[SoundFxFilter.type]: 4,
|
||||
[INSTRUMENT_CHIP]: 5,
|
||||
[INSTRUMENT_ENVELOPE]: 3,
|
||||
};
|
||||
|
||||
/** The column right of a node, past all it takes. */
|
||||
const columnAfter = (node: GraphNode): number => node.x / COLUMN + (COLUMNS_OF[node.type] ?? 1);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||
import type { Widget } from '../../ui';
|
||||
import { currentNodePalette } from '../graph/NodePalette';
|
||||
import { ChipPanel } from '../soundfx/ChipPanel';
|
||||
import { EnvelopePanel } from '../soundfx/EnvelopePanel';
|
||||
import { CompressorPanel } from '../soundfx/CompressorPanel';
|
||||
import { GainPanel } from '../soundfx/GainPanel';
|
||||
import { CurvePanel } from '../soundfx/CurvePanel';
|
||||
@@ -317,6 +318,54 @@ export function graphHooks({ state, editor }: HookContext) {
|
||||
};
|
||||
},
|
||||
},
|
||||
/**
|
||||
* The panel an Envelope node shows in the instrument graph (#388): whether it is shown and
|
||||
* where, its knobs and fields, which settings are idle, the curve's stages, what the screen says
|
||||
* and the node's ports.
|
||||
*/
|
||||
envelope: {
|
||||
panel: (nodeId: string) => {
|
||||
const canvas = editor.instrumentEditor.canvas;
|
||||
const panel = canvas.faceOf(nodeId)?.widget;
|
||||
if (!(panel instanceof EnvelopePanel)) 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 trace = panel.screen.trace;
|
||||
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() },
|
||||
]),
|
||||
),
|
||||
fields: Object.fromEntries(
|
||||
Object.entries(panel.fields).map(([key, field]) => [
|
||||
key,
|
||||
{ text: field.value, enabled: field.enabled, at: screenCentre(field) },
|
||||
]),
|
||||
),
|
||||
idle: [...panel.idle],
|
||||
/** The texts the screen shows, by name. */
|
||||
screen: Object.fromEntries(panel.screen.says),
|
||||
/** What the value moves between, above the screen; empty while nothing takes it. */
|
||||
range: panel.range.text,
|
||||
trace: trace && {
|
||||
stages: trace.stages.map((s) => ({ key: s.key, from: s.from, to: s.to, seconds: s.seconds })),
|
||||
sustain: trace.sustain,
|
||||
ends: trace.ends,
|
||||
/** The level where the curve stops. */
|
||||
last: trace.points.at(-1)?.y ?? null,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
instrument: {
|
||||
current: () => editor.instrumentEditor.currentAssetId,
|
||||
info: () => editor.instrumentEditor.shownInfo,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { GraphNode } from '../../core/graph';
|
||||
import { InstrumentEnvelope } from '../../core/instrument/nodes';
|
||||
import { findGraphAsset } from '../../core/model/Project';
|
||||
import type { NodeFace } from '../graph/nodeFace';
|
||||
import type { EditorState } from '../state';
|
||||
import { compressorWired } from './CompressorEditor';
|
||||
import { type EnvelopeHost, EnvelopePanel } from './EnvelopePanel';
|
||||
import { type EnvelopeKey, envelopeValues, idleEnvelopeKeys } from './envelopeView';
|
||||
import { NodeParamDrag } from './nodeParamDrag';
|
||||
|
||||
/** The output an Envelope moves a number by. */
|
||||
const VALUE = 'value';
|
||||
|
||||
/**
|
||||
* An Envelope node drawn as its device (#388): its curve drawn from its settings, every one of them
|
||||
* worked right in the graph. It listens to nothing, so it has nothing to move between changes.
|
||||
*/
|
||||
export class EnvelopeFace implements NodeFace, EnvelopeHost {
|
||||
readonly widget: EnvelopePanel;
|
||||
private readonly drag: NodeParamDrag;
|
||||
|
||||
constructor(
|
||||
private readonly state: EditorState,
|
||||
private readonly assetId: string,
|
||||
private readonly nodeId: string,
|
||||
) {
|
||||
this.widget = new EnvelopePanel(this);
|
||||
this.drag = new NodeParamDrag(state, 'envelope-face');
|
||||
}
|
||||
|
||||
private node(): GraphNode | null {
|
||||
const node = findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find(
|
||||
(n) => n.id === this.nodeId,
|
||||
);
|
||||
return node?.type === InstrumentEnvelope.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 = envelopeValues(node.params);
|
||||
const valueTaken = Boolean(
|
||||
findGraphAsset(this.state.project, this.assetId)?.graph.edges.some(
|
||||
(e) => e.from.node === this.nodeId && e.from.port === VALUE,
|
||||
),
|
||||
);
|
||||
this.widget.sync({
|
||||
values,
|
||||
wired: this.wired,
|
||||
valueTaken,
|
||||
idle: idleEnvelopeKeys(values, this.wired, valueTaken),
|
||||
});
|
||||
}
|
||||
|
||||
tick(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
hide(): void {}
|
||||
|
||||
preview(key: EnvelopeKey, value: number): void {
|
||||
const node = this.node();
|
||||
if (node) this.drag.preview(node, key, value);
|
||||
}
|
||||
|
||||
commit(key: EnvelopeKey, value: number): void {
|
||||
const node = this.node();
|
||||
if (node) this.drag.commit(this.assetId, node, key, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* The Envelope as a device (#388), what its node shows in the instrument graph: the Compressor's
|
||||
* plate (#379) in pale sea green, a dark screen that draws the curve over a note in amber, a chrome
|
||||
* knob for each stage and two typed numbers for where the value goes from and to.
|
||||
*/
|
||||
import { Graphics, type Text, type TextStyle } from 'pixi.js';
|
||||
import { ENVELOPE_ROWS } from '../../core/instrument/nodes';
|
||||
import { crispText, Knob, NumberField, textStyle, Widget } from '../../ui';
|
||||
import { formatNumber } from '../../ui/math';
|
||||
import { apartLabels } from './eqView';
|
||||
import { drawPlate, engrave, INK, placeEngraving, type PlateLook } from './CompressorPanel';
|
||||
import {
|
||||
ENVELOPE_FIELD_KEYS,
|
||||
ENVELOPE_KNOB_KEYS,
|
||||
ENVELOPE_KNOBS,
|
||||
ENVELOPE_LAYOUT,
|
||||
ENVELOPE_PANEL,
|
||||
type EnvelopeKey,
|
||||
type EnvelopeTrace,
|
||||
envelopeTrace,
|
||||
type EnvelopeValues,
|
||||
STAGE_LETTERS,
|
||||
stageText,
|
||||
sustainText,
|
||||
} from './envelopeView';
|
||||
import { type Box, KNOB_CAPTION } from './hardwareView';
|
||||
|
||||
/** What the panel asks of the node it sits in. */
|
||||
export interface EnvelopeHost {
|
||||
preview(key: EnvelopeKey, value: number): void;
|
||||
commit(key: EnvelopeKey, value: number): void;
|
||||
}
|
||||
|
||||
/** What the panel shows of a node. */
|
||||
export interface EnvelopeShown {
|
||||
values: EnvelopeValues;
|
||||
wired: (key: EnvelopeKey) => boolean;
|
||||
/** Whether a link takes the value: the screen then says what it moves between. */
|
||||
valueTaken: boolean;
|
||||
/** The settings that do nothing as things stand. */
|
||||
idle: ReadonlySet<EnvelopeKey>;
|
||||
}
|
||||
|
||||
/** Pale anodised sea green. */
|
||||
const SEA: PlateLook = {
|
||||
stops: [
|
||||
[0, 0xcdeae2],
|
||||
[0.5, 0xb2dccf],
|
||||
[1, 0x91c4b5],
|
||||
],
|
||||
rim: 0x5f9384,
|
||||
grain: 0x3d6e60,
|
||||
};
|
||||
const BEZEL = 0x2f3a3a;
|
||||
const BEZEL_TEXT = 0xb6d4cc;
|
||||
/** An oscilloscope's dark glass and its amber trace. */
|
||||
const SCOPE = { glass: 0x101a1b, grid: 0x223536, trace: 0xffb43c, dim: 0xa0742c };
|
||||
const IDLE_ALPHA = 0.4;
|
||||
/** The rows of text above and below the curve. */
|
||||
const TEXT_ROW = 14;
|
||||
|
||||
function scopeStyle(size: number, fill: number = SCOPE.trace): TextStyle {
|
||||
const style = textStyle('small').clone();
|
||||
style.fill = fill;
|
||||
style.fontFamily = 'monospace';
|
||||
style.fontSize = size;
|
||||
style.fontWeight = 'bold';
|
||||
return style;
|
||||
}
|
||||
|
||||
function dashed(g: Graphics, x0: number, x1: number, y: number, dash = 3): void {
|
||||
for (let x = x0; x < x1; x += dash * 2) g.moveTo(x, y).lineTo(Math.min(x1, x + dash), y);
|
||||
}
|
||||
|
||||
/**
|
||||
* The screen: the curve over a note, a letter above each stage and its time below it, and the
|
||||
* sustain above its line.
|
||||
*/
|
||||
class EnvelopeScreen extends Widget {
|
||||
private readonly g = new Graphics();
|
||||
private readonly texts = new Map<string, Text>();
|
||||
trace: EnvelopeTrace | null = null;
|
||||
/** What the texts say now, by name (checks). */
|
||||
readonly says = new Map<string, string>();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addChild(this.g);
|
||||
}
|
||||
|
||||
show(trace: EnvelopeTrace): void {
|
||||
this.trace = trace;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
/** Where the curve is drawn: between the rows of text, a little in from the sides. */
|
||||
get plot(): Box {
|
||||
return { x: 8, y: TEXT_ROW + 4, w: this.w - 16, h: this.h - 2 * TEXT_ROW - 8 };
|
||||
}
|
||||
|
||||
/** A text of the screen by name, made the first time it is asked for. */
|
||||
private text(name: string, size: number, fill?: number): Text {
|
||||
let t = this.texts.get(name);
|
||||
if (!t) {
|
||||
t = crispText({ text: '', style: scopeStyle(size, fill) });
|
||||
t.eventMode = 'none';
|
||||
this.texts.set(name, t);
|
||||
this.addChild(t);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
/** Writes and places each text in the order given, leaving out one that would run into the last. */
|
||||
private row(
|
||||
items: { name: string; says: string; x: number; y: number; anchor: number; dim?: boolean }[],
|
||||
): void {
|
||||
const made = items.map((item) => {
|
||||
const t = this.text(item.name, 9, item.dim ? SCOPE.dim : SCOPE.trace);
|
||||
t.text = item.says;
|
||||
t.anchor.set(item.anchor, 0);
|
||||
t.position.set(Math.round(item.x), Math.round(item.y));
|
||||
return t;
|
||||
});
|
||||
const shown = apartLabels(
|
||||
made.map((t, i) => {
|
||||
const left = items[i]!.x - t.width * items[i]!.anchor;
|
||||
return { from: left, to: left + t.width };
|
||||
}),
|
||||
4,
|
||||
);
|
||||
made.forEach((t, i) => {
|
||||
t.visible = shown[i]!;
|
||||
if (t.visible) this.says.set(items[i]!.name, items[i]!.says);
|
||||
});
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
const g = this.g.clear();
|
||||
g.roundRect(0, 0, this.w, this.h, 3).fill(SCOPE.glass);
|
||||
for (const t of this.texts.values()) t.visible = false;
|
||||
this.says.clear();
|
||||
const trace = this.trace;
|
||||
if (!trace) return;
|
||||
const p = this.plot;
|
||||
const x = (share: number) => p.x + share * p.w;
|
||||
const y = (level: number) => p.y + (1 - level) * p.h;
|
||||
for (let i = 0; i <= 4; i++) g.moveTo(p.x, p.y + (p.h * i) / 4).lineTo(p.x + p.w, p.y + (p.h * i) / 4);
|
||||
for (const stage of trace.stages.slice(1)) g.moveTo(x(stage.from), p.y).lineTo(x(stage.from), p.y + p.h);
|
||||
g.stroke({ color: SCOPE.grid, width: 1 });
|
||||
const hold = trace.stages.find((stage) => stage.key === 'hold');
|
||||
if (hold) {
|
||||
dashed(g, p.x, x(hold.from), y(trace.sustain));
|
||||
g.stroke({ color: SCOPE.dim, width: 1 });
|
||||
}
|
||||
const line = trace.points.map((q) => ({ x: x(q.x), y: y(q.y) }));
|
||||
line.forEach((q, i) => (i === 0 ? g.moveTo(q.x, q.y) : g.lineTo(q.x, q.y)));
|
||||
g.lineTo(line[line.length - 1]!.x, y(0)).lineTo(line[0]!.x, y(0));
|
||||
g.fill({ color: SCOPE.trace, alpha: 0.14 });
|
||||
line.forEach((q, i) => (i === 0 ? g.moveTo(q.x, q.y) : g.lineTo(q.x, q.y)));
|
||||
g.stroke({ color: SCOPE.trace, width: 2, join: 'round' });
|
||||
|
||||
const centre = (stage: { from: number; to: number }) => x((stage.from + stage.to) / 2);
|
||||
this.row([
|
||||
...(trace.ends ? [{ name: 'ends', says: 'NOTE ENDS', x: p.x + p.w, y: 2, anchor: 1, dim: true }] : []),
|
||||
...trace.stages.map((stage) => ({
|
||||
name: `letter-${stage.key}`,
|
||||
says: STAGE_LETTERS[stage.key],
|
||||
x: centre(stage),
|
||||
y: 2,
|
||||
anchor: 0.5,
|
||||
})),
|
||||
]);
|
||||
this.row(
|
||||
trace.stages.map((stage) => ({
|
||||
name: `time-${stage.key}`,
|
||||
says: stageText(stage),
|
||||
x: centre(stage),
|
||||
y: this.h - TEXT_ROW,
|
||||
anchor: 0.5,
|
||||
dim: stage.key === 'hold',
|
||||
})),
|
||||
);
|
||||
if (hold)
|
||||
this.row([
|
||||
{
|
||||
name: 'sustain',
|
||||
says: sustainText(trace.sustain),
|
||||
x: centre(hold),
|
||||
y: Math.max(p.y, y(trace.sustain) - 13),
|
||||
anchor: 0.5,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
export class EnvelopePanel extends Widget {
|
||||
private readonly plate = new Graphics();
|
||||
private readonly bezel = new Graphics();
|
||||
private readonly rules = new Graphics();
|
||||
readonly screen = new EnvelopeScreen();
|
||||
readonly knobs = {} as Record<(typeof ENVELOPE_KNOB_KEYS)[number], Knob>;
|
||||
readonly fields = {} as Record<(typeof ENVELOPE_FIELD_KEYS)[number], NumberField>;
|
||||
private readonly captions = {} as Record<EnvelopeKey, { ink: Text; edge: Text }>;
|
||||
private readonly groupNames: { ink: Text; edge: Text }[];
|
||||
private readonly title: { ink: Text; edge: Text };
|
||||
private readonly screenName: Text;
|
||||
/** What the value moves between, while a link takes it (checks). */
|
||||
readonly range: Text;
|
||||
/** The settings shown idle now (checks). */
|
||||
idle: ReadonlySet<EnvelopeKey> = new Set();
|
||||
|
||||
constructor(host: EnvelopeHost) {
|
||||
super();
|
||||
this.minWidth = ENVELOPE_PANEL.w;
|
||||
this.minHeight = ENVELOPE_PANEL.h;
|
||||
this.addChild(this.plate, this.rules, this.bezel, this.screen);
|
||||
this.title = engrave(this, 'ENVELOPE', 14);
|
||||
const nameStyle = textStyle('small').clone();
|
||||
nameStyle.fill = BEZEL_TEXT;
|
||||
nameStyle.fontSize = 9;
|
||||
nameStyle.fontWeight = 'bold';
|
||||
nameStyle.letterSpacing = 1;
|
||||
this.screenName = crispText({ text: 'LEVEL OVER A NOTE', style: nameStyle });
|
||||
this.screenName.eventMode = 'none';
|
||||
this.range = crispText({ text: '', style: nameStyle });
|
||||
this.range.anchor.set(1, 0);
|
||||
this.range.eventMode = 'none';
|
||||
this.addChild(this.screenName, this.range);
|
||||
this.groupNames = ENVELOPE_LAYOUT.groups.map((group) => engrave(this, group.label, 9));
|
||||
for (const key of ENVELOPE_KNOB_KEYS) {
|
||||
const row = ENVELOPE_ROWS[key];
|
||||
const look = ENVELOPE_KNOBS[key];
|
||||
const knob = new Knob('', row.default, {
|
||||
min: row.min ?? 0,
|
||||
max: row.max ?? 0,
|
||||
step: row.step ?? 1,
|
||||
taper: look.taper,
|
||||
default: row.default,
|
||||
decimals: look.decimals,
|
||||
size: ENVELOPE_LAYOUT.knobs[key].size,
|
||||
look: 'chrome',
|
||||
typed: false,
|
||||
format: look.format,
|
||||
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);
|
||||
}
|
||||
for (const key of ENVELOPE_FIELD_KEYS) {
|
||||
const row = ENVELOPE_ROWS[key];
|
||||
const field = new NumberField(row.default, {
|
||||
width: ENVELOPE_LAYOUT.fields[key].w,
|
||||
step: row.step,
|
||||
decimals: 2,
|
||||
onInput: (value) => host.preview(key, value),
|
||||
onChange: (value) => host.commit(key, value),
|
||||
});
|
||||
this.fields[key] = field;
|
||||
this.captions[key] = engrave(this, key.toUpperCase(), 9);
|
||||
this.addChild(field);
|
||||
}
|
||||
}
|
||||
|
||||
override measure(): { w: number; h: number } {
|
||||
return { ...ENVELOPE_PANEL };
|
||||
}
|
||||
|
||||
sync(shown: EnvelopeShown): void {
|
||||
for (const key of ENVELOPE_KNOB_KEYS) {
|
||||
const knob = this.knobs[key];
|
||||
knob.setValue(shown.values[key]);
|
||||
knob.setEnabled(!shown.wired(key));
|
||||
knob.alpha = shown.idle.has(key) ? IDLE_ALPHA : 1;
|
||||
}
|
||||
for (const key of ENVELOPE_FIELD_KEYS) {
|
||||
const field = this.fields[key];
|
||||
field.setNumber(shown.values[key]);
|
||||
field.setEnabled(!shown.wired(key));
|
||||
field.alpha = shown.idle.has(key) ? IDLE_ALPHA : 1;
|
||||
}
|
||||
for (const key of [...ENVELOPE_KNOB_KEYS, ...ENVELOPE_FIELD_KEYS])
|
||||
this.captions[key].ink.alpha = shown.idle.has(key) ? IDLE_ALPHA : 1;
|
||||
this.idle = shown.idle;
|
||||
this.screen.show(envelopeTrace(shown.values));
|
||||
// Above the screen, clear of the curve: what the value moves between while a link takes it.
|
||||
this.range.text = shown.valueTaken
|
||||
? `VALUE ${formatNumber(shown.values.from, 2)} TO ${formatNumber(shown.values.to, 2)}`
|
||||
: '';
|
||||
}
|
||||
|
||||
override layout(): void {
|
||||
const L = ENVELOPE_LAYOUT;
|
||||
this.screen.position.set(L.screen.x, L.screen.y);
|
||||
this.screen.resize(L.screen.w, L.screen.h);
|
||||
this.screenName.position.set(L.screen.x, L.bezel.y + 3);
|
||||
this.range.position.set(L.screen.x + L.screen.w, L.bezel.y + 3);
|
||||
for (const key of ENVELOPE_KNOB_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);
|
||||
}
|
||||
for (const key of ENVELOPE_FIELD_KEYS) {
|
||||
const box = L.fields[key];
|
||||
const field = this.fields[key];
|
||||
const size = field.measure();
|
||||
field.position.set(Math.round(box.x), Math.round(box.y + (box.h - size.h) / 2));
|
||||
field.resize(box.w, size.h);
|
||||
placeEngraving(
|
||||
this.captions[key],
|
||||
box.x + box.w / 2,
|
||||
L.knobs.attack.y - L.knobs.attack.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 = ENVELOPE_LAYOUT;
|
||||
drawPlate(this.plate.clear(), ENVELOPE_PANEL, SEA, 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: 0x1c2323, 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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* What an Envelope's node draws (#388), as numbers: where the parts of its device sit, how its
|
||||
* knobs turn, and its curve over a note as the screen shows it. Kept apart from the drawing so it is
|
||||
* checked without a browser.
|
||||
*/
|
||||
import { releaseTime, SETTLE } from '../../core/instrument/envelope';
|
||||
import { ENVELOPE_ROWS } from '../../core/instrument/nodes';
|
||||
import type { PropertyBag } from '../../core/model/Properties';
|
||||
import { formatNumber } from '../../ui/math';
|
||||
import { secondsText } from './chipView';
|
||||
import { type Box, type KnobSpot, KNOB_CAPTION, screwsOf, SMALL_DIAL } from './hardwareView';
|
||||
|
||||
export type EnvelopeKey = keyof typeof ENVELOPE_ROWS;
|
||||
export type EnvelopeValues = Record<EnvelopeKey, number>;
|
||||
|
||||
export const ENVELOPE_KEYS = Object.keys(ENVELOPE_ROWS) as EnvelopeKey[];
|
||||
/** The four stages turn a knob each; the two ends of the value have no range, so they are typed. */
|
||||
export const ENVELOPE_KNOB_KEYS = ['attack', 'decay', 'sustain', 'release'] as const;
|
||||
export const ENVELOPE_FIELD_KEYS = ['from', 'to'] as const;
|
||||
export type EnvelopeKnobKey = (typeof ENVELOPE_KNOB_KEYS)[number];
|
||||
export type EnvelopeFieldKey = (typeof ENVELOPE_FIELD_KEYS)[number];
|
||||
|
||||
/** The numbers of an Envelope node, each its row's default where none is typed in. */
|
||||
export function envelopeValues(params: PropertyBag): EnvelopeValues {
|
||||
const values = {} as EnvelopeValues;
|
||||
for (const key of ENVELOPE_KEYS) {
|
||||
const value = params[key];
|
||||
values[key] = typeof value === 'number' && Number.isFinite(value) ? value : ENVELOPE_ROWS[key].default;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export interface EnvelopeKnob {
|
||||
label: string;
|
||||
taper: number;
|
||||
decimals: number;
|
||||
format?: (value: number) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* How each knob turns: a time of 0 to 30 s gives most of the turn to its first second – 5 ms sits
|
||||
* an eighth of the way, 1 s not quite half – and the sustain turns evenly.
|
||||
*/
|
||||
export const ENVELOPE_KNOBS: Record<EnvelopeKnobKey, EnvelopeKnob> = {
|
||||
attack: { label: 'Attack', taper: 4, decimals: 3, format: secondsText },
|
||||
decay: { label: 'Decay', taper: 4, decimals: 3, format: secondsText },
|
||||
sustain: { label: 'Sustain', taper: 1, decimals: 2 },
|
||||
release: { label: 'Release', taper: 4, decimals: 3, format: secondsText },
|
||||
};
|
||||
|
||||
/**
|
||||
* The settings that do nothing as things stand, greyed as the Chip's are: the decay with the
|
||||
* sustain at full, and the two ends of the value while nothing takes it.
|
||||
*/
|
||||
export function idleEnvelopeKeys(
|
||||
values: EnvelopeValues,
|
||||
fed: (key: EnvelopeKey) => boolean,
|
||||
valueTaken: boolean,
|
||||
): Set<EnvelopeKey> {
|
||||
const idle = new Set<EnvelopeKey>();
|
||||
if (!fed('decay') && !fed('sustain') && values.sustain >= 1) idle.add('decay');
|
||||
if (!valueTaken) for (const key of ENVELOPE_FIELD_KEYS) if (!fed(key)) idle.add(key);
|
||||
return idle;
|
||||
}
|
||||
|
||||
// --- The plate. ---
|
||||
|
||||
/** The plate: as the Compressor's (#379), the panel never takes another size. */
|
||||
export const ENVELOPE_PANEL = { w: 520, h: 280 };
|
||||
|
||||
const KNOB_SLOT = 64;
|
||||
const FIELD_SLOT = 84;
|
||||
const GROUP_GAP = 22;
|
||||
/** The height of a group's name and its rule. */
|
||||
const GROUP_LINE = 16;
|
||||
export const ENVELOPE_FIELD = { w: 72, h: 24 };
|
||||
|
||||
export interface EnvelopeLayout {
|
||||
title: Box;
|
||||
/** The dark frame round the screen, and the screen in it. */
|
||||
bezel: Box;
|
||||
screen: Box;
|
||||
groups: { label: string; box: Box }[];
|
||||
knobs: Record<EnvelopeKnobKey, KnobSpot>;
|
||||
/** A typed number by the middle of its box, its name engraved above as a knob's is. */
|
||||
fields: Record<EnvelopeFieldKey, Box>;
|
||||
screws: { x: number; y: number }[];
|
||||
}
|
||||
|
||||
function envelopeLayout(): EnvelopeLayout {
|
||||
const { w } = ENVELOPE_PANEL;
|
||||
const bezel = { x: 24, y: 44, w: w - 48, h: 126 };
|
||||
const screen = { x: bezel.x + 10, y: bezel.y + 16, w: bezel.w - 20, h: bezel.h - 24 };
|
||||
const stages = ENVELOPE_KNOB_KEYS.length * KNOB_SLOT;
|
||||
const value = ENVELOPE_FIELD_KEYS.length * FIELD_SLOT;
|
||||
const groupTop = bezel.y + bezel.h + 6;
|
||||
const dialY = groupTop + GROUP_LINE + KNOB_CAPTION + SMALL_DIAL / 2;
|
||||
let x = (w - stages - GROUP_GAP - value) / 2;
|
||||
const groups = [{ label: 'ADSR', box: { x, y: groupTop, w: stages, h: GROUP_LINE } }];
|
||||
const knobs = {} as Record<EnvelopeKnobKey, KnobSpot>;
|
||||
for (const key of ENVELOPE_KNOB_KEYS) {
|
||||
knobs[key] = { x: x + KNOB_SLOT / 2, y: dialY, size: SMALL_DIAL };
|
||||
x += KNOB_SLOT;
|
||||
}
|
||||
x += GROUP_GAP;
|
||||
groups.push({ label: 'VALUE', box: { x, y: groupTop, w: value, h: GROUP_LINE } });
|
||||
const fields = {} as Record<EnvelopeFieldKey, Box>;
|
||||
for (const key of ENVELOPE_FIELD_KEYS) {
|
||||
fields[key] = {
|
||||
x: x + (FIELD_SLOT - ENVELOPE_FIELD.w) / 2,
|
||||
y: dialY - ENVELOPE_FIELD.h / 2,
|
||||
...ENVELOPE_FIELD,
|
||||
};
|
||||
x += FIELD_SLOT;
|
||||
}
|
||||
return {
|
||||
title: { x: 34, y: 14, w: 120, h: 24 },
|
||||
bezel,
|
||||
screen,
|
||||
groups,
|
||||
knobs,
|
||||
fields,
|
||||
screws: screwsOf(ENVELOPE_PANEL),
|
||||
};
|
||||
}
|
||||
|
||||
export const ENVELOPE_LAYOUT: EnvelopeLayout = envelopeLayout();
|
||||
|
||||
// --- The screen: the curve over a note. ---
|
||||
|
||||
export type StageKey = 'attack' | 'decay' | 'hold' | 'release';
|
||||
|
||||
export interface Stage {
|
||||
key: StageKey;
|
||||
/** Where it begins and ends across the screen, 0 to 1. */
|
||||
from: number;
|
||||
to: number;
|
||||
/** How long it takes; null for the hold, which lasts as long as the note is held. */
|
||||
seconds: number | null;
|
||||
}
|
||||
|
||||
export interface EnvelopeTrace {
|
||||
/** The level from 0 to 1 across the screen, `x` from 0 to 1. */
|
||||
points: { x: number; y: number }[];
|
||||
stages: Stage[];
|
||||
sustain: number;
|
||||
/** With the sustain at 0 the note ends with its decay, however long it is held. */
|
||||
ends: boolean;
|
||||
}
|
||||
|
||||
/** The share of the screen the hold takes, whatever the times are. */
|
||||
export const HOLD_SHARE = 0.2;
|
||||
/** The least share a stage of a time above 0 takes, so 1 ms of attack is still a slope. */
|
||||
const LEAST_SHARE = 0.03;
|
||||
/** Points a falling stage is drawn with. */
|
||||
const FALL_POINTS = 48;
|
||||
|
||||
/**
|
||||
* The curve over a note (#388): a straight line up in the attack, down towards the sustain in the
|
||||
* decay, flat while the note is held and down to silence in the release – each fall by `SETTLE`
|
||||
* time constants in its time, as the Envelope plays it. The time is squeezed: each stage is as
|
||||
* wide as the root of its seconds, so 5 ms of attack shows beside a release of 3 s; the hold has a
|
||||
* fixed share. With the sustain at 0 there is no hold and no release to draw.
|
||||
*/
|
||||
export function envelopeTrace(v: EnvelopeValues): EnvelopeTrace {
|
||||
const sustain = Math.min(1, Math.max(0, v.sustain));
|
||||
const ends = sustain <= 0;
|
||||
const attack = Math.max(0, v.attack);
|
||||
const decay = Math.max(0, v.decay);
|
||||
const release = releaseTime({ id: '', attack, decay, sustain, release: v.release });
|
||||
const timed: { key: StageKey; seconds: number }[] = [
|
||||
{ key: 'attack', seconds: attack },
|
||||
{ key: 'decay', seconds: decay },
|
||||
...(ends ? [] : [{ key: 'release' as const, seconds: release }]),
|
||||
];
|
||||
const weights = timed.map((stage) => Math.sqrt(stage.seconds));
|
||||
const total = weights.reduce((sum, w) => sum + w, 0);
|
||||
const room = ends ? 1 : 1 - HOLD_SHARE;
|
||||
// Every stage of a time above 0 gets its least share first, the rest of the room by weight.
|
||||
const floored = timed.filter((stage) => stage.seconds > 0).length * LEAST_SHARE;
|
||||
const shares = weights.map((w, i) =>
|
||||
timed[i]!.seconds > 0 ? LEAST_SHARE + (total > 0 ? ((room - floored) * w) / total : 0) : 0,
|
||||
);
|
||||
if (total === 0 && !ends) shares[timed.length - 1] = room;
|
||||
const stages: Stage[] = [];
|
||||
let x = 0;
|
||||
const push = (key: StageKey, share: number, seconds: number | null) => {
|
||||
stages.push({ key, from: x, to: x + share, seconds });
|
||||
x += share;
|
||||
};
|
||||
push('attack', shares[0]!, attack);
|
||||
push('decay', shares[1]!, decay);
|
||||
if (!ends) {
|
||||
push('hold', HOLD_SHARE, null);
|
||||
push('release', shares[2]!, release);
|
||||
}
|
||||
// Rounding leaves the last stage a hair short or long: it ends at the right edge.
|
||||
stages[stages.length - 1]!.to = 1;
|
||||
|
||||
const [up, down, held, gone] = stages;
|
||||
const points: { x: number; y: number }[] = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: up!.to, y: 1 },
|
||||
];
|
||||
const fall = (stage: Stage, top: number, bottom: number) => {
|
||||
for (let i = 1; i <= FALL_POINTS; i++) {
|
||||
const u = i / FALL_POINTS;
|
||||
const y = bottom + (top - bottom) * Math.exp(-u * SETTLE);
|
||||
points.push({ x: stage.from + u * (stage.to - stage.from), y });
|
||||
}
|
||||
};
|
||||
if (decay > 0) fall(down!, 1, sustain);
|
||||
else points.push({ x: down!.from, y: sustain }, { x: down!.to, y: sustain });
|
||||
if (held && gone) {
|
||||
points.push({ x: held.to, y: sustain });
|
||||
fall(gone, sustain, 0);
|
||||
}
|
||||
return { points, stages, sustain, ends };
|
||||
}
|
||||
|
||||
/** What the screen writes under a stage. */
|
||||
export function stageText(stage: Stage): string {
|
||||
return stage.seconds === null ? 'HOLD' : secondsText(stage.seconds);
|
||||
}
|
||||
|
||||
/** The letter above a stage. */
|
||||
export const STAGE_LETTERS: Record<StageKey, string> = { attack: 'A', decay: 'D', hold: 'S', release: 'R' };
|
||||
|
||||
/** The sustain as the screen writes it beside the hold: `60%`. */
|
||||
export function sustainText(sustain: number): string {
|
||||
return `${formatNumber(Math.round(sustain * 100), 0)}%`;
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
import type { GraphNode } from '../../core/graph';
|
||||
import { InstrumentChip } from '../../core/instrument/nodes';
|
||||
import { InstrumentChip, InstrumentEnvelope } from '../../core/instrument/nodes';
|
||||
import { SoundFxCompressor, SoundFxFilter, SoundFxGain, SoundFxParametricEq } from '../../core/soundfx/nodes';
|
||||
import type { NodeFace } from '../graph/nodeFace';
|
||||
import type { EditorState } from '../state';
|
||||
import { ChipFace } from './ChipFace';
|
||||
import { CompressorFace } from './CompressorFace';
|
||||
import { CurveFace } from './CurveFace';
|
||||
import { EnvelopeFace } from './EnvelopeFace';
|
||||
import { EqPanel } from './EqPanel';
|
||||
import { FilterPanel } from './FilterPanel';
|
||||
import { GainFace } from './GainFace';
|
||||
|
||||
/** The nodes drawn as a face of their own instead of rows (#380, #381, #384, #385, #386), by type. */
|
||||
/** The nodes drawn as a face of their own instead of rows (#380, #381, #384–#386, #388), 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);
|
||||
if (node.type === SoundFxCompressor.type) return new CompressorFace(state, assetId, node.id);
|
||||
if (node.type === SoundFxParametricEq.type)
|
||||
return new CurveFace(new EqPanel(state, true), assetId, node.id);
|
||||
|
||||
Reference in new issue
Block a user