Add piano mono/poly, octave, and expanded drum voice set

This commit is contained in:
Jage9
2026-02-23 00:22:36 -05:00
parent 019e49802d
commit 29eb6a63e3
17 changed files with 338 additions and 72 deletions

View File

@@ -8,6 +8,7 @@ export const PIANO_INSTRUMENT_OPTIONS = [
'bass',
'violin',
'synth_lead',
'brass',
'nintendo',
'drum_kit',
] as const;
@@ -20,6 +21,7 @@ type VoiceRuntime = {
oscillators: OscillatorNode[];
modulators: OscillatorNode[];
releaseSeconds: number;
sourceGroupId: string;
};
type PianoContext = {
@@ -116,10 +118,21 @@ const PRESETS: Record<Exclude<PianoInstrumentId, 'drum_kit'>, InstrumentPreset>
releaseScale: 1,
vibrato: { rateHz: 6.8, depthCents: 9 },
},
brass: {
oscillators: [
{ type: 'sawtooth', gain: 0.72 },
{ type: 'square', ratio: 2, gain: 0.2 },
],
filter: { type: 'lowpass', frequency: 3300, q: 1.05 },
gain: 0.22,
sustainRatio: 0.62,
releaseScale: 0.92,
vibrato: { rateHz: 5.1, depthCents: 5 },
},
nintendo: {
oscillators: [
{ type: 'square', gain: 1 },
{ type: 'square', detune: 8, gain: 0.16 },
{ type: 'square', detune: 2, gain: 0.08 },
],
filter: { type: 'lowpass', frequency: 5200, q: 1.2 },
gain: 0.22,
@@ -136,10 +149,11 @@ export const DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT: Record<
electric_piano: { attack: 12, decay: 40, release: 30, brightness: 62 },
guitar: { attack: 8, decay: 35, release: 25, brightness: 50 },
organ: { attack: 25, decay: 70, release: 45, brightness: 48 },
bass: { attack: 10, decay: 35, release: 28, brightness: 38 },
bass: { attack: 2, decay: 24, release: 18, brightness: 34 },
violin: { attack: 22, decay: 75, release: 55, brightness: 58 },
synth_lead: { attack: 6, decay: 30, release: 22, brightness: 72 },
nintendo: { attack: 2, decay: 28, release: 18, brightness: 85 },
brass: { attack: 10, decay: 45, release: 30, brightness: 60 },
nintendo: { attack: 1, decay: 24, release: 15, brightness: 85 },
drum_kit: { attack: 1, decay: 22, release: 12, brightness: 68 },
};
@@ -167,6 +181,28 @@ function brightnessPercentToMultiplier(value: number): number {
return 0.45 + (clamped / 100) * 1.55;
}
/** Maps midi note number to one deterministic drum voice variant. */
function drumVariantForMidi(midi: number): DrumVariant {
const palette: DrumVariant[] = [
'kick_sub',
'kick_punch',
'snare_tight',
'snare_body',
'hat_closed',
'hat_open',
'tom_low',
'tom_mid',
'tom_high',
'clap',
'pow_mid',
'pow_high',
'snare_noise',
'noise_8bit',
];
const index = ((Math.round(midi) % palette.length) + palette.length) % palette.length;
return palette[index];
}
/** Converts midi note number to frequency in hertz. */
function midiToFrequency(midi: number): number {
return 440 * Math.pow(2, (midi - 69) / 12);
@@ -181,11 +217,25 @@ function safeStop(oscillator: OscillatorNode, when: number): void {
}
}
type DrumVariant = 'kick_808' | 'snare' | 'clap' | 'hat_closed' | 'hat_open' | 'tom_low' | 'tom_high' | 'noise_8bit';
const DRUM_VARIANTS: DrumVariant[] = ['kick_808', 'snare', 'clap', 'hat_closed', 'hat_open', 'tom_low', 'tom_high', 'noise_8bit'];
type DrumVariant =
| 'kick_sub'
| 'kick_punch'
| 'snare_tight'
| 'snare_body'
| 'snare_noise'
| 'clap'
| 'hat_closed'
| 'hat_open'
| 'tom_low'
| 'tom_mid'
| 'tom_high'
| 'pow_mid'
| 'pow_high'
| 'noise_8bit';
export class PianoSynth {
private readonly voices = new Map<string, VoiceRuntime>();
private readonly activeVoiceKeysByGroup = new Map<string, Set<string>>();
private readonly drumNoiseBuffers = new WeakMap<AudioContext, AudioBuffer>();
private readonly bitNoiseBuffers = new WeakMap<AudioContext, AudioBuffer>();
@@ -199,8 +249,10 @@ export class PianoSynth {
/** Starts one note for a specific keyboard key id. */
noteOn(
keyId: string,
sourceGroupId: string,
midi: number,
instrument: PianoInstrumentId,
voiceMode: 'mono' | 'poly',
attackPercent: number,
decayPercent: number,
releasePercent: number,
@@ -209,8 +261,16 @@ export class PianoSynth {
spatial: PianoSpatialSource,
): void {
if (this.voices.has(keyId)) return;
if (voiceMode === 'mono') {
const previousKeys = this.activeVoiceKeysByGroup.get(sourceGroupId);
if (previousKeys) {
for (const previousKey of Array.from(previousKeys)) {
this.noteOff(previousKey);
}
}
}
if (instrument === 'drum_kit') {
this.playDrumHit(keyId, midi, context, spatial, attackPercent, decayPercent, releasePercent, brightnessPercent);
this.playDrumHit(midi, context, spatial, attackPercent, decayPercent, releasePercent, brightnessPercent);
return;
}
@@ -284,7 +344,11 @@ export class PianoSynth {
oscillators,
modulators,
releaseSeconds,
sourceGroupId,
});
const groupKeys = this.activeVoiceKeysByGroup.get(sourceGroupId) ?? new Set<string>();
groupKeys.add(keyId);
this.activeVoiceKeysByGroup.set(sourceGroupId, groupKeys);
}
/** Releases one active note tied to a keyboard key id. */
@@ -292,6 +356,15 @@ export class PianoSynth {
const voice = this.voices.get(keyId);
if (!voice) return;
this.voices.delete(keyId);
const groupKeys = this.activeVoiceKeysByGroup.get(voice.sourceGroupId);
if (groupKeys) {
groupKeys.delete(keyId);
if (groupKeys.size === 0) {
this.activeVoiceKeysByGroup.delete(voice.sourceGroupId);
} else {
this.activeVoiceKeysByGroup.set(voice.sourceGroupId, groupKeys);
}
}
const now = voice.gain.context.currentTime;
const currentGain = Math.max(0.0001, voice.gain.gain.value);
voice.gain.gain.cancelScheduledValues(now);
@@ -321,7 +394,6 @@ export class PianoSynth {
/** Plays one synthesized drum hit for drum-kit instrument mode. */
private playDrumHit(
keyId: string,
midi: number,
context: PianoContext,
spatial: PianoSpatialSource,
@@ -338,8 +410,8 @@ export class PianoSynth {
baseGain: 1,
});
if (!spatialMix || spatialMix.gain <= 0) return;
const typeIndex = Math.abs((midi % DRUM_VARIANTS.length) + this.hashKey(keyId)) % DRUM_VARIANTS.length;
const variant = DRUM_VARIANTS[typeIndex];
const variant = drumVariantForMidi(midi);
const midiOffset = (Math.round(midi) - 60) / 24;
const decaySeconds = 0.03 + decayPercentToSeconds(decayPercent) * 0.5;
const releaseSeconds = 0.02 + releasePercentToSeconds(releasePercent) * 0.35;
const attackSeconds = Math.max(0.001, attackPercentToSeconds(attackPercent) * 0.18);
@@ -350,26 +422,45 @@ export class PianoSynth {
gain.gain.exponentialRampToValueAtTime(0.22 * spatialMix.gain, now + attackSeconds);
gain.gain.exponentialRampToValueAtTime(0.0001, now + decaySeconds);
let tailNode: AudioNode = gain;
if (typeof context.audioCtx.createStereoPanner === 'function') {
const panner = context.audioCtx.createStereoPanner();
panner.pan.setValueAtTime(spatialMix.pan, now);
tailNode.connect(panner).connect(context.destination);
tailNode = panner;
gain.connect(panner).connect(context.destination);
} else {
tailNode.connect(context.destination);
gain.connect(context.destination);
}
if (variant === 'kick_808') {
this.playKick808(context, gain, now, decaySeconds + releaseSeconds * 0.35);
if (variant === 'kick_sub') {
this.playKick808(context, gain, now, (decaySeconds + releaseSeconds * 0.35) * 1.15, 145, 36);
return;
}
if (variant === 'kick_punch') {
this.playKick808(context, gain, now, decaySeconds + releaseSeconds * 0.2, 185, 52);
return;
}
if (variant === 'snare_tight') {
this.playSnare(context, gain, now, decaySeconds * 0.55 + releaseSeconds * 0.08, 0.75);
return;
}
if (variant === 'snare_body') {
this.playSnare(context, gain, now, decaySeconds * 0.92 + releaseSeconds * 0.18, 1);
return;
}
if (variant === 'snare_noise') {
this.playSnare(context, gain, now, decaySeconds * 0.8 + releaseSeconds * 0.15, 0.45);
this.playNoiseDrum(context, gain, now, decaySeconds * 0.75, 'highpass', 1900 * brightnessMultiplier, true);
return;
}
if (variant === 'tom_low') {
this.playTom(context, gain, now, 120, 68, decaySeconds * 0.95 + releaseSeconds * 0.2);
this.playTom(context, gain, now, 120, 70, decaySeconds * 0.95 + releaseSeconds * 0.2);
return;
}
if (variant === 'tom_mid') {
this.playTom(context, gain, now, 175, 100, decaySeconds * 0.86 + releaseSeconds * 0.16);
return;
}
if (variant === 'tom_high') {
this.playTom(context, gain, now, 220, 125, decaySeconds * 0.8 + releaseSeconds * 0.16);
this.playTom(context, gain, now, 250, 138, decaySeconds * 0.78 + releaseSeconds * 0.14);
return;
}
if (variant === 'hat_closed') {
@@ -380,23 +471,38 @@ export class PianoSynth {
this.playNoiseDrum(context, gain, now, decaySeconds * 0.8 + releaseSeconds * 0.2, 'highpass', 5200 * brightnessMultiplier, false);
return;
}
if (variant === 'noise_8bit') {
this.playNoiseDrum(context, gain, now, decaySeconds * 0.45, 'bandpass', 2700 * brightnessMultiplier, true);
return;
}
if (variant === 'clap') {
this.playClap(context, gain, now, decaySeconds + releaseSeconds * 0.1);
return;
}
this.playSnare(context, gain, now, decaySeconds + releaseSeconds * 0.12);
if (variant === 'pow_mid') {
this.playPowDown(context, gain, now, 310 + midiOffset * 30, 150 + midiOffset * 15, decaySeconds * 0.95 + releaseSeconds * 0.15);
return;
}
if (variant === 'pow_high') {
this.playPowDown(context, gain, now, 420 + midiOffset * 40, 210 + midiOffset * 22, decaySeconds * 0.88 + releaseSeconds * 0.12);
return;
}
if (variant === 'noise_8bit') {
this.playNoiseDrum(context, gain, now, decaySeconds * 0.45, 'bandpass', 2700 * brightnessMultiplier, true);
return;
}
this.playSnare(context, gain, now, decaySeconds + releaseSeconds * 0.12, 1);
}
/** 808-like kick: deep sine sweep with long-ish tail. */
private playKick808(context: PianoContext, gain: GainNode, now: number, decaySeconds: number): void {
private playKick808(
context: PianoContext,
gain: GainNode,
now: number,
decaySeconds: number,
startHz: number,
endHz: number,
): void {
const kick = context.audioCtx.createOscillator();
kick.type = 'sine';
kick.frequency.setValueAtTime(160, now);
kick.frequency.exponentialRampToValueAtTime(42, now + Math.max(0.07, decaySeconds * 0.95));
kick.frequency.setValueAtTime(startHz, now);
kick.frequency.exponentialRampToValueAtTime(endHz, now + Math.max(0.07, decaySeconds * 0.95));
const body = context.audioCtx.createGain();
body.gain.setValueAtTime(1, now);
body.gain.exponentialRampToValueAtTime(0.0001, now + Math.max(0.08, decaySeconds));
@@ -440,13 +546,13 @@ export class PianoSynth {
}
/** Snare: short tone + filtered noise burst. */
private playSnare(context: PianoContext, gain: GainNode, now: number, decaySeconds: number): void {
private playSnare(context: PianoContext, gain: GainNode, now: number, decaySeconds: number, toneLevel: number): void {
const tone = context.audioCtx.createOscillator();
tone.type = 'triangle';
tone.frequency.setValueAtTime(220, now);
tone.frequency.exponentialRampToValueAtTime(130, now + Math.max(0.03, decaySeconds * 0.45));
const toneGain = context.audioCtx.createGain();
toneGain.gain.setValueAtTime(0.45, now);
toneGain.gain.setValueAtTime(0.45 * Math.max(0, toneLevel), now);
toneGain.gain.exponentialRampToValueAtTime(0.0001, now + Math.max(0.04, decaySeconds * 0.55));
tone.connect(toneGain).connect(gain);
tone.start(now);
@@ -473,13 +579,22 @@ export class PianoSynth {
}
}
/** Returns deterministic hash for key ids to map drum voice variants. */
private hashKey(value: string): number {
let out = 0;
for (let index = 0; index < value.length; index += 1) {
out = ((out << 5) - out + value.charCodeAt(index)) | 0;
}
return out;
/** Retro game-like downward-bending midrange hit for drum fills. */
private playPowDown(context: PianoContext, gain: GainNode, now: number, startHz: number, endHz: number, decaySeconds: number): void {
const osc = context.audioCtx.createOscillator();
osc.type = 'square';
osc.frequency.setValueAtTime(startHz, now);
osc.frequency.exponentialRampToValueAtTime(Math.max(35, endHz), now + Math.max(0.04, decaySeconds * 0.9));
const amp = context.audioCtx.createGain();
amp.gain.setValueAtTime(0.75, now);
amp.gain.exponentialRampToValueAtTime(0.0001, now + Math.max(0.06, decaySeconds));
const filter = context.audioCtx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime(1700, now);
filter.Q.setValueAtTime(1.2, now);
osc.connect(filter).connect(amp).connect(gain);
osc.start(now);
safeStop(osc, now + Math.max(0.08, decaySeconds) + 0.03);
}
/** Returns or lazily builds short white-noise buffer for percussion synthesis. */