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", "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. // Maintainer-controlled web client version.
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
window.CHGRID_WEB_VERSION = "2026.02.22 R201"; window.CHGRID_WEB_VERSION = "2026.02.22 R202";
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
window.CHGRID_TIME_ZONE = "America/Detroit"; window.CHGRID_TIME_ZONE = "America/Detroit";

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 }> = { export const DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT: Record<
piano: { attack: 15, decay: 45 }, PianoInstrumentId,
electric_piano: { attack: 12, decay: 40 }, { attack: number; decay: number; release: number; brightness: number }
guitar: { attack: 8, decay: 35 }, > = {
organ: { attack: 25, decay: 70 }, piano: { attack: 15, decay: 45, release: 35, brightness: 55 },
bass: { attack: 10, decay: 35 }, electric_piano: { attack: 12, decay: 40, release: 30, brightness: 62 },
violin: { attack: 22, decay: 75 }, guitar: { attack: 8, decay: 35, release: 25, brightness: 50 },
synth_lead: { attack: 6, decay: 30 }, organ: { attack: 25, decay: 70, release: 45, brightness: 48 },
nintendo: { attack: 2, decay: 28 }, bass: { attack: 10, decay: 35, release: 28, brightness: 38 },
drum_kit: { attack: 1, decay: 22 }, 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. */ /** 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; 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. */ /** Converts midi note number to frequency in hertz. */
function midiToFrequency(midi: number): number { function midiToFrequency(midi: number): number {
return 440 * Math.pow(2, (midi - 69) / 12); return 440 * Math.pow(2, (midi - 69) / 12);
@@ -188,12 +203,14 @@ export class PianoSynth {
instrument: PianoInstrumentId, instrument: PianoInstrumentId,
attackPercent: number, attackPercent: number,
decayPercent: number, decayPercent: number,
releasePercent: number,
brightnessPercent: number,
context: PianoContext, context: PianoContext,
spatial: PianoSpatialSource, spatial: PianoSpatialSource,
): void { ): void {
if (this.voices.has(keyId)) return; if (this.voices.has(keyId)) return;
if (instrument === 'drum_kit') { if (instrument === 'drum_kit') {
this.playDrumHit(keyId, midi, context, spatial, attackPercent, decayPercent); this.playDrumHit(keyId, midi, context, spatial, attackPercent, decayPercent, releasePercent, brightnessPercent);
return; return;
} }
@@ -201,7 +218,7 @@ export class PianoSynth {
const now = context.audioCtx.currentTime; const now = context.audioCtx.currentTime;
const attackSeconds = attackPercentToSeconds(attackPercent); const attackSeconds = attackPercentToSeconds(attackPercent);
const decaySeconds = decayPercentToSeconds(decayPercent); 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({ const spatialMix = resolveSpatialMix({
dx: spatial.x, dx: spatial.x,
@@ -222,7 +239,7 @@ export class PianoSynth {
if (preset.filter) { if (preset.filter) {
const filter = context.audioCtx.createBiquadFilter(); const filter = context.audioCtx.createBiquadFilter();
filter.type = preset.filter.type; 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); filter.Q.setValueAtTime(preset.filter.q ?? 0.7, now);
voiceGain.connect(filter); voiceGain.connect(filter);
tailNode = filter; tailNode = filter;
@@ -310,6 +327,8 @@ export class PianoSynth {
spatial: PianoSpatialSource, spatial: PianoSpatialSource,
attackPercent: number, attackPercent: number,
decayPercent: number, decayPercent: number,
releasePercent: number,
brightnessPercent: number,
): void { ): void {
const now = context.audioCtx.currentTime; const now = context.audioCtx.currentTime;
const spatialMix = resolveSpatialMix({ 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 typeIndex = Math.abs((midi % DRUM_VARIANTS.length) + this.hashKey(keyId)) % DRUM_VARIANTS.length;
const variant = DRUM_VARIANTS[typeIndex]; const variant = DRUM_VARIANTS[typeIndex];
const decaySeconds = 0.03 + decayPercentToSeconds(decayPercent) * 0.5; 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 attackSeconds = Math.max(0.001, attackPercentToSeconds(attackPercent) * 0.18);
const brightnessMultiplier = brightnessPercentToMultiplier(brightnessPercent);
const gain = context.audioCtx.createGain(); const gain = context.audioCtx.createGain();
gain.gain.setValueAtTime(0.0001, now); gain.gain.setValueAtTime(0.0001, now);
@@ -340,34 +361,34 @@ export class PianoSynth {
} }
if (variant === 'kick_808') { if (variant === 'kick_808') {
this.playKick808(context, gain, now, decaySeconds); this.playKick808(context, gain, now, decaySeconds + releaseSeconds * 0.35);
return; return;
} }
if (variant === 'tom_low') { 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; return;
} }
if (variant === 'tom_high') { 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; return;
} }
if (variant === 'hat_closed') { 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; return;
} }
if (variant === 'hat_open') { 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; return;
} }
if (variant === 'noise_8bit') { 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; return;
} }
if (variant === 'clap') { if (variant === 'clap') {
this.playClap(context, gain, now, decaySeconds); this.playClap(context, gain, now, decaySeconds + releaseSeconds * 0.1);
return; 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. */ /** 808-like kick: deep sine sweep with long-ish tail. */

View File

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

View File

@@ -61,7 +61,7 @@ const DEFAULT_PIANO_INSTRUMENT_OPTIONS = [
const DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES: Record<ItemType, string[]> = { const DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES: Record<ItemType, string[]> = {
radio_station: ['title', 'streamUrl', 'enabled', 'mediaVolume', 'mediaChannel', 'mediaEffect', 'mediaEffectValue', 'facing', 'emitRange'], radio_station: ['title', 'streamUrl', 'enabled', 'mediaVolume', 'mediaChannel', 'mediaEffect', 'mediaEffectValue', 'facing', 'emitRange'],
dice: ['title', 'sides', 'number'], dice: ['title', 'sides', 'number'],
piano: ['title', 'instrument', 'attack', 'decay', 'emitRange'], piano: ['title', 'instrument', 'attack', 'decay', 'release', 'brightness', 'emitRange'],
wheel: ['title', 'spaces'], wheel: ['title', 'spaces'],
clock: ['title', 'timeZone', 'use24Hour'], clock: ['title', 'timeZone', 'use24Hour'],
widget: ['title', 'enabled', 'directional', 'facing', 'emitRange', 'emitVolume', 'emitSoundSpeed', 'emitSoundTempo', 'emitEffect', 'emitEffectValue', 'useSound', 'emitSound'], 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 === 'instrument') return 'instrument';
if (key === 'attack') return 'attack'; if (key === 'attack') return 'attack';
if (key === 'decay') return 'decay'; if (key === 'decay') return 'decay';
if (key === 'release') return 'release';
if (key === 'brightness') return 'brightness';
if (key === 'useSound') return 'use sound'; if (key === 'useSound') return 'use sound';
if (key === 'emitSound') return 'emit sound'; if (key === 'emitSound') return 'emit sound';
return key; return key;

View File

@@ -14,7 +14,12 @@ import {
shouldProxyStreamUrl, shouldProxyStreamUrl,
} from './audio/radioStationRuntime'; } from './audio/radioStationRuntime';
import { ItemEmitRuntime } from './audio/itemEmitRuntime'; 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 { normalizeDegrees } from './audio/spatial';
import { import {
applyPastedText, applyPastedText,
@@ -789,7 +794,14 @@ function getItemSpatialConfig(item: WorldItem): { range: number; directional: bo
} }
/** Resolves piano params with safe defaults for local play mode. */ /** 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 rawInstrument = String(item.params.instrument ?? 'piano').trim().toLowerCase();
const instrument: PianoInstrumentId = const instrument: PianoInstrumentId =
rawInstrument === 'electric_piano' || rawInstrument === 'electric_piano' ||
@@ -804,11 +816,16 @@ function getPianoParams(item: WorldItem): { instrument: PianoInstrumentId; attac
: 'piano'; : 'piano';
const rawAttack = Number(item.params.attack); const rawAttack = Number(item.params.attack);
const rawDecay = Number(item.params.decay); 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 rawEmitRange = Number(item.params.emitRange ?? getItemTypeGlobalProperties(item.type).emitRange ?? 15);
const defaults = DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT[instrument];
return { return {
instrument, instrument,
attack: Math.max(0, Math.min(100, Number.isFinite(rawAttack) ? Math.round(rawAttack) : 15)), 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) : 45)), 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)), 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. */ /** 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; if (item.type !== 'piano') return;
await audio.ensureContext(); await audio.ensureContext();
const ctx = audio.context; const ctx = audio.context;
@@ -880,6 +900,8 @@ async function previewPianoSettingChange(item: WorldItem, overrides: Partial<{ i
const instrument = overrides.instrument ?? current.instrument; const instrument = overrides.instrument ?? current.instrument;
const attack = Math.max(0, Math.min(100, Math.round(overrides.attack ?? current.attack))); 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 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 sourceX = item.carrierId === state.player.id ? state.player.x : item.x;
const sourceY = item.carrierId === state.player.id ? state.player.y : item.y; const sourceY = item.carrierId === state.player.id ? state.player.y : item.y;
const previewKeyId = '__piano_preview_c4__'; const previewKeyId = '__piano_preview_c4__';
@@ -890,6 +912,8 @@ async function previewPianoSettingChange(item: WorldItem, overrides: Partial<{ i
instrument, instrument,
attack, attack,
decay, decay,
release,
brightness,
{ audioCtx: ctx, destination }, { audioCtx: ctx, destination },
{ x: sourceX - state.player.x, y: sourceY - state.player.y, range: current.emitRange }, { x: sourceX - state.player.x, y: sourceY - state.player.y, range: current.emitRange },
); );
@@ -911,6 +935,8 @@ function playRemotePianoNote(note: {
instrument: string; instrument: string;
attack: number; attack: number;
decay: number; decay: number;
release: number;
brightness: number;
x: number; x: number;
y: number; y: number;
emitRange: number; emitRange: number;
@@ -927,6 +953,8 @@ function playRemotePianoNote(note: {
normalizePianoInstrument(note.instrument), normalizePianoInstrument(note.instrument),
Math.max(0, Math.min(100, Math.round(note.attack))), 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.decay))),
Math.max(0, Math.min(100, Math.round(note.release))),
Math.max(0, Math.min(100, Math.round(note.brightness))),
{ audioCtx: ctx, destination }, { audioCtx: ctx, destination },
{ {
x: note.x - state.player.x, x: note.x - state.player.x,
@@ -1182,6 +1210,8 @@ function inferItemPropertyValueType(item: WorldItem, key: string): string | unde
key === 'emitRange' || key === 'emitRange' ||
key === 'attack' || key === 'attack' ||
key === 'decay' || key === 'decay' ||
key === 'release' ||
key === 'brightness' ||
key === 'sides' || key === 'sides' ||
key === 'number' || key === 'number' ||
key === 'useCooldownMs' key === 'useCooldownMs'
@@ -2253,6 +2283,37 @@ function handlePianoUseModeInput(code: string): void {
stopPianoUseMode(false); stopPianoUseMode(false);
return; 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); const midi = getPianoMidiForCode(code);
if (midi === null) return; if (midi === null) return;
if (activePianoKeys.has(code)) return; if (activePianoKeys.has(code)) return;
@@ -2269,6 +2330,8 @@ function handlePianoUseModeInput(code: string): void {
config.instrument, config.instrument,
config.attack, config.attack,
config.decay, config.decay,
config.release,
config.brightness,
{ audioCtx: ctx, destination }, { audioCtx: ctx, destination },
{ x: sourceX - state.player.x, y: sourceY - state.player.y, range: config.emitRange }, { 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 (item.type !== 'piano') return;
if (key === 'instrument') { if (key === 'instrument') {
const instrument = normalizePianoInstrument(value); const instrument = normalizePianoInstrument(value);
const defaults = DEFAULT_ENVELOPE_BY_INSTRUMENT[instrument]; const defaults = DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT[instrument];
void previewPianoSettingChange(item, { instrument, attack: defaults.attack, decay: defaults.decay }); void previewPianoSettingChange(item, {
instrument,
attack: defaults.attack,
decay: defaults.decay,
release: defaults.release,
brightness: defaults.brightness,
});
return; return;
} }
if (key === 'attack') { if (key === 'attack') {
@@ -2542,6 +2611,18 @@ const itemPropertyEditor = createItemPropertyEditor({
const decay = Number(value); const decay = Number(value);
if (!Number.isFinite(decay)) return; if (!Number.isFinite(decay)) return;
void previewPianoSettingChange(item, { decay }); 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, updateStatus,

View File

@@ -54,6 +54,8 @@ type MessageHandlerDeps = {
instrument: string; instrument: string;
attack: number; attack: number;
decay: number; decay: number;
release: number;
brightness: number;
x: number; x: number;
y: number; y: number;
emitRange: number; emitRange: number;
@@ -275,6 +277,8 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
instrument: message.instrument, instrument: message.instrument,
attack: message.attack, attack: message.attack,
decay: message.decay, decay: message.decay,
release: message.release,
brightness: message.brightness,
x: message.x, x: message.x,
y: message.y, y: message.y,
emitRange: message.emitRange, emitRange: message.emitRange,

View File

@@ -160,6 +160,8 @@ export const itemPianoNoteSchema = z.object({
instrument: z.string(), instrument: z.string(),
attack: z.number().int().min(0).max(100), attack: z.number().int().min(0).max(100),
decay: 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(), x: z.number().int(),
y: z.number().int(), y: z.number().int(),
emitRange: z.number().int().min(1), 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 ## 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) - `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 - `W E T Y U O P ]`: Play sharps
- Multiple keys can be held/played at once - Multiple keys can be held/played at once

View File

@@ -165,6 +165,8 @@
"instrument": "piano", "instrument": "piano",
"attack": 15, "attack": 15,
"decay": 45, "decay": 45,
"release": 35,
"brightness": 55,
"emitRange": 15 "emitRange": 15
} }
``` ```
@@ -174,6 +176,8 @@
- Selecting a new instrument resets `attack`/`decay` to that instrument's defaults. - Selecting a new instrument resets `attack`/`decay` to that instrument's defaults.
- `attack`: integer, range `0-100`, default `15`. - `attack`: integer, range `0-100`, default `15`.
- `decay`: integer, range `0-100`, default `45`. - `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`. - `emitRange`: integer, range `5-20`, default `15`.
## Packet Shapes ## Packet Shapes

View File

@@ -159,6 +159,8 @@ This is behavior-focused documentation for item types and their defaults.
- `instrument="piano"` - `instrument="piano"`
- `attack=15` - `attack=15`
- `decay=45` - `decay=45`
- `release=35`
- `brightness=55`
- `emitRange=15` - `emitRange=15`
- Global: - Global:
- `useSound=none` - `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` - `instrument`: `piano | electric_piano | guitar | organ | bass | violin | synth_lead | nintendo | drum_kit`
- `attack`: integer `0..100` - `attack`: integer `0..100`
- `decay`: integer `0..100` - `decay`: integer `0..100`
- `release`: integer `0..100`
- `brightness`: integer `0..100`
- `emitRange`: integer `5..20` - `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) ## Adding A New Item Type (Registry V1)

View File

@@ -9,7 +9,7 @@ from ..models import WorldItem
LABEL = "piano" LABEL = "piano"
TOOLTIP = "Playable keyboard instrument with multiple synth voices." 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") CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable")
USE_SOUND: str | None = None USE_SOUND: str | None = None
EMIT_SOUND: str | None = None EMIT_SOUND: str | None = None
@@ -21,6 +21,8 @@ DEFAULT_PARAMS: dict = {
"instrument": "piano", "instrument": "piano",
"attack": 15, "attack": 15,
"decay": 45, "decay": 45,
"release": 35,
"brightness": 55,
"emitRange": 15, "emitRange": 15,
} }
@@ -36,16 +38,16 @@ INSTRUMENT_OPTIONS: tuple[str, ...] = (
"drum_kit", "drum_kit",
) )
DEFAULT_ENVELOPE_BY_INSTRUMENT: dict[str, tuple[int, int]] = { DEFAULT_ENVELOPE_BY_INSTRUMENT: dict[str, tuple[int, int, int, int]] = {
"piano": (15, 45), "piano": (15, 45, 35, 55),
"electric_piano": (12, 40), "electric_piano": (12, 40, 30, 62),
"guitar": (8, 35), "guitar": (8, 35, 25, 50),
"organ": (25, 70), "organ": (25, 70, 45, 48),
"bass": (10, 35), "bass": (10, 35, 28, 38),
"violin": (22, 75), "violin": (22, 75, 55, 58),
"synth_lead": (6, 30), "synth_lead": (6, 30, 22, 72),
"nintendo": (2, 28), "nintendo": (2, 28, 18, 85),
"drum_kit": (1, 22), "drum_kit": (1, 22, 12, 68),
} }
PROPERTY_METADATA: dict[str, dict[str, object]] = { 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.", "tooltip": "How long notes ring out after the initial hit.",
"range": {"min": 0, "max": 100, "step": 1}, "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": { "emitRange": {
"valueType": "number", "valueType": "number",
"tooltip": "Maximum distance in squares where this piano can be heard.", "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): if not (0 <= decay <= 100):
raise ValueError("decay must be between 0 and 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. # When instrument changes, reset envelope to instrument-appropriate defaults.
if instrument != previous_instrument: 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["attack"] = attack
next_params["decay"] = decay next_params["decay"] = decay
next_params["release"] = release
next_params["brightness"] = brightness
try: try:
emit_range = int(next_params.get("emitRange", 15)) emit_range = int(next_params.get("emitRange", 15))

View File

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

View File

@@ -68,7 +68,7 @@ from .models import (
LOGGER = logging.getLogger("chgrid.server") LOGGER = logging.getLogger("chgrid.server")
PACKET_LOGGER = logging.getLogger("chgrid.server.packet") PACKET_LOGGER = logging.getLogger("chgrid.server.packet")
CLIENT_PACKET_ADAPTER = TypeAdapter(ClientPacket) CLIENT_PACKET_ADAPTER = TypeAdapter(ClientPacket)
MAX_ACTIVE_PIANO_KEYS_PER_CLIENT = 32 MAX_ACTIVE_PIANO_KEYS_PER_CLIENT = 12
class SignalingServer: class SignalingServer:
@@ -679,6 +679,8 @@ class SignalingServer:
instrument = str(item.params.get("instrument", "piano")).strip().lower() 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 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 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 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_x = client.x if item.carrierId == client.id else item.x
source_y = client.y if item.carrierId == client.id else item.y source_y = client.y if item.carrierId == client.id else item.y
@@ -693,6 +695,8 @@ class SignalingServer:
instrument=instrument, instrument=instrument,
attack=max(0, min(100, attack)), attack=max(0, min(100, attack)),
decay=max(0, min(100, decay)), decay=max(0, min(100, decay)),
release=max(0, min(100, release)),
brightness=max(0, min(100, brightness)),
x=source_x, x=source_x,
y=source_y, y=source_y,
emitRange=max(5, min(20, emit_range)), 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("instrument") == "drum_kit"
assert item.params.get("attack") == 1 assert item.params.get("attack") == 1
assert item.params.get("decay") == 22 assert item.params.get("decay") == 22
assert item.params.get("release") == 12
assert item.params.get("brightness") == 68
assert item.params.get("emitRange") == 12 assert item.params.get("emitRange") == 12
await server._handle_message( 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("instrument") == "nintendo"
assert item.params.get("attack") == 2 assert item.params.get("attack") == 2
assert item.params.get("decay") == 28 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})) await server._handle_message(client, json.dumps({"type": "item_use", "itemId": item.id}))
assert send_payloads[-1].ok is True 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, "instrument", "") == "organ"
assert getattr(packet, "attack", -1) == 20 assert getattr(packet, "attack", -1) == 20
assert getattr(packet, "decay", -1) == 60 assert getattr(packet, "decay", -1) == 60
assert getattr(packet, "release", -1) == 35
assert getattr(packet, "brightness", -1) == 55
assert getattr(packet, "emitRange", -1) == 12 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, "_send", fake_send)
monkeypatch.setattr(server, "_broadcast", fake_broadcast) monkeypatch.setattr(server, "_broadcast", fake_broadcast)
for index in range(32): for index in range(12):
await server._handle_message( await server._handle_message(
sender, sender,
json.dumps({"type": "item_piano_note", "itemId": item.id, "keyId": f"Key{index}", "midi": 60, "on": True}), 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( await server._handle_message(
sender, sender,
json.dumps({"type": "item_piano_note", "itemId": item.id, "keyId": "KeyOverflow", "midi": 60, "on": True}), json.dumps({"type": "item_piano_note", "itemId": item.id, "keyId": "KeyOverflow", "midi": 60, "on": True}),
) )
assert len(broadcast_payloads) == 32 assert len(broadcast_payloads) == 12