Add piano release/brightness controls and instrument hotkeys
This commit is contained in:
@@ -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. */
|
||||
|
||||
@@ -319,6 +319,8 @@ export function createItemPropertyEditor(deps: EditorDeps): {
|
||||
propertyKey === 'emitRange' ||
|
||||
propertyKey === 'attack' ||
|
||||
propertyKey === 'decay' ||
|
||||
propertyKey === 'release' ||
|
||||
propertyKey === 'brightness' ||
|
||||
propertyKey === 'sides' ||
|
||||
propertyKey === 'number'
|
||||
) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user