feat(player): add silence detection and smart speed to local audio player

This commit is contained in:
Jonathan Baldie 2026-05-01 21:17:35 +01:00
parent 48c98f9655
commit ebff884562
4 changed files with 366 additions and 0 deletions

View file

@ -0,0 +1,71 @@
const SPEAKING = 0
const SILENCE = 1
const CANDIDATE = 2
const DEBOUNCE_MS = 200
const RMS_REPORT_INTERVAL = 10
class SilenceDetectorProcessor extends AudioWorkletProcessor {
constructor() {
super()
this.state = SPEAKING
this.silenceThreshold = -40
this.candidateStartSample = 0
this.sampleRate = sampleRate
this.blockCount = 0
this.port.onmessage = (event) => {
const msg = event.data
if (msg.type === 'set-threshold') {
this.silenceThreshold = msg.value
}
}
}
process(inputs) {
const input = inputs[0]
if (!input || !input.length) return true
const channel = input[0]
if (!channel) return true
let sum = 0
for (let i = 0; i < channel.length; i++) {
sum += channel[i] * channel[i]
}
const rms = Math.sqrt(sum / channel.length)
const dbfs = rms === 0 ? -Infinity : 20 * Math.log10(rms)
this.blockCount++
if (dbfs < this.silenceThreshold) {
if (this.state === SPEAKING) {
this.candidateStartSample = currentFrame
this.state = CANDIDATE
} else if (this.state === CANDIDATE) {
const elapsedMs = ((currentFrame - this.candidateStartSample) / this.sampleRate) * 1000
if (elapsedMs >= DEBOUNCE_MS) {
this.state = SILENCE
const silenceStartTime = (this.candidateStartSample / this.sampleRate) * 1000
this.port.postMessage({ type: 'silence-start', time: silenceStartTime })
}
}
} else {
if (this.state === SILENCE) {
const currentTime = (currentFrame / this.sampleRate) * 1000
this.port.postMessage({ type: 'silence-end', time: currentTime })
}
if (this.state !== SPEAKING) {
this.state = SPEAKING
}
}
if (this.blockCount % RMS_REPORT_INTERVAL === 0) {
this.port.postMessage({ type: 'rms', value: dbfs })
}
return true
}
}
registerProcessor('silence-detector', SilenceDetectorProcessor)

View file

@ -0,0 +1,61 @@
class SilenceMap {
constructor() {
this._regions = []
}
get regionCount() {
return this._regions.length
}
getRegions() {
return [...this._regions]
}
addRegion(startMs, endMs) {
if (typeof startMs !== 'number' || typeof endMs !== 'number') return
if (startMs < 0 || endMs < 0) return
if (endMs <= startMs) return
const newRegion = { start: startMs, end: endMs }
const merged = []
let inserted = false
for (const region of this._regions) {
if (newRegion.start <= region.end + 10 && newRegion.end >= region.start - 10) {
newRegion.start = Math.min(newRegion.start, region.start)
newRegion.end = Math.max(newRegion.end, region.end)
} else if (!inserted && region.start > newRegion.end) {
merged.push(newRegion)
merged.push(region)
inserted = true
} else {
merged.push(region)
}
}
if (!inserted) {
merged.push(newRegion)
}
this._regions = merged
}
getCompressedOffset(atTimeMs, ratio) {
if (!ratio || ratio <= 1) return 0
let saved = 0
for (const region of this._regions) {
if (atTimeMs <= region.start) break
const silenceStart = region.start
const silenceEnd = Math.min(region.end, atTimeMs)
const silenceDuration = silenceEnd - silenceStart
saved += silenceDuration * (1 - 1 / ratio)
}
return saved
}
reset() {
this._regions = []
}
}
module.exports = SilenceMap