Adding more effects
This commit is contained in:
166
src/App.svelte
166
src/App.svelte
@ -13,6 +13,11 @@
|
||||
saveDuration,
|
||||
} from "./lib/utils/settings";
|
||||
import { generateRandomColor } from "./lib/utils/colors";
|
||||
import {
|
||||
getRandomProcessor,
|
||||
getAllProcessors,
|
||||
} from "./lib/audio/processors/registry";
|
||||
import type { AudioProcessor } from "./lib/audio/processors/AudioProcessor";
|
||||
|
||||
let currentEngineIndex = 0;
|
||||
let engine = engines[currentEngineIndex];
|
||||
@ -26,6 +31,11 @@
|
||||
let playbackPosition = -1;
|
||||
let waveformColor = generateRandomColor();
|
||||
let showModal = true;
|
||||
let isProcessed = false;
|
||||
let showProcessorPopup = false;
|
||||
let popupTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const allProcessors = getAllProcessors();
|
||||
|
||||
onMount(() => {
|
||||
audioService.setVolume(volume);
|
||||
@ -38,6 +48,7 @@
|
||||
function generateRandom() {
|
||||
currentParams = engine.randomParams();
|
||||
waveformColor = generateRandomColor();
|
||||
isProcessed = false;
|
||||
regenerateBuffer();
|
||||
}
|
||||
|
||||
@ -71,6 +82,65 @@
|
||||
downloadWAV(currentBuffer, "synth-sound.wav");
|
||||
}
|
||||
|
||||
function processSound() {
|
||||
if (!currentBuffer) return;
|
||||
|
||||
const processor = getRandomProcessor();
|
||||
applyProcessor(processor);
|
||||
}
|
||||
|
||||
function processWithSpecificProcessor(processor: AudioProcessor) {
|
||||
if (!currentBuffer) return;
|
||||
|
||||
hideProcessorPopup();
|
||||
applyProcessor(processor);
|
||||
}
|
||||
|
||||
function handlePopupMouseEnter() {
|
||||
if (popupTimeout) {
|
||||
clearTimeout(popupTimeout);
|
||||
}
|
||||
showProcessorPopup = true;
|
||||
popupTimeout = setTimeout(() => {
|
||||
showProcessorPopup = false;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function handlePopupMouseLeave() {
|
||||
if (popupTimeout) {
|
||||
clearTimeout(popupTimeout);
|
||||
}
|
||||
popupTimeout = setTimeout(() => {
|
||||
showProcessorPopup = false;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function hideProcessorPopup() {
|
||||
if (popupTimeout) {
|
||||
clearTimeout(popupTimeout);
|
||||
}
|
||||
showProcessorPopup = false;
|
||||
}
|
||||
|
||||
async function applyProcessor(processor: AudioProcessor) {
|
||||
if (!currentBuffer) return;
|
||||
|
||||
const leftChannel = currentBuffer.getChannelData(0);
|
||||
const rightChannel = currentBuffer.getChannelData(1);
|
||||
|
||||
const [processedLeft, processedRight] = await processor.process(
|
||||
leftChannel,
|
||||
rightChannel,
|
||||
);
|
||||
|
||||
currentBuffer = audioService.createAudioBuffer([
|
||||
processedLeft,
|
||||
processedRight,
|
||||
]);
|
||||
isProcessed = true;
|
||||
audioService.play(currentBuffer);
|
||||
}
|
||||
|
||||
function handleVolumeChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
volume = parseFloat(target.value);
|
||||
@ -114,6 +184,9 @@
|
||||
case "r":
|
||||
generateRandom();
|
||||
break;
|
||||
case "p":
|
||||
processSound();
|
||||
break;
|
||||
case "s":
|
||||
download();
|
||||
break;
|
||||
@ -201,7 +274,30 @@
|
||||
/>
|
||||
<div class="bottom-controls">
|
||||
<button onclick={generateRandom}>Random (R)</button>
|
||||
<button onclick={mutate}>Mutate (M)</button>
|
||||
{#if !isProcessed}
|
||||
<button onclick={mutate}>Mutate (M)</button>
|
||||
{/if}
|
||||
<div
|
||||
class="process-button-container"
|
||||
role="group"
|
||||
onmouseenter={handlePopupMouseEnter}
|
||||
onmouseleave={handlePopupMouseLeave}
|
||||
>
|
||||
<button onclick={processSound}>Process (P)</button>
|
||||
{#if showProcessorPopup}
|
||||
<div class="processor-popup">
|
||||
{#each allProcessors as processor}
|
||||
<button
|
||||
class="processor-tile"
|
||||
data-description={processor.getDescription()}
|
||||
onclick={() => processWithSpecificProcessor(processor)}
|
||||
>
|
||||
{processor.getName()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button onclick={download}>Download (D)</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -290,7 +386,7 @@
|
||||
.engine-button::after {
|
||||
content: attr(data-description);
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
top: 100%;
|
||||
left: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background-color: #0a0a0a;
|
||||
@ -490,4 +586,70 @@
|
||||
.modal-close:hover {
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
.process-button-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.processor-popup {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: #000;
|
||||
border: 2px solid #fff;
|
||||
padding: 0.75rem;
|
||||
z-index: 1000;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
width: 450px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.processor-tile {
|
||||
background-color: #1a1a1a;
|
||||
border: 1px solid #444;
|
||||
padding: 0.6rem 0.4rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, border-color 0.2s;
|
||||
font-size: 0.85rem;
|
||||
color: #fff;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.processor-tile:hover {
|
||||
background-color: #2a2a2a;
|
||||
border-color: #646cff;
|
||||
}
|
||||
|
||||
.processor-tile::after {
|
||||
content: attr(data-description);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.5rem 0.75rem;
|
||||
background-color: #0a0a0a;
|
||||
border: 1px solid #444;
|
||||
color: #ccc;
|
||||
font-size: 0.85rem;
|
||||
width: max-content;
|
||||
max-width: 300px;
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
z-index: 1001;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.processor-tile:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
8
src/lib/audio/processors/AudioProcessor.ts
Normal file
8
src/lib/audio/processors/AudioProcessor.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export interface AudioProcessor {
|
||||
getName(): string;
|
||||
getDescription(): string;
|
||||
process(
|
||||
leftChannel: Float32Array,
|
||||
rightChannel: Float32Array
|
||||
): [Float32Array, Float32Array] | Promise<[Float32Array, Float32Array]>;
|
||||
}
|
||||
156
src/lib/audio/processors/Chorus.ts
Normal file
156
src/lib/audio/processors/Chorus.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import type { AudioProcessor } from './AudioProcessor';
|
||||
|
||||
export class Chorus implements AudioProcessor {
|
||||
private readonly sampleRate = 44100;
|
||||
|
||||
getName(): string {
|
||||
return 'Chorus';
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return 'Multiple delayed copies with pitch modulation for thick, ensemble sounds';
|
||||
}
|
||||
|
||||
process(
|
||||
leftChannel: Float32Array,
|
||||
rightChannel: Float32Array
|
||||
): [Float32Array, Float32Array] {
|
||||
const leftIn = leftChannel;
|
||||
const rightIn = rightChannel;
|
||||
const length = leftIn.length;
|
||||
|
||||
// Random parameters with better ranges
|
||||
const numVoices = Math.floor(Math.random() * 3) + 2; // 2-4 voices
|
||||
const baseDelay = Math.random() * 0.015 + 0.015; // 15-30ms (increased for better effect)
|
||||
const lfoDepth = Math.random() * 0.003 + 0.001; // 1-4ms modulation depth
|
||||
const mix = Math.random() * 0.3 + 0.35; // 35-65% wet (better balance)
|
||||
const feedback = Math.random() * 0.15 + 0.05; // 5-20% feedback (reduced for stability)
|
||||
const stereoSpread = Math.random() * 0.3 + 0.2; // 20-50% stereo width
|
||||
|
||||
// Convert times to samples
|
||||
const baseDelaySamples = Math.floor(baseDelay * this.sampleRate);
|
||||
const lfoDepthSamples = lfoDepth * this.sampleRate;
|
||||
const maxDelaySamples = Math.ceil(baseDelaySamples + lfoDepthSamples + 1);
|
||||
|
||||
// Create delay buffers with enough space for the entire input plus delay
|
||||
const bufferSize = length + maxDelaySamples;
|
||||
const leftDelayBuffer = new Float32Array(bufferSize);
|
||||
const rightDelayBuffer = new Float32Array(bufferSize);
|
||||
|
||||
// Initialize delay buffers with input signal
|
||||
leftDelayBuffer.set(leftIn, maxDelaySamples);
|
||||
rightDelayBuffer.set(rightIn, maxDelaySamples);
|
||||
|
||||
// Output buffers
|
||||
const leftOut = new Float32Array(length);
|
||||
const rightOut = new Float32Array(length);
|
||||
|
||||
// Pre-calculate LFO parameters for each voice
|
||||
const voices: Array<{
|
||||
lfoPhase: number;
|
||||
lfoIncrement: number;
|
||||
panLeft: number;
|
||||
panRight: number;
|
||||
}> = [];
|
||||
|
||||
for (let voice = 0; voice < numVoices; voice++) {
|
||||
const phaseOffset = (voice / numVoices) * Math.PI * 2;
|
||||
const rateVariation = 0.8 + Math.random() * 0.4; // 0.8-1.2x rate variation
|
||||
const lfoRate = (0.5 + Math.random() * 2) * rateVariation; // 0.4-2.5 Hz per voice
|
||||
|
||||
// Stereo panning for each voice
|
||||
const pan = (voice / (numVoices - 1) - 0.5) * stereoSpread;
|
||||
|
||||
voices.push({
|
||||
lfoPhase: phaseOffset,
|
||||
lfoIncrement: (lfoRate * 2 * Math.PI) / this.sampleRate,
|
||||
panLeft: Math.cos((pan + 0.5) * Math.PI * 0.5),
|
||||
panRight: Math.sin((pan + 0.5) * Math.PI * 0.5)
|
||||
});
|
||||
}
|
||||
|
||||
// Process each sample
|
||||
for (let i = 0; i < length; i++) {
|
||||
let leftWet = 0;
|
||||
let rightWet = 0;
|
||||
|
||||
// Process each chorus voice
|
||||
for (let v = 0; v < numVoices; v++) {
|
||||
const voice = voices[v];
|
||||
|
||||
// Calculate LFO value
|
||||
const lfo = Math.sin(voice.lfoPhase);
|
||||
voice.lfoPhase += voice.lfoIncrement;
|
||||
if (voice.lfoPhase > Math.PI * 2) {
|
||||
voice.lfoPhase -= Math.PI * 2;
|
||||
}
|
||||
|
||||
// Calculate modulated delay time in samples
|
||||
const delaySamples = baseDelaySamples + lfo * lfoDepthSamples;
|
||||
|
||||
// Calculate read position with fractional delay
|
||||
const readPos = i + maxDelaySamples - delaySamples;
|
||||
const readPosInt = Math.floor(readPos);
|
||||
const frac = readPos - readPosInt;
|
||||
|
||||
// Ensure we're within bounds
|
||||
if (readPosInt >= 0 && readPosInt < bufferSize - 1) {
|
||||
// Linear interpolation for fractional delay
|
||||
const leftDelayed = this.lerp(
|
||||
leftDelayBuffer[readPosInt],
|
||||
leftDelayBuffer[readPosInt + 1],
|
||||
frac
|
||||
);
|
||||
const rightDelayed = this.lerp(
|
||||
rightDelayBuffer[readPosInt],
|
||||
rightDelayBuffer[readPosInt + 1],
|
||||
frac
|
||||
);
|
||||
|
||||
// Apply panning to each voice for stereo width
|
||||
leftWet += leftDelayed * voice.panLeft;
|
||||
rightWet += rightDelayed * voice.panRight;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize wet signal to maintain level
|
||||
const wetGain = 1.0 / Math.sqrt(numVoices);
|
||||
leftWet *= wetGain;
|
||||
rightWet *= wetGain;
|
||||
|
||||
// Apply feedback (write it to future position to avoid immediate feedback)
|
||||
const feedbackPos = i + maxDelaySamples;
|
||||
if (feedbackPos < bufferSize) {
|
||||
leftDelayBuffer[feedbackPos] += leftWet * feedback;
|
||||
rightDelayBuffer[feedbackPos] += rightWet * feedback;
|
||||
}
|
||||
|
||||
// Mix dry and wet signals with proper gain compensation
|
||||
const dryGain = Math.sqrt(1 - mix);
|
||||
const wetGain2 = Math.sqrt(mix) * 1.5; // Slight boost for presence
|
||||
|
||||
leftOut[i] = leftIn[i] * dryGain + leftWet * wetGain2;
|
||||
rightOut[i] = rightIn[i] * dryGain + rightWet * wetGain2;
|
||||
|
||||
// Soft clipping to prevent harsh distortion
|
||||
leftOut[i] = this.softClip(leftOut[i]);
|
||||
rightOut[i] = this.softClip(rightOut[i]);
|
||||
}
|
||||
|
||||
return [leftOut, rightOut];
|
||||
}
|
||||
|
||||
private lerp(a: number, b: number, t: number): number {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
private softClip(sample: number): number {
|
||||
const threshold = 0.95;
|
||||
if (Math.abs(sample) < threshold) {
|
||||
return sample;
|
||||
}
|
||||
const sign = sample < 0 ? -1 : 1;
|
||||
const abs = Math.abs(sample);
|
||||
return sign * (threshold + (1 - threshold) * Math.tanh((abs - threshold) / (1 - threshold)));
|
||||
}
|
||||
}
|
||||
251
src/lib/audio/processors/ConvolutionReverb.ts
Normal file
251
src/lib/audio/processors/ConvolutionReverb.ts
Normal file
@ -0,0 +1,251 @@
|
||||
import type { AudioProcessor } from './AudioProcessor';
|
||||
|
||||
type RoomType = 'small' | 'medium' | 'large' | 'hall' | 'plate' | 'chamber';
|
||||
|
||||
interface ReverbParams {
|
||||
roomType: RoomType;
|
||||
decayTime: number;
|
||||
preDelay: number;
|
||||
wetMix: number;
|
||||
earlyReflections: number;
|
||||
diffusion: number;
|
||||
}
|
||||
|
||||
export class ConvolutionReverb implements AudioProcessor {
|
||||
private readonly sampleRate = 44100;
|
||||
|
||||
getName(): string {
|
||||
return 'Convolution Reverb';
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return 'Realistic room ambience using Web Audio ConvolverNode with synthetic impulse responses';
|
||||
}
|
||||
|
||||
async process(
|
||||
leftChannel: Float32Array,
|
||||
rightChannel: Float32Array
|
||||
): Promise<[Float32Array, Float32Array]> {
|
||||
const inputLength = leftChannel.length;
|
||||
const params = this.randomParams();
|
||||
|
||||
// Create offline context for processing
|
||||
const offlineContext = new OfflineAudioContext(2, inputLength, this.sampleRate);
|
||||
|
||||
// Create input buffer from our channels (copy to ensure proper ArrayBuffer type)
|
||||
const inputBuffer = offlineContext.createBuffer(2, inputLength, this.sampleRate);
|
||||
inputBuffer.copyToChannel(new Float32Array(leftChannel), 0);
|
||||
inputBuffer.copyToChannel(new Float32Array(rightChannel), 1);
|
||||
|
||||
// Generate impulse response with slight stereo differences
|
||||
const irBuffer = this.generateImpulseResponseBuffer(offlineContext, params);
|
||||
|
||||
// Create audio graph: source -> convolver -> destination
|
||||
const source = offlineContext.createBufferSource();
|
||||
source.buffer = inputBuffer;
|
||||
|
||||
const convolver = offlineContext.createConvolver();
|
||||
convolver.buffer = irBuffer;
|
||||
convolver.normalize = false; // We normalize the IR ourselves for consistent gain
|
||||
|
||||
const dryGain = offlineContext.createGain();
|
||||
const wetGain = offlineContext.createGain();
|
||||
|
||||
// Equal-power crossfade
|
||||
dryGain.gain.value = Math.sqrt(1 - params.wetMix);
|
||||
wetGain.gain.value = Math.sqrt(params.wetMix);
|
||||
|
||||
// Connect dry path
|
||||
source.connect(dryGain);
|
||||
dryGain.connect(offlineContext.destination);
|
||||
|
||||
// Connect wet path
|
||||
source.connect(convolver);
|
||||
convolver.connect(wetGain);
|
||||
wetGain.connect(offlineContext.destination);
|
||||
|
||||
source.start(0);
|
||||
|
||||
// Render offline
|
||||
const renderedBuffer = await offlineContext.startRendering();
|
||||
|
||||
// Extract channels and ensure exact length match
|
||||
const outLeft = new Float32Array(inputLength);
|
||||
const outRight = new Float32Array(inputLength);
|
||||
|
||||
renderedBuffer.copyFromChannel(outLeft, 0);
|
||||
renderedBuffer.copyFromChannel(outRight, 1);
|
||||
|
||||
// Normalize to prevent clipping while maintaining dynamics
|
||||
this.normalizeInPlace(outLeft, outRight);
|
||||
|
||||
return [outLeft, outRight];
|
||||
}
|
||||
|
||||
private randomParams(): ReverbParams {
|
||||
const roomTypes: RoomType[] = ['small', 'medium', 'large', 'hall', 'plate', 'chamber'];
|
||||
const roomType = roomTypes[Math.floor(Math.random() * roomTypes.length)];
|
||||
|
||||
let decayTime: number;
|
||||
let diffusion: number;
|
||||
|
||||
switch (roomType) {
|
||||
case 'small':
|
||||
decayTime = Math.random() * 0.3 + 0.2;
|
||||
diffusion = Math.random() * 0.3 + 0.5;
|
||||
break;
|
||||
case 'medium':
|
||||
decayTime = Math.random() * 0.5 + 0.4;
|
||||
diffusion = Math.random() * 0.2 + 0.6;
|
||||
break;
|
||||
case 'large':
|
||||
decayTime = Math.random() * 0.7 + 0.6;
|
||||
diffusion = Math.random() * 0.2 + 0.7;
|
||||
break;
|
||||
case 'hall':
|
||||
decayTime = Math.random() * 0.5 + 0.8;
|
||||
diffusion = Math.random() * 0.15 + 0.75;
|
||||
break;
|
||||
case 'plate':
|
||||
decayTime = Math.random() * 0.4 + 0.5;
|
||||
diffusion = Math.random() * 0.3 + 0.6;
|
||||
break;
|
||||
case 'chamber':
|
||||
decayTime = Math.random() * 0.6 + 0.5;
|
||||
diffusion = Math.random() * 0.2 + 0.65;
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
roomType,
|
||||
decayTime,
|
||||
preDelay: Math.random() * 0.025 + 0.005,
|
||||
wetMix: Math.random() * 0.4 + 0.3,
|
||||
earlyReflections: Math.random() * 0.3 + 0.4,
|
||||
diffusion
|
||||
};
|
||||
}
|
||||
|
||||
private generateImpulseResponseBuffer(context: OfflineAudioContext, params: ReverbParams): AudioBuffer {
|
||||
// IR length based on decay time, with reasonable maximum
|
||||
const irDuration = Math.min(params.decayTime * 4, 4.0);
|
||||
const irLength = Math.floor(irDuration * this.sampleRate);
|
||||
|
||||
const irBuffer = context.createBuffer(2, irLength, this.sampleRate);
|
||||
const irLeft = new Float32Array(irLength);
|
||||
const irRight = new Float32Array(irLength);
|
||||
|
||||
// Generate stereo IRs with slight differences for width
|
||||
this.generateImpulseResponse(irLeft, params, 0);
|
||||
this.generateImpulseResponse(irRight, params, 1);
|
||||
|
||||
irBuffer.copyToChannel(irLeft, 0);
|
||||
irBuffer.copyToChannel(irRight, 1);
|
||||
|
||||
return irBuffer;
|
||||
}
|
||||
|
||||
private generateImpulseResponse(ir: Float32Array, params: ReverbParams, channel: number): void {
|
||||
const irLength = ir.length;
|
||||
const preDelaySamples = Math.floor(params.preDelay * this.sampleRate);
|
||||
|
||||
// Room-specific parameters
|
||||
const roomSizes: Record<RoomType, number> = {
|
||||
small: 0.2,
|
||||
medium: 0.4,
|
||||
large: 0.6,
|
||||
hall: 0.85,
|
||||
plate: 0.5,
|
||||
chamber: 0.55
|
||||
};
|
||||
|
||||
const roomSize = roomSizes[params.roomType];
|
||||
const earlyDecayTime = roomSize * 0.05;
|
||||
|
||||
// Generate early reflections as sparse impulses
|
||||
const numEarlyReflections = Math.floor(roomSize * 30) + 8;
|
||||
const earlyReflectionTime = earlyDecayTime * this.sampleRate;
|
||||
|
||||
for (let i = 0; i < numEarlyReflections; i++) {
|
||||
const time = preDelaySamples + Math.pow(Math.random(), 1.5) * earlyReflectionTime;
|
||||
const index = Math.floor(time);
|
||||
|
||||
if (index < irLength) {
|
||||
const distanceAttenuation = 1.0 / (1.0 + time / this.sampleRate);
|
||||
const amplitude = params.earlyReflections * distanceAttenuation * (0.5 + Math.random() * 0.5);
|
||||
|
||||
// Stereo width variation
|
||||
const stereoOffset = (channel === 0 ? -1 : 1) * Math.random() * 0.3;
|
||||
ir[index] += amplitude * (1.0 + stereoOffset);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate diffuse tail using filtered noise
|
||||
const diffuseStart = preDelaySamples + Math.floor(earlyReflectionTime);
|
||||
const decaySamples = params.decayTime * this.sampleRate;
|
||||
|
||||
// Multi-band diffusion for realistic reverb character
|
||||
const numBands = 4;
|
||||
const bandGains = new Float32Array(numBands);
|
||||
|
||||
for (let band = 0; band < numBands; band++) {
|
||||
bandGains[band] = 0.7 + Math.random() * 0.3;
|
||||
}
|
||||
|
||||
// Generate noise with exponential decay and frequency-dependent damping
|
||||
for (let i = diffuseStart; i < irLength; i++) {
|
||||
const t = (i - diffuseStart) / decaySamples;
|
||||
const envelope = Math.exp(-t * 6.0);
|
||||
|
||||
if (envelope < 0.001) break;
|
||||
|
||||
let sample = 0;
|
||||
|
||||
// Multi-band noise for frequency-dependent decay
|
||||
for (let band = 0; band < numBands; band++) {
|
||||
const noise = (Math.random() * 2 - 1) * bandGains[band];
|
||||
const bandEnvelope = Math.exp(-t * (3.0 + band * 2.0));
|
||||
sample += noise * bandEnvelope;
|
||||
}
|
||||
|
||||
sample *= envelope * params.diffusion;
|
||||
|
||||
// Apply low-pass filtering for natural high-frequency damping
|
||||
if (i > diffuseStart) {
|
||||
const damping = params.roomType === 'plate' ? 0.4 : 0.5;
|
||||
sample = sample * (1 - damping) + ir[i - 1] * damping;
|
||||
}
|
||||
|
||||
ir[i] += sample;
|
||||
}
|
||||
|
||||
// Normalize IR to consistent level
|
||||
let maxAmp = 0;
|
||||
for (let i = 0; i < irLength; i++) {
|
||||
maxAmp = Math.max(maxAmp, Math.abs(ir[i]));
|
||||
}
|
||||
|
||||
if (maxAmp > 0) {
|
||||
const normGain = 0.5 / maxAmp;
|
||||
for (let i = 0; i < irLength; i++) {
|
||||
ir[i] *= normGain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeInPlace(left: Float32Array, right: Float32Array): void {
|
||||
let maxAmp = 0;
|
||||
|
||||
for (let i = 0; i < left.length; i++) {
|
||||
maxAmp = Math.max(maxAmp, Math.abs(left[i]), Math.abs(right[i]));
|
||||
}
|
||||
|
||||
if (maxAmp > 0.98) {
|
||||
const gain = 0.95 / maxAmp;
|
||||
for (let i = 0; i < left.length; i++) {
|
||||
left[i] *= gain;
|
||||
right[i] *= gain;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
164
src/lib/audio/processors/DopplerEffect.ts
Normal file
164
src/lib/audio/processors/DopplerEffect.ts
Normal file
@ -0,0 +1,164 @@
|
||||
import type { AudioProcessor } from './AudioProcessor';
|
||||
|
||||
export class DopplerEffect implements AudioProcessor {
|
||||
getName(): string {
|
||||
return 'Doppler Effect';
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return 'Simulates pitch and amplitude changes from a moving source passing by';
|
||||
}
|
||||
|
||||
process(
|
||||
leftChannel: Float32Array,
|
||||
rightChannel: Float32Array
|
||||
): [Float32Array, Float32Array] {
|
||||
const length = leftChannel.length;
|
||||
const sampleRate = 44100;
|
||||
const duration = length / sampleRate;
|
||||
|
||||
// Random parameters for movement
|
||||
const approachSpeed = 30 + Math.random() * 70; // 30-100 m/s (108-360 km/h)
|
||||
const closestDistance = 5 + Math.random() * 20; // 5-25 meters
|
||||
const sourceHeight = Math.random() * 10; // 0-10 meters above listener
|
||||
const leftToRight = Math.random() > 0.5; // Direction of movement
|
||||
|
||||
// Physics constants
|
||||
const speedOfSound = 343; // m/s at 20°C
|
||||
const startDistance = approachSpeed * duration * 0.5;
|
||||
const endDistance = startDistance;
|
||||
|
||||
// Pre-calculate delay buffer size needed
|
||||
const maxDelayTime = 0.1; // 100ms maximum delay
|
||||
const maxDelaySamples = Math.ceil(maxDelayTime * sampleRate);
|
||||
|
||||
// Process each channel with delay-based pitch shifting
|
||||
const outputLeft = this.processDopplerChannel(
|
||||
leftToRight ? leftChannel : rightChannel,
|
||||
length,
|
||||
sampleRate,
|
||||
approachSpeed,
|
||||
closestDistance,
|
||||
sourceHeight,
|
||||
startDistance,
|
||||
endDistance,
|
||||
speedOfSound,
|
||||
maxDelaySamples,
|
||||
-2 // Left ear offset
|
||||
);
|
||||
|
||||
const outputRight = this.processDopplerChannel(
|
||||
leftToRight ? rightChannel : leftChannel,
|
||||
length,
|
||||
sampleRate,
|
||||
approachSpeed,
|
||||
closestDistance,
|
||||
sourceHeight,
|
||||
startDistance,
|
||||
endDistance,
|
||||
speedOfSound,
|
||||
maxDelaySamples,
|
||||
2 // Right ear offset
|
||||
);
|
||||
|
||||
return [outputLeft, outputRight];
|
||||
}
|
||||
|
||||
private processDopplerChannel(
|
||||
input: Float32Array,
|
||||
length: number,
|
||||
sampleRate: number,
|
||||
speed: number,
|
||||
closestDistance: number,
|
||||
height: number,
|
||||
startDistance: number,
|
||||
endDistance: number,
|
||||
speedOfSound: number,
|
||||
maxDelaySamples: number,
|
||||
earOffset: number
|
||||
): Float32Array {
|
||||
const output = new Float32Array(length);
|
||||
const delayBuffer = new Float32Array(input.length + maxDelaySamples);
|
||||
|
||||
// Copy input to delay buffer with padding
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
delayBuffer[i + maxDelaySamples] = input[i];
|
||||
}
|
||||
|
||||
// Variables for delay line interpolation
|
||||
let previousDelay = 0;
|
||||
const smoothingFactor = 0.995; // Smooth delay changes to avoid clicks
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const time = i / sampleRate;
|
||||
const normalizedTime = time / (length / sampleRate);
|
||||
|
||||
// Calculate source position along path
|
||||
const alongPath = -startDistance + (startDistance + endDistance) * normalizedTime;
|
||||
|
||||
// Calculate actual distance including lateral offset and height
|
||||
const lateralDistance = Math.sqrt(
|
||||
closestDistance * closestDistance +
|
||||
height * height +
|
||||
earOffset * earOffset
|
||||
);
|
||||
const distance = Math.sqrt(alongPath * alongPath + lateralDistance * lateralDistance);
|
||||
|
||||
// Calculate radial velocity (component of velocity towards listener)
|
||||
const radialVelocity = speed * alongPath / distance;
|
||||
|
||||
// Doppler frequency shift factor
|
||||
const dopplerFactor = speedOfSound / (speedOfSound - radialVelocity);
|
||||
|
||||
// Time-varying delay for pitch shift (in samples)
|
||||
const targetDelay = (dopplerFactor - 1) * 50; // Scale factor for audible effect
|
||||
const currentDelay = previousDelay * smoothingFactor + targetDelay * (1 - smoothingFactor);
|
||||
previousDelay = currentDelay;
|
||||
|
||||
// Calculate amplitude based on distance (inverse square law)
|
||||
const referenceDistance = 10;
|
||||
const amplitude = Math.min(1, (referenceDistance / distance) * (referenceDistance / distance));
|
||||
|
||||
// Read from delay buffer with cubic interpolation
|
||||
const readPos = i + maxDelaySamples + currentDelay;
|
||||
const readIdx = Math.floor(readPos);
|
||||
const frac = readPos - readIdx;
|
||||
|
||||
if (readIdx >= 1 && readIdx < delayBuffer.length - 2) {
|
||||
const y0 = delayBuffer[readIdx - 1];
|
||||
const y1 = delayBuffer[readIdx];
|
||||
const y2 = delayBuffer[readIdx + 1];
|
||||
const y3 = delayBuffer[readIdx + 2];
|
||||
|
||||
// Cubic interpolation for smooth pitch transitions
|
||||
const interpolated = this.cubicInterpolate(y0, y1, y2, y3, frac);
|
||||
output[i] = interpolated * amplitude;
|
||||
} else if (readIdx >= 0 && readIdx < delayBuffer.length) {
|
||||
output[i] = delayBuffer[readIdx] * amplitude;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply fade in/out to prevent clicks
|
||||
const fadeLength = Math.min(1000, Math.floor(length * 0.01));
|
||||
for (let i = 0; i < fadeLength; i++) {
|
||||
const fade = i / fadeLength;
|
||||
output[i] *= fade;
|
||||
output[length - 1 - i] *= fade;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private cubicInterpolate(y0: number, y1: number, y2: number, y3: number, x: number): number {
|
||||
// Catmull-Rom cubic interpolation for smooth sample interpolation
|
||||
const x2 = x * x;
|
||||
const x3 = x2 * x;
|
||||
|
||||
const a0 = -0.5 * y0 + 1.5 * y1 - 1.5 * y2 + 0.5 * y3;
|
||||
const a1 = y0 - 2.5 * y1 + 2 * y2 - 0.5 * y3;
|
||||
const a2 = -0.5 * y0 + 0.5 * y2;
|
||||
const a3 = y1;
|
||||
|
||||
return a0 * x3 + a1 * x2 + a2 * x + a3;
|
||||
}
|
||||
}
|
||||
190
src/lib/audio/processors/MicroPitch.ts
Normal file
190
src/lib/audio/processors/MicroPitch.ts
Normal file
@ -0,0 +1,190 @@
|
||||
import type { AudioProcessor } from './AudioProcessor';
|
||||
|
||||
export class MicroPitch implements AudioProcessor {
|
||||
getName(): string {
|
||||
return 'Micro Pitch';
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return 'Applies subtle random pitch variations for analog warmth and character';
|
||||
}
|
||||
|
||||
process(
|
||||
leftChannel: Float32Array,
|
||||
rightChannel: Float32Array
|
||||
): [Float32Array, Float32Array] {
|
||||
const length = leftChannel.length;
|
||||
const sampleRate = 44100;
|
||||
|
||||
// Random parameters for pitch variation
|
||||
const maxCents = 1 + Math.random() * 4; // 1-5 cents maximum deviation
|
||||
const lfoCount = 2 + Math.floor(Math.random() * 2); // 2-3 LFOs per channel
|
||||
const stereoSpread = 0.3 + Math.random() * 0.7; // 30-100% stereo difference
|
||||
|
||||
// Process each channel independently for stereo variation
|
||||
const outputLeft = this.processMicroPitchChannel(
|
||||
leftChannel,
|
||||
length,
|
||||
sampleRate,
|
||||
maxCents,
|
||||
lfoCount,
|
||||
Math.random() // Random seed for left channel
|
||||
);
|
||||
|
||||
const outputRight = this.processMicroPitchChannel(
|
||||
rightChannel,
|
||||
length,
|
||||
sampleRate,
|
||||
maxCents * (1 - stereoSpread * 0.5 + stereoSpread * Math.random()),
|
||||
lfoCount,
|
||||
Math.random() // Different seed for right channel
|
||||
);
|
||||
|
||||
return [outputLeft, outputRight];
|
||||
}
|
||||
|
||||
private processMicroPitchChannel(
|
||||
input: Float32Array,
|
||||
length: number,
|
||||
sampleRate: number,
|
||||
maxCents: number,
|
||||
lfoCount: number,
|
||||
seed: number
|
||||
): Float32Array {
|
||||
const output = new Float32Array(length);
|
||||
|
||||
// Initialize multiple LFOs with random parameters
|
||||
const lfos: Array<{
|
||||
frequency: number;
|
||||
phase: number;
|
||||
amplitude: number;
|
||||
type: 'sine' | 'triangle' | 'random-walk';
|
||||
}> = [];
|
||||
|
||||
for (let i = 0; i < lfoCount; i++) {
|
||||
const randomValue = this.pseudoRandom(seed + i * 0.137);
|
||||
lfos.push({
|
||||
frequency: 0.1 + randomValue * 2, // 0.1-2.1 Hz
|
||||
phase: this.pseudoRandom(seed + i * 0.271) * Math.PI * 2,
|
||||
amplitude: 0.3 + this.pseudoRandom(seed + i * 0.419) * 0.7,
|
||||
type: this.pseudoRandom(seed + i * 0.613) > 0.6 ? 'triangle' :
|
||||
this.pseudoRandom(seed + i * 0.613) > 0.3 ? 'random-walk' : 'sine'
|
||||
});
|
||||
}
|
||||
|
||||
// Random walk state for smooth random variations
|
||||
let randomWalkValue = 0;
|
||||
let randomWalkTarget = 0;
|
||||
const randomWalkSmooth = 0.999; // Very slow changes
|
||||
|
||||
// Delay buffer for pitch shifting
|
||||
const maxDelayMs = 50; // Maximum delay in milliseconds
|
||||
const maxDelaySamples = Math.ceil(maxDelayMs * sampleRate / 1000);
|
||||
const delayBuffer = new Float32Array(length + maxDelaySamples * 2);
|
||||
|
||||
// Copy input to delay buffer with padding
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
delayBuffer[i + maxDelaySamples] = input[i];
|
||||
}
|
||||
|
||||
// State for smooth delay interpolation
|
||||
let currentDelay = 0;
|
||||
const delaySmoothing = 0.9995; // Very smooth delay changes to avoid artifacts
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const time = i / sampleRate;
|
||||
|
||||
// Calculate combined pitch deviation from all LFOs
|
||||
let pitchDeviation = 0;
|
||||
|
||||
for (const lfo of lfos) {
|
||||
let lfoValue = 0;
|
||||
|
||||
if (lfo.type === 'sine') {
|
||||
lfoValue = Math.sin(2 * Math.PI * lfo.frequency * time + lfo.phase);
|
||||
} else if (lfo.type === 'triangle') {
|
||||
const phase = (lfo.frequency * time + lfo.phase / (2 * Math.PI)) % 1;
|
||||
lfoValue = 4 * Math.abs(phase - 0.5) - 1;
|
||||
} else if (lfo.type === 'random-walk') {
|
||||
// Update random walk every ~100 samples
|
||||
if (i % 100 === 0) {
|
||||
randomWalkTarget = (this.pseudoRandom(seed + i * 0.00001) - 0.5) * 2;
|
||||
}
|
||||
randomWalkValue = randomWalkValue * randomWalkSmooth +
|
||||
randomWalkTarget * (1 - randomWalkSmooth);
|
||||
lfoValue = randomWalkValue;
|
||||
}
|
||||
|
||||
pitchDeviation += lfoValue * lfo.amplitude;
|
||||
}
|
||||
|
||||
// Normalize and scale pitch deviation
|
||||
pitchDeviation = (pitchDeviation / lfoCount) * (maxCents / 100);
|
||||
|
||||
// Convert cents to pitch ratio (very small changes)
|
||||
const pitchRatio = Math.pow(2, pitchDeviation / 12);
|
||||
|
||||
// Calculate time-varying delay for pitch shift
|
||||
const targetDelay = (pitchRatio - 1) * 1000; // Scaled for subtle effect
|
||||
currentDelay = currentDelay * delaySmoothing + targetDelay * (1 - delaySmoothing);
|
||||
|
||||
// Read from delay buffer with high-quality interpolation
|
||||
const readPos = i + maxDelaySamples + currentDelay;
|
||||
const readIdx = Math.floor(readPos);
|
||||
const frac = readPos - readIdx;
|
||||
|
||||
if (readIdx >= 2 && readIdx < delayBuffer.length - 3) {
|
||||
// 4-point Hermite interpolation for highest quality
|
||||
const y0 = delayBuffer[readIdx - 1];
|
||||
const y1 = delayBuffer[readIdx];
|
||||
const y2 = delayBuffer[readIdx + 1];
|
||||
const y3 = delayBuffer[readIdx + 2];
|
||||
|
||||
output[i] = this.hermiteInterpolate(y0, y1, y2, y3, frac);
|
||||
} else if (readIdx >= 0 && readIdx < delayBuffer.length - 1) {
|
||||
// Linear interpolation fallback at boundaries
|
||||
const y1 = delayBuffer[readIdx];
|
||||
const y2 = readIdx + 1 < delayBuffer.length ? delayBuffer[readIdx + 1] : y1;
|
||||
output[i] = y1 + (y2 - y1) * frac;
|
||||
} else if (readIdx >= 0 && readIdx < delayBuffer.length) {
|
||||
output[i] = delayBuffer[readIdx];
|
||||
}
|
||||
}
|
||||
|
||||
// Apply very subtle crossfade at boundaries to ensure smoothness
|
||||
const fadeLength = Math.min(100, Math.floor(length * 0.002));
|
||||
for (let i = 0; i < fadeLength; i++) {
|
||||
const fade = i / fadeLength;
|
||||
const smoothFade = 0.5 - 0.5 * Math.cos(Math.PI * fade);
|
||||
output[i] = input[i] * (1 - smoothFade) + output[i] * smoothFade;
|
||||
const endIdx = length - 1 - i;
|
||||
output[endIdx] = input[endIdx] * (1 - smoothFade) + output[endIdx] * smoothFade;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private hermiteInterpolate(y0: number, y1: number, y2: number, y3: number, x: number): number {
|
||||
// 4-point Hermite interpolation for smooth, high-quality interpolation
|
||||
const x2 = x * x;
|
||||
const x3 = x2 * x;
|
||||
|
||||
// Hermite basis functions
|
||||
const h00 = 2 * x3 - 3 * x2 + 1;
|
||||
const h10 = x3 - 2 * x2 + x;
|
||||
const h01 = -2 * x3 + 3 * x2;
|
||||
const h11 = x3 - x2;
|
||||
|
||||
// Tangents (using Catmull-Rom)
|
||||
const m0 = 0.5 * (y2 - y0);
|
||||
const m1 = 0.5 * (y3 - y1);
|
||||
|
||||
return h00 * y1 + h10 * m0 + h01 * y2 + h11 * m1;
|
||||
}
|
||||
|
||||
private pseudoRandom(seed: number): number {
|
||||
// Simple deterministic pseudo-random number generator
|
||||
const x = Math.sin(seed * 12.9898 + seed * 78.233) * 43758.5453;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
}
|
||||
64
src/lib/audio/processors/PanShuffler.ts
Normal file
64
src/lib/audio/processors/PanShuffler.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import type { AudioProcessor } from './AudioProcessor';
|
||||
|
||||
export class PanShuffler implements AudioProcessor {
|
||||
getName(): string {
|
||||
return 'Pan Shuffler';
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return 'Smoothly pans segments across the stereo field';
|
||||
}
|
||||
|
||||
process(
|
||||
leftChannel: Float32Array,
|
||||
rightChannel: Float32Array
|
||||
): [Float32Array, Float32Array] {
|
||||
const length = leftChannel.length;
|
||||
const numSegments = Math.floor(Math.random() * 8) + 4;
|
||||
const segmentSize = Math.floor(length / numSegments);
|
||||
const transitionLength = Math.min(4410, Math.floor(segmentSize / 4));
|
||||
|
||||
const outputLeft = new Float32Array(length);
|
||||
const outputRight = new Float32Array(length);
|
||||
|
||||
// Generate random pan positions from -1 (left) to 1 (right)
|
||||
const panPositions: number[] = [];
|
||||
for (let i = 0; i < numSegments; i++) {
|
||||
panPositions.push(Math.random() * 2 - 1);
|
||||
}
|
||||
|
||||
let writePos = 0;
|
||||
for (let i = 0; i < numSegments; i++) {
|
||||
const start = i * segmentSize;
|
||||
const end = i === numSegments - 1 ? length : (i + 1) * segmentSize;
|
||||
const segLen = end - start;
|
||||
|
||||
const currentPan = panPositions[i];
|
||||
const nextPan = i < numSegments - 1 ? panPositions[i + 1] : currentPan;
|
||||
|
||||
for (let j = 0; j < segLen && writePos < length; j++, writePos++) {
|
||||
let pan = currentPan;
|
||||
|
||||
// Smooth transition to next segment's pan position
|
||||
if (i < numSegments - 1 && j >= segLen - transitionLength) {
|
||||
const transitionPos = (j - (segLen - transitionLength)) / transitionLength;
|
||||
const smoothTransition = 0.5 * (1.0 - Math.cos(Math.PI * transitionPos));
|
||||
pan = currentPan + (nextPan - currentPan) * smoothTransition;
|
||||
}
|
||||
|
||||
// Constant-power panning
|
||||
const panAngle = (pan + 1) * 0.25 * Math.PI;
|
||||
const leftGain = Math.cos(panAngle);
|
||||
const rightGain = Math.sin(panAngle);
|
||||
|
||||
// Mix both channels and apply panning
|
||||
const mono = (leftChannel[start + j] + rightChannel[start + j]) * 0.5;
|
||||
|
||||
outputLeft[writePos] = mono * leftGain;
|
||||
outputRight[writePos] = mono * rightGain;
|
||||
}
|
||||
}
|
||||
|
||||
return [outputLeft, outputRight];
|
||||
}
|
||||
}
|
||||
160
src/lib/audio/processors/PitchShifter.ts
Normal file
160
src/lib/audio/processors/PitchShifter.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import type { AudioProcessor } from './AudioProcessor';
|
||||
|
||||
export class PitchShifter implements AudioProcessor {
|
||||
getName(): string {
|
||||
return 'Pitch Shifter';
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return 'Transposes audio up or down in semitones without changing duration';
|
||||
}
|
||||
|
||||
process(
|
||||
leftChannel: Float32Array,
|
||||
rightChannel: Float32Array
|
||||
): [Float32Array, Float32Array] {
|
||||
const length = leftChannel.length;
|
||||
|
||||
// Random pitch shift in semitones (-12 to +12)
|
||||
const semitones = (Math.random() - 0.5) * 24;
|
||||
const pitchRatio = Math.pow(2, semitones / 12);
|
||||
|
||||
// Phase vocoder parameters
|
||||
const frameSize = 2048;
|
||||
const hopSize = frameSize / 4;
|
||||
|
||||
const outputLeft = this.pitchShiftPSOLA(leftChannel, pitchRatio, frameSize, hopSize);
|
||||
const outputRight = this.pitchShiftPSOLA(rightChannel, pitchRatio, frameSize, hopSize);
|
||||
|
||||
// Ensure output matches input length exactly
|
||||
const finalLeft = new Float32Array(length);
|
||||
const finalRight = new Float32Array(length);
|
||||
|
||||
const copyLength = Math.min(length, outputLeft.length);
|
||||
for (let i = 0; i < copyLength; i++) {
|
||||
finalLeft[i] = outputLeft[i];
|
||||
finalRight[i] = outputRight[i];
|
||||
}
|
||||
|
||||
// Normalize to prevent clipping
|
||||
const maxAmp = this.findMaxAmplitude(finalLeft, finalRight);
|
||||
if (maxAmp > 0.95) {
|
||||
const scale = 0.95 / maxAmp;
|
||||
for (let i = 0; i < length; i++) {
|
||||
finalLeft[i] *= scale;
|
||||
finalRight[i] *= scale;
|
||||
}
|
||||
}
|
||||
|
||||
return [finalLeft, finalRight];
|
||||
}
|
||||
|
||||
private pitchShiftPSOLA(
|
||||
input: Float32Array,
|
||||
pitchRatio: number,
|
||||
frameSize: number,
|
||||
hopSize: number
|
||||
): Float32Array {
|
||||
const length = input.length;
|
||||
const output = new Float32Array(length + frameSize * 2);
|
||||
|
||||
// Analysis hop size (input reading)
|
||||
const analysisHop = hopSize;
|
||||
// Synthesis hop size (output writing) - this maintains duration
|
||||
const synthesisHop = hopSize;
|
||||
|
||||
let analysisPos = 0;
|
||||
let synthesisPos = 0;
|
||||
let phase = 0;
|
||||
|
||||
while (analysisPos + frameSize < length && synthesisPos + frameSize < output.length) {
|
||||
// Extract frame with windowing
|
||||
const frame = new Float32Array(frameSize);
|
||||
for (let i = 0; i < frameSize; i++) {
|
||||
const window = this.hannWindow(i / frameSize);
|
||||
const inputIdx = Math.floor(analysisPos + i);
|
||||
if (inputIdx < length) {
|
||||
frame[i] = input[inputIdx] * window;
|
||||
}
|
||||
}
|
||||
|
||||
// Resample frame to achieve pitch shift
|
||||
const resampledFrame = this.resampleFrame(frame, pitchRatio);
|
||||
|
||||
// Overlap-add to output
|
||||
for (let i = 0; i < resampledFrame.length; i++) {
|
||||
const outputIdx = Math.floor(synthesisPos + i);
|
||||
if (outputIdx < output.length) {
|
||||
output[outputIdx] += resampledFrame[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Update positions
|
||||
// Key insight: read position moves by pitchRatio to get correct pitch
|
||||
// Write position moves by fixed hop to maintain duration
|
||||
analysisPos += analysisHop * pitchRatio;
|
||||
synthesisPos += synthesisHop;
|
||||
|
||||
// Wrap read position if needed (for extreme pitch down)
|
||||
if (analysisPos >= length - frameSize && synthesisPos < length / 2) {
|
||||
analysisPos = analysisPos % (length - frameSize);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private resampleFrame(frame: Float32Array, pitchRatio: number): Float32Array {
|
||||
// Resample the frame to original size but with pitch shift
|
||||
const outputSize = frame.length;
|
||||
const output = new Float32Array(outputSize);
|
||||
|
||||
for (let i = 0; i < outputSize; i++) {
|
||||
// Read from frame at pitch-shifted position
|
||||
const srcPos = i * pitchRatio;
|
||||
const srcIdx = Math.floor(srcPos);
|
||||
const frac = srcPos - srcIdx;
|
||||
|
||||
if (srcIdx < frame.length - 1) {
|
||||
// Cubic interpolation for better quality
|
||||
const y0 = srcIdx > 0 ? frame[srcIdx - 1] : frame[0];
|
||||
const y1 = frame[srcIdx];
|
||||
const y2 = frame[srcIdx + 1];
|
||||
const y3 = srcIdx < frame.length - 2 ? frame[srcIdx + 2] : frame[frame.length - 1];
|
||||
|
||||
output[i] = this.cubicInterpolate(y0, y1, y2, y3, frac);
|
||||
} else if (srcIdx < frame.length) {
|
||||
output[i] = frame[srcIdx];
|
||||
}
|
||||
}
|
||||
|
||||
// Apply window again to smooth the resampled frame
|
||||
for (let i = 0; i < outputSize; i++) {
|
||||
output[i] *= this.hannWindow(i / outputSize);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private cubicInterpolate(y0: number, y1: number, y2: number, y3: number, x: number): number {
|
||||
// Catmull-Rom cubic interpolation
|
||||
const x2 = x * x;
|
||||
const x3 = x2 * x;
|
||||
|
||||
return y1 + 0.5 * x * (y2 - y0 +
|
||||
x * (2 * y0 - 5 * y1 + 4 * y2 - y3 +
|
||||
x * (3 * (y1 - y2) + y3 - y0)));
|
||||
}
|
||||
|
||||
private hannWindow(position: number): number {
|
||||
return 0.5 - 0.5 * Math.cos(2 * Math.PI * position);
|
||||
}
|
||||
|
||||
private findMaxAmplitude(left: Float32Array, right: Float32Array): number {
|
||||
let max = 0;
|
||||
for (let i = 0; i < left.length; i++) {
|
||||
max = Math.max(max, Math.abs(left[i]), Math.abs(right[i]));
|
||||
}
|
||||
return max;
|
||||
}
|
||||
}
|
||||
114
src/lib/audio/processors/PitchWobble.ts
Normal file
114
src/lib/audio/processors/PitchWobble.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import type { AudioProcessor } from './AudioProcessor';
|
||||
|
||||
export class PitchWobble implements AudioProcessor {
|
||||
getName(): string {
|
||||
return 'Pitch Wobble';
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return 'Variable-rate playback with LFO modulation for tape wow/vibrato effects';
|
||||
}
|
||||
|
||||
process(
|
||||
leftChannel: Float32Array,
|
||||
rightChannel: Float32Array
|
||||
): [Float32Array, Float32Array] {
|
||||
const length = leftChannel.length;
|
||||
const sampleRate = 44100;
|
||||
|
||||
// Random parameters
|
||||
const lfoRate = 0.5 + Math.random() * 4.5; // 0.5-5 Hz
|
||||
const depth = 0.001 + Math.random() * 0.019; // 0.1% to 2% pitch deviation
|
||||
const lfoShape = Math.random(); // 0-1, controls sine vs triangle mix
|
||||
|
||||
// Secondary LFO for more organic movement
|
||||
const lfo2Rate = lfoRate * (2 + Math.random() * 3);
|
||||
const lfo2Depth = depth * 0.3;
|
||||
|
||||
const outputLeft = new Float32Array(length);
|
||||
const outputRight = new Float32Array(length);
|
||||
|
||||
// Delay line size - enough for max pitch deviation
|
||||
const maxDelay = Math.ceil(sampleRate * 0.1); // 100ms max delay
|
||||
const delayLineL = new Float32Array(length + maxDelay);
|
||||
const delayLineR = new Float32Array(length + maxDelay);
|
||||
|
||||
// Copy input to delay lines with padding
|
||||
for (let i = 0; i < length; i++) {
|
||||
delayLineL[i + maxDelay / 2] = leftChannel[i];
|
||||
delayLineR[i + maxDelay / 2] = rightChannel[i];
|
||||
}
|
||||
|
||||
// Phase accumulator for continuous phase
|
||||
let readPos = maxDelay / 2;
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const t = i / sampleRate;
|
||||
|
||||
// Primary LFO - mix of sine and triangle
|
||||
const sineLfo = Math.sin(2 * Math.PI * lfoRate * t);
|
||||
const triangleLfo = 2 * Math.abs(2 * ((lfoRate * t) % 1) - 1) - 1;
|
||||
const lfo1 = sineLfo * (1 - lfoShape) + triangleLfo * lfoShape;
|
||||
|
||||
// Secondary LFO for complexity
|
||||
const lfo2 = Math.sin(2 * Math.PI * lfo2Rate * t + Math.PI / 3);
|
||||
|
||||
// Combined modulation
|
||||
const pitchMod = 1 + (lfo1 * depth + lfo2 * lfo2Depth);
|
||||
|
||||
// Variable playback rate
|
||||
const currentRate = pitchMod;
|
||||
|
||||
// Cubic interpolation for smooth pitch changes
|
||||
outputLeft[i] = this.cubicInterpolate(delayLineL, readPos);
|
||||
outputRight[i] = this.cubicInterpolate(delayLineR, readPos);
|
||||
|
||||
// Advance read position with variable rate
|
||||
readPos += currentRate;
|
||||
}
|
||||
|
||||
// Soft limiting to prevent clipping
|
||||
for (let i = 0; i < length; i++) {
|
||||
outputLeft[i] = this.softClip(outputLeft[i]);
|
||||
outputRight[i] = this.softClip(outputRight[i]);
|
||||
}
|
||||
|
||||
return [outputLeft, outputRight];
|
||||
}
|
||||
|
||||
private cubicInterpolate(buffer: Float32Array, position: number): number {
|
||||
const index = Math.floor(position);
|
||||
const fraction = position - index;
|
||||
|
||||
// Bounds checking
|
||||
if (index < 1 || index >= buffer.length - 2) {
|
||||
return buffer[Math.min(Math.max(index, 0), buffer.length - 1)];
|
||||
}
|
||||
|
||||
const y0 = buffer[index - 1];
|
||||
const y1 = buffer[index];
|
||||
const y2 = buffer[index + 1];
|
||||
const y3 = buffer[index + 2];
|
||||
|
||||
// Cubic Hermite interpolation
|
||||
const a0 = y3 - y2 - y0 + y1;
|
||||
const a1 = y0 - y1 - a0;
|
||||
const a2 = y2 - y0;
|
||||
const a3 = y1;
|
||||
|
||||
const fraction2 = fraction * fraction;
|
||||
const fraction3 = fraction2 * fraction;
|
||||
|
||||
return a0 * fraction3 + a1 * fraction2 + a2 * fraction + a3;
|
||||
}
|
||||
|
||||
private softClip(sample: number): number {
|
||||
const threshold = 0.95;
|
||||
if (Math.abs(sample) < threshold) {
|
||||
return sample;
|
||||
}
|
||||
const sign = sample > 0 ? 1 : -1;
|
||||
const excess = Math.abs(sample) - threshold;
|
||||
return sign * (threshold + Math.tanh(excess * 2) * (1 - threshold));
|
||||
}
|
||||
}
|
||||
27
src/lib/audio/processors/Reverser.ts
Normal file
27
src/lib/audio/processors/Reverser.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import type { AudioProcessor } from './AudioProcessor';
|
||||
|
||||
export class Reverser implements AudioProcessor {
|
||||
getName(): string {
|
||||
return 'Reverser';
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return 'Plays the sound backwards';
|
||||
}
|
||||
|
||||
process(
|
||||
leftChannel: Float32Array,
|
||||
rightChannel: Float32Array
|
||||
): [Float32Array, Float32Array] {
|
||||
const length = leftChannel.length;
|
||||
const outputLeft = new Float32Array(length);
|
||||
const outputRight = new Float32Array(length);
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
outputLeft[i] = leftChannel[length - 1 - i];
|
||||
outputRight[i] = rightChannel[length - 1 - i];
|
||||
}
|
||||
|
||||
return [outputLeft, outputRight];
|
||||
}
|
||||
}
|
||||
116
src/lib/audio/processors/SegmentShuffler.ts
Normal file
116
src/lib/audio/processors/SegmentShuffler.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import type { AudioProcessor } from './AudioProcessor';
|
||||
|
||||
export class SegmentShuffler implements AudioProcessor {
|
||||
getName(): string {
|
||||
return 'Segment Shuffler';
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return 'Randomly reorganizes and swaps parts of the sound';
|
||||
}
|
||||
|
||||
process(
|
||||
leftChannel: Float32Array,
|
||||
rightChannel: Float32Array
|
||||
): [Float32Array, Float32Array] {
|
||||
const length = leftChannel.length;
|
||||
const numSegments = Math.floor(Math.random() * 13) + 4;
|
||||
const segmentSize = Math.floor(length / numSegments);
|
||||
|
||||
// Crossfade length: 441 samples (10ms at 44100Hz) - aggressive smoothing
|
||||
// Using longer fades to ensure no clicks even with very abrupt transitions
|
||||
const crossfadeLength = 441;
|
||||
|
||||
const segments: Array<{ left: Float32Array; right: Float32Array }> = [];
|
||||
for (let i = 0; i < numSegments; i++) {
|
||||
const start = i * segmentSize;
|
||||
const end = i === numSegments - 1 ? length : (i + 1) * segmentSize;
|
||||
segments.push({
|
||||
left: leftChannel.slice(start, end),
|
||||
right: rightChannel.slice(start, end)
|
||||
});
|
||||
}
|
||||
|
||||
this.shuffleArray(segments);
|
||||
|
||||
const outputLeft = new Float32Array(length);
|
||||
const outputRight = new Float32Array(length);
|
||||
|
||||
let writePos = 0;
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i];
|
||||
const segLen = segment.left.length;
|
||||
const isFirstSegment = i === 0;
|
||||
const isLastSegment = i === segments.length - 1;
|
||||
|
||||
// Calculate actual fade length - limit to segment length to prevent overlap issues
|
||||
// If segment is very short, use a single Hann window over entire segment
|
||||
let actualFadeIn = isFirstSegment ? 0 : Math.min(crossfadeLength, Math.floor(segLen / 2));
|
||||
let actualFadeOut = isLastSegment ? 0 : Math.min(crossfadeLength, Math.floor(segLen / 2));
|
||||
|
||||
// Check if fades would overlap - if so, reduce them proportionally
|
||||
if (actualFadeIn + actualFadeOut > segLen) {
|
||||
const scale = segLen / (actualFadeIn + actualFadeOut);
|
||||
actualFadeIn = Math.floor(actualFadeIn * scale);
|
||||
actualFadeOut = Math.floor(actualFadeOut * scale);
|
||||
}
|
||||
|
||||
for (let j = 0; j < segLen && writePos < length; j++, writePos++) {
|
||||
let leftSample = segment.left[j];
|
||||
let rightSample = segment.right[j];
|
||||
let gain = 1.0;
|
||||
|
||||
// Raised cosine (Hann) fade-in at segment start
|
||||
// Formula: 0.5 * (1 - cos(π * t)) where t goes from 0 to 1
|
||||
// This has continuous first and second derivatives (C² continuity)
|
||||
if (actualFadeIn > 0 && j < actualFadeIn) {
|
||||
const t = j / actualFadeIn;
|
||||
gain *= 0.5 * (1.0 - Math.cos(Math.PI * t));
|
||||
}
|
||||
|
||||
// Raised cosine (Hann) fade-out at segment end
|
||||
// Formula: 0.5 * (1 + cos(π * t)) where t goes from 0 to 1
|
||||
// This mirrors the fade-in curve for symmetry
|
||||
if (actualFadeOut > 0 && j >= segLen - actualFadeOut) {
|
||||
const samplesIntoFadeOut = j - (segLen - actualFadeOut);
|
||||
const t = samplesIntoFadeOut / actualFadeOut;
|
||||
gain *= 0.5 * (1.0 + Math.cos(Math.PI * t));
|
||||
}
|
||||
|
||||
outputLeft[writePos] = leftSample * gain;
|
||||
outputRight[writePos] = rightSample * gain;
|
||||
}
|
||||
}
|
||||
|
||||
// Master fade in/out using raised cosine for buffer boundaries
|
||||
// This ensures the entire buffer starts and ends at zero
|
||||
const masterFadeLength = 441;
|
||||
|
||||
// Raised cosine fade-in at buffer start
|
||||
for (let i = 0; i < Math.min(masterFadeLength, length); i++) {
|
||||
const t = i / masterFadeLength;
|
||||
const gain = 0.5 * (1.0 - Math.cos(Math.PI * t));
|
||||
outputLeft[i] *= gain;
|
||||
outputRight[i] *= gain;
|
||||
}
|
||||
|
||||
// Raised cosine fade-out at buffer end
|
||||
for (let i = 0; i < Math.min(masterFadeLength, length); i++) {
|
||||
const pos = length - 1 - i;
|
||||
const samplesIntoFadeOut = i;
|
||||
const t = samplesIntoFadeOut / masterFadeLength;
|
||||
const gain = 0.5 * (1.0 + Math.cos(Math.PI * t));
|
||||
outputLeft[pos] *= gain;
|
||||
outputRight[pos] *= gain;
|
||||
}
|
||||
|
||||
return [outputLeft, outputRight];
|
||||
}
|
||||
|
||||
private shuffleArray<T>(array: T[]): void {
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[array[i], array[j]] = [array[j], array[i]];
|
||||
}
|
||||
}
|
||||
}
|
||||
456
src/lib/audio/processors/SpectralBlur.ts
Normal file
456
src/lib/audio/processors/SpectralBlur.ts
Normal file
@ -0,0 +1,456 @@
|
||||
import type { AudioProcessor } from './AudioProcessor';
|
||||
|
||||
export class SpectralBlur implements AudioProcessor {
|
||||
getName(): string {
|
||||
return 'Spectral Blur';
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return 'Smears frequency content across neighboring bins for dreamy, diffused textures';
|
||||
}
|
||||
|
||||
process(
|
||||
leftChannel: Float32Array,
|
||||
rightChannel: Float32Array
|
||||
): [Float32Array, Float32Array] {
|
||||
const length = leftChannel.length;
|
||||
|
||||
// Random parameters
|
||||
const blurAmount = 0.3 + Math.random() * 0.6; // 0.3 to 0.9
|
||||
const frequencyRangeLow = Math.random() * 0.2; // 0 to 0.2 (low freq cutoff)
|
||||
const frequencyRangeHigh = 0.5 + Math.random() * 0.5; // 0.5 to 1.0 (high freq cutoff)
|
||||
|
||||
// FFT parameters for quality spectral processing
|
||||
const fftSize = 4096;
|
||||
const hopSize = Math.floor(fftSize * 0.25); // 75% overlap
|
||||
const overlap = fftSize - hopSize;
|
||||
|
||||
const outputLeft = this.processChannel(
|
||||
leftChannel,
|
||||
fftSize,
|
||||
hopSize,
|
||||
blurAmount,
|
||||
frequencyRangeLow,
|
||||
frequencyRangeHigh
|
||||
);
|
||||
const outputRight = this.processChannel(
|
||||
rightChannel,
|
||||
fftSize,
|
||||
hopSize,
|
||||
blurAmount,
|
||||
frequencyRangeLow,
|
||||
frequencyRangeHigh
|
||||
);
|
||||
|
||||
// Ensure output matches input length
|
||||
const finalLeft = new Float32Array(length);
|
||||
const finalRight = new Float32Array(length);
|
||||
|
||||
const copyLength = Math.min(length, outputLeft.length);
|
||||
for (let i = 0; i < copyLength; i++) {
|
||||
finalLeft[i] = outputLeft[i];
|
||||
finalRight[i] = outputRight[i];
|
||||
}
|
||||
|
||||
// Match output RMS to input RMS to maintain perceived loudness
|
||||
const inputRMS = this.calculateRMS(leftChannel, rightChannel);
|
||||
const outputRMS = this.calculateRMS(finalLeft, finalRight);
|
||||
|
||||
if (outputRMS > 0.0001) {
|
||||
const rmsScale = inputRMS / outputRMS;
|
||||
for (let i = 0; i < length; i++) {
|
||||
finalLeft[i] *= rmsScale;
|
||||
finalRight[i] *= rmsScale;
|
||||
}
|
||||
}
|
||||
|
||||
// Safety limiter to prevent clipping
|
||||
const maxAmp = this.findMaxAmplitude(finalLeft, finalRight);
|
||||
if (maxAmp > 0.99) {
|
||||
const scale = 0.99 / maxAmp;
|
||||
for (let i = 0; i < length; i++) {
|
||||
finalLeft[i] *= scale;
|
||||
finalRight[i] *= scale;
|
||||
}
|
||||
}
|
||||
|
||||
return [finalLeft, finalRight];
|
||||
}
|
||||
|
||||
private processChannel(
|
||||
input: Float32Array,
|
||||
fftSize: number,
|
||||
hopSize: number,
|
||||
blurAmount: number,
|
||||
freqRangeLow: number,
|
||||
freqRangeHigh: number
|
||||
): Float32Array {
|
||||
const length = input.length;
|
||||
const output = new Float32Array(length + fftSize);
|
||||
const window = this.createHannWindow(fftSize);
|
||||
|
||||
// Calculate COLA normalization factor for 75% overlap with Hann window
|
||||
// With hopSize = fftSize * 0.25, we have 4x overlap
|
||||
const overlap = fftSize / hopSize;
|
||||
const colaNorm = this.calculateCOLANorm(window, hopSize);
|
||||
|
||||
let inputPos = 0;
|
||||
let outputPos = 0;
|
||||
|
||||
while (inputPos + fftSize <= length) {
|
||||
// Extract windowed frame
|
||||
const frame = new Float32Array(fftSize);
|
||||
for (let i = 0; i < fftSize; i++) {
|
||||
frame[i] = input[inputPos + i] * window[i];
|
||||
}
|
||||
|
||||
// FFT
|
||||
const spectrum = this.fft(frame);
|
||||
|
||||
// Apply spectral blur
|
||||
const blurredSpectrum = this.applySpectralBlur(
|
||||
spectrum,
|
||||
blurAmount,
|
||||
freqRangeLow,
|
||||
freqRangeHigh
|
||||
);
|
||||
|
||||
// IFFT
|
||||
const processedFrame = this.ifft(blurredSpectrum);
|
||||
|
||||
// Overlap-add with proper gain compensation
|
||||
for (let i = 0; i < fftSize; i++) {
|
||||
output[outputPos + i] += processedFrame[i].real * colaNorm;
|
||||
}
|
||||
|
||||
inputPos += hopSize;
|
||||
outputPos += hopSize;
|
||||
}
|
||||
|
||||
// Process remaining samples if any
|
||||
if (inputPos < length) {
|
||||
const remainingSamples = length - inputPos;
|
||||
const frame = new Float32Array(fftSize);
|
||||
|
||||
for (let i = 0; i < remainingSamples; i++) {
|
||||
frame[i] = input[inputPos + i] * window[i];
|
||||
}
|
||||
|
||||
const spectrum = this.fft(frame);
|
||||
const blurredSpectrum = this.applySpectralBlur(
|
||||
spectrum,
|
||||
blurAmount,
|
||||
freqRangeLow,
|
||||
freqRangeHigh
|
||||
);
|
||||
const processedFrame = this.ifft(blurredSpectrum);
|
||||
|
||||
for (let i = 0; i < fftSize; i++) {
|
||||
if (outputPos + i < output.length) {
|
||||
output[outputPos + i] += processedFrame[i].real * colaNorm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private applySpectralBlur(
|
||||
spectrum: Complex[],
|
||||
blurAmount: number,
|
||||
freqRangeLow: number,
|
||||
freqRangeHigh: number
|
||||
): Complex[] {
|
||||
const result = new Array<Complex>(spectrum.length);
|
||||
const halfSize = Math.floor(spectrum.length / 2);
|
||||
|
||||
// Create gaussian blur kernel
|
||||
const kernelSize = Math.floor(blurAmount * 20) | 1; // Ensure odd
|
||||
const kernel = this.createGaussianKernel(kernelSize, blurAmount * 5);
|
||||
const kernelCenter = Math.floor(kernelSize / 2);
|
||||
|
||||
// Calculate frequency bin range to process
|
||||
const binLow = Math.floor(freqRangeLow * halfSize);
|
||||
const binHigh = Math.floor(freqRangeHigh * halfSize);
|
||||
|
||||
// Process positive frequencies
|
||||
for (let i = 0; i < halfSize; i++) {
|
||||
if (i >= binLow && i <= binHigh) {
|
||||
// Apply blur in the specified frequency range
|
||||
let realSum = 0;
|
||||
let imagSum = 0;
|
||||
let weightSum = 0;
|
||||
|
||||
for (let k = 0; k < kernelSize; k++) {
|
||||
const binIdx = i + k - kernelCenter;
|
||||
if (binIdx >= 0 && binIdx < halfSize) {
|
||||
const weight = kernel[k];
|
||||
realSum += spectrum[binIdx].real * weight;
|
||||
imagSum += spectrum[binIdx].imag * weight;
|
||||
weightSum += weight;
|
||||
}
|
||||
}
|
||||
|
||||
if (weightSum > 0) {
|
||||
result[i] = {
|
||||
real: realSum / weightSum,
|
||||
imag: imagSum / weightSum
|
||||
};
|
||||
} else {
|
||||
result[i] = spectrum[i];
|
||||
}
|
||||
} else {
|
||||
// Keep original spectrum outside the blur range
|
||||
result[i] = spectrum[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror for negative frequencies (maintain Hermitian symmetry)
|
||||
for (let i = halfSize; i < spectrum.length; i++) {
|
||||
const mirrorIdx = spectrum.length - i;
|
||||
if (mirrorIdx > 0 && mirrorIdx < halfSize) {
|
||||
result[i] = {
|
||||
real: result[mirrorIdx].real,
|
||||
imag: -result[mirrorIdx].imag
|
||||
};
|
||||
} else {
|
||||
result[i] = spectrum[i];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private createGaussianKernel(size: number, sigma: number): Float32Array {
|
||||
const kernel = new Float32Array(size);
|
||||
const center = Math.floor(size / 2);
|
||||
let sum = 0;
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
const x = i - center;
|
||||
kernel[i] = Math.exp(-(x * x) / (2 * sigma * sigma));
|
||||
sum += kernel[i];
|
||||
}
|
||||
|
||||
// Normalize
|
||||
for (let i = 0; i < size; i++) {
|
||||
kernel[i] /= sum;
|
||||
}
|
||||
|
||||
return kernel;
|
||||
}
|
||||
|
||||
private createHannWindow(size: number): Float32Array {
|
||||
const window = new Float32Array(size);
|
||||
for (let i = 0; i < size; i++) {
|
||||
window[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (size - 1));
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
private calculateCOLANorm(window: Float32Array, hopSize: number): number {
|
||||
// Calculate the sum of overlapping windows at any point
|
||||
const fftSize = window.length;
|
||||
let windowSum = 0;
|
||||
|
||||
// Sum all overlapping windows at the center point
|
||||
for (let offset = 0; offset < fftSize; offset += hopSize) {
|
||||
const idx = Math.floor(fftSize / 2) - offset;
|
||||
if (idx >= 0 && idx < fftSize) {
|
||||
windowSum += window[idx] * window[idx];
|
||||
}
|
||||
}
|
||||
|
||||
// Also check overlap from the other direction
|
||||
for (let offset = hopSize; offset < fftSize; offset += hopSize) {
|
||||
const idx = Math.floor(fftSize / 2) + offset;
|
||||
if (idx >= 0 && idx < fftSize) {
|
||||
windowSum += window[idx] * window[idx];
|
||||
}
|
||||
}
|
||||
|
||||
return windowSum > 0 ? 1.0 / windowSum : 1.0;
|
||||
}
|
||||
|
||||
// Cooley-Tukey FFT implementation
|
||||
private fft(input: Float32Array): Complex[] {
|
||||
const n = input.length;
|
||||
const output: Complex[] = new Array(n);
|
||||
|
||||
// Initialize with input as real values
|
||||
for (let i = 0; i < n; i++) {
|
||||
output[i] = { real: input[i], imag: 0 };
|
||||
}
|
||||
|
||||
// Bit reversal
|
||||
const bits = Math.log2(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = this.bitReverse(i, bits);
|
||||
if (j > i) {
|
||||
const temp = output[i];
|
||||
output[i] = output[j];
|
||||
output[j] = temp;
|
||||
}
|
||||
}
|
||||
|
||||
// Cooley-Tukey butterfly operations
|
||||
for (let size = 2; size <= n; size *= 2) {
|
||||
const halfSize = size / 2;
|
||||
const angleStep = -2 * Math.PI / size;
|
||||
|
||||
for (let start = 0; start < n; start += size) {
|
||||
for (let i = 0; i < halfSize; i++) {
|
||||
const angle = angleStep * i;
|
||||
const twiddle = {
|
||||
real: Math.cos(angle),
|
||||
imag: Math.sin(angle)
|
||||
};
|
||||
|
||||
const evenIdx = start + i;
|
||||
const oddIdx = start + i + halfSize;
|
||||
|
||||
const even = output[evenIdx];
|
||||
const odd = output[oddIdx];
|
||||
|
||||
const twiddledOdd = {
|
||||
real: odd.real * twiddle.real - odd.imag * twiddle.imag,
|
||||
imag: odd.real * twiddle.imag + odd.imag * twiddle.real
|
||||
};
|
||||
|
||||
output[evenIdx] = {
|
||||
real: even.real + twiddledOdd.real,
|
||||
imag: even.imag + twiddledOdd.imag
|
||||
};
|
||||
|
||||
output[oddIdx] = {
|
||||
real: even.real - twiddledOdd.real,
|
||||
imag: even.imag - twiddledOdd.imag
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// Inverse FFT
|
||||
private ifft(input: Complex[]): Complex[] {
|
||||
const n = input.length;
|
||||
const output: Complex[] = new Array(n);
|
||||
|
||||
// Copy input
|
||||
for (let i = 0; i < n; i++) {
|
||||
output[i] = { real: input[i].real, imag: input[i].imag };
|
||||
}
|
||||
|
||||
// Conjugate
|
||||
for (let i = 0; i < n; i++) {
|
||||
output[i].imag = -output[i].imag;
|
||||
}
|
||||
|
||||
// Forward FFT
|
||||
const transformed = this.fftComplex(output);
|
||||
|
||||
// Conjugate and scale
|
||||
const scale = 1 / n;
|
||||
for (let i = 0; i < n; i++) {
|
||||
transformed[i].real *= scale;
|
||||
transformed[i].imag *= -scale;
|
||||
}
|
||||
|
||||
return transformed;
|
||||
}
|
||||
|
||||
// FFT for complex input (used by IFFT)
|
||||
private fftComplex(input: Complex[]): Complex[] {
|
||||
const n = input.length;
|
||||
const output: Complex[] = new Array(n);
|
||||
|
||||
// Copy input
|
||||
for (let i = 0; i < n; i++) {
|
||||
output[i] = { real: input[i].real, imag: input[i].imag };
|
||||
}
|
||||
|
||||
// Bit reversal
|
||||
const bits = Math.log2(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = this.bitReverse(i, bits);
|
||||
if (j > i) {
|
||||
const temp = output[i];
|
||||
output[i] = output[j];
|
||||
output[j] = temp;
|
||||
}
|
||||
}
|
||||
|
||||
// Cooley-Tukey butterfly operations
|
||||
for (let size = 2; size <= n; size *= 2) {
|
||||
const halfSize = size / 2;
|
||||
const angleStep = -2 * Math.PI / size;
|
||||
|
||||
for (let start = 0; start < n; start += size) {
|
||||
for (let i = 0; i < halfSize; i++) {
|
||||
const angle = angleStep * i;
|
||||
const twiddle = {
|
||||
real: Math.cos(angle),
|
||||
imag: Math.sin(angle)
|
||||
};
|
||||
|
||||
const evenIdx = start + i;
|
||||
const oddIdx = start + i + halfSize;
|
||||
|
||||
const even = output[evenIdx];
|
||||
const odd = output[oddIdx];
|
||||
|
||||
const twiddledOdd = {
|
||||
real: odd.real * twiddle.real - odd.imag * twiddle.imag,
|
||||
imag: odd.real * twiddle.imag + odd.imag * twiddle.real
|
||||
};
|
||||
|
||||
output[evenIdx] = {
|
||||
real: even.real + twiddledOdd.real,
|
||||
imag: even.imag + twiddledOdd.imag
|
||||
};
|
||||
|
||||
output[oddIdx] = {
|
||||
real: even.real - twiddledOdd.real,
|
||||
imag: even.imag - twiddledOdd.imag
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private bitReverse(n: number, bits: number): number {
|
||||
let reversed = 0;
|
||||
for (let i = 0; i < bits; i++) {
|
||||
reversed = (reversed << 1) | ((n >> i) & 1);
|
||||
}
|
||||
return reversed;
|
||||
}
|
||||
|
||||
private findMaxAmplitude(left: Float32Array, right: Float32Array): number {
|
||||
let max = 0;
|
||||
for (let i = 0; i < left.length; i++) {
|
||||
max = Math.max(max, Math.abs(left[i]), Math.abs(right[i]));
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
private calculateRMS(left: Float32Array, right: Float32Array): number {
|
||||
let sumSquares = 0;
|
||||
const length = left.length;
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
sumSquares += left[i] * left[i] + right[i] * right[i];
|
||||
}
|
||||
|
||||
return Math.sqrt(sumSquares / (2 * length));
|
||||
}
|
||||
}
|
||||
|
||||
interface Complex {
|
||||
real: number;
|
||||
imag: number;
|
||||
}
|
||||
427
src/lib/audio/processors/SpectralShift.ts
Normal file
427
src/lib/audio/processors/SpectralShift.ts
Normal file
@ -0,0 +1,427 @@
|
||||
import type { AudioProcessor } from './AudioProcessor';
|
||||
|
||||
export class SpectralShift implements AudioProcessor {
|
||||
getName(): string {
|
||||
return 'Spectral Shift';
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return 'Shifts all frequencies by a fixed Hz amount creating inharmonic, metallic timbres';
|
||||
}
|
||||
|
||||
process(
|
||||
leftChannel: Float32Array,
|
||||
rightChannel: Float32Array
|
||||
): [Float32Array, Float32Array] {
|
||||
const length = leftChannel.length;
|
||||
const sampleRate = 44100;
|
||||
|
||||
// Random parameters for frequency shift
|
||||
const shiftAmount = (Math.random() - 0.5) * 6000; // -3000 to +3000 Hz
|
||||
const feedbackAmount = Math.random() * 0.3; // 0 to 0.3 for subtle feedback
|
||||
const dryWet = 0.5 + Math.random() * 0.5; // 0.5 to 1.0 (always some effect)
|
||||
|
||||
// FFT parameters
|
||||
const fftSize = 2048;
|
||||
const hopSize = Math.floor(fftSize * 0.25); // 75% overlap
|
||||
|
||||
const outputLeft = this.processChannel(
|
||||
leftChannel,
|
||||
fftSize,
|
||||
hopSize,
|
||||
shiftAmount,
|
||||
feedbackAmount,
|
||||
dryWet,
|
||||
sampleRate
|
||||
);
|
||||
const outputRight = this.processChannel(
|
||||
rightChannel,
|
||||
fftSize,
|
||||
hopSize,
|
||||
shiftAmount * (0.9 + Math.random() * 0.2), // Slight stereo variation
|
||||
feedbackAmount,
|
||||
dryWet,
|
||||
sampleRate
|
||||
);
|
||||
|
||||
// Ensure output matches input length
|
||||
const finalLeft = new Float32Array(length);
|
||||
const finalRight = new Float32Array(length);
|
||||
|
||||
const copyLength = Math.min(length, outputLeft.length);
|
||||
for (let i = 0; i < copyLength; i++) {
|
||||
finalLeft[i] = outputLeft[i];
|
||||
finalRight[i] = outputRight[i];
|
||||
}
|
||||
|
||||
// Match output RMS to input RMS to maintain perceived loudness
|
||||
const inputRMS = this.calculateRMS(leftChannel, rightChannel);
|
||||
const outputRMS = this.calculateRMS(finalLeft, finalRight);
|
||||
|
||||
if (outputRMS > 0.0001) {
|
||||
const rmsScale = inputRMS / outputRMS;
|
||||
for (let i = 0; i < length; i++) {
|
||||
finalLeft[i] *= rmsScale;
|
||||
finalRight[i] *= rmsScale;
|
||||
}
|
||||
}
|
||||
|
||||
// Safety limiter to prevent clipping
|
||||
const maxAmp = this.findMaxAmplitude(finalLeft, finalRight);
|
||||
if (maxAmp > 0.99) {
|
||||
const scale = 0.99 / maxAmp;
|
||||
for (let i = 0; i < length; i++) {
|
||||
finalLeft[i] *= scale;
|
||||
finalRight[i] *= scale;
|
||||
}
|
||||
}
|
||||
|
||||
return [finalLeft, finalRight];
|
||||
}
|
||||
|
||||
private processChannel(
|
||||
input: Float32Array,
|
||||
fftSize: number,
|
||||
hopSize: number,
|
||||
shiftHz: number,
|
||||
feedbackAmount: number,
|
||||
dryWet: number,
|
||||
sampleRate: number
|
||||
): Float32Array {
|
||||
const length = input.length;
|
||||
const output = new Float32Array(length + fftSize);
|
||||
const window = this.createHannWindow(fftSize);
|
||||
const feedback = new Float32Array(fftSize);
|
||||
|
||||
// Calculate COLA normalization factor for 75% overlap with Hann window
|
||||
const colaNorm = this.calculateCOLANorm(window, hopSize);
|
||||
|
||||
// Convert Hz shift to bin shift
|
||||
const binShift = Math.round((shiftHz * fftSize) / sampleRate);
|
||||
|
||||
let inputPos = 0;
|
||||
let outputPos = 0;
|
||||
|
||||
while (inputPos + fftSize <= length) {
|
||||
// Extract windowed frame
|
||||
const frame = new Float32Array(fftSize);
|
||||
for (let i = 0; i < fftSize; i++) {
|
||||
const drySignal = input[inputPos + i];
|
||||
const fbSignal = feedback[i] * feedbackAmount;
|
||||
frame[i] = (drySignal + fbSignal) * window[i];
|
||||
}
|
||||
|
||||
// FFT
|
||||
const spectrum = this.fft(frame);
|
||||
|
||||
// Apply frequency shift
|
||||
const shiftedSpectrum = this.applyFrequencyShift(spectrum, binShift);
|
||||
|
||||
// IFFT
|
||||
const processedFrame = this.ifft(shiftedSpectrum);
|
||||
|
||||
// Store for feedback
|
||||
for (let i = 0; i < fftSize; i++) {
|
||||
feedback[i] = processedFrame[i].real * 0.5;
|
||||
}
|
||||
|
||||
// Overlap-add with proper gain compensation and dry/wet mix
|
||||
for (let i = 0; i < fftSize; i++) {
|
||||
const wet = processedFrame[i].real * colaNorm;
|
||||
const dry = input[inputPos + i];
|
||||
output[outputPos + i] += dry * (1 - dryWet) + wet * dryWet;
|
||||
}
|
||||
|
||||
inputPos += hopSize;
|
||||
outputPos += hopSize;
|
||||
}
|
||||
|
||||
// Process remaining samples
|
||||
if (inputPos < length) {
|
||||
const remainingSamples = length - inputPos;
|
||||
const frame = new Float32Array(fftSize);
|
||||
|
||||
for (let i = 0; i < remainingSamples; i++) {
|
||||
frame[i] = input[inputPos + i] * window[i];
|
||||
}
|
||||
|
||||
const spectrum = this.fft(frame);
|
||||
const shiftedSpectrum = this.applyFrequencyShift(spectrum, binShift);
|
||||
const processedFrame = this.ifft(shiftedSpectrum);
|
||||
|
||||
for (let i = 0; i < fftSize && outputPos + i < output.length; i++) {
|
||||
const wet = processedFrame[i].real * colaNorm;
|
||||
const dry = i < remainingSamples ? input[inputPos + i] : 0;
|
||||
output[outputPos + i] += dry * (1 - dryWet) + wet * dryWet;
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private applyFrequencyShift(spectrum: Complex[], binShift: number): Complex[] {
|
||||
const result: Complex[] = new Array(spectrum.length);
|
||||
const halfSize = Math.floor(spectrum.length / 2);
|
||||
|
||||
// Initialize with zeros
|
||||
for (let i = 0; i < spectrum.length; i++) {
|
||||
result[i] = { real: 0, imag: 0 };
|
||||
}
|
||||
|
||||
// Shift positive frequencies
|
||||
for (let i = 0; i < halfSize; i++) {
|
||||
const newBin = i + binShift;
|
||||
|
||||
if (newBin >= 0 && newBin < halfSize) {
|
||||
// Simple shift within bounds
|
||||
result[newBin] = {
|
||||
real: spectrum[i].real,
|
||||
imag: spectrum[i].imag
|
||||
};
|
||||
} else if (newBin < 0) {
|
||||
// Frequency shifted below 0 Hz - mirror to positive with phase inversion
|
||||
const mirrorBin = Math.abs(newBin);
|
||||
if (mirrorBin < halfSize) {
|
||||
// Add to existing content (creates interesting beating)
|
||||
result[mirrorBin] = {
|
||||
real: result[mirrorBin].real + spectrum[i].real,
|
||||
imag: result[mirrorBin].imag - spectrum[i].imag // Phase inversion
|
||||
};
|
||||
}
|
||||
}
|
||||
// Frequencies shifted above Nyquist are discarded (natural lowpass)
|
||||
}
|
||||
|
||||
// Apply spectral smoothing to reduce artifacts
|
||||
const smoothed = this.smoothSpectrum(result, halfSize);
|
||||
|
||||
// Reconstruct negative frequencies for Hermitian symmetry
|
||||
for (let i = 1; i < halfSize; i++) {
|
||||
const negIdx = spectrum.length - i;
|
||||
smoothed[negIdx] = {
|
||||
real: smoothed[i].real,
|
||||
imag: -smoothed[i].imag
|
||||
};
|
||||
}
|
||||
|
||||
// DC and Nyquist should be real
|
||||
smoothed[0].imag = 0;
|
||||
if (halfSize * 2 === spectrum.length) {
|
||||
smoothed[halfSize].imag = 0;
|
||||
}
|
||||
|
||||
return smoothed;
|
||||
}
|
||||
|
||||
private smoothSpectrum(spectrum: Complex[], halfSize: number): Complex[] {
|
||||
const result: Complex[] = new Array(spectrum.length);
|
||||
|
||||
// Copy spectrum
|
||||
for (let i = 0; i < spectrum.length; i++) {
|
||||
result[i] = { real: spectrum[i].real, imag: spectrum[i].imag };
|
||||
}
|
||||
|
||||
// Apply 3-point smoothing to reduce artifacts
|
||||
for (let i = 1; i < halfSize - 1; i++) {
|
||||
result[i] = {
|
||||
real: (spectrum[i - 1].real * 0.25 + spectrum[i].real * 0.5 + spectrum[i + 1].real * 0.25),
|
||||
imag: (spectrum[i - 1].imag * 0.25 + spectrum[i].imag * 0.5 + spectrum[i + 1].imag * 0.25)
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private createHannWindow(size: number): Float32Array {
|
||||
const window = new Float32Array(size);
|
||||
for (let i = 0; i < size; i++) {
|
||||
window[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (size - 1));
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
private calculateCOLANorm(window: Float32Array, hopSize: number): number {
|
||||
// Calculate the sum of overlapping windows at any point
|
||||
const fftSize = window.length;
|
||||
let windowSum = 0;
|
||||
|
||||
// Sum all overlapping windows at the center point
|
||||
for (let offset = 0; offset < fftSize; offset += hopSize) {
|
||||
const idx = Math.floor(fftSize / 2) - offset;
|
||||
if (idx >= 0 && idx < fftSize) {
|
||||
windowSum += window[idx] * window[idx];
|
||||
}
|
||||
}
|
||||
|
||||
// Also check overlap from the other direction
|
||||
for (let offset = hopSize; offset < fftSize; offset += hopSize) {
|
||||
const idx = Math.floor(fftSize / 2) + offset;
|
||||
if (idx >= 0 && idx < fftSize) {
|
||||
windowSum += window[idx] * window[idx];
|
||||
}
|
||||
}
|
||||
|
||||
return windowSum > 0 ? 1.0 / windowSum : 1.0;
|
||||
}
|
||||
|
||||
// Cooley-Tukey FFT implementation
|
||||
private fft(input: Float32Array): Complex[] {
|
||||
const n = input.length;
|
||||
const output: Complex[] = new Array(n);
|
||||
|
||||
// Initialize with input as real values
|
||||
for (let i = 0; i < n; i++) {
|
||||
output[i] = { real: input[i], imag: 0 };
|
||||
}
|
||||
|
||||
// Bit reversal
|
||||
const bits = Math.log2(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = this.bitReverse(i, bits);
|
||||
if (j > i) {
|
||||
const temp = output[i];
|
||||
output[i] = output[j];
|
||||
output[j] = temp;
|
||||
}
|
||||
}
|
||||
|
||||
// Cooley-Tukey butterfly operations
|
||||
for (let size = 2; size <= n; size *= 2) {
|
||||
const halfSize = size / 2;
|
||||
const angleStep = -2 * Math.PI / size;
|
||||
|
||||
for (let start = 0; start < n; start += size) {
|
||||
for (let i = 0; i < halfSize; i++) {
|
||||
const angle = angleStep * i;
|
||||
const twiddle = {
|
||||
real: Math.cos(angle),
|
||||
imag: Math.sin(angle)
|
||||
};
|
||||
|
||||
const evenIdx = start + i;
|
||||
const oddIdx = start + i + halfSize;
|
||||
|
||||
const even = output[evenIdx];
|
||||
const odd = output[oddIdx];
|
||||
|
||||
const twiddledOdd = {
|
||||
real: odd.real * twiddle.real - odd.imag * twiddle.imag,
|
||||
imag: odd.real * twiddle.imag + odd.imag * twiddle.real
|
||||
};
|
||||
|
||||
output[evenIdx] = {
|
||||
real: even.real + twiddledOdd.real,
|
||||
imag: even.imag + twiddledOdd.imag
|
||||
};
|
||||
|
||||
output[oddIdx] = {
|
||||
real: even.real - twiddledOdd.real,
|
||||
imag: even.imag - twiddledOdd.imag
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// Inverse FFT
|
||||
private ifft(input: Complex[]): Complex[] {
|
||||
const n = input.length;
|
||||
const output: Complex[] = new Array(n);
|
||||
|
||||
// Copy and conjugate input
|
||||
for (let i = 0; i < n; i++) {
|
||||
output[i] = { real: input[i].real, imag: -input[i].imag };
|
||||
}
|
||||
|
||||
// Bit reversal
|
||||
const bits = Math.log2(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = this.bitReverse(i, bits);
|
||||
if (j > i) {
|
||||
const temp = output[i];
|
||||
output[i] = output[j];
|
||||
output[j] = temp;
|
||||
}
|
||||
}
|
||||
|
||||
// Cooley-Tukey butterfly operations (same as forward FFT)
|
||||
for (let size = 2; size <= n; size *= 2) {
|
||||
const halfSize = size / 2;
|
||||
const angleStep = -2 * Math.PI / size;
|
||||
|
||||
for (let start = 0; start < n; start += size) {
|
||||
for (let i = 0; i < halfSize; i++) {
|
||||
const angle = angleStep * i;
|
||||
const twiddle = {
|
||||
real: Math.cos(angle),
|
||||
imag: Math.sin(angle)
|
||||
};
|
||||
|
||||
const evenIdx = start + i;
|
||||
const oddIdx = start + i + halfSize;
|
||||
|
||||
const even = output[evenIdx];
|
||||
const odd = output[oddIdx];
|
||||
|
||||
const twiddledOdd = {
|
||||
real: odd.real * twiddle.real - odd.imag * twiddle.imag,
|
||||
imag: odd.real * twiddle.imag + odd.imag * twiddle.real
|
||||
};
|
||||
|
||||
output[evenIdx] = {
|
||||
real: even.real + twiddledOdd.real,
|
||||
imag: even.imag + twiddledOdd.imag
|
||||
};
|
||||
|
||||
output[oddIdx] = {
|
||||
real: even.real - twiddledOdd.real,
|
||||
imag: even.imag - twiddledOdd.imag
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Conjugate and scale
|
||||
const scale = 1 / n;
|
||||
for (let i = 0; i < n; i++) {
|
||||
output[i].real *= scale;
|
||||
output[i].imag *= -scale;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private bitReverse(n: number, bits: number): number {
|
||||
let reversed = 0;
|
||||
for (let i = 0; i < bits; i++) {
|
||||
reversed = (reversed << 1) | ((n >> i) & 1);
|
||||
}
|
||||
return reversed;
|
||||
}
|
||||
|
||||
private findMaxAmplitude(left: Float32Array, right: Float32Array): number {
|
||||
let max = 0;
|
||||
for (let i = 0; i < left.length; i++) {
|
||||
max = Math.max(max, Math.abs(left[i]), Math.abs(right[i]));
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
private calculateRMS(left: Float32Array, right: Float32Array): number {
|
||||
let sumSquares = 0;
|
||||
const length = left.length;
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
sumSquares += left[i] * left[i] + right[i] * right[i];
|
||||
}
|
||||
|
||||
return Math.sqrt(sumSquares / (2 * length));
|
||||
}
|
||||
}
|
||||
|
||||
interface Complex {
|
||||
real: number;
|
||||
imag: number;
|
||||
}
|
||||
18
src/lib/audio/processors/StereoSwap.ts
Normal file
18
src/lib/audio/processors/StereoSwap.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import type { AudioProcessor } from './AudioProcessor';
|
||||
|
||||
export class StereoSwap implements AudioProcessor {
|
||||
getName(): string {
|
||||
return 'Stereo Swap';
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return 'Swaps left and right channels';
|
||||
}
|
||||
|
||||
process(
|
||||
leftChannel: Float32Array,
|
||||
rightChannel: Float32Array
|
||||
): [Float32Array, Float32Array] {
|
||||
return [rightChannel, leftChannel];
|
||||
}
|
||||
}
|
||||
73
src/lib/audio/processors/Stutter.ts
Normal file
73
src/lib/audio/processors/Stutter.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import type { AudioProcessor } from './AudioProcessor';
|
||||
|
||||
export class Stutter implements AudioProcessor {
|
||||
getName(): string {
|
||||
return 'Stutter';
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return 'Rapidly repeats small fragments with smooth crossfades';
|
||||
}
|
||||
|
||||
process(
|
||||
leftChannel: Float32Array,
|
||||
rightChannel: Float32Array
|
||||
): [Float32Array, Float32Array] {
|
||||
const length = leftChannel.length;
|
||||
const sampleRate = 44100;
|
||||
|
||||
const minFragmentMs = 10;
|
||||
const maxFragmentMs = 50;
|
||||
const minFragmentSamples = Math.floor((minFragmentMs / 1000) * sampleRate);
|
||||
const maxFragmentSamples = Math.floor((maxFragmentMs / 1000) * sampleRate);
|
||||
|
||||
const crossfadeSamples = Math.floor(sampleRate * 0.002);
|
||||
|
||||
const outputLeft = new Float32Array(length);
|
||||
const outputRight = new Float32Array(length);
|
||||
|
||||
let readPos = 0;
|
||||
let writePos = 0;
|
||||
|
||||
while (readPos < length && writePos < length) {
|
||||
const fragmentSize = Math.floor(
|
||||
Math.random() * (maxFragmentSamples - minFragmentSamples) + minFragmentSamples
|
||||
);
|
||||
const actualFragmentSize = Math.min(fragmentSize, length - readPos);
|
||||
|
||||
const numRepeats = Math.floor(Math.random() * 4) + 1;
|
||||
|
||||
for (let repeat = 0; repeat < numRepeats; repeat++) {
|
||||
for (let i = 0; i < actualFragmentSize && writePos < length; i++, writePos++) {
|
||||
let leftSample = leftChannel[readPos + i];
|
||||
let rightSample = rightChannel[readPos + i];
|
||||
|
||||
if (repeat > 0 && i < crossfadeSamples) {
|
||||
const crossfadePos = i / crossfadeSamples;
|
||||
const fadeIn = this.easeInOut(crossfadePos);
|
||||
const fadeOut = 1.0 - fadeIn;
|
||||
|
||||
const prevIdx = readPos + actualFragmentSize - crossfadeSamples + i;
|
||||
if (prevIdx >= 0 && prevIdx < length) {
|
||||
leftSample = leftChannel[prevIdx] * fadeOut + leftSample * fadeIn;
|
||||
rightSample = rightChannel[prevIdx] * fadeOut + rightSample * fadeIn;
|
||||
}
|
||||
}
|
||||
|
||||
outputLeft[writePos] = leftSample;
|
||||
outputRight[writePos] = rightSample;
|
||||
}
|
||||
|
||||
if (writePos >= length) break;
|
||||
}
|
||||
|
||||
readPos += actualFragmentSize;
|
||||
}
|
||||
|
||||
return [outputLeft, outputRight];
|
||||
}
|
||||
|
||||
private easeInOut(t: number): number {
|
||||
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
|
||||
}
|
||||
}
|
||||
36
src/lib/audio/processors/Tremolo.ts
Normal file
36
src/lib/audio/processors/Tremolo.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import type { AudioProcessor } from './AudioProcessor';
|
||||
|
||||
export class Tremolo implements AudioProcessor {
|
||||
getName(): string {
|
||||
return 'Tremolo';
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return 'Applies rhythmic volume modulation';
|
||||
}
|
||||
|
||||
process(
|
||||
leftChannel: Float32Array,
|
||||
rightChannel: Float32Array
|
||||
): [Float32Array, Float32Array] {
|
||||
const length = leftChannel.length;
|
||||
const sampleRate = 44100;
|
||||
|
||||
const lfoFreq = Math.random() * 8 + 2;
|
||||
const depth = 1.0;
|
||||
|
||||
const outputLeft = new Float32Array(length);
|
||||
const outputRight = new Float32Array(length);
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const t = i / sampleRate;
|
||||
const lfo = Math.sin(2 * Math.PI * lfoFreq * t);
|
||||
const gain = 0.5 + (depth * 0.5) * lfo;
|
||||
|
||||
outputLeft[i] = leftChannel[i] * gain;
|
||||
outputRight[i] = rightChannel[i] * gain;
|
||||
}
|
||||
|
||||
return [outputLeft, outputRight];
|
||||
}
|
||||
}
|
||||
36
src/lib/audio/processors/registry.ts
Normal file
36
src/lib/audio/processors/registry.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import type { AudioProcessor } from './AudioProcessor';
|
||||
import { SegmentShuffler } from './SegmentShuffler';
|
||||
import { Reverser } from './Reverser';
|
||||
import { StereoSwap } from './StereoSwap';
|
||||
import { PanShuffler } from './PanShuffler';
|
||||
import { Tremolo } from './Tremolo';
|
||||
import { Chorus } from './Chorus';
|
||||
import { PitchWobble } from './PitchWobble';
|
||||
import { PitchShifter } from './PitchShifter';
|
||||
import { MicroPitch } from './MicroPitch';
|
||||
import { SpectralBlur } from './SpectralBlur';
|
||||
import { SpectralShift } from './SpectralShift';
|
||||
import { ConvolutionReverb } from './ConvolutionReverb';
|
||||
|
||||
const processors: AudioProcessor[] = [
|
||||
new SegmentShuffler(),
|
||||
new Reverser(),
|
||||
new StereoSwap(),
|
||||
new PanShuffler(),
|
||||
new Tremolo(),
|
||||
new Chorus(),
|
||||
new PitchWobble(),
|
||||
new PitchShifter(),
|
||||
new MicroPitch(),
|
||||
new SpectralBlur(),
|
||||
new SpectralShift(),
|
||||
new ConvolutionReverb(),
|
||||
];
|
||||
|
||||
export function getRandomProcessor(): AudioProcessor {
|
||||
return processors[Math.floor(Math.random() * processors.length)];
|
||||
}
|
||||
|
||||
export function getAllProcessors(): AudioProcessor[] {
|
||||
return processors;
|
||||
}
|
||||
@ -43,8 +43,8 @@ export class AudioService {
|
||||
const ctx = this.getContext();
|
||||
const [leftChannel, rightChannel] = stereoData;
|
||||
const buffer = ctx.createBuffer(2, leftChannel.length, DEFAULT_SAMPLE_RATE);
|
||||
buffer.copyToChannel(leftChannel, 0);
|
||||
buffer.copyToChannel(rightChannel, 1);
|
||||
buffer.copyToChannel(new Float32Array(leftChannel), 0);
|
||||
buffer.copyToChannel(new Float32Array(rightChannel), 1);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user