# How Meetily Synchronizes Microphone and System Audio Streams Using a Ring Buffer Mixer

> Discover how Meetily synchronizes microphone and system audio streams using a ring buffer mixer. Learn about dual-ring buffers, zero-padding, and sample-by-sample mixing for seamless audio.

- Repository: [Zackriya Solutions/meetily](https://github.com/Zackriya-Solutions/meetily)
- Tags: internals
- Published: 2026-07-07

---

**Meetily's ring buffer mixing system synchronizes microphone and system audio streams by aligning them into fixed-size windows using a dual-ring buffer structure, zero-padding missing data, and mixing sample-by-sample with soft clipping.**

Meetily, an open-source meeting transcription tool from Zackriya-Solutions, captures two independent audio sources simultaneously: the user's microphone and system audio (desktop sound). Because these streams arrive at different times with varying chunk sizes, the app implements a **ring-buffer mixer** in [`frontend/src-tauri/src/audio/pipeline.rs`](https://github.com/Zackriya-Solutions/meetily/blob/main/frontend/src-tauri/src/audio/pipeline.rs) to synchronize them before sending the combined output to the transcription pipeline.

## Ring Buffer Architecture in `AudioMixerRingBuffer`

The synchronization engine centers on `AudioMixerRingBuffer`, defined in [`pipeline.rs`](https://github.com/Zackriya-Solutions/meetily/blob/main/pipeline.rs). This structure maintains two independent `VecDeque<f32>` buffers—one for each audio source—along with configuration parameters that define the mixing window.

Key fields include:

- **`mic_buffer`** – Stores raw microphone samples as 32-bit floats
- **`system_buffer`** – Stores raw system audio samples as 32-bit floats  
- **`window_size_samples`** – Defines the mixing window duration (default calculated from sample rate, typically 600ms)
- **`max_buffer_size`** – Safety limit (~400ms) preventing memory growth during stream lag

The buffer initializes with `AudioMixerRingBuffer::new(sample_rate)`, computing the window size:

```rust
let window_ms = 600.0;                     // 0.6 s window for mixing
let window_size_samples = (sample_rate as f32 * window_ms / 1000.0) as usize;

```

## Feeding Audio Chunks into the Ring Buffer

When `AudioChunk` objects arrive from either capture source, the pipeline calls `add_samples` to append data to the appropriate buffer:

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

```

This occurs in the main processing loop at approximately lines 217-222 of [`pipeline.rs`](https://github.com/Zackriya-Solutions/meetily/blob/main/pipeline.rs). The `add_samples` method pushes samples onto the corresponding `VecDeque`. If a buffer exceeds `max_buffer_size`, the oldest samples drop automatically with a logged warning—this prevents uncontrolled memory growth when one stream lags behind the other.

## Determining Mix Readiness with `can_mix()`

The system checks readiness through the `can_mix()` method:

```rust
fn can_mix(&self) -> bool {
    self.mic_buffer.len() >= self.window_size_samples ||
    self.system_buffer.len() >= self.window_size_samples
}

```

This returns **true** as soon as **either** stream accumulates enough samples for a full window. This design tolerates temporary gaps: the depleted stream receives zero-padding during extraction, ensuring continuous output without audible glitches or dropout artifacts.

## Extracting Aligned Windows with Zero-Padding

When `can_mix()` returns true, the pipeline calls `extract_window()` to retrieve synchronized pairs:

```rust
if let Some((mic_window, sys_window)) = self.ring_buffer.extract_window() {
    // ...
}

```

The `extract_window` method performs three critical operations:

1. **Drains** exactly `window_size_samples` from each buffer when sufficient data exists
2. **Zero-pads** underfilled buffers to prevent "last-sample-hold" artifacts
3. **Returns** a tuple `(mic_window, sys_window)` ready for mixing

The zero-padding logic handles partial buffers:

```rust
let mic_window = if self.mic_buffer.len() >= self.window_size_samples {
    self.mic_buffer.drain(0..self.window_size_samples).collect()
} else if !self.mic_buffer.is_empty() {
    let mut padded = self.mic_buffer.drain(..).collect::<Vec<f32>>();
    padded.resize(self.window_size_samples, 0.0);
    padded
} else {
    vec![0.0; self.window_size_samples]
};

```

## Mixing Streams with `ProfessionalAudioMixer::mix_window`

The `ProfessionalAudioMixer` struct handles the actual audio combination through `mix_window`. This method adds corresponding samples from both windows, applies scaling, and performs **soft clipping** to maintain the ±1.0 range:

```rust
let sys_scaled = sys * 1.0;               // 70% headroom kept in other code
let sum = mic + sys_scaled;
let mixed_sample = if sum.abs() > 1.0 { sum / sum.abs() } else { sum };

```

Because the microphone audio is already normalized to –23 LUFS during capture, the mixer applies no post-gain—only sample-wise addition with clipping protection. The resulting mixed buffer flows to two destinations: the **VAD processor** for Whisper transcription and the **recording sender** for WAV file persistence.

## Complete Audio Pipeline Flow

The end-to-end synchronization works as follows:

1. **`AudioCapture::process_audio_data`** generates `AudioChunk` objects from hardware
2. Chunks travel through the shared `audio_sender` to the pipeline
3. `add_samples` routes data into the appropriate ring buffer
4. `can_mix()` triggers when either buffer fills the window threshold
5. `extract_window()` produces aligned, zero-padded pairs
6. `ProfessionalAudioMixer::mix_window` combines streams with soft clipping
7. Mixed audio proceeds to VAD detection and Whisper transcription while optionally saving to disk

This architecture guarantees synchronization even when native chunk sizes differ between microphone and system audio interfaces, or when temporary latency spikes occur in either capture path.

## Working with the Ring Buffer: Code Example

The following Rust snippet demonstrates manual interaction with the ring buffer and mixer components:

```rust
use meetily_frontend::audio::pipeline::{AudioMixerRingBuffer, ProfessionalAudioMixer};
use meetily_frontend::audio::recording_state::DeviceType;

fn demo_ring_buffer() {
    // Assume 48 kHz pipeline rate
    let sample_rate = 48_000;
    let mut ring = AudioMixerRingBuffer::new(sample_rate);
    let mut mixer = ProfessionalAudioMixer::new(sample_rate);

    // Simulated microphone chunk (100 ms of a 440 Hz sine)
    let mic_samples = (0..(sample_rate / 10))
        .map(|i| (i as f32 * 440.0 * 2.0 * std::f32::consts::PI / sample_rate as f32).sin())
        .collect::<Vec<f32>>();
    ring.add_samples(DeviceType::Microphone, mic_samples);

    // Simulated system chunk (shorter, 30 ms of noise)
    let sys_samples = vec![0.05_f32; sample_rate / 33];
    ring.add_samples(DeviceType::System, sys_samples);

    // Keep extracting windows until the buffer is empty
    while ring.can_mix() {
        if let Some((mic_win, sys_win)) = ring.extract_window() {
            let mixed = mixer.mix_window(&mic_win, &sys_win);
            println!("Mixed window length: {}", mixed.len());
        }
    }
}

```

## Key Source Files

| File | Purpose |
|------|---------|
| [`frontend/src-tauri/src/audio/pipeline.rs`](https://github.com/Zackriya-Solutions/meetily/blob/main/frontend/src-tauri/src/audio/pipeline.rs) | Contains `AudioMixerRingBuffer`, `ProfessionalAudioMixer`, and the VAD-driven processing loop |
| [`frontend/src-tauri/src/audio/audio_capture.rs`](https://github.com/Zackriya-Solutions/meetily/blob/main/frontend/src-tauri/src/audio/audio_capture.rs) | Generates `AudioChunk` objects from microphone and system streams |
| [`frontend/src-tauri/src/audio/recording_state.rs`](https://github.com/Zackriya-Solutions/meetily/blob/main/frontend/src-tauri/src/audio/recording_state.rs) | Defines `AudioChunk` and `DeviceType` enums used by the ring buffer |
| [`frontend/src-tauri/src/audio/audio_processing.rs`](https://github.com/Zackriya-Solutions/meetily/blob/main/frontend/src-tauri/src/audio/audio_processing.rs) | Provides `audio_to_mono` and resampling utilities for preprocessing |

## Summary

- **Dual-ring buffer design** – Separate `VecDeque<f32>` buffers for microphone and system audio prevent cross-contamination while allowing independent timing
- **Window-based synchronization** – Fixed 600ms windows (configurable via sample rate) align streams regardless of input chunk size variance
- **Zero-padding tolerance** – The `extract_window` method pads missing data with silence rather than blocking, ensuring continuous transcription
- **Soft clipping protection** – `ProfessionalAudioMixer::mix_window` prevents distortion when combining normalized microphone and system audio
- **Memory safety** – `max_buffer_size` limits prevent unbounded growth during stream lag or disconnection

## Frequently Asked Questions

### What happens when one audio stream lags behind the other?

Meetily's ring buffer mixer continues processing the leading stream by zero-padding the lagging buffer when `extract_window` is called. The `can_mix()` method triggers mixing as soon as either buffer reaches the window threshold, ensuring minimal latency rather than waiting for both streams. This zero-padding approach prevents audio glitches while maintaining synchronization.

### How does the ring buffer prevent memory leaks during extended recordings?

The `AudioMixerRingBuffer` implements a `max_buffer_size` safety limit (approximately 400ms of audio). When either `mic_buffer` or `system_buffer` exceeds this limit, the oldest samples automatically drop with a logged warning. This protects against unbounded memory growth when one capture source disconnects or experiences severe jitter.

### Why does the mixer use soft clipping instead of hard limiting?

The `ProfessionalAudioMixer::mix_window` method applies soft clipping (`sum / sum.abs()` when exceeding ±1.0) to preserve audio quality during transient peaks. Since microphone audio is already normalized to –23 LUFS during capture, soft clipping handles occasional system audio spikes without introducing harsh digital distortion that would degrade transcription accuracy.

### Can the mixing window size be adjusted for different latency requirements?

The window size is calculated from the sample rate in `AudioMixerRingBuffer::new()` using a hardcoded 600ms value. While this provides a balance between latency and transcription quality, developers could modify the `window_ms` constant in [`pipeline.rs`](https://github.com/Zackriya-Solutions/meetily/blob/main/pipeline.rs) before compilation. Smaller windows reduce latency but increase processing overhead; larger windows improve alignment stability at the cost of increased delay.