@@ -2145,6 +2145,175 @@ const MIX_SOURCE_GRACE: Duration = Duration::from_millis(2000);
21452145const AUDIO_FORMAT_LPCM : u32 = 0x6C70_636D ; // 'lpcm'
21462146const 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.
21502319enum 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-
22842396enum 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