How Meetily's Audio Ring Buffer Manages Synchronization Between Mic and System Streams

Meetily's AudioMixerRingBuffer synchronizes independent microphone and system audio streams by maintaining separate VecDeque<f32> buffers for each source, extracting fixed-size mixing windows when either stream reaches the readiness threshold, and zero-padding any deficit to maintain time-aligned output for transcription and recording.

Meetily is an open-source meeting transcription application that mixes microphone input with system audio output into a single synchronized track. The audio ring buffer that manages synchronization between mic and system streams is implemented in Rust within frontend/src-tauri/src/audio/pipeline.rs, where it handles asynchronous capture rates across separate threads while preventing drift and audio artifacts.

Architecture of the AudioMixerRingBuffer

Dual-Buffer Design

The AudioMixerRingBuffer struct defined at lines 16-23 in frontend/src-tauri/src/audio/pipeline.rs allocates two independent VecDeque<f32> buffers: one for microphone samples and one for system audio samples. This separation allows each stream to accumulate data at its own rate while providing a unified interface for synchronized extraction.

Timing Parameters

The constructor AudioMixerRingBuffer::new(sample_rate) (lines 27-36) calculates two critical timing thresholds based on the sample rate:

  1. Mixing window: A fixed duration of approximately 50ms, converted to a sample count. This determines the chunk size for each mixing operation.
  2. Maximum buffer size: Approximately 400ms of audio data. This upper bound prevents unbounded memory growth during network jitter or processing delays while tolerating temporary arrival rate mismatches.

The Synchronization Pipeline

Ingesting Samples with add_samples()

Incoming audio chunks from capture threads arrive via add_samples(device_type, samples) (lines 49-84). This method:

  • Appends the floating-point samples to the appropriate internal buffer based on the DeviceType::Microphone or DeviceType::System discriminator
  • Automatically trims the buffer tail if the total length exceeds the 400ms maximum, emitting overflow warnings when data is discarded
  • Maintains chronological order without blocking, allowing capture threads to continue acquisition regardless of mixing backpressure

Triggering Mix with can_mix()

The can_mix() method (lines 87-90) implements the synchronization trigger logic. It returns true when either buffer contains at least one full 50ms mixing window. This design choice permits the mixing loop to proceed as soon as one stream is ready, rather than waiting for both, which minimizes latency while allowing the lagging stream to catch up in subsequent iterations.

Window Extraction and Zero-Padding

extract_window() (lines 92-139) handles the critical task of creating time-aligned audio slices:

  • Pulls exactly one mixing window (50ms) from each buffer
  • If a buffer lacks sufficient samples, the function zero-pads the missing positions with silence (0.0f32) rather than waiting or reusing old samples
  • Returns a tuple of Vec<f32> windows guaranteed to be identical in length, ensuring the downstream mixer receives matched pairs

This zero-padding strategy prevents temporal drift and avoids the echo artifacts or pitch distortions that would result from sample stretching or repetition.

Professional Mixing

The extracted windows are passed to ProfessionalAudioMixer::mix_window, which performs simple additive mixing followed by soft scaling. If the summed samples exceed the ±1.0 range, the mixer scales the entire window down proportionally to prevent hard clipping while preserving the relative dynamics of both sources.

Continuous Processing in AudioPipeline::run

Inside AudioPipeline::run (lines 817-830), a tight processing loop maintains synchronization:

while self.ring_buffer.can_mix() {
    if let Some((mic_win, sys_win)) = self.ring_buffer.extract_window() {
        let mixed = self.mixer.mix_window(&mic_win, &sys_win);
        // Mixed audio is sent to VAD for transcription and WAV recorder
    }
}

This loop continuously drains the buffer whenever a full window is available, processing all ready data before yielding. It handles scenarios where the microphone delivers chunks every 10ms while system audio arrives every 20ms, ensuring the output stream remains regular regardless of input irregularity.

Why This Design Prevents Audio Drift

Asynchronous Stream Handling: Microphone and system audio capture operate on separate OS threads with independent callbacks. The ring buffer decouples these arrival patterns from the processing timeline, allowing each stream to "catch up" during high-load periods without dropping data.

Jitter Absorption: The 400ms maximum buffer capacity absorbs temporary bursts from the OS audio stack or garbage collection pauses. Without this headroom, overflow would force sample dropping, creating audible glitches in the recording.

Zero-Padding Strategy: When one source temporarily stalls—such as when system audio mutes during silence—the insertion of calculated silence maintains the temporal alignment of the mixed track. This ensures that when audio resumes, it remains synchronized with the microphone stream rather than drifting out of phase.

Soft Scaling: The mixer's approach to preventing clipping preserves the integrity of both streams without introducing compression artifacts, maintaining transcription accuracy for speech while retaining background audio context.

Implementation Example

To initialize the pipeline with the ring buffer:

let mut manager = AudioPipelineManager::new();
manager.start(
    recording_state.clone(),
    transcription_sender,
    target_chunk_duration_ms,
    sample_rate,
    Some(recording_sender),
    mic_device_name,
    mic_device_kind,
    system_device_name,
    system_device_kind,
)?;

Feeding captured data into the ring buffer:

// Inside the audio capture callback
let chunk = AudioChunk {
    data: mono_data,
    sample_rate,
    timestamp,
    chunk_id,
    device_type: DeviceType::Microphone, // or DeviceType::System
};
state.send_audio_chunk(chunk)?;

The AudioPipeline routes these chunks to the buffer:

self.ring_buffer.add_samples(chunk.device_type.clone(), chunk.data);

Summary

  • Dual VecDeque<f32> buffers in AudioMixerRingBuffer isolate microphone and system streams while maintaining temporal alignment through independent ingestion.
  • 50ms mixing windows with zero-padding ensure both streams contribute equally-sized slices to the mixer, preventing phase drift when streams arrive at different rates.
  • 400ms maximum capacity absorbs OS-level jitter without audio drops, balancing latency tolerance against memory constraints.
  • Soft-scaled additive mixing in ProfessionalAudioMixer::mix_window prevents clipping when combining streams while preserving dynamic range.
  • Continuous while-loop in AudioPipeline::run processes windows as soon as either stream reaches the threshold, minimizing end-to-end latency for transcription.

Frequently Asked Questions

What happens if one audio stream stops sending data?

The extract_window() method detects buffer underflow and zero-pads the missing samples to maintain the fixed 50ms window size. This inserts silence rather than creating artifacts or repeating old audio, allowing the active stream to continue recording while preserving synchronization for when the stalled stream resumes.

How does the buffer handle different sample rates?

The AudioMixerRingBuffer initializes with a specific sample_rate parameter used to calculate window sizes in sample counts. The pipeline expects both input streams to match this rate; resampling occurs upstream in the capture devices before chunks enter the ring buffer.

What is the maximum latency introduced by the ring buffer?

The buffer introduces between 50ms and 400ms of latency depending on fill levels. The minimum latency equals one mixing window (~50ms), while the 400ms maximum represents the overflow threshold where oldest samples are discarded. Typical operation maintains 50-100ms of buffering during steady-state capture.

How does zero-padding affect transcription accuracy?

Zero-padding inserts calculated silence rather than interpolating or repeating audio content, ensuring the Voice Activity Detection (VAD) processor receives clean, time-aligned data. This prevents false transcriptions that would occur if the buffer duplicated microphone samples to fill gaps in system audio, maintaining accurate speaker diarization and timing.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →