From 9477beb3457651947015ac904d5d7a1059b7d41d Mon Sep 17 00:00:00 2001 From: Jage9 Date: Mon, 23 Feb 2026 00:45:17 -0500 Subject: [PATCH] Fix piano playback controls and recorded settings --- client/public/help.json | 2 +- client/public/version.js | 2 +- client/src/main.ts | 19 ++++- client/src/network/messageHandlers.ts | 7 ++ client/src/network/protocol.ts | 2 +- docs/controls.md | 1 + docs/protocol-notes.md | 2 +- server/app/models.py | 2 +- server/app/server.py | 112 +++++++++++++++++++++++--- 9 files changed, 127 insertions(+), 22 deletions(-) diff --git a/client/public/help.json b/client/public/help.json index 8f76e82..88d5d98 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, comma starts/stops recording, period plays recording, 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, slash stops playback, Escape exits" } ] }, diff --git a/client/public/version.js b/client/public/version.js index 709f3a6..1778e0d 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 R205"; +window.CHGRID_WEB_VERSION = "2026.02.22 R206"; // 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 da9877f..a15d1aa 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -995,7 +995,7 @@ function handlePianoUseModeKeyUp(code: string): void { /** Plays one short C4 preview using the piano item's current/overridden envelope+instrument. */ async function previewPianoSettingChange( item: WorldItem, - overrides: Partial<{ instrument: PianoInstrumentId; attack: number; decay: number; release: number; brightness: number }>, + overrides: Partial<{ instrument: PianoInstrumentId; octave: number; attack: number; decay: number; release: number; brightness: number }>, ): Promise { if (item.type !== 'piano') return; await audio.ensureContext(); @@ -1004,6 +1004,7 @@ async function previewPianoSettingChange( if (!ctx || !destination) return; const current = getPianoParams(item); const instrument = overrides.instrument ?? current.instrument; + const octave = Math.max(-2, Math.min(2, Math.round(overrides.octave ?? current.octave))); const attack = Math.max(0, Math.min(100, Math.round(overrides.attack ?? current.attack))); const decay = Math.max(0, Math.min(100, Math.round(overrides.decay ?? current.decay))); const release = Math.max(0, Math.min(100, Math.round(overrides.release ?? current.release))); @@ -1015,7 +1016,7 @@ async function previewPianoSettingChange( pianoSynth.noteOn( previewKeyId, 'preview', - 60, + Math.max(0, Math.min(127, 60 + octave * 12)), instrument, 'poly', attack, @@ -2423,12 +2424,17 @@ function handlePianoUseModeInput(code: string): void { audio.sfxUiBlip(); return; } + if (code === 'Slash') { + signaling.send({ type: 'item_piano_recording', itemId, action: 'stop_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))); item.params.octave = next; signaling.send({ type: 'item_update', itemId, params: { octave: next } }); - void previewPianoSettingChange(item, {}); + void previewPianoSettingChange(item, { octave: next }); updateStatus(`octave ${next}.`); if (next === current) { audio.sfxUiCancel(); @@ -2462,6 +2468,7 @@ function handlePianoUseModeInput(code: string): void { }); void previewPianoSettingChange(item, { instrument, + octave, attack: defaults.attack, decay: defaults.decay, release: defaults.release, @@ -2749,8 +2756,10 @@ const itemPropertyEditor = createItemPropertyEditor({ if (key === 'instrument') { const instrument = normalizePianoInstrument(value); const defaults = DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT[instrument]; + const octave = defaultsOctaveForInstrument(instrument); void previewPianoSettingChange(item, { instrument, + octave, attack: defaults.attack, decay: defaults.decay, release: defaults.release, @@ -2783,7 +2792,9 @@ const itemPropertyEditor = createItemPropertyEditor({ return; } if (key === 'octave') { - void previewPianoSettingChange(item, {}); + const octave = Number(value); + if (!Number.isFinite(octave)) return; + void previewPianoSettingChange(item, { octave }); } }, updateStatus, diff --git a/client/src/network/messageHandlers.ts b/client/src/network/messageHandlers.ts index c23307c..97ac18b 100644 --- a/client/src/network/messageHandlers.ts +++ b/client/src/network/messageHandlers.ts @@ -243,6 +243,13 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco case 'item_action_result': { if (message.ok) { if (message.action === 'use') { + const pianoStatusLabel = + message.message === 'record' || message.message === 'play' || message.message === 'stop' ? message.message : null; + if (pianoStatusLabel) { + deps.updateStatus(pianoStatusLabel); + deps.audioUiBlip(); + break; + } deps.pushChatMessage(message.message); const item = message.itemId ? deps.getItemById(message.itemId) : null; if (!item?.useSound && item && item.type !== 'piano') { diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index 8ee4b35..b9b333f 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' } + | { type: 'item_piano_recording'; itemId: string; action: 'toggle_record' | 'playback' | 'stop_playback' } | { type: 'item_update'; itemId: string; diff --git a/docs/controls.md b/docs/controls.md index e5bede5..aa1f4b7 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -83,6 +83,7 @@ Applies to effect select, user/item list modes, item selection, item property li - `-` / `=`: Shift octave down/up - `,`: Start/stop recording on this piano (max 30s) - `.`: Play back saved recording on this piano +- `/`: Stop playback on this piano - `Escape`: Exit piano mode ## Help Viewer Mode diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index cf80a6c..53435f1 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -16,7 +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`). +- `item_piano_recording`: piano record/playback control (`toggle_record`, `playback`, `stop_playback`). ## Server -> Client diff --git a/server/app/models.py b/server/app/models.py index 82d72f9..8e59364 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"] + action: Literal["toggle_record", "playback", "stop_playback"] class ItemUpdatePacket(BasePacket): diff --git a/server/app/server.py b/server/app/server.py index 48aa0de..2c27938 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -195,20 +195,57 @@ class SignalingServer: key_id: str, midi: int, on: bool, + instrument_override: str | None = None, + voice_mode_override: str | None = None, + attack_override: int | None = None, + decay_override: int | None = None, + release_override: int | None = None, + brightness_override: int | None = None, + emit_range_override: int | None = None, 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() + instrument = (instrument_override if isinstance(instrument_override, str) else str(item.params.get("instrument", "piano"))).strip().lower() + voice_mode = (voice_mode_override if isinstance(voice_mode_override, str) else 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 + attack = ( + int(attack_override) + if isinstance(attack_override, int) + else int(item.params.get("attack", 15)) + if isinstance(item.params.get("attack", 15), (int, float)) + else 15 + ) + decay = ( + int(decay_override) + if isinstance(decay_override, int) + else int(item.params.get("decay", 45)) + if isinstance(item.params.get("decay", 45), (int, float)) + else 45 + ) + release = ( + int(release_override) + if isinstance(release_override, int) + else int(item.params.get("release", 35)) + if isinstance(item.params.get("release", 35), (int, float)) + else 35 + ) + brightness = ( + int(brightness_override) + if isinstance(brightness_override, int) + else int(item.params.get("brightness", 55)) + if isinstance(item.params.get("brightness", 55), (int, float)) + else 55 + ) + emit_range = ( + int(emit_range_override) + if isinstance(emit_range_override, int) + else 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( @@ -273,7 +310,7 @@ class SignalingServer: 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.") + await self._finalize_piano_recording(item_id, status_message="stop") except asyncio.CancelledError: return @@ -294,12 +331,26 @@ class SignalingServer: 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"])) @@ -330,6 +381,13 @@ class SignalingServer: key_id=key_id, midi=midi, on=on, + instrument_override=event.get("instrument") if isinstance(event.get("instrument"), str) else None, + voice_mode_override=event.get("voiceMode") if isinstance(event.get("voiceMode"), str) else None, + attack_override=event.get("attack") if isinstance(event.get("attack"), int) else None, + decay_override=event.get("decay") if isinstance(event.get("decay"), int) else None, + release_override=event.get("release") if isinstance(event.get("release"), int) else None, + brightness_override=event.get("brightness") if isinstance(event.get("brightness"), int) else None, + emit_range_override=event.get("emitRange") if isinstance(event.get("emitRange"), int) else None, ) previous_at_ms = current_at_ms except asyncio.CancelledError: @@ -877,9 +935,32 @@ class SignalingServer: 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}) + 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" + 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 + events.append( + { + "t": elapsed_ms, + "keyId": packet.keyId[:32], + "midi": packet.midi, + "on": packet.on, + "instrument": instrument, + "voiceMode": voice_mode, + "attack": max(0, min(100, attack)), + "decay": max(0, min(100, decay)), + "release": max(0, min(100, release)), + "brightness": max(0, min(100, brightness)), + "emitRange": max(5, min(20, emit_range)), + } + ) 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._finalize_piano_recording(item.id, status_message="stop") await self._broadcast_item_piano_note( item, sender_id=client.id, @@ -908,7 +989,7 @@ 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="Recording saved.") + await self._finalize_piano_recording(item.id, status_message="stop") return self._cancel_piano_playback(item.id) recording_state = { @@ -919,7 +1000,7 @@ class SignalingServer: 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) + await self._send_item_result(client, True, "use", "record", item.id) return if packet.action == "playback": @@ -933,7 +1014,12 @@ class SignalingServer: 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) + await self._send_item_result(client, True, "use", "play", item.id) + return + + if packet.action == "stop_playback": + self._cancel_piano_playback(item.id) + await self._send_item_result(client, True, "use", "stop", item.id) return return