diff --git a/client/public/help.json b/client/public/help.json index 3350639..8f76e82 100644 --- a/client/public/help.json +++ b/client/public/help.json @@ -87,7 +87,7 @@ }, { "keys": "Piano mode", - "description": "When using a piano: 1-9 (and 0 for the 10th slot) changes instrument, -/= changes octave, ASDFGHJKL;' plays C major notes, WETYUOP] plays sharps, Escape exits" + "description": "When using a piano: 1-9 (and 0 for the 10th slot) changes instrument, -/= changes octave, ASDFGHJKL;' plays C major notes, WETYUOP] plays sharps, comma starts/stops recording, period plays recording, Escape exits" } ] }, diff --git a/client/public/version.js b/client/public/version.js index 9189dac..709f3a6 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.22 R204"; +window.CHGRID_WEB_VERSION = "2026.02.22 R205"; // 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 f3dfdb6..da9877f 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -256,6 +256,8 @@ let activeTeleportLoopToken = 0; let activePianoItemId: string | null = null; const activePianoKeys = new Set(); const activePianoKeyMidi = new Map(); +const activePianoHeldOrder: string[] = []; +let activePianoMonophonicKey: string | null = null; const activeRemotePianoKeys = new Set(); let pianoPreviewTimeoutId: number | null = null; let activeTeleport: @@ -881,6 +883,8 @@ async function startPianoUseMode(itemId: string): Promise { activePianoItemId = itemId; activePianoKeys.clear(); activePianoKeyMidi.clear(); + activePianoHeldOrder.length = 0; + activePianoMonophonicKey = null; state.mode = 'pianoUse'; await audio.ensureContext(); updateStatus(`using ${item.title}, press escape to stop.`); @@ -900,6 +904,8 @@ function stopPianoUseMode(announce = true): void { activePianoItemId = null; activePianoKeys.clear(); activePianoKeyMidi.clear(); + activePianoHeldOrder.length = 0; + activePianoMonophonicKey = null; state.mode = 'normal'; if (announce) { updateStatus('Stopped piano.'); @@ -907,6 +913,85 @@ function stopPianoUseMode(announce = true): void { } } +/** Starts one local piano note and sends the matching network note-on packet. */ +function playLocalPianoNote( + item: WorldItem, + itemId: string, + keyId: string, + midi: number, + config: ReturnType, +): void { + const ctx = audio.context; + const destination = audio.getOutputDestinationNode(); + if (!ctx || !destination) return; + const sourceX = item.carrierId === state.player.id ? state.player.x : item.x; + const sourceY = item.carrierId === state.player.id ? state.player.y : item.y; + pianoSynth.noteOn( + keyId, + `local:${itemId}`, + midi, + config.instrument, + config.voiceMode, + config.attack, + config.decay, + config.release, + config.brightness, + { audioCtx: ctx, destination }, + { x: sourceX - state.player.x, y: sourceY - state.player.y, range: config.emitRange }, + ); + signaling.send({ type: 'item_piano_note', itemId, keyId, midi, on: true }); +} + +/** Handles key release while in piano mode, including mono fallback retrigger behavior. */ +function handlePianoUseModeKeyUp(code: string): void { + if (!activePianoKeys.delete(code)) return; + const orderIndex = activePianoHeldOrder.lastIndexOf(code); + if (orderIndex >= 0) { + activePianoHeldOrder.splice(orderIndex, 1); + } + const itemId = activePianoItemId; + const midi = activePianoKeyMidi.get(code); + activePianoKeyMidi.delete(code); + if (!itemId || !Number.isFinite(midi)) { + pianoSynth.noteOff(code); + if (activePianoMonophonicKey === code) { + activePianoMonophonicKey = null; + } + return; + } + const item = state.items.get(itemId); + if (!item || item.type !== 'piano') { + pianoSynth.noteOff(code); + if (activePianoMonophonicKey === code) { + activePianoMonophonicKey = null; + } + return; + } + const config = getPianoParams(item); + if (config.voiceMode !== 'mono') { + pianoSynth.noteOff(code); + signaling.send({ type: 'item_piano_note', itemId, keyId: code, midi, on: false }); + return; + } + if (activePianoMonophonicKey !== code) { + return; + } + pianoSynth.noteOff(code); + signaling.send({ type: 'item_piano_note', itemId, keyId: code, midi, on: false }); + const fallbackCode = activePianoHeldOrder[activePianoHeldOrder.length - 1] ?? null; + if (!fallbackCode) { + activePianoMonophonicKey = null; + return; + } + const fallbackMidi = activePianoKeyMidi.get(fallbackCode); + if (!Number.isFinite(fallbackMidi)) { + activePianoMonophonicKey = null; + return; + } + activePianoMonophonicKey = fallbackCode; + playLocalPianoNote(item, itemId, fallbackCode, fallbackMidi, config); +} + /** Plays one short C4 preview using the piano item's current/overridden envelope+instrument. */ async function previewPianoSettingChange( item: WorldItem, @@ -2328,6 +2413,16 @@ function handlePianoUseModeInput(code: string): void { stopPianoUseMode(false); return; } + if (code === 'Comma') { + signaling.send({ type: 'item_piano_recording', itemId, action: 'toggle_record' }); + audio.sfxUiBlip(); + return; + } + if (code === 'Period') { + signaling.send({ type: 'item_piano_recording', itemId, action: 'playback' }); + audio.sfxUiBlip(); + return; + } if (code === 'Equal' || code === 'Minus') { const current = getPianoParams(item).octave; const next = Math.max(-2, Math.min(2, current + (code === 'Equal' ? 1 : -1))); @@ -2385,25 +2480,19 @@ function handlePianoUseModeInput(code: string): void { const playedMidi = Math.max(0, Math.min(127, midi + config.octave * 12)); activePianoKeys.add(code); activePianoKeyMidi.set(code, playedMidi); - const ctx = audio.context; - const destination = audio.getOutputDestinationNode(); - if (!ctx || !destination) return; - const sourceX = item.carrierId === state.player.id ? state.player.x : item.x; - const sourceY = item.carrierId === state.player.id ? state.player.y : item.y; - pianoSynth.noteOn( - code, - `local:${itemId}`, - playedMidi, - config.instrument, - config.voiceMode, - config.attack, - config.decay, - config.release, - config.brightness, - { audioCtx: ctx, destination }, - { x: sourceX - state.player.x, y: sourceY - state.player.y, range: config.emitRange }, - ); - signaling.send({ type: 'item_piano_note', itemId, keyId: code, midi: playedMidi, on: true }); + activePianoHeldOrder.push(code); + if (config.voiceMode === 'mono') { + const previousCode = activePianoMonophonicKey; + if (previousCode && previousCode !== code) { + const previousMidi = activePianoKeyMidi.get(previousCode); + pianoSynth.noteOff(previousCode); + if (Number.isFinite(previousMidi)) { + signaling.send({ type: 'item_piano_note', itemId, keyId: previousCode, midi: previousMidi, on: false }); + } + } + activePianoMonophonicKey = code; + } + playLocalPianoNote(item, itemId, code, playedMidi, config); } /** Handles effect menu list navigation and selection. */ @@ -2871,15 +2960,7 @@ function setupInputHandlers(): void { document.addEventListener('keyup', (event) => { const code = normalizeInputCode(event); if (state.mode === 'pianoUse' && code) { - if (activePianoKeys.delete(code)) { - pianoSynth.noteOff(code); - const itemId = activePianoItemId; - const midi = activePianoKeyMidi.get(code); - activePianoKeyMidi.delete(code); - if (itemId && Number.isFinite(midi)) { - signaling.send({ type: 'item_piano_note', itemId, keyId: code, midi, on: false }); - } - } + handlePianoUseModeKeyUp(code); } if (code) { state.keysPressed[code] = false; diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index a78303f..8ee4b35 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -199,6 +199,7 @@ export type OutgoingMessage = | { type: 'item_delete'; itemId: string } | { type: 'item_use'; itemId: string } | { type: 'item_piano_note'; itemId: string; keyId: string; midi: number; on: boolean } + | { type: 'item_piano_recording'; itemId: string; action: 'toggle_record' | 'playback' } | { type: 'item_update'; itemId: string; diff --git a/docs/controls.md b/docs/controls.md index ff6dd7c..e5bede5 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -81,6 +81,8 @@ Applies to effect select, user/item list modes, item selection, item property li - `W E T Y U O P ]`: Play sharps - Multiple keys can be held/played at once - `-` / `=`: Shift octave down/up +- `,`: Start/stop recording on this piano (max 30s) +- `.`: Play back saved recording on this piano - `Escape`: Exit piano mode ## Help Viewer Mode diff --git a/docs/item-schema.md b/docs/item-schema.md index babddf9..85a4330 100644 --- a/docs/item-schema.md +++ b/docs/item-schema.md @@ -183,6 +183,8 @@ - `release`: integer, range `0-100`, default `35`. - `brightness`: integer, range `0-100`, default `55`. - `emitRange`: integer, range `5-20`, default `15`. +- `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`). ## Packet Shapes diff --git a/docs/item-types.md b/docs/item-types.md index bc72030..d4edef0 100644 --- a/docs/item-types.md +++ b/docs/item-types.md @@ -173,6 +173,8 @@ This is behavior-focused documentation for item types and their defaults. ### Use - Announces that the user begins playing the piano (client enters piano key mode). +- Piano mode controls include `,` to start/stop recording (max 30s) and `.` to play saved recording. +- Recordings are stored on the item (server-authoritative), so nearby users hear playback. ### Validation - `instrument`: `piano | electric_piano | guitar | organ | bass | violin | synth_lead | brass | nintendo | drum_kit` diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index 39e01aa..cf80a6c 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -16,6 +16,7 @@ This is a behavior guide for packet semantics beyond raw schemas. - `ping`: latency measurement. - `item_add`, `item_pickup`, `item_drop`, `item_delete`, `item_use`, `item_update`: item actions. - `item_piano_note`: realtime piano note on/off for active piano use mode. +- `item_piano_recording`: piano record/playback control (`toggle_record`, `playback`). ## Server -> Client diff --git a/server/app/items/piano.py b/server/app/items/piano.py index 30d38cd..0c13e0c 100644 --- a/server/app/items/piano.py +++ b/server/app/items/piano.py @@ -105,6 +105,12 @@ 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") + next_params.pop("recording", None) + next_params.pop("recordingLengthMs", None) + instrument = str(next_params.get("instrument", "piano")).strip().lower() if instrument not in INSTRUMENT_OPTIONS: raise ValueError(f"instrument must be one of: {', '.join(INSTRUMENT_OPTIONS)}.") @@ -171,6 +177,11 @@ 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))) + return next_params diff --git a/server/app/models.py b/server/app/models.py index ac8c55d..82d72f9 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -75,6 +75,12 @@ class ItemPianoNotePacket(BasePacket): on: bool +class ItemPianoRecordingPacket(BasePacket): + type: Literal["item_piano_recording"] + itemId: str + action: Literal["toggle_record", "playback"] + + class ItemUpdatePacket(BasePacket): type: Literal["item_update"] itemId: str @@ -94,6 +100,7 @@ ClientPacket = ( | ItemDeletePacket | ItemUsePacket | ItemPianoNotePacket + | ItemPianoRecordingPacket | ItemUpdatePacket ) diff --git a/server/app/server.py b/server/app/server.py index b7eb4d4..48aa0de 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -11,6 +11,7 @@ import logging import os import re import ssl +import time import uuid from pathlib import Path from typing import Literal @@ -48,6 +49,7 @@ from .models import ( ItemDropPacket, ItemPianoNoteBroadcastPacket, ItemPianoNotePacket, + ItemPianoRecordingPacket, ItemPickupPacket, ItemRemovePacket, ItemUpdatePacket, @@ -69,6 +71,8 @@ LOGGER = logging.getLogger("chgrid.server") PACKET_LOGGER = logging.getLogger("chgrid.server.packet") CLIENT_PACKET_ADAPTER = TypeAdapter(ClientPacket) MAX_ACTIVE_PIANO_KEYS_PER_CLIENT = 12 +PIANO_RECORDING_MAX_MS = 30_000 +PIANO_RECORDING_MAX_EVENTS = 4096 class SignalingServer: @@ -94,6 +98,8 @@ class SignalingServer: self.item_service = ItemService(state_file=state_file) self.item_last_use_ms: dict[str, int] = {} self.active_piano_keys_by_client: dict[str, set[str]] = {} + self.piano_recording_state_by_item: dict[str, dict] = {} + self.piano_playback_tasks_by_item: dict[str, asyncio.Task[None]] = {} self.grid_size = max(1, grid_size) self.instance_id = str(uuid.uuid4()) self.server_version = self._resolve_server_version() @@ -164,6 +170,185 @@ class SignalingServer: return item.useSound.strip() return None + def _get_client_by_id(self, client_id: str) -> ClientConnection | None: + """Resolve one connected client by id.""" + + for connected in self.clients.values(): + if connected.id == client_id: + return connected + return None + + def _get_piano_source_position(self, item: WorldItem) -> tuple[int, int]: + """Resolve world position used for piano note spatial broadcasts.""" + + if item.carrierId: + carrier = self._get_client_by_id(item.carrierId) + if carrier is not None: + return carrier.x, carrier.y + return item.x, item.y + + async def _broadcast_item_piano_note( + self, + item: WorldItem, + *, + sender_id: str, + key_id: str, + midi: int, + on: bool, + exclude: ServerConnection | None = None, + ) -> None: + """Broadcast one piano note event using current item synth settings.""" + + instrument = str(item.params.get("instrument", "piano")).strip().lower() + voice_mode = str(item.params.get("voiceMode", "poly")).strip().lower() + if voice_mode not in {"poly", "mono"}: + voice_mode = "poly" + octave = int(item.params.get("octave", 0)) if isinstance(item.params.get("octave", 0), (int, float)) else 0 + attack = int(item.params.get("attack", 15)) if isinstance(item.params.get("attack", 15), (int, float)) else 15 + decay = int(item.params.get("decay", 45)) if isinstance(item.params.get("decay", 45), (int, float)) else 45 + release = int(item.params.get("release", 35)) if isinstance(item.params.get("release", 35), (int, float)) else 35 + brightness = int(item.params.get("brightness", 55)) if isinstance(item.params.get("brightness", 55), (int, float)) else 55 + emit_range = int(item.params.get("emitRange", 15)) if isinstance(item.params.get("emitRange", 15), (int, float)) else 15 + source_x, source_y = self._get_piano_source_position(item) + await self._broadcast( + ItemPianoNoteBroadcastPacket( + type="item_piano_note", + itemId=item.id, + senderId=sender_id, + keyId=key_id, + midi=max(0, min(127, int(midi))), + on=on, + instrument=instrument, + voiceMode=voice_mode, + octave=max(-2, min(2, octave)), + attack=max(0, min(100, attack)), + decay=max(0, min(100, decay)), + release=max(0, min(100, release)), + brightness=max(0, min(100, brightness)), + x=source_x, + y=source_y, + emitRange=max(5, min(20, emit_range)), + ), + exclude=exclude, + ) + + def _cancel_piano_playback(self, item_id: str) -> None: + """Cancel active playback task for one piano item, if any.""" + + task = self.piano_playback_tasks_by_item.pop(item_id, None) + if task is not None and not task.done(): + task.cancel() + + async def _finalize_piano_recording(self, item_id: str, *, status_message: str | None = None) -> None: + """Persist and broadcast one active recording session, then clear runtime state.""" + + session = self.piano_recording_state_by_item.pop(item_id, None) + if not session: + return + auto_stop_task = session.get("autoStopTask") + if isinstance(auto_stop_task, asyncio.Task) and not auto_stop_task.done(): + auto_stop_task.cancel() + item = self.items.get(item_id) + if not item or item.type != "piano": + return + now_monotonic = time.monotonic() + started = float(session.get("startedMonotonic", now_monotonic)) + elapsed_ms = int((now_monotonic - started) * 1000) + 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 + item.updatedAt = self.item_service.now_ms() + item.version += 1 + self.item_service.save_state() + await self._broadcast_item(item) + owner_id = str(session.get("ownerClientId", "")) + owner = self._get_client_by_id(owner_id) if owner_id else None + if owner and status_message: + await self._send_item_result(owner, True, "use", status_message, item.id) + + async def _auto_stop_piano_recording(self, item_id: str) -> None: + """Stop a recording automatically at the max recording duration.""" + + try: + await asyncio.sleep(PIANO_RECORDING_MAX_MS / 1000) + await self._finalize_piano_recording(item_id, status_message="Recording reached 30.0 s and was saved.") + except asyncio.CancelledError: + return + + async def _start_piano_playback(self, item: WorldItem) -> None: + """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 + 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, + } + ) + events.sort(key=lambda entry: int(entry["t"])) + if not events: + return + + active_keys: dict[str, int] = {} + previous_at_ms = 0 + try: + for event in events: + current_at_ms = int(event["t"]) + delay_ms = max(0, current_at_ms - previous_at_ms) + if delay_ms > 0: + await asyncio.sleep(delay_ms / 1000) + current_item = self.items.get(item.id) + if not current_item or current_item.type != "piano": + break + key_id = str(event["keyId"]) + midi = int(event["midi"]) + on = bool(event["on"]) + if on: + active_keys[key_id] = midi + else: + active_keys.pop(key_id, None) + await self._broadcast_item_piano_note( + current_item, + sender_id=sender_id, + key_id=key_id, + midi=midi, + on=on, + ) + previous_at_ms = current_at_ms + except asyncio.CancelledError: + pass + finally: + current_item = self.items.get(item.id) + if current_item and current_item.type == "piano": + for key_id, midi in list(active_keys.items()): + await self._broadcast_item_piano_note( + current_item, + sender_id=sender_id, + key_id=key_id, + midi=midi, + on=False, + ) + current_task = self.piano_playback_tasks_by_item.get(item.id) + if current_task is asyncio.current_task(): + self.piano_playback_tasks_by_item.pop(item.id, None) + def _is_in_bounds(self, x: int, y: int) -> bool: """Check whether a coordinate is inside server-authoritative world bounds.""" @@ -263,6 +448,10 @@ class SignalingServer: if websocket in self.clients: disconnected = self.clients.pop(websocket) self.active_piano_keys_by_client.pop(disconnected.id, None) + for item_id, session in list(self.piano_recording_state_by_item.items()): + if session.get("ownerClientId") != disconnected.id: + continue + await self._finalize_piano_recording(item_id) for item in self.item_service.drop_carried_items_for_disconnect(disconnected): await self._broadcast_item(item) self.item_service.save_state() @@ -589,6 +778,12 @@ class SignalingServer: item.type, item.title, ) + self._cancel_piano_playback(item.id) + recording_state = self.piano_recording_state_by_item.pop(item.id, None) + if recording_state is not None: + auto_stop_task = recording_state.get("autoStopTask") + if isinstance(auto_stop_task, asyncio.Task) and not auto_stop_task.done(): + auto_stop_task.cancel() 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)) @@ -676,41 +871,72 @@ class SignalingServer: active_keys.add(packet.keyId) else: active_keys.discard(packet.keyId) - instrument = str(item.params.get("instrument", "piano")).strip().lower() - voice_mode = str(item.params.get("voiceMode", "poly")).strip().lower() - if voice_mode not in {"poly", "mono"}: - voice_mode = "poly" - octave = int(item.params.get("octave", 0)) if isinstance(item.params.get("octave", 0), (int, float)) else 0 - attack = int(item.params.get("attack", 15)) if isinstance(item.params.get("attack", 15), (int, float)) else 15 - decay = int(item.params.get("decay", 45)) if isinstance(item.params.get("decay", 45), (int, float)) else 45 - release = int(item.params.get("release", 35)) if isinstance(item.params.get("release", 35), (int, float)) else 35 - brightness = int(item.params.get("brightness", 55)) if isinstance(item.params.get("brightness", 55), (int, float)) else 55 - emit_range = int(item.params.get("emitRange", 15)) if isinstance(item.params.get("emitRange", 15), (int, float)) else 15 - source_x = client.x if item.carrierId == client.id else item.x - source_y = client.y if item.carrierId == client.id else item.y - await self._broadcast( - ItemPianoNoteBroadcastPacket( - type="item_piano_note", - itemId=item.id, - senderId=client.id, - keyId=packet.keyId, - midi=packet.midi, - on=packet.on, - instrument=instrument, - voiceMode=voice_mode, - octave=max(-2, min(2, octave)), - attack=max(0, min(100, attack)), - decay=max(0, min(100, decay)), - release=max(0, min(100, release)), - brightness=max(0, min(100, brightness)), - x=source_x, - y=source_y, - emitRange=max(5, min(20, emit_range)), - ), + recording_state = self.piano_recording_state_by_item.get(item.id) + if recording_state and recording_state.get("ownerClientId") == client.id: + started = float(recording_state.get("startedMonotonic", time.monotonic())) + elapsed_ms = max(0, min(PIANO_RECORDING_MAX_MS, int((time.monotonic() - started) * 1000))) + events = recording_state.get("events") + if isinstance(events, list) and len(events) < PIANO_RECORDING_MAX_EVENTS: + events.append({"t": elapsed_ms, "keyId": packet.keyId[:32], "midi": packet.midi, "on": packet.on}) + if elapsed_ms >= PIANO_RECORDING_MAX_MS: + await self._finalize_piano_recording(item.id, status_message="Recording reached 30.0 s and was saved.") + await self._broadcast_item_piano_note( + item, + sender_id=client.id, + key_id=packet.keyId, + midi=packet.midi, + on=packet.on, exclude=client.websocket, ) return + if isinstance(packet, ItemPianoRecordingPacket): + item = self.items.get(packet.itemId) + if not item or item.type != "piano": + await self._send_item_result(client, False, "use", "Piano not found.") + return + if item.carrierId not in (None, client.id): + await self._send_item_result(client, False, "use", "Piano is not available.", item.id) + return + if item.carrierId is None and (item.x != client.x or item.y != client.y): + await self._send_item_result(client, False, "use", "Piano is not on your square.", item.id) + return + + if packet.action == "toggle_record": + existing = self.piano_recording_state_by_item.get(item.id) + if existing and existing.get("ownerClientId") != client.id: + await self._send_item_result(client, False, "use", "This piano is already recording.", item.id) + return + if existing and existing.get("ownerClientId") == client.id: + await self._finalize_piano_recording(item.id, status_message="Recording saved.") + return + self._cancel_piano_playback(item.id) + recording_state = { + "ownerClientId": client.id, + "startedMonotonic": time.monotonic(), + "events": [], + } + self.piano_recording_state_by_item[item.id] = recording_state + auto_stop_task = asyncio.create_task(self._auto_stop_piano_recording(item.id)) + recording_state["autoStopTask"] = auto_stop_task + await self._send_item_result(client, True, "use", "Recording started. Press comma again to stop.", item.id) + return + + if packet.action == "playback": + 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 + recording = item.params.get("recording") + if not isinstance(recording, list) or not recording: + await self._send_item_result(client, False, "use", "No recording saved on this piano.", item.id) + return + self._cancel_piano_playback(item.id) + playback_task = asyncio.create_task(self._start_piano_playback(item)) + self.piano_playback_tasks_by_item[item.id] = playback_task + await self._send_item_result(client, True, "use", f"Playing recording on {item.title}.", item.id) + return + return + if isinstance(packet, ItemUpdatePacket): item = self.items.get(packet.itemId) if not item: diff --git a/server/tests/test_item_use_cooldown.py b/server/tests/test_item_use_cooldown.py index 7b3cb9d..543bff3 100644 --- a/server/tests/test_item_use_cooldown.py +++ b/server/tests/test_item_use_cooldown.py @@ -507,3 +507,87 @@ async def test_piano_note_key_cap(monkeypatch: pytest.MonkeyPatch) -> None: json.dumps({"type": "item_piano_note", "itemId": item.id, "keyId": "KeyOverflow", "midi": 60, "on": True}), ) assert len(broadcast_payloads) == 12 + + +@pytest.mark.asyncio +async def test_piano_recording_toggle_and_save(monkeypatch: pytest.MonkeyPatch) -> None: + server = SignalingServer("127.0.0.1", 8765, None, None) + ws = _fake_ws() + client = ClientConnection(websocket=ws, id="u1", nickname="tester", x=5, y=6) + server.clients[ws] = client + item = server.item_service.default_item(client, "piano") + server.item_service.add_item(item) + + send_payloads: list[object] = [] + + async def fake_send(websocket: ServerConnection, packet: object) -> None: + send_payloads.append(packet) + + async def fake_broadcast(packet: object, exclude: ServerConnection | None = None) -> None: + return + + monkeypatch.setattr(server, "_send", fake_send) + monkeypatch.setattr(server, "_broadcast", fake_broadcast) + + await server._handle_message( + client, + json.dumps({"type": "item_piano_recording", "itemId": item.id, "action": "toggle_record"}), + ) + assert send_payloads[-1].ok is True + assert item.id in server.piano_recording_state_by_item + + await server._handle_message( + client, + json.dumps({"type": "item_piano_note", "itemId": item.id, "keyId": "KeyA", "midi": 60, "on": True}), + ) + await server._handle_message( + client, + json.dumps({"type": "item_piano_note", "itemId": item.id, "keyId": "KeyA", "midi": 60, "on": False}), + ) + await server._handle_message( + client, + json.dumps({"type": "item_piano_recording", "itemId": item.id, "action": "toggle_record"}), + ) + 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" + + +@pytest.mark.asyncio +async def test_piano_playback_starts_task(monkeypatch: pytest.MonkeyPatch) -> None: + server = SignalingServer("127.0.0.1", 8765, None, None) + ws = _fake_ws() + 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}] + server.item_service.add_item(item) + + send_payloads: list[object] = [] + playback_started: list[str] = [] + + async def fake_send(websocket: ServerConnection, packet: object) -> None: + send_payloads.append(packet) + + async def fake_broadcast(packet: object, exclude: ServerConnection | None = None) -> None: + return + + async def fake_start_playback(current_item) -> None: + playback_started.append(current_item.id) + + monkeypatch.setattr(server, "_send", fake_send) + monkeypatch.setattr(server, "_broadcast", fake_broadcast) + monkeypatch.setattr(server, "_start_piano_playback", fake_start_playback) + + await server._handle_message( + client, + json.dumps({"type": "item_piano_recording", "itemId": item.id, "action": "playback"}), + ) + assert send_payloads[-1].ok is True + task = server.piano_playback_tasks_by_item.get(item.id) + assert task is not None + await task + assert playback_started == [item.id] diff --git a/server/tests/test_models.py b/server/tests/test_models.py index 43c27b1..238aa81 100644 --- a/server/tests/test_models.py +++ b/server/tests/test_models.py @@ -22,3 +22,9 @@ def test_item_add_accepts_piano_type() -> None: adapter = TypeAdapter(ClientPacket) packet = adapter.validate_python({"type": "item_add", "itemType": "piano"}) assert packet.type == "item_add" + + +def test_item_piano_recording_packet_validates() -> None: + adapter = TypeAdapter(ClientPacket) + packet = adapter.validate_python({"type": "item_piano_recording", "itemId": "p1", "action": "toggle_record"}) + assert packet.type == "item_piano_recording"