443 lines
15 KiB
TypeScript
443 lines
15 KiB
TypeScript
import type { SynthEngine, PitchLock } from './base/SynthEngine';
|
|
|
|
type HarmonicMode =
|
|
| 'single' // Just fundamental
|
|
| 'octave' // 2:1
|
|
| 'fifth' // 3:2
|
|
| 'fourth' // 4:3
|
|
| 'majorThird' // 5:4
|
|
| 'majorSixth' // 5:3
|
|
| 'octaveFifth' // 3:1
|
|
| 'doubleOctave'; // 4:1
|
|
|
|
interface KarplusStrongParams {
|
|
frequency: number; // Hz (50-2000)
|
|
damping: number; // 0-1 (higher = longer decay)
|
|
brightness: number; // 0-1 (higher = brighter tone)
|
|
decayCharacter: number; // -1 to 1 (negative=brighter over time, positive=darker over time)
|
|
pluckPosition: number; // 0-1 (where to pluck the string)
|
|
pluckHardness: number; // 0-1 (0=soft/warm, 1=hard/bright)
|
|
bodyResonance: number; // 0-1 (amount of body resonance)
|
|
bodyFrequency: number; // 100-800 Hz (body resonance frequency)
|
|
stereoDetune: number; // 0-1 (stereo detuning amount)
|
|
outputGain: number; // 0.5-2.0 (output level boost)
|
|
harmonicMode: HarmonicMode; // Which harmonics to layer
|
|
harmonicDetune: number; // Amount of detuning from perfect ratio (cents)
|
|
harmonicMix: number; // 0-1 (how loud the harmonic is vs fundamental)
|
|
}
|
|
|
|
export class KarplusStrong implements SynthEngine<KarplusStrongParams> {
|
|
getName(): string {
|
|
return 'String(s)';
|
|
}
|
|
|
|
getDescription(): string {
|
|
return 'Plucked string synthesis using a feedback delay line';
|
|
}
|
|
|
|
getType() {
|
|
return 'generative' as const;
|
|
}
|
|
|
|
getCategory() {
|
|
return 'Physical' as const;
|
|
}
|
|
|
|
generate(params: KarplusStrongParams, sampleRate: number, duration: number): [Float32Array, Float32Array] {
|
|
const numSamples = Math.floor(sampleRate * duration);
|
|
const leftBuffer = new Float32Array(numSamples);
|
|
const rightBuffer = new Float32Array(numSamples);
|
|
|
|
// Generate fundamental frequency
|
|
const fundamentalLeft = new Float32Array(numSamples);
|
|
const fundamentalRight = new Float32Array(numSamples);
|
|
|
|
this.generateChannel(fundamentalLeft, params, sampleRate, 0);
|
|
|
|
const rightParams = {
|
|
...params,
|
|
frequency: params.frequency * (1 + params.stereoDetune * 0.005),
|
|
};
|
|
this.generateChannel(fundamentalRight, rightParams, sampleRate, params.stereoDetune);
|
|
|
|
// Copy fundamental to output
|
|
for (let i = 0; i < numSamples; i++) {
|
|
leftBuffer[i] = fundamentalLeft[i];
|
|
rightBuffer[i] = fundamentalRight[i];
|
|
}
|
|
|
|
// Add harmonic if not single mode
|
|
if (params.harmonicMode !== 'single') {
|
|
// Map harmonic mode to frequency ratio
|
|
const harmonicRatios: Record<HarmonicMode, number> = {
|
|
single: 1.0,
|
|
octave: 2.0,
|
|
fifth: 1.5,
|
|
fourth: 4 / 3,
|
|
majorThird: 1.25,
|
|
majorSixth: 5 / 3,
|
|
octaveFifth: 3.0,
|
|
doubleOctave: 4.0,
|
|
};
|
|
|
|
const harmonicRatio = harmonicRatios[params.harmonicMode];
|
|
|
|
// Apply slight detuning (convert cents to ratio)
|
|
// cents = 1200 * log2(ratio), so ratio = 2^(cents/1200)
|
|
const detuneRatio = Math.pow(2, params.harmonicDetune / 1200);
|
|
const detunedRatio = harmonicRatio * detuneRatio;
|
|
|
|
const harmonicLeft = new Float32Array(numSamples);
|
|
const harmonicRight = new Float32Array(numSamples);
|
|
|
|
const harmonicParams = {
|
|
...params,
|
|
frequency: params.frequency * detunedRatio,
|
|
};
|
|
|
|
const harmonicParamsRight = {
|
|
...params,
|
|
frequency: params.frequency * detunedRatio * (1 + params.stereoDetune * 0.005),
|
|
};
|
|
|
|
this.generateChannel(harmonicLeft, harmonicParams, sampleRate, 0.5);
|
|
this.generateChannel(harmonicRight, harmonicParamsRight, sampleRate, params.stereoDetune + 0.5);
|
|
|
|
// Mix harmonic with fundamental
|
|
const harmonicLevel = params.harmonicMix;
|
|
for (let i = 0; i < numSamples; i++) {
|
|
leftBuffer[i] = leftBuffer[i] * (1 - harmonicLevel * 0.5) + harmonicLeft[i] * harmonicLevel;
|
|
rightBuffer[i] = rightBuffer[i] * (1 - harmonicLevel * 0.5) + harmonicRight[i] * harmonicLevel;
|
|
}
|
|
}
|
|
|
|
return [leftBuffer, rightBuffer];
|
|
}
|
|
|
|
private generateChannel(
|
|
buffer: Float32Array,
|
|
params: KarplusStrongParams,
|
|
sampleRate: number,
|
|
seed: number
|
|
): void {
|
|
// Calculate delay line length from frequency
|
|
const delayLength = Math.round(sampleRate / params.frequency);
|
|
const delayLine = new Float32Array(delayLength);
|
|
|
|
// Initialize delay line with noise burst
|
|
this.fillDelayLine(delayLine, params.pluckPosition, params.pluckHardness, seed);
|
|
|
|
// Loop gain controls decay rate
|
|
// Must be < 1 for stability!
|
|
const loopGain = 0.99 + params.damping * 0.0099; // Range: 0.99 to 0.9999
|
|
|
|
// Previous sample for filtering
|
|
let prevSample = 0;
|
|
|
|
// Delay line read position
|
|
let position = 0;
|
|
|
|
// Generate samples
|
|
for (let i = 0; i < buffer.length; i++) {
|
|
// Calculate progress through the sound (0 to 1)
|
|
const progress = i / buffer.length;
|
|
|
|
// Modulate brightness over time based on decay character
|
|
// Negative decay character = gets brighter over time
|
|
// Positive decay character = gets darker over time (natural)
|
|
const brightnessModulation = 1 + params.decayCharacter * progress * 0.8;
|
|
const currentBrightness = Math.max(0, Math.min(1, params.brightness * brightnessModulation));
|
|
|
|
// Low-pass filter coefficient for brightness
|
|
// 0 = dark/muted, 1 = bright/sustaining
|
|
const filterCoeff = 0.5 + currentBrightness * 0.49; // Range: 0.5 to 0.99
|
|
|
|
// Read current sample from delay line
|
|
const current = delayLine[position];
|
|
|
|
// Apply simple low-pass filter
|
|
// Classic KS uses: filtered = (current + prev) * 0.5
|
|
// We add brightness control
|
|
const filtered = current * filterCoeff + prevSample * (1 - filterCoeff);
|
|
|
|
// Apply loop gain for decay
|
|
const damped = filtered * loopGain;
|
|
|
|
// Write back to delay line
|
|
delayLine[position] = damped;
|
|
|
|
// Store for next iteration
|
|
prevSample = current;
|
|
|
|
// Output
|
|
buffer[i] = current;
|
|
|
|
// Advance position
|
|
position = (position + 1) % delayLength;
|
|
}
|
|
|
|
// Apply final envelope to smooth start/end
|
|
this.applyEnvelope(buffer, sampleRate);
|
|
|
|
// Apply body resonance if enabled
|
|
if (params.bodyResonance > 0.01) {
|
|
this.applyBodyResonance(buffer, params.bodyResonance, params.bodyFrequency, sampleRate);
|
|
}
|
|
|
|
// Normalize to ensure consistent output level
|
|
let maxVal = 0;
|
|
for (let i = 0; i < buffer.length; i++) {
|
|
maxVal = Math.max(maxVal, Math.abs(buffer[i]));
|
|
}
|
|
|
|
if (maxVal > 0.01) {
|
|
// Normalize to 0.7 and then apply output gain
|
|
const normalizeGain = (0.7 / maxVal) * params.outputGain;
|
|
for (let i = 0; i < buffer.length; i++) {
|
|
buffer[i] *= normalizeGain;
|
|
}
|
|
} else {
|
|
// Sound is too quiet, just apply output gain
|
|
for (let i = 0; i < buffer.length; i++) {
|
|
buffer[i] *= params.outputGain;
|
|
}
|
|
}
|
|
}
|
|
|
|
private fillDelayLine(delayLine: Float32Array, pluckPosition: number, pluckHardness: number, seed: number): void {
|
|
// Create a burst of noise to excite the string
|
|
// The pluck position affects the harmonic content
|
|
|
|
const burstLength = Math.floor(delayLine.length * 0.1); // 10% of delay line
|
|
const pluckIndex = Math.floor(pluckPosition * delayLine.length);
|
|
|
|
// Simple seeded random for stereo variation
|
|
let random = seed * 9301 + 49297;
|
|
const nextRandom = () => {
|
|
random = (random * 9301 + 49297) % 233280;
|
|
return (random / 233280) * 2 - 1;
|
|
};
|
|
|
|
// Fill entire delay line with noise burst centered at pluck position
|
|
for (let i = 0; i < delayLine.length; i++) {
|
|
const distanceFromPluck = Math.abs(i - pluckIndex);
|
|
|
|
// Noise burst that decays away from pluck position
|
|
if (distanceFromPluck < burstLength) {
|
|
const window = 1 - (distanceFromPluck / burstLength);
|
|
delayLine[i] = (seed > 0 ? nextRandom() : Math.random() * 2 - 1) * window;
|
|
} else {
|
|
delayLine[i] = 0;
|
|
}
|
|
}
|
|
|
|
// Apply pluck hardness filter
|
|
// Low hardness = soft/warm (heavy filtering), high hardness = bright/hard (minimal filtering)
|
|
// One-pole lowpass filter: y[n] = y[n-1] + alpha * (x[n] - y[n-1])
|
|
const filterAmount = 1 - pluckHardness; // 0 = no filter, 1 = heavy filter
|
|
const alpha = 0.1 + pluckHardness * 0.9; // 0.1 (soft) to 1.0 (hard)
|
|
|
|
if (filterAmount > 0.01) {
|
|
let prevSample = 0;
|
|
for (let i = 0; i < delayLine.length; i++) {
|
|
const input = delayLine[i];
|
|
const output = prevSample + alpha * (input - prevSample);
|
|
delayLine[i] = output;
|
|
prevSample = output;
|
|
}
|
|
}
|
|
|
|
// Normalize to prevent instability
|
|
let maxVal = 0;
|
|
for (let i = 0; i < delayLine.length; i++) {
|
|
maxVal = Math.max(maxVal, Math.abs(delayLine[i]));
|
|
}
|
|
|
|
if (maxVal > 0) {
|
|
const normalizeGain = 1.0 / maxVal; // Normalize to full amplitude
|
|
for (let i = 0; i < delayLine.length; i++) {
|
|
delayLine[i] *= normalizeGain;
|
|
}
|
|
}
|
|
}
|
|
|
|
private applyBodyResonance(
|
|
buffer: Float32Array,
|
|
resonance: number,
|
|
frequency: number,
|
|
sampleRate: number
|
|
): void {
|
|
// Resonant bandpass filter to simulate acoustic body
|
|
// Higher resonance = more pronounced body effect
|
|
|
|
const Q = 2 + resonance * 8; // Quality factor: 2 to 10
|
|
const w0 = (2 * Math.PI * frequency) / sampleRate;
|
|
const alpha = Math.sin(w0) / (2 * Q);
|
|
|
|
// Bandpass biquad coefficients
|
|
const b0 = alpha;
|
|
const b1 = 0;
|
|
const b2 = -alpha;
|
|
const a0 = 1 + alpha;
|
|
const a1 = -2 * Math.cos(w0);
|
|
const a2 = 1 - alpha;
|
|
|
|
// Normalize coefficients
|
|
const b0n = b0 / a0;
|
|
const b1n = b1 / a0;
|
|
const b2n = b2 / a0;
|
|
const a1n = a1 / a0;
|
|
const a2n = a2 / a0;
|
|
|
|
// Filter state
|
|
let x1 = 0, x2 = 0, y1 = 0, y2 = 0;
|
|
|
|
// Apply filter with dry/wet mix
|
|
const wet = resonance;
|
|
const dry = 1 - wet * 0.7; // Don't fully remove dry signal
|
|
|
|
for (let i = 0; i < buffer.length; i++) {
|
|
const x0 = buffer[i];
|
|
const y0 = b0n * x0 + b1n * x1 + b2n * x2 - a1n * y1 - a2n * y2;
|
|
|
|
// Mix dry and wet
|
|
buffer[i] = dry * x0 + wet * y0 * 3; // Boost wet signal
|
|
|
|
// Update state
|
|
x2 = x1;
|
|
x1 = x0;
|
|
y2 = y1;
|
|
y1 = y0;
|
|
}
|
|
}
|
|
|
|
private applyEnvelope(buffer: Float32Array, sampleRate: number): void {
|
|
// Short fade in to avoid click
|
|
const fadeInSamples = Math.floor(sampleRate * 0.005); // 5ms
|
|
for (let i = 0; i < fadeInSamples && i < buffer.length; i++) {
|
|
buffer[i] *= i / fadeInSamples;
|
|
}
|
|
|
|
// Fade out at end
|
|
const fadeOutSamples = Math.floor(sampleRate * 0.05); // 50ms
|
|
const fadeOutStart = buffer.length - fadeOutSamples;
|
|
for (let i = fadeOutStart; i < buffer.length; i++) {
|
|
const fade = (buffer.length - i) / fadeOutSamples;
|
|
buffer[i] *= fade;
|
|
}
|
|
}
|
|
|
|
randomParams(pitchLock?: PitchLock): KarplusStrongParams {
|
|
// Musical frequencies (notes from E2 to E5)
|
|
const frequencies = [
|
|
82.41, 87.31, 92.50, 98.00, 103.83, 110.00, 116.54, 123.47, 130.81, 138.59,
|
|
146.83, 155.56, 164.81, 174.61, 185.00, 196.00, 207.65, 220.00, 233.08, 246.94,
|
|
261.63, 277.18, 293.66, 311.13, 329.63, 349.23, 369.99, 392.00, 415.30, 440.00,
|
|
466.16, 493.88, 523.25, 554.37, 587.33, 622.25, 659.25, 698.46, 739.99, 783.99,
|
|
830.61, 880.00, 932.33, 987.77, 1046.50, 1108.73, 1174.66, 1244.51, 1318.51
|
|
];
|
|
|
|
const frequency = pitchLock?.enabled
|
|
? pitchLock.frequency
|
|
: frequencies[Math.floor(Math.random() * frequencies.length)];
|
|
|
|
// Randomly choose harmonic mode
|
|
// Weighted selection: favor more consonant intervals
|
|
const modes: HarmonicMode[] = [
|
|
'single',
|
|
'single', // More likely to have no harmonic
|
|
'octave',
|
|
'octave', // Octaves are very consonant
|
|
'fifth',
|
|
'fifth', // Fifths are very consonant
|
|
'fourth',
|
|
'majorThird',
|
|
'majorSixth',
|
|
'octaveFifth',
|
|
'doubleOctave',
|
|
];
|
|
const harmonicMode = modes[Math.floor(Math.random() * modes.length)];
|
|
|
|
// Small detuning for more natural sound (-5 to +5 cents)
|
|
const harmonicDetune = (Math.random() * 2 - 1) * 5;
|
|
|
|
// Body resonance frequencies for different instrument characters
|
|
const bodyFreqs = [
|
|
120, 150, 180, // Deep guitar/bass bodies
|
|
200, 220, 250, 280, // Classical guitar range
|
|
300, 350, 400, // Mandolin/small guitar
|
|
450, 500, 600, 700, // Banjo/ukulele/bright
|
|
];
|
|
|
|
return {
|
|
frequency,
|
|
damping: 0.7 + Math.random() * 0.29, // 0.7 to 0.99
|
|
brightness: Math.random(), // 0 to 1
|
|
decayCharacter: (Math.random() * 2 - 1) * 0.8, // -0.8 to 0.8 (mostly natural darkening)
|
|
pluckPosition: 0.2 + Math.random() * 0.6, // 0.2 to 0.8
|
|
pluckHardness: Math.random(), // 0 to 1 (full range for variety)
|
|
bodyResonance: Math.random() * 0.7, // 0 to 0.7 (not too extreme)
|
|
bodyFrequency: bodyFreqs[Math.floor(Math.random() * bodyFreqs.length)],
|
|
stereoDetune: 0.3 + Math.random() * 0.7, // 0.3 to 1.0
|
|
outputGain: 1.2 + Math.random() * 0.8, // 1.2 to 2.0 for good volume
|
|
harmonicMode,
|
|
harmonicDetune,
|
|
harmonicMix: harmonicMode === 'single' ? 0 : 0.3 + Math.random() * 0.4, // 0.3 to 0.7
|
|
};
|
|
}
|
|
|
|
mutateParams(params: KarplusStrongParams, mutationAmount: number = 0.15, pitchLock?: PitchLock): KarplusStrongParams {
|
|
const mutate = (value: number, range: number, min: number, max: number) => {
|
|
const delta = (Math.random() * 2 - 1) * range * mutationAmount;
|
|
return Math.max(min, Math.min(max, value + delta));
|
|
};
|
|
|
|
// Occasionally jump to harmonic/subharmonic (unless pitch locked)
|
|
let newFreq: number;
|
|
if (pitchLock?.enabled) {
|
|
newFreq = pitchLock.frequency;
|
|
} else if (Math.random() < 0.15) {
|
|
const multipliers = [0.5, 2, 1.5, 3];
|
|
newFreq = params.frequency * multipliers[Math.floor(Math.random() * multipliers.length)];
|
|
newFreq = Math.max(50, Math.min(2000, newFreq));
|
|
} else {
|
|
newFreq = mutate(params.frequency, 100, 50, 2000);
|
|
}
|
|
|
|
// Occasionally change harmonic mode
|
|
let newHarmonicMode = params.harmonicMode;
|
|
if (Math.random() < 0.1) {
|
|
const modes: HarmonicMode[] = [
|
|
'single',
|
|
'single',
|
|
'octave',
|
|
'octave',
|
|
'fifth',
|
|
'fifth',
|
|
'fourth',
|
|
'majorThird',
|
|
'majorSixth',
|
|
'octaveFifth',
|
|
'doubleOctave',
|
|
];
|
|
newHarmonicMode = modes[Math.floor(Math.random() * modes.length)];
|
|
}
|
|
|
|
return {
|
|
frequency: newFreq,
|
|
damping: mutate(params.damping, 0.2, 0.5, 0.99),
|
|
brightness: mutate(params.brightness, 0.3, 0, 1),
|
|
decayCharacter: mutate(params.decayCharacter, 0.5, -1, 1),
|
|
pluckPosition: mutate(params.pluckPosition, 0.4, 0.1, 0.9),
|
|
pluckHardness: mutate(params.pluckHardness, 0.4, 0, 1),
|
|
bodyResonance: mutate(params.bodyResonance, 0.3, 0, 0.9),
|
|
bodyFrequency: mutate(params.bodyFrequency, 100, 100, 800),
|
|
stereoDetune: mutate(params.stereoDetune, 0.5, 0, 1),
|
|
outputGain: mutate(params.outputGain, 0.5, 1.0, 2.0),
|
|
harmonicMode: newHarmonicMode,
|
|
harmonicDetune: mutate(params.harmonicDetune, 3, -5, 5),
|
|
harmonicMix: newHarmonicMode === 'single' ? 0 : mutate(params.harmonicMix, 0.3, 0.2, 0.8),
|
|
};
|
|
}
|
|
}
|