Let a looped sample play out past its loop when the note ends #331
With "Mode" and "Loop" as two fields, One-shot plus Loop ran until Stop was pressed: letting go of the key did nothing. The two fields are now one, "Play", with four entries that each say what happens while the note is held and when it is let go. - Gate and One-shot play the sound once, as before. Loop repeats it and fades out with the note. - "Loop, then release" is new: the sampler's sustain loop. Let go, it finishes the round it is in and plays what comes after "Loop end" to the end of the sound. - A buffer source stops looping and plays on by itself. The pitch worklet gets a "leave" message. The phase vocoder then lets the round finish that its newest frame has read into, so nothing read already changes under the frames still sounding. - Samples saved with "Mode" and "Loop" are turned into "Play" on load. Gate and One-shot stay, either with Loop becomes Loop, since playing until stopped is gone. - The keys of the Instrument tab stay half lit while a note let go is still heard. Esc on the keys stops everything, like Stop. - Striking a ringing key again plays a new note beside it rather than stopping the old one, so a drum can be hit quickly. This differs from what was proposed in the issue discussion. Tests: - instrument + pitch-player + sound-library + node-catalog: 477 tests, green (12 new). - e2e 34 (melektaus-atx): ALL PASSED, 45 checks (+1). Let go after a loop of 0.05 s, the note played out for 0.31 s on the audio clock, the key half lit meanwhile. - Counter-check: a release that does not leave the loop fails the 2 new player tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8a4e4a6762
commit
144dee0a97
14 files changed
+285
-29
No files matched your search
@@ -30,9 +30,11 @@ import {
|
|||||||
INSTRUMENT_SAMPLE,
|
INSTRUMENT_SAMPLE,
|
||||||
INSTRUMENT_SOUND_FX,
|
INSTRUMENT_SOUND_FX,
|
||||||
isInstrumentNodeDef,
|
isInstrumentNodeDef,
|
||||||
|
loops,
|
||||||
noteOutputKey,
|
noteOutputKey,
|
||||||
noteParam,
|
noteParam,
|
||||||
notesRange,
|
notesRange,
|
||||||
|
samplePlay,
|
||||||
singleNotes,
|
singleNotes,
|
||||||
VELOCITY_PARAM,
|
VELOCITY_PARAM,
|
||||||
} from './nodes';
|
} from './nodes';
|
||||||
@@ -194,7 +196,8 @@ export function compileInstrument(graph: Graph, note: number, sources: Instrumen
|
|||||||
if (!sound?.file) return null;
|
if (!sound?.file) return null;
|
||||||
const follows = node.params.followNote !== false;
|
const follows = node.params.followNote !== false;
|
||||||
const shift = follows ? played - noteParam(node.params, 'root', MIDDLE_C) : 0;
|
const shift = follows ? played - noteParam(node.params, 'root', MIDDLE_C) : 0;
|
||||||
const loop = node.params.loop === true;
|
const play = samplePlay(node.params);
|
||||||
|
const loop = loops(play);
|
||||||
return {
|
return {
|
||||||
pitch: shift === 0 ? [] : [{ value: shift }],
|
pitch: shift === 0 ? [] : [{ value: shift }],
|
||||||
stages: [],
|
stages: [],
|
||||||
@@ -204,7 +207,7 @@ export function compileInstrument(graph: Graph, note: number, sources: Instrumen
|
|||||||
loop,
|
loop,
|
||||||
loopStart: loop ? Math.max(0, asNumber(node.params.loopStart ?? 0)) : 0,
|
loopStart: loop ? Math.max(0, asNumber(node.params.loopStart ?? 0)) : 0,
|
||||||
loopEnd: loop ? Math.max(0, asNumber(node.params.loopEnd ?? 0)) : 0,
|
loopEnd: loop ? Math.max(0, asNumber(node.params.loopEnd ?? 0)) : 0,
|
||||||
gate: textOf(node.params.mode ?? 'gate') !== 'oneshot',
|
gate: play === 'gate' || play === 'loop',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,18 +116,42 @@ export const InstrumentNotes: InstrumentNodeDef = {
|
|||||||
const SOUND: PropertyDef = { key: 'sound', label: 'Sound', kind: 'sound', default: '' };
|
const SOUND: PropertyDef = { key: 'sound', label: 'Sound', kind: 'sound', default: '' };
|
||||||
const ROOT = noteRow('root', 'Root', MIDDLE_C);
|
const ROOT = noteRow('root', 'Root', MIDDLE_C);
|
||||||
const FOLLOW = { key: 'followNote', label: 'Follow note', kind: 'boolean' as const, default: true };
|
const FOLLOW = { key: 'followNote', label: 'Follow note', kind: 'boolean' as const, default: true };
|
||||||
export const SAMPLE_MODES = [
|
/** How a Sample plays while its note is held and once it is let go. */
|
||||||
|
export type SamplePlay = 'gate' | 'oneshot' | 'loop' | 'release';
|
||||||
|
export const SAMPLE_PLAYS: { value: SamplePlay; label: string }[] = [
|
||||||
{ value: 'gate', label: 'Gate' },
|
{ value: 'gate', label: 'Gate' },
|
||||||
{ value: 'oneshot', label: 'One-shot' },
|
{ value: 'oneshot', label: 'One-shot' },
|
||||||
|
{ value: 'loop', label: 'Loop' },
|
||||||
|
{ value: 'release', label: 'Loop, then release' },
|
||||||
];
|
];
|
||||||
const MODE: PropertyDef = {
|
const PLAY: PropertyDef = {
|
||||||
key: 'mode',
|
key: 'play',
|
||||||
label: 'Mode',
|
label: 'Play',
|
||||||
kind: 'enum',
|
kind: 'enum',
|
||||||
default: 'gate',
|
default: 'gate',
|
||||||
options: SAMPLE_MODES,
|
options: SAMPLE_PLAYS,
|
||||||
};
|
};
|
||||||
const LOOP = { key: 'loop', label: 'Loop', kind: 'boolean' as const, default: false };
|
|
||||||
|
/**
|
||||||
|
* What a Sample plays as. Before "Play" it had "Mode" and "Loop": a looping one ended with the note
|
||||||
|
* either way then, or ran until it was stopped, which is gone – both are "Loop" now.
|
||||||
|
*/
|
||||||
|
export function samplePlay(params: PropertyBag): SamplePlay {
|
||||||
|
const play = SAMPLE_PLAYS.find((option) => option.value === params[PLAY.key]);
|
||||||
|
if (play) return play.value;
|
||||||
|
if (params.loop === true) return 'loop';
|
||||||
|
return params.mode === 'oneshot' ? 'oneshot' : 'gate';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loops(play: SamplePlay): boolean {
|
||||||
|
return play === 'loop' || play === 'release';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A Sample as it was saved before "Play", with its "Mode" and "Loop" turned into one. */
|
||||||
|
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: PropertyDef = {
|
||||||
key: 'loopStart',
|
key: 'loopStart',
|
||||||
label: 'Loop start (s)',
|
label: 'Loop start (s)',
|
||||||
@@ -151,13 +175,13 @@ export const InstrumentSample: InstrumentNodeDef = {
|
|||||||
category: 'sound',
|
category: 'sound',
|
||||||
instrument: true,
|
instrument: true,
|
||||||
description:
|
description:
|
||||||
'Plays a sound asset when a note triggers it. With "follow note" it is played higher or lower by the distance from "root" – the note it sounds as it is – and keeps its length. Off, every note plays it as it is, as for a drum. "Gate" ends it with the note, faded out in a few milliseconds; "One-shot" plays it through whatever the note does. "Loop" repeats it, between "loop start" and "loop end" in seconds where they are set (0 is the end of the sound), until the note ends – or, as a one-shot, until it is stopped. The volume of the sound asset counts.',
|
'Plays a sound asset when a note triggers it. With "follow note" it is played higher or lower by the distance from "root" – the note it sounds as it is – and keeps its length. Off, every note plays it as it is, as for a drum. "Play" says what it does: "Gate" plays it once and ends it with the note, faded out in a few milliseconds; "One-shot" plays it once through whatever the note does. "Loop" repeats it between "loop start" and "loop end" in seconds (0 is the end of the sound) until the note ends; "Loop, then release" leaves the loop when the note ends and plays what comes after "loop end" – with "loop end" at 0 there is nothing after it, and the note ends with the loop. The volume of the sound asset counts.',
|
||||||
inputs: [triggerIn],
|
inputs: [triggerIn],
|
||||||
outputs: [audioOut],
|
outputs: [audioOut],
|
||||||
params: [SOUND, ROOT, FOLLOW, MODE, LOOP, LOOP_START, LOOP_END],
|
params: [SOUND, ROOT, FOLLOW, PLAY, LOOP_START, LOOP_END],
|
||||||
idleParams: (params) => [
|
idleParams: (params) => [
|
||||||
...(params[FOLLOW.key] === false ? [ROOT.key] : []),
|
...(params[FOLLOW.key] === false ? [ROOT.key] : []),
|
||||||
...(params[LOOP.key] === true ? [] : [LOOP_START.key, LOOP_END.key]),
|
...(loops(samplePlay(params)) ? [] : [LOOP_START.key, LOOP_END.key]),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ export interface FxDelay {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Where a way of an instrument's note gets its sound (#331): a file of its own, how loud and how it
|
* Where a way of an instrument's note gets its sound (#331): a file of its own, how loud and how it
|
||||||
* loops, and whether it ends with the note (`gate`) or plays through. A way of a sound FX has none –
|
* loops, and whether it ends with the note (`gate`) or plays through – one that loops leaves its
|
||||||
* it plays the sound the voice was started with. `loopEnd` 0 is the end of the sound.
|
* loop then and plays what comes after it. A way of a sound FX has none: it plays the sound the
|
||||||
|
* voice was started with. `loopEnd` 0 is the end of the sound.
|
||||||
*/
|
*/
|
||||||
export interface FxSource {
|
export interface FxSource {
|
||||||
file: string;
|
file: string;
|
||||||
|
|||||||
@@ -55,10 +55,15 @@ export class PitchPlayer {
|
|||||||
/** Whether `lastPhase` holds the frame before: a frame at 0 semitones does not need its phases. */
|
/** Whether `lastPhase` holds the frame before: a frame at 0 semitones does not need its phases. */
|
||||||
private phased = false;
|
private phased = false;
|
||||||
private primed = false;
|
private primed = false;
|
||||||
/** How many samples of the output the sound lasts; a looping one never ends. */
|
/** How many samples of the output the sound lasts; a looping one never ends until it leaves its loop. */
|
||||||
private readonly end: number;
|
private end: number;
|
||||||
/** Where a loop runs in the samples of the sound; null for a sound that does not loop. */
|
/** Where a loop runs in the samples of the sound; null for a sound that does not loop. */
|
||||||
private readonly loop: { start: number; end: number } | null;
|
private readonly loop: { start: number; end: number } | null;
|
||||||
|
/**
|
||||||
|
* Once the loop is left, how far the sound's samples lag behind the output from the end of the
|
||||||
|
* last round on: every round played before then. Infinity while it loops on.
|
||||||
|
*/
|
||||||
|
private unwound = Infinity;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `channels` are the samples of the sound, `step` how many of them pass for one sample of the
|
* `channels` are the samples of the sound, `step` how many of them pass for one sample of the
|
||||||
@@ -129,6 +134,21 @@ export class PitchPlayer {
|
|||||||
return this.frameAt + this.given < this.end;
|
return this.frameAt + this.given < this.end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets the loop finish the round it is in and then plays on into what comes after it, to the end
|
||||||
|
* of the sound (#331). The round is the one the newest frame has read up to, so nothing read
|
||||||
|
* already changes under the frames still sounding.
|
||||||
|
*/
|
||||||
|
leaveLoop(): void {
|
||||||
|
const { loop } = this;
|
||||||
|
if (!loop || this.unwound !== Infinity) return;
|
||||||
|
const span = loop.end - loop.start;
|
||||||
|
const read = (this.frameAt + this.size) * this.step;
|
||||||
|
const rounds = read < loop.end ? 0 : Math.floor((read - loop.end) / span) + 1;
|
||||||
|
this.unwound = rounds * span;
|
||||||
|
this.end = Math.ceil(((this.channels[0]?.length ?? 0) + this.unwound) / this.step);
|
||||||
|
}
|
||||||
|
|
||||||
/** Adds the next frame, a hop on from the last, and hands out the hop it finishes. */
|
/** Adds the next frame, a hop on from the last, and hands out the hop it finishes. */
|
||||||
private frame(semitones: number): void {
|
private frame(semitones: number): void {
|
||||||
this.frameAt += this.hop;
|
this.frameAt += this.hop;
|
||||||
@@ -259,14 +279,22 @@ export class PitchPlayer {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Channel `c` for the frame starting at output sample `from`, each sample read between two of the
|
* Channel `c` for the frame starting at output sample `from`, each sample read between two of the
|
||||||
* sound's; outside the sound it is silence, or the loop again.
|
* sound's; outside the sound it is silence, or the loop again – or past the loop once it is left.
|
||||||
*/
|
*/
|
||||||
private readFrame(c: number, from: number): Float64Array {
|
private readFrame(c: number, from: number): Float64Array {
|
||||||
const { reading, size, step, loop } = this;
|
const { reading, size, step, loop, unwound } = this;
|
||||||
const samples = this.channels[c]!;
|
const samples = this.channels[c]!;
|
||||||
const end = loop ? loop.end : samples.length;
|
const end = loop ? loop.end : samples.length;
|
||||||
for (let j = 0; j < size; j++) {
|
for (let j = 0; j < size; j++) {
|
||||||
let position = (from + j) * step;
|
let position = (from + j) * step;
|
||||||
|
if (loop && position >= loop.end + unwound) {
|
||||||
|
position -= unwound;
|
||||||
|
const below = Math.floor(position);
|
||||||
|
const fraction = position - below;
|
||||||
|
const next = below + 1 < samples.length ? samples[below + 1]! : 0;
|
||||||
|
reading[j] = below < samples.length ? samples[below]! * (1 - fraction) + next * fraction : 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (position < 0 || position >= end) {
|
if (position < 0 || position >= end) {
|
||||||
if (!loop || position < 0) {
|
if (!loop || position < 0) {
|
||||||
reading[j] = 0;
|
reading[j] = 0;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
import { isParticleNodeDef } from '../particles/nodes';
|
import { isParticleNodeDef } from '../particles/nodes';
|
||||||
import { isShaderNodeDef } from '../shader/nodes';
|
import { isShaderNodeDef } from '../shader/nodes';
|
||||||
import { isSoundFxNodeDef } from '../soundfx/nodes';
|
import { isSoundFxNodeDef } from '../soundfx/nodes';
|
||||||
import { isInstrumentNodeDef } from '../instrument/nodes';
|
import { INSTRUMENT_SAMPLE, isInstrumentNodeDef, withSamplePlay } from '../instrument/nodes';
|
||||||
import { isKeyInterpolation, normalizeKeys } from '../model/animation';
|
import { isKeyInterpolation, normalizeKeys } from '../model/animation';
|
||||||
import type {
|
import type {
|
||||||
AnimationKey,
|
AnimationKey,
|
||||||
@@ -240,7 +240,10 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
|||||||
assets.push({ id, type, name, props, graph: normalizeGraph(a.graph, isSoundFxNodeDef) });
|
assets.push({ id, type, name, props, graph: normalizeGraph(a.graph, isSoundFxNodeDef) });
|
||||||
} else if (type === 'instrument') {
|
} else if (type === 'instrument') {
|
||||||
const keep = (def: NodeDef) => isInstrumentNodeDef(def) || isSoundFxNodeDef(def);
|
const keep = (def: NodeDef) => isInstrumentNodeDef(def) || isSoundFxNodeDef(def);
|
||||||
assets.push({ id, type, name, props, graph: normalizeGraph(a.graph, keep) });
|
const graph = normalizeGraph(a.graph, keep);
|
||||||
|
for (const node of graph.nodes)
|
||||||
|
if (node.type === INSTRUMENT_SAMPLE) node.params = withSamplePlay(node.params);
|
||||||
|
assets.push({ id, type, name, props, graph });
|
||||||
} else if (type === 'texture') {
|
} else if (type === 'texture') {
|
||||||
assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) });
|
assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) });
|
||||||
} else if (type === 'font') {
|
} else if (type === 'font') {
|
||||||
|
|||||||
@@ -123,10 +123,14 @@ export function graphHooks({ state, editor }: HookContext) {
|
|||||||
portAt: (nodeId: string, port: string, isOutput: boolean) =>
|
portAt: (nodeId: string, port: string, isOutput: boolean) =>
|
||||||
editor.instrumentEditor.canvas.portGlobal(nodeId, port, isOutput),
|
editor.instrumentEditor.canvas.portGlobal(nodeId, port, isOutput),
|
||||||
canvasRect: () => editor.instrumentEditor.canvas.canvasRect(),
|
canvasRect: () => editor.instrumentEditor.canvas.canvasRect(),
|
||||||
/** The keys: which make a sound, which are held, where one is, and the computer keyboard's octave. */
|
/**
|
||||||
|
* The keys: which make a sound, which are held, which are let go but still heard, where one is,
|
||||||
|
* and the computer keyboard's octave.
|
||||||
|
*/
|
||||||
keys: () => ({
|
keys: () => ({
|
||||||
sounding: editor.instrumentEditor.soundingNotes(),
|
sounding: editor.instrumentEditor.soundingNotes(),
|
||||||
held: editor.instrumentEditor.keyboard.heldNotes,
|
held: editor.instrumentEditor.keyboard.heldNotes,
|
||||||
|
ringing: editor.instrumentEditor.keyboard.ringingNotes,
|
||||||
octave: editor.instrumentEditor.keyboard.bottomOctave,
|
octave: editor.instrumentEditor.keyboard.bottomOctave,
|
||||||
}),
|
}),
|
||||||
keyAt: (note: number) => editor.instrumentEditor.keyboard.keyCentre(note),
|
keyAt: (note: number) => editor.instrumentEditor.keyboard.keyCentre(note),
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
|
|||||||
this.stopButton = new Button('Stop', { icon: TOOLBAR_ICON_SVG.stop, onClick: () => this.stop() });
|
this.stopButton = new Button('Stop', { icon: TOOLBAR_ICON_SVG.stop, onClick: () => this.stop() });
|
||||||
this.keyboard.onNoteOn = (note) => this.playNote(note);
|
this.keyboard.onNoteOn = (note) => this.playNote(note);
|
||||||
this.keyboard.onNoteOff = (voice) => soundLibrary.release(voice);
|
this.keyboard.onNoteOff = (voice) => soundLibrary.release(voice);
|
||||||
|
this.keyboard.isPlaying = (voice) => soundLibrary.playing(voice);
|
||||||
|
this.keyboard.onStop = () => this.stop();
|
||||||
this.keyboard.onOctave = () => this.syncOctave();
|
this.keyboard.onOctave = () => this.syncOctave();
|
||||||
this.mount(new GraphCanvas(state, 'instrument'), [
|
this.mount(new GraphCanvas(state, 'instrument'), [
|
||||||
...this.snap.widgets,
|
...this.snap.widgets,
|
||||||
@@ -107,6 +109,7 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
|
|||||||
this.keyboard.releaseAll();
|
this.keyboard.releaseAll();
|
||||||
soundLibrary.stopOwner(PREVIEW_OWNER);
|
soundLibrary.stopOwner(PREVIEW_OWNER);
|
||||||
this.voices = [];
|
this.voices = [];
|
||||||
|
this.keyboard.syncRinging();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The notes the keys show as making a sound (test hook). */
|
/** The notes the keys show as making a sound (test hook). */
|
||||||
|
|||||||
@@ -17,14 +17,17 @@ const WHITE = 0xe8e8e8;
|
|||||||
const BLACK = 0x22252b;
|
const BLACK = 0x22252b;
|
||||||
const MUTED_WHITE = 0x6b6f78;
|
const MUTED_WHITE = 0x6b6f78;
|
||||||
const MUTED_BLACK = 0x3a3e46;
|
const MUTED_BLACK = 0x3a3e46;
|
||||||
|
/** How strongly a key let go but still heard is lit, over its own color. */
|
||||||
|
const RINGING_ALPHA = 0.5;
|
||||||
/** Dark, to stand on a white key – its own style, since the shared ones are cached for everyone. */
|
/** Dark, to stand on a white key – its own style, since the shared ones are cached for everyone. */
|
||||||
const KEY_LABEL = textStyle('small').clone();
|
const KEY_LABEL = textStyle('small').clone();
|
||||||
KEY_LABEL.fill = 0x30343c;
|
KEY_LABEL.fill = 0x30343c;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The keys of an instrument's tab (#331): every note there is, the ones the instrument plays nothing
|
* The keys of an instrument's tab (#331): every note there is, the ones the instrument plays nothing
|
||||||
* for greyed out, a pressed one lit. A key sounds while the pointer holds it; with the keyboard
|
* for greyed out, a pressed one lit and one let go but still heard half lit. A key sounds while the
|
||||||
* focused, the computer keyboard plays it as a tracker does, and the arrows move its octave.
|
* pointer holds it; with the keyboard focused, the computer keyboard plays it as a tracker does, the
|
||||||
|
* arrows move its octave and `Esc` silences everything.
|
||||||
*/
|
*/
|
||||||
export class PianoKeyboard extends Widget {
|
export class PianoKeyboard extends Widget {
|
||||||
private readonly keysLayer = new Graphics();
|
private readonly keysLayer = new Graphics();
|
||||||
@@ -33,11 +36,19 @@ export class PianoKeyboard extends Widget {
|
|||||||
private sounding: ReadonlySet<number> = new Set();
|
private sounding: ReadonlySet<number> = new Set();
|
||||||
/** What each held note is held by – the pointer, or a key by its code – and the voice it started. */
|
/** What each held note is held by – the pointer, or a key by its code – and the voice it started. */
|
||||||
private readonly held = new Map<string, { note: number; voice: number }>();
|
private readonly held = new Map<string, { note: number; voice: number }>();
|
||||||
|
/** Notes let go that are still heard – a one-shot playing out, a loop left, an echo – by their voice. */
|
||||||
|
private ringing: { note: number; voice: number }[] = [];
|
||||||
|
private watching = false;
|
||||||
|
private readonly watch = () => this.syncRinging();
|
||||||
private octave = DEFAULT_OCTAVE;
|
private octave = DEFAULT_OCTAVE;
|
||||||
private readonly onKeyUp = (event: KeyboardEvent) => this.up(event.code);
|
private readonly onKeyUp = (event: KeyboardEvent) => this.up(event.code);
|
||||||
/** Starts a note and gives back its voice, 0 for none. */
|
/** Starts a note and gives back its voice, 0 for none. */
|
||||||
onNoteOn: (note: number) => number = () => 0;
|
onNoteOn: (note: number) => number = () => 0;
|
||||||
onNoteOff: (voice: number) => void = () => {};
|
onNoteOff: (voice: number) => void = () => {};
|
||||||
|
/** Whether a voice is still heard: a key let go stays lit until it is not. */
|
||||||
|
isPlaying: (voice: number) => boolean = () => false;
|
||||||
|
/** `Esc` while something plays: silence it all. */
|
||||||
|
onStop: () => void = () => {};
|
||||||
onOctave: (octave: number) => void = () => {};
|
onOctave: (octave: number) => void = () => {};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -71,6 +82,11 @@ export class PianoKeyboard extends Widget {
|
|||||||
return [...this.held.values()].map((h) => h.note).sort((a, b) => a - b);
|
return [...this.held.values()].map((h) => h.note).sort((a, b) => a - b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The notes let go that are still heard, lowest first (test hook). */
|
||||||
|
get ringingNotes(): number[] {
|
||||||
|
return [...new Set(this.ringing.map((r) => r.note))].sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
get bottomOctave(): number {
|
get bottomOctave(): number {
|
||||||
return this.octave;
|
return this.octave;
|
||||||
}
|
}
|
||||||
@@ -88,6 +104,17 @@ export class PianoKeyboard extends Widget {
|
|||||||
for (const holder of [...this.held.keys()]) this.up(holder);
|
for (const holder of [...this.held.keys()]) this.up(holder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Looks again at which of the notes let go are still heard, and draws the keys of those lit. */
|
||||||
|
syncRinging(): void {
|
||||||
|
const before = this.ringing.length;
|
||||||
|
this.ringing = this.ringing.filter((r) => this.isPlaying(r.voice));
|
||||||
|
if (this.ringing.length !== before) this.invalidate();
|
||||||
|
if (this.ringing.length === 0 && this.watching) {
|
||||||
|
ui().app.ticker.remove(this.watch);
|
||||||
|
this.watching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override onFocus(): void {
|
override onFocus(): void {
|
||||||
super.onFocus();
|
super.onFocus();
|
||||||
window.addEventListener('keyup', this.onKeyUp);
|
window.addEventListener('keyup', this.onKeyUp);
|
||||||
@@ -101,6 +128,12 @@ export class PianoKeyboard extends Widget {
|
|||||||
|
|
||||||
override onKeyDown(event: KeyboardEvent): boolean {
|
override onKeyDown(event: KeyboardEvent): boolean {
|
||||||
if (event.ctrlKey || event.metaKey || event.altKey) return false;
|
if (event.ctrlKey || event.metaKey || event.altKey) return false;
|
||||||
|
if (event.code === 'Escape') {
|
||||||
|
// Nothing to silence: `Esc` goes on to whatever else takes it.
|
||||||
|
if (this.held.size === 0 && this.ringing.length === 0) return false;
|
||||||
|
this.onStop();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const step = octaveStep(event.code);
|
const step = octaveStep(event.code);
|
||||||
if (step !== 0) {
|
if (step !== 0) {
|
||||||
this.octave = Math.max(LOWEST_OCTAVE, Math.min(HIGHEST_OCTAVE, this.octave + step));
|
this.octave = Math.max(LOWEST_OCTAVE, Math.min(HIGHEST_OCTAVE, this.octave + step));
|
||||||
@@ -117,6 +150,7 @@ export class PianoKeyboard extends Widget {
|
|||||||
|
|
||||||
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
|
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
|
||||||
window.removeEventListener('keyup', this.onKeyUp);
|
window.removeEventListener('keyup', this.onKeyUp);
|
||||||
|
if (this.watching) ui().app.ticker.remove(this.watch);
|
||||||
super.destroy(options);
|
super.destroy(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +162,7 @@ export class PianoKeyboard extends Widget {
|
|||||||
const g = this.keysLayer;
|
const g = this.keysLayer;
|
||||||
g.clear();
|
g.clear();
|
||||||
const lit = new Set(this.heldNotes);
|
const lit = new Set(this.heldNotes);
|
||||||
|
const ringing = new Set(this.ringingNotes);
|
||||||
const c = Theme.colors;
|
const c = Theme.colors;
|
||||||
for (const key of this.keys) {
|
for (const key of this.keys) {
|
||||||
const sounds = this.sounding.has(key.note);
|
const sounds = this.sounding.has(key.note);
|
||||||
@@ -141,6 +176,9 @@ export class PianoKeyboard extends Widget {
|
|||||||
? WHITE
|
? WHITE
|
||||||
: MUTED_WHITE;
|
: MUTED_WHITE;
|
||||||
g.rect(key.x, key.y, key.w, key.h).fill(fill).stroke({ width: 1, color: c.border });
|
g.rect(key.x, key.y, key.w, key.h).fill(fill).stroke({ width: 1, color: c.border });
|
||||||
|
// Let go but still heard: lit, if less than one held.
|
||||||
|
if (!lit.has(key.note) && ringing.has(key.note))
|
||||||
|
g.rect(key.x, key.y, key.w, key.h).fill({ color: c.accent, alpha: RINGING_ALPHA });
|
||||||
}
|
}
|
||||||
// The octave the bottom row of the computer keyboard plays, underlined.
|
// The octave the bottom row of the computer keyboard plays, underlined.
|
||||||
const from = this.keys.find((k) => k.note === (this.octave + 1) * 12);
|
const from = this.keys.find((k) => k.note === (this.octave + 1) * 12);
|
||||||
@@ -179,7 +217,14 @@ export class PianoKeyboard extends Widget {
|
|||||||
const holding = this.held.get(holder);
|
const holding = this.held.get(holder);
|
||||||
if (!holding) return;
|
if (!holding) return;
|
||||||
this.held.delete(holder);
|
this.held.delete(holder);
|
||||||
if (holding.voice) this.onNoteOff(holding.voice);
|
if (holding.voice) {
|
||||||
|
this.onNoteOff(holding.voice);
|
||||||
|
this.ringing.push(holding);
|
||||||
|
if (!this.watching) {
|
||||||
|
ui().app.ticker.add(this.watch);
|
||||||
|
this.watching = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
this.invalidate();
|
this.invalidate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,6 +50,8 @@ interface Source {
|
|||||||
fade: GainNode | null;
|
fade: GainNode | null;
|
||||||
/** Ends with the note (#331): a release fades it out, where everything else plays on. */
|
/** Ends with the note (#331): a release fades it out, where everything else plays on. */
|
||||||
gate: boolean;
|
gate: boolean;
|
||||||
|
/** Plays on past its loop to the end of the sound; null where a release leaves it as it is. */
|
||||||
|
leave: (() => void) | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -222,14 +224,16 @@ export class SoundLibrary implements AudioSink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The end of a note (#331): the ways that end with it fade out, the others play on, and the voice
|
* The end of a note (#331): the ways that end with it fade out, a looping one that does not leaves
|
||||||
* goes once they have all ended – or rings on first where it has echoes.
|
* its loop, the others play on, and the voice goes once they have all ended – or rings on first
|
||||||
|
* where it has echoes.
|
||||||
*/
|
*/
|
||||||
release(id: number): void {
|
release(id: number): void {
|
||||||
const waiting = this.pending.find((other) => other.id === id);
|
const waiting = this.pending.find((other) => other.id === id);
|
||||||
if (waiting) waiting.released = true;
|
if (waiting) waiting.released = true;
|
||||||
const voice = this.voices.find((other) => other.id === id);
|
const voice = this.voices.find((other) => other.id === id);
|
||||||
if (!voice) return;
|
if (!voice) return;
|
||||||
|
for (const source of voice.sources) if (!source.gate) source.leave?.();
|
||||||
const context = this.audio();
|
const context = this.audio();
|
||||||
const gated = voice.sources.filter((source) => source.gate);
|
const gated = voice.sources.filter((source) => source.gate);
|
||||||
for (const source of gated) if (source.fade) fadeOut(source.fade.gain, context.currentTime);
|
for (const source of gated) if (source.fade) fadeOut(source.fade.gain, context.currentTime);
|
||||||
@@ -305,6 +309,11 @@ export class SoundLibrary implements AudioSink {
|
|||||||
return voice ? this.tailOf(voice.fx) : 0;
|
return voice ? this.tailOf(voice.fx) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether a voice is still heard – or waiting on its bytes to be. */
|
||||||
|
playing(id: number): boolean {
|
||||||
|
return this.voices.some((voice) => voice.id === id) || this.pending.some((other) => other.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
/** `letRing` stops the sound itself and lets its echoes fade as they would (#324). */
|
/** `letRing` stops the sound itself and lets its echoes fade as they would (#324). */
|
||||||
stop(file: string, letRing = false): void {
|
stop(file: string, letRing = false): void {
|
||||||
this.silence(file === '' ? this.voices : this.voices.filter((voice) => voice.file === file), letRing);
|
this.silence(file === '' ? this.voices : this.voices.filter((voice) => voice.file === file), letRing);
|
||||||
@@ -673,6 +682,7 @@ export class SoundLibrary implements AudioSink {
|
|||||||
},
|
},
|
||||||
fade: null,
|
fade: null,
|
||||||
gate,
|
gate,
|
||||||
|
leave: own && loop ? () => node.port.postMessage('leave') : null,
|
||||||
});
|
});
|
||||||
wiring.knobs.push({ param: node.parameters.get('pitch')!, target: pitch, even: 'value' });
|
wiring.knobs.push({ param: node.parameters.get('pitch')!, target: pitch, even: 'value' });
|
||||||
return node;
|
return node;
|
||||||
@@ -692,6 +702,13 @@ export class SoundLibrary implements AudioSink {
|
|||||||
},
|
},
|
||||||
fade: null,
|
fade: null,
|
||||||
gate,
|
gate,
|
||||||
|
// Web Audio plays a buffer that stops looping on to its end, past `loopEnd`.
|
||||||
|
leave:
|
||||||
|
own && loop
|
||||||
|
? () => {
|
||||||
|
source.loop = false;
|
||||||
|
}
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
if (shifted) wiring.knobs.push({ param: null, target: pitch, even: 'value' });
|
if (shifted) wiring.knobs.push({ param: null, target: pitch, even: 'value' });
|
||||||
return source;
|
return source;
|
||||||
|
|||||||
@@ -40,8 +40,9 @@ class PitchProcessor extends AudioWorkletProcessor {
|
|||||||
if (channels) sounds.set(file, channels);
|
if (channels) sounds.set(file, channels);
|
||||||
const region = loop && loopEnd !== undefined ? { start: loopStart ?? 0, end: loopEnd } : loop;
|
const region = loop && loopEnd !== undefined ? { start: loopStart ?? 0, end: loopEnd } : loop;
|
||||||
this.player = new PitchPlayer(sounds.get(file) ?? [], step, sampleRate, region);
|
this.player = new PitchPlayer(sounds.get(file) ?? [], step, sampleRate, region);
|
||||||
this.port.onmessage = () => {
|
this.port.onmessage = (event: MessageEvent) => {
|
||||||
this.stopped = true;
|
if (event.data === 'leave') this.player.leaveLoop();
|
||||||
|
else this.stopped = true;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,41 @@ describe('compiling a note', () => {
|
|||||||
expect(compile(f, 71).layers).toHaveLength(1);
|
expect(compile(f, 71).layers).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['gate', { loop: false, gate: true }],
|
||||||
|
['oneshot', { loop: false, gate: false }],
|
||||||
|
['loop', { loop: true, gate: true, loopStart: 0.25, loopEnd: 1 }],
|
||||||
|
// Looping, not ending with the note: the player leaves the loop on a release.
|
||||||
|
['release', { loop: true, gate: false, loopStart: 0.25, loopEnd: 1 }],
|
||||||
|
])('plays a Sample set to %s as such', (play, source) => {
|
||||||
|
const f = makeInstrument();
|
||||||
|
Object.assign(nodeOf(f, 'InstrumentSample').params, {
|
||||||
|
sound: f.sound('Pad', KICK).id,
|
||||||
|
play,
|
||||||
|
loopStart: 0.25,
|
||||||
|
loopEnd: 1,
|
||||||
|
});
|
||||||
|
expect(way(compile(f, 60).layers[0])?.source).toMatchObject(source);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ mode: 'gate' }, 'gate'],
|
||||||
|
[{ mode: 'oneshot' }, 'oneshot'],
|
||||||
|
[{ mode: 'gate', loop: true }, 'loop'],
|
||||||
|
// It ran until it was stopped; that is gone, and Loop is the nearest.
|
||||||
|
[{ mode: 'oneshot', loop: true }, 'loop'],
|
||||||
|
])('loads a Sample saved with %o before "Play" as %s', (old, play) => {
|
||||||
|
const f = makeInstrument();
|
||||||
|
const sample = nodeOf(f, 'InstrumentSample');
|
||||||
|
delete sample.params.play;
|
||||||
|
Object.assign(sample.params, old);
|
||||||
|
const back = roundTrip(f.project).assets.find((a) => a.id === f.instrument.id) as InstrumentAsset;
|
||||||
|
const params = back.graph.nodes.find((n) => n.type === 'InstrumentSample')!.params;
|
||||||
|
expect(params.play).toBe(play);
|
||||||
|
expect(params).not.toHaveProperty('mode');
|
||||||
|
expect(params).not.toHaveProperty('loop');
|
||||||
|
});
|
||||||
|
|
||||||
it('plays a Sample that does not follow the note the same for every note', () => {
|
it('plays a Sample that does not follow the note the same for every note', () => {
|
||||||
const f = makeInstrument();
|
const f = makeInstrument();
|
||||||
Object.assign(nodeOf(f, 'InstrumentSample').params, {
|
Object.assign(nodeOf(f, 'InstrumentSample').params, {
|
||||||
@@ -157,7 +192,7 @@ describe('compiling a note', () => {
|
|||||||
name36: 'Kick',
|
name36: 'Kick',
|
||||||
});
|
});
|
||||||
const kick = f.edit.addNode('InstrumentSample', { sound: f.sound('Kick', KICK).id, followNote: false });
|
const kick = f.edit.addNode('InstrumentSample', { sound: f.sound('Kick', KICK).id, followNote: false });
|
||||||
const snare = f.edit.addNode('InstrumentSample', { sound: f.sound('Snare', SNARE).id, mode: 'oneshot' });
|
const snare = f.edit.addNode('InstrumentSample', { sound: f.sound('Snare', SNARE).id, play: 'oneshot' });
|
||||||
const output = f.edit.addNode('InstrumentOutput');
|
const output = f.edit.addNode('InstrumentOutput');
|
||||||
f.edit.connect(notes, 'n36', kick, 'trigger');
|
f.edit.connect(notes, 'n36', kick, 'trigger');
|
||||||
f.edit.connect(notes, 'n38', snare, 'trigger');
|
f.edit.connect(notes, 'n38', snare, 'trigger');
|
||||||
|
|||||||
@@ -155,6 +155,38 @@ describe('a loop between two points (#331)', () => {
|
|||||||
expect(out[40000]).toBeCloseTo(16000 / 48000, 3);
|
expect(out[40000]).toBeCloseTo(16000 / 48000, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Plays `before` blocks of a loop, leaves it and plays on to the end; says what came out. */
|
||||||
|
function leaving(sound: Float32Array, loop: { start: number; end: number }, before: number): number[] {
|
||||||
|
const player = new PitchPlayer([sound], 1, RATE, loop);
|
||||||
|
const out: number[] = [];
|
||||||
|
const block = new Float32Array(BLOCK);
|
||||||
|
for (let b = 0; b < 2000; b++) {
|
||||||
|
if (b === before) player.leaveLoop();
|
||||||
|
const going = player.render([block], [0]);
|
||||||
|
out.push(...block);
|
||||||
|
if (!going) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('leaves the loop at the end of the round it is in and plays what comes after it', () => {
|
||||||
|
const ramp = Float32Array.from({ length: 48000 }, (_, i) => i / 48000);
|
||||||
|
// Let go at 38400, in the second round of the loop: it runs to 48000, then 24000 on comes.
|
||||||
|
const out = leaving(ramp, { start: 12000, end: 24000 }, 300);
|
||||||
|
expect(out[44000]).toBeCloseTo(20000 / 48000, 3);
|
||||||
|
expect(out[47999]).toBeCloseTo(23999 / 48000, 3);
|
||||||
|
expect(out[50000]).toBeCloseTo(26000 / 48000, 3);
|
||||||
|
expect(out[71999]).toBeCloseTo(47999 / 48000, 3);
|
||||||
|
expect(heard(Float32Array.from(out))).toBe(72000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('plays straight through when the loop is left before it was ever reached', () => {
|
||||||
|
const ramp = Float32Array.from({ length: 48000 }, (_, i) => (i + 1) / 48000);
|
||||||
|
const out = leaving(ramp, { start: 12000, end: 24000 }, 50);
|
||||||
|
expect(out[30000]).toBeCloseTo(30001 / 48000, 3);
|
||||||
|
expect(heard(Float32Array.from(out))).toBe(48000);
|
||||||
|
});
|
||||||
|
|
||||||
it('takes a loop that does not fit the sound as the whole sound', () => {
|
it('takes a loop that does not fit the sound as the whole sound', () => {
|
||||||
const ramp = Float32Array.from({ length: 4800 }, (_, i) => i / 4800);
|
const ramp = Float32Array.from({ length: 4800 }, (_, i) => i / 4800);
|
||||||
const out = render(ramp, 0, { loop: { start: 3000, end: 9000 }, blocks: 60 });
|
const out = render(ramp, 0, { loop: { start: 3000, end: 9000 }, blocks: 60 });
|
||||||
|
|||||||
@@ -990,6 +990,32 @@ describe('notes of an instrument (#331)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('a release lets a loop that does not end with the note leave its loop and play out', async () => {
|
||||||
|
const library = new SoundLibrary();
|
||||||
|
const voice = await note(library, [
|
||||||
|
sample(FILE, { loop: true, loopEnd: 1, gate: false }),
|
||||||
|
sample(OTHER, { loop: true, loopEnd: 1 }),
|
||||||
|
]);
|
||||||
|
library.release(voice);
|
||||||
|
// The first leaves its loop and is not faded; the second, a plain Loop, fades out as before.
|
||||||
|
expect(sources.map((source) => source.loop)).toEqual([false, true]);
|
||||||
|
expect(gains[1]!.ramps).toEqual([]);
|
||||||
|
expect(gains[2]!.ramps).toEqual([['linear', 0, 0.01]]);
|
||||||
|
clockTo();
|
||||||
|
expect(sources.map((source) => source.stopped)).toEqual([false, true]);
|
||||||
|
expect(library.playing(voice)).toBe(true);
|
||||||
|
sources[0]!.onended?.();
|
||||||
|
expect(library.playing(voice)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells the pitch worklet to leave its loop on a release', async () => {
|
||||||
|
const library = new SoundLibrary();
|
||||||
|
const pitched = { ...sample(FILE, { loop: true, loopEnd: 1, gate: false }), pitch: [{ value: 3 }] };
|
||||||
|
const voice = await note(library, [pitched]);
|
||||||
|
library.release(voice);
|
||||||
|
expect(worklets[0]!.posted).toEqual(['leave']);
|
||||||
|
});
|
||||||
|
|
||||||
it('plays nothing where no way has a file', () => {
|
it('plays nothing where no way has a file', () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary();
|
||||||
expect(library.playNote('instrument:i', [], {}, 1, '', 0)).toBe(0);
|
expect(library.playNote('instrument:i', [], {}, 1, '', 0)).toBe(0);
|
||||||
|
|||||||
+35
-1
@@ -787,7 +787,7 @@ export async function sound(t) {
|
|||||||
const ins = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)});
|
const ins = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)});
|
||||||
Object.assign(ins.graph.nodes.find((n) => n.type === 'InstrumentSample').params, {
|
Object.assign(ins.graph.nodes.find((n) => n.type === 'InstrumentSample').params, {
|
||||||
sound: ${JSON.stringify(soundId)},
|
sound: ${JSON.stringify(soundId)},
|
||||||
loop: true,
|
play: 'loop',
|
||||||
});`);
|
});`);
|
||||||
const insReady = await json('{ info: __gm.instrument.info(), keys: __gm.instrument.keys() }');
|
const insReady = await json('{ info: __gm.instrument.info(), keys: __gm.instrument.keys() }');
|
||||||
check(
|
check(
|
||||||
@@ -824,6 +824,40 @@ export async function sound(t) {
|
|||||||
JSON.stringify(letGo),
|
JSON.stringify(letGo),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// "Loop, then release": held, it loops 0.05 to 0.1 s; let go, it plays the 0.15 s after the loop.
|
||||||
|
const setPlay = (fields) =>
|
||||||
|
mutate(`
|
||||||
|
const ins = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)});
|
||||||
|
Object.assign(ins.graph.nodes.find((n) => n.type === 'InstrumentSample').params, ${JSON.stringify(fields)});`);
|
||||||
|
await setPlay({ play: 'release', loopStart: 0.05, loopEnd: 0.1 });
|
||||||
|
const beforeRelease = await stats();
|
||||||
|
await t.mouse('mousePressed', e4.x, e4.y);
|
||||||
|
await waitFor(stats, (s) => s.started > beforeRelease.started);
|
||||||
|
await sleep(600);
|
||||||
|
const heldLoop = await stats();
|
||||||
|
await t.mouse('mouseReleased', e4.x, e4.y);
|
||||||
|
const leftAt = await audioNow();
|
||||||
|
const ringingKeys = await json('__gm.instrument.keys()');
|
||||||
|
const playedOut = await waitFor(audioNow, (s) => s.playing === 0);
|
||||||
|
const afterKeys = await waitFor(
|
||||||
|
() => json('__gm.instrument.keys()'),
|
||||||
|
(keys) => keys.ringing.length === 0,
|
||||||
|
);
|
||||||
|
// 0.15 s after the loop, up to two rounds more (the one it is in, the one the worklet read into)
|
||||||
|
// and the 150 ms waitFor polls at: a fade would be gone in 0.01 s.
|
||||||
|
const outFor = playedOut.clock - leftAt.clock;
|
||||||
|
check(
|
||||||
|
'"Loop, then release" loops while held, and let go plays on past the loop with its key half lit',
|
||||||
|
heldLoop.playing === 1 &&
|
||||||
|
ringingKeys.held.length === 0 &&
|
||||||
|
ringingKeys.ringing.join() === '64' &&
|
||||||
|
outFor > 0.12 &&
|
||||||
|
outFor < 0.5 &&
|
||||||
|
afterKeys.ringing.length === 0,
|
||||||
|
JSON.stringify({ heldLoop: heldLoop.playing, ringingKeys, outFor, afterKeys }),
|
||||||
|
);
|
||||||
|
await setPlay({ play: 'loop', loopStart: 0, loopEnd: 0 });
|
||||||
|
|
||||||
// The click left the keys focused. Z is C3 there – no sound in this instrument –, one octave up C4.
|
// The click left the keys focused. Z is C3 there – no sound in this instrument –, one octave up C4.
|
||||||
const beforeZ = await stats();
|
const beforeZ = await stats();
|
||||||
await press('z');
|
await press('z');
|
||||||
|
|||||||
Reference in new issue
Block a user