diff --git a/Cargo.lock b/Cargo.lock
index 0bded2e4f8..2a24f68304 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1057,6 +1057,7 @@ dependencies = [
  "cpal 0.15.3 (git+https://github.com/CapSoftware/cpal?rev=3cc779a7b4ca)",
  "ffmpeg-next",
  "tokio",
+ "tracing",
 ]
 
 [[package]]
diff --git a/Cargo.toml b/Cargo.toml
index dc023746e9..50940f980c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -46,6 +46,7 @@ cidre = { git = "https://github.com/CapSoftware/cidre", rev = "bf84b67079a8", fe
     "macos_13_0",
     "cv",
     "cf",
+    "core_audio",
     "sc",
     "av",
     "blocks",
diff --git a/crates/audio/Cargo.toml b/crates/audio/Cargo.toml
index 322ceee1f4..49df5da1a8 100644
--- a/crates/audio/Cargo.toml
+++ b/crates/audio/Cargo.toml
@@ -8,6 +8,7 @@ edition = "2024"
 ffmpeg = { workspace = true }
 cpal = { workspace = true }
 tokio.workspace = true
+tracing = { workspace = true }
 
 [target.'cfg(target_os = "macos")'.dependencies]
 cidre = { workspace = true }
diff --git a/crates/audio/src/latency.rs b/crates/audio/src/latency.rs
new file mode 100644
index 0000000000..d7cb52855b
--- /dev/null
+++ b/crates/audio/src/latency.rs
@@ -0,0 +1,819 @@
+//! Audio latency correction module for Cap.
+//!
+//! This module provides comprehensive audio latency detection and correction to maintain
+//! proper audio-video synchronization. It handles measuring hardware buffer delays and
+//! compensating for transmission latency in wireless audio devices.
+//!
+//! # Quick Start
+//!
+//! ```rust,no_run
+//! use cap_audio::{LatencyCorrector, LatencyCorrectionConfig, default_output_latency_hint};
+//!
+//! // Get initial latency hint from hardware
+//! let hint = default_output_latency_hint(48000, 512);
+//!
+//! // Create corrector with default settings
+//! let mut corrector = LatencyCorrector::new(hint, LatencyCorrectionConfig::default());
+//!
+//! // Apply initial compensation to audio playhead
+//! let base_playhead = 5.0; // Current playback position in seconds
+//! let compensated_playhead = base_playhead + corrector.initial_compensation_secs();
+//! // audio_renderer.set_playhead(compensated_playhead);
+//!
+//! // In audio callback, update latency estimate
+//! // let current_latency = corrector.update_from_callback(&callback_info);
+//! ```
+//!
+//! # Transport Types
+//!
+//! The system automatically detects and handles different audio transports:
+//! - **Wired**: Standard audio interfaces (20-50ms typical)
+//! - **Bluetooth**: Wireless audio with encoding delays (120ms+ minimum)
+//! - **AirPlay**: Network streaming with substantial buffering (1.8s+ minimum)
+//!
+//! # Platform Support
+//!
+//! - **macOS**: Full Core Audio integration with precise device inspection
+//! - **Other platforms**: Buffer-based estimation with conservative defaults
+
+use std::time::{Duration, Instant};
+
+use cpal::OutputCallbackInfo;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum OutputTransportKind {
+    Wired,
+    Wireless,
+    Airplay,
+    ContinuityWireless,
+    Unknown,
+}
+
+impl OutputTransportKind {
+    pub const fn is_wireless(self) -> bool {
+        matches!(
+            self,
+            Self::Wireless | Self::Airplay | Self::ContinuityWireless
+        )
+    }
+}
+
+#[derive(Debug, Clone, Copy)]
+pub struct OutputLatencyHint {
+    pub latency_secs: f64,
+    pub transport: OutputTransportKind,
+}
+
+impl OutputLatencyHint {
+    pub fn new(latency_secs: f64, transport: OutputTransportKind) -> Self {
+        Self {
+            latency_secs,
+            transport,
+        }
+    }
+
+    pub fn is_probably_wireless(&self) -> bool {
+        self.transport.is_wireless()
+    }
+}
+
+/// Configuration for dynamic latency correction during playback
+#[derive(Debug, Clone, Copy)]
+pub struct LatencyCorrectionConfig {
+    /// Minimum change in latency (seconds) before applying correction
+    pub min_apply_delta_secs: f64,
+    /// Minimum number of latency updates before enabling dynamic correction
+    pub min_updates_for_dynamic: u64,
+    /// Maximum latency change per second to prevent artifacts
+    pub max_change_per_sec: f64,
+    /// Initial freeze duration to let the system stabilize
+    pub initial_freeze_duration_secs: f64,
+    /// Threshold for logging latency changes (milliseconds)
+    pub log_change_threshold_ms: i32,
+    /// Safety multiplier for initial latency compensation
+    pub initial_safety_multiplier: f64,
+}
+
+impl Default for LatencyCorrectionConfig {
+    fn default() -> Self {
+        Self {
+            min_apply_delta_secs: 0.005,
+            min_updates_for_dynamic: 4,
+            max_change_per_sec: 0.15,
+            initial_freeze_duration_secs: 0.35,
+            log_change_threshold_ms: 5,
+            initial_safety_multiplier: 2.0,
+        }
+    }
+}
+
+/// Manages latency correction for audio playback
+#[derive(Debug)]
+pub struct LatencyCorrector {
+    estimator: OutputLatencyEstimator,
+    config: LatencyCorrectionConfig,
+    last_latency_used: Option<f64>,
+    last_logged_latency_ms: Option<i32>,
+    last_latency_update_at: Instant,
+    latency_freeze_until: Instant,
+}
+
+impl LatencyCorrector {
+    pub fn new(hint: Option<OutputLatencyHint>, config: LatencyCorrectionConfig) -> Self {
+        let estimator = match hint {
+            Some(h) => OutputLatencyEstimator::from_hint(h),
+            None => OutputLatencyEstimator::new(),
+        };
+
+        let now = Instant::now();
+        Self {
+            estimator,
+            config,
+            last_latency_used: None,
+            last_logged_latency_ms: None,
+            last_latency_update_at: now,
+            latency_freeze_until: now
+                + Duration::from_secs_f64(config.initial_freeze_duration_secs),
+        }
+    }
+
+    /// Get the initial latency compensation value (with safety multiplier applied)
+    pub fn initial_compensation_secs(&self) -> f64 {
+        self.estimator.current_secs().unwrap_or_default() * self.config.initial_safety_multiplier
+    }
+
+    /// Update latency estimate from audio callback and return corrected latency
+    pub fn update_from_callback(&mut self, info: &OutputCallbackInfo) -> f64 {
+        let previous_update_count = self.estimator.update_count();
+        let estimated_latency_secs = self
+            .estimator
+            .observe_callback(info)
+            .or(self.last_latency_used)
+            .unwrap_or_default();
+
+        let now = Instant::now();
+
+        let latency_secs = if let Some(previous) = self.last_latency_used {
+            if now < self.latency_freeze_until {
+                previous
+            } else if self.estimator.update_count() >= self.config.min_updates_for_dynamic {
+                let dt_secs = now
+                    .checked_duration_since(self.last_latency_update_at)
+                    .map(|d| d.as_secs_f64())
+                    .unwrap_or(0.0);
+                let max_delta = (self.config.max_change_per_sec * dt_secs)
+                    .max(self.config.min_apply_delta_secs);
+                let delta = estimated_latency_secs - previous;
+
+                if delta.abs() <= max_delta {
+                    self.last_latency_update_at = now;
+                    estimated_latency_secs
+                } else {
+                    self.last_latency_update_at = now;
+                    previous + delta.signum() * max_delta
+                }
+            } else if (estimated_latency_secs - previous).abs() < self.config.min_apply_delta_secs {
+                previous
+            } else {
+                self.last_latency_update_at = now;
+                estimated_latency_secs
+            }
+        } else {
+            self.last_latency_update_at = now;
+            estimated_latency_secs
+        };
+
+        self.last_latency_used = Some(latency_secs);
+
+        // Log significant latency changes
+        if self.estimator.update_count() != previous_update_count {
+            let latency_ms = (latency_secs * 1_000.0).round() as i32;
+            let should_log = match self.last_logged_latency_ms {
+                Some(prev) => (prev - latency_ms).abs() >= self.config.log_change_threshold_ms,
+                None => latency_ms >= 0,
+            };
+
+            if should_log {
+                tracing::info!(
+                    "Estimated audio output latency: {:.1} ms",
+                    latency_secs * 1_000.0
+                );
+                self.last_logged_latency_ms = Some(latency_ms);
+            }
+        }
+
+        latency_secs
+    }
+
+    /// Get the current latency estimate without updating
+    pub fn current_latency_secs(&self) -> Option<f64> {
+        self.last_latency_used
+    }
+
+    /// Get the underlying latency estimator for advanced use cases
+    pub fn estimator(&self) -> &OutputLatencyEstimator {
+        &self.estimator
+    }
+
+    /// Get the underlying latency estimator mutably for advanced use cases
+    pub fn estimator_mut(&mut self) -> &mut OutputLatencyEstimator {
+        &mut self.estimator
+    }
+}
+
+const MAX_LATENCY_SECS: f64 = 3.0;
+const MIN_VALID_LATENCY_SECS: f64 = 0.000_1;
+const INCREASE_TAU_SECS: f64 = 0.25;
+const DECREASE_TAU_SECS: f64 = 1.0;
+const MAX_RISE_PER_SEC: f64 = 0.75;
+const WARMUP_GUARD_SAMPLES: u32 = 3;
+const WARMUP_SPIKE_RATIO: f64 = 50.0;
+#[cfg(not(target_os = "macos"))]
+const FALLBACK_WIRED_LATENCY_SECS: f64 = 0.03;
+const WIRELESS_FALLBACK_LATENCY_SECS: f64 = 0.20;
+const WIRELESS_MIN_LATENCY_SECS: f64 = 0.12;
+
+#[cfg(target_os = "macos")]
+const AIRPLAY_MIN_LATENCY_SECS: f64 = 1.8;
+
+/// Tracks the measured output latency reported by the active audio device.
+///
+/// The estimator smooths reported values so that sudden increases are applied quickly while
+/// decreases are adopted more conservatively. This keeps the playhead ahead of the hardware
+/// buffer without reacting to transient spikes that would otherwise cause unnecessary seeks.
+#[derive(Debug, Clone)]
+pub struct OutputLatencyEstimator {
+    smoothed_latency_secs: Option<f64>,
+    last_raw_latency_secs: Option<f64>,
+    update_count: u64,
+    bias_secs: f64,
+    last_update_at: Option<Instant>,
+    min_floor_secs: f64,
+    max_ceiling_secs: f64,
+}
+
+impl OutputLatencyEstimator {
+    pub fn new() -> Self {
+        Self::with_bias(0.0)
+    }
+
+    pub fn with_bias(bias_secs: f64) -> Self {
+        let bias_secs = bias_secs.max(0.0).min(MAX_LATENCY_SECS);
+        Self {
+            smoothed_latency_secs: if bias_secs > 0.0 {
+                Some(bias_secs)
+            } else {
+                None
+            },
+            last_raw_latency_secs: None,
+            update_count: 0,
+            bias_secs,
+            last_update_at: None,
+            min_floor_secs: 0.0,
+            max_ceiling_secs: MAX_LATENCY_SECS,
+        }
+    }
+
+    pub fn from_hint(hint: OutputLatencyHint) -> Self {
+        let mut estimator = Self::with_bias(0.0);
+        let (floor, ceiling) = transport_constraints(hint.transport);
+        estimator.set_floor_and_ceiling(floor, ceiling);
+
+        if hint.latency_secs > 0.0 {
+            let seeded = hint
+                .latency_secs
+                .max(estimator.min_floor_secs)
+                .min(estimator.max_ceiling_secs);
+            estimator.smoothed_latency_secs = Some(seeded);
+            estimator.last_update_at = Some(Instant::now());
+        }
+
+        estimator
+    }
+
+    pub fn set_bias_secs(&mut self, bias_secs: f64) {
+        self.bias_secs = bias_secs.max(0.0).min(MAX_LATENCY_SECS);
+    }
+
+    pub fn set_floor_and_ceiling(&mut self, min_floor_secs: f64, max_ceiling_secs: f64) {
+        self.min_floor_secs = min_floor_secs.clamp(0.0, MAX_LATENCY_SECS);
+        self.max_ceiling_secs = max_ceiling_secs
+            .max(self.min_floor_secs)
+            .min(MAX_LATENCY_SECS);
+        if let Some(current) = self.smoothed_latency_secs {
+            self.smoothed_latency_secs =
+                Some(current.max(self.min_floor_secs).min(self.max_ceiling_secs));
+        }
+    }
+
+    pub fn reset(&mut self) {
+        let bias = self.bias_secs;
+        let floor = self.min_floor_secs;
+        let ceiling = self.max_ceiling_secs;
+        *self = Self::with_bias(bias);
+        self.set_floor_and_ceiling(floor, ceiling);
+    }
+
+    /// Observes a callback invocation and updates the latency estimate when the backend reports a
+    /// playback timestamp.
+    pub fn observe_callback(&mut self, info: &OutputCallbackInfo) -> Option<f64> {
+        let timestamp = info.timestamp();
+        let latency = timestamp.playback.duration_since(&timestamp.callback);
+        self.observe_latency(latency)
+    }
+
+    /// Observes a latency duration directly. Exposed for tests.
+    pub fn observe_latency(&mut self, latency: Option<Duration>) -> Option<f64> {
+        if let Some(duration) = latency {
+            let secs = duration.as_secs_f64();
+            if secs.is_finite() {
+                self.record_latency(secs);
+            }
+        }
+
+        self.smoothed_latency_secs
+    }
+
+    /// Returns the current smoothed latency estimate in seconds, if available.
+    pub fn current_secs(&self) -> Option<f64> {
+        self.smoothed_latency_secs
+    }
+
+    pub fn current_duration(&self) -> Option<Duration> {
+        self.smoothed_latency_secs
+            .map(|secs| Duration::from_secs_f64(secs.max(0.0)))
+    }
+
+    pub fn bias_secs(&self) -> f64 {
+        self.bias_secs
+    }
+
+    pub fn last_raw_secs(&self) -> Option<f64> {
+        self.last_raw_latency_secs
+    }
+
+    pub fn update_count(&self) -> u64 {
+        self.update_count
+    }
+
+    fn record_latency(&mut self, secs: f64) {
+        if secs < 0.0 {
+            return;
+        }
+
+        let clamped = secs.min(self.max_ceiling_secs);
+
+        if clamped < MIN_VALID_LATENCY_SECS {
+            return;
+        }
+
+        if let Some(prev_raw) = self.last_raw_latency_secs {
+            if self.update_count < WARMUP_GUARD_SAMPLES as u64
+                && clamped > prev_raw * WARMUP_SPIKE_RATIO
+            {
+                self.last_raw_latency_secs = Some(clamped);
+                self.update_count = self.update_count.saturating_add(1);
+                return;
+            }
+        }
+
+        let now = Instant::now();
+        let dt_secs = self
+            .last_update_at
+            .map(|t| (now - t).as_secs_f64())
+            .unwrap_or(0.0);
+        self.last_update_at = Some(now);
+
+        self.last_raw_latency_secs = Some(clamped);
+        self.update_count = self.update_count.saturating_add(1);
+
+        let mut target_latency = clamped + self.bias_secs;
+        if target_latency < self.min_floor_secs {
+            target_latency = self.min_floor_secs;
+        }
+        if target_latency > self.max_ceiling_secs {
+            target_latency = self.max_ceiling_secs;
+        }
+
+        self.smoothed_latency_secs = Some(match self.smoothed_latency_secs {
+            Some(current) => {
+                let rising = target_latency > current;
+                let tau = if rising {
+                    INCREASE_TAU_SECS
+                } else {
+                    DECREASE_TAU_SECS
+                };
+                let alpha = time_based_alpha(dt_secs, tau);
+                let mut next = current + (target_latency - current) * alpha;
+
+                if rising && dt_secs > 0.0 {
+                    let max_allowed = current + MAX_RISE_PER_SEC * dt_secs;
+                    next = next.min(max_allowed);
+                }
+
+                next
+            }
+            None => target_latency,
+        });
+    }
+}
+
+impl Default for OutputLatencyEstimator {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+pub fn default_output_latency_hint(
+    sample_rate: u32,
+    buffer_size_frames: u32,
+) -> Option<OutputLatencyHint> {
+    if sample_rate == 0 {
+        return None;
+    }
+
+    #[cfg(target_os = "macos")]
+    {
+        macos::default_output_latency_hint(sample_rate, buffer_size_frames)
+    }
+
+    #[cfg(not(target_os = "macos"))]
+    {
+        let fallback = (buffer_size_frames as f64 / sample_rate as f64)
+            .max(FALLBACK_WIRED_LATENCY_SECS)
+            .min(MAX_LATENCY_SECS);
+        Some(OutputLatencyHint::new(
+            fallback,
+            OutputTransportKind::Unknown,
+        ))
+    }
+}
+
+fn time_based_alpha(dt_secs: f64, tau_secs: f64) -> f64 {
+    if tau_secs <= 0.0 {
+        return 1.0;
+    }
+
+    if dt_secs <= 0.0 {
+        1.0
+    } else {
+        let alpha = 1.0 - (-dt_secs / tau_secs).exp();
+        alpha.clamp(0.0, 1.0)
+    }
+}
+
+fn transport_constraints(transport: OutputTransportKind) -> (f64, f64) {
+    #[cfg(target_os = "macos")]
+    {
+        match transport {
+            OutputTransportKind::Airplay => (AIRPLAY_MIN_LATENCY_SECS, MAX_LATENCY_SECS),
+            OutputTransportKind::Wireless | OutputTransportKind::ContinuityWireless => {
+                (WIRELESS_MIN_LATENCY_SECS, MAX_LATENCY_SECS)
+            }
+            _ => (0.0, MAX_LATENCY_SECS),
+        }
+    }
+
+    #[cfg(not(target_os = "macos"))]
+    {
+        match transport {
+            OutputTransportKind::Wireless | OutputTransportKind::ContinuityWireless => {
+                (WIRELESS_MIN_LATENCY_SECS, MAX_LATENCY_SECS)
+            }
+            _ => (0.0, MAX_LATENCY_SECS),
+        }
+    }
+}
+
+#[cfg(target_os = "macos")]
+mod macos {
+    #[cfg(target_os = "macos")]
+    use super::AIRPLAY_MIN_LATENCY_SECS;
+    use super::{
+        MAX_LATENCY_SECS, OutputLatencyHint, OutputTransportKind, WIRELESS_FALLBACK_LATENCY_SECS,
+        WIRELESS_MIN_LATENCY_SECS, transport_constraints,
+    };
+    use cidre::{
+        core_audio::{
+            DeviceTransportType, PropElement, PropScope, PropSelector,
+            hardware::{Device, Stream, System},
+        },
+        os,
+    };
+
+    pub(super) fn default_output_latency_hint(
+        sample_rate: u32,
+        fallback_buffer_frames: u32,
+    ) -> Option<OutputLatencyHint> {
+        let device = System::default_output_device().ok()?;
+        compute_latency_hint(&device, sample_rate, fallback_buffer_frames).ok()
+    }
+
+    fn compute_latency_hint(
+        device: &Device,
+        sample_rate: u32,
+        fallback_buffer_frames: u32,
+    ) -> os::Result<OutputLatencyHint> {
+        let transport = device
+            .transport_type()
+            .unwrap_or(DeviceTransportType::UNKNOWN);
+        let transport_kind = transport_kind(transport);
+
+        let device_latency_frames =
+            scoped_u32(device, PropSelector::DEVICE_LATENCY, PropScope::OUTPUT).unwrap_or(0);
+        let safety_offset_frames = scoped_u32(
+            device,
+            PropSelector::DEVICE_SAFETY_OFFSET,
+            PropScope::OUTPUT,
+        )
+        .unwrap_or(0);
+        let buffer_frames = device
+            .prop(&PropSelector::DEVICE_BUF_FRAME_SIZE.global_addr())
+            .unwrap_or(fallback_buffer_frames);
+        let stream_latency_frames = max_output_stream_latency(device).unwrap_or(0);
+
+        let device_sample_rate = device.nominal_sample_rate().unwrap_or(sample_rate as f64);
+        let effective_rate = if device_sample_rate > 0.0 {
+            device_sample_rate
+        } else {
+            sample_rate as f64
+        };
+
+        let total_frames = device_latency_frames as u64
+            + safety_offset_frames as u64
+            + buffer_frames as u64
+            + stream_latency_frames as u64;
+
+        if total_frames == 0 {
+            let (floor, ceiling) = transport_constraints(transport_kind);
+            let base_latency = (buffer_frames as f64 / effective_rate).min(ceiling);
+            let fallback = if transport_kind.is_wireless() {
+                base_latency.max(WIRELESS_FALLBACK_LATENCY_SECS).max(floor)
+            } else {
+                base_latency.max(floor)
+            }
+            .min(MAX_LATENCY_SECS);
+            return Ok(OutputLatencyHint::new(fallback, transport_kind));
+        }
+
+        let mut latency_secs = total_frames as f64 / effective_rate;
+
+        match transport_kind {
+            OutputTransportKind::Airplay => {
+                if latency_secs < AIRPLAY_MIN_LATENCY_SECS {
+                    latency_secs = AIRPLAY_MIN_LATENCY_SECS;
+                }
+            }
+            OutputTransportKind::Wireless | OutputTransportKind::ContinuityWireless => {
+                if latency_secs < WIRELESS_MIN_LATENCY_SECS {
+                    latency_secs = WIRELESS_MIN_LATENCY_SECS;
+                }
+            }
+            _ => {}
+        }
+
+        latency_secs = latency_secs.min(MAX_LATENCY_SECS);
+
+        Ok(OutputLatencyHint::new(latency_secs, transport_kind))
+    }
+
+    fn scoped_u32(device: &Device, selector: PropSelector, scope: PropScope) -> os::Result<u32> {
+        device.prop(&selector.addr(scope, PropElement::MAIN))
+    }
+
+    fn max_output_stream_latency(device: &Device) -> os::Result<u32> {
+        let streams = device.streams()?;
+        let mut max_latency = 0u32;
+
+        for stream in streams {
+            if is_output_stream(&stream)? {
+                if let Ok(latency) = stream.latency() {
+                    max_latency = max_latency.max(latency);
+                }
+            }
+        }
+
+        Ok(max_latency)
+    }
+
+    fn is_output_stream(stream: &Stream) -> os::Result<bool> {
+        stream.direction().map(|dir| dir == 0)
+    }
+
+    fn transport_kind(transport: DeviceTransportType) -> OutputTransportKind {
+        match transport {
+            DeviceTransportType::AIR_PLAY => OutputTransportKind::Airplay,
+            DeviceTransportType::BLUETOOTH | DeviceTransportType::BLUETOOTH_LE => {
+                OutputTransportKind::Wireless
+            }
+            DeviceTransportType::CONTINUITY_CAPTURE_WIRELESS => {
+                OutputTransportKind::ContinuityWireless
+            }
+            DeviceTransportType::UNKNOWN => OutputTransportKind::Unknown,
+            _ => OutputTransportKind::Wired,
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::time::Instant;
+
+    #[test]
+    fn latency_estimator_increases_quickly() {
+        let mut estimator = OutputLatencyEstimator::new();
+        estimator.observe_latency(Some(Duration::from_millis(5)));
+        estimator.last_update_at = Some(Instant::now() - Duration::from_millis(300));
+        estimator.observe_latency(Some(Duration::from_millis(120)));
+
+        let latency = estimator.current_secs().unwrap();
+        assert!(latency > 0.05);
+    }
+
+    #[test]
+    fn latency_estimator_decreases_slowly() {
+        let mut estimator = OutputLatencyEstimator::new();
+        estimator.observe_latency(Some(Duration::from_millis(150)));
+        let first = estimator.current_secs().unwrap();
+
+        estimator.last_update_at = Some(Instant::now() - Duration::from_millis(60));
+        estimator.observe_latency(Some(Duration::from_millis(10)));
+        let second = estimator.current_secs().unwrap();
+
+        assert!(second > 0.02);
+        assert!(second > 0.5 * first);
+    }
+
+    #[test]
+    fn latency_estimator_ignores_initial_zero() {
+        let mut estimator = OutputLatencyEstimator::new();
+        estimator.observe_latency(Some(Duration::from_millis(0)));
+        assert!(estimator.current_secs().is_none());
+
+        estimator.last_update_at = Some(Instant::now() - Duration::from_millis(60));
+        estimator.observe_latency(Some(Duration::from_millis(120)));
+
+        assert!(estimator.current_secs().unwrap() > 0.1);
+    }
+
+    #[test]
+    fn wireless_floor_is_enforced() {
+        let hint = OutputLatencyHint::new(0.05, OutputTransportKind::Wireless);
+        let mut estimator = OutputLatencyEstimator::from_hint(hint);
+        assert!(estimator.current_secs().unwrap() >= WIRELESS_MIN_LATENCY_SECS);
+
+        estimator.last_update_at = Some(Instant::now() - Duration::from_millis(60));
+        estimator.observe_latency(Some(Duration::from_millis(20)));
+
+        assert!(estimator.current_secs().unwrap() >= WIRELESS_MIN_LATENCY_SECS);
+    }
+
+    #[test]
+    fn latency_corrector_applies_initial_multiplier() {
+        let hint = OutputLatencyHint::new(0.05, OutputTransportKind::Wired);
+        let config = LatencyCorrectionConfig::default();
+        let corrector = LatencyCorrector::new(Some(hint), config);
+
+        let initial = corrector.initial_compensation_secs();
+        assert_eq!(initial, 0.05 * 2.0); // Default multiplier is 2.0
+    }
+
+    #[test]
+    fn latency_corrector_freezes_initially() {
+        let hint = OutputLatencyHint::new(0.05, OutputTransportKind::Wired);
+        let config = LatencyCorrectionConfig::default();
+        let corrector = LatencyCorrector::new(Some(hint), config);
+
+        // Initially no latency used in callbacks
+        assert!(corrector.current_latency_secs().is_none());
+
+        // But initial compensation should be available with safety multiplier
+        let initial_compensation = corrector.initial_compensation_secs();
+        assert_eq!(initial_compensation, 0.05 * 2.0);
+    }
+
+    #[test]
+    fn latency_corrector_with_custom_config() {
+        let hint = OutputLatencyHint::new(0.1, OutputTransportKind::Wireless);
+        let config = LatencyCorrectionConfig {
+            initial_safety_multiplier: 3.0,
+            min_apply_delta_secs: 0.01,
+            ..Default::default()
+        };
+        let corrector = LatencyCorrector::new(Some(hint), config);
+
+        // Should apply custom multiplier, but 0.1 gets constrained to WIRELESS_MIN_LATENCY_SECS (0.12)
+        // So: 0.12 * 3.0 = 0.36
+        assert!((corrector.initial_compensation_secs() - 0.36).abs() < f64::EPSILON);
+    }
+
+    #[test]
+    fn default_latency_hint_fallback() {
+        // Test fallback calculation for unknown sample rate
+        let hint = default_output_latency_hint(0, 512);
+        assert!(hint.is_none());
+
+        // Test with valid parameters
+        let hint = default_output_latency_hint(48000, 512);
+        assert!(hint.is_some());
+
+        if let Some(h) = hint {
+            // Should have some reasonable latency value
+            assert!(h.latency_secs > 0.0);
+            assert!(h.latency_secs < 1.0); // Should be reasonable for most cases
+        }
+    }
+
+    #[test]
+    fn transport_kind_wireless_constraints() {
+        let wireless_hint = OutputLatencyHint::new(0.01, OutputTransportKind::Wireless);
+        let estimator = OutputLatencyEstimator::from_hint(wireless_hint);
+
+        // Should enforce minimum wireless latency
+        assert!(estimator.current_secs().unwrap() >= WIRELESS_MIN_LATENCY_SECS);
+    }
+
+    #[test]
+    fn latency_estimator_bias() {
+        let mut estimator = OutputLatencyEstimator::with_bias(0.05);
+        assert_eq!(estimator.bias_secs(), 0.05);
+
+        // Should start with bias value
+        assert_eq!(estimator.current_secs(), Some(0.05));
+
+        // Test bias modification
+        estimator.set_bias_secs(0.1);
+        assert_eq!(estimator.bias_secs(), 0.1);
+    }
+
+    #[test]
+    fn integration_latency_correction_workflow() {
+        // Simulate realistic audio playback scenario
+        let sample_rate = 48000;
+        let buffer_size = 512;
+
+        // 1. Get initial hardware hint
+        let hint = default_output_latency_hint(sample_rate, buffer_size);
+        assert!(hint.is_some());
+
+        let hint = hint.unwrap();
+        assert!(hint.latency_secs > 0.0);
+        assert!(hint.latency_secs < 1.0); // Reasonable for most hardware
+
+        // 2. Create corrector with custom config
+        let config = LatencyCorrectionConfig {
+            initial_safety_multiplier: 2.0,
+            min_apply_delta_secs: 0.001,
+            min_updates_for_dynamic: 3,
+            max_change_per_sec: 0.1,
+            initial_freeze_duration_secs: 0.1,
+            log_change_threshold_ms: 10,
+        };
+
+        let corrector = LatencyCorrector::new(Some(hint), config);
+
+        // 3. Verify initial compensation
+        let initial_compensation = corrector.initial_compensation_secs();
+        assert!(initial_compensation >= hint.latency_secs * 1.5); // At least 1.5x due to constraints
+        assert!(initial_compensation <= hint.latency_secs * 2.5); // But not more than 2.5x
+
+        // 4. Simulate audio rendering setup
+        let base_playhead = 5.0; // 5 seconds into the track
+        let compensated_playhead = base_playhead + initial_compensation;
+        assert!(compensated_playhead > base_playhead);
+
+        // 5. Verify corrector state
+        assert!(corrector.current_latency_secs().is_none()); // No callback updates yet
+        assert_eq!(corrector.estimator().update_count(), 0);
+
+        // This test demonstrates the complete workflow for integrating
+        // latency correction into audio playback systems
+    }
+
+    #[test]
+    fn output_transport_kind_wireless_detection() {
+        assert!(OutputTransportKind::Wireless.is_wireless());
+        assert!(OutputTransportKind::Airplay.is_wireless());
+        assert!(OutputTransportKind::ContinuityWireless.is_wireless());
+        assert!(!OutputTransportKind::Wired.is_wireless());
+        assert!(!OutputTransportKind::Unknown.is_wireless());
+    }
+
+    #[test]
+    fn output_latency_hint_creation() {
+        let hint = OutputLatencyHint::new(0.05, OutputTransportKind::Wired);
+        assert_eq!(hint.latency_secs, 0.05);
+        assert_eq!(hint.transport, OutputTransportKind::Wired);
+        assert!(!hint.is_probably_wireless());
+    }
+
+    #[test]
+    fn wireless_hint_detection() {
+        let wireless_hint = OutputLatencyHint::new(0.12, OutputTransportKind::Wireless);
+        assert!(wireless_hint.is_probably_wireless());
+
+        let wired_hint = OutputLatencyHint::new(0.03, OutputTransportKind::Wired);
+        assert!(!wired_hint.is_probably_wireless());
+    }
+}
diff --git a/crates/audio/src/lib.rs b/crates/audio/src/lib.rs
index a0df1c4c62..9540e1e189 100644
--- a/crates/audio/src/lib.rs
+++ b/crates/audio/src/lib.rs
@@ -1,7 +1,11 @@
 mod audio_data;
+mod latency;
+mod playback;
 mod renderer;
 
 pub use audio_data::*;
+pub use latency::*;
+// playback module now only re-exports from latency module
 pub use renderer::*;
 
 pub trait FromSampleBytes: cpal::SizedSample + std::fmt::Debug + Send + 'static {
diff --git a/crates/audio/src/playback.rs b/crates/audio/src/playback.rs
index e69de29bb2..543709d083 100644
--- a/crates/audio/src/playback.rs
+++ b/crates/audio/src/playback.rs
@@ -0,0 +1,6 @@
+// Re-exports moved to lib.rs to avoid unused import warnings
+
+#[cfg(test)]
+mod tests {
+    use crate::{OutputLatencyHint, OutputTransportKind};
+}
diff --git a/crates/editor/src/audio.rs b/crates/editor/src/audio.rs
index 99e195444b..144c111c62 100644
--- a/crates/editor/src/audio.rs
+++ b/crates/editor/src/audio.rs
@@ -237,6 +237,7 @@ pub struct AudioPlaybackBuffer<T: FromSampleBytes> {
 
 impl<T: FromSampleBytes> AudioPlaybackBuffer<T> {
     pub const PLAYBACK_SAMPLES_COUNT: u32 = 256;
+    pub const WIRELESS_PLAYBACK_SAMPLES_COUNT: u32 = 1024;
     const PROCESSING_SAMPLES_COUNT: u32 = 1024;
 
     pub fn new(data: Vec<AudioSegment>, output_info: AudioInfo) -> Self {
@@ -271,9 +272,9 @@ impl<T: FromSampleBytes> AudioPlaybackBuffer<T> {
             <= 2 * (Self::PROCESSING_SAMPLES_COUNT as usize) * self.resampler.output.channels
     }
 
-    pub fn render(&mut self, project: &ProjectConfiguration) {
+    fn render_chunk(&mut self, project: &ProjectConfiguration) -> bool {
         if self.buffer_reaching_limit() {
-            return;
+            return false;
         }
 
         let bytes_per_sample = self.resampler.output.sample_size();
@@ -287,19 +288,50 @@ impl<T: FromSampleBytes> AudioPlaybackBuffer<T> {
             None => self.resampler.flush_frame(),
         };
 
-        if let Some(rendered) = maybe_rendered {
-            let mut typed_data = vec![T::EQUILIBRIUM; rendered.len() / bytes_per_sample];
+        let Some(rendered) = maybe_rendered else {
+            return false;
+        };
+
+        if rendered.is_empty() {
+            return false;
+        }
+
+        let mut typed_data = vec![T::EQUILIBRIUM; rendered.len() / bytes_per_sample];
+
+        for (src, dest) in std::iter::zip(rendered.chunks(bytes_per_sample), &mut typed_data) {
+            *dest = T::from_bytes(src);
+        }
+        self.resampled_buffer.push_slice(&typed_data);
+        true
+    }
+
+    pub fn prefill(&mut self, project: &ProjectConfiguration, min_samples: usize) {
+        if min_samples == 0 {
+            return;
+        }
+
+        let capacity = self.resampled_buffer.capacity().get();
+        let target = min_samples.min(capacity);
 
-            for (src, dest) in std::iter::zip(rendered.chunks(bytes_per_sample), &mut typed_data) {
-                *dest = T::from_bytes(src);
+        while self.resampled_buffer.occupied_len() < target {
+            if !self.render_chunk(project) {
+                break;
             }
-            self.resampled_buffer.push_slice(&typed_data);
         }
     }
 
-    pub fn fill(&mut self, playback_buffer: &mut [T]) {
+    pub fn fill(
+        &mut self,
+        playback_buffer: &mut [T],
+        project: &ProjectConfiguration,
+        min_headroom_samples: usize,
+    ) {
+        self.prefill(project, min_headroom_samples.max(playback_buffer.len()));
+
         let filled = self.resampled_buffer.pop_slice(playback_buffer);
         playback_buffer[filled..].fill(T::EQUILIBRIUM);
+
+        self.prefill(project, min_headroom_samples);
     }
 }
 
diff --git a/crates/editor/src/playback.rs b/crates/editor/src/playback.rs
index 42c7a302d9..2a61b04489 100644
--- a/crates/editor/src/playback.rs
+++ b/crates/editor/src/playback.rs
@@ -1,15 +1,17 @@
-use cap_audio::FromSampleBytes;
+use cap_audio::{
+    FromSampleBytes, LatencyCorrectionConfig, LatencyCorrector, default_output_latency_hint,
+};
 use cap_media::MediaError;
 use cap_media_info::AudioInfo;
 use cap_project::{ProjectConfiguration, XY};
 use cap_rendering::{ProjectUniforms, RenderVideoConstants};
 use cpal::{
-    BufferSize, SampleFormat,
+    BufferSize, SampleFormat, SupportedBufferSize,
     traits::{DeviceTrait, HostTrait, StreamTrait},
 };
 use std::{sync::Arc, time::Duration};
 use tokio::{sync::watch, time::Instant};
-use tracing::{error, info};
+use tracing::{error, info, warn};
 
 use crate::{
     audio::{AudioPlaybackBuffer, AudioSegment},
@@ -232,43 +234,192 @@ impl AudioPlayback {
             ..
         } = self;
 
-        let mut output_info = AudioInfo::from_stream_config(&supported_config);
-        output_info.sample_format = output_info.sample_format.packed();
+        let mut base_output_info = AudioInfo::from_stream_config(&supported_config);
+        base_output_info.sample_format = base_output_info.sample_format.packed();
+        let default_output_info = base_output_info;
+
+        let initial_latency_hint =
+            default_output_latency_hint(base_output_info.sample_rate, base_output_info.buffer_size);
+        let is_wireless = initial_latency_hint
+            .as_ref()
+            .map(|hint| hint.transport.is_wireless())
+            .unwrap_or(false);
+
+        let default_samples_count = AudioPlaybackBuffer::<T>::PLAYBACK_SAMPLES_COUNT;
+        let wireless_samples_count = AudioPlaybackBuffer::<T>::WIRELESS_PLAYBACK_SAMPLES_COUNT;
+
+        #[derive(Clone, Copy, PartialEq, Eq)]
+        enum BufferSizeStrategy {
+            Fixed(u32),
+            DeviceDefault,
+        }
+
+        let candidate_order = if is_wireless {
+            vec![
+                BufferSizeStrategy::Fixed(wireless_samples_count),
+                BufferSizeStrategy::Fixed(default_samples_count),
+                BufferSizeStrategy::DeviceDefault,
+            ]
+        } else {
+            vec![
+                BufferSizeStrategy::Fixed(default_samples_count),
+                BufferSizeStrategy::DeviceDefault,
+            ]
+        };
+
+        let mut attempts = Vec::new();
+        for strategy in candidate_order {
+            if !attempts.contains(&strategy) {
+                attempts.push(strategy);
+            }
+        }
 
-        let mut audio_renderer = AudioPlaybackBuffer::new(segments, output_info);
         let playhead = f64::from(start_frame_number) / f64::from(fps);
-        audio_renderer.set_playhead(playhead, &project.borrow());
+        let mut last_error: Option<MediaError> = None;
+
+        for (attempt_index, strategy) in attempts.into_iter().enumerate() {
+            let mut config = supported_config.config();
+            base_output_info = match strategy {
+                BufferSizeStrategy::Fixed(desired) => {
+                    let clamped = match supported_config.buffer_size() {
+                        SupportedBufferSize::Range { min, max } => desired.clamp(*min, *max),
+                        SupportedBufferSize::Unknown => desired,
+                    };
+
+                    if let SupportedBufferSize::Range { min, max } = supported_config.buffer_size()
+                    {
+                        if clamped != desired {
+                            info!(
+                                requested_frames = desired,
+                                clamped_frames = clamped,
+                                range_min = *min,
+                                range_max = *max,
+                                "Adjusted requested audio buffer to fit device capabilities",
+                            );
+                        }
+                    }
 
-        let mut config = supported_config.config();
-        config.buffer_size = BufferSize::Fixed(AudioPlaybackBuffer::<T>::PLAYBACK_SAMPLES_COUNT);
+                    config.buffer_size = BufferSize::Fixed(clamped);
 
-        let mut elapsed = 0;
-        let mut prev_audio_config = project.borrow().audio.clone();
+                    let mut info =
+                        AudioInfo::from_stream_config_with_buffer(&supported_config, Some(clamped));
+                    info.sample_format = info.sample_format.packed();
+                    info
+                }
+                BufferSizeStrategy::DeviceDefault => {
+                    config.buffer_size = BufferSize::Default;
+                    default_output_info
+                }
+            };
 
-        let stream_result = device.build_output_stream(
-            &config,
-            move |buffer: &mut [T], _info| {
-                let project = project.borrow();
+            let sample_rate = base_output_info.sample_rate;
+            let buffer_size = base_output_info.buffer_size;
+            let channels = base_output_info.channels;
 
-                audio_renderer.set_playhead(
-                    playhead + elapsed as f64 / output_info.sample_rate as f64,
-                    &project,
-                );
-                prev_audio_config = project.audio.clone();
+            let headroom_samples = (buffer_size as usize)
+                .saturating_mul(channels)
+                .saturating_mul(2)
+                .max(channels * AudioPlaybackBuffer::<T>::PLAYBACK_SAMPLES_COUNT as usize);
 
-                audio_renderer.render(&project);
-                audio_renderer.fill(buffer);
+            let mut audio_renderer = AudioPlaybackBuffer::new(segments.clone(), base_output_info);
 
-                elapsed += buffer.len() / output_info.channels;
-            },
-            |_err| eprintln!("Audio stream error: {_err}"),
-            None,
-        );
+            match strategy {
+                BufferSizeStrategy::Fixed(desired) => {
+                    let actual = match config.buffer_size {
+                        BufferSize::Fixed(value) => value,
+                        _ => desired,
+                    };
 
-        let stream = stream_result.map_err(|e| {
-            MediaError::TaskLaunch(format!("Failed to build audio output stream: {e}"))
-        })?;
+                    if attempt_index == 0 {
+                        if actual > default_samples_count {
+                            info!("Using enlarged audio buffer: {} frames", actual);
+                        } else if is_wireless {
+                            info!(
+                                "Using device-limited audio buffer for wireless output: {} frames",
+                                actual
+                            );
+                        }
+                    } else {
+                        info!("Falling back to audio buffer size: {} frames", actual);
+                    }
+                }
+                BufferSizeStrategy::DeviceDefault => {
+                    if attempt_index == 0 {
+                        info!("Using device default audio buffer size");
+                    } else {
+                        info!("Falling back to device default audio buffer size");
+                    }
+                }
+            }
+
+            let static_latency_hint =
+                default_output_latency_hint(sample_rate, buffer_size).or(initial_latency_hint);
+            let latency_config = LatencyCorrectionConfig::default();
+            let mut latency_corrector = LatencyCorrector::new(static_latency_hint, latency_config);
+            let initial_compensation_secs = latency_corrector.initial_compensation_secs();
+
+            {
+                let project_snapshot = project.borrow();
+                audio_renderer
+                    .set_playhead(playhead + initial_compensation_secs, &project_snapshot);
+                audio_renderer.prefill(&project_snapshot, headroom_samples);
+            }
+
+            if let Some(hint) = static_latency_hint {
+                if hint.latency_secs > 0.0 {
+                    match hint.transport {
+                        cap_audio::OutputTransportKind::Airplay => info!(
+                            "Applying AirPlay output latency hint: {:.1} ms",
+                            hint.latency_secs * 1_000.0
+                        ),
+                        transport if transport.is_wireless() => info!(
+                            "Applying wireless output latency hint: {:.1} ms",
+                            hint.latency_secs * 1_000.0
+                        ),
+                        _ => info!(
+                            "Applying output latency hint: {:.1} ms",
+                            hint.latency_secs * 1_000.0
+                        ),
+                    }
+                }
+            }
+
+            let project_for_stream = project.clone();
+            let headroom_for_stream = headroom_samples;
+
+            let stream_result = device.build_output_stream(
+                &config,
+                move |buffer: &mut [T], info| {
+                    let _latency_secs = latency_corrector.update_from_callback(info);
+
+                    let project = project_for_stream.borrow();
+
+                    let playback_samples = buffer.len();
+                    let min_headroom = headroom_for_stream.max(playback_samples * 2);
+                    audio_renderer.fill(buffer, &project, min_headroom);
+                },
+                |_err| eprintln!("Audio stream error: {_err}"),
+                None,
+            );
+
+            match stream_result {
+                Ok(stream) => {
+                    return Ok((stop_rx, stream));
+                }
+                Err(err) => {
+                    warn!(
+                        error = %err,
+                        "Audio stream creation failed, attempting fallback"
+                    );
+                    last_error = Some(MediaError::TaskLaunch(format!(
+                        "Failed to build audio output stream: {err}"
+                    )));
+                }
+            }
+        }
 
-        Ok((stop_rx, stream))
+        Err(last_error.unwrap_or_else(|| {
+            MediaError::TaskLaunch("Failed to build audio output stream".to_string())
+        }))
     }
 }
diff --git a/crates/media-info/src/lib.rs b/crates/media-info/src/lib.rs
index 0ccfdcaa6d..3d90bd7f21 100644
--- a/crates/media-info/src/lib.rs
+++ b/crates/media-info/src/lib.rs
@@ -55,12 +55,19 @@ impl AudioInfo {
     }
 
     pub fn from_stream_config(config: &SupportedStreamConfig) -> Self {
+        Self::from_stream_config_with_buffer(config, None)
+    }
+
+    pub fn from_stream_config_with_buffer(
+        config: &SupportedStreamConfig,
+        buffer_size_override: Option<u32>,
+    ) -> Self {
         let sample_format = ffmpeg_sample_format_for(config.sample_format()).unwrap();
-        let buffer_size = match config.buffer_size() {
+        let buffer_size = buffer_size_override.unwrap_or_else(|| match config.buffer_size() {
             SupportedBufferSize::Range { max, .. } => *max,
             // TODO: Different buffer sizes for different contexts?
             SupportedBufferSize::Unknown => 1024,
-        };
+        });
 
         let raw_channels = config.channels();
         let channels = if Self::channel_layout_raw(raw_channels).is_some() {
diff --git a/crates/recording/src/feeds/microphone.rs b/crates/recording/src/feeds/microphone.rs
index d73c9e9eb5..719b03e083 100644
--- a/crates/recording/src/feeds/microphone.rs
+++ b/crates/recording/src/feeds/microphone.rs
@@ -1,7 +1,7 @@
 use cap_media_info::{AudioInfo, ffmpeg_sample_format_for};
 use cap_timestamp::Timestamp;
 use cpal::{
-    Device, InputCallbackInfo, SampleFormat, StreamError, SupportedStreamConfig,
+    BufferSize, Device, InputCallbackInfo, SampleFormat, StreamError, SupportedStreamConfig,
     SupportedStreamConfigRange,
     traits::{DeviceTrait, HostTrait, StreamTrait},
 };
@@ -62,6 +62,7 @@ impl OpenState {
             self.attached = Some(AttachedState {
                 id: data.id,
                 config: data.config.clone(),
+                buffer_size_frames: data.buffer_size_frames,
                 done_tx: data.done_tx,
             });
             self.connecting = None;
@@ -78,6 +79,7 @@ struct AttachedState {
     #[allow(dead_code)]
     id: u32,
     config: SupportedStreamConfig,
+    buffer_size_frames: Option<u32>,
     done_tx: mpsc::SyncSender<()>,
 }
 
@@ -168,11 +170,56 @@ fn select_sample_rate(config: &SupportedStreamConfigRange) -> cpal::SampleRate {
     cpal::SampleRate(config.max_sample_rate().0)
 }
 
+const TARGET_LATENCY_MS: u32 = 35;
+const MIN_LATENCY_MS: u32 = 10;
+const MAX_LATENCY_MS: u32 = 120;
+const ABS_MIN_BUFFER_FRAMES: u32 = 128;
+
+fn stream_config_with_latency(config: &SupportedStreamConfig) -> (cpal::StreamConfig, Option<u32>) {
+    let mut stream_config: cpal::StreamConfig = config.clone().into();
+    let buffer_size_frames = desired_buffer_size_frames(config);
+
+    if let Some(frames) = buffer_size_frames {
+        stream_config.buffer_size = BufferSize::Fixed(frames);
+    }
+
+    (stream_config, buffer_size_frames)
+}
+
+fn desired_buffer_size_frames(config: &SupportedStreamConfig) -> Option<u32> {
+    match config.buffer_size() {
+        cpal::SupportedBufferSize::Range { min, max } => {
+            let sample_rate = config.sample_rate().0;
+
+            if sample_rate == 0 || *max == 0 {
+                return None;
+            }
+
+            let desired = latency_ms_to_frames(sample_rate, TARGET_LATENCY_MS);
+            let min_latency_frames = latency_ms_to_frames(sample_rate, MIN_LATENCY_MS);
+            let max_latency_frames = latency_ms_to_frames(sample_rate, MAX_LATENCY_MS);
+
+            let desired = desired.clamp(min_latency_frames, max_latency_frames);
+            let device_max = *max;
+            let device_min = ABS_MIN_BUFFER_FRAMES.min(device_max).max(*min);
+
+            Some(desired.clamp(device_min, device_max))
+        }
+        cpal::SupportedBufferSize::Unknown => None,
+    }
+}
+
+fn latency_ms_to_frames(sample_rate: u32, milliseconds: u32) -> u32 {
+    let frames = (sample_rate as u64 * milliseconds as u64) / 1_000;
+    frames.max(1) as u32
+}
+
 #[derive(Reply)]
 pub struct MicrophoneFeedLock {
     actor: ActorRef<MicrophoneFeed>,
     config: SupportedStreamConfig,
     audio_info: AudioInfo,
+    buffer_size_frames: Option<u32>,
     drop_tx: Option<oneshot::Sender<()>>,
 }
 
@@ -184,6 +231,10 @@ impl MicrophoneFeedLock {
     pub fn audio_info(&self) -> AudioInfo {
         self.audio_info
     }
+
+    pub fn buffer_size_frames(&self) -> Option<u32> {
+        self.buffer_size_frames
+    }
 }
 
 impl Deref for MicrophoneFeedLock {
@@ -219,6 +270,7 @@ pub struct Lock;
 struct InputConnected {
     id: u32,
     config: SupportedStreamConfig,
+    buffer_size_frames: Option<u32>,
     done_tx: SyncSender<()>,
 }
 
@@ -266,17 +318,20 @@ impl Message<SetInput> for MicrophoneFeed {
         };
 
         let sample_format = config.sample_format();
+        let (stream_config, buffer_size_frames) = stream_config_with_latency(&config);
 
-        let (ready_tx, ready_rx) = oneshot::channel();
+        let (ready_tx, ready_rx) = oneshot::channel::<Result<Option<u32>, SetInputError>>();
         let (done_tx, done_rx) = mpsc::sync_channel(0);
 
         let actor_ref = ctx.actor_ref();
         let ready = {
-            let config = config.clone();
+            let config_for_ready = config.clone();
             ready_rx
-                .map(|v| {
+                .map(move |v| {
+                    let config = config_for_ready.clone();
                     v.map_err(|_| SetInputError::BuildStreamCrashed)
-                        .map(|_| config)
+                        .and_then(|inner| inner)
+                        .map(|buffer_size| (config, buffer_size))
                 })
                 .shared()
         };
@@ -289,9 +344,10 @@ impl Message<SetInput> for MicrophoneFeed {
                 ready
                     .clone()
                     .map(move |v| {
-                        v.map(|config| InputConnected {
+                        v.map(|(config, buffer_size_frames)| InputConnected {
                             id,
                             config,
+                            buffer_size_frames,
                             done_tx,
                         })
                     })
@@ -301,6 +357,7 @@ impl Message<SetInput> for MicrophoneFeed {
 
         std::thread::spawn({
             let config = config.clone();
+            let stream_config = stream_config.clone();
             let device_name_for_log = device.name().ok();
             move || {
                 // Log all configs for debugging
@@ -317,16 +374,26 @@ impl Message<SetInput> for MicrophoneFeed {
                     }
                 }
 
+                let buffer_size_description = match &stream_config.buffer_size {
+                    BufferSize::Default => "default".to_string(),
+                    BufferSize::Fixed(frames) => format!(
+                        "{} frames (~{:.1}ms)",
+                        frames,
+                        (*frames as f64 / config.sample_rate().0 as f64) * 1000.0
+                    ),
+                };
+
                 info!(
-                    "🎤 Building stream for '{:?}' with config: rate={}, channels={}, format={:?}",
+                    "🎤 Building stream for '{:?}' with config: rate={}, channels={}, format={:?}, buffer_size={}",
                     device_name_for_log,
                     config.sample_rate().0,
                     config.channels(),
-                    sample_format
+                    sample_format,
+                    buffer_size_description
                 );
 
                 let stream = match device.build_input_stream_raw(
-                    &config.into(),
+                    &stream_config,
                     sample_format,
                     {
                         let actor_ref = actor_ref.clone();
@@ -371,7 +438,7 @@ impl Message<SetInput> for MicrophoneFeed {
                     return;
                 }
 
-                let _ = ready_tx.send(Ok(()));
+                let _ = ready_tx.send(Ok(buffer_size_frames));
 
                 match done_rx.recv() {
                     Ok(_) => {
@@ -387,13 +454,15 @@ impl Message<SetInput> for MicrophoneFeed {
         tokio::spawn({
             let ready = ready.clone();
             let actor = ctx.actor_ref();
+            let done_tx = done_tx;
             async move {
                 match ready.await {
-                    Ok(config) => {
+                    Ok((config, buffer_size_frames)) => {
                         let _ = actor
                             .tell(InputConnected {
                                 id,
                                 config,
+                                buffer_size_frames,
                                 done_tx,
                             })
                             .await;
@@ -405,7 +474,9 @@ impl Message<SetInput> for MicrophoneFeed {
             }
         });
 
-        Ok(ready.boxed())
+        let ready_for_return = ready.clone().map(|result| result.map(|(config, _)| config));
+
+        Ok(ready_for_return.boxed())
     }
 }
 
@@ -491,6 +562,7 @@ impl Message<Lock> for MicrophoneFeed {
         };
 
         let config = attached.config.clone();
+        let buffer_size_frames = attached.buffer_size_frames;
 
         self.state = State::Locked { inner: attached };
 
@@ -503,9 +575,10 @@ impl Message<Lock> for MicrophoneFeed {
         });
 
         Ok(MicrophoneFeedLock {
-            audio_info: AudioInfo::from_stream_config(&config),
+            audio_info: AudioInfo::from_stream_config_with_buffer(&config, buffer_size_frames),
             actor: ctx.actor_ref(),
             config,
+            buffer_size_frames,
             drop_tx: Some(drop_tx),
         })
     }
diff --git a/crates/recording/src/sources/audio_mixer.rs b/crates/recording/src/sources/audio_mixer.rs
index 9d27e72338..345e85b357 100644
--- a/crates/recording/src/sources/audio_mixer.rs
+++ b/crates/recording/src/sources/audio_mixer.rs
@@ -1,9 +1,6 @@
 use cap_media_info::AudioInfo;
 use cap_timestamp::{Timestamp, Timestamps};
-use futures::{
-    SinkExt,
-    channel::{mpsc, oneshot},
-};
+use futures::channel::{mpsc, oneshot};
 use std::{
     collections::VecDeque,
     sync::{
@@ -16,6 +13,11 @@ use tracing::{debug, info};
 
 use crate::output_pipeline::AudioFrame;
 
+const DEFAULT_BUFFER_TIMEOUT: Duration = Duration::from_millis(80);
+const MIN_BUFFER_TIMEOUT: Duration = Duration::from_millis(20);
+const MAX_BUFFER_TIMEOUT: Duration = Duration::from_millis(180);
+const BUFFER_TIMEOUT_HEADROOM: f64 = 2.0;
+
 // Wait TICK_MS for frames to arrive
 // Assume all sources' frames for that tick have arrived after TICK_MS
 // Insert silence where necessary for sources with no frames
@@ -25,6 +27,7 @@ use crate::output_pipeline::AudioFrame;
 struct MixerSource {
     rx: mpsc::Receiver<AudioFrame>,
     info: AudioInfo,
+    buffer_timeout: Duration,
     buffer: VecDeque<AudioFrame>,
     buffer_last: Option<(Timestamp, Duration)>,
 }
@@ -51,9 +54,12 @@ impl AudioMixerBuilder {
     }
 
     pub fn add_source(&mut self, info: AudioInfo, rx: mpsc::Receiver<AudioFrame>) {
+        let buffer_timeout = buffer_timeout_for(&info);
+
         self.sources.push(MixerSource {
             info,
             rx,
+            buffer_timeout,
             buffer: VecDeque::new(),
             buffer_last: None,
         });
@@ -118,6 +124,13 @@ impl AudioMixerBuilder {
 
         filter_graph.validate()?;
 
+        let max_buffer_timeout = self
+            .sources
+            .iter()
+            .map(|source| source.buffer_timeout)
+            .max()
+            .unwrap_or(DEFAULT_BUFFER_TIMEOUT);
+
         Ok(AudioMixer {
             sources: self.sources,
             samples_out: 0,
@@ -130,6 +143,7 @@ impl AudioMixerBuilder {
             _aformat: aformat,
             start_timestamp: None,
             timestamps: Timestamps::now(),
+            max_buffer_timeout,
         })
     }
 
@@ -202,6 +216,7 @@ pub struct AudioMixer {
     _aformat: ffmpeg::filter::Context,
     timestamps: Timestamps,
     start_timestamp: Option<Timestamp>,
+    max_buffer_timeout: Duration,
 }
 
 impl AudioMixer {
@@ -210,11 +225,11 @@ impl AudioMixer {
         48_000,
         2,
     );
-    pub const BUFFER_TIMEOUT: Duration = Duration::from_millis(200);
 
     fn buffer_sources(&mut self, now: Timestamp) {
         for source in &mut self.sources {
             let rate = source.info.rate();
+            let buffer_timeout = source.buffer_timeout;
 
             if let Some(last) = source.buffer_last {
                 let last_end = last.0 + last.1;
@@ -224,9 +239,9 @@ impl AudioMixer {
                 {
                     let mut remaining = elapsed_since_last;
 
-                    while remaining > Self::BUFFER_TIMEOUT {
-                        let chunk_samples =
-                            (Self::BUFFER_TIMEOUT.as_secs_f64() * rate as f64) as usize;
+                    while remaining > buffer_timeout {
+                        let chunk_samples = samples_for_timeout(rate, buffer_timeout);
+                        let frame_duration = duration_from_samples(chunk_samples, rate);
 
                         let mut frame = ffmpeg::frame::Audio::new(
                             source.info.sample_format,
@@ -240,10 +255,14 @@ impl AudioMixer {
                         }
 
                         let timestamp = last_end + (elapsed_since_last - remaining);
-                        source.buffer_last = Some((timestamp, Self::BUFFER_TIMEOUT));
+                        source.buffer_last = Some((timestamp, frame_duration));
                         source.buffer.push_back(AudioFrame::new(frame, timestamp));
 
-                        remaining -= Self::BUFFER_TIMEOUT;
+                        if frame_duration.is_zero() {
+                            break;
+                        }
+
+                        remaining = remaining.saturating_sub(frame_duration);
                     }
                 }
             }
@@ -319,16 +338,17 @@ impl AudioMixer {
             if let Some(elapsed_since_start) = now
                 .duration_since(self.timestamps)
                 .checked_sub(start_timestamp.duration_since(self.timestamps))
-                && elapsed_since_start > Self::BUFFER_TIMEOUT
+                && elapsed_since_start > self.max_buffer_timeout
             {
                 for source in &mut self.sources {
                     if source.buffer_last.is_none() {
                         let rate = source.info.rate();
+                        let buffer_timeout = source.buffer_timeout;
 
                         let mut remaining = elapsed_since_start;
-                        while remaining > Self::BUFFER_TIMEOUT {
-                            let chunk_samples =
-                                (Self::BUFFER_TIMEOUT.as_secs_f64() * rate as f64) as usize;
+                        while remaining > buffer_timeout {
+                            let chunk_samples = samples_for_timeout(rate, buffer_timeout);
+                            let frame_duration = duration_from_samples(chunk_samples, rate);
 
                             let mut frame = ffmpeg::frame::Audio::new(
                                 source.info.sample_format,
@@ -343,13 +363,14 @@ impl AudioMixer {
                             frame.set_rate(source.info.rate() as u32);
 
                             let timestamp = start_timestamp + (elapsed_since_start - remaining);
-                            source.buffer_last = Some((
-                                timestamp,
-                                Duration::from_secs_f64(chunk_samples as f64 / rate as f64),
-                            ));
+                            source.buffer_last = Some((timestamp, frame_duration));
                             source.buffer.push_front(AudioFrame::new(frame, timestamp));
 
-                            remaining -= Self::BUFFER_TIMEOUT;
+                            if frame_duration.is_zero() {
+                                break;
+                            }
+
+                            remaining = remaining.saturating_sub(frame_duration);
                         }
                     }
                 }
@@ -398,6 +419,49 @@ impl AudioMixer {
     }
 }
 
+fn buffer_timeout_for(info: &AudioInfo) -> Duration {
+    if info.sample_rate == 0 || info.buffer_size == 0 {
+        return DEFAULT_BUFFER_TIMEOUT;
+    }
+
+    let base = Duration::from_secs_f64(info.buffer_size as f64 / info.sample_rate as f64);
+
+    if base.is_zero() {
+        return DEFAULT_BUFFER_TIMEOUT;
+    }
+
+    let with_headroom = base.mul_f64(BUFFER_TIMEOUT_HEADROOM);
+
+    clamp_duration(with_headroom, MIN_BUFFER_TIMEOUT, MAX_BUFFER_TIMEOUT)
+}
+
+fn clamp_duration(value: Duration, min: Duration, max: Duration) -> Duration {
+    if value < min {
+        min
+    } else if value > max {
+        max
+    } else {
+        value
+    }
+}
+
+fn samples_for_timeout(rate: i32, timeout: Duration) -> usize {
+    if rate <= 0 {
+        return 1;
+    }
+
+    let samples = (timeout.as_secs_f64() * rate as f64).round();
+    samples.max(1.0) as usize
+}
+
+fn duration_from_samples(samples: usize, rate: i32) -> Duration {
+    if rate <= 0 {
+        return Duration::ZERO;
+    }
+
+    Duration::from_secs_f64(samples as f64 / rate as f64)
+}
+
 pub struct AudioMixerHandle {
     thread_handle: std::thread::JoinHandle<()>,
     stop_flag: Arc<AtomicBool>,
