Refine piano recording controls and stop behavior
This commit is contained in:
@@ -87,7 +87,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys": "Piano mode",
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,19 +21,19 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys": "Z",
|
"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",
|
"keys": "X",
|
||||||
"description": "Play the saved recording."
|
"description": "Play the saved recording. Stops demo if demo is active."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys": "Enter",
|
"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",
|
"keys": "C",
|
||||||
"description": "Stop demo and recording playback."
|
"description": "Stop demo, recording playback, and active recording."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys": "Escape",
|
"keys": "Escape",
|
||||||
|
|||||||
@@ -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 R219";
|
window.CHGRID_WEB_VERSION = "2026.02.23 R220";
|
||||||
// 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";
|
||||||
|
|||||||
@@ -297,6 +297,7 @@ const activePianoDemoTimeoutIds: number[] = [];
|
|||||||
const activePianoDemoNotes = new Map<string, { runtimeKey: string; midi: number }>();
|
const activePianoDemoNotes = new Map<string, { runtimeKey: string; midi: number }>();
|
||||||
const pianoDemoSongs = new Map<string, PianoDemoSong>();
|
const pianoDemoSongs = new Map<string, PianoDemoSong>();
|
||||||
let pianoDemoDefaultSongId = '';
|
let pianoDemoDefaultSongId = '';
|
||||||
|
let activePianoRecordingState: 'idle' | 'recording' | 'paused' = 'idle';
|
||||||
const activeRemotePianoKeys = new Set<string>();
|
const activeRemotePianoKeys = new Set<string>();
|
||||||
let pianoPreviewTimeoutId: number | null = null;
|
let pianoPreviewTimeoutId: number | null = null;
|
||||||
let activeTeleport:
|
let activeTeleport:
|
||||||
@@ -1034,6 +1035,7 @@ async function startPianoUseMode(itemId: string): Promise<void> {
|
|||||||
activePianoKeyMidi.clear();
|
activePianoKeyMidi.clear();
|
||||||
activePianoHeldOrder.length = 0;
|
activePianoHeldOrder.length = 0;
|
||||||
activePianoMonophonicKey = null;
|
activePianoMonophonicKey = null;
|
||||||
|
activePianoRecordingState = 'idle';
|
||||||
state.mode = 'pianoUse';
|
state.mode = 'pianoUse';
|
||||||
await audio.ensureContext();
|
await audio.ensureContext();
|
||||||
updateStatus(`using ${item.title}, press question mark for help.`);
|
updateStatus(`using ${item.title}, press question mark for help.`);
|
||||||
@@ -1056,6 +1058,7 @@ function stopPianoUseMode(announce = true): void {
|
|||||||
activePianoKeyMidi.clear();
|
activePianoKeyMidi.clear();
|
||||||
activePianoHeldOrder.length = 0;
|
activePianoHeldOrder.length = 0;
|
||||||
activePianoMonophonicKey = null;
|
activePianoMonophonicKey = null;
|
||||||
|
activePianoRecordingState = 'idle';
|
||||||
state.mode = 'normal';
|
state.mode = 'normal';
|
||||||
if (announce) {
|
if (announce) {
|
||||||
updateStatus('Stopped piano.');
|
updateStatus('Stopped piano.');
|
||||||
@@ -2136,6 +2139,22 @@ async function onSignalingMessage(message: IncomingMessage): Promise<void> {
|
|||||||
startHeartbeat();
|
startHeartbeat();
|
||||||
}
|
}
|
||||||
await onAppMessage(message);
|
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 (
|
if (
|
||||||
message.type === 'item_action_result' &&
|
message.type === 'item_action_result' &&
|
||||||
message.ok &&
|
message.ok &&
|
||||||
@@ -2645,6 +2664,12 @@ function handlePianoUseModeInput(code: string): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (code === 'Enter') {
|
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);
|
startPianoDemo(item, itemId);
|
||||||
updateStatus('demo play');
|
updateStatus('demo play');
|
||||||
audio.sfxUiBlip();
|
audio.sfxUiBlip();
|
||||||
@@ -2655,12 +2680,20 @@ function handlePianoUseModeInput(code: string): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (code === 'KeyX') {
|
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' });
|
signaling.send({ type: 'item_piano_recording', itemId, action: 'playback' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (code === 'KeyC') {
|
if (code === 'KeyC') {
|
||||||
stopPianoDemo(true);
|
stopPianoDemo(true);
|
||||||
signaling.send({ type: 'item_piano_recording', itemId, action: 'stop_playback' });
|
signaling.send({ type: 'item_piano_recording', itemId, action: 'stop_playback' });
|
||||||
|
signaling.send({ type: 'item_piano_recording', itemId, action: 'stop_record' });
|
||||||
|
activePianoRecordingState = 'idle';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (code === 'Equal' || code === 'Minus') {
|
if (code === 'Equal' || code === 'Minus') {
|
||||||
|
|||||||
@@ -243,6 +243,8 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
|
|||||||
case 'item_action_result': {
|
case 'item_action_result': {
|
||||||
const pianoStatusMessages = new Set([
|
const pianoStatusMessages = new Set([
|
||||||
'record',
|
'record',
|
||||||
|
'pause',
|
||||||
|
'resume',
|
||||||
'play',
|
'play',
|
||||||
'stop',
|
'stop',
|
||||||
'No recording saved on this piano.',
|
'No recording saved on this piano.',
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ export type OutgoingMessage =
|
|||||||
| { type: 'item_delete'; itemId: string }
|
| { type: 'item_delete'; itemId: string }
|
||||||
| { type: 'item_use'; itemId: string }
|
| { type: 'item_use'; itemId: string }
|
||||||
| { type: 'item_piano_note'; itemId: string; keyId: string; midi: number; on: boolean }
|
| { 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';
|
type: 'item_update';
|
||||||
itemId: string;
|
itemId: string;
|
||||||
|
|||||||
@@ -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
|
- Multiple keys can be held/played at once
|
||||||
- `?`: Open piano-mode help viewer
|
- `?`: Open piano-mode help viewer
|
||||||
- `-` / `=`: Shift octave down/up
|
- `-` / `=`: Shift octave down/up
|
||||||
- `Z`: Start/stop recording on this piano (max 30s)
|
- `Z`: Start, pause, or resume recording on this piano (max 30s recorded time)
|
||||||
- `X`: Play back saved recording on this piano
|
- `X`: Play back saved recording on this piano (stops demo first)
|
||||||
- `Enter`: Play demo melody (press again to restart)
|
- `Enter`: Play demo melody (press again to restart; stops recording playback first)
|
||||||
- `C`: Stop demo/playback on this piano
|
- `C`: Stop demo, recording playback, and active recording
|
||||||
- `Escape`: Exit piano mode
|
- `Escape`: Exit piano mode
|
||||||
|
|
||||||
## Help Viewer Mode
|
## Help Viewer Mode
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ class ItemPianoNotePacket(BasePacket):
|
|||||||
class ItemPianoRecordingPacket(BasePacket):
|
class ItemPianoRecordingPacket(BasePacket):
|
||||||
type: Literal["item_piano_recording"]
|
type: Literal["item_piano_recording"]
|
||||||
itemId: str
|
itemId: str
|
||||||
action: Literal["toggle_record", "playback", "stop_playback"]
|
action: Literal["toggle_record", "playback", "stop_playback", "stop_record"]
|
||||||
|
|
||||||
|
|
||||||
class ItemUpdatePacket(BasePacket):
|
class ItemUpdatePacket(BasePacket):
|
||||||
|
|||||||
@@ -276,6 +276,20 @@ class SignalingServer:
|
|||||||
if task is not None and not task.done():
|
if task is not None and not task.done():
|
||||||
task.cancel()
|
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:
|
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."""
|
"""Persist and broadcast one active recording session, then clear runtime state."""
|
||||||
|
|
||||||
@@ -288,10 +302,7 @@ class SignalingServer:
|
|||||||
item = self.items.get(item_id)
|
item = self.items.get(item_id)
|
||||||
if not item or item.type != "piano":
|
if not item or item.type != "piano":
|
||||||
return
|
return
|
||||||
now_monotonic = time.monotonic()
|
elapsed_ms = max(0, min(PIANO_RECORDING_MAX_MS, self._recording_elapsed_ms(session)))
|
||||||
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")
|
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 []
|
||||||
song_id = f"item:{item.id}:recording"
|
song_id = f"item:{item.id}:recording"
|
||||||
@@ -372,8 +383,14 @@ class SignalingServer:
|
|||||||
"""Stop a recording automatically at the max recording duration."""
|
"""Stop a recording automatically at the max recording duration."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await asyncio.sleep(PIANO_RECORDING_MAX_MS / 1000)
|
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")
|
await self._finalize_piano_recording(item_id, status_message="stop")
|
||||||
|
return
|
||||||
|
await asyncio.sleep(0.25)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1036,9 +1053,8 @@ class SignalingServer:
|
|||||||
else:
|
else:
|
||||||
active_keys.discard(packet.keyId)
|
active_keys.discard(packet.keyId)
|
||||||
recording_state = self.piano_recording_state_by_item.get(item.id)
|
recording_state = self.piano_recording_state_by_item.get(item.id)
|
||||||
if recording_state and recording_state.get("ownerClientId") == client.id:
|
if recording_state and recording_state.get("ownerClientId") == client.id and recording_state.get("paused") is not True:
|
||||||
started = float(recording_state.get("startedMonotonic", time.monotonic()))
|
elapsed_ms = max(0, min(PIANO_RECORDING_MAX_MS, self._recording_elapsed_ms(recording_state)))
|
||||||
elapsed_ms = max(0, min(PIANO_RECORDING_MAX_MS, int((time.monotonic() - started) * 1000)))
|
|
||||||
events = recording_state.get("events")
|
events = recording_state.get("events")
|
||||||
if isinstance(events, list) and len(events) < PIANO_RECORDING_MAX_EVENTS:
|
if isinstance(events, list) and len(events) < PIANO_RECORDING_MAX_EVENTS:
|
||||||
instrument = str(item.params.get("instrument", "piano")).strip().lower()
|
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)
|
await self._send_item_result(client, False, "use", "This piano is already recording.", item.id)
|
||||||
return
|
return
|
||||||
if existing and existing.get("ownerClientId") == client.id:
|
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
|
return
|
||||||
self._cancel_piano_playback(item.id)
|
self._cancel_piano_playback(item.id)
|
||||||
recording_state = {
|
recording_state = {
|
||||||
"ownerClientId": client.id,
|
"ownerClientId": client.id,
|
||||||
"startedMonotonic": time.monotonic(),
|
"elapsedMs": 0,
|
||||||
|
"paused": False,
|
||||||
|
"lastResumeMonotonic": time.monotonic(),
|
||||||
"events": [],
|
"events": [],
|
||||||
}
|
}
|
||||||
self.piano_recording_state_by_item[item.id] = recording_state
|
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)
|
await self._send_item_result(client, True, "use", "record", item.id)
|
||||||
return
|
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 packet.action == "playback":
|
||||||
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)
|
||||||
|
|||||||
@@ -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"}),
|
json.dumps({"type": "item_piano_recording", "itemId": item.id, "action": "toggle_record"}),
|
||||||
)
|
)
|
||||||
assert send_payloads[-1].ok is True
|
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
|
assert item.id not in server.piano_recording_state_by_item
|
||||||
song_id = item.params.get("songId")
|
song_id = item.params.get("songId")
|
||||||
assert isinstance(song_id, str)
|
assert isinstance(song_id, str)
|
||||||
|
|||||||
@@ -28,3 +28,5 @@ def test_item_piano_recording_packet_validates() -> None:
|
|||||||
adapter = TypeAdapter(ClientPacket)
|
adapter = TypeAdapter(ClientPacket)
|
||||||
packet = adapter.validate_python({"type": "item_piano_recording", "itemId": "p1", "action": "toggle_record"})
|
packet = adapter.validate_python({"type": "item_piano_recording", "itemId": "p1", "action": "toggle_record"})
|
||||||
assert packet.type == "item_piano_recording"
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user