From 6595c15fa4cc92b231328c52488c8d556b263e32 Mon Sep 17 00:00:00 2001 From: Jage9 Date: Mon, 23 Feb 2026 01:43:02 -0500 Subject: [PATCH] Migrate piano songs to songId registry with compact storage --- client/public/piano_demo.json | 485 ++++++++++++++++++++++++- client/public/version.js | 2 +- client/src/main.ts | 128 +++++-- docs/item-schema.md | 2 + server/app/item_service.py | 40 ++ server/app/items/piano.py | 5 + server/app/server.py | 168 +++++++-- server/tests/test_item_use_cooldown.py | 12 +- 8 files changed, 768 insertions(+), 74 deletions(-) diff --git a/client/public/piano_demo.json b/client/public/piano_demo.json index 53d7f1b..179fe5a 100644 --- a/client/public/piano_demo.json +++ b/client/public/piano_demo.json @@ -1,4 +1,485 @@ { - "recordingLengthMs": 17394, - "recording": [{"t":118,"keyId":"KeyL","midi":74,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":226,"keyId":"KeyL","midi":74,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":946,"keyId":"KeyL","midi":74,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":1096,"keyId":"KeyL","midi":74,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":1459,"keyId":"KeyK","midi":72,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":1573,"keyId":"KeyK","midi":72,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":2018,"keyId":"KeyG","midi":67,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":2125,"keyId":"KeyG","midi":67,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":2291,"keyId":"KeyL","midi":74,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":2419,"keyId":"KeyL","midi":74,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":2590,"keyId":"KeyL","midi":74,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":2716,"keyId":"KeyL","midi":74,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":2856,"keyId":"Semicolon","midi":76,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":2958,"keyId":"Semicolon","midi":76,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":3158,"keyId":"KeyK","midi":72,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":3259,"keyId":"KeyK","midi":72,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":3941,"keyId":"KeyG","midi":67,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":4068,"keyId":"KeyG","midi":67,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":4183,"keyId":"KeyG","midi":67,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":4302,"keyId":"KeyG","midi":67,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":4483,"keyId":"KeyL","midi":74,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":4597,"keyId":"KeyL","midi":74,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":4738,"keyId":"KeyL","midi":74,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":4900,"keyId":"KeyL","midi":74,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":5001,"keyId":"Semicolon","midi":76,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":5122,"keyId":"Semicolon","midi":76,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":5504,"keyId":"KeyK","midi":72,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":5600,"keyId":"KeyK","midi":72,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":6347,"keyId":"KeyH","midi":69,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":6473,"keyId":"KeyH","midi":69,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":6601,"keyId":"KeyJ","midi":71,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":6769,"keyId":"KeyJ","midi":71,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":6853,"keyId":"KeyK","midi":72,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":6990,"keyId":"KeyK","midi":72,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":7133,"keyId":"KeyJ","midi":71,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":7228,"keyId":"KeyJ","midi":71,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":7428,"keyId":"KeyH","midi":69,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":7550,"keyId":"KeyH","midi":69,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":8659,"keyId":"Semicolon","midi":76,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":8660,"keyId":"KeyK","midi":72,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":8768,"keyId":"KeyK","midi":72,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":8792,"keyId":"Semicolon","midi":76,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":9492,"keyId":"KeyL","midi":74,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":9492,"keyId":"KeyF","midi":65,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":9600,"keyId":"KeyL","midi":74,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":9600,"keyId":"KeyF","midi":65,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":10429,"keyId":"KeyH","midi":69,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":10537,"keyId":"KeyH","midi":69,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":10749,"keyId":"Semicolon","midi":76,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":10896,"keyId":"Semicolon","midi":76,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":11008,"keyId":"Quote","midi":77,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":11149,"keyId":"Quote","midi":77,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":11253,"keyId":"Semicolon","midi":76,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":11384,"keyId":"Semicolon","midi":76,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":11569,"keyId":"KeyL","midi":74,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":11677,"keyId":"KeyL","midi":74,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":12423,"keyId":"KeyK","midi":72,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":12557,"keyId":"KeyK","midi":72,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":12695,"keyId":"KeyJ","midi":71,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":12798,"keyId":"KeyJ","midi":71,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":12936,"keyId":"KeyJ","midi":71,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":13045,"keyId":"KeyJ","midi":71,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":13297,"keyId":"KeyK","midi":72,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":13440,"keyId":"KeyK","midi":72,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":13619,"keyId":"KeyJ","midi":71,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":13750,"keyId":"KeyJ","midi":71,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":14139,"keyId":"KeyK","midi":72,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":14272,"keyId":"KeyK","midi":72,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":14658,"keyId":"KeyJ","midi":71,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":14784,"keyId":"KeyJ","midi":71,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":15208,"keyId":"KeyG","midi":67,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":15317,"keyId":"KeyG","midi":67,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":15781,"keyId":"KeyJ","midi":71,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":15882,"keyId":"KeyJ","midi":71,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":16334,"keyId":"KeyG","midi":67,"on":true,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15},{"t":16428,"keyId":"KeyG","midi":67,"on":false,"instrument":"piano","voiceMode":"poly","attack":15,"decay":45,"release":35,"brightness":55,"emitRange":15}] + "defaultSongId": "unterlandersheimweh", + "songs": { + "unterlandersheimweh": { + "meta": { + "instrument": "piano", + "voiceMode": "poly", + "attack": 15, + "decay": 45, + "release": 35, + "brightness": 55, + "emitRange": 15, + "recordingLengthMs": 17394 + }, + "keys": [ + "KeyL", + "KeyK", + "KeyG", + "Semicolon", + "KeyH", + "KeyJ", + "KeyF", + "Quote" + ], + "events": [ + [ + 118, + 0, + 74, + 1 + ], + [ + 226, + 0, + 74, + 0 + ], + [ + 946, + 0, + 74, + 1 + ], + [ + 1096, + 0, + 74, + 0 + ], + [ + 1459, + 1, + 72, + 1 + ], + [ + 1573, + 1, + 72, + 0 + ], + [ + 2018, + 2, + 67, + 1 + ], + [ + 2125, + 2, + 67, + 0 + ], + [ + 2291, + 0, + 74, + 1 + ], + [ + 2419, + 0, + 74, + 0 + ], + [ + 2590, + 0, + 74, + 1 + ], + [ + 2716, + 0, + 74, + 0 + ], + [ + 2856, + 3, + 76, + 1 + ], + [ + 2958, + 3, + 76, + 0 + ], + [ + 3158, + 1, + 72, + 1 + ], + [ + 3259, + 1, + 72, + 0 + ], + [ + 3941, + 2, + 67, + 1 + ], + [ + 4068, + 2, + 67, + 0 + ], + [ + 4183, + 2, + 67, + 1 + ], + [ + 4302, + 2, + 67, + 0 + ], + [ + 4483, + 0, + 74, + 1 + ], + [ + 4597, + 0, + 74, + 0 + ], + [ + 4738, + 0, + 74, + 1 + ], + [ + 4900, + 0, + 74, + 0 + ], + [ + 5001, + 3, + 76, + 1 + ], + [ + 5122, + 3, + 76, + 0 + ], + [ + 5504, + 1, + 72, + 1 + ], + [ + 5600, + 1, + 72, + 0 + ], + [ + 6347, + 4, + 69, + 1 + ], + [ + 6473, + 4, + 69, + 0 + ], + [ + 6601, + 5, + 71, + 1 + ], + [ + 6769, + 5, + 71, + 0 + ], + [ + 6853, + 1, + 72, + 1 + ], + [ + 6990, + 1, + 72, + 0 + ], + [ + 7133, + 5, + 71, + 1 + ], + [ + 7228, + 5, + 71, + 0 + ], + [ + 7428, + 4, + 69, + 1 + ], + [ + 7550, + 4, + 69, + 0 + ], + [ + 8659, + 3, + 76, + 1 + ], + [ + 8660, + 1, + 72, + 1 + ], + [ + 8768, + 1, + 72, + 0 + ], + [ + 8792, + 3, + 76, + 0 + ], + [ + 9492, + 0, + 74, + 1 + ], + [ + 9492, + 6, + 65, + 1 + ], + [ + 9600, + 0, + 74, + 0 + ], + [ + 9600, + 6, + 65, + 0 + ], + [ + 10429, + 4, + 69, + 1 + ], + [ + 10537, + 4, + 69, + 0 + ], + [ + 10749, + 3, + 76, + 1 + ], + [ + 10896, + 3, + 76, + 0 + ], + [ + 11008, + 7, + 77, + 1 + ], + [ + 11149, + 7, + 77, + 0 + ], + [ + 11253, + 3, + 76, + 1 + ], + [ + 11384, + 3, + 76, + 0 + ], + [ + 11569, + 0, + 74, + 1 + ], + [ + 11677, + 0, + 74, + 0 + ], + [ + 12423, + 1, + 72, + 1 + ], + [ + 12557, + 1, + 72, + 0 + ], + [ + 12695, + 5, + 71, + 1 + ], + [ + 12798, + 5, + 71, + 0 + ], + [ + 12936, + 5, + 71, + 1 + ], + [ + 13045, + 5, + 71, + 0 + ], + [ + 13297, + 1, + 72, + 1 + ], + [ + 13440, + 1, + 72, + 0 + ], + [ + 13619, + 5, + 71, + 1 + ], + [ + 13750, + 5, + 71, + 0 + ], + [ + 14139, + 1, + 72, + 1 + ], + [ + 14272, + 1, + 72, + 0 + ], + [ + 14658, + 5, + 71, + 1 + ], + [ + 14784, + 5, + 71, + 0 + ], + [ + 15208, + 2, + 67, + 1 + ], + [ + 15317, + 2, + 67, + 0 + ], + [ + 15781, + 5, + 71, + 1 + ], + [ + 15882, + 5, + 71, + 0 + ], + [ + 16334, + 2, + 67, + 1 + ], + [ + 16428, + 2, + 67, + 0 + ] + ] + } + } } diff --git a/client/public/version.js b/client/public/version.js index d455d36..7d93f63 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 R216"; +window.CHGRID_WEB_VERSION = "2026.02.23 R217"; // 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 b02a81d..1368aa8 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -125,6 +125,10 @@ type PianoDemoEvent = { brightness?: number; emitRange?: number; }; +type PianoDemoSong = { + id: string; + events: PianoDemoEvent[]; +}; declare global { interface Window { @@ -291,7 +295,8 @@ let activePianoDemoRunToken = 0; let activePianoDemoItemId: string | null = null; const activePianoDemoTimeoutIds: number[] = []; const activePianoDemoNotes = new Map(); -let pianoDemoEvents: PianoDemoEvent[] = []; +const pianoDemoSongs = new Map(); +let pianoDemoDefaultSongId = ''; const activeRemotePianoKeys = new Set(); let pianoPreviewTimeoutId: number | null = null; let activeTeleport: @@ -470,33 +475,93 @@ async function loadPianoDemo(): Promise { 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; - 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, - }); + const data = (await response.json()) as { + defaultSongId?: unknown; + songs?: unknown; + recording?: unknown; + }; + pianoDemoSongs.clear(); + pianoDemoDefaultSongId = ''; + + const parseLegacyEvents = (rawEvents: unknown): PianoDemoEvent[] => { + const parsed: PianoDemoEvent[] = []; + if (!Array.isArray(rawEvents)) return parsed; + for (const entry of rawEvents) { + if (!entry || typeof entry !== 'object') continue; + const record = entry as Record; + 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); + return parsed; + }; + + if (data.songs && typeof data.songs === 'object') { + const songs = data.songs as Record; + for (const [songId, rawSong] of Object.entries(songs)) { + if (!rawSong || typeof rawSong !== 'object') continue; + const song = rawSong as Record; + const meta = song.meta as Record | undefined; + const keys = Array.isArray(song.keys) ? song.keys.filter((value): value is string => typeof value === 'string') : []; + const compactEvents = Array.isArray(song.events) ? song.events : []; + const events: PianoDemoEvent[] = []; + for (const compact of compactEvents) { + if (!Array.isArray(compact) || compact.length < 4) continue; + const [rawT, rawKeyIdx, rawMidi, rawOn] = compact; + if (typeof rawT !== 'number' || typeof rawKeyIdx !== 'number' || typeof rawMidi !== 'number') continue; + const keyId = keys[Math.max(0, Math.round(rawKeyIdx))]; + if (!keyId) continue; + events.push({ + t: Math.max(0, Math.round(rawT)), + keyId: keyId.slice(0, 32), + midi: Math.max(0, Math.min(127, Math.round(rawMidi))), + on: Boolean(rawOn), + instrument: typeof meta?.instrument === 'string' ? meta.instrument : undefined, + voiceMode: meta?.voiceMode === 'mono' ? 'mono' : meta?.voiceMode === 'poly' ? 'poly' : undefined, + attack: Number.isFinite(Number(meta?.attack)) ? Math.max(0, Math.min(100, Math.round(Number(meta?.attack)))) : undefined, + decay: Number.isFinite(Number(meta?.decay)) ? Math.max(0, Math.min(100, Math.round(Number(meta?.decay)))) : undefined, + release: Number.isFinite(Number(meta?.release)) ? Math.max(0, Math.min(100, Math.round(Number(meta?.release)))) : undefined, + brightness: Number.isFinite(Number(meta?.brightness)) ? Math.max(0, Math.min(100, Math.round(Number(meta?.brightness)))) : undefined, + emitRange: Number.isFinite(Number(meta?.emitRange)) ? Math.max(5, Math.min(20, Math.round(Number(meta?.emitRange)))) : undefined, + }); + } + events.sort((a, b) => a.t - b.t); + if (events.length > 0) { + pianoDemoSongs.set(songId, { id: songId, events }); + } + } + const preferredId = String(data.defaultSongId ?? '').trim(); + if (preferredId && pianoDemoSongs.has(preferredId)) { + pianoDemoDefaultSongId = preferredId; + } else { + pianoDemoDefaultSongId = pianoDemoSongs.keys().next().value ?? ''; + } + return; + } + + // Backward fallback for legacy flat recording JSON format. + const legacyEvents = parseLegacyEvents(data.recording); + if (legacyEvents.length > 0) { + const legacyId = 'unterlandersheimweh'; + pianoDemoSongs.set(legacyId, { id: legacyId, events: legacyEvents }); + pianoDemoDefaultSongId = legacyId; } - parsed.sort((a, b) => a.t - b.t); - pianoDemoEvents = parsed; } catch { // Demo remains unavailable if loading/parsing fails. } @@ -1060,15 +1125,18 @@ function stopPianoDemo(sendNoteOff = true): boolean { /** Starts the built-in piano demo sequence from the beginning. */ function startPianoDemo(item: WorldItem, itemId: string): void { stopPianoDemo(true); - if (pianoDemoEvents.length === 0) { + const requestedSongId = String(item.params.songId ?? '').trim(); + const songId = (requestedSongId && pianoDemoSongs.has(requestedSongId) ? requestedSongId : pianoDemoDefaultSongId) || ''; + const song = songId ? pianoDemoSongs.get(songId) ?? null : null; + if (!song || song.events.length === 0) { updateStatus('demo unavailable'); audio.sfxUiCancel(); return; } const runToken = activePianoDemoRunToken; activePianoDemoItemId = itemId; - for (let index = 0; index < pianoDemoEvents.length; index += 1) { - const event = pianoDemoEvents[index]!; + for (let index = 0; index < song.events.length; index += 1) { + const event = song.events[index]!; const timeoutId = window.setTimeout(() => { if (runToken !== activePianoDemoRunToken) return; const liveItem = state.items.get(itemId); diff --git a/docs/item-schema.md b/docs/item-schema.md index 85a4330..b9164a1 100644 --- a/docs/item-schema.md +++ b/docs/item-schema.md @@ -183,8 +183,10 @@ - `release`: integer, range `0-100`, default `35`. - `brightness`: integer, range `0-100`, default `55`. - `emitRange`: integer, range `5-20`, default `15`. +- `songId`: server-managed song reference used for piano demo/playback content. - `recording`: server-managed array of note events (`t`, `keyId`, `midi`, `on`) captured from piano mode recording. - `recordingLengthMs`: server-managed recording duration in milliseconds (`0..30000`). + - Legacy fallback only during migration; new recordings are stored in server song registry by `songId`. ## Packet Shapes diff --git a/server/app/item_service.py b/server/app/item_service.py index dcaf6f7..9ae7488 100644 --- a/server/app/item_service.py +++ b/server/app/item_service.py @@ -24,8 +24,11 @@ class ItemService: """Create service and eagerly load persisted state when configured.""" self.state_file = state_file + self.piano_songs_file = state_file.with_name("piano_songs.json") if state_file else None self.items: dict[str, WorldItem] = {} + self.piano_songs: dict[str, dict] = {} self.load_state() + self.load_piano_songs() @staticmethod def now_ms() -> int: @@ -129,6 +132,29 @@ class ItemService: except Exception as exc: LOGGER.warning("failed to load persisted item state from %s: %s", self.state_file, exc) + def load_piano_songs(self) -> None: + """Load persisted piano song registry used by piano items.""" + + if not self.piano_songs_file: + return + try: + if not self.piano_songs_file.exists(): + return + raw = json.loads(self.piano_songs_file.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + return + loaded: dict[str, dict] = {} + for song_id, payload in raw.items(): + if not isinstance(song_id, str) or not song_id.strip(): + continue + if not isinstance(payload, dict): + continue + loaded[song_id] = payload + self.piano_songs = loaded + LOGGER.info("loaded %d persisted piano songs from %s", len(self.piano_songs), self.piano_songs_file) + except Exception as exc: + LOGGER.warning("failed to load piano songs from %s: %s", self.piano_songs_file, exc) + def save_state(self) -> None: """Persist instance-only item data to configured state file.""" @@ -155,3 +181,17 @@ class ItemService: self.state_file.write_text(json.dumps(payload, ensure_ascii=True, separators=(",", ":")), encoding="utf-8") except Exception as exc: LOGGER.warning("failed to persist item state to %s: %s", self.state_file, exc) + + def save_piano_songs(self) -> None: + """Persist compact piano song registry payload to configured storage file.""" + + if not self.piano_songs_file: + return + try: + self.piano_songs_file.parent.mkdir(parents=True, exist_ok=True) + self.piano_songs_file.write_text( + json.dumps(self.piano_songs, ensure_ascii=True, separators=(",", ":")), + encoding="utf-8", + ) + except Exception as exc: + LOGGER.warning("failed to persist piano songs to %s: %s", self.piano_songs_file, exc) diff --git a/server/app/items/piano.py b/server/app/items/piano.py index 0c13e0c..72845a1 100644 --- a/server/app/items/piano.py +++ b/server/app/items/piano.py @@ -36,6 +36,7 @@ DEFAULT_PARAMS: dict = { "release": 35, "brightness": 55, "emitRange": 15, + "songId": "unterlandersheimweh", } INSTRUMENT_OPTIONS: tuple[str, ...] = ( @@ -108,8 +109,10 @@ def validate_update(_item: WorldItem, next_params: dict) -> dict: # Recording data is server-managed and not directly editable from client updates. preserved_recording = _item.params.get("recording") preserved_recording_length = _item.params.get("recordingLengthMs") + preserved_song_id = _item.params.get("songId") next_params.pop("recording", None) next_params.pop("recordingLengthMs", None) + next_params.pop("songId", None) instrument = str(next_params.get("instrument", "piano")).strip().lower() if instrument not in INSTRUMENT_OPTIONS: @@ -181,6 +184,8 @@ def validate_update(_item: WorldItem, next_params: dict) -> dict: next_params["recording"] = preserved_recording if isinstance(preserved_recording_length, (int, float)): next_params["recordingLengthMs"] = max(0, min(30_000, int(preserved_recording_length))) + if isinstance(preserved_song_id, str) and preserved_song_id.strip(): + next_params["songId"] = preserved_song_id.strip() return next_params diff --git a/server/app/server.py b/server/app/server.py index 2c27938..f1f6637 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -294,8 +294,44 @@ class SignalingServer: elapsed_ms = max(0, min(PIANO_RECORDING_MAX_MS, elapsed_ms)) recorded_events = session.get("events") events = list(recorded_events) if isinstance(recorded_events, list) else [] - item.params["recording"] = events - item.params["recordingLengthMs"] = elapsed_ms + song_id = f"item:{item.id}:recording" + keys: list[str] = [] + key_to_index: dict[str, int] = {} + compact_events: list[list[int]] = [] + for event in events: + if not isinstance(event, dict): + continue + t = int(event.get("t", 0)) if isinstance(event.get("t"), (int, float)) else 0 + key_id = str(event.get("keyId", "")).strip() + midi = int(event.get("midi", 0)) if isinstance(event.get("midi"), (int, float)) else 0 + on = 1 if event.get("on") is True else 0 + if not key_id: + continue + index = key_to_index.get(key_id) + if index is None: + index = len(keys) + keys.append(key_id) + key_to_index[key_id] = index + compact_events.append([max(0, min(PIANO_RECORDING_MAX_MS, t)), index, max(0, min(127, midi)), on]) + compact_events.sort(key=lambda row: row[0]) + self.item_service.piano_songs[song_id] = { + "meta": { + "instrument": str(item.params.get("instrument", "piano")).strip().lower(), + "voiceMode": str(item.params.get("voiceMode", "poly")).strip().lower(), + "attack": int(item.params.get("attack", 15)) if isinstance(item.params.get("attack"), (int, float)) else 15, + "decay": int(item.params.get("decay", 45)) if isinstance(item.params.get("decay"), (int, float)) else 45, + "release": int(item.params.get("release", 35)) if isinstance(item.params.get("release"), (int, float)) else 35, + "brightness": int(item.params.get("brightness", 55)) if isinstance(item.params.get("brightness"), (int, float)) else 55, + "emitRange": int(item.params.get("emitRange", 15)) if isinstance(item.params.get("emitRange"), (int, float)) else 15, + "recordingLengthMs": elapsed_ms, + }, + "keys": keys, + "events": compact_events, + } + self.item_service.save_piano_songs() + item.params["songId"] = song_id + item.params.pop("recording", None) + item.params.pop("recordingLengthMs", None) item.updatedAt = self.item_service.now_ms() item.version += 1 self.item_service.save_state() @@ -318,41 +354,92 @@ class SignalingServer: """Run one piano recording playback task and broadcast note events.""" sender_id = f"item:{item.id}:playback" - raw_events = item.params.get("recording") - if not isinstance(raw_events, list): - return events: list[dict[str, object]] = [] - for event in raw_events: - if not isinstance(event, dict): - continue - raw_time = event.get("t") - raw_key = event.get("keyId") - raw_midi = event.get("midi") - raw_on = event.get("on") - if not isinstance(raw_time, (int, float)) or not isinstance(raw_key, str) or not isinstance(raw_midi, (int, float)) or not isinstance(raw_on, bool): - continue - raw_instrument = event.get("instrument") - raw_voice_mode = event.get("voiceMode") - raw_attack = event.get("attack") - raw_decay = event.get("decay") - raw_release = event.get("release") - raw_brightness = event.get("brightness") - raw_emit_range = event.get("emitRange") - events.append( - { - "t": max(0, min(PIANO_RECORDING_MAX_MS, int(raw_time))), - "keyId": raw_key[:32] or "KeyA", - "midi": max(0, min(127, int(raw_midi))), - "on": raw_on, - "instrument": str(raw_instrument).strip().lower() if isinstance(raw_instrument, str) else None, - "voiceMode": str(raw_voice_mode).strip().lower() if isinstance(raw_voice_mode, str) else None, - "attack": max(0, min(100, int(raw_attack))) if isinstance(raw_attack, (int, float)) else None, - "decay": max(0, min(100, int(raw_decay))) if isinstance(raw_decay, (int, float)) else None, - "release": max(0, min(100, int(raw_release))) if isinstance(raw_release, (int, float)) else None, - "brightness": max(0, min(100, int(raw_brightness))) if isinstance(raw_brightness, (int, float)) else None, - "emitRange": max(5, min(20, int(raw_emit_range))) if isinstance(raw_emit_range, (int, float)) else None, - } - ) + song_id = str(item.params.get("songId", "")).strip() + song_payload = self.item_service.piano_songs.get(song_id) if song_id else None + if isinstance(song_payload, dict): + keys = song_payload.get("keys") + compact_events = song_payload.get("events") + meta = song_payload.get("meta") + if isinstance(keys, list) and isinstance(compact_events, list): + instrument = None + voice_mode = None + attack = None + decay = None + release = None + brightness = None + emit_range = None + if isinstance(meta, dict): + instrument = str(meta.get("instrument", "")).strip().lower() or None + raw_voice_mode = str(meta.get("voiceMode", "")).strip().lower() + voice_mode = raw_voice_mode if raw_voice_mode in {"mono", "poly"} else None + attack = int(meta.get("attack", 15)) if isinstance(meta.get("attack"), (int, float)) else None + decay = int(meta.get("decay", 45)) if isinstance(meta.get("decay"), (int, float)) else None + release = int(meta.get("release", 35)) if isinstance(meta.get("release"), (int, float)) else None + brightness = int(meta.get("brightness", 55)) if isinstance(meta.get("brightness"), (int, float)) else None + emit_range = int(meta.get("emitRange", 15)) if isinstance(meta.get("emitRange"), (int, float)) else None + for row in compact_events: + if not isinstance(row, list) or len(row) < 4: + continue + raw_time, raw_key_idx, raw_midi, raw_on = row[:4] + if not isinstance(raw_time, (int, float)) or not isinstance(raw_key_idx, (int, float)) or not isinstance(raw_midi, (int, float)): + continue + key_idx = int(raw_key_idx) + if key_idx < 0 or key_idx >= len(keys): + continue + raw_key = keys[key_idx] + if not isinstance(raw_key, str) or not raw_key.strip(): + continue + events.append( + { + "t": max(0, min(PIANO_RECORDING_MAX_MS, int(raw_time))), + "keyId": raw_key[:32], + "midi": max(0, min(127, int(raw_midi))), + "on": bool(raw_on), + "instrument": instrument, + "voiceMode": voice_mode, + "attack": max(0, min(100, attack)) if isinstance(attack, int) else None, + "decay": max(0, min(100, decay)) if isinstance(decay, int) else None, + "release": max(0, min(100, release)) if isinstance(release, int) else None, + "brightness": max(0, min(100, brightness)) if isinstance(brightness, int) else None, + "emitRange": max(5, min(20, emit_range)) if isinstance(emit_range, int) else None, + } + ) + # Backward fallback for older items that still carry recording payload in params. + if not events: + raw_events = item.params.get("recording") + if isinstance(raw_events, list): + for event in raw_events: + if not isinstance(event, dict): + continue + raw_time = event.get("t") + raw_key = event.get("keyId") + raw_midi = event.get("midi") + raw_on = event.get("on") + if not isinstance(raw_time, (int, float)) or not isinstance(raw_key, str) or not isinstance(raw_midi, (int, float)) or not isinstance(raw_on, bool): + continue + raw_instrument = event.get("instrument") + raw_voice_mode = event.get("voiceMode") + raw_attack = event.get("attack") + raw_decay = event.get("decay") + raw_release = event.get("release") + raw_brightness = event.get("brightness") + raw_emit_range = event.get("emitRange") + events.append( + { + "t": max(0, min(PIANO_RECORDING_MAX_MS, int(raw_time))), + "keyId": raw_key[:32] or "KeyA", + "midi": max(0, min(127, int(raw_midi))), + "on": raw_on, + "instrument": str(raw_instrument).strip().lower() if isinstance(raw_instrument, str) else None, + "voiceMode": str(raw_voice_mode).strip().lower() if isinstance(raw_voice_mode, str) else None, + "attack": max(0, min(100, int(raw_attack))) if isinstance(raw_attack, (int, float)) else None, + "decay": max(0, min(100, int(raw_decay))) if isinstance(raw_decay, (int, float)) else None, + "release": max(0, min(100, int(raw_release))) if isinstance(raw_release, (int, float)) else None, + "brightness": max(0, min(100, int(raw_brightness))) if isinstance(raw_brightness, (int, float)) else None, + "emitRange": max(5, min(20, int(raw_emit_range))) if isinstance(raw_emit_range, (int, float)) else None, + } + ) events.sort(key=lambda entry: int(entry["t"])) if not events: return @@ -842,6 +929,10 @@ class SignalingServer: auto_stop_task = recording_state.get("autoStopTask") if isinstance(auto_stop_task, asyncio.Task) and not auto_stop_task.done(): auto_stop_task.cancel() + song_id = str(item.params.get("songId", "")).strip() + if song_id and song_id in self.item_service.piano_songs: + self.item_service.piano_songs.pop(song_id, None) + self.item_service.save_piano_songs() self.item_service.remove_item(item.id) self.item_last_use_ms.pop(item.id, None) await self._broadcast(ItemRemovePacket(type="item_remove", itemId=item.id)) @@ -1007,8 +1098,11 @@ class SignalingServer: if item.id in self.piano_recording_state_by_item: await self._send_item_result(client, False, "use", "Stop recording before playback.", item.id) return + song_id = str(item.params.get("songId", "")).strip() + has_song = isinstance(self.item_service.piano_songs.get(song_id), dict) if song_id else False recording = item.params.get("recording") - if not isinstance(recording, list) or not recording: + has_legacy_recording = isinstance(recording, list) and len(recording) > 0 + if not has_song and not has_legacy_recording: await self._send_item_result(client, False, "use", "No recording saved on this piano.", item.id) return self._cancel_piano_playback(item.id) diff --git a/server/tests/test_item_use_cooldown.py b/server/tests/test_item_use_cooldown.py index 543bff3..c0de428 100644 --- a/server/tests/test_item_use_cooldown.py +++ b/server/tests/test_item_use_cooldown.py @@ -550,10 +550,14 @@ async def test_piano_recording_toggle_and_save(monkeypatch: pytest.MonkeyPatch) ) assert send_payloads[-1].ok is True assert item.id not in server.piano_recording_state_by_item - recording = item.params.get("recording") - assert isinstance(recording, list) - assert len(recording) >= 2 - assert recording[0]["keyId"] == "KeyA" + song_id = item.params.get("songId") + assert isinstance(song_id, str) + payload = server.item_service.piano_songs.get(song_id) + assert isinstance(payload, dict) + keys = payload.get("keys") + events = payload.get("events") + assert isinstance(keys, list) and "KeyA" in keys + assert isinstance(events, list) and len(events) >= 2 @pytest.mark.asyncio