Add piano mono/poly, octave, and expanded drum voice set
This commit is contained in:
@@ -87,7 +87,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys": "Piano mode",
|
"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.
|
// 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 R202";
|
window.CHGRID_WEB_VERSION = "2026.02.22 R203";
|
||||||
// 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";
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const PIANO_INSTRUMENT_OPTIONS = [
|
|||||||
'bass',
|
'bass',
|
||||||
'violin',
|
'violin',
|
||||||
'synth_lead',
|
'synth_lead',
|
||||||
|
'brass',
|
||||||
'nintendo',
|
'nintendo',
|
||||||
'drum_kit',
|
'drum_kit',
|
||||||
] as const;
|
] as const;
|
||||||
@@ -20,6 +21,7 @@ type VoiceRuntime = {
|
|||||||
oscillators: OscillatorNode[];
|
oscillators: OscillatorNode[];
|
||||||
modulators: OscillatorNode[];
|
modulators: OscillatorNode[];
|
||||||
releaseSeconds: number;
|
releaseSeconds: number;
|
||||||
|
sourceGroupId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PianoContext = {
|
type PianoContext = {
|
||||||
@@ -116,10 +118,21 @@ const PRESETS: Record<Exclude<PianoInstrumentId, 'drum_kit'>, InstrumentPreset>
|
|||||||
releaseScale: 1,
|
releaseScale: 1,
|
||||||
vibrato: { rateHz: 6.8, depthCents: 9 },
|
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: {
|
nintendo: {
|
||||||
oscillators: [
|
oscillators: [
|
||||||
{ type: 'square', gain: 1 },
|
{ 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 },
|
filter: { type: 'lowpass', frequency: 5200, q: 1.2 },
|
||||||
gain: 0.22,
|
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 },
|
electric_piano: { attack: 12, decay: 40, release: 30, brightness: 62 },
|
||||||
guitar: { attack: 8, decay: 35, release: 25, brightness: 50 },
|
guitar: { attack: 8, decay: 35, release: 25, brightness: 50 },
|
||||||
organ: { attack: 25, decay: 70, release: 45, brightness: 48 },
|
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 },
|
violin: { attack: 22, decay: 75, release: 55, brightness: 58 },
|
||||||
synth_lead: { attack: 6, decay: 30, release: 22, brightness: 72 },
|
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 },
|
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;
|
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. */
|
/** 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);
|
||||||
@@ -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';
|
type DrumVariant =
|
||||||
const DRUM_VARIANTS: DrumVariant[] = ['kick_808', 'snare', 'clap', 'hat_closed', 'hat_open', 'tom_low', 'tom_high', 'noise_8bit'];
|
| '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 {
|
export class PianoSynth {
|
||||||
private readonly voices = new Map<string, VoiceRuntime>();
|
private readonly voices = new Map<string, VoiceRuntime>();
|
||||||
|
private readonly activeVoiceKeysByGroup = new Map<string, Set<string>>();
|
||||||
private readonly drumNoiseBuffers = new WeakMap<AudioContext, AudioBuffer>();
|
private readonly drumNoiseBuffers = new WeakMap<AudioContext, AudioBuffer>();
|
||||||
private readonly bitNoiseBuffers = 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. */
|
/** Starts one note for a specific keyboard key id. */
|
||||||
noteOn(
|
noteOn(
|
||||||
keyId: string,
|
keyId: string,
|
||||||
|
sourceGroupId: string,
|
||||||
midi: number,
|
midi: number,
|
||||||
instrument: PianoInstrumentId,
|
instrument: PianoInstrumentId,
|
||||||
|
voiceMode: 'mono' | 'poly',
|
||||||
attackPercent: number,
|
attackPercent: number,
|
||||||
decayPercent: number,
|
decayPercent: number,
|
||||||
releasePercent: number,
|
releasePercent: number,
|
||||||
@@ -209,8 +261,16 @@ export class PianoSynth {
|
|||||||
spatial: PianoSpatialSource,
|
spatial: PianoSpatialSource,
|
||||||
): void {
|
): void {
|
||||||
if (this.voices.has(keyId)) return;
|
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') {
|
if (instrument === 'drum_kit') {
|
||||||
this.playDrumHit(keyId, midi, context, spatial, attackPercent, decayPercent, releasePercent, brightnessPercent);
|
this.playDrumHit(midi, context, spatial, attackPercent, decayPercent, releasePercent, brightnessPercent);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +344,11 @@ export class PianoSynth {
|
|||||||
oscillators,
|
oscillators,
|
||||||
modulators,
|
modulators,
|
||||||
releaseSeconds,
|
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. */
|
/** Releases one active note tied to a keyboard key id. */
|
||||||
@@ -292,6 +356,15 @@ export class PianoSynth {
|
|||||||
const voice = this.voices.get(keyId);
|
const voice = this.voices.get(keyId);
|
||||||
if (!voice) return;
|
if (!voice) return;
|
||||||
this.voices.delete(keyId);
|
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 now = voice.gain.context.currentTime;
|
||||||
const currentGain = Math.max(0.0001, voice.gain.gain.value);
|
const currentGain = Math.max(0.0001, voice.gain.gain.value);
|
||||||
voice.gain.gain.cancelScheduledValues(now);
|
voice.gain.gain.cancelScheduledValues(now);
|
||||||
@@ -321,7 +394,6 @@ export class PianoSynth {
|
|||||||
|
|
||||||
/** Plays one synthesized drum hit for drum-kit instrument mode. */
|
/** Plays one synthesized drum hit for drum-kit instrument mode. */
|
||||||
private playDrumHit(
|
private playDrumHit(
|
||||||
keyId: string,
|
|
||||||
midi: number,
|
midi: number,
|
||||||
context: PianoContext,
|
context: PianoContext,
|
||||||
spatial: PianoSpatialSource,
|
spatial: PianoSpatialSource,
|
||||||
@@ -338,8 +410,8 @@ export class PianoSynth {
|
|||||||
baseGain: 1,
|
baseGain: 1,
|
||||||
});
|
});
|
||||||
if (!spatialMix || spatialMix.gain <= 0) return;
|
if (!spatialMix || spatialMix.gain <= 0) return;
|
||||||
const typeIndex = Math.abs((midi % DRUM_VARIANTS.length) + this.hashKey(keyId)) % DRUM_VARIANTS.length;
|
const variant = drumVariantForMidi(midi);
|
||||||
const variant = DRUM_VARIANTS[typeIndex];
|
const midiOffset = (Math.round(midi) - 60) / 24;
|
||||||
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 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);
|
||||||
@@ -350,26 +422,45 @@ export class PianoSynth {
|
|||||||
gain.gain.exponentialRampToValueAtTime(0.22 * spatialMix.gain, now + attackSeconds);
|
gain.gain.exponentialRampToValueAtTime(0.22 * spatialMix.gain, now + attackSeconds);
|
||||||
gain.gain.exponentialRampToValueAtTime(0.0001, now + decaySeconds);
|
gain.gain.exponentialRampToValueAtTime(0.0001, now + decaySeconds);
|
||||||
|
|
||||||
let tailNode: AudioNode = gain;
|
|
||||||
if (typeof context.audioCtx.createStereoPanner === 'function') {
|
if (typeof context.audioCtx.createStereoPanner === 'function') {
|
||||||
const panner = context.audioCtx.createStereoPanner();
|
const panner = context.audioCtx.createStereoPanner();
|
||||||
panner.pan.setValueAtTime(spatialMix.pan, now);
|
panner.pan.setValueAtTime(spatialMix.pan, now);
|
||||||
tailNode.connect(panner).connect(context.destination);
|
gain.connect(panner).connect(context.destination);
|
||||||
tailNode = panner;
|
|
||||||
} else {
|
} else {
|
||||||
tailNode.connect(context.destination);
|
gain.connect(context.destination);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (variant === 'kick_808') {
|
if (variant === 'kick_sub') {
|
||||||
this.playKick808(context, gain, now, decaySeconds + releaseSeconds * 0.35);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (variant === 'tom_low') {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (variant === 'tom_high') {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (variant === 'hat_closed') {
|
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);
|
this.playNoiseDrum(context, gain, now, decaySeconds * 0.8 + releaseSeconds * 0.2, 'highpass', 5200 * brightnessMultiplier, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (variant === 'noise_8bit') {
|
|
||||||
this.playNoiseDrum(context, gain, now, decaySeconds * 0.45, 'bandpass', 2700 * brightnessMultiplier, true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (variant === 'clap') {
|
if (variant === 'clap') {
|
||||||
this.playClap(context, gain, now, decaySeconds + releaseSeconds * 0.1);
|
this.playClap(context, gain, now, decaySeconds + releaseSeconds * 0.1);
|
||||||
return;
|
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. */
|
/** 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();
|
const kick = context.audioCtx.createOscillator();
|
||||||
kick.type = 'sine';
|
kick.type = 'sine';
|
||||||
kick.frequency.setValueAtTime(160, now);
|
kick.frequency.setValueAtTime(startHz, now);
|
||||||
kick.frequency.exponentialRampToValueAtTime(42, now + Math.max(0.07, decaySeconds * 0.95));
|
kick.frequency.exponentialRampToValueAtTime(endHz, now + Math.max(0.07, decaySeconds * 0.95));
|
||||||
const body = context.audioCtx.createGain();
|
const body = context.audioCtx.createGain();
|
||||||
body.gain.setValueAtTime(1, now);
|
body.gain.setValueAtTime(1, now);
|
||||||
body.gain.exponentialRampToValueAtTime(0.0001, now + Math.max(0.08, decaySeconds));
|
body.gain.exponentialRampToValueAtTime(0.0001, now + Math.max(0.08, decaySeconds));
|
||||||
@@ -440,13 +546,13 @@ export class PianoSynth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Snare: short tone + filtered noise burst. */
|
/** 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();
|
const tone = context.audioCtx.createOscillator();
|
||||||
tone.type = 'triangle';
|
tone.type = 'triangle';
|
||||||
tone.frequency.setValueAtTime(220, now);
|
tone.frequency.setValueAtTime(220, now);
|
||||||
tone.frequency.exponentialRampToValueAtTime(130, now + Math.max(0.03, decaySeconds * 0.45));
|
tone.frequency.exponentialRampToValueAtTime(130, now + Math.max(0.03, decaySeconds * 0.45));
|
||||||
const toneGain = context.audioCtx.createGain();
|
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));
|
toneGain.gain.exponentialRampToValueAtTime(0.0001, now + Math.max(0.04, decaySeconds * 0.55));
|
||||||
tone.connect(toneGain).connect(gain);
|
tone.connect(toneGain).connect(gain);
|
||||||
tone.start(now);
|
tone.start(now);
|
||||||
@@ -473,13 +579,22 @@ export class PianoSynth {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns deterministic hash for key ids to map drum voice variants. */
|
/** Retro game-like downward-bending midrange hit for drum fills. */
|
||||||
private hashKey(value: string): number {
|
private playPowDown(context: PianoContext, gain: GainNode, now: number, startHz: number, endHz: number, decaySeconds: number): void {
|
||||||
let out = 0;
|
const osc = context.audioCtx.createOscillator();
|
||||||
for (let index = 0; index < value.length; index += 1) {
|
osc.type = 'square';
|
||||||
out = ((out << 5) - out + value.charCodeAt(index)) | 0;
|
osc.frequency.setValueAtTime(startHz, now);
|
||||||
}
|
osc.frequency.exponentialRampToValueAtTime(Math.max(35, endHz), now + Math.max(0.04, decaySeconds * 0.9));
|
||||||
return out;
|
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. */
|
/** Returns or lazily builds short white-noise buffer for percussion synthesis. */
|
||||||
|
|||||||
@@ -317,6 +317,7 @@ export function createItemPropertyEditor(deps: EditorDeps): {
|
|||||||
propertyKey === 'mediaVolume' ||
|
propertyKey === 'mediaVolume' ||
|
||||||
propertyKey === 'emitVolume' ||
|
propertyKey === 'emitVolume' ||
|
||||||
propertyKey === 'emitRange' ||
|
propertyKey === 'emitRange' ||
|
||||||
|
propertyKey === 'octave' ||
|
||||||
propertyKey === 'attack' ||
|
propertyKey === 'attack' ||
|
||||||
propertyKey === 'decay' ||
|
propertyKey === 'decay' ||
|
||||||
propertyKey === 'release' ||
|
propertyKey === 'release' ||
|
||||||
|
|||||||
@@ -54,14 +54,16 @@ const DEFAULT_PIANO_INSTRUMENT_OPTIONS = [
|
|||||||
'bass',
|
'bass',
|
||||||
'violin',
|
'violin',
|
||||||
'synth_lead',
|
'synth_lead',
|
||||||
|
'brass',
|
||||||
'nintendo',
|
'nintendo',
|
||||||
'drum_kit',
|
'drum_kit',
|
||||||
] as const;
|
] as const;
|
||||||
|
const DEFAULT_PIANO_VOICE_MODE_OPTIONS = ['poly', 'mono'] as const;
|
||||||
|
|
||||||
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', 'release', 'brightness', 'emitRange'],
|
piano: ['title', 'instrument', 'voiceMode', 'octave', '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'],
|
||||||
@@ -133,6 +135,7 @@ let optionItemPropertyValues: Partial<Record<string, string[]>> = {
|
|||||||
emitEffect: EFFECT_SEQUENCE.map((effect) => effect.id),
|
emitEffect: EFFECT_SEQUENCE.map((effect) => effect.id),
|
||||||
mediaChannel: [...RADIO_CHANNEL_OPTIONS],
|
mediaChannel: [...RADIO_CHANNEL_OPTIONS],
|
||||||
instrument: [...DEFAULT_PIANO_INSTRUMENT_OPTIONS],
|
instrument: [...DEFAULT_PIANO_INSTRUMENT_OPTIONS],
|
||||||
|
voiceMode: [...DEFAULT_PIANO_VOICE_MODE_OPTIONS],
|
||||||
timeZone: [...DEFAULT_CLOCK_TIME_ZONE_OPTIONS],
|
timeZone: [...DEFAULT_CLOCK_TIME_ZONE_OPTIONS],
|
||||||
};
|
};
|
||||||
let itemTypePropertyMetadata: Partial<Record<ItemType, Record<string, ItemPropertyMetadata>>> = {};
|
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 === 'emitEffect') return 'emit effect';
|
||||||
if (key === 'emitEffectValue') return 'emit effect value';
|
if (key === 'emitEffectValue') return 'emit effect value';
|
||||||
if (key === 'instrument') return 'instrument';
|
if (key === 'instrument') return 'instrument';
|
||||||
|
if (key === 'voiceMode') return 'voice mode';
|
||||||
|
if (key === 'octave') return 'octave';
|
||||||
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 === 'release') return 'release';
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ let activeTeleportLoopStop: (() => void) | null = null;
|
|||||||
let activeTeleportLoopToken = 0;
|
let activeTeleportLoopToken = 0;
|
||||||
let activePianoItemId: string | null = null;
|
let activePianoItemId: string | null = null;
|
||||||
const activePianoKeys = new Set<string>();
|
const activePianoKeys = new Set<string>();
|
||||||
|
const activePianoKeyMidi = new Map<string, number>();
|
||||||
const activeRemotePianoKeys = new Set<string>();
|
const activeRemotePianoKeys = new Set<string>();
|
||||||
let pianoPreviewTimeoutId: number | null = null;
|
let pianoPreviewTimeoutId: number | null = null;
|
||||||
let activeTeleport:
|
let activeTeleport:
|
||||||
@@ -796,6 +797,8 @@ 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): {
|
function getPianoParams(item: WorldItem): {
|
||||||
instrument: PianoInstrumentId;
|
instrument: PianoInstrumentId;
|
||||||
|
voiceMode: 'mono' | 'poly';
|
||||||
|
octave: number;
|
||||||
attack: number;
|
attack: number;
|
||||||
decay: number;
|
decay: number;
|
||||||
release: number;
|
release: number;
|
||||||
@@ -816,12 +819,16 @@ function getPianoParams(item: WorldItem): {
|
|||||||
: '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 rawOctave = Number(item.params.octave);
|
||||||
|
const rawVoiceMode = String(item.params.voiceMode ?? defaultsVoiceModeForInstrument(instrument)).trim().toLowerCase();
|
||||||
const rawRelease = Number(item.params.release);
|
const rawRelease = Number(item.params.release);
|
||||||
const rawBrightness = Number(item.params.brightness);
|
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];
|
const defaults = DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT[instrument];
|
||||||
return {
|
return {
|
||||||
instrument,
|
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)),
|
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)),
|
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)),
|
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. */
|
/** Normalizes arbitrary instrument strings into supported piano synth ids. */
|
||||||
function normalizePianoInstrument(value: unknown): PianoInstrumentId {
|
function normalizePianoInstrument(value: unknown): PianoInstrumentId {
|
||||||
const raw = String(value ?? 'piano').trim().toLowerCase();
|
const raw = String(value ?? 'piano').trim().toLowerCase();
|
||||||
@@ -839,6 +857,7 @@ function normalizePianoInstrument(value: unknown): PianoInstrumentId {
|
|||||||
if (raw === 'bass') return 'bass';
|
if (raw === 'bass') return 'bass';
|
||||||
if (raw === 'violin') return 'violin';
|
if (raw === 'violin') return 'violin';
|
||||||
if (raw === 'synth_lead') return 'synth_lead';
|
if (raw === 'synth_lead') return 'synth_lead';
|
||||||
|
if (raw === 'brass') return 'brass';
|
||||||
if (raw === 'nintendo') return 'nintendo';
|
if (raw === 'nintendo') return 'nintendo';
|
||||||
if (raw === 'drum_kit') return 'drum_kit';
|
if (raw === 'drum_kit') return 'drum_kit';
|
||||||
return 'piano';
|
return 'piano';
|
||||||
@@ -872,13 +891,14 @@ function stopPianoUseMode(announce = true): void {
|
|||||||
if (!activePianoItemId) return;
|
if (!activePianoItemId) return;
|
||||||
const itemId = activePianoItemId;
|
const itemId = activePianoItemId;
|
||||||
for (const code of Array.from(activePianoKeys)) {
|
for (const code of Array.from(activePianoKeys)) {
|
||||||
const midi = getPianoMidiForCode(code);
|
const midi = activePianoKeyMidi.get(code);
|
||||||
if (midi === null) continue;
|
if (!Number.isFinite(midi)) continue;
|
||||||
signaling.send({ type: 'item_piano_note', itemId, keyId: code, midi, on: false });
|
signaling.send({ type: 'item_piano_note', itemId, keyId: code, midi, on: false });
|
||||||
pianoSynth.noteOff(code);
|
pianoSynth.noteOff(code);
|
||||||
}
|
}
|
||||||
activePianoItemId = null;
|
activePianoItemId = null;
|
||||||
activePianoKeys.clear();
|
activePianoKeys.clear();
|
||||||
|
activePianoKeyMidi.clear();
|
||||||
state.mode = 'normal';
|
state.mode = 'normal';
|
||||||
if (announce) {
|
if (announce) {
|
||||||
updateStatus('Stopped piano.');
|
updateStatus('Stopped piano.');
|
||||||
@@ -908,8 +928,10 @@ async function previewPianoSettingChange(
|
|||||||
pianoSynth.noteOff(previewKeyId);
|
pianoSynth.noteOff(previewKeyId);
|
||||||
pianoSynth.noteOn(
|
pianoSynth.noteOn(
|
||||||
previewKeyId,
|
previewKeyId,
|
||||||
|
'preview',
|
||||||
60,
|
60,
|
||||||
instrument,
|
instrument,
|
||||||
|
'poly',
|
||||||
attack,
|
attack,
|
||||||
decay,
|
decay,
|
||||||
release,
|
release,
|
||||||
@@ -933,6 +955,8 @@ function playRemotePianoNote(note: {
|
|||||||
keyId: string;
|
keyId: string;
|
||||||
midi: number;
|
midi: number;
|
||||||
instrument: string;
|
instrument: string;
|
||||||
|
voiceMode: 'mono' | 'poly';
|
||||||
|
octave: number;
|
||||||
attack: number;
|
attack: number;
|
||||||
decay: number;
|
decay: number;
|
||||||
release: number;
|
release: number;
|
||||||
@@ -944,13 +968,18 @@ function playRemotePianoNote(note: {
|
|||||||
const ctx = audio.context;
|
const ctx = audio.context;
|
||||||
const destination = audio.getOutputDestinationNode();
|
const destination = audio.getOutputDestinationNode();
|
||||||
if (!ctx || !destination) return;
|
if (!ctx || !destination) return;
|
||||||
const runtimeKey = `${note.senderId}:${note.keyId}`;
|
const runtimeKey = `${note.senderId}:${note.itemId}:${note.keyId}`;
|
||||||
if (activeRemotePianoKeys.has(runtimeKey)) return;
|
if (activeRemotePianoKeys.has(runtimeKey)) return;
|
||||||
|
if (note.voiceMode === 'mono') {
|
||||||
|
stopRemotePianoNotesForSource(note.senderId, note.itemId);
|
||||||
|
}
|
||||||
activeRemotePianoKeys.add(runtimeKey);
|
activeRemotePianoKeys.add(runtimeKey);
|
||||||
pianoSynth.noteOn(
|
pianoSynth.noteOn(
|
||||||
runtimeKey,
|
runtimeKey,
|
||||||
|
`remote:${note.senderId}:${note.itemId}`,
|
||||||
Math.max(0, Math.min(127, Math.round(note.midi))),
|
Math.max(0, Math.min(127, Math.round(note.midi))),
|
||||||
normalizePianoInstrument(note.instrument),
|
normalizePianoInstrument(note.instrument),
|
||||||
|
note.voiceMode,
|
||||||
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.release))),
|
||||||
@@ -966,10 +995,14 @@ function playRemotePianoNote(note: {
|
|||||||
|
|
||||||
/** Stops one inbound piano note previously started for another user. */
|
/** Stops one inbound piano note previously started for another user. */
|
||||||
function stopRemotePianoNote(senderId: string, keyId: string): void {
|
function stopRemotePianoNote(senderId: string, keyId: string): void {
|
||||||
const runtimeKey = `${senderId}:${keyId}`;
|
const suffix = `:${keyId}`;
|
||||||
if (!activeRemotePianoKeys.delete(runtimeKey)) return;
|
for (const runtimeKey of Array.from(activeRemotePianoKeys)) {
|
||||||
|
if (!runtimeKey.startsWith(`${senderId}:`)) continue;
|
||||||
|
if (!runtimeKey.endsWith(suffix)) continue;
|
||||||
|
activeRemotePianoKeys.delete(runtimeKey);
|
||||||
pianoSynth.noteOff(runtimeKey);
|
pianoSynth.noteOff(runtimeKey);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Stops all currently active remote piano notes for a sender id. */
|
/** Stops all currently active remote piano notes for a sender id. */
|
||||||
function stopAllRemotePianoNotesForSender(senderId: string): void {
|
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. */
|
/** Enters help-view mode and announces the first help line. */
|
||||||
function openHelpViewer(): void {
|
function openHelpViewer(): void {
|
||||||
if (helpViewerLines.length === 0) {
|
if (helpViewerLines.length === 0) {
|
||||||
@@ -1195,7 +1238,7 @@ function getItemPropertyValue(item: WorldItem, key: string): string {
|
|||||||
function inferItemPropertyValueType(item: WorldItem, key: string): string | undefined {
|
function inferItemPropertyValueType(item: WorldItem, key: string): string | undefined {
|
||||||
if (key === 'useSound' || key === 'emitSound') return 'sound';
|
if (key === 'useSound' || key === 'emitSound') return 'sound';
|
||||||
if (key === 'enabled' || key === 'use24Hour' || key === 'directional') return 'boolean';
|
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 (
|
if (
|
||||||
key === 'x' ||
|
key === 'x' ||
|
||||||
key === 'y' ||
|
key === 'y' ||
|
||||||
@@ -1208,6 +1251,7 @@ function inferItemPropertyValueType(item: WorldItem, key: string): string | unde
|
|||||||
key === 'emitEffectValue' ||
|
key === 'emitEffectValue' ||
|
||||||
key === 'facing' ||
|
key === 'facing' ||
|
||||||
key === 'emitRange' ||
|
key === 'emitRange' ||
|
||||||
|
key === 'octave' ||
|
||||||
key === 'attack' ||
|
key === 'attack' ||
|
||||||
key === 'decay' ||
|
key === 'decay' ||
|
||||||
key === 'release' ||
|
key === 'release' ||
|
||||||
@@ -2285,11 +2329,16 @@ function handlePianoUseModeInput(code: string): void {
|
|||||||
}
|
}
|
||||||
if (code.startsWith('Digit')) {
|
if (code.startsWith('Digit')) {
|
||||||
const digit = Number(code.slice(5));
|
const digit = Number(code.slice(5));
|
||||||
if (Number.isInteger(digit) && digit >= 1 && digit <= 9) {
|
const instrumentIndex = digit === 0 ? 9 : digit - 1;
|
||||||
const instrument = PIANO_INSTRUMENT_OPTIONS[digit - 1];
|
if (Number.isInteger(instrumentIndex) && instrumentIndex >= 0 && instrumentIndex < PIANO_INSTRUMENT_OPTIONS.length) {
|
||||||
|
const instrument = PIANO_INSTRUMENT_OPTIONS[instrumentIndex];
|
||||||
if (instrument) {
|
if (instrument) {
|
||||||
const defaults = DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT[instrument];
|
const defaults = DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT[instrument];
|
||||||
|
const voiceMode = defaultsVoiceModeForInstrument(instrument);
|
||||||
|
const octave = defaultsOctaveForInstrument(instrument);
|
||||||
item.params.instrument = instrument;
|
item.params.instrument = instrument;
|
||||||
|
item.params.voiceMode = voiceMode;
|
||||||
|
item.params.octave = octave;
|
||||||
item.params.attack = defaults.attack;
|
item.params.attack = defaults.attack;
|
||||||
item.params.decay = defaults.decay;
|
item.params.decay = defaults.decay;
|
||||||
item.params.release = defaults.release;
|
item.params.release = defaults.release;
|
||||||
@@ -2317,17 +2366,21 @@ function handlePianoUseModeInput(code: string): void {
|
|||||||
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;
|
||||||
|
const config = getPianoParams(item);
|
||||||
|
const playedMidi = Math.max(0, Math.min(127, midi + config.octave * 12));
|
||||||
activePianoKeys.add(code);
|
activePianoKeys.add(code);
|
||||||
|
activePianoKeyMidi.set(code, playedMidi);
|
||||||
const ctx = audio.context;
|
const ctx = audio.context;
|
||||||
const destination = audio.getOutputDestinationNode();
|
const destination = audio.getOutputDestinationNode();
|
||||||
if (!ctx || !destination) return;
|
if (!ctx || !destination) return;
|
||||||
const config = getPianoParams(item);
|
|
||||||
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;
|
||||||
pianoSynth.noteOn(
|
pianoSynth.noteOn(
|
||||||
code,
|
code,
|
||||||
midi,
|
`local:${itemId}`,
|
||||||
|
playedMidi,
|
||||||
config.instrument,
|
config.instrument,
|
||||||
|
config.voiceMode,
|
||||||
config.attack,
|
config.attack,
|
||||||
config.decay,
|
config.decay,
|
||||||
config.release,
|
config.release,
|
||||||
@@ -2335,7 +2388,7 @@ function handlePianoUseModeInput(code: string): void {
|
|||||||
{ 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 },
|
||||||
);
|
);
|
||||||
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. */
|
/** Handles effect menu list navigation and selection. */
|
||||||
@@ -2623,6 +2676,10 @@ const itemPropertyEditor = createItemPropertyEditor({
|
|||||||
const brightness = Number(value);
|
const brightness = Number(value);
|
||||||
if (!Number.isFinite(brightness)) return;
|
if (!Number.isFinite(brightness)) return;
|
||||||
void previewPianoSettingChange(item, { brightness });
|
void previewPianoSettingChange(item, { brightness });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'octave') {
|
||||||
|
void previewPianoSettingChange(item, {});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateStatus,
|
updateStatus,
|
||||||
@@ -2802,8 +2859,9 @@ function setupInputHandlers(): void {
|
|||||||
if (activePianoKeys.delete(code)) {
|
if (activePianoKeys.delete(code)) {
|
||||||
pianoSynth.noteOff(code);
|
pianoSynth.noteOff(code);
|
||||||
const itemId = activePianoItemId;
|
const itemId = activePianoItemId;
|
||||||
const midi = getPianoMidiForCode(code);
|
const midi = activePianoKeyMidi.get(code);
|
||||||
if (itemId && midi !== null) {
|
activePianoKeyMidi.delete(code);
|
||||||
|
if (itemId && Number.isFinite(midi)) {
|
||||||
signaling.send({ type: 'item_piano_note', itemId, keyId: code, midi, on: false });
|
signaling.send({ type: 'item_piano_note', itemId, keyId: code, midi, on: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ type MessageHandlerDeps = {
|
|||||||
keyId: string;
|
keyId: string;
|
||||||
midi: number;
|
midi: number;
|
||||||
instrument: string;
|
instrument: string;
|
||||||
|
voiceMode: 'mono' | 'poly';
|
||||||
|
octave: number;
|
||||||
attack: number;
|
attack: number;
|
||||||
decay: number;
|
decay: number;
|
||||||
release: number;
|
release: number;
|
||||||
@@ -275,6 +277,8 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
|
|||||||
keyId: message.keyId,
|
keyId: message.keyId,
|
||||||
midi: message.midi,
|
midi: message.midi,
|
||||||
instrument: message.instrument,
|
instrument: message.instrument,
|
||||||
|
voiceMode: message.voiceMode,
|
||||||
|
octave: message.octave,
|
||||||
attack: message.attack,
|
attack: message.attack,
|
||||||
decay: message.decay,
|
decay: message.decay,
|
||||||
release: message.release,
|
release: message.release,
|
||||||
|
|||||||
@@ -158,6 +158,8 @@ export const itemPianoNoteSchema = z.object({
|
|||||||
midi: z.number().int().min(0).max(127),
|
midi: z.number().int().min(0).max(127),
|
||||||
on: z.boolean(),
|
on: z.boolean(),
|
||||||
instrument: z.string(),
|
instrument: z.string(),
|
||||||
|
voiceMode: z.enum(['mono', 'poly']),
|
||||||
|
octave: z.number().int().min(-2).max(2),
|
||||||
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),
|
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
|
## 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)
|
- `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
|
||||||
|
|||||||
@@ -163,6 +163,8 @@
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"instrument": "piano",
|
"instrument": "piano",
|
||||||
|
"voiceMode": "poly",
|
||||||
|
"octave": 0,
|
||||||
"attack": 15,
|
"attack": 15,
|
||||||
"decay": 45,
|
"decay": 45,
|
||||||
"release": 35,
|
"release": 35,
|
||||||
@@ -172,8 +174,10 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `instrument`: one of
|
- `instrument`: one of
|
||||||
`piano | electric_piano | guitar | organ | bass | violin | synth_lead | nintendo | drum_kit`.
|
`piano | electric_piano | guitar | organ | bass | violin | synth_lead | brass | nintendo | drum_kit`.
|
||||||
- Selecting a new instrument resets `attack`/`decay` to that instrument's defaults.
|
- `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`.
|
- `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`.
|
- `release`: integer, range `0-100`, default `35`.
|
||||||
@@ -235,8 +239,12 @@
|
|||||||
"midi": 60,
|
"midi": 60,
|
||||||
"on": true,
|
"on": true,
|
||||||
"instrument": "piano",
|
"instrument": "piano",
|
||||||
|
"voiceMode": "poly",
|
||||||
|
"octave": 0,
|
||||||
"attack": 15,
|
"attack": 15,
|
||||||
"decay": 45,
|
"decay": 45,
|
||||||
|
"release": 35,
|
||||||
|
"brightness": 55,
|
||||||
"x": 12,
|
"x": 12,
|
||||||
"y": 8,
|
"y": 8,
|
||||||
"emitRange": 15
|
"emitRange": 15
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ This is behavior-focused documentation for item types and their defaults.
|
|||||||
- Title: `piano`
|
- Title: `piano`
|
||||||
- Params:
|
- Params:
|
||||||
- `instrument="piano"`
|
- `instrument="piano"`
|
||||||
|
- `voiceMode="poly"`
|
||||||
|
- `octave=0`
|
||||||
- `attack=15`
|
- `attack=15`
|
||||||
- `decay=45`
|
- `decay=45`
|
||||||
- `release=35`
|
- `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).
|
- Announces that the user begins playing the piano (client enters piano key mode).
|
||||||
|
|
||||||
### Validation
|
### 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`
|
- `attack`: integer `0..100`
|
||||||
- `decay`: integer `0..100`
|
- `decay`: integer `0..100`
|
||||||
- `release`: integer `0..100`
|
- `release`: integer `0..100`
|
||||||
- `brightness`: integer `0..100`
|
- `brightness`: integer `0..100`
|
||||||
- `emitRange`: integer `5..20`
|
- `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)
|
## 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_use_sound` contains absolute item world coordinates (`x`, `y`) and sound path.
|
||||||
- `item_piano_note` contains:
|
- `item_piano_note` contains:
|
||||||
- `itemId`, `senderId`, `keyId`, `midi`, `on`
|
- `itemId`, `senderId`, `keyId`, `midi`, `on`
|
||||||
- resolved `instrument`, `attack`, `decay`, `emitRange`
|
- resolved `instrument`, `voiceMode`, `octave`, `attack`, `decay`, `release`, `brightness`, `emitRange`
|
||||||
- absolute source coordinates `x`, `y`
|
- absolute source coordinates `x`, `y`
|
||||||
|
|
||||||
## Welcome Metadata
|
## Welcome Metadata
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ CLOCK_TIME_ZONE_OPTIONS = clock.TIME_ZONE_OPTIONS
|
|||||||
RADIO_EFFECT_OPTIONS = radio.EFFECT_OPTIONS
|
RADIO_EFFECT_OPTIONS = radio.EFFECT_OPTIONS
|
||||||
RADIO_CHANNEL_OPTIONS = radio.CHANNEL_OPTIONS
|
RADIO_CHANNEL_OPTIONS = radio.CHANNEL_OPTIONS
|
||||||
PIANO_INSTRUMENT_OPTIONS = piano.INSTRUMENT_OPTIONS
|
PIANO_INSTRUMENT_OPTIONS = piano.INSTRUMENT_OPTIONS
|
||||||
|
PIANO_VOICE_MODE_OPTIONS = piano.VOICE_MODE_OPTIONS
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -81,6 +82,7 @@ ITEM_PROPERTY_OPTIONS: dict[str, tuple[str, ...]] = {
|
|||||||
"mediaChannel": RADIO_CHANNEL_OPTIONS,
|
"mediaChannel": RADIO_CHANNEL_OPTIONS,
|
||||||
"timeZone": CLOCK_TIME_ZONE_OPTIONS,
|
"timeZone": CLOCK_TIME_ZONE_OPTIONS,
|
||||||
"instrument": PIANO_INSTRUMENT_OPTIONS,
|
"instrument": PIANO_INSTRUMENT_OPTIONS,
|
||||||
|
"voiceMode": PIANO_VOICE_MODE_OPTIONS,
|
||||||
}
|
}
|
||||||
|
|
||||||
ITEM_TYPE_TOOLTIPS: dict[ItemType, str] = {
|
ITEM_TYPE_TOOLTIPS: dict[ItemType, str] = {
|
||||||
|
|||||||
@@ -9,7 +9,17 @@ 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", "release", "brightness", "emitRange")
|
EDITABLE_PROPERTIES: tuple[str, ...] = (
|
||||||
|
"title",
|
||||||
|
"instrument",
|
||||||
|
"voiceMode",
|
||||||
|
"octave",
|
||||||
|
"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
|
||||||
@@ -19,6 +29,8 @@ DIRECTIONAL = False
|
|||||||
DEFAULT_TITLE = "piano"
|
DEFAULT_TITLE = "piano"
|
||||||
DEFAULT_PARAMS: dict = {
|
DEFAULT_PARAMS: dict = {
|
||||||
"instrument": "piano",
|
"instrument": "piano",
|
||||||
|
"voiceMode": "poly",
|
||||||
|
"octave": 0,
|
||||||
"attack": 15,
|
"attack": 15,
|
||||||
"decay": 45,
|
"decay": 45,
|
||||||
"release": 35,
|
"release": 35,
|
||||||
@@ -34,25 +46,34 @@ INSTRUMENT_OPTIONS: tuple[str, ...] = (
|
|||||||
"bass",
|
"bass",
|
||||||
"violin",
|
"violin",
|
||||||
"synth_lead",
|
"synth_lead",
|
||||||
|
"brass",
|
||||||
"nintendo",
|
"nintendo",
|
||||||
"drum_kit",
|
"drum_kit",
|
||||||
)
|
)
|
||||||
|
VOICE_MODE_OPTIONS: tuple[str, ...] = ("poly", "mono")
|
||||||
|
|
||||||
DEFAULT_ENVELOPE_BY_INSTRUMENT: dict[str, tuple[int, int, int, int]] = {
|
DEFAULT_ENVELOPE_BY_INSTRUMENT: dict[str, tuple[int, int, int, int, str, int]] = {
|
||||||
"piano": (15, 45, 35, 55),
|
"piano": (15, 45, 35, 55, "poly", 0),
|
||||||
"electric_piano": (12, 40, 30, 62),
|
"electric_piano": (12, 40, 30, 62, "poly", 0),
|
||||||
"guitar": (8, 35, 25, 50),
|
"guitar": (8, 35, 25, 50, "poly", 0),
|
||||||
"organ": (25, 70, 45, 48),
|
"organ": (25, 70, 45, 48, "poly", 0),
|
||||||
"bass": (10, 35, 28, 38),
|
"bass": (2, 24, 18, 34, "mono", -1),
|
||||||
"violin": (22, 75, 55, 58),
|
"violin": (22, 75, 55, 58, "mono", 0),
|
||||||
"synth_lead": (6, 30, 22, 72),
|
"synth_lead": (6, 30, 22, 72, "poly", 0),
|
||||||
"nintendo": (2, 28, 18, 85),
|
"brass": (10, 45, 30, 60, "mono", 0),
|
||||||
"drum_kit": (1, 22, 12, 68),
|
"nintendo": (1, 24, 15, 85, "poly", 0),
|
||||||
|
"drum_kit": (1, 22, 12, 68, "poly", 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
||||||
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
|
"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."},
|
"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": {
|
"attack": {
|
||||||
"valueType": "number",
|
"valueType": "number",
|
||||||
"tooltip": "How quickly notes ramp in. Lower is sharper; higher is softer.",
|
"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()
|
previous_instrument = str(_item.params.get("instrument", "piano")).strip().lower()
|
||||||
next_params["instrument"] = instrument
|
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:
|
try:
|
||||||
attack = int(next_params.get("attack", 15))
|
attack = int(next_params.get("attack", 15))
|
||||||
except (TypeError, ValueError) as exc:
|
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.
|
# When instrument changes, reset envelope to instrument-appropriate defaults.
|
||||||
if instrument != previous_instrument:
|
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["attack"] = attack
|
||||||
next_params["decay"] = decay
|
next_params["decay"] = decay
|
||||||
next_params["release"] = release
|
next_params["release"] = release
|
||||||
|
|||||||
@@ -230,6 +230,8 @@ class ItemPianoNoteBroadcastPacket(BasePacket):
|
|||||||
midi: int
|
midi: int
|
||||||
on: bool
|
on: bool
|
||||||
instrument: str
|
instrument: str
|
||||||
|
voiceMode: str
|
||||||
|
octave: int
|
||||||
attack: int
|
attack: int
|
||||||
decay: int
|
decay: int
|
||||||
release: int
|
release: int
|
||||||
|
|||||||
@@ -677,6 +677,10 @@ class SignalingServer:
|
|||||||
else:
|
else:
|
||||||
active_keys.discard(packet.keyId)
|
active_keys.discard(packet.keyId)
|
||||||
instrument = str(item.params.get("instrument", "piano")).strip().lower()
|
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
|
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
|
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,
|
midi=packet.midi,
|
||||||
on=packet.on,
|
on=packet.on,
|
||||||
instrument=instrument,
|
instrument=instrument,
|
||||||
|
voiceMode=voice_mode,
|
||||||
|
octave=max(-2, min(2, octave)),
|
||||||
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)),
|
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 send_payloads[-1].ok is True
|
||||||
assert item.params.get("instrument") == "drum_kit"
|
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("attack") == 1
|
||||||
assert item.params.get("decay") == 22
|
assert item.params.get("decay") == 22
|
||||||
assert item.params.get("release") == 12
|
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 send_payloads[-1].ok is True
|
||||||
assert item.params.get("instrument") == "nintendo"
|
assert item.params.get("instrument") == "nintendo"
|
||||||
assert item.params.get("attack") == 2
|
assert item.params.get("voiceMode") == "poly"
|
||||||
assert item.params.get("decay") == 28
|
assert item.params.get("octave") == 0
|
||||||
assert item.params.get("release") == 18
|
assert item.params.get("attack") == 1
|
||||||
|
assert item.params.get("decay") == 24
|
||||||
|
assert item.params.get("release") == 15
|
||||||
assert item.params.get("brightness") == 85
|
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}))
|
||||||
@@ -406,6 +410,21 @@ async def test_piano_update_and_use(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
assert send_payloads[-1].ok is False
|
assert send_payloads[-1].ok is False
|
||||||
assert "instrument must be one of" in send_payloads[-1].message.lower()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_piano_note_packet_broadcasts(monkeypatch: pytest.MonkeyPatch) -> None:
|
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, "type", "") == "item_piano_note"
|
||||||
assert getattr(packet, "itemId", "") == item.id
|
assert getattr(packet, "itemId", "") == item.id
|
||||||
assert getattr(packet, "instrument", "") == "organ"
|
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, "attack", -1) == 20
|
||||||
assert getattr(packet, "decay", -1) == 60
|
assert getattr(packet, "decay", -1) == 60
|
||||||
assert getattr(packet, "release", -1) == 35
|
assert getattr(packet, "release", -1) == 35
|
||||||
|
|||||||
Reference in New Issue
Block a user