Files
audio-ui/lib/audio/effects/dynamics.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

206 lines
6.4 KiB
TypeScript

/**
* 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)
}