Add shared piano recording/playback and mono key fallback

This commit is contained in:
Jage9
2026-02-23 00:36:36 -05:00
parent b4cf85ac44
commit 93b9d19455
13 changed files with 484 additions and 61 deletions

View File

@@ -87,7 +87,7 @@
}, },
{ {
"keys": "Piano mode", "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"
} }
] ]
}, },

View File

@@ -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.22 R204"; window.CHGRID_WEB_VERSION = "2026.02.22 R205";
// 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";

View File

@@ -256,6 +256,8 @@ let activeTeleportLoopToken = 0;
let activePianoItemId: string | null = null; let activePianoItemId: string | null = null;
const activePianoKeys = new Set<string>(); const activePianoKeys = new Set<string>();
const activePianoKeyMidi = new Map<string, number>(); const activePianoKeyMidi = new Map<string, number>();
const activePianoHeldOrder: string[] = [];
let activePianoMonophonicKey: string | null = null;
const activeRemotePianoKeys = new Set<string>(); const activeRemotePianoKeys = new Set<string>();
let pianoPreviewTimeoutId: number | null = null; let pianoPreviewTimeoutId: number | null = null;
let activeTeleport: let activeTeleport:
@@ -881,6 +883,8 @@ async function startPianoUseMode(itemId: string): Promise<void> {
activePianoItemId = itemId; activePianoItemId = itemId;
activePianoKeys.clear(); activePianoKeys.clear();
activePianoKeyMidi.clear(); activePianoKeyMidi.clear();
activePianoHeldOrder.length = 0;
activePianoMonophonicKey = null;
state.mode = 'pianoUse'; state.mode = 'pianoUse';
await audio.ensureContext(); await audio.ensureContext();
updateStatus(`using ${item.title}, press escape to stop.`); updateStatus(`using ${item.title}, press escape to stop.`);
@@ -900,6 +904,8 @@ function stopPianoUseMode(announce = true): void {
activePianoItemId = null; activePianoItemId = null;
activePianoKeys.clear(); activePianoKeys.clear();
activePianoKeyMidi.clear(); activePianoKeyMidi.clear();
activePianoHeldOrder.length = 0;
activePianoMonophonicKey = null;
state.mode = 'normal'; state.mode = 'normal';
if (announce) { if (announce) {
updateStatus('Stopped piano.'); 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<typeof getPianoParams>,
): 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. */ /** Plays one short C4 preview using the piano item's current/overridden envelope+instrument. */
async function previewPianoSettingChange( async function previewPianoSettingChange(
item: WorldItem, item: WorldItem,
@@ -2328,6 +2413,16 @@ function handlePianoUseModeInput(code: string): void {
stopPianoUseMode(false); stopPianoUseMode(false);
return; 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') { if (code === 'Equal' || code === 'Minus') {
const current = getPianoParams(item).octave; const current = getPianoParams(item).octave;
const next = Math.max(-2, Math.min(2, current + (code === 'Equal' ? 1 : -1))); 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)); const playedMidi = Math.max(0, Math.min(127, midi + config.octave * 12));
activePianoKeys.add(code); activePianoKeys.add(code);
activePianoKeyMidi.set(code, playedMidi); activePianoKeyMidi.set(code, playedMidi);
const ctx = audio.context; activePianoHeldOrder.push(code);
const destination = audio.getOutputDestinationNode(); if (config.voiceMode === 'mono') {
if (!ctx || !destination) return; const previousCode = activePianoMonophonicKey;
const sourceX = item.carrierId === state.player.id ? state.player.x : item.x; if (previousCode && previousCode !== code) {
const sourceY = item.carrierId === state.player.id ? state.player.y : item.y; const previousMidi = activePianoKeyMidi.get(previousCode);
pianoSynth.noteOn( pianoSynth.noteOff(previousCode);
code, if (Number.isFinite(previousMidi)) {
`local:${itemId}`, signaling.send({ type: 'item_piano_note', itemId, keyId: previousCode, midi: previousMidi, on: false });
playedMidi, }
config.instrument, }
config.voiceMode, activePianoMonophonicKey = code;
config.attack, }
config.decay, playLocalPianoNote(item, itemId, code, playedMidi, config);
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 });
} }
/** Handles effect menu list navigation and selection. */ /** Handles effect menu list navigation and selection. */
@@ -2871,15 +2960,7 @@ function setupInputHandlers(): void {
document.addEventListener('keyup', (event) => { document.addEventListener('keyup', (event) => {
const code = normalizeInputCode(event); const code = normalizeInputCode(event);
if (state.mode === 'pianoUse' && code) { if (state.mode === 'pianoUse' && code) {
if (activePianoKeys.delete(code)) { handlePianoUseModeKeyUp(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 });
}
}
} }
if (code) { if (code) {
state.keysPressed[code] = false; state.keysPressed[code] = false;

View File

@@ -199,6 +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' }
| { | {
type: 'item_update'; type: 'item_update';
itemId: string; itemId: string;

View File

@@ -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 - `W E T Y U O P ]`: Play sharps
- Multiple keys can be held/played at once - Multiple keys can be held/played at once
- `-` / `=`: Shift octave down/up - `-` / `=`: Shift octave down/up
- `,`: Start/stop recording on this piano (max 30s)
- `.`: Play back saved recording on this piano
- `Escape`: Exit piano mode - `Escape`: Exit piano mode
## Help Viewer Mode ## Help Viewer Mode

View File

@@ -183,6 +183,8 @@
- `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`.
- `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 ## Packet Shapes

View File

@@ -173,6 +173,8 @@ This is behavior-focused documentation for item types and their defaults.
### Use ### Use
- Announces that the user begins playing the piano (client enters piano key mode). - 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 ### Validation
- `instrument`: `piano | electric_piano | guitar | organ | bass | violin | synth_lead | brass | nintendo | drum_kit` - `instrument`: `piano | electric_piano | guitar | organ | bass | violin | synth_lead | brass | nintendo | drum_kit`

View File

@@ -16,6 +16,7 @@ This is a behavior guide for packet semantics beyond raw schemas.
- `ping`: latency measurement. - `ping`: latency measurement.
- `item_add`, `item_pickup`, `item_drop`, `item_delete`, `item_use`, `item_update`: item actions. - `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_note`: realtime piano note on/off for active piano use mode.
- `item_piano_recording`: piano record/playback control (`toggle_record`, `playback`).
## Server -> Client ## Server -> Client

View File

@@ -105,6 +105,12 @@ PROPERTY_METADATA: dict[str, dict[str, object]] = {
def validate_update(_item: WorldItem, next_params: dict) -> dict: def validate_update(_item: WorldItem, next_params: dict) -> dict:
"""Validate and normalize piano params.""" """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() instrument = str(next_params.get("instrument", "piano")).strip().lower()
if instrument not in INSTRUMENT_OPTIONS: if instrument not in INSTRUMENT_OPTIONS:
raise ValueError(f"instrument must be one of: {', '.join(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.") raise ValueError("emitRange must be between 5 and 20.")
next_params["emitRange"] = emit_range 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 return next_params

View File

@@ -75,6 +75,12 @@ class ItemPianoNotePacket(BasePacket):
on: bool on: bool
class ItemPianoRecordingPacket(BasePacket):
type: Literal["item_piano_recording"]
itemId: str
action: Literal["toggle_record", "playback"]
class ItemUpdatePacket(BasePacket): class ItemUpdatePacket(BasePacket):
type: Literal["item_update"] type: Literal["item_update"]
itemId: str itemId: str
@@ -94,6 +100,7 @@ ClientPacket = (
| ItemDeletePacket | ItemDeletePacket
| ItemUsePacket | ItemUsePacket
| ItemPianoNotePacket | ItemPianoNotePacket
| ItemPianoRecordingPacket
| ItemUpdatePacket | ItemUpdatePacket
) )

View File

@@ -11,6 +11,7 @@ import logging
import os import os
import re import re
import ssl import ssl
import time
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
@@ -48,6 +49,7 @@ from .models import (
ItemDropPacket, ItemDropPacket,
ItemPianoNoteBroadcastPacket, ItemPianoNoteBroadcastPacket,
ItemPianoNotePacket, ItemPianoNotePacket,
ItemPianoRecordingPacket,
ItemPickupPacket, ItemPickupPacket,
ItemRemovePacket, ItemRemovePacket,
ItemUpdatePacket, ItemUpdatePacket,
@@ -69,6 +71,8 @@ LOGGER = logging.getLogger("chgrid.server")
PACKET_LOGGER = logging.getLogger("chgrid.server.packet") PACKET_LOGGER = logging.getLogger("chgrid.server.packet")
CLIENT_PACKET_ADAPTER = TypeAdapter(ClientPacket) CLIENT_PACKET_ADAPTER = TypeAdapter(ClientPacket)
MAX_ACTIVE_PIANO_KEYS_PER_CLIENT = 12 MAX_ACTIVE_PIANO_KEYS_PER_CLIENT = 12
PIANO_RECORDING_MAX_MS = 30_000
PIANO_RECORDING_MAX_EVENTS = 4096
class SignalingServer: class SignalingServer:
@@ -94,6 +98,8 @@ class SignalingServer:
self.item_service = ItemService(state_file=state_file) self.item_service = ItemService(state_file=state_file)
self.item_last_use_ms: dict[str, int] = {} self.item_last_use_ms: dict[str, int] = {}
self.active_piano_keys_by_client: dict[str, set[str]] = {} 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.grid_size = max(1, grid_size)
self.instance_id = str(uuid.uuid4()) self.instance_id = str(uuid.uuid4())
self.server_version = self._resolve_server_version() self.server_version = self._resolve_server_version()
@@ -164,6 +170,185 @@ class SignalingServer:
return item.useSound.strip() return item.useSound.strip()
return None 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: def _is_in_bounds(self, x: int, y: int) -> bool:
"""Check whether a coordinate is inside server-authoritative world bounds.""" """Check whether a coordinate is inside server-authoritative world bounds."""
@@ -263,6 +448,10 @@ class SignalingServer:
if websocket in self.clients: if websocket in self.clients:
disconnected = self.clients.pop(websocket) disconnected = self.clients.pop(websocket)
self.active_piano_keys_by_client.pop(disconnected.id, None) 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): for item in self.item_service.drop_carried_items_for_disconnect(disconnected):
await self._broadcast_item(item) await self._broadcast_item(item)
self.item_service.save_state() self.item_service.save_state()
@@ -589,6 +778,12 @@ class SignalingServer:
item.type, item.type,
item.title, 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_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))
@@ -676,41 +871,72 @@ class SignalingServer:
active_keys.add(packet.keyId) active_keys.add(packet.keyId)
else: else:
active_keys.discard(packet.keyId) active_keys.discard(packet.keyId)
instrument = str(item.params.get("instrument", "piano")).strip().lower() recording_state = self.piano_recording_state_by_item.get(item.id)
voice_mode = str(item.params.get("voiceMode", "poly")).strip().lower() if recording_state and recording_state.get("ownerClientId") == client.id:
if voice_mode not in {"poly", "mono"}: started = float(recording_state.get("startedMonotonic", time.monotonic()))
voice_mode = "poly" elapsed_ms = max(0, min(PIANO_RECORDING_MAX_MS, int((time.monotonic() - started) * 1000)))
octave = int(item.params.get("octave", 0)) if isinstance(item.params.get("octave", 0), (int, float)) else 0 events = recording_state.get("events")
attack = int(item.params.get("attack", 15)) if isinstance(item.params.get("attack", 15), (int, float)) else 15 if isinstance(events, list) and len(events) < PIANO_RECORDING_MAX_EVENTS:
decay = int(item.params.get("decay", 45)) if isinstance(item.params.get("decay", 45), (int, float)) else 45 events.append({"t": elapsed_ms, "keyId": packet.keyId[:32], "midi": packet.midi, "on": packet.on})
release = int(item.params.get("release", 35)) if isinstance(item.params.get("release", 35), (int, float)) else 35 if elapsed_ms >= PIANO_RECORDING_MAX_MS:
brightness = int(item.params.get("brightness", 55)) if isinstance(item.params.get("brightness", 55), (int, float)) else 55 await self._finalize_piano_recording(item.id, status_message="Recording reached 30.0 s and was saved.")
emit_range = int(item.params.get("emitRange", 15)) if isinstance(item.params.get("emitRange", 15), (int, float)) else 15 await self._broadcast_item_piano_note(
source_x = client.x if item.carrierId == client.id else item.x item,
source_y = client.y if item.carrierId == client.id else item.y sender_id=client.id,
await self._broadcast( key_id=packet.keyId,
ItemPianoNoteBroadcastPacket( midi=packet.midi,
type="item_piano_note", on=packet.on,
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)),
),
exclude=client.websocket, exclude=client.websocket,
) )
return 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): if isinstance(packet, ItemUpdatePacket):
item = self.items.get(packet.itemId) item = self.items.get(packet.itemId)
if not item: if not item:

View File

@@ -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}), json.dumps({"type": "item_piano_note", "itemId": item.id, "keyId": "KeyOverflow", "midi": 60, "on": True}),
) )
assert len(broadcast_payloads) == 12 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]

View File

@@ -22,3 +22,9 @@ def test_item_add_accepts_piano_type() -> None:
adapter = TypeAdapter(ClientPacket) adapter = TypeAdapter(ClientPacket)
packet = adapter.validate_python({"type": "item_add", "itemType": "piano"}) packet = adapter.validate_python({"type": "item_add", "itemType": "piano"})
assert packet.type == "item_add" 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"