diff --git a/client/public/help.json b/client/public/help.json index d4735f5..e8d72c9 100644 --- a/client/public/help.json +++ b/client/public/help.json @@ -87,7 +87,7 @@ }, { "keys": "Piano mode", - "description": "When using a piano: 1-9 changes instrument, ASDFGHJKL;' plays C major notes, WETYUOP] plays sharps, Enter/Escape exits" + "description": "When using a piano: 1-9 (and 0 for the 10th slot) changes instrument, ASDFGHJKL;' plays C major notes, WETYUOP] plays sharps, Enter/Escape exits" } ] }, diff --git a/client/public/version.js b/client/public/version.js index 21fda7e..a3a5674 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 R202"; +window.CHGRID_WEB_VERSION = "2026.02.22 R203"; // 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 index 43ce39b..d703f70 100644 --- a/client/src/audio/pianoSynth.ts +++ b/client/src/audio/pianoSynth.ts @@ -8,6 +8,7 @@ export const PIANO_INSTRUMENT_OPTIONS = [ 'bass', 'violin', 'synth_lead', + 'brass', 'nintendo', 'drum_kit', ] as const; @@ -20,6 +21,7 @@ type VoiceRuntime = { oscillators: OscillatorNode[]; modulators: OscillatorNode[]; releaseSeconds: number; + sourceGroupId: string; }; type PianoContext = { @@ -116,10 +118,21 @@ const PRESETS: Record, InstrumentPreset> releaseScale: 1, vibrato: { rateHz: 6.8, depthCents: 9 }, }, + brass: { + oscillators: [ + { type: 'sawtooth', gain: 0.72 }, + { type: 'square', ratio: 2, gain: 0.2 }, + ], + filter: { type: 'lowpass', frequency: 3300, q: 1.05 }, + gain: 0.22, + sustainRatio: 0.62, + releaseScale: 0.92, + vibrato: { rateHz: 5.1, depthCents: 5 }, + }, nintendo: { oscillators: [ { type: 'square', gain: 1 }, - { type: 'square', detune: 8, gain: 0.16 }, + { type: 'square', detune: 2, gain: 0.08 }, ], filter: { type: 'lowpass', frequency: 5200, q: 1.2 }, gain: 0.22, @@ -136,10 +149,11 @@ export const DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT: Record< electric_piano: { attack: 12, decay: 40, release: 30, brightness: 62 }, guitar: { attack: 8, decay: 35, release: 25, brightness: 50 }, organ: { attack: 25, decay: 70, release: 45, brightness: 48 }, - bass: { attack: 10, decay: 35, release: 28, brightness: 38 }, + bass: { attack: 2, decay: 24, release: 18, brightness: 34 }, violin: { attack: 22, decay: 75, release: 55, brightness: 58 }, synth_lead: { attack: 6, decay: 30, release: 22, brightness: 72 }, - nintendo: { attack: 2, decay: 28, release: 18, brightness: 85 }, + brass: { attack: 10, decay: 45, release: 30, brightness: 60 }, + nintendo: { attack: 1, decay: 24, release: 15, brightness: 85 }, drum_kit: { attack: 1, decay: 22, release: 12, brightness: 68 }, }; @@ -167,6 +181,28 @@ function brightnessPercentToMultiplier(value: number): number { return 0.45 + (clamped / 100) * 1.55; } +/** Maps midi note number to one deterministic drum voice variant. */ +function drumVariantForMidi(midi: number): DrumVariant { + const palette: DrumVariant[] = [ + 'kick_sub', + 'kick_punch', + 'snare_tight', + 'snare_body', + 'hat_closed', + 'hat_open', + 'tom_low', + 'tom_mid', + 'tom_high', + 'clap', + 'pow_mid', + 'pow_high', + 'snare_noise', + 'noise_8bit', + ]; + const index = ((Math.round(midi) % palette.length) + palette.length) % palette.length; + return palette[index]; +} + /** Converts midi note number to frequency in hertz. */ function midiToFrequency(midi: number): number { return 440 * Math.pow(2, (midi - 69) / 12); @@ -181,11 +217,25 @@ function safeStop(oscillator: OscillatorNode, when: number): void { } } -type DrumVariant = 'kick_808' | 'snare' | 'clap' | 'hat_closed' | 'hat_open' | 'tom_low' | 'tom_high' | 'noise_8bit'; -const DRUM_VARIANTS: DrumVariant[] = ['kick_808', 'snare', 'clap', 'hat_closed', 'hat_open', 'tom_low', 'tom_high', 'noise_8bit']; +type DrumVariant = + | 'kick_sub' + | 'kick_punch' + | 'snare_tight' + | 'snare_body' + | 'snare_noise' + | 'clap' + | 'hat_closed' + | 'hat_open' + | 'tom_low' + | 'tom_mid' + | 'tom_high' + | 'pow_mid' + | 'pow_high' + | 'noise_8bit'; export class PianoSynth { private readonly voices = new Map(); + private readonly activeVoiceKeysByGroup = new Map>(); private readonly drumNoiseBuffers = new WeakMap(); private readonly bitNoiseBuffers = new WeakMap(); @@ -199,8 +249,10 @@ export class PianoSynth { /** Starts one note for a specific keyboard key id. */ noteOn( keyId: string, + sourceGroupId: string, midi: number, instrument: PianoInstrumentId, + voiceMode: 'mono' | 'poly', attackPercent: number, decayPercent: number, releasePercent: number, @@ -209,8 +261,16 @@ export class PianoSynth { spatial: PianoSpatialSource, ): void { if (this.voices.has(keyId)) return; + if (voiceMode === 'mono') { + const previousKeys = this.activeVoiceKeysByGroup.get(sourceGroupId); + if (previousKeys) { + for (const previousKey of Array.from(previousKeys)) { + this.noteOff(previousKey); + } + } + } if (instrument === 'drum_kit') { - this.playDrumHit(keyId, midi, context, spatial, attackPercent, decayPercent, releasePercent, brightnessPercent); + this.playDrumHit(midi, context, spatial, attackPercent, decayPercent, releasePercent, brightnessPercent); return; } @@ -284,7 +344,11 @@ export class PianoSynth { oscillators, modulators, releaseSeconds, + sourceGroupId, }); + const groupKeys = this.activeVoiceKeysByGroup.get(sourceGroupId) ?? new Set(); + groupKeys.add(keyId); + this.activeVoiceKeysByGroup.set(sourceGroupId, groupKeys); } /** Releases one active note tied to a keyboard key id. */ @@ -292,6 +356,15 @@ export class PianoSynth { const voice = this.voices.get(keyId); if (!voice) return; this.voices.delete(keyId); + const groupKeys = this.activeVoiceKeysByGroup.get(voice.sourceGroupId); + if (groupKeys) { + groupKeys.delete(keyId); + if (groupKeys.size === 0) { + this.activeVoiceKeysByGroup.delete(voice.sourceGroupId); + } else { + this.activeVoiceKeysByGroup.set(voice.sourceGroupId, groupKeys); + } + } const now = voice.gain.context.currentTime; const currentGain = Math.max(0.0001, voice.gain.gain.value); voice.gain.gain.cancelScheduledValues(now); @@ -321,7 +394,6 @@ export class PianoSynth { /** Plays one synthesized drum hit for drum-kit instrument mode. */ private playDrumHit( - keyId: string, midi: number, context: PianoContext, spatial: PianoSpatialSource, @@ -338,8 +410,8 @@ export class PianoSynth { baseGain: 1, }); if (!spatialMix || spatialMix.gain <= 0) return; - const typeIndex = Math.abs((midi % DRUM_VARIANTS.length) + this.hashKey(keyId)) % DRUM_VARIANTS.length; - const variant = DRUM_VARIANTS[typeIndex]; + const variant = drumVariantForMidi(midi); + const midiOffset = (Math.round(midi) - 60) / 24; const decaySeconds = 0.03 + decayPercentToSeconds(decayPercent) * 0.5; const releaseSeconds = 0.02 + releasePercentToSeconds(releasePercent) * 0.35; const attackSeconds = Math.max(0.001, attackPercentToSeconds(attackPercent) * 0.18); @@ -350,26 +422,45 @@ export class PianoSynth { 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; + gain.connect(panner).connect(context.destination); } else { - tailNode.connect(context.destination); + gain.connect(context.destination); } - if (variant === 'kick_808') { - this.playKick808(context, gain, now, decaySeconds + releaseSeconds * 0.35); + if (variant === 'kick_sub') { + this.playKick808(context, gain, now, (decaySeconds + releaseSeconds * 0.35) * 1.15, 145, 36); + return; + } + if (variant === 'kick_punch') { + this.playKick808(context, gain, now, decaySeconds + releaseSeconds * 0.2, 185, 52); + return; + } + if (variant === 'snare_tight') { + this.playSnare(context, gain, now, decaySeconds * 0.55 + releaseSeconds * 0.08, 0.75); + return; + } + if (variant === 'snare_body') { + this.playSnare(context, gain, now, decaySeconds * 0.92 + releaseSeconds * 0.18, 1); + return; + } + if (variant === 'snare_noise') { + this.playSnare(context, gain, now, decaySeconds * 0.8 + releaseSeconds * 0.15, 0.45); + this.playNoiseDrum(context, gain, now, decaySeconds * 0.75, 'highpass', 1900 * brightnessMultiplier, true); return; } if (variant === 'tom_low') { - this.playTom(context, gain, now, 120, 68, decaySeconds * 0.95 + releaseSeconds * 0.2); + this.playTom(context, gain, now, 120, 70, decaySeconds * 0.95 + releaseSeconds * 0.2); + return; + } + if (variant === 'tom_mid') { + this.playTom(context, gain, now, 175, 100, decaySeconds * 0.86 + releaseSeconds * 0.16); return; } if (variant === 'tom_high') { - this.playTom(context, gain, now, 220, 125, decaySeconds * 0.8 + releaseSeconds * 0.16); + this.playTom(context, gain, now, 250, 138, decaySeconds * 0.78 + releaseSeconds * 0.14); return; } if (variant === 'hat_closed') { @@ -380,23 +471,38 @@ export class PianoSynth { this.playNoiseDrum(context, gain, now, decaySeconds * 0.8 + releaseSeconds * 0.2, 'highpass', 5200 * brightnessMultiplier, false); return; } - if (variant === 'noise_8bit') { - this.playNoiseDrum(context, gain, now, decaySeconds * 0.45, 'bandpass', 2700 * brightnessMultiplier, true); - return; - } if (variant === 'clap') { this.playClap(context, gain, now, decaySeconds + releaseSeconds * 0.1); return; } - this.playSnare(context, gain, now, decaySeconds + releaseSeconds * 0.12); + if (variant === 'pow_mid') { + this.playPowDown(context, gain, now, 310 + midiOffset * 30, 150 + midiOffset * 15, decaySeconds * 0.95 + releaseSeconds * 0.15); + return; + } + if (variant === 'pow_high') { + this.playPowDown(context, gain, now, 420 + midiOffset * 40, 210 + midiOffset * 22, decaySeconds * 0.88 + releaseSeconds * 0.12); + return; + } + if (variant === 'noise_8bit') { + this.playNoiseDrum(context, gain, now, decaySeconds * 0.45, 'bandpass', 2700 * brightnessMultiplier, true); + return; + } + this.playSnare(context, gain, now, decaySeconds + releaseSeconds * 0.12, 1); } /** 808-like kick: deep sine sweep with long-ish tail. */ - private playKick808(context: PianoContext, gain: GainNode, now: number, decaySeconds: number): void { + private playKick808( + context: PianoContext, + gain: GainNode, + now: number, + decaySeconds: number, + startHz: number, + endHz: number, + ): void { const kick = context.audioCtx.createOscillator(); kick.type = 'sine'; - kick.frequency.setValueAtTime(160, now); - kick.frequency.exponentialRampToValueAtTime(42, now + Math.max(0.07, decaySeconds * 0.95)); + kick.frequency.setValueAtTime(startHz, now); + kick.frequency.exponentialRampToValueAtTime(endHz, now + Math.max(0.07, decaySeconds * 0.95)); const body = context.audioCtx.createGain(); body.gain.setValueAtTime(1, now); body.gain.exponentialRampToValueAtTime(0.0001, now + Math.max(0.08, decaySeconds)); @@ -440,13 +546,13 @@ export class PianoSynth { } /** Snare: short tone + filtered noise burst. */ - private playSnare(context: PianoContext, gain: GainNode, now: number, decaySeconds: number): void { + private playSnare(context: PianoContext, gain: GainNode, now: number, decaySeconds: number, toneLevel: number): void { const tone = context.audioCtx.createOscillator(); tone.type = 'triangle'; tone.frequency.setValueAtTime(220, now); tone.frequency.exponentialRampToValueAtTime(130, now + Math.max(0.03, decaySeconds * 0.45)); const toneGain = context.audioCtx.createGain(); - toneGain.gain.setValueAtTime(0.45, now); + toneGain.gain.setValueAtTime(0.45 * Math.max(0, toneLevel), now); toneGain.gain.exponentialRampToValueAtTime(0.0001, now + Math.max(0.04, decaySeconds * 0.55)); tone.connect(toneGain).connect(gain); tone.start(now); @@ -473,13 +579,22 @@ export class PianoSynth { } } - /** 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; + /** Retro game-like downward-bending midrange hit for drum fills. */ + private playPowDown(context: PianoContext, gain: GainNode, now: number, startHz: number, endHz: number, decaySeconds: number): void { + const osc = context.audioCtx.createOscillator(); + osc.type = 'square'; + osc.frequency.setValueAtTime(startHz, now); + osc.frequency.exponentialRampToValueAtTime(Math.max(35, endHz), now + Math.max(0.04, decaySeconds * 0.9)); + const amp = context.audioCtx.createGain(); + amp.gain.setValueAtTime(0.75, now); + amp.gain.exponentialRampToValueAtTime(0.0001, now + Math.max(0.06, decaySeconds)); + const filter = context.audioCtx.createBiquadFilter(); + filter.type = 'bandpass'; + filter.frequency.setValueAtTime(1700, now); + filter.Q.setValueAtTime(1.2, now); + osc.connect(filter).connect(amp).connect(gain); + osc.start(now); + safeStop(osc, now + Math.max(0.08, decaySeconds) + 0.03); } /** Returns or lazily builds short white-noise buffer for percussion synthesis. */ diff --git a/client/src/items/itemPropertyEditor.ts b/client/src/items/itemPropertyEditor.ts index a32e3f9..cf060aa 100644 --- a/client/src/items/itemPropertyEditor.ts +++ b/client/src/items/itemPropertyEditor.ts @@ -317,6 +317,7 @@ export function createItemPropertyEditor(deps: EditorDeps): { propertyKey === 'mediaVolume' || propertyKey === 'emitVolume' || propertyKey === 'emitRange' || + propertyKey === 'octave' || propertyKey === 'attack' || propertyKey === 'decay' || propertyKey === 'release' || diff --git a/client/src/items/itemRegistry.ts b/client/src/items/itemRegistry.ts index c917226..8a96183 100644 --- a/client/src/items/itemRegistry.ts +++ b/client/src/items/itemRegistry.ts @@ -54,14 +54,16 @@ const DEFAULT_PIANO_INSTRUMENT_OPTIONS = [ 'bass', 'violin', 'synth_lead', + 'brass', 'nintendo', 'drum_kit', ] as const; +const DEFAULT_PIANO_VOICE_MODE_OPTIONS = ['poly', 'mono'] 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', 'release', 'brightness', 'emitRange'], + piano: ['title', 'instrument', 'voiceMode', 'octave', 'attack', 'decay', 'release', 'brightness', 'emitRange'], wheel: ['title', 'spaces'], clock: ['title', 'timeZone', 'use24Hour'], widget: ['title', 'enabled', 'directional', 'facing', 'emitRange', 'emitVolume', 'emitSoundSpeed', 'emitSoundTempo', 'emitEffect', 'emitEffectValue', 'useSound', 'emitSound'], @@ -133,6 +135,7 @@ let optionItemPropertyValues: Partial> = { emitEffect: EFFECT_SEQUENCE.map((effect) => effect.id), mediaChannel: [...RADIO_CHANNEL_OPTIONS], instrument: [...DEFAULT_PIANO_INSTRUMENT_OPTIONS], + voiceMode: [...DEFAULT_PIANO_VOICE_MODE_OPTIONS], timeZone: [...DEFAULT_CLOCK_TIME_ZONE_OPTIONS], }; let itemTypePropertyMetadata: Partial>> = {}; @@ -239,6 +242,8 @@ export function itemPropertyLabel(key: string): string { if (key === 'emitEffect') return 'emit effect'; if (key === 'emitEffectValue') return 'emit effect value'; if (key === 'instrument') return 'instrument'; + if (key === 'voiceMode') return 'voice mode'; + if (key === 'octave') return 'octave'; if (key === 'attack') return 'attack'; if (key === 'decay') return 'decay'; if (key === 'release') return 'release'; diff --git a/client/src/main.ts b/client/src/main.ts index 3459499..3d6f94c 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -255,6 +255,7 @@ let activeTeleportLoopStop: (() => void) | null = null; let activeTeleportLoopToken = 0; let activePianoItemId: string | null = null; const activePianoKeys = new Set(); +const activePianoKeyMidi = new Map(); const activeRemotePianoKeys = new Set(); let pianoPreviewTimeoutId: number | null = null; let activeTeleport: @@ -796,6 +797,8 @@ function getItemSpatialConfig(item: WorldItem): { range: number; directional: bo /** Resolves piano params with safe defaults for local play mode. */ function getPianoParams(item: WorldItem): { instrument: PianoInstrumentId; + voiceMode: 'mono' | 'poly'; + octave: number; attack: number; decay: number; release: number; @@ -816,12 +819,16 @@ function getPianoParams(item: WorldItem): { : 'piano'; const rawAttack = Number(item.params.attack); const rawDecay = Number(item.params.decay); + const rawOctave = Number(item.params.octave); + const rawVoiceMode = String(item.params.voiceMode ?? defaultsVoiceModeForInstrument(instrument)).trim().toLowerCase(); const rawRelease = Number(item.params.release); const rawBrightness = Number(item.params.brightness); const rawEmitRange = Number(item.params.emitRange ?? getItemTypeGlobalProperties(item.type).emitRange ?? 15); const defaults = DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT[instrument]; return { instrument, + voiceMode: rawVoiceMode === 'mono' ? 'mono' : 'poly', + octave: Math.max(-2, Math.min(2, Number.isFinite(rawOctave) ? Math.round(rawOctave) : defaultsOctaveForInstrument(instrument))), attack: Math.max(0, Math.min(100, Number.isFinite(rawAttack) ? Math.round(rawAttack) : defaults.attack)), decay: Math.max(0, Math.min(100, Number.isFinite(rawDecay) ? Math.round(rawDecay) : defaults.decay)), release: Math.max(0, Math.min(100, Number.isFinite(rawRelease) ? Math.round(rawRelease) : defaults.release)), @@ -830,6 +837,17 @@ function getPianoParams(item: WorldItem): { }; } +/** Returns default voice mode for a given piano instrument. */ +function defaultsVoiceModeForInstrument(instrument: PianoInstrumentId): 'mono' | 'poly' { + if (instrument === 'bass' || instrument === 'violin' || instrument === 'brass') return 'mono'; + return 'poly'; +} + +/** Returns default octave offset for a given piano instrument. */ +function defaultsOctaveForInstrument(instrument: PianoInstrumentId): number { + return instrument === 'bass' ? -1 : 0; +} + /** Normalizes arbitrary instrument strings into supported piano synth ids. */ function normalizePianoInstrument(value: unknown): PianoInstrumentId { const raw = String(value ?? 'piano').trim().toLowerCase(); @@ -839,6 +857,7 @@ function normalizePianoInstrument(value: unknown): PianoInstrumentId { if (raw === 'bass') return 'bass'; if (raw === 'violin') return 'violin'; if (raw === 'synth_lead') return 'synth_lead'; + if (raw === 'brass') return 'brass'; if (raw === 'nintendo') return 'nintendo'; if (raw === 'drum_kit') return 'drum_kit'; return 'piano'; @@ -872,13 +891,14 @@ 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; + const midi = activePianoKeyMidi.get(code); + if (!Number.isFinite(midi)) continue; signaling.send({ type: 'item_piano_note', itemId, keyId: code, midi, on: false }); pianoSynth.noteOff(code); } activePianoItemId = null; activePianoKeys.clear(); + activePianoKeyMidi.clear(); state.mode = 'normal'; if (announce) { updateStatus('Stopped piano.'); @@ -908,8 +928,10 @@ async function previewPianoSettingChange( pianoSynth.noteOff(previewKeyId); pianoSynth.noteOn( previewKeyId, + 'preview', 60, instrument, + 'poly', attack, decay, release, @@ -933,6 +955,8 @@ function playRemotePianoNote(note: { keyId: string; midi: number; instrument: string; + voiceMode: 'mono' | 'poly'; + octave: number; attack: number; decay: number; release: number; @@ -944,13 +968,18 @@ function playRemotePianoNote(note: { const ctx = audio.context; const destination = audio.getOutputDestinationNode(); if (!ctx || !destination) return; - const runtimeKey = `${note.senderId}:${note.keyId}`; + const runtimeKey = `${note.senderId}:${note.itemId}:${note.keyId}`; if (activeRemotePianoKeys.has(runtimeKey)) return; + if (note.voiceMode === 'mono') { + stopRemotePianoNotesForSource(note.senderId, note.itemId); + } activeRemotePianoKeys.add(runtimeKey); pianoSynth.noteOn( runtimeKey, + `remote:${note.senderId}:${note.itemId}`, Math.max(0, Math.min(127, Math.round(note.midi))), normalizePianoInstrument(note.instrument), + note.voiceMode, Math.max(0, Math.min(100, Math.round(note.attack))), Math.max(0, Math.min(100, Math.round(note.decay))), Math.max(0, Math.min(100, Math.round(note.release))), @@ -966,9 +995,13 @@ function playRemotePianoNote(note: { /** 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); + const suffix = `:${keyId}`; + for (const runtimeKey of Array.from(activeRemotePianoKeys)) { + if (!runtimeKey.startsWith(`${senderId}:`)) continue; + if (!runtimeKey.endsWith(suffix)) continue; + activeRemotePianoKeys.delete(runtimeKey); + pianoSynth.noteOff(runtimeKey); + } } /** Stops all currently active remote piano notes for a sender id. */ @@ -981,6 +1014,16 @@ function stopAllRemotePianoNotesForSender(senderId: string): void { } } +/** Stops all remote piano notes for one sender+item source group. */ +function stopRemotePianoNotesForSource(senderId: string, itemId: string): void { + const prefix = `${senderId}:${itemId}:`; + 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) { @@ -1195,7 +1238,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' || key === 'instrument') return 'list'; + if (key === 'mediaChannel' || key === 'mediaEffect' || key === 'emitEffect' || key === 'timeZone' || key === 'instrument' || key === 'voiceMode') return 'list'; if ( key === 'x' || key === 'y' || @@ -1208,6 +1251,7 @@ function inferItemPropertyValueType(item: WorldItem, key: string): string | unde key === 'emitEffectValue' || key === 'facing' || key === 'emitRange' || + key === 'octave' || key === 'attack' || key === 'decay' || key === 'release' || @@ -2285,11 +2329,16 @@ function handlePianoUseModeInput(code: string): void { } if (code.startsWith('Digit')) { const digit = Number(code.slice(5)); - if (Number.isInteger(digit) && digit >= 1 && digit <= 9) { - const instrument = PIANO_INSTRUMENT_OPTIONS[digit - 1]; + const instrumentIndex = digit === 0 ? 9 : digit - 1; + if (Number.isInteger(instrumentIndex) && instrumentIndex >= 0 && instrumentIndex < PIANO_INSTRUMENT_OPTIONS.length) { + const instrument = PIANO_INSTRUMENT_OPTIONS[instrumentIndex]; if (instrument) { const defaults = DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT[instrument]; + const voiceMode = defaultsVoiceModeForInstrument(instrument); + const octave = defaultsOctaveForInstrument(instrument); item.params.instrument = instrument; + item.params.voiceMode = voiceMode; + item.params.octave = octave; item.params.attack = defaults.attack; item.params.decay = defaults.decay; item.params.release = defaults.release; @@ -2317,17 +2366,21 @@ function handlePianoUseModeInput(code: string): void { const midi = getPianoMidiForCode(code); if (midi === null) return; if (activePianoKeys.has(code)) return; + const config = getPianoParams(item); + const playedMidi = Math.max(0, Math.min(127, midi + config.octave * 12)); activePianoKeys.add(code); + activePianoKeyMidi.set(code, playedMidi); 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, + `local:${itemId}`, + playedMidi, config.instrument, + config.voiceMode, config.attack, config.decay, config.release, @@ -2335,7 +2388,7 @@ function handlePianoUseModeInput(code: string): void { { 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 }); + signaling.send({ type: 'item_piano_note', itemId, keyId: code, midi: playedMidi, on: true }); } /** Handles effect menu list navigation and selection. */ @@ -2623,6 +2676,10 @@ const itemPropertyEditor = createItemPropertyEditor({ const brightness = Number(value); if (!Number.isFinite(brightness)) return; void previewPianoSettingChange(item, { brightness }); + return; + } + if (key === 'octave') { + void previewPianoSettingChange(item, {}); } }, updateStatus, @@ -2802,8 +2859,9 @@ function setupInputHandlers(): void { if (activePianoKeys.delete(code)) { pianoSynth.noteOff(code); const itemId = activePianoItemId; - const midi = getPianoMidiForCode(code); - if (itemId && midi !== null) { + 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 }); } } diff --git a/client/src/network/messageHandlers.ts b/client/src/network/messageHandlers.ts index b064dfd..c23307c 100644 --- a/client/src/network/messageHandlers.ts +++ b/client/src/network/messageHandlers.ts @@ -52,6 +52,8 @@ type MessageHandlerDeps = { keyId: string; midi: number; instrument: string; + voiceMode: 'mono' | 'poly'; + octave: number; attack: number; decay: number; release: number; @@ -275,6 +277,8 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco keyId: message.keyId, midi: message.midi, instrument: message.instrument, + voiceMode: message.voiceMode, + octave: message.octave, attack: message.attack, decay: message.decay, release: message.release, diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index aab9a4a..a78303f 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -158,6 +158,8 @@ export const itemPianoNoteSchema = z.object({ midi: z.number().int().min(0).max(127), on: z.boolean(), instrument: z.string(), + voiceMode: z.enum(['mono', 'poly']), + octave: z.number().int().min(-2).max(2), attack: z.number().int().min(0).max(100), decay: z.number().int().min(0).max(100), release: z.number().int().min(0).max(100), diff --git a/docs/controls.md b/docs/controls.md index 7311f2c..4f9c948 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -76,7 +76,7 @@ Applies to effect select, user/item list modes, item selection, item property li ## Piano Use Mode -- `1-9`: Switch instrument preset quickly +- `1-9` (and `0` for the 10th slot): Switch instrument preset quickly - `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 diff --git a/docs/item-schema.md b/docs/item-schema.md index 0be6c33..babddf9 100644 --- a/docs/item-schema.md +++ b/docs/item-schema.md @@ -163,6 +163,8 @@ ```json { "instrument": "piano", + "voiceMode": "poly", + "octave": 0, "attack": 15, "decay": 45, "release": 35, @@ -172,8 +174,10 @@ ``` - `instrument`: one of - `piano | electric_piano | guitar | organ | bass | violin | synth_lead | nintendo | drum_kit`. -- Selecting a new instrument resets `attack`/`decay` to that instrument's defaults. + `piano | electric_piano | guitar | organ | bass | violin | synth_lead | brass | nintendo | drum_kit`. +- `voiceMode`: one of `poly | mono`. +- `octave`: integer, range `-2..2` (default `0`; bass defaults to `-1`). +- Selecting a new instrument resets `voiceMode`/`octave`/`attack`/`decay`/`release`/`brightness` to that instrument's defaults. - `attack`: integer, range `0-100`, default `15`. - `decay`: integer, range `0-100`, default `45`. - `release`: integer, range `0-100`, default `35`. @@ -235,8 +239,12 @@ "midi": 60, "on": true, "instrument": "piano", + "voiceMode": "poly", + "octave": 0, "attack": 15, "decay": 45, + "release": 35, + "brightness": 55, "x": 12, "y": 8, "emitRange": 15 diff --git a/docs/item-types.md b/docs/item-types.md index a357e11..bc72030 100644 --- a/docs/item-types.md +++ b/docs/item-types.md @@ -157,6 +157,8 @@ This is behavior-focused documentation for item types and their defaults. - Title: `piano` - Params: - `instrument="piano"` + - `voiceMode="poly"` + - `octave=0` - `attack=15` - `decay=45` - `release=35` @@ -173,13 +175,15 @@ This is behavior-focused documentation for item types and their defaults. - Announces that the user begins playing the piano (client enters piano key mode). ### Validation -- `instrument`: `piano | electric_piano | guitar | organ | bass | violin | synth_lead | nintendo | drum_kit` +- `instrument`: `piano | electric_piano | guitar | organ | bass | violin | synth_lead | brass | nintendo | drum_kit` +- `voiceMode`: `poly | mono` +- `octave`: integer `-2..2` - `attack`: integer `0..100` - `decay`: integer `0..100` - `release`: integer `0..100` - `brightness`: integer `0..100` - `emitRange`: integer `5..20` -- Instrument changes reset `attack`/`decay`/`release`/`brightness` to instrument defaults. +- Instrument changes reset `voiceMode`/`octave`/`attack`/`decay`/`release`/`brightness` to instrument defaults. ## Adding A New Item Type (Registry V1) diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index ba32bea..39e01aa 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -38,7 +38,7 @@ This is a behavior guide for packet semantics beyond raw schemas. - `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` + - resolved `instrument`, `voiceMode`, `octave`, `attack`, `decay`, `release`, `brightness`, `emitRange` - absolute source coordinates `x`, `y` ## Welcome Metadata diff --git a/server/app/item_catalog.py b/server/app/item_catalog.py index 68e4a7d..bb3213f 100644 --- a/server/app/item_catalog.py +++ b/server/app/item_catalog.py @@ -20,6 +20,7 @@ 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 +PIANO_VOICE_MODE_OPTIONS = piano.VOICE_MODE_OPTIONS @dataclass(frozen=True) @@ -81,6 +82,7 @@ ITEM_PROPERTY_OPTIONS: dict[str, tuple[str, ...]] = { "mediaChannel": RADIO_CHANNEL_OPTIONS, "timeZone": CLOCK_TIME_ZONE_OPTIONS, "instrument": PIANO_INSTRUMENT_OPTIONS, + "voiceMode": PIANO_VOICE_MODE_OPTIONS, } ITEM_TYPE_TOOLTIPS: dict[ItemType, str] = { diff --git a/server/app/items/piano.py b/server/app/items/piano.py index 33204a0..30d38cd 100644 --- a/server/app/items/piano.py +++ b/server/app/items/piano.py @@ -9,7 +9,17 @@ from ..models import WorldItem LABEL = "piano" TOOLTIP = "Playable keyboard instrument with multiple synth voices." -EDITABLE_PROPERTIES: tuple[str, ...] = ("title", "instrument", "attack", "decay", "release", "brightness", "emitRange") +EDITABLE_PROPERTIES: tuple[str, ...] = ( + "title", + "instrument", + "voiceMode", + "octave", + "attack", + "decay", + "release", + "brightness", + "emitRange", +) CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable") USE_SOUND: str | None = None EMIT_SOUND: str | None = None @@ -19,6 +29,8 @@ DIRECTIONAL = False DEFAULT_TITLE = "piano" DEFAULT_PARAMS: dict = { "instrument": "piano", + "voiceMode": "poly", + "octave": 0, "attack": 15, "decay": 45, "release": 35, @@ -34,25 +46,34 @@ INSTRUMENT_OPTIONS: tuple[str, ...] = ( "bass", "violin", "synth_lead", + "brass", "nintendo", "drum_kit", ) +VOICE_MODE_OPTIONS: tuple[str, ...] = ("poly", "mono") -DEFAULT_ENVELOPE_BY_INSTRUMENT: dict[str, tuple[int, int, int, int]] = { - "piano": (15, 45, 35, 55), - "electric_piano": (12, 40, 30, 62), - "guitar": (8, 35, 25, 50), - "organ": (25, 70, 45, 48), - "bass": (10, 35, 28, 38), - "violin": (22, 75, 55, 58), - "synth_lead": (6, 30, 22, 72), - "nintendo": (2, 28, 18, 85), - "drum_kit": (1, 22, 12, 68), +DEFAULT_ENVELOPE_BY_INSTRUMENT: dict[str, tuple[int, int, int, int, str, int]] = { + "piano": (15, 45, 35, 55, "poly", 0), + "electric_piano": (12, 40, 30, 62, "poly", 0), + "guitar": (8, 35, 25, 50, "poly", 0), + "organ": (25, 70, 45, 48, "poly", 0), + "bass": (2, 24, 18, 34, "mono", -1), + "violin": (22, 75, 55, 58, "mono", 0), + "synth_lead": (6, 30, 22, 72, "poly", 0), + "brass": (10, 45, 30, 60, "mono", 0), + "nintendo": (1, 24, 15, 85, "poly", 0), + "drum_kit": (1, 22, 12, 68, "poly", 0), } 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."}, + "voiceMode": {"valueType": "list", "tooltip": "Mono plays one note at a time; poly allows chords."}, + "octave": { + "valueType": "number", + "tooltip": "Shifts played notes in octaves. -1 is one octave down.", + "range": {"min": -2, "max": 2, "step": 1}, + }, "attack": { "valueType": "number", "tooltip": "How quickly notes ramp in. Lower is sharper; higher is softer.", @@ -90,6 +111,19 @@ def validate_update(_item: WorldItem, next_params: dict) -> dict: previous_instrument = str(_item.params.get("instrument", "piano")).strip().lower() next_params["instrument"] = instrument + voice_mode = str(next_params.get("voiceMode", _item.params.get("voiceMode", "poly"))).strip().lower() + if voice_mode not in VOICE_MODE_OPTIONS: + raise ValueError("voiceMode must be one of: poly, mono.") + next_params["voiceMode"] = voice_mode + + try: + octave = int(next_params.get("octave", _item.params.get("octave", 0))) + except (TypeError, ValueError) as exc: + raise ValueError("octave must be an integer between -2 and 2.") from exc + if not (-2 <= octave <= 2): + raise ValueError("octave must be between -2 and 2.") + next_params["octave"] = octave + try: attack = int(next_params.get("attack", 15)) except (TypeError, ValueError) as exc: @@ -119,7 +153,11 @@ def validate_update(_item: WorldItem, next_params: dict) -> dict: # When instrument changes, reset envelope to instrument-appropriate defaults. if instrument != previous_instrument: - attack, decay, release, brightness = DEFAULT_ENVELOPE_BY_INSTRUMENT.get(instrument, (15, 45, 35, 55)) + attack, decay, release, brightness, voice_mode, octave = DEFAULT_ENVELOPE_BY_INSTRUMENT.get( + instrument, (15, 45, 35, 55, "poly", 0) + ) + next_params["voiceMode"] = voice_mode + next_params["octave"] = octave next_params["attack"] = attack next_params["decay"] = decay next_params["release"] = release diff --git a/server/app/models.py b/server/app/models.py index 5c95940..ac8c55d 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -230,6 +230,8 @@ class ItemPianoNoteBroadcastPacket(BasePacket): midi: int on: bool instrument: str + voiceMode: str + octave: int attack: int decay: int release: int diff --git a/server/app/server.py b/server/app/server.py index b26efe7..b7eb4d4 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -677,6 +677,10 @@ class SignalingServer: else: active_keys.discard(packet.keyId) 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 @@ -693,6 +697,8 @@ class SignalingServer: midi=packet.midi, on=packet.on, instrument=instrument, + voiceMode=voice_mode, + octave=max(-2, min(2, octave)), attack=max(0, min(100, attack)), decay=max(0, min(100, decay)), release=max(0, min(100, release)), diff --git a/server/tests/test_item_use_cooldown.py b/server/tests/test_item_use_cooldown.py index 71a020f..7b3cb9d 100644 --- a/server/tests/test_item_use_cooldown.py +++ b/server/tests/test_item_use_cooldown.py @@ -377,6 +377,8 @@ async def test_piano_update_and_use(monkeypatch: pytest.MonkeyPatch) -> None: ) assert send_payloads[-1].ok is True assert item.params.get("instrument") == "drum_kit" + assert item.params.get("voiceMode") == "poly" + assert item.params.get("octave") == 0 assert item.params.get("attack") == 1 assert item.params.get("decay") == 22 assert item.params.get("release") == 12 @@ -389,9 +391,11 @@ async def test_piano_update_and_use(monkeypatch: pytest.MonkeyPatch) -> None: ) assert send_payloads[-1].ok is True assert item.params.get("instrument") == "nintendo" - assert item.params.get("attack") == 2 - assert item.params.get("decay") == 28 - assert item.params.get("release") == 18 + assert item.params.get("voiceMode") == "poly" + assert item.params.get("octave") == 0 + assert item.params.get("attack") == 1 + assert item.params.get("decay") == 24 + assert item.params.get("release") == 15 assert item.params.get("brightness") == 85 await server._handle_message(client, json.dumps({"type": "item_use", "itemId": item.id})) @@ -406,6 +410,21 @@ async def test_piano_update_and_use(monkeypatch: pytest.MonkeyPatch) -> None: assert send_payloads[-1].ok is False assert "instrument must be one of" in send_payloads[-1].message.lower() + await server._handle_message( + client, + json.dumps({"type": "item_update", "itemId": item.id, "params": {"voiceMode": "mono", "octave": -2}}), + ) + assert send_payloads[-1].ok is True + assert item.params.get("voiceMode") == "mono" + assert item.params.get("octave") == -2 + + await server._handle_message( + client, + json.dumps({"type": "item_update", "itemId": item.id, "params": {"octave": 3}}), + ) + assert send_payloads[-1].ok is False + assert "octave must be between -2 and 2" in send_payloads[-1].message.lower() + @pytest.mark.asyncio async def test_piano_note_packet_broadcasts(monkeypatch: pytest.MonkeyPatch) -> None: @@ -446,6 +465,8 @@ async def test_piano_note_packet_broadcasts(monkeypatch: pytest.MonkeyPatch) -> assert getattr(packet, "type", "") == "item_piano_note" assert getattr(packet, "itemId", "") == item.id assert getattr(packet, "instrument", "") == "organ" + assert getattr(packet, "voiceMode", "") == "poly" + assert getattr(packet, "octave", 999) == 0 assert getattr(packet, "attack", -1) == 20 assert getattr(packet, "decay", -1) == 60 assert getattr(packet, "release", -1) == 35