From 4aa7f0bf4d003adfa810d0ec30e82a1ec5296e30 Mon Sep 17 00:00:00 2001 From: Jage9 Date: Mon, 23 Feb 2026 01:18:17 -0500 Subject: [PATCH] Add piano demo playback on Enter with stop on C --- client/public/help.json | 2 +- client/public/piano.json | 6 ++- client/public/version.js | 2 +- client/src/main.ts | 85 ++++++++++++++++++++++++++++++++++++++++ docs/controls.md | 3 +- 5 files changed, 94 insertions(+), 4 deletions(-) diff --git a/client/public/help.json b/client/public/help.json index 4b3f4c5..bdf9e9c 100644 --- a/client/public/help.json +++ b/client/public/help.json @@ -87,7 +87,7 @@ }, { "keys": "Piano mode", - "description": "When using a piano: press question mark for piano help. 1-9 (and 0 for the 10th slot) changes instrument, -/= changes octave, ASDFGHJKL;' plays C major notes, WETYUOP] plays sharps, Z starts/stops recording, X plays recording, C stops playback, Escape exits" + "description": "When using a piano: press question mark for piano help. 1-9 (and 0 for the 10th slot) changes instrument, -/= changes octave, ASDFGHJKL;' plays C major notes, WETYUOP] plays sharps, Z starts/stops recording, X plays recording, Enter plays demo, C stops demo/playback, Escape exits" } ] }, diff --git a/client/public/piano.json b/client/public/piano.json index b48cd38..3fea0c2 100644 --- a/client/public/piano.json +++ b/client/public/piano.json @@ -27,9 +27,13 @@ "keys": "X", "description": "Play the saved recording." }, + { + "keys": "Enter", + "description": "Play demo melody. Press Enter again to restart it." + }, { "keys": "C", - "description": "Stop recording playback." + "description": "Stop demo and recording playback." }, { "keys": "Escape", diff --git a/client/public/version.js b/client/public/version.js index c1ace1f..1a59211 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -1,5 +1,5 @@ // Maintainer-controlled web client version. // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) -window.CHGRID_WEB_VERSION = "2026.02.23 R211"; +window.CHGRID_WEB_VERSION = "2026.02.23 R212"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/main.ts b/client/src/main.ts index 708cbfc..6fc8fe7 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -112,6 +112,26 @@ const PIANO_SHARP_KEY_MIDI_BY_CODE: Record = { KeyP: 75, BracketRight: 78, }; +const PIANO_DEMO_STEPS_F_MAJOR: Array<{ midi: number; durationMs: number; gapMs: number }> = [ + { midi: 65, durationMs: 220, gapMs: 40 }, // F4 + { midi: 69, durationMs: 220, gapMs: 40 }, // A4 + { midi: 72, durationMs: 280, gapMs: 60 }, // C5 + { midi: 74, durationMs: 220, gapMs: 40 }, // D5 + { midi: 72, durationMs: 220, gapMs: 40 }, // C5 + { midi: 69, durationMs: 260, gapMs: 60 }, // A4 + { midi: 70, durationMs: 220, gapMs: 40 }, // Bb4 + { midi: 69, durationMs: 220, gapMs: 40 }, // A4 + { midi: 67, durationMs: 260, gapMs: 60 }, // G4 + { midi: 65, durationMs: 360, gapMs: 90 }, // F4 + { midi: 67, durationMs: 220, gapMs: 40 }, // G4 + { midi: 69, durationMs: 220, gapMs: 40 }, // A4 + { midi: 70, durationMs: 220, gapMs: 40 }, // Bb4 + { midi: 72, durationMs: 280, gapMs: 60 }, // C5 + { midi: 70, durationMs: 220, gapMs: 40 }, // Bb4 + { midi: 69, durationMs: 220, gapMs: 40 }, // A4 + { midi: 67, durationMs: 260, gapMs: 60 }, // G4 + { midi: 65, durationMs: 420, gapMs: 120 }, // F4 +]; declare global { interface Window { @@ -274,6 +294,10 @@ const activePianoKeys = new Set(); const activePianoKeyMidi = new Map(); const activePianoHeldOrder: string[] = []; let activePianoMonophonicKey: string | null = null; +let activePianoDemoRunToken = 0; +let activePianoDemoItemId: string | null = null; +const activePianoDemoTimeoutIds: number[] = []; +const activePianoDemoNotes = new Map(); const activeRemotePianoKeys = new Set(); let pianoPreviewTimeoutId: number | null = null; let activeTeleport: @@ -928,6 +952,7 @@ async function startPianoUseMode(itemId: string): Promise { /** Exits local piano key mode and releases any held notes. */ function stopPianoUseMode(announce = true): void { if (!activePianoItemId) return; + stopPianoDemo(true); const itemId = activePianoItemId; for (const code of Array.from(activePianoKeys)) { const midi = activePianoKeyMidi.get(code); @@ -976,6 +1001,59 @@ function playLocalPianoNote( signaling.send({ type: 'item_piano_note', itemId, keyId, midi, on: true }); } +/** Stops active piano demo notes/timeouts and optionally emits note-off packets. */ +function stopPianoDemo(sendNoteOff = true): boolean { + const hadActiveDemo = activePianoDemoNotes.size > 0 || activePianoDemoTimeoutIds.length > 0; + activePianoDemoRunToken += 1; + while (activePianoDemoTimeoutIds.length > 0) { + const timeoutId = activePianoDemoTimeoutIds.pop(); + if (typeof timeoutId === 'number') { + window.clearTimeout(timeoutId); + } + } + const itemId = activePianoDemoItemId; + for (const [keyId, midi] of Array.from(activePianoDemoNotes.entries())) { + pianoSynth.noteOff(keyId); + if (sendNoteOff && itemId && Number.isFinite(midi)) { + signaling.send({ type: 'item_piano_note', itemId, keyId, midi, on: false }); + } + } + activePianoDemoNotes.clear(); + activePianoDemoItemId = null; + return hadActiveDemo; +} + +/** Starts the built-in piano demo sequence from the beginning. */ +function startPianoDemo(item: WorldItem, itemId: string): void { + stopPianoDemo(true); + const runToken = activePianoDemoRunToken; + activePianoDemoItemId = itemId; + let atMs = 0; + for (let index = 0; index < PIANO_DEMO_STEPS_F_MAJOR.length; index += 1) { + const step = PIANO_DEMO_STEPS_F_MAJOR[index]!; + const startTimeoutId = window.setTimeout(() => { + if (runToken !== activePianoDemoRunToken) return; + const liveItem = state.items.get(itemId); + if (!liveItem || liveItem.type !== 'piano') return; + const liveConfig = getPianoParams(liveItem); + const midi = Math.max(0, Math.min(127, step.midi + liveConfig.octave * 12)); + const keyId = `__piano_demo_${runToken}_${index}`; + activePianoDemoNotes.set(keyId, midi); + playLocalPianoNote(liveItem, itemId, keyId, midi, liveConfig); + const stopTimeoutId = window.setTimeout(() => { + if (runToken !== activePianoDemoRunToken) return; + if (!activePianoDemoNotes.has(keyId)) return; + activePianoDemoNotes.delete(keyId); + pianoSynth.noteOff(keyId); + signaling.send({ type: 'item_piano_note', itemId, keyId, midi, on: false }); + }, step.durationMs); + activePianoDemoTimeoutIds.push(stopTimeoutId); + }, atMs); + activePianoDemoTimeoutIds.push(startTimeoutId); + atMs += step.durationMs + step.gapMs; + } +} + /** Handles key release while in piano mode, including mono fallback retrigger behavior. */ function handlePianoUseModeKeyUp(code: string): void { if (!activePianoKeys.delete(code)) return; @@ -2456,6 +2534,12 @@ function handlePianoUseModeInput(code: string): void { stopPianoUseMode(false); return; } + if (code === 'Enter') { + startPianoDemo(item, itemId); + updateStatus('demo play'); + audio.sfxUiBlip(); + return; + } if (code === 'KeyZ') { signaling.send({ type: 'item_piano_recording', itemId, action: 'toggle_record' }); return; @@ -2465,6 +2549,7 @@ function handlePianoUseModeInput(code: string): void { return; } if (code === 'KeyC') { + stopPianoDemo(true); signaling.send({ type: 'item_piano_recording', itemId, action: 'stop_playback' }); return; } diff --git a/docs/controls.md b/docs/controls.md index e99122c..6b5aca4 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -84,7 +84,8 @@ Applies to effect select, user/item list modes, item selection, item property li - `-` / `=`: Shift octave down/up - `Z`: Start/stop recording on this piano (max 30s) - `X`: Play back saved recording on this piano -- `C`: Stop playback on this piano +- `Enter`: Play demo melody (press again to restart) +- `C`: Stop demo/playback on this piano - `Escape`: Exit piano mode ## Help Viewer Mode