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
@@ -123,10 +123,14 @@ export function graphHooks({ state, editor }: HookContext) {
|
||||
portAt: (nodeId: string, port: string, isOutput: boolean) =>
|
||||
editor.instrumentEditor.canvas.portGlobal(nodeId, port, isOutput),
|
||||
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: () => ({
|
||||
sounding: editor.instrumentEditor.soundingNotes(),
|
||||
held: editor.instrumentEditor.keyboard.heldNotes,
|
||||
ringing: editor.instrumentEditor.keyboard.ringingNotes,
|
||||
octave: editor.instrumentEditor.keyboard.bottomOctave,
|
||||
}),
|
||||
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.keyboard.onNoteOn = (note) => this.playNote(note);
|
||||
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.mount(new GraphCanvas(state, 'instrument'), [
|
||||
...this.snap.widgets,
|
||||
@@ -107,6 +109,7 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
|
||||
this.keyboard.releaseAll();
|
||||
soundLibrary.stopOwner(PREVIEW_OWNER);
|
||||
this.voices = [];
|
||||
this.keyboard.syncRinging();
|
||||
}
|
||||
|
||||
/** The notes the keys show as making a sound (test hook). */
|
||||
|
||||
@@ -17,14 +17,17 @@ const WHITE = 0xe8e8e8;
|
||||
const BLACK = 0x22252b;
|
||||
const MUTED_WHITE = 0x6b6f78;
|
||||
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. */
|
||||
const KEY_LABEL = textStyle('small').clone();
|
||||
KEY_LABEL.fill = 0x30343c;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* focused, the computer keyboard plays it as a tracker does, and the arrows move its octave.
|
||||
* for greyed out, a pressed one lit and one let go but still heard half lit. A key sounds while the
|
||||
* 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 {
|
||||
private readonly keysLayer = new Graphics();
|
||||
@@ -33,11 +36,19 @@ export class PianoKeyboard extends Widget {
|
||||
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. */
|
||||
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 readonly onKeyUp = (event: KeyboardEvent) => this.up(event.code);
|
||||
/** Starts a note and gives back its voice, 0 for none. */
|
||||
onNoteOn: (note: number) => number = () => 0;
|
||||
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 = () => {};
|
||||
|
||||
constructor() {
|
||||
@@ -71,6 +82,11 @@ export class PianoKeyboard extends Widget {
|
||||
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 {
|
||||
return this.octave;
|
||||
}
|
||||
@@ -88,6 +104,17 @@ export class PianoKeyboard extends Widget {
|
||||
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 {
|
||||
super.onFocus();
|
||||
window.addEventListener('keyup', this.onKeyUp);
|
||||
@@ -101,6 +128,12 @@ export class PianoKeyboard extends Widget {
|
||||
|
||||
override onKeyDown(event: KeyboardEvent): boolean {
|
||||
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);
|
||||
if (step !== 0) {
|
||||
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 {
|
||||
window.removeEventListener('keyup', this.onKeyUp);
|
||||
if (this.watching) ui().app.ticker.remove(this.watch);
|
||||
super.destroy(options);
|
||||
}
|
||||
|
||||
@@ -128,6 +162,7 @@ export class PianoKeyboard extends Widget {
|
||||
const g = this.keysLayer;
|
||||
g.clear();
|
||||
const lit = new Set(this.heldNotes);
|
||||
const ringing = new Set(this.ringingNotes);
|
||||
const c = Theme.colors;
|
||||
for (const key of this.keys) {
|
||||
const sounds = this.sounding.has(key.note);
|
||||
@@ -141,6 +176,9 @@ export class PianoKeyboard extends Widget {
|
||||
? WHITE
|
||||
: MUTED_WHITE;
|
||||
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.
|
||||
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);
|
||||
if (!holding) return;
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user