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>
This commit is contained in:
340
lib/audio/effects/time-based.ts
Normal file
340
lib/audio/effects/time-based.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user