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