Add piano release/brightness controls and instrument hotkeys

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

View File

@@ -128,16 +128,19 @@ const PRESETS: Record<Exclude<PianoInstrumentId, 'drum_kit'>, InstrumentPreset>
},
};
export const DEFAULT_ENVELOPE_BY_INSTRUMENT: Record<PianoInstrumentId, { attack: number; decay: number }> = {
piano: { attack: 15, decay: 45 },
electric_piano: { attack: 12, decay: 40 },
guitar: { attack: 8, decay: 35 },
organ: { attack: 25, decay: 70 },
bass: { attack: 10, decay: 35 },
violin: { attack: 22, decay: 75 },
synth_lead: { attack: 6, decay: 30 },
nintendo: { attack: 2, decay: 28 },
drum_kit: { attack: 1, decay: 22 },
export const DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT: Record<
PianoInstrumentId,
{ attack: number; decay: number; release: number; brightness: number }
> = {
piano: { attack: 15, decay: 45, release: 35, brightness: 55 },
electric_piano: { attack: 12, decay: 40, release: 30, brightness: 62 },
guitar: { attack: 8, decay: 35, release: 25, brightness: 50 },
organ: { attack: 25, decay: 70, release: 45, brightness: 48 },
bass: { attack: 10, decay: 35, release: 28, brightness: 38 },
violin: { attack: 22, decay: 75, release: 55, brightness: 58 },
synth_lead: { attack: 6, decay: 30, release: 22, brightness: 72 },
nintendo: { attack: 2, decay: 28, release: 18, brightness: 85 },
drum_kit: { attack: 1, decay: 22, release: 12, brightness: 68 },
};
/** Maps 0..100 control values to note attack seconds. */
@@ -152,6 +155,18 @@ function decayPercentToSeconds(value: number): number {
return 0.05 + (clamped / 100) * 2.7;
}
/** Maps 0..100 control values to release tail seconds after note-off. */
function releasePercentToSeconds(value: number): number {
const clamped = Math.max(0, Math.min(100, value));
return 0.03 + (clamped / 100) * 3.4;
}
/** Maps 0..100 control values to low-pass filter brightness multiplier. */
function brightnessPercentToMultiplier(value: number): number {
const clamped = Math.max(0, Math.min(100, value));
return 0.45 + (clamped / 100) * 1.55;
}
/** Converts midi note number to frequency in hertz. */
function midiToFrequency(midi: number): number {
return 440 * Math.pow(2, (midi - 69) / 12);
@@ -188,12 +203,14 @@ export class PianoSynth {
instrument: PianoInstrumentId,
attackPercent: number,
decayPercent: number,
releasePercent: number,
brightnessPercent: number,
context: PianoContext,
spatial: PianoSpatialSource,
): void {
if (this.voices.has(keyId)) return;
if (instrument === 'drum_kit') {
this.playDrumHit(keyId, midi, context, spatial, attackPercent, decayPercent);
this.playDrumHit(keyId, midi, context, spatial, attackPercent, decayPercent, releasePercent, brightnessPercent);
return;
}
@@ -201,7 +218,7 @@ export class PianoSynth {
const now = context.audioCtx.currentTime;
const attackSeconds = attackPercentToSeconds(attackPercent);
const decaySeconds = decayPercentToSeconds(decayPercent);
const releaseSeconds = Math.max(0.02, decaySeconds * (preset.releaseScale ?? 1));
const releaseSeconds = Math.max(0.02, releasePercentToSeconds(releasePercent) * (preset.releaseScale ?? 1));
const spatialMix = resolveSpatialMix({
dx: spatial.x,
@@ -222,7 +239,7 @@ export class PianoSynth {
if (preset.filter) {
const filter = context.audioCtx.createBiquadFilter();
filter.type = preset.filter.type;
filter.frequency.setValueAtTime(preset.filter.frequency, now);
filter.frequency.setValueAtTime(preset.filter.frequency * brightnessPercentToMultiplier(brightnessPercent), now);
filter.Q.setValueAtTime(preset.filter.q ?? 0.7, now);
voiceGain.connect(filter);
tailNode = filter;
@@ -310,6 +327,8 @@ export class PianoSynth {
spatial: PianoSpatialSource,
attackPercent: number,
decayPercent: number,
releasePercent: number,
brightnessPercent: number,
): void {
const now = context.audioCtx.currentTime;
const spatialMix = resolveSpatialMix({
@@ -322,7 +341,9 @@ export class PianoSynth {
const typeIndex = Math.abs((midi % DRUM_VARIANTS.length) + this.hashKey(keyId)) % DRUM_VARIANTS.length;
const variant = DRUM_VARIANTS[typeIndex];
const decaySeconds = 0.03 + decayPercentToSeconds(decayPercent) * 0.5;
const releaseSeconds = 0.02 + releasePercentToSeconds(releasePercent) * 0.35;
const attackSeconds = Math.max(0.001, attackPercentToSeconds(attackPercent) * 0.18);
const brightnessMultiplier = brightnessPercentToMultiplier(brightnessPercent);
const gain = context.audioCtx.createGain();
gain.gain.setValueAtTime(0.0001, now);
@@ -340,34 +361,34 @@ export class PianoSynth {
}
if (variant === 'kick_808') {
this.playKick808(context, gain, now, decaySeconds);
this.playKick808(context, gain, now, decaySeconds + releaseSeconds * 0.35);
return;
}
if (variant === 'tom_low') {
this.playTom(context, gain, now, 120, 68, decaySeconds * 0.95);
this.playTom(context, gain, now, 120, 68, decaySeconds * 0.95 + releaseSeconds * 0.2);
return;
}
if (variant === 'tom_high') {
this.playTom(context, gain, now, 220, 125, decaySeconds * 0.8);
this.playTom(context, gain, now, 220, 125, decaySeconds * 0.8 + releaseSeconds * 0.16);
return;
}
if (variant === 'hat_closed') {
this.playNoiseDrum(context, gain, now, decaySeconds * 0.25, 'highpass', 6500, false);
this.playNoiseDrum(context, gain, now, decaySeconds * 0.25, 'highpass', 6500 * brightnessMultiplier, false);
return;
}
if (variant === 'hat_open') {
this.playNoiseDrum(context, gain, now, decaySeconds * 0.8, 'highpass', 5200, false);
this.playNoiseDrum(context, gain, now, decaySeconds * 0.8 + releaseSeconds * 0.2, 'highpass', 5200 * brightnessMultiplier, false);
return;
}
if (variant === 'noise_8bit') {
this.playNoiseDrum(context, gain, now, decaySeconds * 0.45, 'bandpass', 2700, true);
this.playNoiseDrum(context, gain, now, decaySeconds * 0.45, 'bandpass', 2700 * brightnessMultiplier, true);
return;
}
if (variant === 'clap') {
this.playClap(context, gain, now, decaySeconds);
this.playClap(context, gain, now, decaySeconds + releaseSeconds * 0.1);
return;
}
this.playSnare(context, gain, now, decaySeconds);
this.playSnare(context, gain, now, decaySeconds + releaseSeconds * 0.12);
}
/** 808-like kick: deep sine sweep with long-ish tail. */

View File

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

View File

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

View File

@@ -14,7 +14,12 @@ import {
shouldProxyStreamUrl,
} from './audio/radioStationRuntime';
import { ItemEmitRuntime } from './audio/itemEmitRuntime';
import { DEFAULT_ENVELOPE_BY_INSTRUMENT, PianoSynth, type PianoInstrumentId } from './audio/pianoSynth';
import {
DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT,
PIANO_INSTRUMENT_OPTIONS,
PianoSynth,
type PianoInstrumentId,
} from './audio/pianoSynth';
import { normalizeDegrees } from './audio/spatial';
import {
applyPastedText,
@@ -789,7 +794,14 @@ function getItemSpatialConfig(item: WorldItem): { range: number; directional: bo
}
/** Resolves piano params with safe defaults for local play mode. */
function getPianoParams(item: WorldItem): { instrument: PianoInstrumentId; attack: number; decay: number; emitRange: number } {
function getPianoParams(item: WorldItem): {
instrument: PianoInstrumentId;
attack: number;
decay: number;
release: number;
brightness: number;
emitRange: number;
} {
const rawInstrument = String(item.params.instrument ?? 'piano').trim().toLowerCase();
const instrument: PianoInstrumentId =
rawInstrument === 'electric_piano' ||
@@ -804,11 +816,16 @@ function getPianoParams(item: WorldItem): { instrument: PianoInstrumentId; attac
: 'piano';
const rawAttack = Number(item.params.attack);
const rawDecay = Number(item.params.decay);
const rawRelease = Number(item.params.release);
const rawBrightness = Number(item.params.brightness);
const rawEmitRange = Number(item.params.emitRange ?? getItemTypeGlobalProperties(item.type).emitRange ?? 15);
const defaults = DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT[instrument];
return {
instrument,
attack: Math.max(0, Math.min(100, Number.isFinite(rawAttack) ? Math.round(rawAttack) : 15)),
decay: Math.max(0, Math.min(100, Number.isFinite(rawDecay) ? Math.round(rawDecay) : 45)),
attack: Math.max(0, Math.min(100, Number.isFinite(rawAttack) ? Math.round(rawAttack) : defaults.attack)),
decay: Math.max(0, Math.min(100, Number.isFinite(rawDecay) ? Math.round(rawDecay) : defaults.decay)),
release: Math.max(0, Math.min(100, Number.isFinite(rawRelease) ? Math.round(rawRelease) : defaults.release)),
brightness: Math.max(0, Math.min(100, Number.isFinite(rawBrightness) ? Math.round(rawBrightness) : defaults.brightness)),
emitRange: Math.max(5, Math.min(20, Number.isFinite(rawEmitRange) ? Math.round(rawEmitRange) : 15)),
};
}
@@ -870,7 +887,10 @@ function stopPianoUseMode(announce = true): void {
}
/** Plays one short C4 preview using the piano item's current/overridden envelope+instrument. */
async function previewPianoSettingChange(item: WorldItem, overrides: Partial<{ instrument: PianoInstrumentId; attack: number; decay: number }>): Promise<void> {
async function previewPianoSettingChange(
item: WorldItem,
overrides: Partial<{ instrument: PianoInstrumentId; attack: number; decay: number; release: number; brightness: number }>,
): Promise<void> {
if (item.type !== 'piano') return;
await audio.ensureContext();
const ctx = audio.context;
@@ -880,6 +900,8 @@ async function previewPianoSettingChange(item: WorldItem, overrides: Partial<{ i
const instrument = overrides.instrument ?? current.instrument;
const attack = Math.max(0, Math.min(100, Math.round(overrides.attack ?? current.attack)));
const decay = Math.max(0, Math.min(100, Math.round(overrides.decay ?? current.decay)));
const release = Math.max(0, Math.min(100, Math.round(overrides.release ?? current.release)));
const brightness = Math.max(0, Math.min(100, Math.round(overrides.brightness ?? current.brightness)));
const sourceX = item.carrierId === state.player.id ? state.player.x : item.x;
const sourceY = item.carrierId === state.player.id ? state.player.y : item.y;
const previewKeyId = '__piano_preview_c4__';
@@ -890,6 +912,8 @@ async function previewPianoSettingChange(item: WorldItem, overrides: Partial<{ i
instrument,
attack,
decay,
release,
brightness,
{ audioCtx: ctx, destination },
{ x: sourceX - state.player.x, y: sourceY - state.player.y, range: current.emitRange },
);
@@ -911,6 +935,8 @@ function playRemotePianoNote(note: {
instrument: string;
attack: number;
decay: number;
release: number;
brightness: number;
x: number;
y: number;
emitRange: number;
@@ -927,6 +953,8 @@ function playRemotePianoNote(note: {
normalizePianoInstrument(note.instrument),
Math.max(0, Math.min(100, Math.round(note.attack))),
Math.max(0, Math.min(100, Math.round(note.decay))),
Math.max(0, Math.min(100, Math.round(note.release))),
Math.max(0, Math.min(100, Math.round(note.brightness))),
{ audioCtx: ctx, destination },
{
x: note.x - state.player.x,
@@ -1182,6 +1210,8 @@ function inferItemPropertyValueType(item: WorldItem, key: string): string | unde
key === 'emitRange' ||
key === 'attack' ||
key === 'decay' ||
key === 'release' ||
key === 'brightness' ||
key === 'sides' ||
key === 'number' ||
key === 'useCooldownMs'
@@ -2253,6 +2283,37 @@ function handlePianoUseModeInput(code: string): void {
stopPianoUseMode(false);
return;
}
if (code.startsWith('Digit')) {
const digit = Number(code.slice(5));
if (Number.isInteger(digit) && digit >= 1 && digit <= 9) {
const instrument = PIANO_INSTRUMENT_OPTIONS[digit - 1];
if (instrument) {
const defaults = DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT[instrument];
item.params.instrument = instrument;
item.params.attack = defaults.attack;
item.params.decay = defaults.decay;
item.params.release = defaults.release;
item.params.brightness = defaults.brightness;
signaling.send({
type: 'item_update',
itemId,
params: {
instrument,
},
});
void previewPianoSettingChange(item, {
instrument,
attack: defaults.attack,
decay: defaults.decay,
release: defaults.release,
brightness: defaults.brightness,
});
updateStatus(`Instrument ${instrument}.`);
audio.sfxUiBlip();
}
return;
}
}
const midi = getPianoMidiForCode(code);
if (midi === null) return;
if (activePianoKeys.has(code)) return;
@@ -2269,6 +2330,8 @@ function handlePianoUseModeInput(code: string): void {
config.instrument,
config.attack,
config.decay,
config.release,
config.brightness,
{ audioCtx: ctx, destination },
{ x: sourceX - state.player.x, y: sourceY - state.player.y, range: config.emitRange },
);
@@ -2528,8 +2591,14 @@ const itemPropertyEditor = createItemPropertyEditor({
if (item.type !== 'piano') return;
if (key === 'instrument') {
const instrument = normalizePianoInstrument(value);
const defaults = DEFAULT_ENVELOPE_BY_INSTRUMENT[instrument];
void previewPianoSettingChange(item, { instrument, attack: defaults.attack, decay: defaults.decay });
const defaults = DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT[instrument];
void previewPianoSettingChange(item, {
instrument,
attack: defaults.attack,
decay: defaults.decay,
release: defaults.release,
brightness: defaults.brightness,
});
return;
}
if (key === 'attack') {
@@ -2542,6 +2611,18 @@ const itemPropertyEditor = createItemPropertyEditor({
const decay = Number(value);
if (!Number.isFinite(decay)) return;
void previewPianoSettingChange(item, { decay });
return;
}
if (key === 'release') {
const release = Number(value);
if (!Number.isFinite(release)) return;
void previewPianoSettingChange(item, { release });
return;
}
if (key === 'brightness') {
const brightness = Number(value);
if (!Number.isFinite(brightness)) return;
void previewPianoSettingChange(item, { brightness });
}
},
updateStatus,

View File

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

View File

@@ -160,6 +160,8 @@ export const itemPianoNoteSchema = z.object({
instrument: z.string(),
attack: z.number().int().min(0).max(100),
decay: z.number().int().min(0).max(100),
release: z.number().int().min(0).max(100),
brightness: z.number().int().min(0).max(100),
x: z.number().int(),
y: z.number().int(),
emitRange: z.number().int().min(1),