diff --git a/client/public/help.json b/client/public/help.json index 7e31c2b..d4735f5 100644 --- a/client/public/help.json +++ b/client/public/help.json @@ -87,7 +87,7 @@ }, { "keys": "Piano mode", - "description": "When using a piano: ASDFGHJKL;' plays C major notes, WETYUOP] plays sharps, Enter/Escape exits" + "description": "When using a piano: 1-9 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 92a1ee6..21fda7e 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 R201"; +window.CHGRID_WEB_VERSION = "2026.02.22 R202"; // 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 123822d..43ce39b 100644 --- a/client/src/audio/pianoSynth.ts +++ b/client/src/audio/pianoSynth.ts @@ -128,16 +128,19 @@ const PRESETS: Record, InstrumentPreset> }, }; -export const DEFAULT_ENVELOPE_BY_INSTRUMENT: Record = { - piano: { attack: 15, decay: 45 }, - electric_piano: { attack: 12, decay: 40 }, - guitar: { attack: 8, decay: 35 }, - organ: { attack: 25, decay: 70 }, - bass: { attack: 10, decay: 35 }, - violin: { attack: 22, decay: 75 }, - synth_lead: { attack: 6, decay: 30 }, - nintendo: { attack: 2, decay: 28 }, - drum_kit: { attack: 1, decay: 22 }, +export const DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT: Record< + PianoInstrumentId, + { attack: number; decay: number; release: number; brightness: number } +> = { + piano: { attack: 15, decay: 45, release: 35, brightness: 55 }, + 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 }, + 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 }, + drum_kit: { attack: 1, decay: 22, release: 12, brightness: 68 }, }; /** Maps 0..100 control values to note attack seconds. */ @@ -152,6 +155,18 @@ function decayPercentToSeconds(value: number): number { return 0.05 + (clamped / 100) * 2.7; } +/** Maps 0..100 control values to release tail seconds after note-off. */ +function releasePercentToSeconds(value: number): number { + const clamped = Math.max(0, Math.min(100, value)); + return 0.03 + (clamped / 100) * 3.4; +} + +/** Maps 0..100 control values to low-pass filter brightness multiplier. */ +function brightnessPercentToMultiplier(value: number): number { + const clamped = Math.max(0, Math.min(100, value)); + return 0.45 + (clamped / 100) * 1.55; +} + /** Converts midi note number to frequency in hertz. */ function midiToFrequency(midi: number): number { return 440 * Math.pow(2, (midi - 69) / 12); @@ -188,12 +203,14 @@ export class PianoSynth { instrument: PianoInstrumentId, attackPercent: number, decayPercent: number, + releasePercent: number, + brightnessPercent: number, context: PianoContext, spatial: PianoSpatialSource, ): void { if (this.voices.has(keyId)) return; if (instrument === 'drum_kit') { - this.playDrumHit(keyId, midi, context, spatial, attackPercent, decayPercent); + this.playDrumHit(keyId, midi, context, spatial, attackPercent, decayPercent, releasePercent, brightnessPercent); return; } @@ -201,7 +218,7 @@ export class PianoSynth { const now = context.audioCtx.currentTime; const attackSeconds = attackPercentToSeconds(attackPercent); const decaySeconds = decayPercentToSeconds(decayPercent); - const releaseSeconds = Math.max(0.02, decaySeconds * (preset.releaseScale ?? 1)); + const releaseSeconds = Math.max(0.02, releasePercentToSeconds(releasePercent) * (preset.releaseScale ?? 1)); const spatialMix = resolveSpatialMix({ dx: spatial.x, @@ -222,7 +239,7 @@ export class PianoSynth { if (preset.filter) { const filter = context.audioCtx.createBiquadFilter(); filter.type = preset.filter.type; - filter.frequency.setValueAtTime(preset.filter.frequency, now); + filter.frequency.setValueAtTime(preset.filter.frequency * brightnessPercentToMultiplier(brightnessPercent), now); filter.Q.setValueAtTime(preset.filter.q ?? 0.7, now); voiceGain.connect(filter); tailNode = filter; @@ -310,6 +327,8 @@ export class PianoSynth { spatial: PianoSpatialSource, attackPercent: number, decayPercent: number, + releasePercent: number, + brightnessPercent: number, ): void { const now = context.audioCtx.currentTime; const spatialMix = resolveSpatialMix({ @@ -322,7 +341,9 @@ export class PianoSynth { const typeIndex = Math.abs((midi % DRUM_VARIANTS.length) + this.hashKey(keyId)) % DRUM_VARIANTS.length; const variant = DRUM_VARIANTS[typeIndex]; 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); + const brightnessMultiplier = brightnessPercentToMultiplier(brightnessPercent); const gain = context.audioCtx.createGain(); gain.gain.setValueAtTime(0.0001, now); @@ -340,34 +361,34 @@ export class PianoSynth { } if (variant === 'kick_808') { - this.playKick808(context, gain, now, decaySeconds); + this.playKick808(context, gain, now, decaySeconds + releaseSeconds * 0.35); return; } if (variant === 'tom_low') { - this.playTom(context, gain, now, 120, 68, decaySeconds * 0.95); + this.playTom(context, gain, now, 120, 68, decaySeconds * 0.95 + releaseSeconds * 0.2); return; } if (variant === 'tom_high') { - this.playTom(context, gain, now, 220, 125, decaySeconds * 0.8); + this.playTom(context, gain, now, 220, 125, decaySeconds * 0.8 + releaseSeconds * 0.16); return; } if (variant === 'hat_closed') { - this.playNoiseDrum(context, gain, now, decaySeconds * 0.25, 'highpass', 6500, false); + this.playNoiseDrum(context, gain, now, decaySeconds * 0.25, 'highpass', 6500 * brightnessMultiplier, false); return; } if (variant === 'hat_open') { - this.playNoiseDrum(context, gain, now, decaySeconds * 0.8, 'highpass', 5200, false); + 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, true); + this.playNoiseDrum(context, gain, now, decaySeconds * 0.45, 'bandpass', 2700 * brightnessMultiplier, true); return; } if (variant === 'clap') { - this.playClap(context, gain, now, decaySeconds); + this.playClap(context, gain, now, decaySeconds + releaseSeconds * 0.1); return; } - this.playSnare(context, gain, now, decaySeconds); + this.playSnare(context, gain, now, decaySeconds + releaseSeconds * 0.12); } /** 808-like kick: deep sine sweep with long-ish tail. */ diff --git a/client/src/items/itemPropertyEditor.ts b/client/src/items/itemPropertyEditor.ts index 8820124..a32e3f9 100644 --- a/client/src/items/itemPropertyEditor.ts +++ b/client/src/items/itemPropertyEditor.ts @@ -319,6 +319,8 @@ export function createItemPropertyEditor(deps: EditorDeps): { propertyKey === 'emitRange' || propertyKey === 'attack' || propertyKey === 'decay' || + propertyKey === 'release' || + propertyKey === 'brightness' || propertyKey === 'sides' || propertyKey === 'number' ) { diff --git a/client/src/items/itemRegistry.ts b/client/src/items/itemRegistry.ts index c2a0cba..c917226 100644 --- a/client/src/items/itemRegistry.ts +++ b/client/src/items/itemRegistry.ts @@ -61,7 +61,7 @@ const DEFAULT_PIANO_INSTRUMENT_OPTIONS = [ 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'], + piano: ['title', 'instrument', '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'], @@ -241,6 +241,8 @@ export function itemPropertyLabel(key: string): string { if (key === 'instrument') return 'instrument'; if (key === 'attack') return 'attack'; if (key === 'decay') return 'decay'; + if (key === 'release') return 'release'; + if (key === 'brightness') return 'brightness'; 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 0a1d2dc..3459499 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -14,7 +14,12 @@ import { shouldProxyStreamUrl, } from './audio/radioStationRuntime'; import { ItemEmitRuntime } from './audio/itemEmitRuntime'; -import { DEFAULT_ENVELOPE_BY_INSTRUMENT, PianoSynth, type PianoInstrumentId } from './audio/pianoSynth'; +import { + DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT, + PIANO_INSTRUMENT_OPTIONS, + PianoSynth, + type PianoInstrumentId, +} from './audio/pianoSynth'; import { normalizeDegrees } from './audio/spatial'; import { applyPastedText, @@ -789,7 +794,14 @@ function getItemSpatialConfig(item: WorldItem): { range: number; directional: bo } /** Resolves piano params with safe defaults for local play mode. */ -function getPianoParams(item: WorldItem): { instrument: PianoInstrumentId; attack: number; decay: number; emitRange: number } { +function getPianoParams(item: WorldItem): { + instrument: PianoInstrumentId; + attack: number; + decay: number; + release: number; + brightness: number; + emitRange: number; +} { const rawInstrument = String(item.params.instrument ?? 'piano').trim().toLowerCase(); const instrument: PianoInstrumentId = rawInstrument === 'electric_piano' || @@ -804,11 +816,16 @@ function getPianoParams(item: WorldItem): { instrument: PianoInstrumentId; attac : 'piano'; const rawAttack = Number(item.params.attack); const rawDecay = Number(item.params.decay); + 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, - 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)), + 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)), + brightness: Math.max(0, Math.min(100, Number.isFinite(rawBrightness) ? Math.round(rawBrightness) : defaults.brightness)), emitRange: Math.max(5, Math.min(20, Number.isFinite(rawEmitRange) ? Math.round(rawEmitRange) : 15)), }; } @@ -870,7 +887,10 @@ function stopPianoUseMode(announce = true): void { } /** Plays one short C4 preview using the piano item's current/overridden envelope+instrument. */ -async function previewPianoSettingChange(item: WorldItem, overrides: Partial<{ instrument: PianoInstrumentId; attack: number; decay: number }>): Promise { +async function previewPianoSettingChange( + item: WorldItem, + overrides: Partial<{ instrument: PianoInstrumentId; attack: number; decay: number; release: number; brightness: number }>, +): Promise { if (item.type !== 'piano') return; await audio.ensureContext(); const ctx = audio.context; @@ -880,6 +900,8 @@ async function previewPianoSettingChange(item: WorldItem, overrides: Partial<{ i const instrument = overrides.instrument ?? current.instrument; const attack = Math.max(0, Math.min(100, Math.round(overrides.attack ?? current.attack))); const decay = Math.max(0, Math.min(100, Math.round(overrides.decay ?? current.decay))); + const release = Math.max(0, Math.min(100, Math.round(overrides.release ?? current.release))); + const brightness = Math.max(0, Math.min(100, Math.round(overrides.brightness ?? current.brightness))); const sourceX = item.carrierId === state.player.id ? state.player.x : item.x; const sourceY = item.carrierId === state.player.id ? state.player.y : item.y; const previewKeyId = '__piano_preview_c4__'; @@ -890,6 +912,8 @@ async function previewPianoSettingChange(item: WorldItem, overrides: Partial<{ i instrument, attack, decay, + release, + brightness, { audioCtx: ctx, destination }, { x: sourceX - state.player.x, y: sourceY - state.player.y, range: current.emitRange }, ); @@ -911,6 +935,8 @@ function playRemotePianoNote(note: { instrument: string; attack: number; decay: number; + release: number; + brightness: number; x: number; y: number; emitRange: number; @@ -927,6 +953,8 @@ function playRemotePianoNote(note: { normalizePianoInstrument(note.instrument), 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))), + Math.max(0, Math.min(100, Math.round(note.brightness))), { audioCtx: ctx, destination }, { x: note.x - state.player.x, @@ -1182,6 +1210,8 @@ function inferItemPropertyValueType(item: WorldItem, key: string): string | unde key === 'emitRange' || key === 'attack' || key === 'decay' || + key === 'release' || + key === 'brightness' || key === 'sides' || key === 'number' || key === 'useCooldownMs' @@ -2253,6 +2283,37 @@ function handlePianoUseModeInput(code: string): void { stopPianoUseMode(false); return; } + 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]; + if (instrument) { + const defaults = DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT[instrument]; + item.params.instrument = instrument; + item.params.attack = defaults.attack; + item.params.decay = defaults.decay; + item.params.release = defaults.release; + item.params.brightness = defaults.brightness; + signaling.send({ + type: 'item_update', + itemId, + params: { + instrument, + }, + }); + void previewPianoSettingChange(item, { + instrument, + attack: defaults.attack, + decay: defaults.decay, + release: defaults.release, + brightness: defaults.brightness, + }); + updateStatus(`Instrument ${instrument}.`); + audio.sfxUiBlip(); + } + return; + } + } const midi = getPianoMidiForCode(code); if (midi === null) return; if (activePianoKeys.has(code)) return; @@ -2269,6 +2330,8 @@ function handlePianoUseModeInput(code: string): void { config.instrument, config.attack, config.decay, + config.release, + config.brightness, { audioCtx: ctx, destination }, { x: sourceX - state.player.x, y: sourceY - state.player.y, range: config.emitRange }, ); @@ -2528,8 +2591,14 @@ const itemPropertyEditor = createItemPropertyEditor({ if (item.type !== 'piano') return; if (key === 'instrument') { const instrument = normalizePianoInstrument(value); - const defaults = DEFAULT_ENVELOPE_BY_INSTRUMENT[instrument]; - void previewPianoSettingChange(item, { instrument, attack: defaults.attack, decay: defaults.decay }); + const defaults = DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT[instrument]; + void previewPianoSettingChange(item, { + instrument, + attack: defaults.attack, + decay: defaults.decay, + release: defaults.release, + brightness: defaults.brightness, + }); return; } if (key === 'attack') { @@ -2542,6 +2611,18 @@ const itemPropertyEditor = createItemPropertyEditor({ const decay = Number(value); if (!Number.isFinite(decay)) return; void previewPianoSettingChange(item, { decay }); + return; + } + if (key === 'release') { + const release = Number(value); + if (!Number.isFinite(release)) return; + void previewPianoSettingChange(item, { release }); + return; + } + if (key === 'brightness') { + const brightness = Number(value); + if (!Number.isFinite(brightness)) return; + void previewPianoSettingChange(item, { brightness }); } }, updateStatus, diff --git a/client/src/network/messageHandlers.ts b/client/src/network/messageHandlers.ts index d2e7c94..b064dfd 100644 --- a/client/src/network/messageHandlers.ts +++ b/client/src/network/messageHandlers.ts @@ -54,6 +54,8 @@ type MessageHandlerDeps = { instrument: string; attack: number; decay: number; + release: number; + brightness: number; x: number; y: number; emitRange: number; @@ -275,6 +277,8 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco instrument: message.instrument, attack: message.attack, decay: message.decay, + release: message.release, + brightness: message.brightness, x: message.x, y: message.y, emitRange: message.emitRange, diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index 5f32dc9..aab9a4a 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -160,6 +160,8 @@ export const itemPianoNoteSchema = z.object({ instrument: z.string(), attack: z.number().int().min(0).max(100), decay: z.number().int().min(0).max(100), + release: z.number().int().min(0).max(100), + brightness: z.number().int().min(0).max(100), x: z.number().int(), y: z.number().int(), emitRange: z.number().int().min(1), diff --git a/docs/controls.md b/docs/controls.md index 0558f63..7311f2c 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -76,6 +76,7 @@ Applies to effect select, user/item list modes, item selection, item property li ## Piano Use Mode +- `1-9`: 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 db3385c..0be6c33 100644 --- a/docs/item-schema.md +++ b/docs/item-schema.md @@ -165,6 +165,8 @@ "instrument": "piano", "attack": 15, "decay": 45, + "release": 35, + "brightness": 55, "emitRange": 15 } ``` @@ -174,6 +176,8 @@ - Selecting a new instrument resets `attack`/`decay` 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`. +- `brightness`: integer, range `0-100`, default `55`. - `emitRange`: integer, range `5-20`, default `15`. ## Packet Shapes diff --git a/docs/item-types.md b/docs/item-types.md index fbe3809..a357e11 100644 --- a/docs/item-types.md +++ b/docs/item-types.md @@ -159,6 +159,8 @@ This is behavior-focused documentation for item types and their defaults. - `instrument="piano"` - `attack=15` - `decay=45` + - `release=35` + - `brightness=55` - `emitRange=15` - Global: - `useSound=none` @@ -174,8 +176,10 @@ This is behavior-focused documentation for item types and their defaults. - `instrument`: `piano | electric_piano | guitar | organ | bass | violin | synth_lead | nintendo | drum_kit` - `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` to instrument defaults. +- Instrument changes reset `attack`/`decay`/`release`/`brightness` to instrument defaults. ## Adding A New Item Type (Registry V1) diff --git a/server/app/items/piano.py b/server/app/items/piano.py index c30aa2a..33204a0 100644 --- a/server/app/items/piano.py +++ b/server/app/items/piano.py @@ -9,7 +9,7 @@ from ..models import WorldItem LABEL = "piano" TOOLTIP = "Playable keyboard instrument with multiple synth voices." -EDITABLE_PROPERTIES: tuple[str, ...] = ("title", "instrument", "attack", "decay", "emitRange") +EDITABLE_PROPERTIES: tuple[str, ...] = ("title", "instrument", "attack", "decay", "release", "brightness", "emitRange") CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable") USE_SOUND: str | None = None EMIT_SOUND: str | None = None @@ -21,6 +21,8 @@ DEFAULT_PARAMS: dict = { "instrument": "piano", "attack": 15, "decay": 45, + "release": 35, + "brightness": 55, "emitRange": 15, } @@ -36,16 +38,16 @@ INSTRUMENT_OPTIONS: tuple[str, ...] = ( "drum_kit", ) -DEFAULT_ENVELOPE_BY_INSTRUMENT: dict[str, tuple[int, int]] = { - "piano": (15, 45), - "electric_piano": (12, 40), - "guitar": (8, 35), - "organ": (25, 70), - "bass": (10, 35), - "violin": (22, 75), - "synth_lead": (6, 30), - "nintendo": (2, 28), - "drum_kit": (1, 22), +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), } PROPERTY_METADATA: dict[str, dict[str, object]] = { @@ -61,6 +63,16 @@ PROPERTY_METADATA: dict[str, dict[str, object]] = { "tooltip": "How long notes ring out after the initial hit.", "range": {"min": 0, "max": 100, "step": 1}, }, + "release": { + "valueType": "number", + "tooltip": "How long notes continue after key release.", + "range": {"min": 0, "max": 100, "step": 1}, + }, + "brightness": { + "valueType": "number", + "tooltip": "Tone brightness; higher values sound brighter.", + "range": {"min": 0, "max": 100, "step": 1}, + }, "emitRange": { "valueType": "number", "tooltip": "Maximum distance in squares where this piano can be heard.", @@ -91,11 +103,27 @@ def validate_update(_item: WorldItem, next_params: dict) -> dict: if not (0 <= decay <= 100): raise ValueError("decay must be between 0 and 100.") + try: + release = int(next_params.get("release", 35)) + except (TypeError, ValueError) as exc: + raise ValueError("release must be an integer between 0 and 100.") from exc + if not (0 <= release <= 100): + raise ValueError("release must be between 0 and 100.") + + try: + brightness = int(next_params.get("brightness", 55)) + except (TypeError, ValueError) as exc: + raise ValueError("brightness must be an integer between 0 and 100.") from exc + if not (0 <= brightness <= 100): + raise ValueError("brightness must be between 0 and 100.") + # When instrument changes, reset envelope to instrument-appropriate defaults. if instrument != previous_instrument: - attack, decay = DEFAULT_ENVELOPE_BY_INSTRUMENT.get(instrument, (15, 45)) + attack, decay, release, brightness = DEFAULT_ENVELOPE_BY_INSTRUMENT.get(instrument, (15, 45, 35, 55)) next_params["attack"] = attack next_params["decay"] = decay + next_params["release"] = release + next_params["brightness"] = brightness try: emit_range = int(next_params.get("emitRange", 15)) diff --git a/server/app/models.py b/server/app/models.py index ec5d4dd..5c95940 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -232,6 +232,8 @@ class ItemPianoNoteBroadcastPacket(BasePacket): instrument: str attack: int decay: int + release: int + brightness: int x: int y: int emitRange: int diff --git a/server/app/server.py b/server/app/server.py index 76090ca..b26efe7 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -68,7 +68,7 @@ from .models import ( LOGGER = logging.getLogger("chgrid.server") PACKET_LOGGER = logging.getLogger("chgrid.server.packet") CLIENT_PACKET_ADAPTER = TypeAdapter(ClientPacket) -MAX_ACTIVE_PIANO_KEYS_PER_CLIENT = 32 +MAX_ACTIVE_PIANO_KEYS_PER_CLIENT = 12 class SignalingServer: @@ -679,6 +679,8 @@ class SignalingServer: 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 + release = int(item.params.get("release", 35)) if isinstance(item.params.get("release", 35), (int, float)) else 35 + brightness = int(item.params.get("brightness", 55)) if isinstance(item.params.get("brightness", 55), (int, float)) else 55 emit_range = int(item.params.get("emitRange", 15)) if isinstance(item.params.get("emitRange", 15), (int, float)) else 15 source_x = client.x if item.carrierId == client.id else item.x source_y = client.y if item.carrierId == client.id else item.y @@ -693,6 +695,8 @@ class SignalingServer: instrument=instrument, attack=max(0, min(100, attack)), decay=max(0, min(100, decay)), + release=max(0, min(100, release)), + brightness=max(0, min(100, brightness)), x=source_x, y=source_y, emitRange=max(5, min(20, emit_range)), diff --git a/server/tests/test_item_use_cooldown.py b/server/tests/test_item_use_cooldown.py index 49fa841..71a020f 100644 --- a/server/tests/test_item_use_cooldown.py +++ b/server/tests/test_item_use_cooldown.py @@ -379,6 +379,8 @@ async def test_piano_update_and_use(monkeypatch: pytest.MonkeyPatch) -> None: assert item.params.get("instrument") == "drum_kit" assert item.params.get("attack") == 1 assert item.params.get("decay") == 22 + assert item.params.get("release") == 12 + assert item.params.get("brightness") == 68 assert item.params.get("emitRange") == 12 await server._handle_message( @@ -389,6 +391,8 @@ async def test_piano_update_and_use(monkeypatch: pytest.MonkeyPatch) -> None: 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("brightness") == 85 await server._handle_message(client, json.dumps({"type": "item_use", "itemId": item.id})) assert send_payloads[-1].ok is True @@ -444,6 +448,8 @@ async def test_piano_note_packet_broadcasts(monkeypatch: pytest.MonkeyPatch) -> assert getattr(packet, "instrument", "") == "organ" assert getattr(packet, "attack", -1) == 20 assert getattr(packet, "decay", -1) == 60 + assert getattr(packet, "release", -1) == 35 + assert getattr(packet, "brightness", -1) == 55 assert getattr(packet, "emitRange", -1) == 12 @@ -467,16 +473,16 @@ async def test_piano_note_key_cap(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(server, "_send", fake_send) monkeypatch.setattr(server, "_broadcast", fake_broadcast) - for index in range(32): + for index in range(12): await server._handle_message( sender, json.dumps({"type": "item_piano_note", "itemId": item.id, "keyId": f"Key{index}", "midi": 60, "on": True}), ) - assert len(broadcast_payloads) == 32 + assert len(broadcast_payloads) == 12 - # 33rd distinct held key is dropped by cap. + # 13th distinct held key is dropped by cap. await server._handle_message( sender, json.dumps({"type": "item_piano_note", "itemId": item.id, "keyId": "KeyOverflow", "midi": 60, "on": True}), ) - assert len(broadcast_payloads) == 32 + assert len(broadcast_payloads) == 12