Redesign Smart Speed to dynamically adjust playbackRate instead of dropping samples, fix TimeMapper bugs by mapping audioContext to media time, and prevent SilenceMap memory leak by capping regions

This commit is contained in:
Jonathan Baldie 2026-05-01 21:47:34 +01:00
parent fa2460868e
commit 545c77a2dc
4 changed files with 32 additions and 314 deletions

View file

@ -1,122 +0,0 @@
class SilenceCompressorProcessor extends AudioWorkletProcessor {
constructor() {
super()
this.regions = []
this.ratio = 1.0
this.totalCompressedMs = 0
this.rampDurationSec = 0.005 // 5ms
this.port.onmessage = (event) => {
const msg = event.data
if (msg.type === 'set-regions') {
this.regions = msg.regions.filter(r => (r.end - r.start) >= 200)
} else if (msg.type === 'set-ratio') {
this.ratio = msg.value
}
}
}
getActiveRegion(timeMs) {
for (const r of this.regions) {
if (timeMs >= r.start && timeMs <= r.end) return r
}
return null
}
calculateRampGain(timeMs, region) {
const rampMs = this.rampDurationSec * 1000
// Entry ramp (0 -> 1)
if (timeMs - region.start < rampMs) {
return (timeMs - region.start) / rampMs
}
// Exit ramp (1 -> 0)
if (region.end - timeMs < rampMs) {
return (region.end - timeMs) / rampMs
}
return 1.0
}
process(inputs, outputs, parameters) {
const input = inputs[0]
const output = outputs[0]
if (!input || !input.length || !output || !output.length) return true
const numChannels = input.length
const numFrames = input[0].length
const sampleRateC = typeof sampleRate !== 'undefined' ? sampleRate : 48000
// Use currentTime if available, otherwise fallback to 0 (for tests)
const currentTimeSec = typeof currentTime !== 'undefined' ? currentTime : 0
let outputIndex = 0
let inputIndex = 0
let savedSecThisBlock = 0
while (inputIndex < numFrames) {
const sampleTimeSec = currentTimeSec + (inputIndex / sampleRateC)
const sampleTimeMs = sampleTimeSec * 1000
const region = this.getActiveRegion(sampleTimeMs)
let step = 1.0
let rampGain = 1.0
if (region && this.ratio > 1.0) {
step = this.ratio
rampGain = this.calculateRampGain(sampleTimeMs, region)
}
// If taking this step exceeds the input buffer, we must stop
if (inputIndex >= numFrames) break
const intIndex = Math.floor(inputIndex)
const frac = inputIndex - intIndex
for (let c = 0; c < numChannels; c++) {
const inChannel = input[c]
const outChannel = output[c]
let sample = inChannel[intIndex]
if (frac > 0 && intIndex + 1 < numFrames) {
sample = sample + frac * (inChannel[intIndex + 1] - sample)
}
if (outputIndex < numFrames) {
outChannel[outputIndex] = sample * rampGain
}
}
inputIndex += step
outputIndex += 1
if (step > 1.0) {
savedSecThisBlock += (step - 1.0) / sampleRateC
}
}
// Fill the rest of the output buffer with 0s if we compressed
for (let c = 0; c < numChannels; c++) {
for (let i = outputIndex; i < numFrames; i++) {
output[c][i] = 0
}
}
if (savedSecThisBlock > 0) {
this.totalCompressedMs += savedSecThisBlock * 1000
this.port.postMessage({ type: 'time-saved', ms: this.totalCompressedMs })
}
return true
}
}
if (typeof registerProcessor !== 'undefined') {
registerProcessor('silence-compressor', SilenceCompressorProcessor)
}
if (typeof module !== 'undefined') {
module.exports = SilenceCompressorProcessor
}

View file

@ -38,6 +38,12 @@ class SilenceMap {
}
this._regions = merged
// Cap the number of regions to prevent memory leaks for long audiobooks
// Assuming each region is ~1 second, 5000 regions is over an hour of silence
if (this._regions.length > 5000) {
this._regions = this._regions.slice(-5000)
}
}
getCompressedOffset(atTimeMs, ratio) {