239 lines
7.1 KiB
TypeScript
239 lines
7.1 KiB
TypeScript
import type { PitchLock, SynthEngine } from './base/SynthEngine';
|
|
|
|
interface SnareParams {
|
|
// Core frequency (base pitch of the snare)
|
|
baseFreq: number;
|
|
|
|
// Tone control (0 = more tonal, 1 = more noise)
|
|
tone: number;
|
|
|
|
// Snap amount (pitch modulation intensity)
|
|
snap: number;
|
|
|
|
// Decay times
|
|
tonalDecay: number;
|
|
noiseDecay: number;
|
|
|
|
// Accent (volume boost)
|
|
accent: number;
|
|
|
|
// Tuning offset
|
|
tuning: number;
|
|
}
|
|
|
|
export class Snare implements SynthEngine {
|
|
getName(): string {
|
|
return 'Noise Snare';
|
|
}
|
|
|
|
getDescription(): string {
|
|
return 'Classic snare drum with triangle wave and noise';
|
|
}
|
|
|
|
getType() {
|
|
return 'generative' as const;
|
|
}
|
|
|
|
getCategory() {
|
|
return 'Percussion' as const;
|
|
}
|
|
|
|
randomParams(pitchLock?: PitchLock): SnareParams {
|
|
return {
|
|
baseFreq: pitchLock ? this.freqToParam(pitchLock.frequency) : 0.3 + Math.random() * 0.4,
|
|
tone: 0.4 + Math.random() * 0.4,
|
|
snap: 0.5 + Math.random() * 0.5,
|
|
tonalDecay: 0.15 + Math.random() * 0.35,
|
|
noiseDecay: 0.4 + Math.random() * 0.4,
|
|
accent: 0.5 + Math.random() * 0.5,
|
|
tuning: pitchLock ? 0.5 : 0.4 + Math.random() * 0.2,
|
|
};
|
|
}
|
|
|
|
mutateParams(params: SnareParams, mutationAmount: number = 0.15, pitchLock?: PitchLock): SnareParams {
|
|
const mutate = (value: number, amount: number = mutationAmount): number => {
|
|
return Math.max(0, Math.min(1, value + (Math.random() - 0.5) * amount));
|
|
};
|
|
|
|
return {
|
|
baseFreq: pitchLock ? params.baseFreq : mutate(params.baseFreq, 0.2),
|
|
tone: mutate(params.tone, 0.25),
|
|
snap: mutate(params.snap, 0.2),
|
|
tonalDecay: mutate(params.tonalDecay, 0.2),
|
|
noiseDecay: mutate(params.noiseDecay, 0.2),
|
|
accent: mutate(params.accent, 0.2),
|
|
tuning: pitchLock ? params.tuning : mutate(params.tuning, 0.15),
|
|
};
|
|
}
|
|
|
|
generate(
|
|
params: SnareParams,
|
|
sampleRate: number,
|
|
duration: number,
|
|
pitchLock?: PitchLock
|
|
): [Float32Array, Float32Array] {
|
|
const numSamples = Math.floor(sampleRate * duration);
|
|
const left = new Float32Array(numSamples);
|
|
const right = new Float32Array(numSamples);
|
|
|
|
// Base frequency: 100Hz to 400Hz
|
|
const baseFreq = pitchLock ? pitchLock.frequency : 100 + params.baseFreq * 300;
|
|
|
|
// Tuning offset: -12% to +12%
|
|
const tuningFactor = 0.88 + params.tuning * 0.24;
|
|
const tunedFreq = baseFreq * tuningFactor;
|
|
|
|
// Pitch modulation parameters (starts 4x higher, drops over 10ms)
|
|
const pitchMultiplier = 1 + params.snap * 3; // 1x to 4x
|
|
const modDuration = 0.008 + params.snap * 0.007; // 8ms to 15ms
|
|
|
|
// Decay times scaled by duration
|
|
const tonalDecayTime = (0.05 + params.tonalDecay * 0.15) * duration; // 50ms to 200ms
|
|
const noiseDecayTime = (0.15 + params.noiseDecay * 0.35) * duration; // 150ms to 500ms
|
|
|
|
// Volume and gain staging
|
|
const baseVolume = 0.5;
|
|
const accentGain = baseVolume * (1 + params.accent);
|
|
|
|
// Attack time (5ms ramp)
|
|
const attackTime = 0.005;
|
|
|
|
// Notch filter parameters for noise (fixed at 1000Hz)
|
|
const notchFreq = 1000;
|
|
const notchQ = 5;
|
|
|
|
for (let channel = 0; channel < 2; channel++) {
|
|
const output = channel === 0 ? left : right;
|
|
|
|
// Triangle oscillator phase
|
|
let phase = Math.random() * Math.PI * 2;
|
|
|
|
// Notch filter state variables
|
|
let notchState1 = 0;
|
|
let notchState2 = 0;
|
|
|
|
// Stereo variation (slight detune and phase offset)
|
|
const stereoDetune = channel === 0 ? 0.998 : 1.002;
|
|
const stereoPhaseOffset = channel === 0 ? 0 : 0.1;
|
|
|
|
for (let i = 0; i < numSamples; i++) {
|
|
const t = i / sampleRate;
|
|
const normPhase = i / numSamples;
|
|
|
|
// Pitch envelope: exponential ramp from pitchMultiplier*freq down to base freq
|
|
const pitchEnv = Math.exp(-t / modDuration);
|
|
const currentFreq = tunedFreq * stereoDetune * (1 + (pitchMultiplier - 1) * pitchEnv);
|
|
|
|
// Generate triangle wave
|
|
phase += (2 * Math.PI * currentFreq) / sampleRate;
|
|
if (phase > 2 * Math.PI) phase -= 2 * Math.PI;
|
|
|
|
// Triangle wave from phase
|
|
const triangle = phase < Math.PI
|
|
? -1 + (2 * phase) / Math.PI
|
|
: 3 - (2 * phase) / Math.PI;
|
|
|
|
// Attack envelope (5ms linear ramp)
|
|
const attackEnv = t < attackTime ? t / attackTime : 1.0;
|
|
|
|
// Tonal envelope: exponential decay
|
|
const tonalEnv = Math.exp(-t / tonalDecayTime) * attackEnv;
|
|
const tonalSignal = triangle * tonalEnv;
|
|
|
|
// Generate white noise
|
|
const noise = Math.random() * 2 - 1;
|
|
|
|
// Apply notch filter to noise (removes 1000Hz component)
|
|
const notchFiltered = this.notchFilter(
|
|
noise,
|
|
notchFreq,
|
|
notchQ,
|
|
sampleRate,
|
|
notchState1,
|
|
notchState2
|
|
);
|
|
notchState1 = notchFiltered.state1;
|
|
notchState2 = notchFiltered.state2;
|
|
|
|
// Noise envelope: exponential decay (longer than tonal)
|
|
const noiseEnv = Math.exp(-t / noiseDecayTime);
|
|
const noiseSignal = notchFiltered.output * noiseEnv;
|
|
|
|
// Mix tonal and noise
|
|
const tonalGain = baseVolume * (1 - params.tone * 0.5); // 0.5 to 0.25
|
|
const noiseGain = 0.15 + params.tone * 0.2; // 0.15 to 0.35
|
|
|
|
let sample = tonalSignal * tonalGain + noiseSignal * noiseGain;
|
|
|
|
// Apply accent
|
|
sample *= (0.5 + params.accent * 0.5);
|
|
|
|
// Soft clipping
|
|
sample = this.softClip(sample);
|
|
|
|
output[i] = sample;
|
|
}
|
|
}
|
|
|
|
// Normalize output to consistent level
|
|
let peak = 0;
|
|
for (let i = 0; i < numSamples; i++) {
|
|
peak = Math.max(peak, Math.abs(left[i]), Math.abs(right[i]));
|
|
}
|
|
|
|
if (peak > 0.001) {
|
|
const normGain = 0.5 / peak; // Normalize to 0.5 peak for more headroom
|
|
for (let i = 0; i < numSamples; i++) {
|
|
left[i] *= normGain;
|
|
right[i] *= normGain;
|
|
}
|
|
}
|
|
|
|
return [left, right];
|
|
}
|
|
|
|
private notchFilter(
|
|
input: number,
|
|
frequency: number,
|
|
q: number,
|
|
sampleRate: number,
|
|
state1: number,
|
|
state2: number
|
|
): { output: number; state1: number; state2: number } {
|
|
// State variable filter configured as notch
|
|
const normalizedFreq = Math.min(frequency / sampleRate, 0.48);
|
|
const f = 2 * Math.sin(Math.PI * normalizedFreq);
|
|
const qRecip = 1 / Math.max(q, 0.5);
|
|
|
|
const lowpass = state2 + f * state1;
|
|
const highpass = input - lowpass - qRecip * state1;
|
|
const bandpass = f * highpass + state1;
|
|
|
|
// Notch = input - bandpass
|
|
const notch = input - bandpass;
|
|
|
|
// Update states with clamping
|
|
const newState1 = Math.max(-2, Math.min(2, bandpass));
|
|
const newState2 = Math.max(-2, Math.min(2, lowpass));
|
|
|
|
return {
|
|
output: notch,
|
|
state1: newState1,
|
|
state2: newState2,
|
|
};
|
|
}
|
|
|
|
private softClip(x: number): number {
|
|
if (x > 1) return 1;
|
|
if (x < -1) return -1;
|
|
if (x > 0.66) return (3 - (2 - 3 * x) ** 2) / 3;
|
|
if (x < -0.66) return -(3 - (2 - 3 * -x) ** 2) / 3;
|
|
return x;
|
|
}
|
|
|
|
private freqToParam(freq: number): number {
|
|
// Map frequency to 0-1 range (100-400 Hz)
|
|
return Math.max(0, Math.min(1, (freq - 100) / 300));
|
|
}
|
|
}
|