From d9e9e6052464936ab7e70b6462a7fb1c81d3741c Mon Sep 17 00:00:00 2001 From: Jage9 Date: Sun, 22 Feb 2026 23:58:07 -0500 Subject: [PATCH] Expand piano drum-kit voices and polish instrument synthesis --- client/public/version.js | 2 +- client/src/audio/pianoSynth.ts | 178 +++++++++++++++++++++++++++++---- 2 files changed, 162 insertions(+), 18 deletions(-) diff --git a/client/public/version.js b/client/public/version.js index c7d22a7..92a1ee6 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -1,5 +1,5 @@ // Maintainer-controlled web client version. // 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. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/audio/pianoSynth.ts b/client/src/audio/pianoSynth.ts index 57bf6e1..123822d 100644 --- a/client/src/audio/pianoSynth.ts +++ b/client/src/audio/pianoSynth.ts @@ -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, InstrumentPreset> = { @@ -47,6 +50,7 @@ const PRESETS: Record, 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, 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, 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, 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, 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, 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, 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, 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(); private readonly drumNoiseBuffers = new WeakMap(); + private readonly bitNoiseBuffers = new WeakMap(); /** 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; + } }