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:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user