Remove legacy piano fallback and add state-indexed song events

This commit is contained in:
Jage9
2026-02-23 01:49:27 -05:00
parent 6595c15fa4
commit 5d88fce752
6 changed files with 134 additions and 127 deletions

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 R217"; window.CHGRID_WEB_VERSION = "2026.02.23 R218";
// 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

@@ -478,67 +478,67 @@ async function loadPianoDemo(): Promise<void> {
const data = (await response.json()) as { const data = (await response.json()) as {
defaultSongId?: unknown; defaultSongId?: unknown;
songs?: unknown; songs?: unknown;
recording?: unknown;
}; };
pianoDemoSongs.clear(); pianoDemoSongs.clear();
pianoDemoDefaultSongId = ''; 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<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);
return parsed;
};
if (data.songs && typeof data.songs === 'object') { if (data.songs && typeof data.songs === 'object') {
const songs = data.songs as Record<string, unknown>; const songs = data.songs as Record<string, unknown>;
for (const [songId, rawSong] of Object.entries(songs)) { for (const [songId, rawSong] of Object.entries(songs)) {
if (!rawSong || typeof rawSong !== 'object') continue; if (!rawSong || typeof rawSong !== 'object') continue;
const song = rawSong as Record<string, unknown>; const song = rawSong as Record<string, unknown>;
const meta = song.meta as Record<string, unknown> | undefined; const meta = song.meta as Record<string, unknown> | 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 keys = Array.isArray(song.keys) ? song.keys.filter((value): value is string => typeof value === 'string') : [];
const compactEvents = Array.isArray(song.events) ? song.events : []; const compactEvents = Array.isArray(song.events) ? song.events : [];
const events: PianoDemoEvent[] = []; const events: PianoDemoEvent[] = [];
const resolveState = (stateIndex: number): Partial<PianoDemoEvent> => {
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) { for (const compact of compactEvents) {
if (!Array.isArray(compact) || compact.length < 4) continue; 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; if (typeof rawT !== 'number' || typeof rawKeyIdx !== 'number' || typeof rawMidi !== 'number') continue;
const keyId = keys[Math.max(0, Math.round(rawKeyIdx))]; const keyId = keys[Math.max(0, Math.round(rawKeyIdx))];
if (!keyId) continue; if (!keyId) continue;
const eventState = typeof rawStateIdx === 'number' ? resolveState(Math.round(rawStateIdx)) : {};
events.push({ events.push({
t: Math.max(0, Math.round(rawT)), t: Math.max(0, Math.round(rawT)),
keyId: keyId.slice(0, 32), keyId: keyId.slice(0, 32),
midi: Math.max(0, Math.min(127, Math.round(rawMidi))), midi: Math.max(0, Math.min(127, Math.round(rawMidi))),
on: Boolean(rawOn), on: Boolean(rawOn),
instrument: typeof meta?.instrument === 'string' ? meta.instrument : undefined, instrument: eventState.instrument ?? (typeof meta?.instrument === 'string' ? meta.instrument : undefined),
voiceMode: meta?.voiceMode === 'mono' ? 'mono' : meta?.voiceMode === 'poly' ? 'poly' : undefined, voiceMode: eventState.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, attack:
decay: Number.isFinite(Number(meta?.decay)) ? Math.max(0, Math.min(100, Math.round(Number(meta?.decay)))) : undefined, eventState.attack ??
release: Number.isFinite(Number(meta?.release)) ? Math.max(0, Math.min(100, Math.round(Number(meta?.release)))) : undefined, (Number.isFinite(Number(meta?.attack)) ? Math.max(0, Math.min(100, Math.round(Number(meta?.attack)))) : undefined),
brightness: Number.isFinite(Number(meta?.brightness)) ? Math.max(0, Math.min(100, Math.round(Number(meta?.brightness)))) : undefined, decay:
emitRange: Number.isFinite(Number(meta?.emitRange)) ? Math.max(5, Math.min(20, Math.round(Number(meta?.emitRange)))) : undefined, 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); events.sort((a, b) => a.t - b.t);
@@ -554,14 +554,6 @@ async function loadPianoDemo(): Promise<void> {
} }
return; 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 { } catch {
// Demo remains unavailable if loading/parsing fails. // Demo remains unavailable if loading/parsing fails.
} }

View File

@@ -184,9 +184,11 @@
- `brightness`: integer, range `0-100`, default `55`. - `brightness`: integer, range `0-100`, default `55`.
- `emitRange`: integer, range `5-20`, default `15`. - `emitRange`: integer, range `5-20`, default `15`.
- `songId`: server-managed song reference used for piano demo/playback content. - `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. - Recorded/demo song payload is stored in server song registry (`runtime/piano_songs.json`) using compact format:
- `recordingLengthMs`: server-managed recording duration in milliseconds (`0..30000`). - `meta`: shared synth parameters
- Legacy fallback only during migration; new recordings are stored in server song registry by `songId`. - `keys`: keyId dictionary
- `states`: parameter-state dictionary (for mid-song instrument/param changes)
- `events`: `[t, keyIndex, midi, on, stateIndex]`
## Packet Shapes ## Packet Shapes

View File

@@ -106,12 +106,8 @@ PROPERTY_METADATA: dict[str, dict[str, object]] = {
def validate_update(_item: WorldItem, next_params: dict) -> dict: def validate_update(_item: WorldItem, next_params: dict) -> dict:
"""Validate and normalize piano params.""" """Validate and normalize piano params."""
# Recording data is server-managed and not directly editable from client updates. # Song references are 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") preserved_song_id = _item.params.get("songId")
next_params.pop("recording", None)
next_params.pop("recordingLengthMs", None)
next_params.pop("songId", None) next_params.pop("songId", None)
instrument = str(next_params.get("instrument", "piano")).strip().lower() 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.") raise ValueError("emitRange must be between 5 and 20.")
next_params["emitRange"] = emit_range 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(): if isinstance(preserved_song_id, str) and preserved_song_id.strip():
next_params["songId"] = preserved_song_id.strip() next_params["songId"] = preserved_song_id.strip()

View File

@@ -297,6 +297,8 @@ class SignalingServer:
song_id = f"item:{item.id}:recording" song_id = f"item:{item.id}:recording"
keys: list[str] = [] keys: list[str] = []
key_to_index: dict[str, int] = {} key_to_index: dict[str, int] = {}
states: list[list[object]] = []
state_to_index: dict[tuple[object, ...], int] = {}
compact_events: list[list[int]] = [] compact_events: list[list[int]] = []
for event in events: for event in events:
if not isinstance(event, dict): if not isinstance(event, dict):
@@ -305,6 +307,24 @@ class SignalingServer:
key_id = str(event.get("keyId", "")).strip() key_id = str(event.get("keyId", "")).strip()
midi = int(event.get("midi", 0)) if isinstance(event.get("midi"), (int, float)) else 0 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 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: if not key_id:
continue continue
index = key_to_index.get(key_id) index = key_to_index.get(key_id)
@@ -312,20 +332,27 @@ class SignalingServer:
index = len(keys) index = len(keys)
keys.append(key_id) keys.append(key_id)
key_to_index[key_id] = index 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]) 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] = { self.item_service.piano_songs[song_id] = {
"meta": { "meta": {
"instrument": str(item.params.get("instrument", "piano")).strip().lower(), "instrument": first_state[0],
"voiceMode": str(item.params.get("voiceMode", "poly")).strip().lower(), "voiceMode": first_state[1],
"attack": int(item.params.get("attack", 15)) if isinstance(item.params.get("attack"), (int, float)) else 15, "attack": first_state[2],
"decay": int(item.params.get("decay", 45)) if isinstance(item.params.get("decay"), (int, float)) else 45, "decay": first_state[3],
"release": int(item.params.get("release", 35)) if isinstance(item.params.get("release"), (int, float)) else 35, "release": first_state[4],
"brightness": int(item.params.get("brightness", 55)) if isinstance(item.params.get("brightness"), (int, float)) else 55, "brightness": first_state[5],
"emitRange": int(item.params.get("emitRange", 15)) if isinstance(item.params.get("emitRange"), (int, float)) else 15, "emitRange": first_state[6],
"recordingLengthMs": elapsed_ms, "recordingLengthMs": elapsed_ms,
}, },
"keys": keys, "keys": keys,
"states": states,
"events": compact_events, "events": compact_events,
} }
self.item_service.save_piano_songs() 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 song_payload = self.item_service.piano_songs.get(song_id) if song_id else None
if isinstance(song_payload, dict): if isinstance(song_payload, dict):
keys = song_payload.get("keys") keys = song_payload.get("keys")
states = song_payload.get("states")
compact_events = song_payload.get("events") compact_events = song_payload.get("events")
meta = song_payload.get("meta") meta = song_payload.get("meta")
if isinstance(keys, list) and isinstance(compact_events, list): if isinstance(keys, list) and isinstance(compact_events, list):
instrument = None base_state = None
voice_mode = None
attack = None
decay = None
release = None
brightness = None
emit_range = None
if isinstance(meta, dict): 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() raw_voice_mode = str(meta.get("voiceMode", "")).strip().lower()
voice_mode = raw_voice_mode if raw_voice_mode in {"mono", "poly"} 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 None 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 None 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 None 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 None 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 None 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: for row in compact_events:
if not isinstance(row, list) or len(row) < 4: if not isinstance(row, list) or len(row) < 4:
continue continue
@@ -390,54 +421,38 @@ class SignalingServer:
raw_key = keys[key_idx] raw_key = keys[key_idx]
if not isinstance(raw_key, str) or not raw_key.strip(): if not isinstance(raw_key, str) or not raw_key.strip():
continue 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( events.append(
{ {
"t": max(0, min(PIANO_RECORDING_MAX_MS, int(raw_time))), "t": max(0, min(PIANO_RECORDING_MAX_MS, int(raw_time))),
"keyId": raw_key[:32], "keyId": raw_key[:32],
"midi": max(0, min(127, int(raw_midi))), "midi": max(0, min(127, int(raw_midi))),
"on": bool(raw_on), "on": bool(raw_on),
"instrument": instrument, "instrument": state[0],
"voiceMode": voice_mode, "voiceMode": state[1],
"attack": max(0, min(100, attack)) if isinstance(attack, int) else None, "attack": state[2],
"decay": max(0, min(100, decay)) if isinstance(decay, int) else None, "decay": state[3],
"release": max(0, min(100, release)) if isinstance(release, int) else None, "release": state[4],
"brightness": max(0, min(100, brightness)) if isinstance(brightness, int) else None, "brightness": state[5],
"emitRange": max(5, min(20, emit_range)) if isinstance(emit_range, int) else None, "emitRange": state[6],
}
)
# 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"])) events.sort(key=lambda entry: int(entry["t"]))
@@ -1100,9 +1115,7 @@ class SignalingServer:
return return
song_id = str(item.params.get("songId", "")).strip() 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 has_song = isinstance(self.item_service.piano_songs.get(song_id), dict) if song_id else False
recording = item.params.get("recording") if not has_song:
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) await self._send_item_result(client, False, "use", "No recording saved on this piano.", item.id)
return return
self._cancel_piano_playback(item.id) self._cancel_piano_playback(item.id)

View File

@@ -555,8 +555,10 @@ async def test_piano_recording_toggle_and_save(monkeypatch: pytest.MonkeyPatch)
payload = server.item_service.piano_songs.get(song_id) payload = server.item_service.piano_songs.get(song_id)
assert isinstance(payload, dict) assert isinstance(payload, dict)
keys = payload.get("keys") keys = payload.get("keys")
states = payload.get("states")
events = payload.get("events") events = payload.get("events")
assert isinstance(keys, list) and "KeyA" in keys assert isinstance(keys, list) and "KeyA" in keys
assert isinstance(states, list) and len(states) >= 1
assert isinstance(events, list) and len(events) >= 2 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) client = ClientConnection(websocket=ws, id="u1", nickname="tester", x=5, y=6)
server.clients[ws] = client server.clients[ws] = client
item = server.item_service.default_item(client, "piano") 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) server.item_service.add_item(item)
send_payloads: list[object] = [] send_payloads: list[object] = []