From ccbe41e61888e62f7afb4b38baceaf690c41ae3b Mon Sep 17 00:00:00 2001 From: Jage9 Date: Mon, 23 Feb 2026 02:00:01 -0500 Subject: [PATCH] Refine piano recording controls and stop behavior --- client/public/help.json | 2 +- client/public/piano.json | 8 ++-- client/public/version.js | 2 +- client/src/main.ts | 33 ++++++++++++++ client/src/network/messageHandlers.ts | 2 + client/src/network/protocol.ts | 2 +- docs/controls.md | 8 ++-- server/app/models.py | 2 +- server/app/server.py | 59 +++++++++++++++++++++----- server/tests/test_item_use_cooldown.py | 9 ++++ server/tests/test_models.py | 2 + 11 files changed, 106 insertions(+), 23 deletions(-) diff --git a/client/public/help.json b/client/public/help.json index bdf9e9c..c4e754c 100644 --- a/client/public/help.json +++ b/client/public/help.json @@ -87,7 +87,7 @@ }, { "keys": "Piano mode", - "description": "When using a piano: press question mark for piano help. 1-9 (and 0 for the 10th slot) changes instrument, -/= changes octave, ASDFGHJKL;' plays C major notes, WETYUOP] plays sharps, Z starts/stops recording, X plays recording, Enter plays demo, C stops demo/playback, Escape exits" + "description": "When using a piano: press question mark for piano help. 1-9 (and 0 for the 10th slot) changes instrument, -/= changes octave, ASDFGHJKL;' plays C major notes, WETYUOP] plays sharps, Z starts/pauses/resumes recording, X plays recording, Enter plays demo, C stops demo/playback/recording, Escape exits" } ] }, diff --git a/client/public/piano.json b/client/public/piano.json index 3fea0c2..005422f 100644 --- a/client/public/piano.json +++ b/client/public/piano.json @@ -21,19 +21,19 @@ }, { "keys": "Z", - "description": "Start or stop recording (up to 30 seconds)." + "description": "Start, pause, or resume recording (up to 30 seconds of recorded time)." }, { "keys": "X", - "description": "Play the saved recording." + "description": "Play the saved recording. Stops demo if demo is active." }, { "keys": "Enter", - "description": "Play demo melody. Press Enter again to restart it." + "description": "Play demo melody. Press Enter again to restart it. Stops recording playback first." }, { "keys": "C", - "description": "Stop demo and recording playback." + "description": "Stop demo, recording playback, and active recording." }, { "keys": "Escape", diff --git a/client/public/version.js b/client/public/version.js index d01df17..fd3ee13 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 R219"; +window.CHGRID_WEB_VERSION = "2026.02.23 R220"; // 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 3e41bbb..5c9b076 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -297,6 +297,7 @@ const activePianoDemoTimeoutIds: number[] = []; const activePianoDemoNotes = new Map(); const pianoDemoSongs = new Map(); let pianoDemoDefaultSongId = ''; +let activePianoRecordingState: 'idle' | 'recording' | 'paused' = 'idle'; const activeRemotePianoKeys = new Set(); let pianoPreviewTimeoutId: number | null = null; let activeTeleport: @@ -1034,6 +1035,7 @@ async function startPianoUseMode(itemId: string): Promise { activePianoKeyMidi.clear(); activePianoHeldOrder.length = 0; activePianoMonophonicKey = null; + activePianoRecordingState = 'idle'; state.mode = 'pianoUse'; await audio.ensureContext(); updateStatus(`using ${item.title}, press question mark for help.`); @@ -1056,6 +1058,7 @@ function stopPianoUseMode(announce = true): void { activePianoKeyMidi.clear(); activePianoHeldOrder.length = 0; activePianoMonophonicKey = null; + activePianoRecordingState = 'idle'; state.mode = 'normal'; if (announce) { updateStatus('Stopped piano.'); @@ -2136,6 +2139,22 @@ async function onSignalingMessage(message: IncomingMessage): Promise { startHeartbeat(); } await onAppMessage(message); + if ( + message.type === 'item_action_result' && + message.ok && + message.action === 'use' && + typeof message.itemId === 'string' && + activePianoItemId && + message.itemId === activePianoItemId + ) { + if (message.message === 'record' || message.message === 'resume') { + activePianoRecordingState = 'recording'; + } else if (message.message === 'pause') { + activePianoRecordingState = 'paused'; + } else if (message.message === 'stop') { + activePianoRecordingState = 'idle'; + } + } if ( message.type === 'item_action_result' && message.ok && @@ -2645,6 +2664,12 @@ function handlePianoUseModeInput(code: string): void { return; } if (code === 'Enter') { + if (activePianoRecordingState !== 'idle') { + updateStatus('Stop or pause recording first.'); + audio.sfxUiCancel(); + return; + } + signaling.send({ type: 'item_piano_recording', itemId, action: 'stop_playback' }); startPianoDemo(item, itemId); updateStatus('demo play'); audio.sfxUiBlip(); @@ -2655,12 +2680,20 @@ function handlePianoUseModeInput(code: string): void { return; } if (code === 'KeyX') { + if (activePianoRecordingState !== 'idle') { + updateStatus('Stop or pause recording first.'); + audio.sfxUiCancel(); + return; + } + stopPianoDemo(true); signaling.send({ type: 'item_piano_recording', itemId, action: 'playback' }); return; } if (code === 'KeyC') { stopPianoDemo(true); signaling.send({ type: 'item_piano_recording', itemId, action: 'stop_playback' }); + signaling.send({ type: 'item_piano_recording', itemId, action: 'stop_record' }); + activePianoRecordingState = 'idle'; return; } if (code === 'Equal' || code === 'Minus') { diff --git a/client/src/network/messageHandlers.ts b/client/src/network/messageHandlers.ts index d43067b..fd1485c 100644 --- a/client/src/network/messageHandlers.ts +++ b/client/src/network/messageHandlers.ts @@ -243,6 +243,8 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco case 'item_action_result': { const pianoStatusMessages = new Set([ 'record', + 'pause', + 'resume', 'play', 'stop', 'No recording saved on this piano.', diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index b9b333f..6b45a6a 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -199,7 +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' | 'stop_playback' } + | { type: 'item_piano_recording'; itemId: string; action: 'toggle_record' | 'playback' | 'stop_playback' | 'stop_record' } | { type: 'item_update'; itemId: string; diff --git a/docs/controls.md b/docs/controls.md index 6b5aca4..f8c6ef8 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -82,10 +82,10 @@ Applies to effect select, user/item list modes, item selection, item property li - Multiple keys can be held/played at once - `?`: Open piano-mode help viewer - `-` / `=`: Shift octave down/up -- `Z`: Start/stop recording on this piano (max 30s) -- `X`: Play back saved recording on this piano -- `Enter`: Play demo melody (press again to restart) -- `C`: Stop demo/playback on this piano +- `Z`: Start, pause, or resume recording on this piano (max 30s recorded time) +- `X`: Play back saved recording on this piano (stops demo first) +- `Enter`: Play demo melody (press again to restart; stops recording playback first) +- `C`: Stop demo, recording playback, and active recording - `Escape`: Exit piano mode ## Help Viewer Mode diff --git a/server/app/models.py b/server/app/models.py index 8e59364..f40c245 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -78,7 +78,7 @@ class ItemPianoNotePacket(BasePacket): class ItemPianoRecordingPacket(BasePacket): type: Literal["item_piano_recording"] itemId: str - action: Literal["toggle_record", "playback", "stop_playback"] + action: Literal["toggle_record", "playback", "stop_playback", "stop_record"] class ItemUpdatePacket(BasePacket): diff --git a/server/app/server.py b/server/app/server.py index 6081e51..ed46fb8 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -276,6 +276,20 @@ class SignalingServer: if task is not None and not task.done(): task.cancel() + @staticmethod + def _recording_elapsed_ms(session: dict, now_monotonic: float | None = None) -> int: + """Compute effective recorded duration, including currently active segment.""" + + elapsed_ms = int(session.get("elapsedMs", 0)) if isinstance(session.get("elapsedMs"), (int, float)) else 0 + paused = session.get("paused") is True + if paused: + return max(0, elapsed_ms) + last_resume = session.get("lastResumeMonotonic") + if isinstance(last_resume, (int, float)): + now_value = now_monotonic if isinstance(now_monotonic, (int, float)) else time.monotonic() + elapsed_ms += max(0, int((now_value - float(last_resume)) * 1000)) + return max(0, elapsed_ms) + 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.""" @@ -288,10 +302,7 @@ class SignalingServer: 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)) + elapsed_ms = max(0, min(PIANO_RECORDING_MAX_MS, self._recording_elapsed_ms(session))) recorded_events = session.get("events") events = list(recorded_events) if isinstance(recorded_events, list) else [] song_id = f"item:{item.id}:recording" @@ -372,8 +383,14 @@ class SignalingServer: """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="stop") + while True: + session = self.piano_recording_state_by_item.get(item_id) + if not isinstance(session, dict): + return + if self._recording_elapsed_ms(session) >= PIANO_RECORDING_MAX_MS: + await self._finalize_piano_recording(item_id, status_message="stop") + return + await asyncio.sleep(0.25) except asyncio.CancelledError: return @@ -1036,9 +1053,8 @@ class SignalingServer: else: active_keys.discard(packet.keyId) 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))) + if recording_state and recording_state.get("ownerClientId") == client.id and recording_state.get("paused") is not True: + elapsed_ms = max(0, min(PIANO_RECORDING_MAX_MS, self._recording_elapsed_ms(recording_state))) events = recording_state.get("events") if isinstance(events, list) and len(events) < PIANO_RECORDING_MAX_EVENTS: instrument = str(item.params.get("instrument", "piano")).strip().lower() @@ -1095,12 +1111,22 @@ class SignalingServer: 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="stop") + if existing.get("paused") is True: + existing["paused"] = False + existing["lastResumeMonotonic"] = time.monotonic() + await self._send_item_result(client, True, "use", "resume", item.id) + else: + existing["elapsedMs"] = self._recording_elapsed_ms(existing) + existing["paused"] = True + existing.pop("lastResumeMonotonic", None) + await self._send_item_result(client, True, "use", "pause", item.id) return self._cancel_piano_playback(item.id) recording_state = { "ownerClientId": client.id, - "startedMonotonic": time.monotonic(), + "elapsedMs": 0, + "paused": False, + "lastResumeMonotonic": time.monotonic(), "events": [], } self.piano_recording_state_by_item[item.id] = recording_state @@ -1109,6 +1135,17 @@ class SignalingServer: await self._send_item_result(client, True, "use", "record", item.id) return + if packet.action == "stop_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="stop") + return + await self._send_item_result(client, True, "use", "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) diff --git a/server/tests/test_item_use_cooldown.py b/server/tests/test_item_use_cooldown.py index c3b9e52..1d8e9cb 100644 --- a/server/tests/test_item_use_cooldown.py +++ b/server/tests/test_item_use_cooldown.py @@ -549,6 +549,15 @@ async def test_piano_recording_toggle_and_save(monkeypatch: pytest.MonkeyPatch) json.dumps({"type": "item_piano_recording", "itemId": item.id, "action": "toggle_record"}), ) assert send_payloads[-1].ok is True + assert send_payloads[-1].message == "pause" + assert item.id in server.piano_recording_state_by_item + + await server._handle_message( + client, + json.dumps({"type": "item_piano_recording", "itemId": item.id, "action": "stop_record"}), + ) + assert send_payloads[-1].ok is True + assert send_payloads[-1].message == "stop" assert item.id not in server.piano_recording_state_by_item song_id = item.params.get("songId") assert isinstance(song_id, str) diff --git a/server/tests/test_models.py b/server/tests/test_models.py index 238aa81..177c205 100644 --- a/server/tests/test_models.py +++ b/server/tests/test_models.py @@ -28,3 +28,5 @@ 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" + stop_packet = adapter.validate_python({"type": "item_piano_recording", "itemId": "p1", "action": "stop_record"}) + assert stop_packet.type == "item_piano_recording"