Load piano Enter-demo from external recording JSON

This commit is contained in:
Jage9
2026-02-23 01:35:46 -05:00
parent 01ec672693
commit 0f616a3fe8
3 changed files with 100 additions and 70 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
// Maintainer-controlled web client version. // Maintainer-controlled web client version.
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
window.CHGRID_WEB_VERSION = "2026.02.23 R215"; window.CHGRID_WEB_VERSION = "2026.02.23 R216";
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
window.CHGRID_TIME_ZONE = "America/Detroit"; window.CHGRID_TIME_ZONE = "America/Detroit";

View File

@@ -112,49 +112,19 @@ const PIANO_SHARP_KEY_MIDI_BY_CODE: Record<string, number> = {
KeyP: 75, KeyP: 75,
BracketRight: 78, BracketRight: 78,
}; };
const PIANO_DEMO_STEPS_F_MAJOR: Array<{ midi: number; durationMs: number; gapMs: number }> = [ type PianoDemoEvent = {
// "Yama no Ongakuka" / "Das Lied vom Musikanten" core melody in F major. t: number;
{ midi: 65, durationMs: 240, gapMs: 40 }, // F4 keyId: string;
{ midi: 69, durationMs: 240, gapMs: 40 }, // A4 midi: number;
{ midi: 72, durationMs: 240, gapMs: 60 }, // C5 on: boolean;
{ midi: 72, durationMs: 240, gapMs: 40 }, // C5 instrument?: string;
{ midi: 70, durationMs: 240, gapMs: 40 }, // Bb4 voiceMode?: 'mono' | 'poly';
{ midi: 69, durationMs: 240, gapMs: 60 }, // A4 attack?: number;
{ midi: 67, durationMs: 240, gapMs: 40 }, // G4 decay?: number;
{ midi: 69, durationMs: 240, gapMs: 40 }, // A4 release?: number;
{ midi: 65, durationMs: 240, gapMs: 60 }, // F4 brightness?: number;
{ midi: 65, durationMs: 480, gapMs: 120 }, // F4 (held) emitRange?: number;
{ midi: 69, durationMs: 240, gapMs: 40 }, // A4 };
{ midi: 70, durationMs: 240, gapMs: 40 }, // Bb4
{ midi: 72, durationMs: 240, gapMs: 60 }, // C5
{ midi: 72, durationMs: 240, gapMs: 40 }, // C5
{ midi: 74, durationMs: 240, gapMs: 40 }, // D5
{ midi: 72, durationMs: 240, gapMs: 60 }, // C5
{ midi: 70, durationMs: 240, gapMs: 40 }, // Bb4
{ midi: 69, durationMs: 240, gapMs: 40 }, // A4
{ midi: 67, durationMs: 240, gapMs: 60 }, // G4
{ midi: 65, durationMs: 480, gapMs: 120 }, // F4 (held)
{ midi: 72, durationMs: 240, gapMs: 40 }, // C5
{ midi: 72, durationMs: 240, gapMs: 40 }, // C5
{ midi: 74, durationMs: 240, gapMs: 60 }, // D5
{ midi: 65, durationMs: 240, gapMs: 40 }, // F4
{ midi: 65, durationMs: 240, gapMs: 40 }, // F4
{ midi: 65, durationMs: 240, gapMs: 60 }, // F4
{ midi: 67, durationMs: 240, gapMs: 40 }, // G4
{ midi: 65, durationMs: 240, gapMs: 40 }, // F4
{ midi: 64, durationMs: 240, gapMs: 60 }, // E4
{ midi: 65, durationMs: 480, gapMs: 120 }, // F4 (held)
{ midi: 69, durationMs: 240, gapMs: 40 }, // A4
{ midi: 70, durationMs: 240, gapMs: 40 }, // Bb4
{ midi: 72, durationMs: 240, gapMs: 60 }, // C5
{ midi: 72, durationMs: 240, gapMs: 40 }, // C5
{ midi: 74, durationMs: 240, gapMs: 40 }, // D5
{ midi: 72, durationMs: 240, gapMs: 60 }, // C5
{ midi: 70, durationMs: 240, gapMs: 40 }, // Bb4
{ midi: 69, durationMs: 240, gapMs: 40 }, // A4
{ midi: 67, durationMs: 240, gapMs: 60 }, // G4
{ midi: 65, durationMs: 520, gapMs: 140 }, // F4 (held, phrase ending)
];
declare global { declare global {
interface Window { interface Window {
@@ -320,7 +290,8 @@ let activePianoMonophonicKey: string | null = null;
let activePianoDemoRunToken = 0; let activePianoDemoRunToken = 0;
let activePianoDemoItemId: string | null = null; let activePianoDemoItemId: string | null = null;
const activePianoDemoTimeoutIds: number[] = []; const activePianoDemoTimeoutIds: number[] = [];
const activePianoDemoNotes = new Map<string, number>(); const activePianoDemoNotes = new Map<string, { runtimeKey: string; midi: number }>();
let pianoDemoEvents: PianoDemoEvent[] = [];
const activeRemotePianoKeys = new Set<string>(); const activeRemotePianoKeys = new Set<string>();
let pianoPreviewTimeoutId: number | null = null; let pianoPreviewTimeoutId: number | null = null;
let activeTeleport: let activeTeleport:
@@ -375,6 +346,7 @@ loadMicInputGain();
loadMasterVolume(); loadMasterVolume();
void loadHelp(); void loadHelp();
void loadPianoHelp(); void loadPianoHelp();
void loadPianoDemo();
void loadChangelog(); void loadChangelog();
/** Fetches a required DOM element and casts it to the requested element type. */ /** Fetches a required DOM element and casts it to the requested element type. */
@@ -491,6 +463,45 @@ async function loadPianoHelp(): Promise<void> {
} }
} }
/** Loads piano demo note events from `piano_demo.json` for Enter-key demo playback. */
async function loadPianoDemo(): Promise<void> {
try {
const response = await fetch(withBase('piano_demo.json'), { cache: 'no-store' });
if (!response.ok) {
return;
}
const data = (await response.json()) as { recording?: unknown };
const rawEvents = Array.isArray(data.recording) ? data.recording : [];
const parsed: PianoDemoEvent[] = [];
for (const entry of rawEvents) {
if (!entry || typeof entry !== 'object') continue;
const record = entry as Record<string, unknown>;
const t = Number(record.t);
const midi = Number(record.midi);
const keyId = String(record.keyId ?? '').trim();
const on = record.on === true;
if (!Number.isFinite(t) || !Number.isFinite(midi) || !keyId) continue;
parsed.push({
t: Math.max(0, Math.round(t)),
keyId: keyId.slice(0, 32),
midi: Math.max(0, Math.min(127, Math.round(midi))),
on,
instrument: typeof record.instrument === 'string' ? record.instrument : undefined,
voiceMode: record.voiceMode === 'mono' ? 'mono' : record.voiceMode === 'poly' ? 'poly' : undefined,
attack: Number.isFinite(Number(record.attack)) ? Math.max(0, Math.min(100, Math.round(Number(record.attack)))) : undefined,
decay: Number.isFinite(Number(record.decay)) ? Math.max(0, Math.min(100, Math.round(Number(record.decay)))) : undefined,
release: Number.isFinite(Number(record.release)) ? Math.max(0, Math.min(100, Math.round(Number(record.release)))) : undefined,
brightness: Number.isFinite(Number(record.brightness)) ? Math.max(0, Math.min(100, Math.round(Number(record.brightness)))) : undefined,
emitRange: Number.isFinite(Number(record.emitRange)) ? Math.max(5, Math.min(20, Math.round(Number(record.emitRange)))) : undefined,
});
}
parsed.sort((a, b) => a.t - b.t);
pianoDemoEvents = parsed;
} catch {
// Demo remains unavailable if loading/parsing fails.
}
}
/** Renders changelog sections into the collapsible updates panel. */ /** Renders changelog sections into the collapsible updates panel. */
function renderChangelog(changelog: ChangelogData): void { function renderChangelog(changelog: ChangelogData): void {
dom.updatesPanel.innerHTML = ''; dom.updatesPanel.innerHTML = '';
@@ -1035,13 +1046,13 @@ function stopPianoDemo(sendNoteOff = true): boolean {
} }
} }
const itemId = activePianoDemoItemId; const itemId = activePianoDemoItemId;
for (const [keyId, midi] of Array.from(activePianoDemoNotes.entries())) { for (const [logicalKey, note] of Array.from(activePianoDemoNotes.entries())) {
pianoSynth.noteOff(keyId); pianoSynth.noteOff(note.runtimeKey);
if (sendNoteOff && itemId && Number.isFinite(midi)) { if (sendNoteOff && itemId && Number.isFinite(note.midi)) {
signaling.send({ type: 'item_piano_note', itemId, keyId, midi, on: false }); signaling.send({ type: 'item_piano_note', itemId, keyId: note.runtimeKey, midi: note.midi, on: false });
} }
activePianoDemoNotes.delete(logicalKey);
} }
activePianoDemoNotes.clear();
activePianoDemoItemId = null; activePianoDemoItemId = null;
return hadActiveDemo; return hadActiveDemo;
} }
@@ -1049,31 +1060,46 @@ function stopPianoDemo(sendNoteOff = true): boolean {
/** Starts the built-in piano demo sequence from the beginning. */ /** Starts the built-in piano demo sequence from the beginning. */
function startPianoDemo(item: WorldItem, itemId: string): void { function startPianoDemo(item: WorldItem, itemId: string): void {
stopPianoDemo(true); stopPianoDemo(true);
if (pianoDemoEvents.length === 0) {
updateStatus('demo unavailable');
audio.sfxUiCancel();
return;
}
const runToken = activePianoDemoRunToken; const runToken = activePianoDemoRunToken;
activePianoDemoItemId = itemId; activePianoDemoItemId = itemId;
let atMs = 0; for (let index = 0; index < pianoDemoEvents.length; index += 1) {
for (let index = 0; index < PIANO_DEMO_STEPS_F_MAJOR.length; index += 1) { const event = pianoDemoEvents[index]!;
const step = PIANO_DEMO_STEPS_F_MAJOR[index]!; const timeoutId = window.setTimeout(() => {
const startTimeoutId = window.setTimeout(() => {
if (runToken !== activePianoDemoRunToken) return; if (runToken !== activePianoDemoRunToken) return;
const liveItem = state.items.get(itemId); const liveItem = state.items.get(itemId);
if (!liveItem || liveItem.type !== 'piano') return; if (!liveItem || liveItem.type !== 'piano') return;
const liveConfig = getPianoParams(liveItem); const baseConfig = getPianoParams(liveItem);
const midi = Math.max(0, Math.min(127, step.midi + liveConfig.octave * 12)); const config = {
const keyId = `__piano_demo_${runToken}_${index}`; instrument: event.instrument ? normalizePianoInstrument(event.instrument) : baseConfig.instrument,
activePianoDemoNotes.set(keyId, midi); voiceMode: event.voiceMode ?? baseConfig.voiceMode,
playLocalPianoNote(liveItem, itemId, keyId, midi, liveConfig); octave: 0,
const stopTimeoutId = window.setTimeout(() => { attack: event.attack ?? baseConfig.attack,
if (runToken !== activePianoDemoRunToken) return; decay: event.decay ?? baseConfig.decay,
if (!activePianoDemoNotes.has(keyId)) return; release: event.release ?? baseConfig.release,
activePianoDemoNotes.delete(keyId); brightness: event.brightness ?? baseConfig.brightness,
pianoSynth.noteOff(keyId); emitRange: event.emitRange ?? baseConfig.emitRange,
signaling.send({ type: 'item_piano_note', itemId, keyId, midi, on: false }); } as ReturnType<typeof getPianoParams>;
}, step.durationMs); const logicalKey = event.keyId;
activePianoDemoTimeoutIds.push(stopTimeoutId); const runtimeKey = `__piano_demo_${logicalKey}`;
}, atMs); if (event.on) {
activePianoDemoTimeoutIds.push(startTimeoutId); if (activePianoDemoNotes.has(logicalKey)) return;
atMs += step.durationMs + step.gapMs; activePianoDemoNotes.set(logicalKey, { runtimeKey, midi: event.midi });
playLocalPianoNote(liveItem, itemId, runtimeKey, event.midi, config);
} else {
const active = activePianoDemoNotes.get(logicalKey);
if (active) {
activePianoDemoNotes.delete(logicalKey);
pianoSynth.noteOff(active.runtimeKey);
signaling.send({ type: 'item_piano_note', itemId, keyId: active.runtimeKey, midi: active.midi, on: false });
}
}
}, event.t);
activePianoDemoTimeoutIds.push(timeoutId);
} }
} }