Add piano mono/poly, octave, and expanded drum voice set
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<Exclude<PianoInstrumentId, 'drum_kit'>, 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<string, VoiceRuntime>();
|
||||
private readonly activeVoiceKeysByGroup = new Map<string, Set<string>>();
|
||||
private readonly drumNoiseBuffers = new WeakMap<AudioContext, AudioBuffer>();
|
||||
private readonly bitNoiseBuffers = new WeakMap<AudioContext, AudioBuffer>();
|
||||
|
||||
@@ -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<string>();
|
||||
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. */
|
||||
|
||||
@@ -317,6 +317,7 @@ export function createItemPropertyEditor(deps: EditorDeps): {
|
||||
propertyKey === 'mediaVolume' ||
|
||||
propertyKey === 'emitVolume' ||
|
||||
propertyKey === 'emitRange' ||
|
||||
propertyKey === 'octave' ||
|
||||
propertyKey === 'attack' ||
|
||||
propertyKey === 'decay' ||
|
||||
propertyKey === 'release' ||
|
||||
|
||||
@@ -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<ItemType, string[]> = {
|
||||
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<Record<string, string[]>> = {
|
||||
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<Record<ItemType, Record<string, ItemPropertyMetadata>>> = {};
|
||||
@@ -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';
|
||||
|
||||
@@ -255,6 +255,7 @@ let activeTeleportLoopStop: (() => void) | null = null;
|
||||
let activeTeleportLoopToken = 0;
|
||||
let activePianoItemId: string | null = null;
|
||||
const activePianoKeys = new Set<string>();
|
||||
const activePianoKeyMidi = new Map<string, number>();
|
||||
const activeRemotePianoKeys = new Set<string>();
|
||||
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,10 +995,14 @@ 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;
|
||||
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. */
|
||||
function stopAllRemotePianoNotesForSender(senderId: string): void {
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -230,6 +230,8 @@ class ItemPianoNoteBroadcastPacket(BasePacket):
|
||||
midi: int
|
||||
on: bool
|
||||
instrument: str
|
||||
voiceMode: str
|
||||
octave: int
|
||||
attack: int
|
||||
decay: int
|
||||
release: int
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user