fix: time selection click and shiftclick

This commit is contained in:
2026-07-20 19:44:23 +07:00
parent 271f0583f4
commit 85a8dc6d17
13 changed files with 1788 additions and 293 deletions
+234 -1
View File
@@ -1,6 +1,9 @@
// 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) {
@@ -12,6 +15,22 @@
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;
@@ -34,9 +53,223 @@
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
decodeAudioFile,
// EDL VFS
createEDL,
deleteEDLRange,
renderEDLToBuffer,
// DSP Core
calculateConstantPowerPan,
applyDynamicsCompressor,
applyPhaseVocoderStretch
};
})();