Add shared piano recording/playback and mono key fallback
This commit is contained in:
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
|
||||||
type="item_piano_note",
|
|
||||||
itemId=item.id,
|
|
||||||
senderId=client.id,
|
|
||||||
keyId=packet.keyId,
|
|
||||||
midi=packet.midi,
|
midi=packet.midi,
|
||||||
on=packet.on,
|
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:
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user