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 { 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 = { 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), }; } }