Skip to main content
Architecture Exploration9 min read

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

  1. Before transport

    What should travel?

    Sample format is a choice, not a property of the microphone.

  2. Before display

    What earns a caption?

    Returned text still passes an explicit client acceptance rule.

  3. Before a performance claim

    What was actually tested?

    Separate execution paths identify work; they do not measure its speed.

These are inspection questions, not measured outcomes. The companion project owns the complete system boundary and broadcast workflow.

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.

Companion project artifact

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

DownsampleClamp + Int16

Transmitted format

16 kHz · mono · Int16

16,000 × 1 × 2 bytes

≈ 32 KB/sec

≈ 91.7% less raw sample data in this comparison

The 91.7% reduction is arithmetic derived from the two stated sample formats before protocol overhead, not a measured bandwidth result.

The Client-Side Downsampling Solution

In CaptionControl.vue, downsampling is performed before packet transmission:

CaptionControl.vueSource excerpt
function 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

Next experiment: compare chunk-duration strategies rather than treating the default as validated.

Three seconds is the public backend default when no client override is supplied. The comparison describes a qualitative trade-off; it does not identify an optimum.

The implementation of that default is visible in fastapi_app.py:

fastapi_app.pySource excerpt
chunk_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 excerpt
function 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

  1. 01Buffered audio reaches process_buffer()
  2. 02Whisper runs without VAD first
  3. 03Segments return to CaptionControl.vue
  4. 04Client accepts nonempty text when no_speech_prob ≤ 0.6 and temperature ≤ 0.8
The distinction is source-grounded. Client filtering can reduce likely low-confidence output; it does not guarantee that all undesirable text is eliminated.

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 excerpt
const 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

Moving inference off the async event loop separates blocking model work from transport handling. The renderer uses its own scheduling path; the source still shows forced-layout risk worth testing.

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.