Expand piano drum-kit voices and polish instrument synthesis
This commit is contained in:
@@ -18,6 +18,7 @@ type VoiceRuntime = {
|
||||
gain: GainNode;
|
||||
panner: StereoPannerNode | null;
|
||||
oscillators: OscillatorNode[];
|
||||
modulators: OscillatorNode[];
|
||||
releaseSeconds: number;
|
||||
};
|
||||
|
||||
@@ -36,7 +37,9 @@ type InstrumentPreset = {
|
||||
oscillators: Array<{ type: OscillatorType; detune?: number; gain?: number; ratio?: number }>;
|
||||
filter?: { type: BiquadFilterType; frequency: number; q?: number };
|
||||
gain: number;
|
||||
sustainRatio?: number;
|
||||
releaseScale?: number;
|
||||
vibrato?: { rateHz: number; depthCents: number };
|
||||
};
|
||||
|
||||
const PRESETS: Record<Exclude<PianoInstrumentId, 'drum_kit'>, InstrumentPreset> = {
|
||||
@@ -47,6 +50,7 @@ const PRESETS: Record<Exclude<PianoInstrumentId, 'drum_kit'>, InstrumentPreset>
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 5200, q: 0.7 },
|
||||
gain: 0.32,
|
||||
sustainRatio: 0.5,
|
||||
releaseScale: 0.9,
|
||||
},
|
||||
electric_piano: {
|
||||
@@ -56,6 +60,7 @@ const PRESETS: Record<Exclude<PianoInstrumentId, 'drum_kit'>, InstrumentPreset>
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 4200, q: 0.8 },
|
||||
gain: 0.3,
|
||||
sustainRatio: 0.52,
|
||||
releaseScale: 0.8,
|
||||
},
|
||||
guitar: {
|
||||
@@ -65,6 +70,7 @@ const PRESETS: Record<Exclude<PianoInstrumentId, 'drum_kit'>, InstrumentPreset>
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3200, q: 0.9 },
|
||||
gain: 0.24,
|
||||
sustainRatio: 0.48,
|
||||
releaseScale: 0.7,
|
||||
},
|
||||
organ: {
|
||||
@@ -75,6 +81,7 @@ const PRESETS: Record<Exclude<PianoInstrumentId, 'drum_kit'>, InstrumentPreset>
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 6500, q: 0.6 },
|
||||
gain: 0.18,
|
||||
sustainRatio: 0.72,
|
||||
releaseScale: 1.4,
|
||||
},
|
||||
bass: {
|
||||
@@ -84,6 +91,7 @@ const PRESETS: Record<Exclude<PianoInstrumentId, 'drum_kit'>, InstrumentPreset>
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1500, q: 1.1 },
|
||||
gain: 0.28,
|
||||
sustainRatio: 0.45,
|
||||
releaseScale: 0.9,
|
||||
},
|
||||
violin: {
|
||||
@@ -93,7 +101,9 @@ const PRESETS: Record<Exclude<PianoInstrumentId, 'drum_kit'>, InstrumentPreset>
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3600, q: 1.0 },
|
||||
gain: 0.24,
|
||||
sustainRatio: 0.68,
|
||||
releaseScale: 1.5,
|
||||
vibrato: { rateHz: 5.7, depthCents: 12 },
|
||||
},
|
||||
synth_lead: {
|
||||
oscillators: [
|
||||
@@ -102,7 +112,9 @@ const PRESETS: Record<Exclude<PianoInstrumentId, 'drum_kit'>, InstrumentPreset>
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 5400, q: 0.9 },
|
||||
gain: 0.2,
|
||||
sustainRatio: 0.6,
|
||||
releaseScale: 1,
|
||||
vibrato: { rateHz: 6.8, depthCents: 9 },
|
||||
},
|
||||
nintendo: {
|
||||
oscillators: [
|
||||
@@ -111,6 +123,7 @@ const PRESETS: Record<Exclude<PianoInstrumentId, 'drum_kit'>, InstrumentPreset>
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 5200, q: 1.2 },
|
||||
gain: 0.22,
|
||||
sustainRatio: 0.62,
|
||||
releaseScale: 0.65,
|
||||
},
|
||||
};
|
||||
@@ -153,9 +166,13 @@ 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'];
|
||||
|
||||
export class PianoSynth {
|
||||
private readonly voices = new Map<string, VoiceRuntime>();
|
||||
private readonly drumNoiseBuffers = new WeakMap<AudioContext, AudioBuffer>();
|
||||
private readonly bitNoiseBuffers = new WeakMap<AudioContext, AudioBuffer>();
|
||||
|
||||
/** Stops and disconnects all active notes. */
|
||||
stopAll(): void {
|
||||
@@ -176,7 +193,7 @@ export class PianoSynth {
|
||||
): void {
|
||||
if (this.voices.has(keyId)) return;
|
||||
if (instrument === 'drum_kit') {
|
||||
this.playDrumHit(keyId, context, spatial, attackPercent, decayPercent);
|
||||
this.playDrumHit(keyId, midi, context, spatial, attackPercent, decayPercent);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -197,7 +214,7 @@ export class PianoSynth {
|
||||
const voiceGain = context.audioCtx.createGain();
|
||||
voiceGain.gain.setValueAtTime(0.0001, now);
|
||||
const peakGain = Math.max(0.0001, preset.gain * spatialMix.gain);
|
||||
const sustainGain = Math.max(0.0001, peakGain * 0.55);
|
||||
const sustainGain = Math.max(0.0001, peakGain * (preset.sustainRatio ?? 0.55));
|
||||
voiceGain.gain.exponentialRampToValueAtTime(peakGain, now + attackSeconds);
|
||||
voiceGain.gain.exponentialRampToValueAtTime(sustainGain, now + attackSeconds + decaySeconds * 0.6);
|
||||
|
||||
@@ -222,6 +239,7 @@ export class PianoSynth {
|
||||
|
||||
const frequency = midiToFrequency(midi);
|
||||
const oscillators: OscillatorNode[] = [];
|
||||
const modulators: OscillatorNode[] = [];
|
||||
for (const partial of preset.oscillators) {
|
||||
const oscillator = context.audioCtx.createOscillator();
|
||||
oscillator.type = partial.type;
|
||||
@@ -232,12 +250,22 @@ export class PianoSynth {
|
||||
oscillator.connect(oscGain).connect(voiceGain);
|
||||
oscillator.start(now);
|
||||
oscillators.push(oscillator);
|
||||
if (preset.vibrato) {
|
||||
const lfo = context.audioCtx.createOscillator();
|
||||
const lfoGain = context.audioCtx.createGain();
|
||||
lfo.frequency.setValueAtTime(preset.vibrato.rateHz, now);
|
||||
lfoGain.gain.setValueAtTime(preset.vibrato.depthCents, now);
|
||||
lfo.connect(lfoGain).connect(oscillator.detune);
|
||||
lfo.start(now);
|
||||
modulators.push(lfo);
|
||||
}
|
||||
}
|
||||
|
||||
this.voices.set(keyId, {
|
||||
gain: voiceGain,
|
||||
panner,
|
||||
oscillators,
|
||||
modulators,
|
||||
releaseSeconds,
|
||||
});
|
||||
}
|
||||
@@ -255,6 +283,9 @@ export class PianoSynth {
|
||||
for (const oscillator of voice.oscillators) {
|
||||
safeStop(oscillator, now + voice.releaseSeconds + 0.02);
|
||||
}
|
||||
for (const oscillator of voice.modulators) {
|
||||
safeStop(oscillator, now + voice.releaseSeconds + 0.02);
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
voice.gain.disconnect();
|
||||
@@ -274,6 +305,7 @@ export class PianoSynth {
|
||||
/** Plays one synthesized drum hit for drum-kit instrument mode. */
|
||||
private playDrumHit(
|
||||
keyId: string,
|
||||
midi: number,
|
||||
context: PianoContext,
|
||||
spatial: PianoSpatialSource,
|
||||
attackPercent: number,
|
||||
@@ -287,9 +319,10 @@ export class PianoSynth {
|
||||
baseGain: 1,
|
||||
});
|
||||
if (!spatialMix || spatialMix.gain <= 0) return;
|
||||
const typeIndex = Math.abs(this.hashKey(keyId)) % 4;
|
||||
const decaySeconds = 0.03 + decayPercentToSeconds(decayPercent) * 0.45;
|
||||
const attackSeconds = Math.max(0.001, attackPercentToSeconds(attackPercent) * 0.2);
|
||||
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 attackSeconds = Math.max(0.001, attackPercentToSeconds(attackPercent) * 0.18);
|
||||
|
||||
const gain = context.audioCtx.createGain();
|
||||
gain.gain.setValueAtTime(0.0001, now);
|
||||
@@ -306,25 +339,117 @@ export class PianoSynth {
|
||||
tailNode.connect(context.destination);
|
||||
}
|
||||
|
||||
if (typeIndex === 0) {
|
||||
const kick = context.audioCtx.createOscillator();
|
||||
kick.type = 'sine';
|
||||
kick.frequency.setValueAtTime(150, now);
|
||||
kick.frequency.exponentialRampToValueAtTime(45, now + decaySeconds * 0.85);
|
||||
kick.connect(gain);
|
||||
kick.start(now);
|
||||
safeStop(kick, now + decaySeconds + 0.04);
|
||||
if (variant === 'kick_808') {
|
||||
this.playKick808(context, gain, now, decaySeconds);
|
||||
return;
|
||||
}
|
||||
if (variant === 'tom_low') {
|
||||
this.playTom(context, gain, now, 120, 68, decaySeconds * 0.95);
|
||||
return;
|
||||
}
|
||||
if (variant === 'tom_high') {
|
||||
this.playTom(context, gain, now, 220, 125, decaySeconds * 0.8);
|
||||
return;
|
||||
}
|
||||
if (variant === 'hat_closed') {
|
||||
this.playNoiseDrum(context, gain, now, decaySeconds * 0.25, 'highpass', 6500, false);
|
||||
return;
|
||||
}
|
||||
if (variant === 'hat_open') {
|
||||
this.playNoiseDrum(context, gain, now, decaySeconds * 0.8, 'highpass', 5200, false);
|
||||
return;
|
||||
}
|
||||
if (variant === 'noise_8bit') {
|
||||
this.playNoiseDrum(context, gain, now, decaySeconds * 0.45, 'bandpass', 2700, true);
|
||||
return;
|
||||
}
|
||||
if (variant === 'clap') {
|
||||
this.playClap(context, gain, now, decaySeconds);
|
||||
return;
|
||||
}
|
||||
this.playSnare(context, gain, now, decaySeconds);
|
||||
}
|
||||
|
||||
/** 808-like kick: deep sine sweep with long-ish tail. */
|
||||
private playKick808(context: PianoContext, gain: GainNode, now: number, decaySeconds: 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));
|
||||
const body = context.audioCtx.createGain();
|
||||
body.gain.setValueAtTime(1, now);
|
||||
body.gain.exponentialRampToValueAtTime(0.0001, now + Math.max(0.08, decaySeconds));
|
||||
kick.connect(body).connect(gain);
|
||||
kick.start(now);
|
||||
safeStop(kick, now + Math.max(0.1, decaySeconds) + 0.05);
|
||||
}
|
||||
|
||||
/** Simple tom synthesis with tuned sine drop. */
|
||||
private playTom(context: PianoContext, gain: GainNode, now: number, startHz: number, endHz: number, decaySeconds: number): void {
|
||||
const tom = context.audioCtx.createOscillator();
|
||||
tom.type = 'sine';
|
||||
tom.frequency.setValueAtTime(startHz, now);
|
||||
tom.frequency.exponentialRampToValueAtTime(endHz, now + Math.max(0.05, decaySeconds * 0.85));
|
||||
const body = context.audioCtx.createGain();
|
||||
body.gain.setValueAtTime(1, now);
|
||||
body.gain.exponentialRampToValueAtTime(0.0001, now + Math.max(0.07, decaySeconds));
|
||||
tom.connect(body).connect(gain);
|
||||
tom.start(now);
|
||||
safeStop(tom, now + Math.max(0.1, decaySeconds) + 0.04);
|
||||
}
|
||||
|
||||
/** White-noise percussion core used by hats/snare/noise blips. */
|
||||
private playNoiseDrum(
|
||||
context: PianoContext,
|
||||
gain: GainNode,
|
||||
now: number,
|
||||
decaySeconds: number,
|
||||
filterType: BiquadFilterType,
|
||||
filterHz: number,
|
||||
bitStyle: boolean,
|
||||
): void {
|
||||
const noise = context.audioCtx.createBufferSource();
|
||||
noise.buffer = this.getNoiseBuffer(context.audioCtx);
|
||||
noise.buffer = bitStyle ? this.getBitNoiseBuffer(context.audioCtx) : this.getNoiseBuffer(context.audioCtx);
|
||||
const noiseFilter = context.audioCtx.createBiquadFilter();
|
||||
noiseFilter.type = typeIndex === 1 ? 'highpass' : typeIndex === 2 ? 'bandpass' : 'lowpass';
|
||||
noiseFilter.frequency.setValueAtTime(typeIndex === 1 ? 1700 : typeIndex === 2 ? 900 : 1300, now);
|
||||
noiseFilter.type = filterType;
|
||||
noiseFilter.frequency.setValueAtTime(filterHz, now);
|
||||
noise.connect(noiseFilter).connect(gain);
|
||||
noise.start(now);
|
||||
safeStop(noise, now + decaySeconds + 0.03);
|
||||
safeStop(noise, now + Math.max(0.02, decaySeconds) + 0.03);
|
||||
}
|
||||
|
||||
/** Snare: short tone + filtered noise burst. */
|
||||
private playSnare(context: PianoContext, gain: GainNode, now: number, decaySeconds: 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.exponentialRampToValueAtTime(0.0001, now + Math.max(0.04, decaySeconds * 0.55));
|
||||
tone.connect(toneGain).connect(gain);
|
||||
tone.start(now);
|
||||
safeStop(tone, now + Math.max(0.06, decaySeconds * 0.6) + 0.03);
|
||||
this.playNoiseDrum(context, gain, now, decaySeconds * 0.65, 'highpass', 1800, false);
|
||||
}
|
||||
|
||||
/** Clap: layered short filtered noise bursts. */
|
||||
private playClap(context: PianoContext, gain: GainNode, now: number, decaySeconds: number): void {
|
||||
const burstTimes = [0, 0.018, 0.035];
|
||||
for (const burstOffset of burstTimes) {
|
||||
const noise = context.audioCtx.createBufferSource();
|
||||
noise.buffer = this.getNoiseBuffer(context.audioCtx);
|
||||
const filter = context.audioCtx.createBiquadFilter();
|
||||
filter.type = 'bandpass';
|
||||
filter.frequency.setValueAtTime(2100, now + burstOffset);
|
||||
const burstGain = context.audioCtx.createGain();
|
||||
burstGain.gain.setValueAtTime(0.0001, now + burstOffset);
|
||||
burstGain.gain.exponentialRampToValueAtTime(0.85, now + burstOffset + 0.002);
|
||||
burstGain.gain.exponentialRampToValueAtTime(0.0001, now + burstOffset + Math.max(0.03, decaySeconds * 0.25));
|
||||
noise.connect(filter).connect(burstGain).connect(gain);
|
||||
noise.start(now + burstOffset);
|
||||
safeStop(noise, now + burstOffset + Math.max(0.05, decaySeconds * 0.32));
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns deterministic hash for key ids to map drum voice variants. */
|
||||
@@ -349,4 +474,23 @@ export class PianoSynth {
|
||||
this.drumNoiseBuffers.set(audioCtx, buffer);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/** Returns quantized 8-bit style noise buffer for retro percussion. */
|
||||
private getBitNoiseBuffer(audioCtx: AudioContext): AudioBuffer {
|
||||
const existing = this.bitNoiseBuffers.get(audioCtx);
|
||||
if (existing) return existing;
|
||||
const length = Math.max(1, Math.floor(audioCtx.sampleRate * 0.45));
|
||||
const buffer = audioCtx.createBuffer(1, length, audioCtx.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
let held = 0;
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (index % 16 === 0) {
|
||||
const raw = Math.random() * 2 - 1;
|
||||
held = Math.round(raw * 8) / 8;
|
||||
}
|
||||
data[index] = held;
|
||||
}
|
||||
this.bitNoiseBuffers.set(audioCtx, buffer);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user