Skip to content

Commit 75622b6

Browse files
authored
Fix quiet audio in desktop full-screen recordings (#2687)
1 parent 15389ab commit 75622b6

2 files changed

Lines changed: 191 additions & 69 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
type: fixed
3+
date: 2026-08-06
4+
---
5+
6+
Desktop full-screen recordings now normalize microphone level while recording, so clips are no longer quieter than the rest of your library.

templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs

Lines changed: 185 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -2145,6 +2145,175 @@ const MIX_SOURCE_GRACE: Duration = Duration::from_millis(2000);
21452145
const AUDIO_FORMAT_LPCM: u32 = 0x6C70_636D; // 'lpcm'
21462146
const AUDIO_FORMAT_FLAGS_FLOAT_PACKED: u32 = 1 | 8; // float + packed, interleaved, little-endian
21472147

2148+
/// Speech RMS the mic auto-gain aims for (~-20 dBFS), and how far it may
2149+
/// travel to get there. It never attenuates: a hot mic keeps its own level
2150+
/// and the limiter below owns peak safety.
2151+
const MIC_AGC_TARGET_RMS: f32 = 0.1;
2152+
const MIC_AGC_MIN_GAIN: f32 = 1.0;
2153+
const MIC_AGC_MAX_GAIN: f32 = 8.0; // +18 dB
2154+
/// Below this RMS the mic is between words: hold the current gain rather than
2155+
/// chasing the target, or every pause swells room tone to speech level.
2156+
///
2157+
/// Set at ~-60 dBFS, not the -50 dBFS that room tone alone would justify. A
2158+
/// quiet built-in MacBook mic — the whole reason this stage exists — can carry
2159+
/// speech at -45 dBFS RMS, and a floor set just under that would gate out the
2160+
/// exact signal it is supposed to lift. Worst case here is room tone amplified
2161+
/// to 0.001 × MIC_AGC_MAX_GAIN, still inaudible.
2162+
const MIC_AGC_NOISE_FLOOR_RMS: f32 = 0.001;
2163+
const MIC_AGC_ENVELOPE_SECONDS: f32 = 0.15;
2164+
/// Asymmetric: back off quickly when the mic gets loud (the limiter should be
2165+
/// a backstop, not the thing shaping the sound), return slowly so a boost
2166+
/// doesn't audibly pump across sentences.
2167+
const MIC_AGC_DUCK_SECONDS: f32 = 0.08;
2168+
const MIC_AGC_BOOST_SECONDS: f32 = 1.5;
2169+
/// Until the first speech has been levelled, converge at this rate instead of
2170+
/// `MIC_AGC_BOOST_SECONDS`. Gain starts at unity, so a quiet mic needs to
2171+
/// travel most of its range; at the steady-state rate that takes several
2172+
/// seconds and the opening sentence audibly swells.
2173+
const MIC_AGC_WARMUP_SECONDS: f32 = 0.2;
2174+
/// How close to the target counts as levelled and ends the warmup.
2175+
const MIC_AGC_CONVERGED_TOLERANCE: f32 = 0.05;
2176+
/// -1.0 dBFS, near the true-peak ceiling the offline loudnorm chain targets.
2177+
const MIX_LIMIT_CEILING: f32 = 0.891;
2178+
const MIX_LIMIT_RELEASE_SECONDS: f32 = 0.15;
2179+
2180+
/// One-pole smoothing coefficient for a time constant at the mixer's rate.
2181+
///
2182+
/// `exp_m1`, not `1.0 - exp(x)`: at 48 kHz the exponent is ~1e-5, so the
2183+
/// subtraction form cancels away most of the f32 mantissa and the longer time
2184+
/// constants come out with about three significant digits.
2185+
fn one_pole_coefficient(tau_seconds: f32, sample_rate: i32) -> f32 {
2186+
if tau_seconds <= 0.0 || sample_rate <= 0 {
2187+
return 1.0;
2188+
}
2189+
-(-1.0 / (tau_seconds * sample_rate as f32)).exp_m1()
2190+
}
2191+
2192+
/// Causal mic auto-gain.
2193+
///
2194+
/// ScreenCaptureKit captures without AGC — unlike the browser path, which gets
2195+
/// one from `autoGainControl` — so a MacBook mic lands far below the -16 LUFS
2196+
/// the offline chain in `native_screen.rs` normalizes to. Live-uploaded clips
2197+
/// never reach that chain: their bytes stream to the server while the writer
2198+
/// is still producing them, so nothing downstream can re-read the file. Gain
2199+
/// has to be decided here, from what has already been heard.
2200+
struct MicAutoGain {
2201+
mean_square: f32,
2202+
gain: f32,
2203+
/// False until the first speech has been levelled; see
2204+
/// `MIC_AGC_WARMUP_SECONDS`.
2205+
levelled: bool,
2206+
envelope_coefficient: f32,
2207+
warmup_coefficient: f32,
2208+
duck_coefficient: f32,
2209+
boost_coefficient: f32,
2210+
}
2211+
2212+
impl MicAutoGain {
2213+
fn new(sample_rate: i32) -> Self {
2214+
Self {
2215+
mean_square: 0.0,
2216+
gain: 1.0,
2217+
levelled: false,
2218+
envelope_coefficient: one_pole_coefficient(MIC_AGC_ENVELOPE_SECONDS, sample_rate),
2219+
warmup_coefficient: one_pole_coefficient(MIC_AGC_WARMUP_SECONDS, sample_rate),
2220+
duck_coefficient: one_pole_coefficient(MIC_AGC_DUCK_SECONDS, sample_rate),
2221+
boost_coefficient: one_pole_coefficient(MIC_AGC_BOOST_SECONDS, sample_rate),
2222+
}
2223+
}
2224+
2225+
fn next_gain(&mut self, left: f32, right: f32) -> f32 {
2226+
let peak = left.abs().max(right.abs());
2227+
self.mean_square += (peak * peak - self.mean_square) * self.envelope_coefficient;
2228+
let rms = self.mean_square.max(0.0).sqrt();
2229+
if rms >= MIC_AGC_NOISE_FLOOR_RMS {
2230+
let target = (MIC_AGC_TARGET_RMS / rms).clamp(MIC_AGC_MIN_GAIN, MIC_AGC_MAX_GAIN);
2231+
let coefficient = if !self.levelled {
2232+
self.warmup_coefficient
2233+
} else if target < self.gain {
2234+
self.duck_coefficient
2235+
} else {
2236+
self.boost_coefficient
2237+
};
2238+
self.gain += (target - self.gain) * coefficient;
2239+
if !self.levelled && (self.gain - target).abs() <= target * MIC_AGC_CONVERGED_TOLERANCE
2240+
{
2241+
self.levelled = true;
2242+
// Once per recording. A "clip is too quiet" report is only
2243+
// diagnosable if the log says what the mic measured and how
2244+
// much was added; `gain` pinned at MIC_AGC_MAX_GAIN means the
2245+
// cap, not the target, decided the level.
2246+
crate::logfile::diagnostic(&format!(
2247+
"[capture-health] mic auto-gain levelled: rms={rms:.6} gain={:.2} capped={}",
2248+
self.gain,
2249+
self.gain >= MIC_AGC_MAX_GAIN * 0.99
2250+
));
2251+
}
2252+
}
2253+
self.gain
2254+
}
2255+
}
2256+
2257+
/// Zero-latency peak limiter over the summed mix.
2258+
///
2259+
/// This replaces a flat 0.5x weight on each source, which bought clip safety
2260+
/// by discarding 6 dB whether or not anything was near full scale. Lookahead
2261+
/// would buy cleaner transients but costs an output delay, and the live
2262+
/// uploader has already streamed the frames preceding it.
2263+
struct PeakLimiter {
2264+
gain: f32,
2265+
release_coefficient: f32,
2266+
}
2267+
2268+
impl PeakLimiter {
2269+
fn new(sample_rate: i32) -> Self {
2270+
Self {
2271+
gain: 1.0,
2272+
release_coefficient: one_pole_coefficient(MIX_LIMIT_RELEASE_SECONDS, sample_rate),
2273+
}
2274+
}
2275+
2276+
fn next_gain(&mut self, peak: f32) -> f32 {
2277+
self.gain += (1.0 - self.gain) * self.release_coefficient;
2278+
if peak > MIX_LIMIT_CEILING {
2279+
self.gain = self.gain.min(MIX_LIMIT_CEILING / peak);
2280+
}
2281+
self.gain
2282+
}
2283+
}
2284+
2285+
/// The mixer's whole gain stage: auto-gain the mic, sum with system audio at
2286+
/// unity, limit the result. Kept separate from `LiveAudioMixer` so the mix rule
2287+
/// is reachable without constructing a `CMFormatDescription` — the reported bug
2288+
/// was a wrong per-frame weight, so that arithmetic is what needs covering.
2289+
struct MixGainStage {
2290+
mic: MicAutoGain,
2291+
limiter: PeakLimiter,
2292+
}
2293+
2294+
impl MixGainStage {
2295+
fn new(sample_rate: i32) -> Self {
2296+
Self {
2297+
mic: MicAutoGain::new(sample_rate),
2298+
limiter: PeakLimiter::new(sample_rate),
2299+
}
2300+
}
2301+
2302+
/// One output frame. Neither source is pre-attenuated: system audio passes
2303+
/// at unity and the mic is only ever boosted, with the limiter owning peak
2304+
/// safety for the sum.
2305+
fn frame(&mut self, system: (f32, f32), mic: (f32, f32)) -> (f32, f32) {
2306+
let mic_gain = self.mic.next_gain(mic.0, mic.1);
2307+
let left = system.0 + mic.0 * mic_gain;
2308+
let right = system.1 + mic.1 * mic_gain;
2309+
let limit = self.limiter.next_gain(left.abs().max(right.abs()));
2310+
(
2311+
(left * limit).clamp(-1.0, 1.0),
2312+
(right * limit).clamp(-1.0, 1.0),
2313+
)
2314+
}
2315+
}
2316+
21482317
#[derive(Clone, Copy, PartialEq, Eq)]
21492318
/// Which capture stream a pushed PCM chunk came from.
21502319
enum MixSource {
@@ -2224,63 +2393,6 @@ impl MixerTimeline {
22242393
}
22252394
}
22262395

2227-
#[cfg(test)]
2228-
mod mixer_timeline_tests {
2229-
use super::MixerTimeline;
2230-
2231-
fn stereo_frames(values: &[f32]) -> Vec<f32> {
2232-
values.iter().flat_map(|value| [*value, *value]).collect()
2233-
}
2234-
2235-
#[test]
2236-
fn trims_overlapping_frames_instead_of_extending_the_timeline() {
2237-
let mut timeline = MixerTimeline::new();
2238-
timeline.push_at(0, &stereo_frames(&[1.0, 2.0, 3.0, 4.0]), 0, 96_000);
2239-
timeline.push_at(3, &stereo_frames(&[4.0, 5.0, 6.0, 7.0]), 0, 96_000);
2240-
2241-
assert_eq!(
2242-
timeline.samples,
2243-
stereo_frames(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0])
2244-
);
2245-
assert_eq!(timeline.end_frame(), 7);
2246-
}
2247-
2248-
#[test]
2249-
fn ignores_fully_duplicated_buffers() {
2250-
let mut timeline = MixerTimeline::new();
2251-
let frames = stereo_frames(&[1.0, 2.0, 3.0]);
2252-
timeline.push_at(0, &frames, 0, 96_000);
2253-
timeline.push_at(0, &frames, 0, 96_000);
2254-
2255-
assert_eq!(timeline.samples, frames);
2256-
assert_eq!(timeline.end_frame(), 3);
2257-
}
2258-
2259-
#[test]
2260-
fn trims_input_that_starts_behind_already_emitted_output() {
2261-
let mut timeline = MixerTimeline::new();
2262-
timeline.started = true;
2263-
timeline.base_frame = 10;
2264-
timeline.push_at(8, &stereo_frames(&[1.0, 2.0, 3.0, 4.0]), 10, 96_000);
2265-
2266-
assert_eq!(timeline.samples, stereo_frames(&[3.0, 4.0]));
2267-
assert_eq!(timeline.end_frame(), 12);
2268-
}
2269-
2270-
#[test]
2271-
fn preserves_forward_timestamp_gaps_as_silence() {
2272-
let mut timeline = MixerTimeline::new();
2273-
timeline.push_at(0, &stereo_frames(&[1.0, 2.0]), 0, 96_000);
2274-
timeline.push_at(5, &stereo_frames(&[6.0]), 0, 96_000);
2275-
2276-
assert_eq!(
2277-
timeline.samples,
2278-
stereo_frames(&[1.0, 2.0, 0.0, 0.0, 0.0, 6.0])
2279-
);
2280-
assert_eq!(timeline.end_frame(), 6);
2281-
}
2282-
}
2283-
22842396
enum SourceBound {
22852397
/// Started and recently fed; bounds output to `end_frame`.
22862398
Active(i64),
@@ -2313,6 +2425,9 @@ struct LiveAudioMixer {
23132425
out_pos: i64,
23142426
system: MixerTimeline,
23152427
mic: MixerTimeline,
2428+
/// Gain stage, carried across chunks: emitted frames are already uploaded,
2429+
/// so its state is the only memory the mix has.
2430+
gain: MixGainStage,
23162431
created_at: Instant,
23172432
}
23182433

@@ -2360,6 +2475,7 @@ impl LiveAudioMixer {
23602475
out_pos: 0,
23612476
system: MixerTimeline::new(),
23622477
mic: MixerTimeline::new(),
2478+
gain: MixGainStage::new(sample_rate),
23632479
created_at: Instant::now(),
23642480
})
23652481
}
@@ -2465,15 +2581,14 @@ impl LiveAudioMixer {
24652581
}
24662582

