feat: implement smart speed phase 3 silence compression

This commit is contained in:
Jonathan Baldie 2026-05-01 21:31:38 +01:00
parent ebff884562
commit 4299fdce59
5 changed files with 568 additions and 7 deletions

View file

@ -1,6 +1,7 @@
import Hls from 'hls.js'
import EventEmitter from 'events'
import SilenceMap from './smart-speed/SilenceMap'
import TimeMapper from './smart-speed/TimeMapper'
export default class LocalAudioPlayer extends EventEmitter {
constructor(ctx) {
@ -28,6 +29,9 @@ export default class LocalAudioPlayer extends EventEmitter {
this.silenceMap = new SilenceMap()
this.silenceDetectorNode = null
this.silenceCompressorNode = null
this.timeMapper = new TimeMapper([], 1.0)
this.smartSpeedRatio = 2.0
this.enableSmartSpeed = false
this.initialize()
@ -97,13 +101,29 @@ export default class LocalAudioPlayer extends EventEmitter {
}
}
updateSmartSpeedRegions() {
if (this.silenceCompressorNode) {
this.silenceCompressorNode.port.postMessage({ type: 'set-regions', regions: this.silenceMap.getRegions() })
}
this.timeMapper = new TimeMapper(this.silenceMap.getRegions(), this.smartSpeedRatio)
}
async initSilenceDetector() {
if (!this.usingWebAudio || !this.audioContext) return
if (this.silenceDetectorNode) return
try {
await this.audioContext.audioWorklet.addModule('/client/players/smart-speed/SilenceDetectorProcessor.js')
await this.audioContext.audioWorklet.addModule('/client/players/smart-speed/SilenceCompressorProcessor.js')
this.silenceDetectorNode = new AudioWorkletNode(this.audioContext, 'silence-detector')
this.silenceCompressorNode = new AudioWorkletNode(this.audioContext, 'silence-compressor')
this.silenceCompressorNode.port.postMessage({ type: 'set-ratio', value: this.smartSpeedRatio })
this.silenceCompressorNode.port.onmessage = (event) => {
const msg = event.data
if (msg.type === 'time-saved') {
this.emit('timeSaved', msg.ms)
}
}
this.silenceDetectorNode.port.onmessage = (event) => {
const msg = event.data
@ -113,13 +133,15 @@ export default class LocalAudioPlayer extends EventEmitter {
if (this._silenceStartTime !== null) {
this.silenceMap.addRegion(this._silenceStartTime, msg.time)
this._silenceStartTime = null
this.updateSmartSpeedRegions()
}
}
}
this.audioSourceNode.disconnect()
this.audioSourceNode.connect(this.silenceDetectorNode)
this.silenceDetectorNode.connect(this.audioContext.destination)
this.silenceDetectorNode.connect(this.silenceCompressorNode)
this.silenceCompressorNode.connect(this.audioContext.destination)
this._silenceStartTime = null
console.log('[LocalPlayer] Silence detector initialised')
@ -138,7 +160,14 @@ export default class LocalAudioPlayer extends EventEmitter {
}
this.silenceDetectorNode = null
}
if (this.silenceCompressorNode) {
try {
this.silenceCompressorNode.disconnect()
} catch (err) {}
this.silenceCompressorNode = null
}
this.silenceMap.reset()
this.updateSmartSpeedRegions()
this._silenceStartTime = null
}
@ -291,6 +320,7 @@ export default class LocalAudioPlayer extends EventEmitter {
loadCurrentTrack() {
if (!this.currentTrack) return
this.silenceMap.reset()
this.updateSmartSpeedRegions()
// When direct play track is loaded current time needs to be set
this.trackStartTime = Math.max(0, this.startTime - (this.currentTrack.startOffset || 0))
this.player.src = this.currentTrack.relativeContentUrl
@ -356,7 +386,14 @@ export default class LocalAudioPlayer extends EventEmitter {
getCurrentTime() {
var currentTrackOffset = this.currentTrack.startOffset || 0
return this.player ? currentTrackOffset + this.player.currentTime : 0
if (!this.player) return 0
if (this.enableSmartSpeed) {
var audioMs = this.player.currentTime * 1000
var wallMs = this.timeMapper.audioToWallClock(audioMs)
return currentTrackOffset + (wallMs / 1000)
}
return currentTrackOffset + this.player.currentTime
}
getDuration() {
@ -383,20 +420,28 @@ export default class LocalAudioPlayer extends EventEmitter {
seek(time, playWhenReady) {
if (!this.player) return
// Map wall-clock seek time to audio time before resetting regions
var mappedTime = time
if (this.enableSmartSpeed && time >= (this.currentTrack.startOffset || 0) && time <= (this.currentTrack.startOffset || 0) + (this.currentTrack.duration || Infinity)) {
var offsetTime = mappedTime - (this.currentTrack.startOffset || 0)
mappedTime = (this.currentTrack.startOffset || 0) + (this.timeMapper.wallClockToAudio(offsetTime * 1000) / 1000)
}
this.silenceMap.reset()
this.updateSmartSpeedRegions()
this.playWhenReady = playWhenReady
if (this.isHlsTranscode) {
// Seeking HLS stream
var offsetTime = time - (this.currentTrack.startOffset || 0)
var offsetTime = mappedTime - (this.currentTrack.startOffset || 0)
this.player.currentTime = Math.max(0, offsetTime)
} else {
// Seeking Direct play
if (time < this.currentTrack.startOffset || time > this.currentTrack.startOffset + this.currentTrack.duration) {
if (mappedTime < this.currentTrack.startOffset || mappedTime > this.currentTrack.startOffset + this.currentTrack.duration) {
// Change Track
var trackIndex = this.audioTracks.findIndex((t) => time >= t.startOffset && time < t.startOffset + t.duration)
var trackIndex = this.audioTracks.findIndex((t) => mappedTime >= t.startOffset && mappedTime < t.startOffset + t.duration)
if (trackIndex >= 0) {
this.startTime = time
this.startTime = mappedTime
this.currentTrackIndex = trackIndex
if (!this.player.paused) {
@ -406,7 +451,7 @@ export default class LocalAudioPlayer extends EventEmitter {
this.loadCurrentTrack()
}
} else {
var offsetTime = time - (this.currentTrack.startOffset || 0)
var offsetTime = mappedTime - (this.currentTrack.startOffset || 0)
this.player.currentTime = Math.max(0, offsetTime)
}
}

View file

@ -0,0 +1,122 @@
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

@ -0,0 +1,89 @@
class TimeMapper {
constructor(silenceRegions = [], compressionRatio = 1.0) {
this.ratio = compressionRatio
// Only keep regions >= 200ms
this.regions = silenceRegions.filter(r => (r.end - r.start) >= 200)
// Calculate compressed durations and cumulative time saved
this.processedRegions = []
let accumulatedSaved = 0
for (const r of this.regions) {
const originalDuration = r.end - r.start
const compressedDuration = this.ratio === 0 ? 0 : originalDuration / this.ratio
const saved = originalDuration - compressedDuration
this.processedRegions.push({
...r,
originalDuration,
compressedDuration,
saved,
accumulatedSavedBefore: accumulatedSaved
})
accumulatedSaved += saved
}
this._totalTimeSaved = accumulatedSaved
}
wallClockToAudio(wallMs) {
if (this.ratio === 1.0 || this.regions.length === 0) return wallMs
let audioMs = wallMs
for (const r of this.processedRegions) {
// The start time of this region in wall-clock time
const regionWallStart = r.start - r.accumulatedSavedBefore
if (wallMs < regionWallStart) {
// Before this region, no more accumulated saved to add
break
}
const regionWallEnd = regionWallStart + r.compressedDuration
if (wallMs <= regionWallEnd) {
// Inside the compressed region
const timeSpentInRegionWall = wallMs - regionWallStart
const timeSpentInRegionAudio = timeSpentInRegionWall * this.ratio
return r.start + timeSpentInRegionAudio
}
// After this region, we add the total time saved by this region
audioMs = wallMs + (r.accumulatedSavedBefore + r.saved)
}
return audioMs
}
audioToWallClock(audioMs) {
if (this.ratio === 1.0 || this.regions.length === 0) return audioMs
let wallMs = audioMs
for (const r of this.processedRegions) {
if (audioMs < r.start) {
break
}
if (audioMs <= r.end) {
// Inside the region
const timeSpentInRegionAudio = audioMs - r.start
const timeSpentInRegionWall = timeSpentInRegionAudio / this.ratio
return r.start - r.accumulatedSavedBefore + timeSpentInRegionWall
}
// After the region
wallMs = audioMs - (r.accumulatedSavedBefore + r.saved)
}
return wallMs
}
totalTimeSaved() {
return this._totalTimeSaved
}
}
module.exports = TimeMapper