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>
282 lines
8.4 KiB
TypeScript
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;
|
|
}
|