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

282 lines
8.4 KiB
TypeScript

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