Files
adminandClaude Opus 5.5 6383cc0d8e
game-maker / gate (push) Successful in 4m14s
game-maker / suite (push) Successful in 16m49s
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 <noreply@anthropic.com>
2026-10-10 22:54:17 +02:00

190 lines
7.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 => ({ ...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('');
});
});