276 lines
9.7 KiB
JavaScript
276 lines
9.7 KiB
JavaScript
// SonicForge Studio Audio Engine Service
|
|
// High-performance Desktop-Grade Client-Side Audio Engine & DSP Service (21_CLIENT_PRE.md)
|
|
|
|
(function() {
|
|
let audioCtx = null;
|
|
let workletLoaded = false;
|
|
|
|
function getAudioContext() {
|
|
if (!audioCtx) {
|
|
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
}
|
|
if (audioCtx.state === 'suspended') {
|
|
audioCtx.resume();
|
|
}
|
|
return audioCtx;
|
|
}
|
|
|
|
async function initAudioWorklet() {
|
|
if (workletLoaded) return true;
|
|
const ctx = getAudioContext();
|
|
try {
|
|
if (ctx.audioWorklet) {
|
|
await ctx.audioWorklet.addModule('/static/js/services/sonicAudioWorklet.js');
|
|
workletLoaded = true;
|
|
console.log('[SonicAudio] AudioWorklet registered successfully.');
|
|
return true;
|
|
}
|
|
} catch (err) {
|
|
console.warn('[SonicAudio] AudioWorklet initialization fallback:', err.message);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function analyzeAudioBufferChannels(audioBuffer) {
|
|
if (!audioBuffer) return { channels: 1, isStereo: false, label: 'MONO' };
|
|
const numChannels = audioBuffer.numberOfChannels;
|
|
const isStereo = numChannels >= 2;
|
|
return {
|
|
channels: numChannels,
|
|
isStereo: isStereo,
|
|
label: isStereo ? 'STEREO' : 'MONO',
|
|
sampleRate: audioBuffer.sampleRate,
|
|
duration: audioBuffer.duration,
|
|
length: audioBuffer.length
|
|
};
|
|
}
|
|
|
|
async function decodeAudioFile(file) {
|
|
const ctx = getAudioContext();
|
|
const arrayBuffer = await file.arrayBuffer();
|
|
const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
|
|
const channelInfo = analyzeAudioBufferChannels(audioBuffer);
|
|
return { audioBuffer, channelInfo };
|
|
}
|
|
|
|
// ── 1. Non-Destructive Edit Decision List (EDL VFS Engine - 21_CLIENT_PRE.md §4) ──
|
|
function createEDL(bufferId, buffer) {
|
|
if (!buffer) return [];
|
|
return [{
|
|
id: 'seg_' + Math.random().toString(36).substr(2, 9),
|
|
sourceBufferId: bufferId,
|
|
startSample: 0,
|
|
length: buffer.length,
|
|
playbackRate: 1.0,
|
|
isSilence: false,
|
|
isReversed: false
|
|
}];
|
|
}
|
|
|
|
function deleteEDLRange(edlList, startSec, endSec, sampleRate) {
|
|
const startSample = Math.floor(startSec * sampleRate);
|
|
const endSample = Math.floor(endSec * sampleRate);
|
|
const result = [];
|
|
let currentPos = 0;
|
|
|
|
for (const seg of edlList) {
|
|
const segStart = currentPos;
|
|
const segEnd = currentPos + seg.length;
|
|
|
|
if (segEnd <= startSample || segStart >= endSample) {
|
|
// Completely outside delete window
|
|
result.push({ ...seg });
|
|
} else {
|
|
// Overlaps delete window
|
|
if (segStart < startSample) {
|
|
const keepLen = startSample - segStart;
|
|
result.push({ ...seg, id: 'seg_' + Math.random().toString(36).substr(2, 9), length: keepLen });
|
|
}
|
|
if (segEnd > endSample) {
|
|
const cutOffset = endSample - segStart;
|
|
const keepLen = segEnd - endSample;
|
|
result.push({
|
|
...seg,
|
|
id: 'seg_' + Math.random().toString(36).substr(2, 9),
|
|
startSample: seg.startSample + cutOffset,
|
|
length: keepLen
|
|
});
|
|
}
|
|
}
|
|
currentPos = segEnd;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function renderEDLToBuffer(edlList, sourceBuffersMap, sampleRate) {
|
|
let totalSamples = 0;
|
|
for (const seg of edlList) {
|
|
totalSamples += seg.length;
|
|
}
|
|
|
|
const ctx = getAudioContext();
|
|
if (totalSamples === 0) {
|
|
return ctx.createBuffer(2, sampleRate * 0.1, sampleRate);
|
|
}
|
|
|
|
const numChannels = 2;
|
|
const outBuffer = ctx.createBuffer(numChannels, totalSamples, sampleRate);
|
|
const outL = outBuffer.getChannelData(0);
|
|
const outR = outBuffer.getChannelData(1);
|
|
|
|
let writeOffset = 0;
|
|
for (const seg of edlList) {
|
|
if (seg.isSilence) {
|
|
writeOffset += seg.length;
|
|
continue;
|
|
}
|
|
const srcBuffer = sourceBuffersMap[seg.sourceBufferId];
|
|
if (!srcBuffer) {
|
|
writeOffset += seg.length;
|
|
continue;
|
|
}
|
|
|
|
const srcL = srcBuffer.getChannelData(0);
|
|
const srcR = srcBuffer.numberOfChannels > 1 ? srcBuffer.getChannelData(1) : srcL;
|
|
const len = Math.min(seg.length, srcBuffer.length - seg.startSample);
|
|
|
|
for (let i = 0; i < len; i++) {
|
|
const readIdx = seg.isReversed
|
|
? seg.startSample + len - 1 - i
|
|
: seg.startSample + i;
|
|
if (readIdx >= 0 && readIdx < srcBuffer.length) {
|
|
outL[writeOffset + i] = srcL[readIdx];
|
|
outR[writeOffset + i] = srcR[readIdx];
|
|
}
|
|
}
|
|
writeOffset += seg.length;
|
|
}
|
|
return outBuffer;
|
|
}
|
|
|
|
// ── 2. Client-Side DSP Core Engine (21_CLIENT_PRE.md §3 & §5) ──
|
|
|
|
// Constant-Power Panning Math
|
|
function calculateConstantPowerPan(panVal, volDb = 0) {
|
|
const gain = Math.pow(10, volDb / 20);
|
|
const theta = ((Math.max(-1, Math.min(1, panVal)) + 1) / 2) * (Math.PI / 2);
|
|
return {
|
|
gainL: Math.cos(theta) * gain,
|
|
gainR: Math.sin(theta) * gain,
|
|
gainLinear: gain
|
|
};
|
|
}
|
|
|
|
// Dynamics Compressor / Limiter
|
|
function applyDynamicsCompressor(audioBuffer, thresholdDb = -20, ratio = 4.0, attackMs = 10, releaseMs = 100) {
|
|
const ctx = getAudioContext();
|
|
const numChannels = audioBuffer.numberOfChannels;
|
|
const sampleRate = audioBuffer.sampleRate;
|
|
const len = audioBuffer.length;
|
|
const outBuffer = ctx.createBuffer(numChannels, len, sampleRate);
|
|
|
|
const attackCoef = Math.exp(-1 / (sampleRate * (attackMs / 1000)));
|
|
const releaseCoef = Math.exp(-1 / (sampleRate * (releaseMs / 1000)));
|
|
const thresholdLinear = Math.pow(10, thresholdDb / 20);
|
|
|
|
const channelsData = [];
|
|
const outData = [];
|
|
for (let ch = 0; ch < numChannels; ch++) {
|
|
channelsData.push(audioBuffer.getChannelData(ch));
|
|
outData.push(outBuffer.getChannelData(ch));
|
|
}
|
|
|
|
let envelope = 0;
|
|
const blockSize = 128;
|
|
|
|
for (let i = 0; i < len; i += blockSize) {
|
|
const currentBlockSize = Math.min(blockSize, len - i);
|
|
|
|
// Compute RMS energy of block
|
|
let sumSq = 0;
|
|
for (let b = 0; b < currentBlockSize; b++) {
|
|
const sampleL = channelsData[0][i + b];
|
|
sumSq += sampleL * sampleL;
|
|
}
|
|
const rms = Math.sqrt(sumSq / currentBlockSize);
|
|
|
|
// Envelope follower
|
|
if (rms > envelope) {
|
|
envelope = attackCoef * envelope + (1 - attackCoef) * rms;
|
|
} else {
|
|
envelope = releaseCoef * envelope + (1 - releaseCoef) * rms;
|
|
}
|
|
|
|
// Target Gain calculation
|
|
let targetGain = 1.0;
|
|
if (envelope > thresholdLinear && envelope > 0) {
|
|
const envDb = 20 * Math.log10(envelope);
|
|
const overDb = envDb - thresholdDb;
|
|
const compressedDb = thresholdDb + overDb / ratio;
|
|
targetGain = Math.pow(10, (compressedDb - envDb) / 20);
|
|
}
|
|
|
|
for (let b = 0; b < currentBlockSize; b++) {
|
|
for (let ch = 0; ch < numChannels; ch++) {
|
|
outData[ch][i + b] = channelsData[ch][i + b] * targetGain;
|
|
}
|
|
}
|
|
}
|
|
return outBuffer;
|
|
}
|
|
|
|
// Phase Vocoder / Overlap-Add Time Stretch
|
|
function applyPhaseVocoderStretch(audioBuffer, speedRatio) {
|
|
if (speedRatio <= 0.01 || Math.abs(speedRatio - 1.0) < 0.001) return audioBuffer;
|
|
|
|
const ctx = getAudioContext();
|
|
const numChannels = audioBuffer.numberOfChannels;
|
|
const sampleRate = audioBuffer.sampleRate;
|
|
const inLen = audioBuffer.length;
|
|
const outLen = Math.floor(inLen / speedRatio);
|
|
|
|
const outBuffer = ctx.createBuffer(numChannels, outLen, sampleRate);
|
|
const windowSize = 1024;
|
|
const inHop = Math.floor(windowSize / 4);
|
|
const outHop = Math.floor(inHop / speedRatio);
|
|
|
|
// Hanning Window
|
|
const win = new Float32Array(windowSize);
|
|
for (let n = 0; n < windowSize; n++) {
|
|
win[n] = 0.5 * (1 - Math.cos((2 * Math.PI * n) / (windowSize - 1)));
|
|
}
|
|
|
|
for (let ch = 0; ch < numChannels; ch++) {
|
|
const inData = audioBuffer.getChannelData(ch);
|
|
const outData = outBuffer.getChannelData(ch);
|
|
|
|
let inPos = 0;
|
|
let outPos = 0;
|
|
|
|
while (inPos + windowSize < inLen && outPos + windowSize < outLen) {
|
|
for (let n = 0; n < windowSize; n++) {
|
|
outData[outPos + n] += inData[Math.floor(inPos) + n] * win[n];
|
|
}
|
|
inPos += inHop;
|
|
outPos += outHop;
|
|
}
|
|
}
|
|
return outBuffer;
|
|
}
|
|
|
|
window.SonicAudio = {
|
|
getAudioContext,
|
|
initAudioWorklet,
|
|
analyzeAudioBufferChannels,
|
|
decodeAudioFile,
|
|
// EDL VFS
|
|
createEDL,
|
|
deleteEDLRange,
|
|
renderEDLToBuffer,
|
|
// DSP Core
|
|
calculateConstantPowerPan,
|
|
applyDynamicsCompressor,
|
|
applyPhaseVocoderStretch
|
|
};
|
|
})();
|