Karplus strong generator

This commit is contained in:
2025-10-11 23:27:01 +02:00
parent 00e8b4a3a5
commit 94a36b1a29
6 changed files with 541 additions and 0 deletions

View File

@ -504,6 +504,10 @@
position: relative;
}
.engine-button:hover {
z-index: 1001;
}
.engine-button.active {
opacity: 1;
border-color: #646cff;

View File

@ -0,0 +1,432 @@
import type { SynthEngine } from './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 'Karplus-Strong';
}
getDescription(): string {
return 'Plucked string synthesis using a feedback delay line';
}
getType() {
return 'generative' 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(): 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
];
// 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: frequencies[Math.floor(Math.random() * frequencies.length)],
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): 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
let newFreq = params.frequency;
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),
};
}
}

View File

@ -7,6 +7,7 @@ import { NoiseDrum } from './NoiseDrum';
import { Ring } from './Ring';
import { Sample } from './Sample';
import { Input } from './Input';
import { KarplusStrong } from './KarplusStrong';
export const engines: SynthEngine[] = [
new Sample(),
@ -17,4 +18,5 @@ export const engines: SynthEngine[] = [
new ZzfxEngine(),
new NoiseDrum(),
new Ring(),
new KarplusStrong(),
];

View File

@ -0,0 +1,37 @@
import type { AudioProcessor } from "./AudioProcessor";
export class DCOffsetRemover implements AudioProcessor {
getName(): string {
return "DC Offset Remover";
}
getDescription(): string {
return "Removes DC offset bias from the audio signal";
}
async process(
leftChannel: Float32Array,
rightChannel: Float32Array
): Promise<[Float32Array, Float32Array]> {
const leftOffset = this.calculateDCOffset(leftChannel);
const rightOffset = this.calculateDCOffset(rightChannel);
const newLeft = new Float32Array(leftChannel.length);
const newRight = new Float32Array(rightChannel.length);
for (let i = 0; i < leftChannel.length; i++) {
newLeft[i] = leftChannel[i] - leftOffset;
newRight[i] = rightChannel[i] - rightOffset;
}
return [newLeft, newRight];
}
private calculateDCOffset(channel: Float32Array): number {
let sum = 0;
for (let i = 0; i < channel.length; i++) {
sum += channel[i];
}
return sum / channel.length;
}
}

View File

@ -0,0 +1,62 @@
import type { AudioProcessor } from "./AudioProcessor";
export class TrimSilence implements AudioProcessor {
getName(): string {
return "Trim Silence";
}
getDescription(): string {
return "Removes leading and trailing silence from audio";
}
async process(
leftChannel: Float32Array,
rightChannel: Float32Array
): Promise<[Float32Array, Float32Array]> {
const threshold = 0.001;
const startIndex = this.findSoundStart(leftChannel, rightChannel, threshold);
const endIndex = this.findSoundEnd(leftChannel, rightChannel, threshold);
if (startIndex >= endIndex) {
return [new Float32Array(0), new Float32Array(0)];
}
const trimmedLength = endIndex - startIndex + 1;
const newLeft = new Float32Array(trimmedLength);
const newRight = new Float32Array(trimmedLength);
for (let i = 0; i < trimmedLength; i++) {
newLeft[i] = leftChannel[startIndex + i];
newRight[i] = rightChannel[startIndex + i];
}
return [newLeft, newRight];
}
private findSoundStart(
leftChannel: Float32Array,
rightChannel: Float32Array,
threshold: number
): number {
for (let i = 0; i < leftChannel.length; i++) {
if (Math.abs(leftChannel[i]) > threshold || Math.abs(rightChannel[i]) > threshold) {
return i;
}
}
return leftChannel.length - 1;
}
private findSoundEnd(
leftChannel: Float32Array,
rightChannel: Float32Array,
threshold: number
): number {
for (let i = leftChannel.length - 1; i >= 0; i--) {
if (Math.abs(leftChannel[i]) > threshold || Math.abs(rightChannel[i]) > threshold) {
return i;
}
}
return 0;
}
}

View File

@ -22,6 +22,8 @@ import { PhaseInverter } from './PhaseInverter';
import { Compressor } from './Compressor';
import { RingModulator } from './RingModulator';
import { Waveshaper } from './Waveshaper';
import { DCOffsetRemover } from './DCOffsetRemover';
import { TrimSilence } from './TrimSilence';
const processors: AudioProcessor[] = [
new SegmentShuffler(),
@ -47,6 +49,8 @@ const processors: AudioProcessor[] = [
new Compressor(),
new RingModulator(),
new Waveshaper(),
new DCOffsetRemover(),
new TrimSilence(),
];
export function getRandomProcessor(): AudioProcessor {