diff --git a/src/App.svelte b/src/App.svelte index 9d90a0e..2d5da37 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -504,6 +504,10 @@ position: relative; } + .engine-button:hover { + z-index: 1001; + } + .engine-button.active { opacity: 1; border-color: #646cff; diff --git a/src/lib/audio/engines/KarplusStrong.ts b/src/lib/audio/engines/KarplusStrong.ts new file mode 100644 index 0000000..7c33a35 --- /dev/null +++ b/src/lib/audio/engines/KarplusStrong.ts @@ -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 { + 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 = { + 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), + }; + } +} diff --git a/src/lib/audio/engines/registry.ts b/src/lib/audio/engines/registry.ts index ccb3110..288c52f 100644 --- a/src/lib/audio/engines/registry.ts +++ b/src/lib/audio/engines/registry.ts @@ -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(), ]; diff --git a/src/lib/audio/processors/DCOffsetRemover.ts b/src/lib/audio/processors/DCOffsetRemover.ts new file mode 100644 index 0000000..c105818 --- /dev/null +++ b/src/lib/audio/processors/DCOffsetRemover.ts @@ -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; + } +} diff --git a/src/lib/audio/processors/TrimSilence.ts b/src/lib/audio/processors/TrimSilence.ts new file mode 100644 index 0000000..28eee67 --- /dev/null +++ b/src/lib/audio/processors/TrimSilence.ts @@ -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; + } +} diff --git a/src/lib/audio/processors/registry.ts b/src/lib/audio/processors/registry.ts index dd4bb28..58c3075 100644 --- a/src/lib/audio/processors/registry.ts +++ b/src/lib/audio/processors/registry.ts @@ -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 {