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()');