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>
206 lines
6.4 KiB
TypeScript
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)
|
|
}
|