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:
281
lib/audio/effects/advanced.ts
Normal file
281
lib/audio/effects/advanced.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Advanced effects (Pitch Shifter, Time Stretcher, Distortion, Bitcrusher)
|
||||
*/
|
||||
|
||||
import { getAudioContext } from '../context';
|
||||
|
||||
export interface PitchShifterParameters {
|
||||
semitones: number; // -12 to +12 - pitch shift in semitones
|
||||
cents: number; // -100 to +100 - fine tuning in cents
|
||||
mix: number; // 0-1 - dry/wet mix
|
||||
}
|
||||
|
||||
export interface TimeStretchParameters {
|
||||
rate: number; // 0.5-2.0 - playback rate (0.5 = half speed, 2 = double speed)
|
||||
preservePitch: boolean; // whether to preserve pitch
|
||||
mix: number; // 0-1 - dry/wet mix
|
||||
}
|
||||
|
||||
export interface DistortionParameters {
|
||||
drive: number; // 0-1 - amount of distortion
|
||||
tone: number; // 0-1 - pre-distortion tone control
|
||||
output: number; // 0-1 - output level
|
||||
type: 'soft' | 'hard' | 'tube'; // distortion type
|
||||
mix: number; // 0-1 - dry/wet mix
|
||||
}
|
||||
|
||||
export interface BitcrusherParameters {
|
||||
bitDepth: number; // 1-16 - bit depth
|
||||
sampleRate: number; // 100-48000 - sample rate reduction
|
||||
mix: number; // 0-1 - dry/wet mix
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply pitch shifting to audio buffer
|
||||
* Uses simple time-domain pitch shifting (overlap-add)
|
||||
*/
|
||||
export async function applyPitchShift(
|
||||
buffer: AudioBuffer,
|
||||
params: PitchShifterParameters
|
||||
): Promise<AudioBuffer> {
|
||||
const audioContext = getAudioContext();
|
||||
const channels = buffer.numberOfChannels;
|
||||
const sampleRate = buffer.sampleRate;
|
||||
|
||||
// Calculate pitch shift ratio
|
||||
const totalCents = params.semitones * 100 + params.cents;
|
||||
const pitchRatio = Math.pow(2, totalCents / 1200);
|
||||
|
||||
// For pitch shifting, we change the playback rate then resample
|
||||
const newLength = Math.floor(buffer.length / pitchRatio);
|
||||
const outputBuffer = audioContext.createBuffer(channels, newLength, sampleRate);
|
||||
|
||||
// Simple linear interpolation resampling
|
||||
for (let channel = 0; channel < channels; channel++) {
|
||||
const inputData = buffer.getChannelData(channel);
|
||||
const outputData = outputBuffer.getChannelData(channel);
|
||||
|
||||
for (let i = 0; i < newLength; i++) {
|
||||
const srcIndex = i * pitchRatio;
|
||||
const srcIndexInt = Math.floor(srcIndex);
|
||||
const srcIndexFrac = srcIndex - srcIndexInt;
|
||||
|
||||
if (srcIndexInt < buffer.length - 1) {
|
||||
const sample1 = inputData[srcIndexInt];
|
||||
const sample2 = inputData[srcIndexInt + 1];
|
||||
const interpolated = sample1 + (sample2 - sample1) * srcIndexFrac;
|
||||
|
||||
// Mix dry/wet
|
||||
const dry = i < buffer.length ? inputData[i] : 0;
|
||||
outputData[i] = dry * (1 - params.mix) + interpolated * params.mix;
|
||||
} else if (srcIndexInt < buffer.length) {
|
||||
const dry = i < buffer.length ? inputData[i] : 0;
|
||||
outputData[i] = dry * (1 - params.mix) + inputData[srcIndexInt] * params.mix;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return outputBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply time stretching to audio buffer
|
||||
* Changes duration without affecting pitch (basic implementation)
|
||||
*/
|
||||
export async function applyTimeStretch(
|
||||
buffer: AudioBuffer,
|
||||
params: TimeStretchParameters
|
||||
): Promise<AudioBuffer> {
|
||||
const audioContext = getAudioContext();
|
||||
const channels = buffer.numberOfChannels;
|
||||
const sampleRate = buffer.sampleRate;
|
||||
|
||||
if (params.preservePitch) {
|
||||
// Time stretch with pitch preservation (overlap-add)
|
||||
const newLength = Math.floor(buffer.length / params.rate);
|
||||
const outputBuffer = audioContext.createBuffer(channels, newLength, sampleRate);
|
||||
|
||||
const windowSize = 2048;
|
||||
const hopSize = Math.floor(windowSize / 4);
|
||||
|
||||
for (let channel = 0; channel < channels; channel++) {
|
||||
const inputData = buffer.getChannelData(channel);
|
||||
const outputData = outputBuffer.getChannelData(channel);
|
||||
|
||||
let readPos = 0;
|
||||
let writePos = 0;
|
||||
|
||||
while (writePos < newLength) {
|
||||
// Simple overlap-add
|
||||
for (let i = 0; i < windowSize && writePos + i < newLength; i++) {
|
||||
const readIndex = Math.floor(readPos + i);
|
||||
if (readIndex < buffer.length) {
|
||||
// Hanning window
|
||||
const window = 0.5 * (1 - Math.cos((2 * Math.PI * i) / windowSize));
|
||||
outputData[writePos + i] += inputData[readIndex] * window;
|
||||
}
|
||||
}
|
||||
|
||||
readPos += hopSize * params.rate;
|
||||
writePos += hopSize;
|
||||
}
|
||||
|
||||
// Normalize
|
||||
let maxVal = 0;
|
||||
for (let i = 0; i < newLength; i++) {
|
||||
maxVal = Math.max(maxVal, Math.abs(outputData[i]));
|
||||
}
|
||||
if (maxVal > 0) {
|
||||
for (let i = 0; i < newLength; i++) {
|
||||
outputData[i] /= maxVal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return outputBuffer;
|
||||
} else {
|
||||
// Simple speed change (changes pitch)
|
||||
const newLength = Math.floor(buffer.length / params.rate);
|
||||
const outputBuffer = audioContext.createBuffer(channels, newLength, sampleRate);
|
||||
|
||||
for (let channel = 0; channel < channels; channel++) {
|
||||
const inputData = buffer.getChannelData(channel);
|
||||
const outputData = outputBuffer.getChannelData(channel);
|
||||
|
||||
for (let i = 0; i < newLength; i++) {
|
||||
const srcIndex = i * params.rate;
|
||||
const srcIndexInt = Math.floor(srcIndex);
|
||||
const srcIndexFrac = srcIndex - srcIndexInt;
|
||||
|
||||
if (srcIndexInt < buffer.length - 1) {
|
||||
const sample1 = inputData[srcIndexInt];
|
||||
const sample2 = inputData[srcIndexInt + 1];
|
||||
outputData[i] = sample1 + (sample2 - sample1) * srcIndexFrac;
|
||||
} else if (srcIndexInt < buffer.length) {
|
||||
outputData[i] = inputData[srcIndexInt];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return outputBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply distortion/overdrive effect
|
||||
*/
|
||||
export async function applyDistortion(
|
||||
buffer: AudioBuffer,
|
||||
params: DistortionParameters
|
||||
): Promise<AudioBuffer> {
|
||||
const audioContext = getAudioContext();
|
||||
const channels = buffer.numberOfChannels;
|
||||
const length = buffer.length;
|
||||
const sampleRate = buffer.sampleRate;
|
||||
|
||||
const outputBuffer = audioContext.createBuffer(channels, length, sampleRate);
|
||||
|
||||
// Distortion function based on type
|
||||
const distort = (sample: number, drive: number, type: string): number => {
|
||||
const x = sample * (1 + drive * 10);
|
||||
|
||||
switch (type) {
|
||||
case 'soft':
|
||||
// Soft clipping (tanh)
|
||||
return Math.tanh(x);
|
||||
|
||||
case 'hard':
|
||||
// Hard clipping
|
||||
return Math.max(-1, Math.min(1, x));
|
||||
|
||||
case 'tube':
|
||||
// Tube-like distortion (asymmetric)
|
||||
if (x > 0) {
|
||||
return 1 - Math.exp(-x);
|
||||
} else {
|
||||
return -1 + Math.exp(x);
|
||||
}
|
||||
|
||||
default:
|
||||
return x;
|
||||
}
|
||||
};
|
||||
|
||||
for (let channel = 0; channel < channels; channel++) {
|
||||
const inputData = buffer.getChannelData(channel);
|
||||
const outputData = outputBuffer.getChannelData(channel);
|
||||
|
||||
// Simple low-pass filter for tone control
|
||||
let filterState = 0;
|
||||
const filterCutoff = params.tone;
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
let sample = inputData[i];
|
||||
|
||||
// Pre-distortion tone filter
|
||||
filterState = filterState * (1 - filterCutoff) + sample * filterCutoff;
|
||||
sample = filterState;
|
||||
|
||||
// Apply distortion
|
||||
const distorted = distort(sample, params.drive, params.type);
|
||||
|
||||
// Output level
|
||||
const processed = distorted * params.output;
|
||||
|
||||
// Mix dry/wet
|
||||
outputData[i] = inputData[i] * (1 - params.mix) + processed * params.mix;
|
||||
}
|
||||
}
|
||||
|
||||
return outputBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply bitcrusher effect
|
||||
*/
|
||||
export async function applyBitcrusher(
|
||||
buffer: AudioBuffer,
|
||||
params: BitcrusherParameters
|
||||
): Promise<AudioBuffer> {
|
||||
const audioContext = getAudioContext();
|
||||
const channels = buffer.numberOfChannels;
|
||||
const length = buffer.length;
|
||||
const sampleRate = buffer.sampleRate;
|
||||
|
||||
const outputBuffer = audioContext.createBuffer(channels, length, sampleRate);
|
||||
|
||||
// Calculate bit depth quantization step
|
||||
const bitLevels = Math.pow(2, params.bitDepth);
|
||||
const step = 2 / bitLevels;
|
||||
|
||||
// Calculate sample rate reduction ratio
|
||||
const srRatio = sampleRate / params.sampleRate;
|
||||
|
||||
for (let channel = 0; channel < channels; channel++) {
|
||||
const inputData = buffer.getChannelData(channel);
|
||||
const outputData = outputBuffer.getChannelData(channel);
|
||||
|
||||
let holdSample = 0;
|
||||
let holdCounter = 0;
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
// Sample rate reduction (sample and hold)
|
||||
if (holdCounter <= 0) {
|
||||
let sample = inputData[i];
|
||||
|
||||
// Bit depth reduction
|
||||
sample = Math.floor(sample / step) * step;
|
||||
|
||||
holdSample = sample;
|
||||
holdCounter = srRatio;
|
||||
}
|
||||
|
||||
holdCounter--;
|
||||
|
||||
// Mix dry/wet
|
||||
outputData[i] = inputData[i] * (1 - params.mix) + holdSample * params.mix;
|
||||
}
|
||||
}
|
||||
|
||||
return outputBuffer;
|
||||
}
|
||||
205
lib/audio/effects/dynamics.ts
Normal file
205
lib/audio/effects/dynamics.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Dynamics processing effects (Compressor, Limiter, Gate/Expander)
|
||||
*/
|
||||
|
||||
import { getAudioContext } from '../context';
|
||||
|
||||
export interface CompressorParameters {
|
||||
threshold: number; // dB - level where compression starts
|
||||
ratio: number; // Compression ratio (e.g., 4 = 4:1)
|
||||
attack: number; // ms - how quickly to compress
|
||||
release: number; // ms - how quickly to stop compressing
|
||||
knee: number; // dB - width of soft knee (0 = hard knee)
|
||||
makeupGain: number; // dB - gain to apply after compression
|
||||
}
|
||||
|
||||
export interface LimiterParameters {
|
||||
threshold: number; // dB - maximum level
|
||||
attack: number; // ms - how quickly to limit
|
||||
release: number; // ms - how quickly to stop limiting
|
||||
makeupGain: number; // dB - gain to apply after limiting
|
||||
}
|
||||
|
||||
export interface GateParameters {
|
||||
threshold: number; // dB - level below which gate activates
|
||||
ratio: number; // Expansion ratio (e.g., 2 = 2:1)
|
||||
attack: number; // ms - how quickly to close gate
|
||||
release: number; // ms - how quickly to open gate
|
||||
knee: number; // dB - width of soft knee
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply compression to audio buffer
|
||||
*/
|
||||
export async function applyCompressor(
|
||||
buffer: AudioBuffer,
|
||||
params: CompressorParameters
|
||||
): 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);
|
||||
|
||||
// Convert time constants to samples
|
||||
const attackSamples = (params.attack / 1000) * sampleRate;
|
||||
const releaseSamples = (params.release / 1000) * sampleRate;
|
||||
|
||||
// Convert dB to linear
|
||||
const thresholdLinear = dbToLinear(params.threshold);
|
||||
const makeupGainLinear = dbToLinear(params.makeupGain);
|
||||
const kneeLinear = dbToLinear(params.knee);
|
||||
|
||||
// Process each channel
|
||||
for (let channel = 0; channel < channels; channel++) {
|
||||
const inputData = buffer.getChannelData(channel);
|
||||
const outputData = outputBuffer.getChannelData(channel);
|
||||
|
||||
let envelope = 0;
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const input = inputData[i];
|
||||
const inputAbs = Math.abs(input);
|
||||
|
||||
// Envelope follower with attack/release
|
||||
if (inputAbs > envelope) {
|
||||
envelope = envelope + (inputAbs - envelope) / attackSamples;
|
||||
} else {
|
||||
envelope = envelope + (inputAbs - envelope) / releaseSamples;
|
||||
}
|
||||
|
||||
// Calculate gain reduction
|
||||
let gain = 1.0;
|
||||
|
||||
if (envelope > thresholdLinear) {
|
||||
// Soft knee calculation
|
||||
const overThreshold = envelope - thresholdLinear;
|
||||
const kneeRange = kneeLinear / 2;
|
||||
|
||||
if (params.knee > 0 && overThreshold < kneeRange) {
|
||||
// In the knee region - smooth transition
|
||||
const kneeRatio = overThreshold / kneeRange;
|
||||
const compressionAmount = (1 - 1 / params.ratio) * kneeRatio;
|
||||
gain = 1 - compressionAmount * (overThreshold / envelope);
|
||||
} else {
|
||||
// Above knee - full compression
|
||||
const exceededDb = linearToDb(envelope) - params.threshold;
|
||||
const gainReductionDb = exceededDb * (1 - 1 / params.ratio);
|
||||
gain = dbToLinear(-gainReductionDb);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply gain reduction and makeup gain
|
||||
outputData[i] = input * gain * makeupGainLinear;
|
||||
}
|
||||
}
|
||||
|
||||
return outputBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply limiting to audio buffer
|
||||
*/
|
||||
export async function applyLimiter(
|
||||
buffer: AudioBuffer,
|
||||
params: LimiterParameters
|
||||
): Promise<AudioBuffer> {
|
||||
// Limiter is essentially a compressor with infinite ratio
|
||||
return applyCompressor(buffer, {
|
||||
threshold: params.threshold,
|
||||
ratio: 100, // Very high ratio approximates infinity:1
|
||||
attack: params.attack,
|
||||
release: params.release,
|
||||
knee: 0, // Hard knee for brick-wall limiting
|
||||
makeupGain: params.makeupGain,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply gate/expander to audio buffer
|
||||
*/
|
||||
export async function applyGate(
|
||||
buffer: AudioBuffer,
|
||||
params: GateParameters
|
||||
): 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);
|
||||
|
||||
// Convert time constants to samples
|
||||
const attackSamples = (params.attack / 1000) * sampleRate;
|
||||
const releaseSamples = (params.release / 1000) * sampleRate;
|
||||
|
||||
// Convert dB to linear
|
||||
const thresholdLinear = dbToLinear(params.threshold);
|
||||
const kneeLinear = dbToLinear(params.knee);
|
||||
|
||||
// Process each channel
|
||||
for (let channel = 0; channel < channels; channel++) {
|
||||
const inputData = buffer.getChannelData(channel);
|
||||
const outputData = outputBuffer.getChannelData(channel);
|
||||
|
||||
let envelope = 0;
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const input = inputData[i];
|
||||
const inputAbs = Math.abs(input);
|
||||
|
||||
// Envelope follower with attack/release
|
||||
if (inputAbs > envelope) {
|
||||
envelope = envelope + (inputAbs - envelope) / attackSamples;
|
||||
} else {
|
||||
envelope = envelope + (inputAbs - envelope) / releaseSamples;
|
||||
}
|
||||
|
||||
// Calculate gain reduction
|
||||
let gain = 1.0;
|
||||
|
||||
if (envelope < thresholdLinear) {
|
||||
// Below threshold - apply expansion/gating
|
||||
const belowThreshold = thresholdLinear - envelope;
|
||||
const kneeRange = kneeLinear / 2;
|
||||
|
||||
if (params.knee > 0 && belowThreshold < kneeRange) {
|
||||
// In the knee region - smooth transition
|
||||
const kneeRatio = belowThreshold / kneeRange;
|
||||
const expansionAmount = (1 - params.ratio) * kneeRatio;
|
||||
gain = 1 + expansionAmount * (belowThreshold / thresholdLinear);
|
||||
} else {
|
||||
// Below knee - full expansion
|
||||
const belowDb = params.threshold - linearToDb(envelope);
|
||||
const gainReductionDb = belowDb * (params.ratio - 1);
|
||||
gain = dbToLinear(-gainReductionDb);
|
||||
}
|
||||
|
||||
// Clamp to prevent extreme amplification
|
||||
gain = Math.max(0, Math.min(1, gain));
|
||||
}
|
||||
|
||||
// Apply gain
|
||||
outputData[i] = input * gain;
|
||||
}
|
||||
}
|
||||
|
||||
return outputBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert decibels to linear gain
|
||||
*/
|
||||
function dbToLinear(db: number): number {
|
||||
return Math.pow(10, db / 20);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert linear gain to decibels
|
||||
*/
|
||||
function linearToDb(linear: number): number {
|
||||
return 20 * Math.log10(Math.max(linear, 0.00001)); // Prevent log(0)
|
||||
}
|
||||
116
lib/audio/effects/fade.ts
Normal file
116
lib/audio/effects/fade.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Fade in/out effects
|
||||
*/
|
||||
|
||||
import { getAudioContext } from '../context';
|
||||
|
||||
export type FadeType = 'linear' | 'exponential' | 'logarithmic';
|
||||
|
||||
/**
|
||||
* Apply fade in to audio buffer
|
||||
* @param buffer - Source audio buffer
|
||||
* @param duration - Fade duration in seconds
|
||||
* @param type - Fade curve type
|
||||
* @returns New audio buffer with fade in applied
|
||||
*/
|
||||
export function applyFadeIn(
|
||||
buffer: AudioBuffer,
|
||||
duration: number,
|
||||
type: FadeType = 'linear'
|
||||
): AudioBuffer {
|
||||
const audioContext = getAudioContext();
|
||||
const fadeSamples = Math.min(
|
||||
Math.floor(duration * buffer.sampleRate),
|
||||
buffer.length
|
||||
);
|
||||
|
||||
const outputBuffer = audioContext.createBuffer(
|
||||
buffer.numberOfChannels,
|
||||
buffer.length,
|
||||
buffer.sampleRate
|
||||
);
|
||||
|
||||
for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
|
||||
const inputData = buffer.getChannelData(channel);
|
||||
const outputData = outputBuffer.getChannelData(channel);
|
||||
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
if (i < fadeSamples) {
|
||||
const progress = i / fadeSamples;
|
||||
const gain = calculateFadeGain(progress, type);
|
||||
outputData[i] = inputData[i] * gain;
|
||||
} else {
|
||||
outputData[i] = inputData[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return outputBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply fade out to audio buffer
|
||||
* @param buffer - Source audio buffer
|
||||
* @param duration - Fade duration in seconds
|
||||
* @param type - Fade curve type
|
||||
* @returns New audio buffer with fade out applied
|
||||
*/
|
||||
export function applyFadeOut(
|
||||
buffer: AudioBuffer,
|
||||
duration: number,
|
||||
type: FadeType = 'linear'
|
||||
): AudioBuffer {
|
||||
const audioContext = getAudioContext();
|
||||
const fadeSamples = Math.min(
|
||||
Math.floor(duration * buffer.sampleRate),
|
||||
buffer.length
|
||||
);
|
||||
const fadeStartSample = buffer.length - fadeSamples;
|
||||
|
||||
const outputBuffer = audioContext.createBuffer(
|
||||
buffer.numberOfChannels,
|
||||
buffer.length,
|
||||
buffer.sampleRate
|
||||
);
|
||||
|
||||
for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
|
||||
const inputData = buffer.getChannelData(channel);
|
||||
const outputData = outputBuffer.getChannelData(channel);
|
||||
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
if (i >= fadeStartSample) {
|
||||
const progress = (i - fadeStartSample) / fadeSamples;
|
||||
const gain = calculateFadeGain(1 - progress, type);
|
||||
outputData[i] = inputData[i] * gain;
|
||||
} else {
|
||||
outputData[i] = inputData[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return outputBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate fade gain based on progress and curve type
|
||||
* @param progress - Progress from 0 to 1
|
||||
* @param type - Fade curve type
|
||||
* @returns Gain value from 0 to 1
|
||||
*/
|
||||
function calculateFadeGain(progress: number, type: FadeType): number {
|
||||
switch (type) {
|
||||
case 'linear':
|
||||
return progress;
|
||||
|
||||
case 'exponential':
|
||||
// Exponential curve: faster at the start, slower at the end
|
||||
return progress * progress;
|
||||
|
||||
case 'logarithmic':
|
||||
// Logarithmic curve: slower at the start, faster at the end
|
||||
return Math.sqrt(progress);
|
||||
|
||||
default:
|
||||
return progress;
|
||||
}
|
||||
}
|
||||
168
lib/audio/effects/filters.ts
Normal file
168
lib/audio/effects/filters.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Audio filter effects using BiquadFilterNode
|
||||
*/
|
||||
|
||||
import { getAudioContext } from '../context';
|
||||
|
||||
export type FilterType = 'lowpass' | 'highpass' | 'bandpass' | 'lowshelf' | 'highshelf' | 'peaking' | 'notch' | 'allpass';
|
||||
|
||||
export interface FilterOptions {
|
||||
type: FilterType;
|
||||
frequency: number;
|
||||
Q?: number;
|
||||
gain?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a filter to an audio buffer using offline audio processing
|
||||
* @param buffer - Source audio buffer
|
||||
* @param options - Filter options
|
||||
* @returns New audio buffer with filter applied
|
||||
*/
|
||||
export async function applyFilter(
|
||||
buffer: AudioBuffer,
|
||||
options: FilterOptions
|
||||
): Promise<AudioBuffer> {
|
||||
const audioContext = getAudioContext();
|
||||
|
||||
// Create offline context for processing
|
||||
const offlineContext = new OfflineAudioContext(
|
||||
buffer.numberOfChannels,
|
||||
buffer.length,
|
||||
buffer.sampleRate
|
||||
);
|
||||
|
||||
// Create source from buffer
|
||||
const source = offlineContext.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
|
||||
// Create and configure filter
|
||||
const filter = offlineContext.createBiquadFilter();
|
||||
filter.type = options.type;
|
||||
filter.frequency.setValueAtTime(options.frequency, offlineContext.currentTime);
|
||||
|
||||
if (options.Q !== undefined) {
|
||||
filter.Q.setValueAtTime(options.Q, offlineContext.currentTime);
|
||||
}
|
||||
|
||||
if (options.gain !== undefined) {
|
||||
filter.gain.setValueAtTime(options.gain, offlineContext.currentTime);
|
||||
}
|
||||
|
||||
// Connect nodes
|
||||
source.connect(filter);
|
||||
filter.connect(offlineContext.destination);
|
||||
|
||||
// Start playback and render
|
||||
source.start(0);
|
||||
const renderedBuffer = await offlineContext.startRendering();
|
||||
|
||||
return renderedBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply low-pass filter (cuts high frequencies)
|
||||
* @param buffer - Source audio buffer
|
||||
* @param frequency - Cutoff frequency in Hz (default: 1000)
|
||||
* @param Q - Quality factor (default: 1.0)
|
||||
* @returns New audio buffer with filter applied
|
||||
*/
|
||||
export async function applyLowPassFilter(
|
||||
buffer: AudioBuffer,
|
||||
frequency: number = 1000,
|
||||
Q: number = 1.0
|
||||
): Promise<AudioBuffer> {
|
||||
return applyFilter(buffer, { type: 'lowpass', frequency, Q });
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply high-pass filter (cuts low frequencies)
|
||||
* @param buffer - Source audio buffer
|
||||
* @param frequency - Cutoff frequency in Hz (default: 100)
|
||||
* @param Q - Quality factor (default: 1.0)
|
||||
* @returns New audio buffer with filter applied
|
||||
*/
|
||||
export async function applyHighPassFilter(
|
||||
buffer: AudioBuffer,
|
||||
frequency: number = 100,
|
||||
Q: number = 1.0
|
||||
): Promise<AudioBuffer> {
|
||||
return applyFilter(buffer, { type: 'highpass', frequency, Q });
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply band-pass filter (isolates a frequency range)
|
||||
* @param buffer - Source audio buffer
|
||||
* @param frequency - Center frequency in Hz (default: 1000)
|
||||
* @param Q - Quality factor/bandwidth (default: 1.0)
|
||||
* @returns New audio buffer with filter applied
|
||||
*/
|
||||
export async function applyBandPassFilter(
|
||||
buffer: AudioBuffer,
|
||||
frequency: number = 1000,
|
||||
Q: number = 1.0
|
||||
): Promise<AudioBuffer> {
|
||||
return applyFilter(buffer, { type: 'bandpass', frequency, Q });
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply notch filter (removes a specific frequency)
|
||||
* @param buffer - Source audio buffer
|
||||
* @param frequency - Notch frequency in Hz (default: 1000)
|
||||
* @param Q - Quality factor/bandwidth (default: 1.0)
|
||||
* @returns New audio buffer with filter applied
|
||||
*/
|
||||
export async function applyNotchFilter(
|
||||
buffer: AudioBuffer,
|
||||
frequency: number = 1000,
|
||||
Q: number = 1.0
|
||||
): Promise<AudioBuffer> {
|
||||
return applyFilter(buffer, { type: 'notch', frequency, Q });
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply low shelf filter (boost/cut low frequencies)
|
||||
* @param buffer - Source audio buffer
|
||||
* @param frequency - Shelf frequency in Hz (default: 200)
|
||||
* @param gain - Gain in dB (default: 6)
|
||||
* @returns New audio buffer with filter applied
|
||||
*/
|
||||
export async function applyLowShelfFilter(
|
||||
buffer: AudioBuffer,
|
||||
frequency: number = 200,
|
||||
gain: number = 6
|
||||
): Promise<AudioBuffer> {
|
||||
return applyFilter(buffer, { type: 'lowshelf', frequency, gain });
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply high shelf filter (boost/cut high frequencies)
|
||||
* @param buffer - Source audio buffer
|
||||
* @param frequency - Shelf frequency in Hz (default: 3000)
|
||||
* @param gain - Gain in dB (default: 6)
|
||||
* @returns New audio buffer with filter applied
|
||||
*/
|
||||
export async function applyHighShelfFilter(
|
||||
buffer: AudioBuffer,
|
||||
frequency: number = 3000,
|
||||
gain: number = 6
|
||||
): Promise<AudioBuffer> {
|
||||
return applyFilter(buffer, { type: 'highshelf', frequency, gain });
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply peaking EQ filter (boost/cut a specific frequency band)
|
||||
* @param buffer - Source audio buffer
|
||||
* @param frequency - Center frequency in Hz (default: 1000)
|
||||
* @param Q - Quality factor/bandwidth (default: 1.0)
|
||||
* @param gain - Gain in dB (default: 6)
|
||||
* @returns New audio buffer with filter applied
|
||||
*/
|
||||
export async function applyPeakingFilter(
|
||||
buffer: AudioBuffer,
|
||||
frequency: number = 1000,
|
||||
Q: number = 1.0,
|
||||
gain: number = 6
|
||||
): Promise<AudioBuffer> {
|
||||
return applyFilter(buffer, { type: 'peaking', frequency, Q, gain });
|
||||
}
|
||||
52
lib/audio/effects/gain.ts
Normal file
52
lib/audio/effects/gain.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Gain/Volume adjustment effect
|
||||
*/
|
||||
|
||||
import { getAudioContext } from '../context';
|
||||
|
||||
/**
|
||||
* Apply gain to an audio buffer
|
||||
* @param buffer - Source audio buffer
|
||||
* @param gainValue - Gain multiplier (1.0 = no change, 0.5 = -6dB, 2.0 = +6dB)
|
||||
* @returns New audio buffer with gain applied
|
||||
*/
|
||||
export function applyGain(buffer: AudioBuffer, gainValue: number): AudioBuffer {
|
||||
const audioContext = getAudioContext();
|
||||
const outputBuffer = audioContext.createBuffer(
|
||||
buffer.numberOfChannels,
|
||||
buffer.length,
|
||||
buffer.sampleRate
|
||||
);
|
||||
|
||||
// Apply gain to each channel
|
||||
for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
|
||||
const inputData = buffer.getChannelData(channel);
|
||||
const outputData = outputBuffer.getChannelData(channel);
|
||||
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
outputData[i] = inputData[i] * gainValue;
|
||||
// Clamp to prevent distortion
|
||||
outputData[i] = Math.max(-1, Math.min(1, outputData[i]));
|
||||
}
|
||||
}
|
||||
|
||||
return outputBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert dB to gain multiplier
|
||||
* @param db - Decibels
|
||||
* @returns Gain multiplier
|
||||
*/
|
||||
export function dbToGain(db: number): number {
|
||||
return Math.pow(10, db / 20);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert gain multiplier to dB
|
||||
* @param gain - Gain multiplier
|
||||
* @returns Decibels
|
||||
*/
|
||||
export function gainToDb(gain: number): number {
|
||||
return 20 * Math.log10(gain);
|
||||
}
|
||||
132
lib/audio/effects/normalize.ts
Normal file
132
lib/audio/effects/normalize.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Normalization effects
|
||||
*/
|
||||
|
||||
import { getAudioContext } from '../context';
|
||||
|
||||
/**
|
||||
* Normalize audio to peak amplitude
|
||||
* @param buffer - Source audio buffer
|
||||
* @param targetPeak - Target peak amplitude (0.0 to 1.0, default 1.0)
|
||||
* @returns New audio buffer with normalized audio
|
||||
*/
|
||||
export function normalizePeak(buffer: AudioBuffer, targetPeak: number = 1.0): AudioBuffer {
|
||||
const audioContext = getAudioContext();
|
||||
|
||||
// Find the absolute peak across all channels
|
||||
let maxPeak = 0;
|
||||
for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
|
||||
const channelData = buffer.getChannelData(channel);
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const abs = Math.abs(channelData[i]);
|
||||
if (abs > maxPeak) {
|
||||
maxPeak = abs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate gain factor
|
||||
const gainFactor = maxPeak > 0 ? targetPeak / maxPeak : 1.0;
|
||||
|
||||
// Create output buffer and apply gain
|
||||
const outputBuffer = audioContext.createBuffer(
|
||||
buffer.numberOfChannels,
|
||||
buffer.length,
|
||||
buffer.sampleRate
|
||||
);
|
||||
|
||||
for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
|
||||
const inputData = buffer.getChannelData(channel);
|
||||
const outputData = outputBuffer.getChannelData(channel);
|
||||
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
outputData[i] = inputData[i] * gainFactor;
|
||||
}
|
||||
}
|
||||
|
||||
return outputBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize audio to RMS (loudness)
|
||||
* @param buffer - Source audio buffer
|
||||
* @param targetRMS - Target RMS level (0.0 to 1.0, default 0.5)
|
||||
* @returns New audio buffer with normalized audio
|
||||
*/
|
||||
export function normalizeRMS(buffer: AudioBuffer, targetRMS: number = 0.5): AudioBuffer {
|
||||
const audioContext = getAudioContext();
|
||||
|
||||
// Calculate RMS across all channels
|
||||
let sumSquares = 0;
|
||||
let totalSamples = 0;
|
||||
|
||||
for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
|
||||
const channelData = buffer.getChannelData(channel);
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
sumSquares += channelData[i] * channelData[i];
|
||||
totalSamples++;
|
||||
}
|
||||
}
|
||||
|
||||
const currentRMS = Math.sqrt(sumSquares / totalSamples);
|
||||
const gainFactor = currentRMS > 0 ? targetRMS / currentRMS : 1.0;
|
||||
|
||||
// Create output buffer and apply gain
|
||||
const outputBuffer = audioContext.createBuffer(
|
||||
buffer.numberOfChannels,
|
||||
buffer.length,
|
||||
buffer.sampleRate
|
||||
);
|
||||
|
||||
for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
|
||||
const inputData = buffer.getChannelData(channel);
|
||||
const outputData = outputBuffer.getChannelData(channel);
|
||||
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
outputData[i] = inputData[i] * gainFactor;
|
||||
// Clamp to prevent distortion
|
||||
outputData[i] = Math.max(-1, Math.min(1, outputData[i]));
|
||||
}
|
||||
}
|
||||
|
||||
return outputBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get peak amplitude of audio buffer
|
||||
* @param buffer - Audio buffer
|
||||
* @returns Peak amplitude (0.0 to 1.0)
|
||||
*/
|
||||
export function getPeakAmplitude(buffer: AudioBuffer): number {
|
||||
let maxPeak = 0;
|
||||
for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
|
||||
const channelData = buffer.getChannelData(channel);
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const abs = Math.abs(channelData[i]);
|
||||
if (abs > maxPeak) {
|
||||
maxPeak = abs;
|
||||
}
|
||||
}
|
||||
}
|
||||
return maxPeak;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get RMS amplitude of audio buffer
|
||||
* @param buffer - Audio buffer
|
||||
* @returns RMS amplitude
|
||||
*/
|
||||
export function getRMSAmplitude(buffer: AudioBuffer): number {
|
||||
let sumSquares = 0;
|
||||
let totalSamples = 0;
|
||||
|
||||
for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
|
||||
const channelData = buffer.getChannelData(channel);
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
sumSquares += channelData[i] * channelData[i];
|
||||
totalSamples++;
|
||||
}
|
||||
}
|
||||
|
||||
return Math.sqrt(sumSquares / totalSamples);
|
||||
}
|
||||
31
lib/audio/effects/reverse.ts
Normal file
31
lib/audio/effects/reverse.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Reverse audio effect
|
||||
*/
|
||||
|
||||
import { getAudioContext } from '../context';
|
||||
|
||||
/**
|
||||
* Reverse audio buffer
|
||||
* @param buffer - Source audio buffer
|
||||
* @returns New audio buffer with reversed audio
|
||||
*/
|
||||
export function reverseAudio(buffer: AudioBuffer): AudioBuffer {
|
||||
const audioContext = getAudioContext();
|
||||
const outputBuffer = audioContext.createBuffer(
|
||||
buffer.numberOfChannels,
|
||||
buffer.length,
|
||||
buffer.sampleRate
|
||||
);
|
||||
|
||||
// Reverse each channel
|
||||
for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
|
||||
const inputData = buffer.getChannelData(channel);
|
||||
const outputData = outputBuffer.getChannelData(channel);
|
||||
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
outputData[i] = inputData[buffer.length - 1 - i];
|
||||
}
|
||||
}
|
||||
|
||||
return outputBuffer;
|
||||
}
|
||||
128
lib/audio/effects/selection.ts
Normal file
128
lib/audio/effects/selection.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Utilities for applying effects to audio selections
|
||||
*/
|
||||
|
||||
import type { Selection } from '@/types/selection';
|
||||
import { getAudioContext } from '../context';
|
||||
|
||||
/**
|
||||
* Extract a region from an audio buffer
|
||||
*/
|
||||
export function extractRegion(
|
||||
buffer: AudioBuffer,
|
||||
startTime: number,
|
||||
endTime: number
|
||||
): AudioBuffer {
|
||||
const audioContext = getAudioContext();
|
||||
const sampleRate = buffer.sampleRate;
|
||||
const numberOfChannels = buffer.numberOfChannels;
|
||||
|
||||
const startSample = Math.floor(startTime * sampleRate);
|
||||
const endSample = Math.floor(endTime * sampleRate);
|
||||
const length = endSample - startSample;
|
||||
|
||||
const regionBuffer = audioContext.createBuffer(
|
||||
numberOfChannels,
|
||||
length,
|
||||
sampleRate
|
||||
);
|
||||
|
||||
for (let channel = 0; channel < numberOfChannels; channel++) {
|
||||
const sourceData = buffer.getChannelData(channel);
|
||||
const targetData = regionBuffer.getChannelData(channel);
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
targetData[i] = sourceData[startSample + i];
|
||||
}
|
||||
}
|
||||
|
||||
return regionBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a region in an audio buffer with processed audio
|
||||
*/
|
||||
export function replaceRegion(
|
||||
originalBuffer: AudioBuffer,
|
||||
processedRegion: AudioBuffer,
|
||||
startTime: number
|
||||
): AudioBuffer {
|
||||
const audioContext = getAudioContext();
|
||||
const sampleRate = originalBuffer.sampleRate;
|
||||
const numberOfChannels = originalBuffer.numberOfChannels;
|
||||
|
||||
// Create new buffer with same length as original
|
||||
const newBuffer = audioContext.createBuffer(
|
||||
numberOfChannels,
|
||||
originalBuffer.length,
|
||||
sampleRate
|
||||
);
|
||||
|
||||
const startSample = Math.floor(startTime * sampleRate);
|
||||
|
||||
for (let channel = 0; channel < numberOfChannels; channel++) {
|
||||
const originalData = originalBuffer.getChannelData(channel);
|
||||
const processedData = processedRegion.getChannelData(channel);
|
||||
const newData = newBuffer.getChannelData(channel);
|
||||
|
||||
// Copy everything from original
|
||||
for (let i = 0; i < originalBuffer.length; i++) {
|
||||
newData[i] = originalData[i];
|
||||
}
|
||||
|
||||
// Replace the selected region with processed data
|
||||
for (let i = 0; i < processedRegion.length; i++) {
|
||||
if (startSample + i < newBuffer.length) {
|
||||
newData[startSample + i] = processedData[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an effect function to a selection, or entire buffer if no selection
|
||||
*/
|
||||
export function applyEffectToSelection(
|
||||
buffer: AudioBuffer,
|
||||
selection: Selection | null,
|
||||
effectFn: (buffer: AudioBuffer) => AudioBuffer
|
||||
): AudioBuffer {
|
||||
if (!selection || selection.start === selection.end) {
|
||||
// No selection, apply to entire buffer
|
||||
return effectFn(buffer);
|
||||
}
|
||||
|
||||
// Extract the selected region
|
||||
const region = extractRegion(buffer, selection.start, selection.end);
|
||||
|
||||
// Apply effect to the region
|
||||
const processedRegion = effectFn(region);
|
||||
|
||||
// Replace the region in the original buffer
|
||||
return replaceRegion(buffer, processedRegion, selection.start);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an async effect function to a selection, or entire buffer if no selection
|
||||
*/
|
||||
export async function applyAsyncEffectToSelection(
|
||||
buffer: AudioBuffer,
|
||||
selection: Selection | null,
|
||||
effectFn: (buffer: AudioBuffer) => Promise<AudioBuffer>
|
||||
): Promise<AudioBuffer> {
|
||||
if (!selection || selection.start === selection.end) {
|
||||
// No selection, apply to entire buffer
|
||||
return await effectFn(buffer);
|
||||
}
|
||||
|
||||
// Extract the selected region
|
||||
const region = extractRegion(buffer, selection.start, selection.end);
|
||||
|
||||
// Apply effect to the region
|
||||
const processedRegion = await effectFn(region);
|
||||
|
||||
// Replace the region in the original buffer
|
||||
return replaceRegion(buffer, processedRegion, selection.start);
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -75,8 +75,10 @@ export class AudioPlayer {
|
||||
pause(): void {
|
||||
if (!this.isPlaying) return;
|
||||
|
||||
this.pauseTime = this.getCurrentTime();
|
||||
// Save current time BEFORE calling stop (which resets it)
|
||||
const savedTime = this.getCurrentTime();
|
||||
this.stop();
|
||||
this.pauseTime = savedTime;
|
||||
this.isPaused = true;
|
||||
}
|
||||
|
||||
|
||||
242
lib/history/commands/effect-command.ts
Normal file
242
lib/history/commands/effect-command.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Effect commands for undo/redo system
|
||||
*/
|
||||
|
||||
import { BaseCommand } from '../command';
|
||||
|
||||
export class EffectCommand extends BaseCommand {
|
||||
private originalBuffer: AudioBuffer;
|
||||
private modifiedBuffer: AudioBuffer;
|
||||
private applyCallback: (buffer: AudioBuffer) => void;
|
||||
private description: string;
|
||||
|
||||
constructor(
|
||||
originalBuffer: AudioBuffer,
|
||||
modifiedBuffer: AudioBuffer,
|
||||
applyCallback: (buffer: AudioBuffer) => void,
|
||||
description: string
|
||||
) {
|
||||
super();
|
||||
this.originalBuffer = originalBuffer;
|
||||
this.modifiedBuffer = modifiedBuffer;
|
||||
this.applyCallback = applyCallback;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return this.description;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
this.applyCallback(this.modifiedBuffer);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
this.applyCallback(this.originalBuffer);
|
||||
}
|
||||
|
||||
redo(): void {
|
||||
this.execute();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create effect commands
|
||||
*/
|
||||
export function createEffectCommand(
|
||||
originalBuffer: AudioBuffer,
|
||||
effectFunction: (buffer: AudioBuffer) => AudioBuffer | Promise<AudioBuffer>,
|
||||
applyCallback: (buffer: AudioBuffer) => void,
|
||||
description: string
|
||||
): EffectCommand {
|
||||
const result = effectFunction(originalBuffer);
|
||||
const modifiedBuffer = result instanceof Promise ? originalBuffer : result;
|
||||
return new EffectCommand(originalBuffer, modifiedBuffer, applyCallback, description);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create async effect commands
|
||||
*/
|
||||
export async function createAsyncEffectCommand(
|
||||
originalBuffer: AudioBuffer,
|
||||
effectFunction: (buffer: AudioBuffer) => Promise<AudioBuffer>,
|
||||
applyCallback: (buffer: AudioBuffer) => void,
|
||||
description: string
|
||||
): Promise<EffectCommand> {
|
||||
const modifiedBuffer = await effectFunction(originalBuffer);
|
||||
return new EffectCommand(originalBuffer, modifiedBuffer, applyCallback, description);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for gain effect command
|
||||
*/
|
||||
export function createGainCommand(
|
||||
buffer: AudioBuffer,
|
||||
gainValue: number,
|
||||
applyCallback: (buffer: AudioBuffer) => void
|
||||
): EffectCommand {
|
||||
return createEffectCommand(
|
||||
buffer,
|
||||
(buf) => {
|
||||
// Import will happen at runtime
|
||||
const { applyGain } = require('@/lib/audio/effects/gain');
|
||||
return applyGain(buf, gainValue);
|
||||
},
|
||||
applyCallback,
|
||||
`Apply Gain (${gainValue.toFixed(2)}x)`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for normalize peak command
|
||||
*/
|
||||
export function createNormalizePeakCommand(
|
||||
buffer: AudioBuffer,
|
||||
targetPeak: number,
|
||||
applyCallback: (buffer: AudioBuffer) => void
|
||||
): EffectCommand {
|
||||
return createEffectCommand(
|
||||
buffer,
|
||||
(buf) => {
|
||||
const { normalizePeak } = require('@/lib/audio/effects/normalize');
|
||||
return normalizePeak(buf, targetPeak);
|
||||
},
|
||||
applyCallback,
|
||||
`Normalize to Peak (${targetPeak.toFixed(2)})`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for normalize RMS command
|
||||
*/
|
||||
export function createNormalizeRMSCommand(
|
||||
buffer: AudioBuffer,
|
||||
targetRMS: number,
|
||||
applyCallback: (buffer: AudioBuffer) => void
|
||||
): EffectCommand {
|
||||
return createEffectCommand(
|
||||
buffer,
|
||||
(buf) => {
|
||||
const { normalizeRMS } = require('@/lib/audio/effects/normalize');
|
||||
return normalizeRMS(buf, targetRMS);
|
||||
},
|
||||
applyCallback,
|
||||
`Normalize to RMS (${targetRMS.toFixed(2)})`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for fade in command
|
||||
*/
|
||||
export function createFadeInCommand(
|
||||
buffer: AudioBuffer,
|
||||
duration: number,
|
||||
applyCallback: (buffer: AudioBuffer) => void
|
||||
): EffectCommand {
|
||||
return createEffectCommand(
|
||||
buffer,
|
||||
(buf) => {
|
||||
const { applyFadeIn } = require('@/lib/audio/effects/fade');
|
||||
return applyFadeIn(buf, duration);
|
||||
},
|
||||
applyCallback,
|
||||
`Fade In (${duration.toFixed(2)}s)`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for fade out command
|
||||
*/
|
||||
export function createFadeOutCommand(
|
||||
buffer: AudioBuffer,
|
||||
duration: number,
|
||||
applyCallback: (buffer: AudioBuffer) => void
|
||||
): EffectCommand {
|
||||
return createEffectCommand(
|
||||
buffer,
|
||||
(buf) => {
|
||||
const { applyFadeOut } = require('@/lib/audio/effects/fade');
|
||||
return applyFadeOut(buf, duration);
|
||||
},
|
||||
applyCallback,
|
||||
`Fade Out (${duration.toFixed(2)}s)`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for reverse command
|
||||
*/
|
||||
export function createReverseCommand(
|
||||
buffer: AudioBuffer,
|
||||
applyCallback: (buffer: AudioBuffer) => void
|
||||
): EffectCommand {
|
||||
return createEffectCommand(
|
||||
buffer,
|
||||
(buf) => {
|
||||
const { reverseAudio } = require('@/lib/audio/effects/reverse');
|
||||
return reverseAudio(buf);
|
||||
},
|
||||
applyCallback,
|
||||
'Reverse Audio'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for low-pass filter command
|
||||
*/
|
||||
export async function createLowPassFilterCommand(
|
||||
buffer: AudioBuffer,
|
||||
frequency: number,
|
||||
Q: number,
|
||||
applyCallback: (buffer: AudioBuffer) => void
|
||||
): Promise<EffectCommand> {
|
||||
return createAsyncEffectCommand(
|
||||
buffer,
|
||||
async (buf) => {
|
||||
const { applyLowPassFilter } = require('@/lib/audio/effects/filters');
|
||||
return await applyLowPassFilter(buf, frequency, Q);
|
||||
},
|
||||
applyCallback,
|
||||
`Low-Pass Filter (${frequency}Hz)`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for high-pass filter command
|
||||
*/
|
||||
export async function createHighPassFilterCommand(
|
||||
buffer: AudioBuffer,
|
||||
frequency: number,
|
||||
Q: number,
|
||||
applyCallback: (buffer: AudioBuffer) => void
|
||||
): Promise<EffectCommand> {
|
||||
return createAsyncEffectCommand(
|
||||
buffer,
|
||||
async (buf) => {
|
||||
const { applyHighPassFilter } = require('@/lib/audio/effects/filters');
|
||||
return await applyHighPassFilter(buf, frequency, Q);
|
||||
},
|
||||
applyCallback,
|
||||
`High-Pass Filter (${frequency}Hz)`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for band-pass filter command
|
||||
*/
|
||||
export async function createBandPassFilterCommand(
|
||||
buffer: AudioBuffer,
|
||||
frequency: number,
|
||||
Q: number,
|
||||
applyCallback: (buffer: AudioBuffer) => void
|
||||
): Promise<EffectCommand> {
|
||||
return createAsyncEffectCommand(
|
||||
buffer,
|
||||
async (buf) => {
|
||||
const { applyBandPassFilter } = require('@/lib/audio/effects/filters');
|
||||
return await applyBandPassFilter(buf, frequency, Q);
|
||||
},
|
||||
applyCallback,
|
||||
`Band-Pass Filter (${frequency}Hz)`
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user