Expand piano drum-kit voices and polish instrument synthesis

This commit is contained in:
Jage9
2026-02-22 23:58:07 -05:00
parent 89c6aa7e9b
commit d9e9e60524
2 changed files with 162 additions and 18 deletions

View File

@@ -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 R200"; window.CHGRID_WEB_VERSION = "2026.02.22 R201";
// 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";

View File

@@ -18,6 +18,7 @@ type VoiceRuntime = {
gain: GainNode; gain: GainNode;
panner: StereoPannerNode | null; panner: StereoPannerNode | null;
oscillators: OscillatorNode[]; oscillators: OscillatorNode[];
modulators: OscillatorNode[];
releaseSeconds: number; releaseSeconds: number;
}; };
@@ -36,7 +37,9 @@ type InstrumentPreset = {
oscillators: Array<{ type: OscillatorType; detune?: number; gain?: number; ratio?: number }>; oscillators: Array<{ type: OscillatorType; detune?: number; gain?: number; ratio?: number }>;
filter?: { type: BiquadFilterType; frequency: number; q?: number }; filter?: { type: BiquadFilterType; frequency: number; q?: number };
gain: number; gain: number;
sustainRatio?: number;
releaseScale?: number; releaseScale?: number;
vibrato?: { rateHz: number; depthCents: number };
}; };
const PRESETS: Record<Exclude<PianoInstrumentId, 'drum_kit'>, InstrumentPreset> = { 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 }, filter: { type: 'lowpass', frequency: 5200, q: 0.7 },
gain: 0.32, gain: 0.32,
sustainRatio: 0.5,
releaseScale: 0.9, releaseScale: 0.9,
}, },
electric_piano: { electric_piano: {
@@ -56,6 +60,7 @@ const PRESETS: Record<Exclude<PianoInstrumentId, 'drum_kit'>, InstrumentPreset>
], ],
filter: { type: 'lowpass', frequency: 4200, q: 0.8 }, filter: { type: 'lowpass', frequency: 4200, q: 0.8 },
gain: 0.3, gain: 0.3,
sustainRatio: 0.52,
releaseScale: 0.8, releaseScale: 0.8,
}, },
guitar: { guitar: {
@@ -65,6 +70,7 @@ const PRESETS: Record<Exclude<PianoInstrumentId, 'drum_kit'>, InstrumentPreset>
], ],
filter: { type: 'lowpass', frequency: 3200, q: 0.9 }, filter: { type: 'lowpass', frequency: 3200, q: 0.9 },
gain: 0.24, gain: 0.24,
sustainRatio: 0.48,
releaseScale: 0.7, releaseScale: 0.7,
}, },
organ: { organ: {
@@ -75,6 +81,7 @@ const PRESETS: Record<Exclude<PianoInstrumentId, 'drum_kit'>, InstrumentPreset>
], ],
filter: { type: 'lowpass', frequency: 6500, q: 0.6 }, filter: { type: 'lowpass', frequency: 6500, q: 0.6 },
gain: 0.18, gain: 0.18,
sustainRatio: 0.72,
releaseScale: 1.4, releaseScale: 1.4,
}, },
bass: { bass: {
@@ -84,6 +91,7 @@ const PRESETS: Record<Exclude<PianoInstrumentId, 'drum_kit'>, InstrumentPreset>
], ],
filter: { type: 'lowpass', frequency: 1500, q: 1.1 }, filter: { type: 'lowpass', frequency: 1500, q: 1.1 },
gain: 0.28, gain: 0.28,
sustainRatio: 0.45,
releaseScale: 0.9, releaseScale: 0.9,
}, },
violin: { violin: {
@@ -93,7 +101,9 @@ const PRESETS: Record<Exclude<PianoInstrumentId, 'drum_kit'>, InstrumentPreset>
], ],
filter: { type: 'lowpass', frequency: 3600, q: 1.0 }, filter: { type: 'lowpass', frequency: 3600, q: 1.0 },
gain: 0.24, gain: 0.24,
sustainRatio: 0.68,
releaseScale: 1.5, releaseScale: 1.5,
vibrato: { rateHz: 5.7, depthCents: 12 },
}, },
synth_lead: { synth_lead: {
oscillators: [ oscillators: [
@@ -102,7 +112,9 @@ const PRESETS: Record<Exclude<PianoInstrumentId, 'drum_kit'>, InstrumentPreset>
], ],
filter: { type: 'lowpass', frequency: 5400, q: 0.9 }, filter: { type: 'lowpass', frequency: 5400, q: 0.9 },
gain: 0.2, gain: 0.2,
sustainRatio: 0.6,
releaseScale: 1, releaseScale: 1,
vibrato: { rateHz: 6.8, depthCents: 9 },
}, },
nintendo: { nintendo: {
oscillators: [ oscillators: [
@@ -111,6 +123,7 @@ const PRESETS: Record<Exclude<PianoInstrumentId, 'drum_kit'>, InstrumentPreset>
], ],
filter: { type: 'lowpass', frequency: 5200, q: 1.2 }, filter: { type: 'lowpass', frequency: 5200, q: 1.2 },
gain: 0.22, gain: 0.22,
sustainRatio: 0.62,
releaseScale: 0.65, 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 { export class PianoSynth {
private readonly voices = new Map<string, VoiceRuntime>(); private readonly voices = new Map<string, VoiceRuntime>();
private readonly drumNoiseBuffers = new WeakMap<AudioContext, AudioBuffer>(); private readonly drumNoiseBuffers = new WeakMap<AudioContext, AudioBuffer>();
private readonly bitNoiseBuffers = new WeakMap<AudioContext, AudioBuffer>();
/** Stops and disconnects all active notes. */ /** Stops and disconnects all active notes. */
stopAll(): void { stopAll(): void {
@@ -176,7 +193,7 @@ export class PianoSynth {
): void { ): void {
if (this.voices.has(keyId)) return; if (this.voices.has(keyId)) return;
if (instrument === 'drum_kit') { if (instrument === 'drum_kit') {
this.playDrumHit(keyId, context, spatial, attackPercent, decayPercent); this.playDrumHit(keyId, midi, context, spatial, attackPercent, decayPercent);
return; return;
} }
@@ -197,7 +214,7 @@ export class PianoSynth {
const voiceGain = context.audioCtx.createGain(); const voiceGain = context.audioCtx.createGain();
voiceGain.gain.setValueAtTime(0.0001, now); voiceGain.gain.setValueAtTime(0.0001, now);
const peakGain = Math.max(0.0001, preset.gain * spatialMix.gain); 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(peakGain, now + attackSeconds);
voiceGain.gain.exponentialRampToValueAtTime(sustainGain, now + attackSeconds + decaySeconds * 0.6); voiceGain.gain.exponentialRampToValueAtTime(sustainGain, now + attackSeconds + decaySeconds * 0.6);
@@ -222,6 +239,7 @@ export class PianoSynth {
const frequency = midiToFrequency(midi); const frequency = midiToFrequency(midi);
const oscillators: OscillatorNode[] = []; const oscillators: OscillatorNode[] = [];
const modulators: OscillatorNode[] = [];
for (const partial of preset.oscillators) { for (const partial of preset.oscillators) {
const oscillator = context.audioCtx.createOscillator(); const oscillator = context.audioCtx.createOscillator();
oscillator.type = partial.type; oscillator.type = partial.type;
@@ -232,12 +250,22 @@ export class PianoSynth {
oscillator.connect(oscGain).connect(voiceGain); oscillator.connect(oscGain).connect(voiceGain);
oscillator.start(now); oscillator.start(now);
oscillators.push(oscillator); 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, { this.voices.set(keyId, {
gain: voiceGain, gain: voiceGain,
panner, panner,
oscillators, oscillators,
modulators,
releaseSeconds, releaseSeconds,
}); });
} }
@@ -255,6 +283,9 @@ export class PianoSynth {
for (const oscillator of voice.oscillators) { for (const oscillator of voice.oscillators) {
safeStop(oscillator, now + voice.releaseSeconds + 0.02); safeStop(oscillator, now + voice.releaseSeconds + 0.02);
} }
for (const oscillator of voice.modulators) {
safeStop(oscillator, now + voice.releaseSeconds + 0.02);
}
window.setTimeout(() => { window.setTimeout(() => {
try { try {
voice.gain.disconnect(); voice.gain.disconnect();
@@ -274,6 +305,7 @@ 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, keyId: string,
midi: number,
context: PianoContext, context: PianoContext,
spatial: PianoSpatialSource, spatial: PianoSpatialSource,
attackPercent: number, attackPercent: number,
@@ -287,9 +319,10 @@ export class PianoSynth {
baseGain: 1, baseGain: 1,
}); });
if (!spatialMix || spatialMix.gain <= 0) return; if (!spatialMix || spatialMix.gain <= 0) return;
const typeIndex = Math.abs(this.hashKey(keyId)) % 4; const typeIndex = Math.abs((midi % DRUM_VARIANTS.length) + this.hashKey(keyId)) % DRUM_VARIANTS.length;
const decaySeconds = 0.03 + decayPercentToSeconds(decayPercent) * 0.45; const variant = DRUM_VARIANTS[typeIndex];
const attackSeconds = Math.max(0.001, attackPercentToSeconds(attackPercent) * 0.2); const decaySeconds = 0.03 + decayPercentToSeconds(decayPercent) * 0.5;
const attackSeconds = Math.max(0.001, attackPercentToSeconds(attackPercent) * 0.18);
const gain = context.audioCtx.createGain(); const gain = context.audioCtx.createGain();
gain.gain.setValueAtTime(0.0001, now); gain.gain.setValueAtTime(0.0001, now);
@@ -306,25 +339,117 @@ export class PianoSynth {
tailNode.connect(context.destination); tailNode.connect(context.destination);
} }
if (typeIndex === 0) { if (variant === 'kick_808') {
const kick = context.audioCtx.createOscillator(); this.playKick808(context, gain, now, decaySeconds);
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);
return; 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(); 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(); const noiseFilter = context.audioCtx.createBiquadFilter();
noiseFilter.type = typeIndex === 1 ? 'highpass' : typeIndex === 2 ? 'bandpass' : 'lowpass'; noiseFilter.type = filterType;
noiseFilter.frequency.setValueAtTime(typeIndex === 1 ? 1700 : typeIndex === 2 ? 900 : 1300, now); noiseFilter.frequency.setValueAtTime(filterHz, now);
noise.connect(noiseFilter).connect(gain); noise.connect(noiseFilter).connect(gain);
noise.start(now); 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. */ /** Returns deterministic hash for key ids to map drum voice variants. */
@@ -349,4 +474,23 @@ export class PianoSynth {
this.drumNoiseBuffers.set(audioCtx, buffer); this.drumNoiseBuffers.set(audioCtx, buffer);
return 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;
}
} }