Files
audio-ui/lib/audio/effects/time-based.ts
Sebastian Krüger ee48f9475f feat: add advanced audio effects and improve UI
Phase 6.5 Advanced Effects:
- Add Pitch Shifter with semitones and cents adjustment
- Add Time Stretch with pitch preservation using overlap-add
- Add Distortion with soft/hard/tube types and tone control
- Add Bitcrusher with bit depth and sample rate reduction
- Add AdvancedParameterDialog with real-time waveform visualization
- Add 4 professional presets per effect type

Improvements:
- Fix undefined parameter errors by adding nullish coalescing operators
- Add global custom scrollbar styling with color-mix transparency
- Add custom-scrollbar utility class for side panel
- Improve theme-aware scrollbar appearance in light/dark modes
- Fix parameter initialization when switching effect types

Integration:
- All advanced effects support undo/redo via EffectCommand
- Effects accessible via command palette and side panel
- Selection-based processing support
- Toast notifications for all effects

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 20:03:40 +01:00

341 lines
11 KiB
TypeScript

/**
* Time-based effects (Delay, Reverb, Chorus, Flanger, Phaser)
*/
import { getAudioContext } from '../context';
export interface DelayParameters {
time: number; // ms - delay time
feedback: number; // 0-1 - amount of delayed signal fed back
mix: number; // 0-1 - dry/wet mix (0 = dry, 1 = wet)
}
export interface ReverbParameters {
roomSize: number; // 0-1 - size of the reverb room
damping: number; // 0-1 - high frequency damping
mix: number; // 0-1 - dry/wet mix
}
export interface ChorusParameters {
rate: number; // Hz - LFO rate
depth: number; // 0-1 - modulation depth
delay: number; // ms - base delay time
mix: number; // 0-1 - dry/wet mix
}
export interface FlangerParameters {
rate: number; // Hz - LFO rate
depth: number; // 0-1 - modulation depth
feedback: number; // 0-1 - feedback amount
delay: number; // ms - base delay time
mix: number; // 0-1 - dry/wet mix
}
export interface PhaserParameters {
rate: number; // Hz - LFO rate
depth: number; // 0-1 - modulation depth
feedback: number; // 0-1 - feedback amount
stages: number; // 2-12 - number of allpass filters
mix: number; // 0-1 - dry/wet mix
}
/**
* Apply delay/echo effect to audio buffer
*/
export async function applyDelay(
buffer: AudioBuffer,
params: DelayParameters
): Promise<AudioBuffer> {
const audioContext = getAudioContext();
const channels = buffer.numberOfChannels;
const length = buffer.length;
const sampleRate = buffer.sampleRate;
// Calculate delay in samples
const delaySamples = Math.floor((params.time / 1000) * sampleRate);
// Create output buffer (needs extra length for delay tail)
const outputLength = length + delaySamples * 5; // Allow for multiple echoes
const outputBuffer = audioContext.createBuffer(channels, outputLength, sampleRate);
// Process each channel
for (let channel = 0; channel < channels; channel++) {
const inputData = buffer.getChannelData(channel);
const outputData = outputBuffer.getChannelData(channel);
// Copy input and add delayed copies with feedback
for (let i = 0; i < outputLength; i++) {
let sample = 0;
// Add original signal
if (i < length) {
sample += inputData[i] * (1 - params.mix);
}
// Add delayed signal with feedback
let delayIndex = i;
let feedbackGain = params.mix;
for (let echo = 0; echo < 10; echo++) {
delayIndex -= delaySamples;
if (delayIndex >= 0 && delayIndex < length) {
sample += inputData[delayIndex] * feedbackGain;
}
feedbackGain *= params.feedback;
if (feedbackGain < 0.001) break; // Stop when feedback is negligible
}
outputData[i] = sample;
}
}
return outputBuffer;
}
/**
* Apply simple algorithmic reverb to audio buffer
*/
export async function applyReverb(
buffer: AudioBuffer,
params: ReverbParameters
): Promise<AudioBuffer> {
const audioContext = getAudioContext();
const channels = buffer.numberOfChannels;
const length = buffer.length;
const sampleRate = buffer.sampleRate;
// Reverb uses multiple delay lines (Schroeder reverb algorithm)
const combDelays = [1557, 1617, 1491, 1422, 1277, 1356, 1188, 1116].map(
d => Math.floor(d * params.roomSize * (sampleRate / 44100))
);
const allpassDelays = [225, 556, 441, 341].map(
d => Math.floor(d * (sampleRate / 44100))
);
// Create output buffer with reverb tail
const outputLength = length + Math.floor(sampleRate * 3 * params.roomSize);
const outputBuffer = audioContext.createBuffer(channels, outputLength, sampleRate);
// Process each channel
for (let channel = 0; channel < channels; channel++) {
const inputData = buffer.getChannelData(channel);
const outputData = outputBuffer.getChannelData(channel);
// Comb filter buffers
const combBuffers = combDelays.map(delay => new Float32Array(delay));
const combIndices = combDelays.map(() => 0);
// Allpass filter buffers
const allpassBuffers = allpassDelays.map(delay => new Float32Array(delay));
const allpassIndices = allpassDelays.map(() => 0);
// Process samples
for (let i = 0; i < outputLength; i++) {
let input = i < length ? inputData[i] : 0;
let combSum = 0;
// Parallel comb filters
for (let c = 0; c < combDelays.length; c++) {
const delayedSample = combBuffers[c][combIndices[c]];
combSum += delayedSample;
// Feedback with damping
const feedback = delayedSample * (0.84 - params.damping * 0.2);
combBuffers[c][combIndices[c]] = input + feedback;
combIndices[c] = (combIndices[c] + 1) % combDelays[c];
}
// Average comb outputs
let sample = combSum / combDelays.length;
// Series allpass filters
for (let a = 0; a < allpassDelays.length; a++) {
const delayed = allpassBuffers[a][allpassIndices[a]];
const output = -sample + delayed;
allpassBuffers[a][allpassIndices[a]] = sample + delayed * 0.5;
sample = output;
allpassIndices[a] = (allpassIndices[a] + 1) % allpassDelays[a];
}
// Mix dry and wet
outputData[i] = input * (1 - params.mix) + sample * params.mix * 0.5;
}
}
return outputBuffer;
}
/**
* Apply chorus effect to audio buffer
*/
export async function applyChorus(
buffer: AudioBuffer,
params: ChorusParameters
): Promise<AudioBuffer> {
const audioContext = getAudioContext();
const channels = buffer.numberOfChannels;
const length = buffer.length;
const sampleRate = buffer.sampleRate;
// Create output buffer
const outputBuffer = audioContext.createBuffer(channels, length, sampleRate);
// Base delay in samples
const baseDelaySamples = (params.delay / 1000) * sampleRate;
const maxDelaySamples = baseDelaySamples + (params.depth * sampleRate * 0.005);
// Process each channel
for (let channel = 0; channel < channels; channel++) {
const inputData = buffer.getChannelData(channel);
const outputData = outputBuffer.getChannelData(channel);
// Create delay buffer
const delayBuffer = new Float32Array(Math.ceil(maxDelaySamples) + 1);
let delayIndex = 0;
for (let i = 0; i < length; i++) {
const input = inputData[i];
// Calculate LFO (Low Frequency Oscillator)
const lfoPhase = (i / sampleRate) * params.rate * 2 * Math.PI;
const lfo = Math.sin(lfoPhase);
// Modulated delay time
const modulatedDelay = baseDelaySamples + (lfo * params.depth * sampleRate * 0.005);
// Read from delay buffer with interpolation
const readIndex = (delayIndex - modulatedDelay + delayBuffer.length) % delayBuffer.length;
const readIndexInt = Math.floor(readIndex);
const readIndexFrac = readIndex - readIndexInt;
const sample1 = delayBuffer[readIndexInt];
const sample2 = delayBuffer[(readIndexInt + 1) % delayBuffer.length];
const delayedSample = sample1 + (sample2 - sample1) * readIndexFrac;
// Write to delay buffer
delayBuffer[delayIndex] = input;
delayIndex = (delayIndex + 1) % delayBuffer.length;
// Mix dry and wet
outputData[i] = input * (1 - params.mix) + delayedSample * params.mix;
}
}
return outputBuffer;
}
/**
* Apply flanger effect to audio buffer
*/
export async function applyFlanger(
buffer: AudioBuffer,
params: FlangerParameters
): Promise<AudioBuffer> {
const audioContext = getAudioContext();
const channels = buffer.numberOfChannels;
const length = buffer.length;
const sampleRate = buffer.sampleRate;
// Create output buffer
const outputBuffer = audioContext.createBuffer(channels, length, sampleRate);
// Base delay in samples (shorter than chorus)
const baseDelaySamples = (params.delay / 1000) * sampleRate;
const maxDelaySamples = baseDelaySamples + (params.depth * sampleRate * 0.002);
// Process each channel
for (let channel = 0; channel < channels; channel++) {
const inputData = buffer.getChannelData(channel);
const outputData = outputBuffer.getChannelData(channel);
// Create delay buffer
const delayBuffer = new Float32Array(Math.ceil(maxDelaySamples) + 1);
let delayIndex = 0;
for (let i = 0; i < length; i++) {
const input = inputData[i];
// Calculate LFO
const lfoPhase = (i / sampleRate) * params.rate * 2 * Math.PI;
const lfo = Math.sin(lfoPhase);
// Modulated delay time
const modulatedDelay = baseDelaySamples + (lfo * params.depth * sampleRate * 0.002);
// Read from delay buffer with interpolation
const readIndex = (delayIndex - modulatedDelay + delayBuffer.length) % delayBuffer.length;
const readIndexInt = Math.floor(readIndex);
const readIndexFrac = readIndex - readIndexInt;
const sample1 = delayBuffer[readIndexInt];
const sample2 = delayBuffer[(readIndexInt + 1) % delayBuffer.length];
const delayedSample = sample1 + (sample2 - sample1) * readIndexFrac;
// Write to delay buffer with feedback
delayBuffer[delayIndex] = input + delayedSample * params.feedback;
delayIndex = (delayIndex + 1) % delayBuffer.length;
// Mix dry and wet
outputData[i] = input * (1 - params.mix) + delayedSample * params.mix;
}
}
return outputBuffer;
}
/**
* Apply phaser effect to audio buffer
*/
export async function applyPhaser(
buffer: AudioBuffer,
params: PhaserParameters
): Promise<AudioBuffer> {
const audioContext = getAudioContext();
const channels = buffer.numberOfChannels;
const length = buffer.length;
const sampleRate = buffer.sampleRate;
// Create output buffer
const outputBuffer = audioContext.createBuffer(channels, length, sampleRate);
// Process each channel
for (let channel = 0; channel < channels; channel++) {
const inputData = buffer.getChannelData(channel);
const outputData = outputBuffer.getChannelData(channel);
// Allpass filter state for each stage
const stages = Math.floor(params.stages);
const allpassStates = new Array(stages).fill(0);
for (let i = 0; i < length; i++) {
let input = inputData[i];
let output = input;
// Calculate LFO
const lfoPhase = (i / sampleRate) * params.rate * 2 * Math.PI;
const lfo = Math.sin(lfoPhase);
// Modulated allpass frequency (200Hz to 2000Hz)
const baseFreq = 200 + (lfo + 1) * 0.5 * 1800 * params.depth;
const omega = (2 * Math.PI * baseFreq) / sampleRate;
const alpha = (1 - Math.tan(omega / 2)) / (1 + Math.tan(omega / 2));
// Apply cascaded allpass filters
for (let stage = 0; stage < stages; stage++) {
const filtered = alpha * output + allpassStates[stage];
allpassStates[stage] = output - alpha * filtered;
output = filtered;
}
// Add feedback
output = output + output * params.feedback;
// Mix dry and wet
outputData[i] = input * (1 - params.mix) + output * params.mix;
}
}
return outputBuffer;
}