diff --git a/client/public/help.json b/client/public/help.json index ec11d66..7e31c2b 100644 --- a/client/public/help.json +++ b/client/public/help.json @@ -84,6 +84,10 @@ { "keys": "Enter", "description": "Use item" + }, + { + "keys": "Piano mode", + "description": "When using a piano: ASDFGHJKL;' plays C major notes, WETYUOP] plays sharps, Enter/Escape exits" } ] }, diff --git a/client/public/version.js b/client/public/version.js index 59f619a..eb82c58 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 R198"; +window.CHGRID_WEB_VERSION = "2026.02.22 R199"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/audio/pianoSynth.ts b/client/src/audio/pianoSynth.ts new file mode 100644 index 0000000..0f53bc7 --- /dev/null +++ b/client/src/audio/pianoSynth.ts @@ -0,0 +1,330 @@ +import { resolveSpatialMix } from './spatial'; + +export const PIANO_INSTRUMENT_OPTIONS = [ + 'piano', + 'electric_piano', + 'guitar', + 'organ', + 'bass', + 'violin', + 'synth_lead', + 'drum_kit', +] as const; + +export type PianoInstrumentId = (typeof PIANO_INSTRUMENT_OPTIONS)[number]; + +type VoiceRuntime = { + gain: GainNode; + panner: StereoPannerNode | null; + oscillators: OscillatorNode[]; + releaseSeconds: number; +}; + +type PianoContext = { + audioCtx: AudioContext; + destination: AudioNode; +}; + +type PianoSpatialSource = { + x: number; + y: number; + range: number; +}; + +type InstrumentPreset = { + oscillators: Array<{ type: OscillatorType; detune?: number; gain?: number; ratio?: number }>; + filter?: { type: BiquadFilterType; frequency: number; q?: number }; + gain: number; + releaseScale?: number; +}; + +const PRESETS: Record, InstrumentPreset> = { + piano: { + oscillators: [ + { type: 'triangle', gain: 1 }, + { type: 'sine', ratio: 2, gain: 0.28 }, + ], + filter: { type: 'lowpass', frequency: 5200, q: 0.7 }, + gain: 0.32, + releaseScale: 0.9, + }, + electric_piano: { + oscillators: [ + { type: 'sine', gain: 1 }, + { type: 'triangle', detune: 5, gain: 0.35 }, + ], + filter: { type: 'lowpass', frequency: 4200, q: 0.8 }, + gain: 0.3, + releaseScale: 0.8, + }, + guitar: { + oscillators: [ + { type: 'triangle', gain: 1 }, + { type: 'sawtooth', detune: -3, gain: 0.2 }, + ], + filter: { type: 'lowpass', frequency: 3200, q: 0.9 }, + gain: 0.24, + releaseScale: 0.7, + }, + organ: { + oscillators: [ + { type: 'square', gain: 0.8 }, + { type: 'sine', ratio: 2, gain: 0.28 }, + { type: 'sine', ratio: 3, gain: 0.2 }, + ], + filter: { type: 'lowpass', frequency: 6500, q: 0.6 }, + gain: 0.18, + releaseScale: 1.4, + }, + bass: { + oscillators: [ + { type: 'sawtooth', gain: 0.9 }, + { type: 'square', ratio: 0.5, gain: 0.25 }, + ], + filter: { type: 'lowpass', frequency: 1500, q: 1.1 }, + gain: 0.28, + releaseScale: 0.9, + }, + violin: { + oscillators: [ + { type: 'sawtooth', gain: 0.8 }, + { type: 'triangle', detune: 3, gain: 0.35 }, + ], + filter: { type: 'lowpass', frequency: 3600, q: 1.0 }, + gain: 0.24, + releaseScale: 1.5, + }, + synth_lead: { + oscillators: [ + { type: 'sawtooth', gain: 0.85 }, + { type: 'square', detune: 6, gain: 0.3 }, + ], + filter: { type: 'lowpass', frequency: 5400, q: 0.9 }, + gain: 0.2, + releaseScale: 1, + }, +}; + +/** Maps 0..100 control values to note attack seconds. */ +function attackPercentToSeconds(value: number): number { + const clamped = Math.max(0, Math.min(100, value)); + return 0.002 + (clamped / 100) * 0.6; +} + +/** Maps 0..100 control values to note decay/release seconds. */ +function decayPercentToSeconds(value: number): number { + const clamped = Math.max(0, Math.min(100, value)); + return 0.05 + (clamped / 100) * 2.7; +} + +/** Converts midi note number to frequency in hertz. */ +function midiToFrequency(midi: number): number { + return 440 * Math.pow(2, (midi - 69) / 12); +} + +/** Small helper to safely stop audio nodes. */ +function safeStop(oscillator: OscillatorNode, when: number): void { + try { + oscillator.stop(when); + } catch { + // Ignore already-stopped oscillators. + } +} + +export class PianoSynth { + private readonly voices = new Map(); + private readonly drumNoiseBuffers = new WeakMap(); + + /** Stops and disconnects all active notes. */ + stopAll(): void { + for (const key of Array.from(this.voices.keys())) { + this.noteOff(key); + } + } + + /** Starts one note for a specific keyboard key id. */ + noteOn( + keyId: string, + midi: number, + instrument: PianoInstrumentId, + attackPercent: number, + decayPercent: number, + context: PianoContext, + spatial: PianoSpatialSource, + ): void { + if (this.voices.has(keyId)) return; + if (instrument === 'drum_kit') { + this.playDrumHit(keyId, context, spatial, attackPercent, decayPercent); + return; + } + + const preset = PRESETS[instrument] ?? PRESETS.piano; + const now = context.audioCtx.currentTime; + const attackSeconds = attackPercentToSeconds(attackPercent); + const decaySeconds = decayPercentToSeconds(decayPercent); + const releaseSeconds = Math.max(0.02, decaySeconds * (preset.releaseScale ?? 1)); + + const spatialMix = resolveSpatialMix({ + dx: spatial.x, + dy: spatial.y, + range: spatial.range, + baseGain: 1, + }); + if (!spatialMix || spatialMix.gain <= 0) return; + + const voiceGain = context.audioCtx.createGain(); + voiceGain.gain.setValueAtTime(0.0001, now); + const peakGain = Math.max(0.0001, preset.gain * spatialMix.gain); + const sustainGain = Math.max(0.0001, peakGain * 0.55); + voiceGain.gain.exponentialRampToValueAtTime(peakGain, now + attackSeconds); + voiceGain.gain.exponentialRampToValueAtTime(sustainGain, now + attackSeconds + decaySeconds * 0.6); + + let tailNode: AudioNode = voiceGain; + if (preset.filter) { + const filter = context.audioCtx.createBiquadFilter(); + filter.type = preset.filter.type; + filter.frequency.setValueAtTime(preset.filter.frequency, now); + filter.Q.setValueAtTime(preset.filter.q ?? 0.7, now); + voiceGain.connect(filter); + tailNode = filter; + } + + let panner: StereoPannerNode | null = null; + if (typeof context.audioCtx.createStereoPanner === 'function') { + panner = context.audioCtx.createStereoPanner(); + panner.pan.setValueAtTime(spatialMix.pan, now); + tailNode.connect(panner).connect(context.destination); + } else { + tailNode.connect(context.destination); + } + + const frequency = midiToFrequency(midi); + const oscillators: OscillatorNode[] = []; + for (const partial of preset.oscillators) { + const oscillator = context.audioCtx.createOscillator(); + oscillator.type = partial.type; + oscillator.frequency.setValueAtTime(frequency * (partial.ratio ?? 1), now); + oscillator.detune.setValueAtTime(partial.detune ?? 0, now); + const oscGain = context.audioCtx.createGain(); + oscGain.gain.setValueAtTime(partial.gain ?? 1, now); + oscillator.connect(oscGain).connect(voiceGain); + oscillator.start(now); + oscillators.push(oscillator); + } + + this.voices.set(keyId, { + gain: voiceGain, + panner, + oscillators, + releaseSeconds, + }); + } + + /** Releases one active note tied to a keyboard key id. */ + noteOff(keyId: string): void { + const voice = this.voices.get(keyId); + if (!voice) return; + this.voices.delete(keyId); + const now = voice.gain.context.currentTime; + const currentGain = Math.max(0.0001, voice.gain.gain.value); + voice.gain.gain.cancelScheduledValues(now); + voice.gain.gain.setValueAtTime(currentGain, now); + voice.gain.gain.exponentialRampToValueAtTime(0.0001, now + voice.releaseSeconds); + for (const oscillator of voice.oscillators) { + safeStop(oscillator, now + voice.releaseSeconds + 0.02); + } + window.setTimeout(() => { + try { + voice.gain.disconnect(); + } catch { + // Ignore stale disconnects. + } + if (voice.panner) { + try { + voice.panner.disconnect(); + } catch { + // Ignore stale disconnects. + } + } + }, Math.max(60, Math.round((voice.releaseSeconds + 0.04) * 1000))); + } + + /** Plays one synthesized drum hit for drum-kit instrument mode. */ + private playDrumHit( + keyId: string, + context: PianoContext, + spatial: PianoSpatialSource, + attackPercent: number, + decayPercent: number, + ): void { + const now = context.audioCtx.currentTime; + const spatialMix = resolveSpatialMix({ + dx: spatial.x, + dy: spatial.y, + range: spatial.range, + baseGain: 1, + }); + if (!spatialMix || spatialMix.gain <= 0) return; + const typeIndex = Math.abs(this.hashKey(keyId)) % 4; + const decaySeconds = 0.03 + decayPercentToSeconds(decayPercent) * 0.45; + const attackSeconds = Math.max(0.001, attackPercentToSeconds(attackPercent) * 0.2); + + const gain = context.audioCtx.createGain(); + gain.gain.setValueAtTime(0.0001, now); + gain.gain.exponentialRampToValueAtTime(0.22 * spatialMix.gain, now + attackSeconds); + gain.gain.exponentialRampToValueAtTime(0.0001, now + decaySeconds); + + let tailNode: AudioNode = gain; + if (typeof context.audioCtx.createStereoPanner === 'function') { + const panner = context.audioCtx.createStereoPanner(); + panner.pan.setValueAtTime(spatialMix.pan, now); + tailNode.connect(panner).connect(context.destination); + tailNode = panner; + } else { + tailNode.connect(context.destination); + } + + if (typeIndex === 0) { + const kick = context.audioCtx.createOscillator(); + kick.type = 'sine'; + kick.frequency.setValueAtTime(150, now); + kick.frequency.exponentialRampToValueAtTime(45, now + decaySeconds * 0.85); + kick.connect(gain); + kick.start(now); + safeStop(kick, now + decaySeconds + 0.04); + return; + } + + const noise = context.audioCtx.createBufferSource(); + noise.buffer = this.getNoiseBuffer(context.audioCtx); + const noiseFilter = context.audioCtx.createBiquadFilter(); + noiseFilter.type = typeIndex === 1 ? 'highpass' : typeIndex === 2 ? 'bandpass' : 'lowpass'; + noiseFilter.frequency.setValueAtTime(typeIndex === 1 ? 1700 : typeIndex === 2 ? 900 : 1300, now); + noise.connect(noiseFilter).connect(gain); + noise.start(now); + safeStop(noise, now + decaySeconds + 0.03); + } + + /** Returns deterministic hash for key ids to map drum voice variants. */ + private hashKey(value: string): number { + let out = 0; + for (let index = 0; index < value.length; index += 1) { + out = ((out << 5) - out + value.charCodeAt(index)) | 0; + } + return out; + } + + /** Returns or lazily builds short white-noise buffer for percussion synthesis. */ + private getNoiseBuffer(audioCtx: AudioContext): AudioBuffer { + const existing = this.drumNoiseBuffers.get(audioCtx); + if (existing) return existing; + const length = Math.max(1, Math.floor(audioCtx.sampleRate * 0.5)); + const buffer = audioCtx.createBuffer(1, length, audioCtx.sampleRate); + const data = buffer.getChannelData(0); + for (let index = 0; index < length; index += 1) { + data[index] = Math.random() * 2 - 1; + } + this.drumNoiseBuffers.set(audioCtx, buffer); + return buffer; + } +} diff --git a/client/src/items/itemPropertyEditor.ts b/client/src/items/itemPropertyEditor.ts index 13d184f..1f22da3 100644 --- a/client/src/items/itemPropertyEditor.ts +++ b/client/src/items/itemPropertyEditor.ts @@ -309,6 +309,8 @@ export function createItemPropertyEditor(deps: EditorDeps): { propertyKey === 'mediaVolume' || propertyKey === 'emitVolume' || propertyKey === 'emitRange' || + propertyKey === 'attack' || + propertyKey === 'decay' || propertyKey === 'sides' || propertyKey === 'number' ) { diff --git a/client/src/items/itemRegistry.ts b/client/src/items/itemRegistry.ts index e17f300..e938869 100644 --- a/client/src/items/itemRegistry.ts +++ b/client/src/items/itemRegistry.ts @@ -45,11 +45,22 @@ const DEFAULT_CLOCK_TIME_ZONE_OPTIONS = [ 'UTC', ] as const; -const DEFAULT_ITEM_TYPE_SEQUENCE: ItemType[] = ['clock', 'dice', 'radio_station', 'wheel', 'widget']; +const DEFAULT_ITEM_TYPE_SEQUENCE: ItemType[] = ['clock', 'dice', 'piano', 'radio_station', 'wheel', 'widget']; +const DEFAULT_PIANO_INSTRUMENT_OPTIONS = [ + 'piano', + 'electric_piano', + 'guitar', + 'organ', + 'bass', + 'violin', + 'synth_lead', + 'drum_kit', +] as const; const DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES: Record = { radio_station: ['title', 'streamUrl', 'enabled', 'mediaVolume', 'mediaChannel', 'mediaEffect', 'mediaEffectValue', 'facing', 'emitRange'], dice: ['title', 'sides', 'number'], + piano: ['title', 'instrument', 'attack', 'decay', 'emitRange'], wheel: ['title', 'spaces'], clock: ['title', 'timeZone', 'use24Hour'], widget: ['title', 'enabled', 'directional', 'facing', 'emitRange', 'emitVolume', 'emitSoundSpeed', 'emitSoundTempo', 'emitEffect', 'emitEffectValue', 'useSound', 'emitSound'], @@ -58,6 +69,7 @@ const DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES: Record = { const DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES: Record> = { radio_station: { useSound: 'none', emitSound: 'none', useCooldownMs: 1000, emitRange: 20, directional: true, emitSoundSpeed: 50, emitSoundTempo: 50 }, dice: { useSound: 'sounds/roll.ogg', emitSound: 'none', useCooldownMs: 1000, emitRange: 15, directional: false, emitSoundSpeed: 50, emitSoundTempo: 50 }, + piano: { useSound: 'none', emitSound: 'none', useCooldownMs: 1000, emitRange: 15, directional: false, emitSoundSpeed: 50, emitSoundTempo: 50 }, wheel: { useSound: 'sounds/spin.ogg', emitSound: 'none', useCooldownMs: 4000, emitRange: 15, directional: false, emitSoundSpeed: 50, emitSoundTempo: 50 }, clock: { useSound: 'none', emitSound: 'sounds/clock.ogg', useCooldownMs: 1000, emitRange: 10, directional: false, emitSoundSpeed: 50, emitSoundTempo: 50 }, widget: { useSound: 'none', emitSound: 'none', useCooldownMs: 1000, emitRange: 15, directional: false, emitSoundSpeed: 50, emitSoundTempo: 50 }, @@ -93,6 +105,7 @@ let itemTypeSequence: ItemType[] = [...DEFAULT_ITEM_TYPE_SEQUENCE]; let itemTypeLabels: Record = { radio_station: 'radio', dice: 'dice', + piano: 'piano', wheel: 'wheel', clock: 'clock', widget: 'widget', @@ -101,6 +114,7 @@ let itemTypeTooltips: Partial> = {}; let itemTypeEditableProperties: Record = { radio_station: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.radio_station], dice: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.dice], + piano: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.piano], wheel: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.wheel], clock: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.clock], widget: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.widget], @@ -108,6 +122,7 @@ let itemTypeEditableProperties: Record = { let itemTypeGlobalProperties: Record> = { radio_station: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.radio_station }, dice: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.dice }, + piano: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.piano }, wheel: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.wheel }, clock: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.clock }, widget: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.widget }, @@ -116,6 +131,7 @@ let optionItemPropertyValues: Partial> = { mediaEffect: EFFECT_SEQUENCE.map((effect) => effect.id), emitEffect: EFFECT_SEQUENCE.map((effect) => effect.id), mediaChannel: [...RADIO_CHANNEL_OPTIONS], + instrument: [...DEFAULT_PIANO_INSTRUMENT_OPTIONS], timeZone: [...DEFAULT_CLOCK_TIME_ZONE_OPTIONS], }; let itemTypePropertyMetadata: Partial>> = {}; @@ -221,6 +237,9 @@ export function itemPropertyLabel(key: string): string { if (key === 'mediaEffectValue') return 'media effect value'; if (key === 'emitEffect') return 'emit effect'; if (key === 'emitEffectValue') return 'emit effect value'; + if (key === 'instrument') return 'instrument'; + if (key === 'attack') return 'attack'; + if (key === 'decay') return 'decay'; if (key === 'useSound') return 'use sound'; if (key === 'emitSound') return 'emit sound'; return key; diff --git a/client/src/main.ts b/client/src/main.ts index 754b7a4..39ee77e 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -14,6 +14,7 @@ import { shouldProxyStreamUrl, } from './audio/radioStationRuntime'; import { ItemEmitRuntime } from './audio/itemEmitRuntime'; +import { PianoSynth, type PianoInstrumentId } from './audio/pianoSynth'; import { normalizeDegrees } from './audio/spatial'; import { applyPastedText, @@ -82,6 +83,29 @@ const RECONNECT_MAX_ATTEMPTS = 3; const AUDIO_SUBSCRIPTION_REFRESH_MS = 500; const TELEPORT_SQUARES_PER_SECOND = 20; const TELEPORT_SYNC_INTERVAL_MS = 100; +const PIANO_WHITE_KEY_MIDI_BY_CODE: Record = { + KeyA: 60, + KeyS: 62, + KeyD: 64, + KeyF: 65, + KeyG: 67, + KeyH: 69, + KeyJ: 71, + KeyK: 72, + KeyL: 74, + Semicolon: 76, + Quote: 77, +}; +const PIANO_SHARP_KEY_MIDI_BY_CODE: Record = { + KeyW: 61, + KeyE: 63, + KeyT: 66, + KeyY: 68, + KeyU: 70, + KeyO: 73, + KeyP: 75, + BracketRight: 78, +}; declare global { interface Window { @@ -224,6 +248,9 @@ let subscriptionRefreshPending = false; let suppressItemPropertyEchoUntilMs = 0; let activeTeleportLoopStop: (() => void) | null = null; let activeTeleportLoopToken = 0; +let activePianoItemId: string | null = null; +const activePianoKeys = new Set(); +const activeRemotePianoKeys = new Set(); let activeTeleport: | { startX: number; @@ -238,6 +265,7 @@ let activeTeleport: completionStatus: string; } | null = null; +const pianoSynth = new PianoSynth(); const signalingProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; const signalingUrl = `${signalingProtocol}://${window.location.host}/ws`; @@ -759,6 +787,136 @@ function getItemSpatialConfig(item: WorldItem): { range: number; directional: bo return { range, directional, facingDeg }; } +/** Resolves piano params with safe defaults for local play mode. */ +function getPianoParams(item: WorldItem): { instrument: PianoInstrumentId; attack: number; decay: number; emitRange: number } { + const rawInstrument = String(item.params.instrument ?? 'piano').trim().toLowerCase(); + const instrument: PianoInstrumentId = + rawInstrument === 'electric_piano' || + rawInstrument === 'guitar' || + rawInstrument === 'organ' || + rawInstrument === 'bass' || + rawInstrument === 'violin' || + rawInstrument === 'synth_lead' || + rawInstrument === 'drum_kit' + ? rawInstrument + : 'piano'; + const rawAttack = Number(item.params.attack); + const rawDecay = Number(item.params.decay); + const rawEmitRange = Number(item.params.emitRange ?? getItemTypeGlobalProperties(item.type).emitRange ?? 15); + return { + instrument, + attack: Math.max(0, Math.min(100, Number.isFinite(rawAttack) ? Math.round(rawAttack) : 15)), + decay: Math.max(0, Math.min(100, Number.isFinite(rawDecay) ? Math.round(rawDecay) : 45)), + emitRange: Math.max(5, Math.min(20, Number.isFinite(rawEmitRange) ? Math.round(rawEmitRange) : 15)), + }; +} + +/** Normalizes arbitrary instrument strings into supported piano synth ids. */ +function normalizePianoInstrument(value: unknown): PianoInstrumentId { + const raw = String(value ?? 'piano').trim().toLowerCase(); + if (raw === 'electric_piano') return 'electric_piano'; + if (raw === 'guitar') return 'guitar'; + if (raw === 'organ') return 'organ'; + if (raw === 'bass') return 'bass'; + if (raw === 'violin') return 'violin'; + if (raw === 'synth_lead') return 'synth_lead'; + if (raw === 'drum_kit') return 'drum_kit'; + return 'piano'; +} + +/** Returns playable MIDI note for a piano-mode key code, or null when unmapped. */ +function getPianoMidiForCode(code: string): number | null { + if (code in PIANO_WHITE_KEY_MIDI_BY_CODE) { + return PIANO_WHITE_KEY_MIDI_BY_CODE[code]!; + } + if (code in PIANO_SHARP_KEY_MIDI_BY_CODE) { + return PIANO_SHARP_KEY_MIDI_BY_CODE[code]!; + } + return null; +} + +/** Starts local piano key mode for one used piano item. */ +async function startPianoUseMode(itemId: string): Promise { + const item = state.items.get(itemId); + if (!item || item.type !== 'piano') return; + activePianoItemId = itemId; + activePianoKeys.clear(); + state.mode = 'pianoUse'; + await audio.ensureContext(); + updateStatus(`Piano mode: ${item.title}. Press Enter or Escape to stop.`); + audio.sfxUiBlip(); +} + +/** Exits local piano key mode and releases any held notes. */ +function stopPianoUseMode(announce = true): void { + if (!activePianoItemId) return; + const itemId = activePianoItemId; + for (const code of Array.from(activePianoKeys)) { + const midi = getPianoMidiForCode(code); + if (midi === null) continue; + signaling.send({ type: 'item_piano_note', itemId, keyId: code, midi, on: false }); + pianoSynth.noteOff(code); + } + activePianoItemId = null; + activePianoKeys.clear(); + state.mode = 'normal'; + if (announce) { + updateStatus('Stopped piano.'); + audio.sfxUiCancel(); + } +} + +/** Plays one inbound piano note from another user using item spatial position. */ +function playRemotePianoNote(note: { + itemId: string; + senderId: string; + keyId: string; + midi: number; + instrument: string; + attack: number; + decay: number; + x: number; + y: number; + emitRange: number; +}): void { + const ctx = audio.context; + const destination = audio.getOutputDestinationNode(); + if (!ctx || !destination) return; + const runtimeKey = `${note.senderId}:${note.keyId}`; + if (activeRemotePianoKeys.has(runtimeKey)) return; + activeRemotePianoKeys.add(runtimeKey); + pianoSynth.noteOn( + runtimeKey, + Math.max(0, Math.min(127, Math.round(note.midi))), + normalizePianoInstrument(note.instrument), + Math.max(0, Math.min(100, Math.round(note.attack))), + Math.max(0, Math.min(100, Math.round(note.decay))), + { audioCtx: ctx, destination }, + { + x: note.x - state.player.x, + y: note.y - state.player.y, + range: Math.max(1, Math.round(note.emitRange)), + }, + ); +} + +/** Stops one inbound piano note previously started for another user. */ +function stopRemotePianoNote(senderId: string, keyId: string): void { + const runtimeKey = `${senderId}:${keyId}`; + if (!activeRemotePianoKeys.delete(runtimeKey)) return; + pianoSynth.noteOff(runtimeKey); +} + +/** Stops all currently active remote piano notes for a sender id. */ +function stopAllRemotePianoNotesForSender(senderId: string): void { + const prefix = `${senderId}:`; + for (const runtimeKey of Array.from(activeRemotePianoKeys)) { + if (!runtimeKey.startsWith(prefix)) continue; + activeRemotePianoKeys.delete(runtimeKey); + pianoSynth.noteOff(runtimeKey); + } +} + /** Enters help-view mode and announces the first help line. */ function openHelpViewer(): void { if (helpViewerLines.length === 0) { @@ -973,7 +1131,7 @@ function getItemPropertyValue(item: WorldItem, key: string): string { function inferItemPropertyValueType(item: WorldItem, key: string): string | undefined { if (key === 'useSound' || key === 'emitSound') return 'sound'; if (key === 'enabled' || key === 'use24Hour' || key === 'directional') return 'boolean'; - if (key === 'mediaChannel' || key === 'mediaEffect' || key === 'emitEffect' || key === 'timeZone') return 'list'; + if (key === 'mediaChannel' || key === 'mediaEffect' || key === 'emitEffect' || key === 'timeZone' || key === 'instrument') return 'list'; if ( key === 'x' || key === 'y' || @@ -986,6 +1144,8 @@ function inferItemPropertyValueType(item: WorldItem, key: string): string | unde key === 'emitEffectValue' || key === 'facing' || key === 'emitRange' || + key === 'attack' || + key === 'decay' || key === 'sides' || key === 'number' || key === 'useCooldownMs' @@ -1447,6 +1607,11 @@ function disconnect(): void { lastSubscriptionRefreshTileY = Math.round(state.player.y); stopTeleportLoopAudio(); activeTeleport = null; + stopPianoUseMode(false); + for (const key of Array.from(activeRemotePianoKeys)) { + activeRemotePianoKeys.delete(key); + pianoSynth.noteOff(key); + } } const onAppMessage = createOnMessageHandler({ @@ -1482,6 +1647,9 @@ const onAppMessage = createOnMessageHandler({ gain, ); }, + playRemotePianoNote, + stopRemotePianoNote, + stopAllRemotePianoNotesForSender, TELEPORT_SOUND_URL, TELEPORT_START_SOUND_URL, getAudioLayers: () => audioLayers, @@ -1543,6 +1711,20 @@ async function onSignalingMessage(message: IncomingMessage): Promise { startHeartbeat(); } await onAppMessage(message); + if ( + message.type === 'item_action_result' && + message.ok && + message.action === 'use' && + typeof message.itemId === 'string' + ) { + const item = state.items.get(message.itemId); + if (item?.type === 'piano') { + await startPianoUseMode(item.id); + } + } + if (activePianoItemId && !state.items.has(activePianoItemId)) { + stopPianoUseMode(false); + } applyConfiguredPeerListenGains(); if (restartAnnouncement) { setConnectionStatus(restartAnnouncement); @@ -2015,6 +2197,44 @@ function handleMicGainEditModeInput(code: string, key: string, ctrlKey: boolean) applyTextInputEdit(code, key, 8, ctrlKey, true); } +/** Handles realtime keyboard performance while piano item mode is active. */ +function handlePianoUseModeInput(code: string): void { + if (code === 'Escape' || code === 'Enter') { + stopPianoUseMode(true); + return; + } + const itemId = activePianoItemId; + if (!itemId) { + state.mode = 'normal'; + return; + } + const item = state.items.get(itemId); + if (!item || item.type !== 'piano') { + stopPianoUseMode(false); + return; + } + const midi = getPianoMidiForCode(code); + if (midi === null) return; + if (activePianoKeys.has(code)) return; + activePianoKeys.add(code); + const ctx = audio.context; + const destination = audio.getOutputDestinationNode(); + if (!ctx || !destination) return; + const config = getPianoParams(item); + 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( + code, + midi, + config.instrument, + config.attack, + config.decay, + { 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, on: true }); +} + /** Handles effect menu list navigation and selection. */ function handleEffectSelectModeInput(code: string, key: string): void { const control = handleListControlKey(code, key, EFFECT_SEQUENCE, state.effectSelectIndex, (effect) => effect.label); @@ -2339,6 +2559,11 @@ function codeFromKey(key: string, location: number): string | null { if (key === '/' || key === '?') return 'Slash'; if (key === ',' || key === '<') return 'Comma'; if (key === '.' || key === '>') return 'Period'; + if (key === ';' || key === ':') return 'Semicolon'; + if (key === "'" || key === '"') return 'Quote'; + if (key === '[' || key === '{') return 'BracketLeft'; + if (key === ']' || key === '}') return 'BracketRight'; + if (key === '\\' || key === '|') return 'Backslash'; } return null; } @@ -2411,6 +2636,7 @@ function setupInputHandlers(): void { nickname: handleNicknameModeInput, chat: handleChatModeInput, micGainEdit: handleMicGainEditModeInput, + pianoUse: (currentCode) => handlePianoUseModeInput(currentCode), effectSelect: (currentCode, currentKey) => handleEffectSelectModeInput(currentCode, currentKey), helpView: (currentCode) => handleHelpViewModeInput(currentCode), listUsers: (currentCode, currentKey) => handleListModeInput(currentCode, currentKey), @@ -2431,6 +2657,16 @@ function setupInputHandlers(): void { document.addEventListener('keyup', (event) => { const code = normalizeInputCode(event); + if (state.mode === 'pianoUse' && code) { + if (activePianoKeys.delete(code)) { + pianoSynth.noteOff(code); + const itemId = activePianoItemId; + const midi = getPianoMidiForCode(code); + if (itemId && midi !== null) { + signaling.send({ type: 'item_piano_note', itemId, keyId: code, midi, on: false }); + } + } + } if (code) { state.keysPressed[code] = false; } diff --git a/client/src/network/messageHandlers.ts b/client/src/network/messageHandlers.ts index 61fb676..d2e7c94 100644 --- a/client/src/network/messageHandlers.ts +++ b/client/src/network/messageHandlers.ts @@ -46,9 +46,23 @@ type MessageHandlerDeps = { sanitizeName: (value: string) => string; randomFootstepUrl: () => string; playRemoteSpatialStepOrTeleport: (url: string, peerX: number, peerY: number) => void; + playRemotePianoNote: (note: { + itemId: string; + senderId: string; + keyId: string; + midi: number; + instrument: string; + attack: number; + decay: number; + x: number; + y: number; + emitRange: number; + }) => void; + stopRemotePianoNote: (senderId: string, keyId: string) => void; + stopAllRemotePianoNotesForSender: (senderId: string) => void; TELEPORT_SOUND_URL: string; TELEPORT_START_SOUND_URL: string; - getAudioLayers: () => { world: boolean }; + getAudioLayers: () => { world: boolean; item: boolean }; pushChatMessage: (message: string) => void; classifySystemMessageSound: (message: string) => 'logon' | 'logout' | 'notify' | null; SYSTEM_SOUND_URLS: { logon: string; logout: string; notify: string }; @@ -159,6 +173,7 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco if (peer) { deps.updateStatus(`${peer.nickname} has left.`); } + deps.stopAllRemotePianoNotesForSender(message.id); deps.state.peers.delete(message.id); deps.peerManager.removePeer(message.id); break; @@ -226,7 +241,7 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco if (message.action === 'use') { deps.pushChatMessage(message.message); const item = message.itemId ? deps.getItemById(message.itemId) : null; - if (!item?.useSound && item) { + if (!item?.useSound && item && item.type !== 'piano') { deps.playLocateToneAt(item.x, item.y); } } else if (message.action !== 'update') { @@ -248,6 +263,27 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco } break; } + + case 'item_piano_note': { + if (!deps.getAudioLayers().item) break; + if (message.on) { + deps.playRemotePianoNote({ + itemId: message.itemId, + senderId: message.senderId, + keyId: message.keyId, + midi: message.midi, + instrument: message.instrument, + attack: message.attack, + decay: message.decay, + x: message.x, + y: message.y, + emitRange: message.emitRange, + }); + } else { + deps.stopRemotePianoNote(message.senderId, message.keyId); + } + break; + } } }; } diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index 4be201e..5f32dc9 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; export const itemSchema = z.object({ id: z.string(), - type: z.enum(['radio_station', 'dice', 'wheel', 'clock', 'widget']), + type: z.enum(['radio_station', 'dice', 'wheel', 'clock', 'widget', 'piano']), title: z.string(), x: z.number().int(), y: z.number().int(), @@ -42,10 +42,10 @@ export const welcomeMessageSchema = z.object({ .optional(), uiDefinitions: z .object({ - itemTypeOrder: z.array(z.enum(['radio_station', 'dice', 'wheel', 'clock', 'widget'])), + itemTypeOrder: z.array(z.enum(['radio_station', 'dice', 'wheel', 'clock', 'widget', 'piano'])), itemTypes: z.array( z.object({ - type: z.enum(['radio_station', 'dice', 'wheel', 'clock', 'widget']), + type: z.enum(['radio_station', 'dice', 'wheel', 'clock', 'widget', 'piano']), label: z.string().optional(), tooltip: z.string().optional(), editableProperties: z.array(z.string()), @@ -150,6 +150,21 @@ export const itemUseSoundSchema = z.object({ y: z.number().int(), }); +export const itemPianoNoteSchema = z.object({ + type: z.literal('item_piano_note'), + itemId: z.string(), + senderId: z.string(), + keyId: z.string(), + midi: z.number().int().min(0).max(127), + on: z.boolean(), + instrument: z.string(), + attack: z.number().int().min(0).max(100), + decay: z.number().int().min(0).max(100), + x: z.number().int(), + y: z.number().int(), + emitRange: z.number().int().min(1), +}); + export const incomingMessageSchema = z.discriminatedUnion('type', [ welcomeMessageSchema, signalMessageSchema, @@ -163,6 +178,7 @@ export const incomingMessageSchema = z.discriminatedUnion('type', [ itemRemoveSchema, itemActionResultSchema, itemUseSoundSchema, + itemPianoNoteSchema, ]); export type IncomingMessage = z.infer; @@ -173,11 +189,12 @@ export type OutgoingMessage = | { type: 'update_nickname'; nickname: string } | { type: 'chat_message'; message: string } | { type: 'ping'; clientSentAt: number } - | { type: 'item_add'; itemType: 'radio_station' | 'dice' | 'wheel' | 'clock' | 'widget' } + | { type: 'item_add'; itemType: 'radio_station' | 'dice' | 'wheel' | 'clock' | 'widget' | 'piano' } | { type: 'item_pickup'; itemId: string } | { type: 'item_drop'; itemId: string; x: number; y: number } | { type: 'item_delete'; itemId: string } | { type: 'item_use'; itemId: string } + | { type: 'item_piano_note'; itemId: string; keyId: string; midi: number; on: boolean } | { type: 'item_update'; itemId: string; diff --git a/client/src/render/canvasRenderer.ts b/client/src/render/canvasRenderer.ts index 3667169..7500577 100644 --- a/client/src/render/canvasRenderer.ts +++ b/client/src/render/canvasRenderer.ts @@ -89,6 +89,8 @@ export class CanvasRenderer { ? '#fbbf24' : item.type === 'wheel' ? '#f97316' + : item.type === 'piano' + ? '#c4b5fd' : item.type === 'clock' ? '#86efac' : item.type === 'widget' @@ -103,6 +105,8 @@ export class CanvasRenderer { ? 'R' : item.type === 'wheel' ? 'W' + : item.type === 'piano' + ? 'P' : item.type === 'clock' ? 'C' : item.type === 'widget' diff --git a/client/src/state/gameState.ts b/client/src/state/gameState.ts index 8ee5be9..dc55a5f 100644 --- a/client/src/state/gameState.ts +++ b/client/src/state/gameState.ts @@ -2,7 +2,7 @@ export const GRID_SIZE = 41; export const HEARING_RADIUS = 20; export const MOVE_COOLDOWN_MS = 200; -export type ItemType = 'radio_station' | 'dice' | 'wheel' | 'clock' | 'widget'; +export type ItemType = 'radio_station' | 'dice' | 'wheel' | 'clock' | 'widget' | 'piano'; export type WorldItem = { id: string; @@ -36,7 +36,8 @@ export type GameMode = | 'selectItem' | 'itemProperties' | 'itemPropertyEdit' - | 'itemPropertyOptionSelect'; + | 'itemPropertyOptionSelect' + | 'pianoUse'; export type Player = { id: string | null; diff --git a/docs/controls.md b/docs/controls.md index 0dbb5b6..0558f63 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -74,6 +74,13 @@ Applies to effect select, user/item list modes, item selection, item property li - `Space`: Read tooltip/help for current option (where metadata is available) - First-letter navigation: jump to next matching entry +## Piano Use Mode + +- `A S D F G H J K L ; '`: Play white keys (C major from C4 upward) +- `W E T Y U O P ]`: Play sharps +- Multiple keys can be held/played at once +- `Enter` / `Escape`: Exit piano mode + ## Help Viewer Mode - `ArrowUp` / `ArrowDown`: Previous/next help line diff --git a/docs/item-schema.md b/docs/item-schema.md index e8ef520..a10ac87 100644 --- a/docs/item-schema.md +++ b/docs/item-schema.md @@ -5,7 +5,7 @@ ```json { "id": "string", - "type": "radio_station | dice | wheel | clock | widget", + "type": "radio_station | dice | wheel | clock | widget | piano", "title": "string", "x": 0, "y": 0, @@ -24,8 +24,8 @@ - `useSound`: optional client-played one-shot sound when item `use` succeeds; global item field and not user-editable in V1. - `emitSound`: optional continuously-looping spatial sound emitted from the item on the grid; global item field and not user-editable in V1. - `capabilities`, `useSound`, and `emitSound` are derived from global item-type definitions at runtime (not stored per-instance in persisted state). -- `useCooldownMs`: global per item type (`radio_station=1000`, `dice=1000`, `wheel=4000`, `clock=1000`, `widget=1000`), not per-instance editable. -- `emitRange`: global spatial range default per item type (`radio_station=20`, `dice=15`, `wheel=15`, `clock=10`, `widget=15`). +- `useCooldownMs`: global per item type (`radio_station=1000`, `dice=1000`, `wheel=4000`, `clock=1000`, `widget=1000`, `piano=1000`), not per-instance editable. +- `emitRange`: global spatial range default per item type (`radio_station=20`, `dice=15`, `wheel=15`, `clock=10`, `widget=15`, `piano=15`). - `radio_station` can override this per instance via `params.emitRange` (`5..20`). - `directional`: global directional attenuation flag per item type (`radio_station=true`, others `false`); `widget` can override per instance via `params.directional`. @@ -34,7 +34,7 @@ ```json { "id": "string", - "type": "radio_station | dice | wheel | clock | widget", + "type": "radio_station | dice | wheel | clock | widget | piano", "title": "string", "x": 0, "y": 0, @@ -158,6 +158,23 @@ - `useSound`: empty, filename (assumed under `sounds/`), or full URL. - `emitSound`: empty, filename (assumed under `sounds/`), or full URL. +### `piano` + +```json +{ + "instrument": "piano", + "attack": 15, + "decay": 45, + "emitRange": 15 +} +``` + +- `instrument`: one of + `piano | electric_piano | guitar | organ | bass | violin | synth_lead | drum_kit`. +- `attack`: integer, range `0-100`, default `15`. +- `decay`: integer, range `0-100`, default `45`. +- `emitRange`: integer, range `5-20`, default `15`. + ## Packet Shapes - `item_upsert`: @@ -201,3 +218,22 @@ "y": 8 } ``` + +- `item_piano_note`: + +```json +{ + "type": "item_piano_note", + "itemId": "item-id", + "senderId": "user-id", + "keyId": "KeyA", + "midi": 60, + "on": true, + "instrument": "piano", + "attack": 15, + "decay": 45, + "x": 12, + "y": 8, + "emitRange": 15 +} +``` diff --git a/docs/item-types.md b/docs/item-types.md index b9c7ce6..c99f902 100644 --- a/docs/item-types.md +++ b/docs/item-types.md @@ -151,6 +151,31 @@ This is behavior-focused documentation for item types and their defaults. - `useSound`: empty, filename (assumed under `sounds/`), or full URL - `emitSound`: empty, filename (assumed under `sounds/`), or full URL +## `piano` + +### Defaults +- Title: `piano` +- Params: + - `instrument="piano"` + - `attack=15` + - `decay=45` + - `emitRange=15` +- Global: + - `useSound=none` + - `emitSound=none` + - `useCooldownMs=1000` + - `emitRange=15` + - `directional=false` + +### Use +- Announces that the user begins playing the piano (client enters piano key mode). + +### Validation +- `instrument`: `piano | electric_piano | guitar | organ | bass | violin | synth_lead | drum_kit` +- `attack`: integer `0..100` +- `decay`: integer `0..100` +- `emitRange`: integer `5..20` + ## Adding A New Item Type (Registry V1) Item types are currently code-registered on both server and client. Server item logic is split per item module and wired through one registry. diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index 77410e0..ba32bea 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -15,6 +15,7 @@ This is a behavior guide for packet semantics beyond raw schemas. - `chat_message`: player chat. - `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. ## Server -> Client @@ -28,12 +29,17 @@ This is a behavior guide for packet semantics beyond raw schemas. - `item_remove`: item deletion. - `item_action_result`: action success/failure and user-facing message. - `item_use_sound`: spatial one-shot sound on successful item use (if `useSound` configured). +- `item_piano_note`: broadcast piano note on/off with resolved instrument/envelope/spatial params. ## Item Packet Behavior - `item_upsert` is full-state replacement for one item, not partial patch. - `item_action_result` messages are intended for direct screen-reader/user status feedback. - `item_use_sound` contains absolute item world coordinates (`x`, `y`) and sound path. +- `item_piano_note` contains: + - `itemId`, `senderId`, `keyId`, `midi`, `on` + - resolved `instrument`, `attack`, `decay`, `emitRange` + - absolute source coordinates `x`, `y` ## Welcome Metadata diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md index 22ed750..1112f21 100644 --- a/docs/runtime-flow.md +++ b/docs/runtime-flow.md @@ -41,6 +41,7 @@ Core incoming message effects: - `item_remove`: remove item and cleanup runtimes. - `item_action_result`: success/error status for actions. - `item_use_sound`: play one-shot spatial sample (world layer gated). +- `item_piano_note`: start/stop synthesized piano notes from remote users (item layer gated). - `pong`: - positive `clientSentAt`: user ping response (`P` command) - negative `clientSentAt`: internal heartbeat response diff --git a/server/app/item_catalog.py b/server/app/item_catalog.py index 3a4c732..68e4a7d 100644 --- a/server/app/item_catalog.py +++ b/server/app/item_catalog.py @@ -5,10 +5,10 @@ from __future__ import annotations from dataclasses import dataclass from typing import Literal, cast -from .items import clock, radio +from .items import clock, piano, radio from .items.registry import ITEM_MODULES, ITEM_TYPE_ORDER -ItemType = Literal["radio_station", "dice", "wheel", "clock", "widget"] +ItemType = Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"] ITEM_TYPE_SEQUENCE: tuple[ItemType, ...] = cast(tuple[ItemType, ...], ITEM_TYPE_ORDER) ITEM_TYPE_LABELS: dict[ItemType, str] = {item_type: ITEM_MODULES[item_type].LABEL for item_type in ITEM_TYPE_SEQUENCE} ITEM_TYPE_EDITABLE_PROPERTIES: dict[ItemType, tuple[str, ...]] = { @@ -19,6 +19,7 @@ CLOCK_DEFAULT_TIME_ZONE = clock.DEFAULT_TIME_ZONE CLOCK_TIME_ZONE_OPTIONS = clock.TIME_ZONE_OPTIONS RADIO_EFFECT_OPTIONS = radio.EFFECT_OPTIONS RADIO_CHANNEL_OPTIONS = radio.CHANNEL_OPTIONS +PIANO_INSTRUMENT_OPTIONS = piano.INSTRUMENT_OPTIONS @dataclass(frozen=True) @@ -79,6 +80,7 @@ ITEM_PROPERTY_OPTIONS: dict[str, tuple[str, ...]] = { "emitEffect": RADIO_EFFECT_OPTIONS, "mediaChannel": RADIO_CHANNEL_OPTIONS, "timeZone": CLOCK_TIME_ZONE_OPTIONS, + "instrument": PIANO_INSTRUMENT_OPTIONS, } ITEM_TYPE_TOOLTIPS: dict[ItemType, str] = { diff --git a/server/app/item_service.py b/server/app/item_service.py index 8966d60..dcaf6f7 100644 --- a/server/app/item_service.py +++ b/server/app/item_service.py @@ -33,7 +33,7 @@ class ItemService: return int(time.time() * 1000) - def default_item(self, client: ClientConnection, item_type: Literal["radio_station", "dice", "wheel", "clock", "widget"]) -> WorldItem: + def default_item(self, client: ClientConnection, item_type: Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"]) -> WorldItem: """Create a new server-authoritative item at the caller's position.""" item_def = get_item_definition(item_type) diff --git a/server/app/items/piano.py b/server/app/items/piano.py new file mode 100644 index 0000000..bff80be --- /dev/null +++ b/server/app/items/piano.py @@ -0,0 +1,100 @@ +"""Piano item schema metadata and behavior.""" + +from __future__ import annotations + +from typing import Callable + +from ..item_types import ItemUseResult +from ..models import WorldItem + +LABEL = "piano" +TOOLTIP = "Playable keyboard instrument with multiple synth voices." +EDITABLE_PROPERTIES: tuple[str, ...] = ("title", "instrument", "attack", "decay", "emitRange") +CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable") +USE_SOUND: str | None = None +EMIT_SOUND: str | None = None +USE_COOLDOWN_MS = 1000 +EMIT_RANGE = 15 +DIRECTIONAL = False +DEFAULT_TITLE = "piano" +DEFAULT_PARAMS: dict = { + "instrument": "piano", + "attack": 15, + "decay": 45, + "emitRange": 15, +} + +INSTRUMENT_OPTIONS: tuple[str, ...] = ( + "piano", + "electric_piano", + "guitar", + "organ", + "bass", + "violin", + "synth_lead", + "drum_kit", +) + +PROPERTY_METADATA: dict[str, dict[str, object]] = { + "title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80}, + "instrument": {"valueType": "list", "tooltip": "Instrument voice used when playing this piano."}, + "attack": { + "valueType": "number", + "tooltip": "How quickly notes ramp in. Lower is sharper; higher is softer.", + "range": {"min": 0, "max": 100, "step": 1}, + }, + "decay": { + "valueType": "number", + "tooltip": "How long notes ring out after the initial hit.", + "range": {"min": 0, "max": 100, "step": 1}, + }, + "emitRange": { + "valueType": "number", + "tooltip": "Maximum distance in squares where this piano can be heard.", + "range": {"min": 5, "max": 20, "step": 1}, + }, +} + + +def validate_update(_item: WorldItem, next_params: dict) -> dict: + """Validate and normalize piano params.""" + + instrument = str(next_params.get("instrument", "piano")).strip().lower() + if instrument not in INSTRUMENT_OPTIONS: + raise ValueError(f"instrument must be one of: {', '.join(INSTRUMENT_OPTIONS)}.") + next_params["instrument"] = instrument + + try: + attack = int(next_params.get("attack", 15)) + except (TypeError, ValueError) as exc: + raise ValueError("attack must be an integer between 0 and 100.") from exc + if not (0 <= attack <= 100): + raise ValueError("attack must be between 0 and 100.") + next_params["attack"] = attack + + try: + decay = int(next_params.get("decay", 45)) + except (TypeError, ValueError) as exc: + raise ValueError("decay must be an integer between 0 and 100.") from exc + if not (0 <= decay <= 100): + raise ValueError("decay must be between 0 and 100.") + next_params["decay"] = decay + + try: + emit_range = int(next_params.get("emitRange", 15)) + except (TypeError, ValueError) as exc: + raise ValueError("emitRange must be an integer between 5 and 20.") from exc + if not (5 <= emit_range <= 20): + raise ValueError("emitRange must be between 5 and 20.") + next_params["emitRange"] = emit_range + + return next_params + + +def use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult: + """Enter piano play mode for the user who used the item.""" + + return ItemUseResult( + self_message=f"You begin playing {item.title}.", + others_message=f"{nickname} begins playing {item.title}.", + ) diff --git a/server/app/items/registry.py b/server/app/items/registry.py index d819367..1711615 100644 --- a/server/app/items/registry.py +++ b/server/app/items/registry.py @@ -7,7 +7,7 @@ from typing import Callable, Protocol from ..item_types import ItemUseResult from ..models import WorldItem -from . import clock, dice, radio, wheel, widget +from . import clock, dice, piano, radio, wheel, widget class ItemModule(Protocol): @@ -29,11 +29,12 @@ class ItemModule(Protocol): use_item: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult] -ITEM_TYPE_ORDER: tuple[str, ...] = ("clock", "dice", "radio_station", "wheel", "widget") +ITEM_TYPE_ORDER: tuple[str, ...] = ("clock", "dice", "piano", "radio_station", "wheel", "widget") ITEM_MODULES: dict[str, ItemModule] = { "clock": clock, "dice": dice, + "piano": piano, "radio_station": radio, "wheel": wheel, "widget": widget, diff --git a/server/app/models.py b/server/app/models.py index d133a3f..ec5d4dd 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -42,7 +42,7 @@ class PingPacket(BasePacket): class ItemAddPacket(BasePacket): type: Literal["item_add"] - itemType: Literal["radio_station", "dice", "wheel", "clock", "widget"] + itemType: Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"] class ItemPickupPacket(BasePacket): @@ -67,6 +67,14 @@ class ItemUsePacket(BasePacket): itemId: str +class ItemPianoNotePacket(BasePacket): + type: Literal["item_piano_note"] + itemId: str + keyId: str = Field(min_length=1, max_length=32) + midi: int = Field(ge=0, le=127) + on: bool + + class ItemUpdatePacket(BasePacket): type: Literal["item_update"] itemId: str @@ -85,6 +93,7 @@ ClientPacket = ( | ItemDropPacket | ItemDeletePacket | ItemUsePacket + | ItemPianoNotePacket | ItemUpdatePacket ) @@ -157,7 +166,7 @@ class NicknameResultPacket(BasePacket): class WorldItem(BaseModel): id: str - type: Literal["radio_station", "dice", "wheel", "clock", "widget"] + type: Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"] title: str x: int y: int @@ -175,7 +184,7 @@ class WorldItem(BaseModel): class PersistedWorldItem(BaseModel): model_config = ConfigDict(extra="ignore") id: str - type: Literal["radio_station", "dice", "wheel", "clock", "widget"] + type: Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"] title: str x: int y: int @@ -211,3 +220,18 @@ class ItemUseSoundPacket(BasePacket): sound: str x: int y: int + + +class ItemPianoNoteBroadcastPacket(BasePacket): + type: Literal["item_piano_note"] + itemId: str + senderId: str + keyId: str + midi: int + on: bool + instrument: str + attack: int + decay: int + x: int + y: int + emitRange: int diff --git a/server/app/server.py b/server/app/server.py index a6a017f..d5324a0 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -46,6 +46,8 @@ from .models import ( ItemAddPacket, ItemDeletePacket, ItemDropPacket, + ItemPianoNoteBroadcastPacket, + ItemPianoNotePacket, ItemPickupPacket, ItemRemovePacket, ItemUpdatePacket, @@ -656,6 +658,39 @@ class SignalingServer: ) return + if isinstance(packet, ItemPianoNotePacket): + item = self.items.get(packet.itemId) + if not item or item.type != "piano": + return + if item.carrierId not in (None, client.id): + return + if item.carrierId is None and (item.x != client.x or item.y != client.y): + return + instrument = str(item.params.get("instrument", "piano")).strip().lower() + 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 + emit_range = int(item.params.get("emitRange", 15)) if isinstance(item.params.get("emitRange", 15), (int, float)) else 15 + source_x = client.x if item.carrierId == client.id else item.x + source_y = client.y if item.carrierId == client.id else item.y + await self._broadcast( + ItemPianoNoteBroadcastPacket( + type="item_piano_note", + itemId=item.id, + senderId=client.id, + keyId=packet.keyId, + midi=packet.midi, + on=packet.on, + instrument=instrument, + attack=max(0, min(100, attack)), + decay=max(0, min(100, decay)), + x=source_x, + y=source_y, + emitRange=max(5, min(20, emit_range)), + ), + exclude=client.websocket, + ) + return + if isinstance(packet, ItemUpdatePacket): item = self.items.get(packet.itemId) if not item: diff --git a/server/tests/test_item_use_cooldown.py b/server/tests/test_item_use_cooldown.py index d90a9dd..31883c4 100644 --- a/server/tests/test_item_use_cooldown.py +++ b/server/tests/test_item_use_cooldown.py @@ -339,3 +339,102 @@ async def test_widget_update_and_use(monkeypatch: pytest.MonkeyPatch) -> None: ) assert send_payloads[-1].ok is False assert "emitsoundtempo must be between 0 and 100" in send_payloads[-1].message.lower() + + +@pytest.mark.asyncio +async def test_piano_update_and_use(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] = [] + broadcast_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: + broadcast_payloads.append(packet) + + monkeypatch.setattr(server, "_send", fake_send) + monkeypatch.setattr(server, "_broadcast", fake_broadcast) + + await server._handle_message( + client, + json.dumps( + { + "type": "item_update", + "itemId": item.id, + "params": { + "instrument": "drum_kit", + "attack": 22, + "decay": 67, + "emitRange": 12, + }, + } + ), + ) + assert send_payloads[-1].ok is True + assert item.params.get("instrument") == "drum_kit" + assert item.params.get("attack") == 22 + assert item.params.get("decay") == 67 + assert item.params.get("emitRange") == 12 + + await server._handle_message(client, json.dumps({"type": "item_use", "itemId": item.id})) + assert send_payloads[-1].ok is True + assert "begin playing" in send_payloads[-1].message.lower() + assert not any(getattr(packet, "type", "") == "item_use_sound" for packet in broadcast_payloads) + + await server._handle_message( + client, + json.dumps({"type": "item_update", "itemId": item.id, "params": {"instrument": "banjo"}}), + ) + assert send_payloads[-1].ok is False + assert "instrument must be one of" in send_payloads[-1].message.lower() + + +@pytest.mark.asyncio +async def test_piano_note_packet_broadcasts(monkeypatch: pytest.MonkeyPatch) -> None: + server = SignalingServer("127.0.0.1", 8765, None, None) + ws_sender = _fake_ws() + sender = ClientConnection(websocket=ws_sender, id="u1", nickname="tester", x=5, y=6) + ws_other = _fake_ws() + other = ClientConnection(websocket=ws_other, id="u2", nickname="listener", x=7, y=6) + server.clients[ws_sender] = sender + server.clients[ws_other] = other + item = server.item_service.default_item(sender, "piano") + item.params["instrument"] = "organ" + item.params["attack"] = 20 + item.params["decay"] = 60 + item.params["emitRange"] = 12 + server.item_service.add_item(item) + + send_payloads: list[object] = [] + broadcast_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: + broadcast_payloads.append(packet) + + monkeypatch.setattr(server, "_send", fake_send) + monkeypatch.setattr(server, "_broadcast", fake_broadcast) + + await server._handle_message( + sender, + json.dumps({"type": "item_piano_note", "itemId": item.id, "keyId": "KeyA", "midi": 60, "on": True}), + ) + + assert not send_payloads + assert broadcast_payloads + packet = broadcast_payloads[-1] + assert getattr(packet, "type", "") == "item_piano_note" + assert getattr(packet, "itemId", "") == item.id + assert getattr(packet, "instrument", "") == "organ" + assert getattr(packet, "attack", -1) == 20 + assert getattr(packet, "decay", -1) == 60 + assert getattr(packet, "emitRange", -1) == 12 diff --git a/server/tests/test_models.py b/server/tests/test_models.py index 03388ed..43c27b1 100644 --- a/server/tests/test_models.py +++ b/server/tests/test_models.py @@ -16,3 +16,9 @@ def test_unknown_type_rejected() -> None: except ValidationError: return assert False, "validation should fail" + + +def test_item_add_accepts_piano_type() -> None: + adapter = TypeAdapter(ClientPacket) + packet = adapter.validate_python({"type": "item_add", "itemType": "piano"}) + assert packet.type == "item_add"