Architecture Notes: Audio Chunking, Transcription Filtering, and Real-Time Speech UI Systems
A technical exploration of streaming audio chunk management, Whisper transcription filtering, and Electron overlay rendering, grounded in the current public speechBubbles and whisper-api source.
- #speech-to-text
- #audio-engineering
- #whisper
- #electron
- #ui-architecture
- #obs-studio
Reading depth
A caption is the last decision in a chain.
Live transcription is not just a model returning text. Audio must be captured, reduced, accumulated, transcribed, accepted, and rendered while each part operates under a different constraint. This paper begins inside the working speechBubbles system established by the project case study; it inspects what the current public source actually does at four boundaries.
Three questions at the hand-offs
An editorial lens, not a system map
Before transport
What should travel?
Sample format is a choice, not a property of the microphone.
Before display
What earns a caption?
Returned text still passes an explicit client acceptance rule.
Before a performance claim
What was actually tested?
Separate execution paths identify work; they do not measure its speed.
Reduce the data; choose the wait
The capture format need not be the transport format. In the source, client-side downsampling and Int16 conversion change a 48 kHz stereo Float32 example from about 384 KB/sec of raw samples to 16 kHz mono Int16 at about 32 KB/sec before WebSocket overhead. That ~91.7% difference is arithmetic, not a measured bandwidth result. The service then accumulates fixed-duration chunks: shorter ones can return updates earlier with less linguistic context; longer ones offer more context but arrive later. Three seconds is the public backend default without an override, not an established optimum or a sliding window.
Follow the active path, not the repository inventory
VAD scaffolding exists, but the current public transcription path does not run it before Whisper. Instead, returned segments reach client-side filtering, which accepts nonempty text only when no_speech_prob ≤ 0.6 and temperature ≤ 0.8. This can suppress likely low-confidence text; it cannot promise that all phantom captions disappear.
Separation makes the next tests legible
Whisper inference runs through asyncio.to_thread() rather than blocking the service event loop. Caption layout and transitions follow their own Electron/Vue scheduling path, which still includes geometry reads worth investigating. Neither separation proves low latency, reliability, or smooth rendering. The next experiments are to wire and evaluate VAD, compare chunk strategies, and batch geometry reads before DOM writes.
Inspect source behavior, the four reconciled figures, and the qualifications behind each boundary.
The speechBubbles project case study establishes the working prototype, creator role, broadcast workflow, and system-level responsibility boundary. This paper begins inside that boundary and inspects four implementation paths: audio-format reduction, chunking, transcription filtering, and async inference/render scheduling.
Real-time speech UI is a pipeline problem. Capture, format reduction, buffering, inference, filtering, state, and rendering introduce different constraints, so the architecture is easier to reason about when those responsibilities remain separate.
This article details the engineering architecture and streaming trade-offs behind speechBubbles, an on-device live broadcast graphics and speech-to-text overlay platform. For the full system case study, see the speechBubbles project detail.
The Audio Sample Rate Dilemma
Consumer audio hardware and browser AudioContext instances commonly operate at 44,100 Hz or 48,000 Hz using floating-point sample buffers.
Sending 48 kHz stereo 32-bit float audio directly would produce about 384 KB/sec of raw sample data before protocol overhead:
48,000 samples/sec × 2 channels × 4 bytes/sample = 384,000 bytes/sec.
Figure 01
Reduce before transport
Source arithmetic · not a benchmark
Source example
48 kHz · stereo · Float32
48,000 × 2 × 4 bytes≈ 384 KB/sec
Transmitted format
16 kHz · mono · Int16
16,000 × 1 × 2 bytes≈ 32 KB/sec
≈ 91.7% less raw sample data in this comparison
The Client-Side Downsampling Solution
In CaptionControl.vue, downsampling is performed before packet transmission:
CaptionControl.vueSource excerptfunction downsampleBuffer(buffer, inputSampleRate, outputSampleRate = 16000) {
if (inputSampleRate === outputSampleRate) return buffer;
const sampleRateRatio = inputSampleRate / outputSampleRate;
const newLength = Math.round(buffer.length / sampleRateRatio);
const result = new Float32Array(newLength);
let offsetResult = 0;
let offsetBuffer = 0;
while (offsetResult < result.length) {
const nextOffsetBuffer = Math.round((offsetResult + 1) * sampleRateRatio);
let accum = 0, count = 0;
for (let i = offsetBuffer; i < nextOffsetBuffer && i < buffer.length; i++) {
accum += buffer[i];
count++;
}
result[offsetResult] = count > 0 ? accum / count : 0;
offsetResult++;
offsetBuffer = nextOffsetBuffer;
}
return result;
}The filtered float array is then clamped and converted to signed 16-bit integers (Int16Array). At 16 kHz × 16-bit mono, that format is approximately 32 kB/s (32,000 bytes/s) before WebSocket overhead—a mathematically derived reduction of about 91.7% compared with the 48 kHz stereo 32-bit-float example above.
Buffered Chunking: Delay vs. Linguistic Context
Speech transcription needs enough surrounding audio to resolve word boundaries and linguistic context. That creates a practical trade-off:
Figure 02
Chunk duration changes the trade-off
Current default + proposed experiment
Shorter chunks
Earlier updates
Less linguistic context; greater risk of unstable or incomplete phrases.
Longer chunks
Later updates
More decoding context; transcript arrives later.
Current implementation
Fixed-duration accumulator · default 3 seconds · processed samples cleared after transcription · not an overlapping sliding window
The implementation of that default is visible in fastapi_app.py:
fastapi_app.pySource excerptchunk_length_bytes = int(client.config.get("chunk_length_seconds", 3)) * client.sampling_rate * client.samples_width
if len(client.buffer) >= chunk_length_bytes:
client.scratch_buffer.extend(client.buffer)
client.buffer.clear()
asyncio.create_task(self.process_buffer(client, websocket))After transcription finishes, scratch_buffer is cleared. Because processed samples are not intentionally retained and reused across overlapping windows, describing this as a sliding window would overstate what the current source implements.
Handling Low-Confidence Transcription Output
Continuous microphone input can cause Whisper to emit undesirable text during silence, ambient noise, or otherwise low-confidence audio. The public source currently addresses this primarily on the client after transcription.
Current Backend Behavior
whisper-api includes a VADFactory, PyAnnote dependencies, and a lazy get_vad() helper. However, the current public process_buffer() implementation does not invoke that VAD pipeline before model.transcribe().
Client-Side Probabilistic Filtering
In CaptionControl.vue, returned segments are evaluated against Whisper metadata:
CaptionControl.vueSource excerptfunction filterSegments(segments) {
for (let x = 0; x < segments.length; x += 1) {
const seg = segments[x];
const noSpeech = seg.no_speech_prob ?? 0;
const temp = seg.temperature ?? 0;
if (noSpeech <= 0.6 && temp <= 0.8 && seg.text?.trim()) {
store.captions.push(seg.text.trim());
}
}
}Segments above those thresholds are discarded by the client. This helps suppress likely hallucinated or low-confidence text before it reaches the caption queue, but it does not guarantee that all phantom output is eliminated.
Figure 03
Repository presence is not runtime behavior
Current public source
Present in repository
- VADFactory
- PyAnnote dependencies
- lazy get_vad() helper
Available scaffolding, not active silence dropping.
Active path
- Buffered audio reaches process_buffer()
- Whisper runs without VAD first
- Segments return to CaptionControl.vue
- Client accepts nonempty text when no_speech_prob ≤ 0.6 and temperature ≤ 0.8
Async Inference & UI Rendering
The backend uses asyncio.to_thread() around model.transcribe(), moving blocking inference off the async event loop. The same principle—keep blocking work off the responsive path—applies in the renderer.
CaptionsView.vue measures caption geometry after Vue’s nextTick(), then uses nested requestAnimationFrame() callbacks to apply the transition styles:
CaptionsView.vueSource excerptconst performAnimation = (multiplier = 1) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
reference.$el.style.transition = `all ease-in-out ${animationTiming * multiplier}ms`;
reference.$el.style.opacity = x >= messageRefs.value.length - 1 ? 1 : 0;
reference.$el.style.transform = `revert`;
setTimeout(() => {
if (reference.$el) reference.$el.style.transition = 'unset';
}, animationTiming * multiplier);
});
});
};The current prototype still performs synchronous geometry reads when captions mount, including a second getBoundingClientRect() after style changes. That is acceptable as implementation evidence for the current prototype, but it is not presented here as an optimized rendering strategy; a production pass should batch geometry reads before DOM writes to reduce forced-layout risk.
Figure 04
Three execution paths
Source behavior · bounded interpretation
Async service path
Transport
WebSocket handling + buffering
Worker thread
Inference
asyncio.to_thread(model.transcribe)
Electron / Vue
Rendering
nextTick, geometry reads, requestAnimationFrame
Architectural Takeaways & Future Directions
The most defensible principle is specific: capture, blocking inference, acceptance policy, and rendering are easier to inspect when their ownership remains explicit. That separation does not establish a latency or reliability improvement by itself; it makes the different failure modes and optimization targets easier to locate.
The next experiments follow those boundaries: wire the existing VAD path into the active transcription flow, compare chunk-duration strategies rather than treating the current default as optimal, and batch caption geometry reads before DOM writes.
For the practical implementation and video demonstration of this architecture in action, visit the speechBubbles Project Case Study.
Research lineage
Where this argument came from and where it continues.
- Runs alongside
- Progressive Enhancement for Intelligent Interfaces
- Built as
- speechBubbles