Add piano release/brightness controls and instrument hotkeys

This commit is contained in:
Jage9
2026-02-23 00:05:01 -05:00
parent d9e9e60524
commit 019e49802d
15 changed files with 210 additions and 49 deletions

View File

@@ -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"
}
]
},

View File

@@ -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";

View File

@@ -128,16 +128,19 @@ const PRESETS: Record<Exclude<PianoInstrumentId, 'drum_kit'>, InstrumentPreset>
},
};
export const DEFAULT_ENVELOPE_BY_INSTRUMENT: Record<PianoInstrumentId, { attack: number; decay: number }> = {
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. */

View File

@@ -319,6 +319,8 @@ export function createItemPropertyEditor(deps: EditorDeps): {
propertyKey === 'emitRange' ||
propertyKey === 'attack' ||
propertyKey === 'decay' ||
propertyKey === 'release' ||
propertyKey === 'brightness' ||
propertyKey === 'sides' ||
propertyKey === 'number'
) {

View File

@@ -61,7 +61,7 @@ const DEFAULT_PIANO_INSTRUMENT_OPTIONS = [
const DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES: Record<ItemType, string[]> = {
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;

View File

@@ -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<void> {
async function previewPianoSettingChange(
item: WorldItem,
overrides: Partial<{ instrument: PianoInstrumentId; attack: number; decay: number; release: number; brightness: number }>,
): Promise<void> {
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,

View File

@@ -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,

View File

@@ -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),

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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))

View File

@@ -232,6 +232,8 @@ class ItemPianoNoteBroadcastPacket(BasePacket):
instrument: str
attack: int
decay: int
release: int
brightness: int
x: int
y: int
emitRange: int

View File

@@ -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)),

View File

@@ -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