Chunk offline audio decoding to prevent ONNX SIGTRAP crashes (#7932)

This commit is contained in:
Jinjing 2026-07-09 05:00:58 -07:00 committed by GitHub
parent 775fa956ce
commit b32924bf59
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 223 additions and 23 deletions

View File

@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import { OFFLINE_DECODE_CHUNK_SECONDS, OfflineAudioChunker } from './stt-offline-audio-chunker'
// Why: a small rate keeps test arrays tiny while exercising the same
// seconds-based limits used with real 16 kHz audio.
const SAMPLE_RATE = 1000
const CHUNK_LIMIT = OFFLINE_DECODE_CHUNK_SECONDS * SAMPLE_RATE
function loudSignal(length: number): Float32Array {
const samples = new Float32Array(length)
for (let i = 0; i < length; i += 1) {
samples[i] = Math.sin(i * 0.3) * 0.8
}
return samples
}
describe('OfflineAudioChunker', () => {
it('buffers audio below the chunk limit without emitting chunks', () => {
const chunker = new OfflineAudioChunker(SAMPLE_RATE)
expect(chunker.push(loudSignal(CHUNK_LIMIT - 1))).toEqual([])
})
it('emits a bounded chunk once the limit is reached and keeps the remainder', () => {
const chunker = new OfflineAudioChunker(SAMPLE_RATE)
const total = CHUNK_LIMIT + 500
const ready = chunker.push(loudSignal(total))
const remainder = chunker.flush()
expect(ready).toHaveLength(1)
expect(ready[0].length).toBeLessThanOrEqual(CHUNK_LIMIT)
expect(ready[0].length).toBeGreaterThan(0)
expect(ready[0].length + (remainder?.length ?? 0)).toBe(total)
})
it('never emits a chunk larger than the limit across many small pushes', () => {
const chunker = new OfflineAudioChunker(SAMPLE_RATE)
const emitted: Float32Array[] = []
const pushSize = 160
const pushes = Math.ceil((CHUNK_LIMIT * 3.5) / pushSize)
for (let i = 0; i < pushes; i += 1) {
emitted.push(...chunker.push(loudSignal(pushSize)))
}
const remainder = chunker.flush()
expect(emitted.length).toBeGreaterThanOrEqual(3)
for (const chunk of emitted) {
expect(chunk.length).toBeLessThanOrEqual(CHUNK_LIMIT)
}
const totalOut = emitted.reduce((sum, c) => sum + c.length, 0) + (remainder?.length ?? 0)
expect(totalOut).toBe(pushes * pushSize)
})
it('splits multiple chunks out of one oversized push', () => {
const chunker = new OfflineAudioChunker(SAMPLE_RATE)
const ready = chunker.push(loudSignal(CHUNK_LIMIT * 2 + 100))
expect(ready.length).toBeGreaterThanOrEqual(2)
for (const chunk of ready) {
expect(chunk.length).toBeLessThanOrEqual(CHUNK_LIMIT)
}
})
it('splits at a silent pause near the chunk boundary instead of mid-speech', () => {
const chunker = new OfflineAudioChunker(SAMPLE_RATE)
const samples = loudSignal(CHUNK_LIMIT + 200)
// Quiet gap 2 s before the limit, inside the 5 s split-search window.
const gapStart = CHUNK_LIMIT - 2 * SAMPLE_RATE
const gapEnd = gapStart + Math.round(0.2 * SAMPLE_RATE)
samples.fill(0, gapStart, gapEnd)
const [chunk] = chunker.push(samples)
expect(chunk.length).toBeGreaterThanOrEqual(gapStart)
expect(chunk.length).toBeLessThanOrEqual(gapEnd)
})
it('conserves sample values across the split', () => {
const chunker = new OfflineAudioChunker(SAMPLE_RATE)
const samples = loudSignal(CHUNK_LIMIT + 50)
const [chunk] = chunker.push(samples)
const remainder = chunker.flush()
const rejoined = new Float32Array(samples.length)
rejoined.set(chunk, 0)
rejoined.set(remainder!, chunk.length)
expect(rejoined).toEqual(samples)
})
it('flush returns null when nothing is buffered', () => {
const chunker = new OfflineAudioChunker(SAMPLE_RATE)
expect(chunker.flush()).toBeNull()
expect(chunker.push(new Float32Array(0))).toEqual([])
expect(chunker.flush()).toBeNull()
})
})

View File

@ -0,0 +1,92 @@
// Why: offline recognizers decode a whole buffer per call, and ONNX Runtime's
// arena allocations scale with buffer length. Chromium's allocator shim kills
// the entire app on any single allocation >= 2 GiB (#7925), so audio must be
// decoded in bounded chunks regardless of how long dictation runs.
export const OFFLINE_DECODE_CHUNK_SECONDS = 30
// Why: cutting audio mid-word degrades transcription at chunk boundaries.
// Search the tail of each chunk for its quietest window and split at its
// center, so cuts land on real inter-word pauses whenever one exists. The
// window must be pause-sized (~100ms): shorter windows match momentary
// quiet inside a word (e.g. plosive closures) and cut mid-word.
const SPLIT_SEARCH_SECONDS = 5
const SPLIT_ENERGY_WINDOW_SECONDS = 0.1
export class OfflineAudioChunker {
private buffered: Float32Array[] = []
private bufferedSamples = 0
private readonly chunkSampleLimit: number
private readonly splitSearchSamples: number
private readonly energyWindowSamples: number
constructor(sampleRate: number) {
this.chunkSampleLimit = Math.max(1, Math.round(OFFLINE_DECODE_CHUNK_SECONDS * sampleRate))
this.splitSearchSamples = Math.round(SPLIT_SEARCH_SECONDS * sampleRate)
this.energyWindowSamples = Math.max(1, Math.round(SPLIT_ENERGY_WINDOW_SECONDS * sampleRate))
}
/** Buffers samples and returns any full chunks now ready to decode. */
push(samples: Float32Array): Float32Array[] {
if (samples.length === 0) {
return []
}
this.buffered.push(samples)
this.bufferedSamples += samples.length
const ready: Float32Array[] = []
while (this.bufferedSamples >= this.chunkSampleLimit) {
const combined = this.combineBuffered()
const splitIndex = this.findQuietSplitIndex(combined)
ready.push(combined.slice(0, splitIndex))
const tail = combined.slice(splitIndex)
this.buffered = tail.length > 0 ? [tail] : []
this.bufferedSamples = tail.length
}
return ready
}
/** Returns all remaining buffered audio (any length below the chunk limit). */
flush(): Float32Array | null {
if (this.bufferedSamples === 0) {
return null
}
const combined = this.combineBuffered()
this.buffered = []
this.bufferedSamples = 0
return combined
}
private combineBuffered(): Float32Array {
if (this.buffered.length === 1) {
return this.buffered[0]
}
const combined = new Float32Array(this.bufferedSamples)
let offset = 0
for (const chunk of this.buffered) {
combined.set(chunk, offset)
offset += chunk.length
}
return combined
}
private findQuietSplitIndex(samples: Float32Array): number {
const limit = Math.min(this.chunkSampleLimit, samples.length)
const window = this.energyWindowSamples
const searchStart = Math.max(0, limit - this.splitSearchSamples)
const hop = Math.max(1, Math.floor(window / 2))
let bestIndex = limit
let bestEnergy = Infinity
for (let start = searchStart; start + window <= limit; start += hop) {
let energy = 0
for (let i = start; i < start + window; i += 1) {
energy += samples[i] * samples[i]
}
if (energy < bestEnergy) {
bestEnergy = energy
bestIndex = start + Math.floor(window / 2)
}
}
// Why: the split must consume at least one sample or push() would loop forever.
return Math.max(1, bestIndex)
}
}

View File

@ -2,6 +2,7 @@
import { parentPort, workerData } from 'node:worker_threads'
import { readdirSync } from 'node:fs'
import { resampleToRate } from './stt-audio-resample'
import { OfflineAudioChunker } from './stt-offline-audio-chunker'
type WorkerMessage =
| {
@ -27,7 +28,7 @@ let sherpa: any = null
let recognizer: any = null
let stream: any = null
let isStreaming = false
let offlineBuffer: Float32Array[] = []
let offlineChunker: OfflineAudioChunker | null = null
let offlineSampleRate = 16000
function loadSherpa(): any {
@ -111,7 +112,7 @@ function handleInit(msg: Extract<WorkerMessage, { type: 'init' }>): void {
const { modelDir, modelType, streaming, sampleRate, files } = msg
isStreaming = streaming
offlineBuffer = []
offlineChunker = streaming ? null : new OfflineAudioChunker(sampleRate)
offlineSampleRate = sampleRate
const tokens = resolveTokens(files, modelDir)
@ -203,6 +204,17 @@ function handleInit(msg: Extract<WorkerMessage, { type: 'init' }>): void {
}
}
// Why: an offline stream is single-use — decode one bounded chunk, then
// recreate the stream so the recognizer is ready for the next chunk.
function decodeOfflineChunk(samples: Float32Array): string {
sherpa.acceptWaveformOffline(stream, { sampleRate: offlineSampleRate, samples })
sherpa.decodeOfflineStream(recognizer, stream)
const resultJson = sherpa.getOfflineStreamResultAsJson(stream)
stream = sherpa.createOfflineStream(recognizer)
const result = JSON.parse(resultJson)
return result?.text?.trim() ?? ''
}
function handleFeed(msg: Extract<WorkerMessage, { type: 'feed' }>): void {
if (!recognizer || !stream) {
return
@ -236,9 +248,17 @@ function handleFeed(msg: Extract<WorkerMessage, { type: 'feed' }>): void {
sherpa.reset(recognizer, stream)
}
} else {
// Why: offline recognizers cannot decode incrementally — they need all
// audio buffered first, then decoded in one shot when dictation stops.
offlineBuffer.push(new Float32Array(samples))
// Why: decoding one unbounded capture in a single call makes ONNX tensor
// sizes scale with dictation length until a >=2 GiB allocation SIGTRAPs
// the whole app (#7925). Decode bounded chunks as they fill instead;
// each consumer already appends multiple 'final' segments per session.
const readyChunks = offlineChunker?.push(new Float32Array(samples)) ?? []
for (const chunk of readyChunks) {
const text = decodeOfflineChunk(chunk)
if (text) {
parentPort?.postMessage({ type: 'final', text })
}
}
}
} catch (err) {
parentPort?.postMessage({ type: 'error', error: String(err) })
@ -265,27 +285,15 @@ function handleStop(): void {
}
stream = sherpa.createOnlineStream(recognizer)
} else {
// Why: offline recognizer decodes all audio at once — concatenate
// buffered chunks into a single Float32Array and feed it to the stream.
const totalLength = offlineBuffer.reduce((sum, chunk) => sum + chunk.length, 0)
if (totalLength > 0) {
const combined = new Float32Array(totalLength)
let offset = 0
for (const chunk of offlineBuffer) {
combined.set(chunk, offset)
offset += chunk.length
}
sherpa.acceptWaveformOffline(stream, { sampleRate: offlineSampleRate, samples: combined })
sherpa.decodeOfflineStream(recognizer, stream)
const resultJson = sherpa.getOfflineStreamResultAsJson(stream)
const result = JSON.parse(resultJson)
const text = result?.text?.trim()
// Why: the remainder is below the chunk limit by construction, so this
// last decode is bounded too.
const remaining = offlineChunker?.flush()
if (remaining && remaining.length > 0) {
const text = decodeOfflineChunk(remaining)
if (text) {
parentPort?.postMessage({ type: 'final', text })
}
}
offlineBuffer = []
stream = sherpa.createOfflineStream(recognizer)
}
} catch (err) {
parentPort?.postMessage({ type: 'error', error: String(err) })
@ -298,7 +306,7 @@ function handleTeardown(): void {
stream = null
recognizer = null
sherpa = null
offlineBuffer = []
offlineChunker = null
process.exit(0)
}