24672583
/// Emit mixed sample buffers covering `out_pos..safe_end` in
2468-
/// `MIX_CHUNK_FRAMES` chunks: average system + mic per frame, clamp, wrap
2469-
/// as LPCM `CMSampleBuffer`s with contiguous PTS.
2584+
/// `MIX_CHUNK_FRAMES` chunks: auto-gain the mic, sum with system audio,
2585+
/// limit, wrap as LPCM `CMSampleBuffer`s with contiguous PTS.
24702586
///
2471-
/// Each source is weighted at 0.5 before summing so that two full-scale
2472-
/// signals (which occurs when a USB audio interface with software monitoring
2473-
/// routes the mic back through system audio) can never exceed ±1.0 and
2474-
/// hard-clip. The standard SCK pipeline applies the same 0.5×L + 0.5×R
2475-
/// pan-downmix for the same reason; loudnorm restores the target loudness
2476-
/// in post-processing.
2587+
/// Clip safety belongs to `PeakLimiter`, not to a fixed weight per source.
2588+
/// Two full-scale signals do occur — a USB interface with software
2589+
/// monitoring routes the mic back through system audio — but attenuating
2590+
/// every frame for that case is what left live-uploaded clips ~6 dB below
2591+
/// the rest, with no post-processing stage left to make it back up.
24772592
fn drain_ready(
24782593
&mut self,
24792594
flush: bool,
@@ -2493,10 +2608,11 @@ impl LiveAudioMixer {
24932608
let mut interleaved = vec![0.0_f32; n * 2];
24942609
for f in 0..n {
24952610
let frame = a + f as i64;
2496-
let (sl, sr) = self.system.sample_at(frame);
2497-
let (ml, mr) = self.mic.sample_at(frame);
2498-
interleaved[f * 2] = (sl * 0.5 + ml * 0.5).clamp(-1.0, 1.0);
2499-
interleaved[f * 2 + 1] = (sr * 0.5 + mr * 0.5).clamp(-1.0, 1.0);
2611+
let (left, right) = self
2612+
.gain
2613+
.frame(self.system.sample_at(frame), self.mic.sample_at(frame));
2614+
interleaved[f * 2] = left;
2615+
interleaved[f * 2 + 1] = right;
25002616
}
25012617
emitted.push(self.build_sample_buffer(&interleaved, a)?);
25022618
a = b;

0 commit comments

Comments
 (0)