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