From 6383cc0d8ef76aa725f1a531506c50796afb95a3 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sat, 10 Oct 2026 22:54:17 +0200 Subject: [PATCH] Show a Sample node as a device that draws its waveform (#400) The Sample was the last node of an instrument that still showed rows. It now draws the sound or clip it plays, so the loop and what plays after the note can be seen instead of read off two numbers. - SamplePanel, SampleFace and sampleView: a compact gold plate of 480x280 with the sound picker beside the name, a waveform screen, the four ways of playing it as buttons two by two, the root as a knob that reads as a note, a lever for following the note and the loop's start above its end. - The waveform comes from the library's peaks of the played sound, a clip's own cut included, scaled by the sound's volume. It is loaded on demand as the Reverb's sound is. - Each part has its colour: played once, looped, played after the note, never heard. The loop is held as playback holds it: an end of 0 or past the sound is the end, a start not before it loops all of it. - Idle settings come from the node's own idleParams, so the device greys out what the rows did. - A new instrument places its Output at x 840, past the device. At 460 it lay under the Sample. - Hook __gm.sample.panel and a check in e2e 34 behind the first instrument check. Tests: - Unit sample-view: 11 tests, new. pattern and chip-presets green. - e2e 34 (atx): ALL PASSED, 144 checks (1 new). - Gate (atx): 293 files, 4495 tests. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/core/instrument/nodes.ts | 12 +- src/editor/debug/graphHooks.ts | 52 ++++ src/editor/soundfx/SampleFace.ts | 143 ++++++++++ src/editor/soundfx/SamplePanel.ts | 418 ++++++++++++++++++++++++++++++ src/editor/soundfx/nodeFaces.ts | 6 +- src/editor/soundfx/sampleView.ts | 249 ++++++++++++++++++ tests/editor/sample-view.test.ts | 189 ++++++++++++++ tools/e2e/34-sound.mjs | 53 ++++ 9 files changed, 1116 insertions(+), 8 deletions(-) create mode 100644 src/editor/soundfx/SampleFace.ts create mode 100644 src/editor/soundfx/SamplePanel.ts create mode 100644 src/editor/soundfx/sampleView.ts create mode 100644 tests/editor/sample-view.test.ts diff --git a/README.md b/README.md index d8f373e8..7d64e470 100644 --- a/README.md +++ b/README.md @@ -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, 1975 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1976 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 diff --git a/src/core/instrument/nodes.ts b/src/core/instrument/nodes.ts index 85f0eedf..36032342 100644 --- a/src/core/instrument/nodes.ts +++ b/src/core/instrument/nodes.ts @@ -120,7 +120,7 @@ const ROOT = noteRow('root', 'Root', MIDDLE_C); const FOLLOW = { key: 'followNote', label: 'Follow note', kind: 'boolean' as const, default: true }; /** How a Sample plays while its note is held and once it is let go. */ export type SamplePlay = 'gate' | 'oneshot' | 'loop' | 'release'; -const SAMPLE_PLAYS: { value: SamplePlay; label: string }[] = [ +export const SAMPLE_PLAYS: { value: SamplePlay; label: string }[] = [ { value: 'gate', label: 'Gate' }, { value: 'oneshot', label: 'One-shot' }, { value: 'loop', label: 'Loop' }, @@ -154,7 +154,7 @@ export function withSamplePlay(params: PropertyBag): PropertyBag { const { mode: _mode, loop: _loop, ...rest } = params; return { ...rest, [PLAY.key]: samplePlay(params) }; } -const LOOP_START: PropertyDef = { +const LOOP_START: FlatDef<'number'> = { key: 'loopStart', label: 'Loop start (s)', kind: 'number', @@ -162,7 +162,7 @@ const LOOP_START: PropertyDef = { min: 0, step: 0.01, }; -const LOOP_END: PropertyDef = { +const LOOP_END: FlatDef<'number'> = { key: 'loopEnd', label: 'Loop end (s)', kind: 'number', @@ -170,6 +170,8 @@ const LOOP_END: PropertyDef = { min: 0, step: 0.01, }; +/** Where a Sample loops, in seconds; its device (#400) types them in. */ +export const SAMPLE_LOOP_ROWS = { loopStart: LOOP_START, loopEnd: LOOP_END }; export const InstrumentSample: InstrumentNodeDef = { type: INSTRUMENT_SAMPLE, @@ -366,7 +368,7 @@ export const INSTRUMENT_NODES: readonly InstrumentNodeDef[] = [ /** * What a new instrument starts as: every note of an octave from C4 plays one Sample, which waits for - * its sound to be picked. + * its sound to be picked. The Output sits past the Sample's device (#400), some 580 wide. */ export function starterInstrumentGraph(): Graph { const notes = newId(); @@ -376,7 +378,7 @@ export function starterInstrumentGraph(): Graph { nodes: [ { id: notes, type: INSTRUMENT_NOTES, x: 0, y: 0, params: {} }, { id: sample, type: INSTRUMENT_SAMPLE, x: 220, y: 0, params: {} }, - { id: output, type: INSTRUMENT_OUTPUT, x: 460, y: 0, params: {} }, + { id: output, type: INSTRUMENT_OUTPUT, x: 840, y: 0, params: {} }, ], edges: [ { id: newId(), from: { node: notes, port: 'any' }, to: { node: sample, port: 'trigger' } }, diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts index 181b647c..64cdf3de 100644 --- a/src/editor/debug/graphHooks.ts +++ b/src/editor/debug/graphHooks.ts @@ -11,6 +11,7 @@ import { KnobPanel } from '../soundfx/KnobPanel'; import { DelayPanel } from '../soundfx/DelayPanel'; import { LimiterPanel } from '../soundfx/LimiterPanel'; import { ModulationPanel } from '../soundfx/ModulationPanel'; +import { SamplePanel } from '../soundfx/SamplePanel'; import { SaturationPanel } from '../soundfx/SaturationPanel'; import { ReverbPanel } from '../soundfx/ReverbPanel'; import { CurvePanel } from '../soundfx/CurvePanel'; @@ -429,6 +430,57 @@ export function graphHooks({ state, editor }: HookContext) { }; }, }, + /** + * The panel a Sample node shows in the instrument graph (#400): whether it is shown and where, the + * sound, the play buttons, the root, the lever and the loop's fields, which settings are idle, what + * the screen draws and says, and the node's ports. + */ + sample: { + panel: (nodeId: string) => { + const canvas = editor.instrumentEditor.canvas; + const panel = canvas.faceOf(nodeId)?.widget; + if (!(panel instanceof SamplePanel)) 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; + 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), + sound: { text: panel.soundButton.text, at: screenCentre(panel.soundButton) }, + play: panel.playButtons.find((b) => b.picked)?.value ?? null, + plays: Object.fromEntries(panel.playButtons.map((b) => [b.value, screenCentre(b)])), + root: { + value: panel.root.value, + number: panel.root.shown, + enabled: panel.root.enabled, + dial: panel.root.dialGlobal(), + }, + follow: { on: panel.follow.lit, says: panel.follow.says.text, at: screenCentre(panel.follow) }, + fields: Object.fromEntries( + Object.entries(panel.fields).map(([key, field]) => [ + key, + { text: field.value, enabled: field.enabled, at: screenCentre(field) }, + ]), + ), + idle: [...panel.idle], + /** How long the sound runs and how loud it plays, above the screen. */ + length: panel.length.text, + scope: { + says: scope.says, + /** The parts of the sound in seconds and what becomes of each, the columns, the tallest, the marks. */ + regions: scope.drawn.regions, + columns: scope.drawn.columns, + tallest: scope.drawn.tallest, + marks: scope.drawn.marks, + }, + }; + }, + }, /** * The panel a Reverb node shows (#389), in the sound FX graph or the instrument graph: whether it * is shown and where, its knobs, which settings are idle, the picked type, the sound, what the diff --git a/src/editor/soundfx/SampleFace.ts b/src/editor/soundfx/SampleFace.ts new file mode 100644 index 00000000..d2d5a052 --- /dev/null +++ b/src/editor/soundfx/SampleFace.ts @@ -0,0 +1,143 @@ +import { SetNodeParamCommand } from '../../core/commands/graphCommands'; +import type { GraphNode } from '../../core/graph'; +import { InstrumentSample, type SamplePlay } from '../../core/instrument/nodes'; +import { findGraphAsset } from '../../core/model/Project'; +import { findSound } from '../../core/model/sound'; +import { assetRefLabel, soundChoices } from '../../core/properties/assetRef'; +import { playedSoundOf } from '../../core/properties/playedSound'; +import { resolveAssetProps } from '../../core/properties/resolve'; +import { volumeOf } from '../../core/properties/sounds'; +import { Menu, type Widget } from '../../ui'; +import { soundLibrary } from '../../viewport/SoundLibrary'; +import type { NodeFace } from '../graph/nodeFace'; +import type { EditorState } from '../state'; +import { NodeParamDrag } from './nodeParamDrag'; +import { type SampleHost, SamplePanel, type SamplePicture } from './SamplePanel'; +import { idleSampleKeys, type SampleLoopKey, sampleValues } from './sampleView'; + +const SOUND = 'sound'; +const ROOT = 'root'; +const PLAY = 'play'; +const FOLLOW = 'followNote'; + +/** + * A Sample node drawn as its device (#400): the waveform of the sound or clip it plays, drawn from + * its peaks once they are loaded, and every setting worked right in the graph. Nothing on it moves + * by itself, and it listens to nothing. + */ +export class SampleFace implements NodeFace, SampleHost { + readonly widget: SamplePanel; + private readonly drag: NodeParamDrag; + /** Sounds whose peaks are on their way, so they are asked for once. */ + private readonly loading = new Set(); + + constructor( + private readonly state: EditorState, + private readonly assetId: string, + private readonly nodeId: string, + ) { + this.widget = new SamplePanel(this); + this.drag = new NodeParamDrag(state, 'sample-face'); + } + + private node(): GraphNode | null { + const node = findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find( + (n) => n.id === this.nodeId, + ); + return node?.type === InstrumentSample.type ? node : null; + } + + sync(): void { + const node = this.node(); + if (!node) return; + const values = sampleValues(node.params); + const ref = values.sound ? findSound(this.state.project, values.sound) : null; + const file = ref ? playedSoundOf(ref) : ''; + let picture: SamplePicture | null = null; + let missing = ''; + // What the screen says without a sound to draw: what the instrument says of it, then the loading. + if (!values.sound) missing = 'NO SOUND PICKED'; + else if (!ref) missing = 'THAT SOUND IS GONE'; + else if (!file) missing = 'THE SOUND HAS NO FILE'; + else { + const peaks = soundLibrary.peaks(file); + const seconds = soundLibrary.duration(file); + if (peaks && seconds !== null) + picture = { peaks, seconds, volume: volumeOf(resolveAssetProps(this.state.project, ref.asset)) }; + else if (soundLibrary.failedToLoad(file)) missing = 'CANNOT LOAD'; + else { + missing = 'LOADING'; + this.load(file); + } + } + this.widget.sync({ + values, + idle: idleSampleKeys(node.params), + picture, + sound: values.sound ? assetRefLabel(this.state.project, values.sound) : '', + missing, + }); + } + + /** Loads what the screen draws the waveform from, and draws it once it is there. */ + private load(file: string): void { + if (this.loading.has(file)) return; + this.loading.add(file); + void soundLibrary.loadPicture(file).then(() => { + this.loading.delete(file); + this.sync(); + }); + } + + tick(): boolean { + return false; + } + + hide(): void {} + + previewRoot(note: number): void { + const node = this.node(); + if (node) this.drag.previewAll(node, { [ROOT]: String(note) }); + } + + commitRoot(note: number): void { + const node = this.node(); + if (node) this.drag.commit(this.assetId, node, ROOT, String(note)); + } + + preview(key: SampleLoopKey, seconds: number): void { + const node = this.node(); + if (node) this.drag.preview(node, key, seconds); + } + + commit(key: SampleLoopKey, seconds: number): void { + const node = this.node(); + if (node) this.drag.commit(this.assetId, node, key, seconds); + } + + pickPlay(play: SamplePlay): void { + const node = this.node(); + if (node && sampleValues(node.params).play !== play) + this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, PLAY, play)); + } + + switchFollow(on: boolean): void { + if (this.node()) this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, FOLLOW, on)); + } + + /** The sounds of the project and their clips; picking one makes it what the Sample plays. */ + pickSound(button: Widget): void { + const node = this.node(); + if (!node) return; + const current = sampleValues(node.params).sound; + const items = soundChoices(this.state.project).map((sound) => ({ + label: sound.label, + current: sound.id === current, + onSelect: () => { + if (sound.id !== current) + this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, SOUND, sound.id)); + }, + })); + if (items.length > 0) new Menu(items, { searchable: true }).openBelow(button); + } +} diff --git a/src/editor/soundfx/SamplePanel.ts b/src/editor/soundfx/SamplePanel.ts new file mode 100644 index 00000000..a710b169 --- /dev/null +++ b/src/editor/soundfx/SamplePanel.ts @@ -0,0 +1,418 @@ +/** + * The Sample as a device (#400), what its node shows in the instrument graph: the Compressor's + * plate (#379) in gold, a long screen that draws the waveform of the sound or clip it plays – its + * loop lit, what plays after the loop in amber, what is never heard dark – the sound to pick beside + * its name, a button for each way of playing it, the root as a knob, a lever for following the note + * and the loop's two ends typed in. + */ +import { type FederatedPointerEvent, Graphics, type Text, type TextStyle } from 'pixi.js'; +import { SAMPLE_LOOP_ROWS, SAMPLE_PLAYS, type SamplePlay } from '../../core/instrument/nodes'; +import { HIGHEST_NOTE, LOWEST_NOTE, MIDDLE_C, noteName } from '../../core/instrument/notes'; +import { crispText, Dropdown, Knob, NumberField, textStyle, Widget } from '../../ui'; +import { drawPlate, engrave, INK, Lever, placeEngraving, type PlateLook } from './CompressorPanel'; +import { type Box, KNOB_CAPTION } from './hardwareView'; +import { PickButton, type PickLook } from './ReverbPanel'; +import { + FIELD_NAME, + markText, + PLAY_NAMES, + type Region, + type RegionKind, + SAMPLE_LAYOUT, + SAMPLE_LOOP_KEYS, + SAMPLE_PANEL, + type SampleIdleKey, + type SampleLoopKey, + sampleRegions, + sampleTexts, + type SampleValues, + TEXT_ROW, + timeMarks, + volumeText, + waveColumns, +} from './sampleView'; + +/** What the panel asks of the node it sits in. */ +export interface SampleHost { + previewRoot(note: number): void; + commitRoot(note: number): void; + preview(key: SampleLoopKey, seconds: number): void; + commit(key: SampleLoopKey, seconds: number): void; + pickPlay(play: SamplePlay): void; + switchFollow(on: boolean): void; + /** Opens the sounds to pick from below the button. */ + pickSound(button: Widget): void; +} + +/** The sound the screen draws: its peaks, how long it runs and how loud it plays. */ +export interface SamplePicture { + peaks: ArrayLike; + seconds: number; + volume: number; +} + +/** What the panel shows of a node. */ +export interface SampleShown { + values: SampleValues; + /** The settings that do nothing as things stand. */ + idle: ReadonlySet; + /** The sound to draw; null while there is none to know – none picked, or not loaded yet. */ + picture: SamplePicture | null; + /** The picked sound's name, '' for none. */ + sound: string; + /** What the screen says where it has no sound to draw. */ + missing: string; +} + +/** Polished gold. */ +const GOLD: PlateLook = { + stops: [ + [0, 0xf8e39a], + [0.5, 0xeac865], + [1, 0xcf9f34], + ], + rim: 0x8c6a1e, + grain: 0x6e5114, +}; +/** The arc of the chrome dial: its orange would vanish on gold. */ +const ARC = 0x5c3b06; +const BEZEL = 0x2e2618; +const BEZEL_TEXT = 0xf3dfa4; +/** Smoked glass; the waveform gold, its loop pale gold, what plays after it amber, the rest dark. */ +const SCOPE = { + glass: 0x120e08, + grid: 0x2e2616, + text: 0xffd86a, + dim: 0x8c7440, + band: 0xffe7a0, +}; +const WAVE: Record = { + heard: 0xffc93c, + loop: 0xfff0b4, + after: 0xff8f2e, + never: 0x5a4a28, +}; +const GOLD_BUTTONS: PickLook = { face: 0x4a3c20, hover: 0x5c4c2c, picked: 0xa86f0a, shadow: 0x271e0c }; +const IDLE_ALPHA = 0.4; + +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; +} + +/** + * The screen: the waveform of the sound across its whole length, mirrored about the middle, each + * part in the colour of what becomes of it; the loop lit behind and bounded by two lines, its name + * above it. Seconds along the bottom, and under the glass how it plays and at what pitch. + */ +class WaveScope extends Widget { + private readonly glass = new Graphics(); + private readonly wave = new Graphics(); + private readonly marks: Text[] = []; + private readonly flags: Record<'loop' | 'after', Text>; + private readonly middle: Text; + private readonly under: Record<'play' | 'pitch', Text>; + picture: SamplePicture | null = null; + values: SampleValues | null = null; + missing = ''; + /** What was drawn last (checks): the parts and their seconds, the columns, the marks, the texts. */ + drawn = { regions: [] as Region[], columns: 0, tallest: 0, marks: [] as string[] }; + says = { play: '', pitch: '', middle: '', loop: '', after: '' }; + + constructor() { + super(); + this.addChild(this.glass, this.wave); + this.flags = { loop: this.made(8, SCOPE.band, 0.5), after: this.made(8, WAVE.after, 0.5) }; + this.middle = this.made(11, SCOPE.text, 0.5); + this.middle.anchor.set(0.5, 0.5); + this.under = { play: this.made(9, SCOPE.text, 0), pitch: this.made(9, SCOPE.text, 1) }; + } + + private made(size: number, fill: number, ax: number): Text { + const t = crispText({ text: '', style: scopeStyle(size, fill) }); + t.anchor.set(ax, 0); + t.eventMode = 'none'; + this.addChild(t); + return t; + } + + show(values: SampleValues, picture: SamplePicture | null, missing: string): void { + this.values = values; + this.picture = picture; + this.missing = missing; + this.invalidate(); + } + + override redraw(): void { + const w = this.w; + const h = this.h - TEXT_ROW; + const plot = { x: 6, y: 14, w: w - 12, h: h - 28 }; + const cy = plot.y + plot.h / 2; + const half = plot.h / 2; + const g = this.glass.clear(); + g.roundRect(0, 0, w, h, 4).fill(SCOPE.glass); + const c = this.wave.clear(); + for (const t of this.marks) t.visible = false; + this.flags.loop.visible = false; + this.flags.after.visible = false; + this.drawn = { regions: [], columns: 0, tallest: 0, marks: [] }; + const v = this.values; + const picture = this.picture; + const texts = v ? sampleTexts(v, picture?.seconds ?? null) : { play: '', pitch: '' }; + this.says = { play: picture ? texts.play : '', pitch: texts.pitch, middle: '', loop: '', after: '' }; + + if (v && picture && picture.seconds > 0) { + const x = (t: number) => plot.x + (t / picture.seconds) * plot.w; + // Seconds along the bottom, a faint line up the glass at each. + const marks = timeMarks(picture.seconds, plot.w); + marks.forEach((t, i) => { + const mx = x(t); + g.moveTo(mx, plot.y).lineTo(mx, plot.y + plot.h); + const label = (this.marks[i] ??= this.made(8, SCOPE.dim, 0)); + label.text = markText(t); + label.anchor.set(i === 0 ? 0 : mx + label.width / 2 > w - 4 ? 1 : 0.5, 0); + label.position.set(Math.round(mx), Math.round(plot.y + plot.h + 2)); + label.visible = true; + this.drawn.marks.push(label.text); + }); + g.moveTo(plot.x, cy).lineTo(plot.x + plot.w, cy); + g.stroke({ color: SCOPE.grid, width: 1 }); + + const regions = sampleRegions(v, picture.seconds); + const loop = regions.find((r) => r.kind === 'loop'); + // The loop lit behind the wave and bounded by a line each end, its name above it. + if (loop) { + g.rect(x(loop.from), 2, x(loop.to) - x(loop.from), h - 4).fill({ color: SCOPE.band, alpha: 0.1 }); + for (const end of [loop.from, loop.to]) + g.moveTo(x(end), 2) + .lineTo(x(end), h - 2) + .stroke({ color: SCOPE.band, width: 1, alpha: 0.8 }); + } + const columns = waveColumns(picture.peaks, Math.round(plot.w), picture.volume); + const colX = (i: number) => plot.x + ((i + 0.5) / columns.length) * plot.w; + for (const region of regions) { + const a = Math.max(0, Math.floor(((x(region.from) - plot.x) / plot.w) * columns.length)); + const b = Math.min( + columns.length - 1, + Math.ceil(((x(region.to) - plot.x) / plot.w) * columns.length) - 1, + ); + if (b < a) continue; + // Mirrored about the middle: along the top, then back along the bottom. + c.moveTo(colX(a), cy - columns[a]! * half); + for (let i = a + 1; i <= b; i++) c.lineTo(colX(i), cy - columns[i]! * half); + for (let i = b; i >= a; i--) c.lineTo(colX(i), cy + columns[i]! * half); + c.closePath().fill({ color: WAVE[region.kind], alpha: region.kind === 'never' ? 0.6 : 0.85 }); + // A hairline across the middle, so silence still shows where the sound runs. + c.moveTo(colX(a), cy).lineTo(colX(b), cy).stroke({ color: WAVE[region.kind], width: 1, alpha: 0.6 }); + } + this.drawn = { + ...this.drawn, + regions, + columns: columns.length, + tallest: columns.reduce((most, value) => Math.max(most, value), 0), + }; + const flag = (t: Text, region: Region | undefined, says: string): string => { + if (!region) return ''; + t.text = says; + t.position.set(Math.round((x(region.from) + x(region.to)) / 2), 2); + t.visible = t.width + 6 <= x(region.to) - x(region.from); + return t.visible ? says : ''; + }; + this.says.loop = flag(this.flags.loop, loop, 'LOOP'); + this.says.after = flag( + this.flags.after, + regions.find((r) => r.kind === 'after'), + 'AFTER THE NOTE', + ); + } else this.says.middle = this.missing; + + this.middle.text = this.says.middle; + this.middle.position.set(Math.round(w / 2), Math.round(h / 2)); + this.under.play.text = this.says.play; + this.under.play.position.set(0, h + 3); + this.under.pitch.text = this.says.pitch; + this.under.pitch.position.set(w, h + 3); + } +} + +export class SamplePanel extends Widget { + private readonly plate = new Graphics(); + private readonly bezel = new Graphics(); + private readonly rules = new Graphics(); + readonly scope = new WaveScope(); + readonly soundButton: Dropdown; + readonly playButtons: PickButton[]; + readonly root: Knob; + readonly follow: Lever; + readonly fields = {} as Record; + private readonly rootCaption: { ink: Text; edge: Text }; + private readonly fieldCaptions = {} as Record; + private readonly groupNames: { ink: Text; edge: Text }[]; + private readonly title: { ink: Text; edge: Text }; + private readonly screenName: Text; + /** How long the sound runs and how loud it plays, above the screen on the right. */ + readonly length: Text; + /** The settings shown idle now (checks). */ + idle: ReadonlySet = new Set(); + + constructor(host: SampleHost) { + super(); + this.minWidth = SAMPLE_PANEL.w; + this.minHeight = SAMPLE_PANEL.h; + this.addChild(this.plate, this.rules, this.bezel, this.scope); + this.title = engrave(this, 'SAMPLE', 14); + this.screenName = crispText({ text: 'WAVEFORM', style: bezelStyle() }); + this.screenName.eventMode = 'none'; + this.length = crispText({ text: '', style: bezelStyle() }); + this.length.anchor.set(1, 0); + this.length.eventMode = 'none'; + this.addChild(this.screenName, this.length); + this.groupNames = SAMPLE_LAYOUT.groups.map((group) => engrave(this, group.label, 9)); + this.soundButton = new Dropdown('No sound', { + width: SAMPLE_LAYOUT.sound.w, + onClick: () => host.pickSound(this.soundButton), + }); + // A press on it is not one on the node. + this.soundButton.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation()); + this.addChild(this.soundButton); + this.playButtons = SAMPLE_PLAYS.map((play) => { + const button = new PickButton(play.value, PLAY_NAMES[play.value], GOLD_BUTTONS, (picked) => + host.pickPlay(picked), + ); + this.addChild(button); + return button; + }); + this.root = new Knob('', MIDDLE_C, { + min: LOWEST_NOTE, + max: HIGHEST_NOTE, + step: 1, + taper: 1, + default: MIDDLE_C, + decimals: 0, + size: SAMPLE_LAYOUT.root.size, + look: 'chrome', + typed: false, + format: noteName, + ink: INK, + arc: ARC, + onInput: (note) => host.previewRoot(note), + onChange: (note) => host.commitRoot(note), + }); + this.rootCaption = engrave(this, 'ROOT', 9); + this.addChild(this.root); + this.follow = new Lever('FOLLOW', (on) => host.switchFollow(on)); + this.addChild(this.follow); + for (const key of SAMPLE_LOOP_KEYS) { + const row = SAMPLE_LOOP_ROWS[key]; + const field = new NumberField(row.default, { + width: SAMPLE_LAYOUT.fields[key].w, + min: row.min, + step: row.step, + decimals: 3, + onInput: (seconds) => host.preview(key, seconds), + onChange: (seconds) => host.commit(key, seconds), + }); + this.fields[key] = field; + this.fieldCaptions[key] = engrave(this, key === 'loopStart' ? 'START' : 'END', 9); + this.addChild(field); + } + } + + override measure(): { w: number; h: number } { + return { ...SAMPLE_PANEL }; + } + + sync(shown: SampleShown): void { + const v = shown.values; + const dim = (key: SampleIdleKey) => (shown.idle.has(key) ? IDLE_ALPHA : 1); + this.soundButton.setText(shown.sound || 'No sound'); + for (const button of this.playButtons) button.pick(button.value === v.play); + this.root.setValue(v.root); + this.root.alpha = dim('root'); + this.rootCaption.ink.alpha = this.root.alpha; + this.follow.set(v.follow, v.follow ? 'on' : 'off'); + for (const key of SAMPLE_LOOP_KEYS) { + const field = this.fields[key]; + field.setNumber(v[key]); + field.alpha = dim(key); + this.fieldCaptions[key].ink.alpha = field.alpha; + } + this.idle = shown.idle; + this.scope.show(v, shown.picture, shown.missing); + const picture = shown.picture; + this.length.text = picture + ? [markText(picture.seconds), volumeText(picture.volume)].filter(Boolean).join(' ') + : ''; + } + + override layout(): void { + const L = SAMPLE_LAYOUT; + const put = (widget: Widget, box: Box) => { + widget.position.set(Math.round(box.x), Math.round(box.y)); + widget.resize(box.w, box.h); + }; + put(this.scope, { ...L.screen, h: L.screen.h + TEXT_ROW }); + this.screenName.position.set(L.screen.x, L.bezel.y + 3); + this.length.position.set(L.screen.x + L.screen.w, L.bezel.y + 3); + const sound = this.soundButton.measure(); + put(this.soundButton, { ...L.sound, y: L.sound.y + (L.sound.h - sound.h) / 2, h: sound.h }); + this.playButtons.forEach((button, i) => put(button, L.plays[i]!)); + const size = this.root.measure(); + this.root.position.set(Math.round(L.root.x - size.w / 2), Math.round(L.root.y - L.root.size / 2)); + this.root.resize(size.w, size.h); + const captionY = L.root.y - L.root.size / 2 - KNOB_CAPTION; + placeEngraving(this.rootCaption, L.root.x, captionY); + put(this.follow, L.follow); + for (const key of SAMPLE_LOOP_KEYS) { + const box = L.fields[key]; + const field = this.fields[key]; + const measured = field.measure(); + field.position.set(Math.round(box.x), Math.round(box.y + (box.h - measured.h) / 2)); + field.resize(box.w, measured.h); + const caption = this.fieldCaptions[key]; + placeEngraving(caption, box.x - FIELD_NAME / 2, box.y + (box.h - caption.ink.height) / 2); + } + 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 = SAMPLE_LAYOUT; + drawPlate(this.plate.clear(), SAMPLE_PANEL, GOLD, 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: 0x1a140a, 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 }); + } +} diff --git a/src/editor/soundfx/nodeFaces.ts b/src/editor/soundfx/nodeFaces.ts index aff129e2..7e05c274 100644 --- a/src/editor/soundfx/nodeFaces.ts +++ b/src/editor/soundfx/nodeFaces.ts @@ -1,5 +1,5 @@ import type { GraphNode } from '../../core/graph'; -import { InstrumentChip, InstrumentEnvelope } from '../../core/instrument/nodes'; +import { InstrumentChip, InstrumentEnvelope, InstrumentSample } from '../../core/instrument/nodes'; import { SoundFxBitcrusher, SoundFxCompressor, @@ -26,12 +26,14 @@ import { LimiterFace } from './LimiterFace'; import { ModulationFace } from './ModulationFace'; import { MOD_DEVICES } from './modulationView'; import { ReverbFace } from './ReverbFace'; +import { SampleFace } from './SampleFace'; 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–#399), by type. */ +/** The nodes drawn as a face of their own instead of rows (#380, #381, #384–#386, #388–#390, #392, #393, #396–#400), 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 === InstrumentSample.type) return new SampleFace(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); diff --git a/src/editor/soundfx/sampleView.ts b/src/editor/soundfx/sampleView.ts new file mode 100644 index 00000000..f71898e9 --- /dev/null +++ b/src/editor/soundfx/sampleView.ts @@ -0,0 +1,249 @@ +/** + * What a Sample's node draws (#400), as numbers: where the parts of its device sit, and the sound it + * plays as the screen shows it – its waveform, what of it is looped, what plays once the note is let + * go and what is never heard. Kept apart from the drawing so it is checked without a browser. + */ +import { + InstrumentSample, + loops, + noteParam, + SAMPLE_LOOP_ROWS, + samplePlay, + type SamplePlay, +} from '../../core/instrument/nodes'; +import { MIDDLE_C, noteName } from '../../core/instrument/notes'; +import { type PropertyBag, textOf } 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 SampleLoopKey = keyof typeof SAMPLE_LOOP_ROWS; +export const SAMPLE_LOOP_KEYS = Object.keys(SAMPLE_LOOP_ROWS) as SampleLoopKey[]; +/** The settings the device can grey out. */ +export type SampleIdleKey = 'root' | SampleLoopKey; + +export interface SampleValues { + /** The id of the sound or clip it plays, '' for none. */ + sound: string; + root: number; + follow: boolean; + play: SamplePlay; + loopStart: number; + loopEnd: number; +} + +function secondsOf(params: PropertyBag, key: SampleLoopKey): number { + const value = params[key]; + return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0; +} + +/** The settings of a Sample node, each its row's default where none is set – as its notes read them. */ +export function sampleValues(params: PropertyBag): SampleValues { + return { + sound: textOf(params.sound ?? ''), + root: noteParam(params, 'root', MIDDLE_C), + follow: params.followNote !== false, + play: samplePlay(params), + loopStart: secondsOf(params, 'loopStart'), + loopEnd: secondsOf(params, 'loopEnd'), + }; +} + +/** The settings that do nothing as things stand: the node's own say, as its rows had it. */ +export function idleSampleKeys(params: PropertyBag): Set { + return new Set(InstrumentSample.idleParams?.(params) as SampleIdleKey[] | undefined); +} + +// --- The plate. --- + +/** + * The plate: as the Chip's (#386), the panel never takes another size. Kept narrow: the play buttons + * and the loop's two ends stack two high. + */ +export const SAMPLE_PANEL = { w: 480, h: 280 }; + +/** The extra the strip of text under the screen takes. */ +export const TEXT_ROW = 14; +const PLAY_BUTTON = { w: 58, h: 24 }; +const PLAY_GAP = 4; +const KNOB_SLOT = 60; +const LEVER = { w: 96, h: 40 }; +/** The room left of a loop field for its name. */ +export const FIELD_NAME = 40; +export const SAMPLE_FIELD = { w: 64, h: 24 }; +const GROUP_GAP = 20; +/** The height of a group's name and its rule. */ +const GROUP_LINE = 16; + +export interface SampleLayout { + title: Box; + /** The button that names the sound and opens the sounds to pick from, beside the device's name. */ + sound: Box; + bezel: Box; + /** The glass the waveform is drawn on; the strip of text runs under it. */ + screen: Box; + groups: { label: string; box: Box }[]; + plays: Box[]; + root: KnobSpot; + follow: Box; + /** A typed number, its name engraved left of it; start above end. */ + fields: Record; + screws: { x: number; y: number }[]; +} + +function sampleLayout(): SampleLayout { + const { w } = SAMPLE_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 - TEXT_ROW - 2 }; + const plays = 2 * PLAY_BUTTON.w + PLAY_GAP; + const pitch = KNOB_SLOT + LEVER.w; + const loop = FIELD_NAME + SAMPLE_FIELD.w; + const groupTop = bezel.y + bezel.h + 6; + const dialY = groupTop + GROUP_LINE + KNOB_CAPTION + SMALL_DIAL / 2; + // Two rows, as high as the knob with its caption, about its middle. + const rowY = (row: number) => dialY - PLAY_BUTTON.h - PLAY_GAP / 2 + row * (PLAY_BUTTON.h + PLAY_GAP); + let x = (w - plays - pitch - loop - 2 * GROUP_GAP) / 2; + const groups = [{ label: 'PLAY', box: { x, y: groupTop, w: plays, h: GROUP_LINE } }]; + const playBoxes = Array.from({ length: 4 }, (_, i) => ({ + x: x + (i % 2) * (PLAY_BUTTON.w + PLAY_GAP), + y: rowY(Math.floor(i / 2)), + ...PLAY_BUTTON, + })); + x += plays + GROUP_GAP; + groups.push({ label: 'PITCH', box: { x, y: groupTop, w: pitch, h: GROUP_LINE } }); + const root = { x: x + KNOB_SLOT / 2, y: dialY, size: SMALL_DIAL }; + const follow = { x: x + KNOB_SLOT, y: dialY - LEVER.h / 2 - 4, ...LEVER }; + x += pitch + GROUP_GAP; + groups.push({ label: 'LOOP (S)', box: { x, y: groupTop, w: loop, h: GROUP_LINE } }); + const fields = {} as Record; + SAMPLE_LOOP_KEYS.forEach((key, row) => { + fields[key] = { x: x + FIELD_NAME, y: rowY(row), ...SAMPLE_FIELD }; + }); + return { + title: { x: 34, y: 14, w: 100, h: 24 }, + sound: { x: 150, y: 12, w: w - 150 - 34, h: 28 }, + bezel, + screen, + groups, + plays: playBoxes, + root, + follow, + fields, + screws: screwsOf(SAMPLE_PANEL), + }; +} + +export const SAMPLE_LAYOUT: SampleLayout = sampleLayout(); + +/** What a play's button says: the first word would make two of LOOP. */ +export const PLAY_NAMES: Record = { + gate: 'GATE', + oneshot: 'ONE-SHOT', + loop: 'LOOP', + release: 'LOOP+REL', +}; + +// --- The screen: the sound as it is played. --- + +/** + * The stretch a Sample loops in seconds, as both ways of playing it hold it (#331): an end of 0 or + * past the sound is the end of the sound; a start not before the end loops all of it. + */ +export function loopSpan( + start: number, + end: number, + seconds: number, +): { from: number; to: number; whole: boolean } { + const to = end > 0 ? Math.min(seconds, end) : seconds; + const from = Math.max(0, start); + return from < to ? { from, to, whole: false } : { from: 0, to: seconds, whole: true }; +} + +/** + * What becomes of a stretch of the sound: played once, looped while the note is held, played once + * the note is let go, or never heard – what a Loop leaves after its loop. + */ +export type RegionKind = 'heard' | 'loop' | 'after' | 'never'; + +export interface Region { + kind: RegionKind; + /** Seconds into the sound. */ + from: number; + to: number; +} + +/** The sound cut into what becomes of each part of it, left to right; none of them empty. */ +export function sampleRegions(v: SampleValues, seconds: number): Region[] { + if (!loops(v.play)) return seconds > 0 ? [{ kind: 'heard', from: 0, to: seconds }] : []; + const span = loopSpan(v.loopStart, v.loopEnd, seconds); + const regions: Region[] = [ + { kind: 'heard', from: 0, to: span.from }, + { kind: 'loop', from: span.from, to: span.to }, + { kind: v.play === 'release' ? 'after' : 'never', from: span.to, to: seconds }, + ]; + return regions.filter((region) => region.to > region.from); +} + +/** + * How high the waveform is in each column across the screen, 0 to 1: the loudest peak of the + * stretch each column covers, as loud as the sound's volume plays it, at most full scale. + */ +export function waveColumns(peaks: ArrayLike, columns: number, volume: number): Float32Array { + const out = new Float32Array(Math.max(0, columns)); + const n = peaks.length; + if (n === 0) return out; + for (let i = 0; i < out.length; i++) { + const a = Math.floor((i * n) / out.length); + const b = Math.max(a + 1, Math.floor(((i + 1) * n) / out.length)); + let most = 0; + for (let k = a; k < Math.min(n, b); k++) most = Math.max(most, peaks[k] ?? 0); + out[i] = Math.min(1, most * Math.max(0, volume)); + } + return out; +} + +/** The steps the time marks may take, so they fall on round numbers whatever the length. */ +const MARK_STEPS = [0.01, 0.02, 0.05, 0.1, 0.2, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1800]; +/** The least room between two time marks. */ +export const MARK_ROOM = 64; + +/** The seconds a mark is drawn at along a sound of `seconds` drawn `width` wide: from 0, round steps apart. */ +export function timeMarks(seconds: number, width: number): number[] { + if (!(seconds > 0) || !(width > 0)) return []; + const step = + MARK_STEPS.find((s) => (s / seconds) * width >= MARK_ROOM) ?? MARK_STEPS[MARK_STEPS.length - 1]!; + const marks: number[] = []; + for (let k = 0; k * step <= seconds + 1e-9; k++) marks.push(Math.round(k * step * 1000) / 1000); + return marks; +} + +/** A time as the screen writes it: `0`, `250 ms`, `1.5 s`, `2:30`. */ +export function markText(seconds: number): string { + if (seconds === 0) return '0'; + if (seconds >= 60) { + const whole = Math.round(seconds); + return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}`; + } + return secondsText(seconds); +} + +/** A sound's volume as the bezel writes it beside its length; nothing at 1, as it plays at full. */ +export function volumeText(volume: number): string { + if (volume === 1) return ''; + if (!(volume > 0)) return 'MUTED'; + return `VOLUME ${formatNumber(20 * Math.log10(volume), 1)} dB`; +} + +const at = (seconds: number): string => (seconds === 0 ? '0 s' : secondsText(seconds)); + +/** What the strip under the screen says: how the sound plays, and at what pitch. */ +export function sampleTexts(v: SampleValues, seconds: number | null): { play: string; pitch: string } { + const pitch = v.follow ? `AS IT IS AT ${noteName(v.root)}` : 'AS IT IS ON EVERY NOTE'; + if (!loops(v.play)) + return { play: v.play === 'gate' ? 'ONCE, ENDS WITH THE NOTE' : 'ONCE, ALL THE WAY THROUGH', pitch }; + if (seconds === null) return { play: '', pitch }; + const span = loopSpan(v.loopStart, v.loopEnd, seconds); + const loop = span.whole ? 'LOOPS ALL OF IT' : `LOOPS ${at(span.from)} TO ${at(span.to)}`; + if (v.play === 'loop') return { play: `${loop} WHILE HELD`, pitch }; + return { play: span.to < seconds ? `${loop}, THEN PLAYS ON` : `${loop}, NOTHING AFTER`, pitch }; +} diff --git a/tests/editor/sample-view.test.ts b/tests/editor/sample-view.test.ts new file mode 100644 index 00000000..b16718b9 --- /dev/null +++ b/tests/editor/sample-view.test.ts @@ -0,0 +1,189 @@ +/** + * A Sample's node as a device (#400): every part on the plate and none over another, its settings + * read as its notes read them, and the screen – the waveform of its sound, cut into what is played + * once, looped, played after the note and never heard, the loop held as both ways of playing it do. + */ +import { describe, expect, it } from 'vitest'; +import { InstrumentSample } from '../../src/core/instrument/nodes'; +import type { PropertyBag } from '../../src/core/model/Properties'; +import { type Box, knobBox } from '../../src/editor/soundfx/hardwareView'; +import { + idleSampleKeys, + loopSpan, + MARK_ROOM, + markText, + SAMPLE_LAYOUT, + SAMPLE_LOOP_KEYS, + SAMPLE_PANEL, + sampleRegions, + sampleTexts, + type SampleValues, + sampleValues, + timeMarks, + volumeText, + waveColumns, +} from '../../src/editor/soundfx/sampleView'; + +const overlap = (a: Box, b: Box) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h; +const inside = (a: Box) => a.x >= 0 && a.y >= 0 && a.x + a.w <= SAMPLE_PANEL.w && a.y + a.h <= SAMPLE_PANEL.h; + +const values = (over: Partial = {}): SampleValues => ({ ...sampleValues({}), ...over }); + +describe('the Sample plate', () => { + const L = SAMPLE_LAYOUT; + const screws = L.screws.map((s) => ({ x: s.x - 7, y: s.y - 7, w: 14, h: 14 })); + + it('keeps the screen, the sound, the buttons, the knob, the lever and the fields on the plate, clear of each other and the screws', () => { + const parts: Box[] = [ + L.title, + L.sound, + L.bezel, + ...L.plays, + knobBox(L.root), + L.follow, + ...SAMPLE_LOOP_KEYS.map((key) => L.fields[key]), + ]; + for (const part of parts) { + expect(inside(part)).toBe(true); + for (const screw of screws) expect(overlap(part, screw)).toBe(false); + } + for (let i = 0; i < parts.length; i++) + for (let j = i + 1; j < parts.length; j++) expect(overlap(parts[i]!, parts[j]!)).toBe(false); + }); + + it('puts the screen inside its bezel, with room for the strip of text under it', () => { + const { screen, bezel } = L; + expect(screen.x).toBeGreaterThan(bezel.x); + expect(screen.x + screen.w).toBeLessThan(bezel.x + bezel.w); + expect(screen.y).toBeGreaterThan(bezel.y); + expect(screen.y + screen.h + 14).toBeLessThan(bezel.y + bezel.h); + }); +}); + +describe('the Sample settings', () => { + it('reads what is unset as its row has it: no sound, C4, following the note, Gate, no loop', () => { + expect(sampleValues({})).toEqual({ + sound: '', + root: 60, + follow: true, + play: 'gate', + loopStart: 0, + loopEnd: 0, + }); + }); + + it('reads the root from its note and a Sample saved before "Play" as its notes do', () => { + const v = sampleValues({ sound: 'kick', root: '67', followNote: false, loop: true, loopStart: 0.25 }); + expect(v).toMatchObject({ sound: 'kick', root: 67, follow: false, play: 'loop', loopStart: 0.25 }); + expect(sampleValues({ mode: 'oneshot' }).play).toBe('oneshot'); + expect(sampleValues({ loopStart: -1, loopEnd: Number.NaN })).toMatchObject({ loopStart: 0, loopEnd: 0 }); + }); + + it('greys out what the node greys out: the root without following, the loop without looping', () => { + const bags: PropertyBag[] = [ + {}, + { followNote: false }, + { play: 'loop' }, + { play: 'release', followNote: false }, + ]; + for (const params of bags) + expect([...idleSampleKeys(params)].sort()).toEqual([...InstrumentSample.idleParams!(params)].sort()); + expect([...idleSampleKeys({ followNote: false, play: 'oneshot' })].sort()).toEqual([ + 'loopEnd', + 'loopStart', + 'root', + ]); + }); +}); + +describe('the Sample screen', () => { + it('holds the loop as the sound is played: 0 or past the end is the end, a start not before it loops all', () => { + expect(loopSpan(0.2, 0.9, 1.5)).toEqual({ from: 0.2, to: 0.9, whole: false }); + expect(loopSpan(0.2, 0, 1.5)).toEqual({ from: 0.2, to: 1.5, whole: false }); + expect(loopSpan(0.2, 4, 1.5)).toEqual({ from: 0.2, to: 1.5, whole: false }); + expect(loopSpan(0.9, 0.5, 1.5)).toEqual({ from: 0, to: 1.5, whole: true }); + expect(loopSpan(2, 0, 1.5)).toEqual({ from: 0, to: 1.5, whole: true }); + }); + + it('cuts the sound into what becomes of it for each way of playing it', () => { + const loop = { loopStart: 0.5, loopEnd: 0.9 }; + expect(sampleRegions(values(loop), 1.5)).toEqual([{ kind: 'heard', from: 0, to: 1.5 }]); + expect(sampleRegions(values({ ...loop, play: 'oneshot' }), 1.5)).toEqual([ + { kind: 'heard', from: 0, to: 1.5 }, + ]); + expect(sampleRegions(values({ ...loop, play: 'loop' }), 1.5)).toEqual([ + { kind: 'heard', from: 0, to: 0.5 }, + { kind: 'loop', from: 0.5, to: 0.9 }, + { kind: 'never', from: 0.9, to: 1.5 }, + ]); + expect(sampleRegions(values({ ...loop, play: 'release' }), 1.5).map((r) => r.kind)).toEqual([ + 'heard', + 'loop', + 'after', + ]); + // Looped from the start to the end: nothing before it and nothing after it. + expect(sampleRegions(values({ play: 'release' }), 1.5)).toEqual([{ kind: 'loop', from: 0, to: 1.5 }]); + expect(sampleRegions(values({ play: 'loop' }), 0)).toEqual([]); + }); + + it('draws each column as high as the loudest peak it covers, as loud as the volume plays it', () => { + const peaks = Float32Array.from([0.1, 0.5, 0.2, 0.9, 0.3, 0.4]); + expect([...waveColumns(peaks, 3, 1)].map((v) => +v.toFixed(3))).toEqual([0.5, 0.9, 0.4]); + expect([...waveColumns(peaks, 3, 0.5)].map((v) => +v.toFixed(3))).toEqual([0.25, 0.45, 0.2]); + // Louder than full scale stays on the glass. + expect(Math.max(...waveColumns(peaks, 3, 4))).toBe(1); + // More columns than peaks: each reads the peak under it, none is left empty. + expect([...waveColumns(Float32Array.from([0.2, 0.8]), 4, 1)].map((v) => +v.toFixed(3))).toEqual([ + 0.2, 0.2, 0.8, 0.8, + ]); + expect([...waveColumns(new Float32Array(0), 3, 1)]).toEqual([0, 0, 0]); + }); + + it('marks the seconds from 0 on round steps, far enough apart, never past the end', () => { + for (const [seconds, width] of [ + [1.5, 680], + [0.45, 680], + [12, 680], + [300, 680], + ] as const) { + const marks = timeMarks(seconds, width); + expect(marks[0]).toBe(0); + expect(marks.at(-1)!).toBeLessThanOrEqual(seconds); + expect(((marks[1]! - marks[0]!) / seconds) * width).toBeGreaterThanOrEqual(MARK_ROOM); + } + expect(timeMarks(1.5, 680)).toEqual([0, 0.2, 0.4, 0.6, 0.8, 1, 1.2, 1.4]); + expect(timeMarks(0, 680)).toEqual([]); + }); + + it('writes the times and the volume as the bezel shows them', () => { + expect(markText(0)).toBe('0'); + expect(markText(0.25)).toBe('250 ms'); + expect(markText(1.5)).toBe('1.5 s'); + expect(markText(150)).toBe('2:30'); + expect(volumeText(1)).toBe(''); + expect(volumeText(0.5)).toBe('VOLUME -6 dB'); + expect(volumeText(0)).toBe('MUTED'); + }); + + it('says how it plays and at what pitch', () => { + expect(sampleTexts(values(), 1.5)).toEqual({ play: 'ONCE, ENDS WITH THE NOTE', pitch: 'AS IT IS AT C4' }); + expect(sampleTexts(values({ play: 'oneshot', root: 67 }), 1.5)).toEqual({ + play: 'ONCE, ALL THE WAY THROUGH', + pitch: 'AS IT IS AT G4', + }); + const loop = { loopStart: 0.5, loopEnd: 0.9 }; + expect(sampleTexts(values({ ...loop, play: 'loop', follow: false }), 1.5)).toEqual({ + play: 'LOOPS 500 ms TO 900 ms WHILE HELD', + pitch: 'AS IT IS ON EVERY NOTE', + }); + expect(sampleTexts(values({ ...loop, play: 'release' }), 1.5).play).toBe( + 'LOOPS 500 ms TO 900 ms, THEN PLAYS ON', + ); + expect(sampleTexts(values({ play: 'release' }), 1.5).play).toBe('LOOPS 0 s TO 1.5 s, NOTHING AFTER'); + expect(sampleTexts(values({ play: 'loop', loopStart: 1, loopEnd: 0.5 }), 1.5).play).toBe( + 'LOOPS ALL OF IT WHILE HELD', + ); + // Before the sound is loaded there is no length to say the loop in. + expect(sampleTexts(values({ play: 'loop' }), null).play).toBe(''); + }); +}); diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 6ccd6fae..9fd981d0 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -2611,6 +2611,59 @@ export async function sound(t) { insReady.keys.sounding.join() === [60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71].join(), JSON.stringify(insReady), ); + // #400: the Sample is a device of its own size whose screen draws the waveform of its sound, the + // loop lit over all of it, and the Output sits clear of it. A play button and the root knob + // are one undo step each, and the screen follows them. + const insNodes = await json( + `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}).graph.nodes`, + ); + const smpId = insNodes.find((n) => n.type === 'InstrumentSample')?.id; + const outAt = await json( + `__gm.instrument.nodeAt(${JSON.stringify(insNodes.find((n) => n.type === 'InstrumentOutput')?.id)})`, + ); + const smpPanel = () => json(`__gm.sample.panel(${JSON.stringify(smpId)})`); + const smpParams = () => + json( + `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}).graph.nodes.find((n) => n.id === ${JSON.stringify(smpId)}).params`, + ); + const smpShown = await waitFor(smpPanel, (p) => p?.scope.columns > 0); + await click(smpShown.plays.oneshot.x, smpShown.plays.oneshot.y); + const smpOneShot = { params: await smpParams(), panel: await smpPanel() }; + const smpDial = smpShown.root.dial; + await drag(smpDial.x, smpDial.y, smpDial.x, smpDial.y - 30); + const smpRoot = { params: await smpParams(), panel: await smpPanel() }; + for (let i = 0; i < 2; i++) await press('z', { ctrl: true }); + const smpUndone = { params: await smpParams(), panel: await smpPanel() }; + const loopedAll = (p) => p.scope.regions.length === 1 && p.scope.regions[0].kind === 'loop'; + check( + 'a Sample node is a device whose screen draws the waveform of its sound; a play button and the root knob are one undo step each', + smpShown.size.w === 480 && + smpShown.size.h === 280 && + smpShown.node.x + smpShown.node.w < outAt.x && + Object.values(smpShown.inputs).every((p) => p.x < smpShown.rect.x) && + Object.values(smpShown.outputs).every((p) => p.x > smpShown.rect.x + smpShown.rect.w) && + smpShown.sound.text !== 'No sound' && + smpShown.play === 'loop' && + smpShown.root.number === 'C4' && + smpShown.follow.on && + smpShown.idle.length === 0 && + loopedAll(smpShown) && + smpShown.scope.says.loop === 'LOOP' && + smpShown.scope.tallest > 0.1 && + smpShown.length !== '' && + smpOneShot.params.play === 'oneshot' && + smpOneShot.panel.play === 'oneshot' && + smpOneShot.panel.idle.includes('loopStart') && + smpOneShot.panel.scope.regions.map((r) => r.kind).join() === 'heard' && + smpOneShot.panel.scope.says.play === 'ONCE, ALL THE WAY THROUGH' && + Number(smpRoot.params.root) > 60 && + smpRoot.panel.scope.says.pitch === `AS IT IS AT ${smpRoot.panel.root.number}` && + smpUndone.params.play === 'loop' && + smpUndone.params.root === undefined && + smpUndone.panel.root.number === 'C4' && + loopedAll(smpUndone.panel), + JSON.stringify({ smpShown, smpOneShot, smpRoot, smpUndone, outAt }), + ); await shot('34-instrument'); await evaluate('__gm.sound.stopAll()');