From 5d88fce752d8580c839879c04c9f870c40313f27 Mon Sep 17 00:00:00 2001 From: Jage9 Date: Mon, 23 Feb 2026 01:49:27 -0500 Subject: [PATCH] Remove legacy piano fallback and add state-indexed song events --- client/public/version.js | 2 +- client/src/main.ts | 84 +++++++------- docs/item-schema.md | 8 +- server/app/items/piano.py | 10 +- server/app/server.py | 147 ++++++++++++++----------- server/tests/test_item_use_cooldown.py | 10 +- 6 files changed, 134 insertions(+), 127 deletions(-) diff --git a/client/public/version.js b/client/public/version.js index 7d93f63..0ad09f1 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 R217"; +window.CHGRID_WEB_VERSION = "2026.02.23 R218"; // 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 1368aa8..9762de7 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -478,67 +478,67 @@ async function loadPianoDemo(): Promise { 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 states = Array.isArray(song.states) ? song.states : []; 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[] = []; + const resolveState = (stateIndex: number): Partial => { + if (stateIndex < 0 || stateIndex >= states.length) { + return {}; + } + const row = states[stateIndex]; + if (!Array.isArray(row) || row.length < 7) { + return {}; + } + return { + instrument: typeof row[0] === 'string' ? row[0] : undefined, + voiceMode: row[1] === 'mono' ? 'mono' : row[1] === 'poly' ? 'poly' : undefined, + attack: typeof row[2] === 'number' ? Math.max(0, Math.min(100, Math.round(row[2]))) : undefined, + decay: typeof row[3] === 'number' ? Math.max(0, Math.min(100, Math.round(row[3]))) : undefined, + release: typeof row[4] === 'number' ? Math.max(0, Math.min(100, Math.round(row[4]))) : undefined, + brightness: typeof row[5] === 'number' ? Math.max(0, Math.min(100, Math.round(row[5]))) : undefined, + emitRange: typeof row[6] === 'number' ? Math.max(5, Math.min(20, Math.round(row[6]))) : undefined, + }; + }; for (const compact of compactEvents) { if (!Array.isArray(compact) || compact.length < 4) continue; - const [rawT, rawKeyIdx, rawMidi, rawOn] = compact; + const [rawT, rawKeyIdx, rawMidi, rawOn, rawStateIdx] = compact; if (typeof rawT !== 'number' || typeof rawKeyIdx !== 'number' || typeof rawMidi !== 'number') continue; const keyId = keys[Math.max(0, Math.round(rawKeyIdx))]; if (!keyId) continue; + const eventState = typeof rawStateIdx === 'number' ? resolveState(Math.round(rawStateIdx)) : {}; 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, + instrument: eventState.instrument ?? (typeof meta?.instrument === 'string' ? meta.instrument : undefined), + voiceMode: eventState.voiceMode ?? (meta?.voiceMode === 'mono' ? 'mono' : meta?.voiceMode === 'poly' ? 'poly' : undefined), + attack: + eventState.attack ?? + (Number.isFinite(Number(meta?.attack)) ? Math.max(0, Math.min(100, Math.round(Number(meta?.attack)))) : undefined), + decay: + eventState.decay ?? + (Number.isFinite(Number(meta?.decay)) ? Math.max(0, Math.min(100, Math.round(Number(meta?.decay)))) : undefined), + release: + eventState.release ?? + (Number.isFinite(Number(meta?.release)) ? Math.max(0, Math.min(100, Math.round(Number(meta?.release)))) : undefined), + brightness: + eventState.brightness ?? + (Number.isFinite(Number(meta?.brightness)) ? Math.max(0, Math.min(100, Math.round(Number(meta?.brightness)))) : undefined), + emitRange: + eventState.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); @@ -554,14 +554,6 @@ async function loadPianoDemo(): Promise { } 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; - } } catch { // Demo remains unavailable if loading/parsing fails. } diff --git a/docs/item-schema.md b/docs/item-schema.md index b9164a1..2186257 100644 --- a/docs/item-schema.md +++ b/docs/item-schema.md @@ -184,9 +184,11 @@ - `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`. +- Recorded/demo song payload is stored in server song registry (`runtime/piano_songs.json`) using compact format: + - `meta`: shared synth parameters + - `keys`: keyId dictionary + - `states`: parameter-state dictionary (for mid-song instrument/param changes) + - `events`: `[t, keyIndex, midi, on, stateIndex]` ## Packet Shapes diff --git a/server/app/items/piano.py b/server/app/items/piano.py index 72845a1..fff8ebe 100644 --- a/server/app/items/piano.py +++ b/server/app/items/piano.py @@ -106,12 +106,8 @@ PROPERTY_METADATA: dict[str, dict[str, object]] = { def validate_update(_item: WorldItem, next_params: dict) -> dict: """Validate and normalize piano params.""" - # 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") + # Song references are server-managed and not directly editable from client updates. 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() @@ -180,10 +176,6 @@ def validate_update(_item: WorldItem, next_params: dict) -> dict: raise ValueError("emitRange must be between 5 and 20.") next_params["emitRange"] = emit_range - if isinstance(preserved_recording, list): - 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() diff --git a/server/app/server.py b/server/app/server.py index f1f6637..6081e51 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -297,6 +297,8 @@ class SignalingServer: song_id = f"item:{item.id}:recording" keys: list[str] = [] key_to_index: dict[str, int] = {} + states: list[list[object]] = [] + state_to_index: dict[tuple[object, ...], int] = {} compact_events: list[list[int]] = [] for event in events: if not isinstance(event, dict): @@ -305,6 +307,24 @@ class SignalingServer: 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 + instrument = str(event.get("instrument", "piano")).strip().lower() or "piano" + voice_mode = str(event.get("voiceMode", "poly")).strip().lower() + if voice_mode not in {"mono", "poly"}: + voice_mode = "poly" + attack = int(event.get("attack", 15)) if isinstance(event.get("attack"), (int, float)) else 15 + decay = int(event.get("decay", 45)) if isinstance(event.get("decay"), (int, float)) else 45 + release = int(event.get("release", 35)) if isinstance(event.get("release"), (int, float)) else 35 + brightness = int(event.get("brightness", 55)) if isinstance(event.get("brightness"), (int, float)) else 55 + emit_range = int(event.get("emitRange", 15)) if isinstance(event.get("emitRange"), (int, float)) else 15 + state_key = ( + instrument, + voice_mode, + max(0, min(100, attack)), + max(0, min(100, decay)), + max(0, min(100, release)), + max(0, min(100, brightness)), + max(5, min(20, emit_range)), + ) if not key_id: continue index = key_to_index.get(key_id) @@ -312,20 +332,27 @@ class SignalingServer: 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]) + state_index = state_to_index.get(state_key) + if state_index is None: + state_index = len(states) + states.append(list(state_key)) + state_to_index[state_key] = state_index + compact_events.append([max(0, min(PIANO_RECORDING_MAX_MS, t)), index, max(0, min(127, midi)), on, state_index]) compact_events.sort(key=lambda row: row[0]) + first_state = states[0] if states else ["piano", "poly", 15, 45, 35, 55, 15] 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, + "instrument": first_state[0], + "voiceMode": first_state[1], + "attack": first_state[2], + "decay": first_state[3], + "release": first_state[4], + "brightness": first_state[5], + "emitRange": first_state[6], "recordingLengthMs": elapsed_ms, }, "keys": keys, + "states": states, "events": compact_events, } self.item_service.save_piano_songs() @@ -359,25 +386,29 @@ class SignalingServer: 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") + states = song_payload.get("states") 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 + base_state = None if isinstance(meta, dict): - instrument = str(meta.get("instrument", "")).strip().lower() or None + instrument = str(meta.get("instrument", "")).strip().lower() or "piano" 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 + voice_mode = raw_voice_mode if raw_voice_mode in {"mono", "poly"} else "poly" + attack = int(meta.get("attack", 15)) if isinstance(meta.get("attack"), (int, float)) else 15 + decay = int(meta.get("decay", 45)) if isinstance(meta.get("decay"), (int, float)) else 45 + release = int(meta.get("release", 35)) if isinstance(meta.get("release"), (int, float)) else 35 + brightness = int(meta.get("brightness", 55)) if isinstance(meta.get("brightness"), (int, float)) else 55 + emit_range = int(meta.get("emitRange", 15)) if isinstance(meta.get("emitRange"), (int, float)) else 15 + base_state = ( + instrument, + voice_mode, + max(0, min(100, attack)), + max(0, min(100, decay)), + max(0, min(100, release)), + max(0, min(100, brightness)), + max(5, min(20, emit_range)), + ) for row in compact_events: if not isinstance(row, list) or len(row) < 4: continue @@ -390,54 +421,38 @@ class SignalingServer: raw_key = keys[key_idx] if not isinstance(raw_key, str) or not raw_key.strip(): continue + state = base_state + if len(row) >= 5 and isinstance(states, list) and isinstance(row[4], (int, float)): + state_idx = int(row[4]) + if 0 <= state_idx < len(states): + state_row = states[state_idx] + if isinstance(state_row, list) and len(state_row) >= 7: + candidate_instrument = str(state_row[0]).strip().lower() or "piano" + candidate_voice_mode = str(state_row[1]).strip().lower() + state = ( + candidate_instrument, + candidate_voice_mode if candidate_voice_mode in {"mono", "poly"} else "poly", + max(0, min(100, int(state_row[2]) if isinstance(state_row[2], (int, float)) else 15)), + max(0, min(100, int(state_row[3]) if isinstance(state_row[3], (int, float)) else 45)), + max(0, min(100, int(state_row[4]) if isinstance(state_row[4], (int, float)) else 35)), + max(0, min(100, int(state_row[5]) if isinstance(state_row[5], (int, float)) else 55)), + max(5, min(20, int(state_row[6]) if isinstance(state_row[6], (int, float)) else 15)), + ) + if state is None: + 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, + "instrument": state[0], + "voiceMode": state[1], + "attack": state[2], + "decay": state[3], + "release": state[4], + "brightness": state[5], + "emitRange": state[6], } ) events.sort(key=lambda entry: int(entry["t"])) @@ -1100,9 +1115,7 @@ class SignalingServer: 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") - has_legacy_recording = isinstance(recording, list) and len(recording) > 0 - if not has_song and not has_legacy_recording: + if not has_song: 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 c0de428..c3b9e52 100644 --- a/server/tests/test_item_use_cooldown.py +++ b/server/tests/test_item_use_cooldown.py @@ -555,8 +555,10 @@ async def test_piano_recording_toggle_and_save(monkeypatch: pytest.MonkeyPatch) payload = server.item_service.piano_songs.get(song_id) assert isinstance(payload, dict) keys = payload.get("keys") + states = payload.get("states") events = payload.get("events") assert isinstance(keys, list) and "KeyA" in keys + assert isinstance(states, list) and len(states) >= 1 assert isinstance(events, list) and len(events) >= 2 @@ -567,7 +569,13 @@ async def test_piano_playback_starts_task(monkeypatch: pytest.MonkeyPatch) -> No client = ClientConnection(websocket=ws, id="u1", nickname="tester", x=5, y=6) server.clients[ws] = client item = server.item_service.default_item(client, "piano") - item.params["recording"] = [{"t": 0, "keyId": "KeyA", "midi": 60, "on": True}] + item.params["songId"] = "item:test-song" + server.item_service.piano_songs["item:test-song"] = { + "meta": {"instrument": "piano", "voiceMode": "poly", "attack": 15, "decay": 45, "release": 35, "brightness": 55, "emitRange": 15}, + "keys": ["KeyA"], + "states": [["piano", "poly", 15, 45, 35, 55, 15]], + "events": [[0, 0, 60, 1, 0]], + } server.item_service.add_item(item) send_payloads: list[object] = []