Migrate piano songs to songId registry with compact storage
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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 R216";
|
window.CHGRID_WEB_VERSION = "2026.02.23 R217";
|
||||||
// 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";
|
||||||
|
|||||||
@@ -125,6 +125,10 @@ type PianoDemoEvent = {
|
|||||||
brightness?: number;
|
brightness?: number;
|
||||||
emitRange?: number;
|
emitRange?: number;
|
||||||
};
|
};
|
||||||
|
type PianoDemoSong = {
|
||||||
|
id: string;
|
||||||
|
events: PianoDemoEvent[];
|
||||||
|
};
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -291,7 +295,8 @@ let activePianoDemoRunToken = 0;
|
|||||||
let activePianoDemoItemId: string | null = null;
|
let activePianoDemoItemId: string | null = null;
|
||||||
const activePianoDemoTimeoutIds: number[] = [];
|
const activePianoDemoTimeoutIds: number[] = [];
|
||||||
const activePianoDemoNotes = new Map<string, { runtimeKey: string; midi: number }>();
|
const activePianoDemoNotes = new Map<string, { runtimeKey: string; midi: number }>();
|
||||||
let pianoDemoEvents: PianoDemoEvent[] = [];
|
const pianoDemoSongs = new Map<string, PianoDemoSong>();
|
||||||
|
let pianoDemoDefaultSongId = '';
|
||||||
const activeRemotePianoKeys = new Set<string>();
|
const activeRemotePianoKeys = new Set<string>();
|
||||||
let pianoPreviewTimeoutId: number | null = null;
|
let pianoPreviewTimeoutId: number | null = null;
|
||||||
let activeTeleport:
|
let activeTeleport:
|
||||||
@@ -470,9 +475,17 @@ async function loadPianoDemo(): Promise<void> {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = (await response.json()) as { recording?: unknown };
|
const data = (await response.json()) as {
|
||||||
const rawEvents = Array.isArray(data.recording) ? data.recording : [];
|
defaultSongId?: unknown;
|
||||||
|
songs?: unknown;
|
||||||
|
recording?: unknown;
|
||||||
|
};
|
||||||
|
pianoDemoSongs.clear();
|
||||||
|
pianoDemoDefaultSongId = '';
|
||||||
|
|
||||||
|
const parseLegacyEvents = (rawEvents: unknown): PianoDemoEvent[] => {
|
||||||
const parsed: PianoDemoEvent[] = [];
|
const parsed: PianoDemoEvent[] = [];
|
||||||
|
if (!Array.isArray(rawEvents)) return parsed;
|
||||||
for (const entry of rawEvents) {
|
for (const entry of rawEvents) {
|
||||||
if (!entry || typeof entry !== 'object') continue;
|
if (!entry || typeof entry !== 'object') continue;
|
||||||
const record = entry as Record<string, unknown>;
|
const record = entry as Record<string, unknown>;
|
||||||
@@ -496,7 +509,59 @@ async function loadPianoDemo(): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
parsed.sort((a, b) => a.t - b.t);
|
parsed.sort((a, b) => a.t - b.t);
|
||||||
pianoDemoEvents = parsed;
|
return parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.songs && typeof data.songs === 'object') {
|
||||||
|
const songs = data.songs as Record<string, unknown>;
|
||||||
|
for (const [songId, rawSong] of Object.entries(songs)) {
|
||||||
|
if (!rawSong || typeof rawSong !== 'object') continue;
|
||||||
|
const song = rawSong as Record<string, unknown>;
|
||||||
|
const meta = song.meta as Record<string, unknown> | 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;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Demo remains unavailable if loading/parsing fails.
|
// 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. */
|
/** 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) {
|
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');
|
updateStatus('demo unavailable');
|
||||||
audio.sfxUiCancel();
|
audio.sfxUiCancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const runToken = activePianoDemoRunToken;
|
const runToken = activePianoDemoRunToken;
|
||||||
activePianoDemoItemId = itemId;
|
activePianoDemoItemId = itemId;
|
||||||
for (let index = 0; index < pianoDemoEvents.length; index += 1) {
|
for (let index = 0; index < song.events.length; index += 1) {
|
||||||
const event = pianoDemoEvents[index]!;
|
const event = song.events[index]!;
|
||||||
const timeoutId = window.setTimeout(() => {
|
const timeoutId = window.setTimeout(() => {
|
||||||
if (runToken !== activePianoDemoRunToken) return;
|
if (runToken !== activePianoDemoRunToken) return;
|
||||||
const liveItem = state.items.get(itemId);
|
const liveItem = state.items.get(itemId);
|
||||||
|
|||||||
@@ -183,8 +183,10 @@
|
|||||||
- `release`: integer, range `0-100`, default `35`.
|
- `release`: integer, range `0-100`, default `35`.
|
||||||
- `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.
|
||||||
- `recording`: server-managed array of note events (`t`, `keyId`, `midi`, `on`) captured from piano mode recording.
|
- `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`).
|
- `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
|
## Packet Shapes
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,11 @@ class ItemService:
|
|||||||
"""Create service and eagerly load persisted state when configured."""
|
"""Create service and eagerly load persisted state when configured."""
|
||||||
|
|
||||||
self.state_file = state_file
|
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.items: dict[str, WorldItem] = {}
|
||||||
|
self.piano_songs: dict[str, dict] = {}
|
||||||
self.load_state()
|
self.load_state()
|
||||||
|
self.load_piano_songs()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def now_ms() -> int:
|
def now_ms() -> int:
|
||||||
@@ -129,6 +132,29 @@ class ItemService:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
LOGGER.warning("failed to load persisted item state from %s: %s", self.state_file, 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:
|
def save_state(self) -> None:
|
||||||
"""Persist instance-only item data to configured state file."""
|
"""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")
|
self.state_file.write_text(json.dumps(payload, ensure_ascii=True, separators=(",", ":")), encoding="utf-8")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
LOGGER.warning("failed to persist item state to %s: %s", self.state_file, 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)
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ DEFAULT_PARAMS: dict = {
|
|||||||
"release": 35,
|
"release": 35,
|
||||||
"brightness": 55,
|
"brightness": 55,
|
||||||
"emitRange": 15,
|
"emitRange": 15,
|
||||||
|
"songId": "unterlandersheimweh",
|
||||||
}
|
}
|
||||||
|
|
||||||
INSTRUMENT_OPTIONS: tuple[str, ...] = (
|
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.
|
# Recording data is server-managed and not directly editable from client updates.
|
||||||
preserved_recording = _item.params.get("recording")
|
preserved_recording = _item.params.get("recording")
|
||||||
preserved_recording_length = _item.params.get("recordingLengthMs")
|
preserved_recording_length = _item.params.get("recordingLengthMs")
|
||||||
|
preserved_song_id = _item.params.get("songId")
|
||||||
next_params.pop("recording", None)
|
next_params.pop("recording", None)
|
||||||
next_params.pop("recordingLengthMs", None)
|
next_params.pop("recordingLengthMs", None)
|
||||||
|
next_params.pop("songId", None)
|
||||||
|
|
||||||
instrument = str(next_params.get("instrument", "piano")).strip().lower()
|
instrument = str(next_params.get("instrument", "piano")).strip().lower()
|
||||||
if instrument not in INSTRUMENT_OPTIONS:
|
if instrument not in INSTRUMENT_OPTIONS:
|
||||||
@@ -181,6 +184,8 @@ def validate_update(_item: WorldItem, next_params: dict) -> dict:
|
|||||||
next_params["recording"] = preserved_recording
|
next_params["recording"] = preserved_recording
|
||||||
if isinstance(preserved_recording_length, (int, float)):
|
if isinstance(preserved_recording_length, (int, float)):
|
||||||
next_params["recordingLengthMs"] = max(0, min(30_000, int(preserved_recording_length)))
|
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
|
return next_params
|
||||||
|
|
||||||
|
|||||||
@@ -294,8 +294,44 @@ class SignalingServer:
|
|||||||
elapsed_ms = max(0, min(PIANO_RECORDING_MAX_MS, elapsed_ms))
|
elapsed_ms = max(0, min(PIANO_RECORDING_MAX_MS, elapsed_ms))
|
||||||
recorded_events = session.get("events")
|
recorded_events = session.get("events")
|
||||||
events = list(recorded_events) if isinstance(recorded_events, list) else []
|
events = list(recorded_events) if isinstance(recorded_events, list) else []
|
||||||
item.params["recording"] = events
|
song_id = f"item:{item.id}:recording"
|
||||||
item.params["recordingLengthMs"] = elapsed_ms
|
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.updatedAt = self.item_service.now_ms()
|
||||||
item.version += 1
|
item.version += 1
|
||||||
self.item_service.save_state()
|
self.item_service.save_state()
|
||||||
@@ -318,10 +354,61 @@ class SignalingServer:
|
|||||||
"""Run one piano recording playback task and broadcast note events."""
|
"""Run one piano recording playback task and broadcast note events."""
|
||||||
|
|
||||||
sender_id = f"item:{item.id}:playback"
|
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]] = []
|
events: list[dict[str, object]] = []
|
||||||
|
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:
|
for event in raw_events:
|
||||||
if not isinstance(event, dict):
|
if not isinstance(event, dict):
|
||||||
continue
|
continue
|
||||||
@@ -842,6 +929,10 @@ class SignalingServer:
|
|||||||
auto_stop_task = recording_state.get("autoStopTask")
|
auto_stop_task = recording_state.get("autoStopTask")
|
||||||
if isinstance(auto_stop_task, asyncio.Task) and not auto_stop_task.done():
|
if isinstance(auto_stop_task, asyncio.Task) and not auto_stop_task.done():
|
||||||
auto_stop_task.cancel()
|
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_service.remove_item(item.id)
|
||||||
self.item_last_use_ms.pop(item.id, None)
|
self.item_last_use_ms.pop(item.id, None)
|
||||||
await self._broadcast(ItemRemovePacket(type="item_remove", itemId=item.id))
|
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:
|
if item.id in self.piano_recording_state_by_item:
|
||||||
await self._send_item_result(client, False, "use", "Stop recording before playback.", item.id)
|
await self._send_item_result(client, False, "use", "Stop recording before playback.", item.id)
|
||||||
return
|
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")
|
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)
|
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)
|
||||||
|
|||||||
@@ -550,10 +550,14 @@ async def test_piano_recording_toggle_and_save(monkeypatch: pytest.MonkeyPatch)
|
|||||||
)
|
)
|
||||||
assert send_payloads[-1].ok is True
|
assert send_payloads[-1].ok is True
|
||||||
assert item.id not in server.piano_recording_state_by_item
|
assert item.id not in server.piano_recording_state_by_item
|
||||||
recording = item.params.get("recording")
|
song_id = item.params.get("songId")
|
||||||
assert isinstance(recording, list)
|
assert isinstance(song_id, str)
|
||||||
assert len(recording) >= 2
|
payload = server.item_service.piano_songs.get(song_id)
|
||||||
assert recording[0]["keyId"] == "KeyA"
|
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
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Reference in New Issue
Block a user