How Meetily Synchronizes Microphone and System Audio Streams Using a Ring Buffer Mixer
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 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. 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 floatssystem_buffer– Stores raw system audio samples as 32-bit floatswindow_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:
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:
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. 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:
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:
if let Some((mic_window, sys_window)) = self.ring_buffer.extract_window() {
// ...
}
The extract_window method performs three critical operations:
- Drains exactly
window_size_samplesfrom each buffer when sufficient data exists - Zero-pads underfilled buffers to prevent "last-sample-hold" artifacts
- Returns a tuple
(mic_window, sys_window)ready for mixing
The zero-padding logic handles partial buffers:
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:
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:
AudioCapture::process_audio_datageneratesAudioChunkobjects from hardware- Chunks travel through the shared
audio_senderto the pipeline add_samplesroutes data into the appropriate ring buffercan_mix()triggers when either buffer fills the window thresholdextract_window()produces aligned, zero-padded pairsProfessionalAudioMixer::mix_windowcombines streams with soft clipping- 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:
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 |
Contains AudioMixerRingBuffer, ProfessionalAudioMixer, and the VAD-driven processing loop |
frontend/src-tauri/src/audio/audio_capture.rs |
Generates AudioChunk objects from microphone and system streams |
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 |
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_windowmethod pads missing data with silence rather than blocking, ensuring continuous transcription - Soft clipping protection –
ProfessionalAudioMixer::mix_windowprevents distortion when combining normalized microphone and system audio - Memory safety –
max_buffer_sizelimits 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 before compilation. Smaller windows reduce latency but increase processing overhead; larger windows improve alignment stability at the cost of increased delay.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →