@agent-native/core 0.157.0 → 0.157.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/server/jobs/analytics-bigquery-backfill.ts +19 -4
  3. package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +7 -2
  4. package/corpus/templates/assets/app/global.css +4 -0
  5. package/corpus/templates/assets/app/routes/brand-kits.$id.tsx +1 -2
  6. package/corpus/templates/brain/app/routes/sources.tsx +1 -2
  7. package/corpus/templates/calendar/app/hooks/use-bookings.ts +12 -2
  8. package/corpus/templates/calendar/app/pages/BookingLinksPage.tsx +64 -2
  9. package/corpus/templates/calendar/server/handlers/bookings.ts +219 -93
  10. package/corpus/templates/calendar/server/plugins/auth.ts +0 -2
  11. package/corpus/templates/clips/actions/create-folder.ts +7 -1
  12. package/corpus/templates/clips/actions/create-meeting.ts +6 -2
  13. package/corpus/templates/clips/actions/create-recording.ts +5 -3
  14. package/corpus/templates/clips/actions/finalize-recording.ts +97 -65
  15. package/corpus/templates/clips/actions/import-loom-recording.ts +5 -3
  16. package/corpus/templates/clips/actions/request-recording-access.ts +272 -0
  17. package/corpus/templates/clips/actions/stitch-recordings.ts +5 -3
  18. package/corpus/templates/clips/app/components/meetings/share-meeting-dialog.tsx +38 -63
  19. package/corpus/templates/clips/app/components/player/share-dialog.tsx +75 -75
  20. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +3 -21
  21. package/corpus/templates/clips/app/components/sharing/share-ui.tsx +13 -30
  22. package/corpus/templates/clips/app/i18n/en-US.ts +10 -0
  23. package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +5 -5
  24. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +9 -16
  25. package/corpus/templates/clips/app/routes/share.$shareId.tsx +135 -18
  26. package/corpus/templates/clips/changelog/2026-08-14-long-resumable-uploads-now-abort-cleanly-and-show-errors.md +6 -0
  27. package/corpus/templates/clips/changelog/2026-08-14-standardized-share-popovers.md +6 -0
  28. package/corpus/templates/clips/chrome-extension/public/manifest.json +11 -3
  29. package/corpus/templates/clips/chrome-extension/src/github-preview-content.ts +37 -8
  30. package/corpus/templates/clips/chrome-extension/src/github-preview.ts +3 -3
  31. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +8 -25
  32. package/corpus/templates/clips/desktop/src/app.tsx +24 -0
  33. package/corpus/templates/clips/desktop/src/lib/media-capture-constraints.ts +42 -13
  34. package/corpus/templates/clips/desktop/src/lib/recorder.ts +11 -18
  35. package/corpus/templates/clips/desktop/src/overlays/onboarding.tsx +1 -0
  36. package/corpus/templates/clips/desktop/src/shared/config.ts +1 -0
  37. package/corpus/templates/clips/desktop/src/styles.css +24 -9
  38. package/corpus/templates/clips/desktop/src-tauri/Cargo.toml +3 -0
  39. package/corpus/templates/clips/desktop/src-tauri/src/config.rs +8 -0
  40. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +154 -176
  41. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +115 -31
  42. package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +7 -0
  43. package/corpus/templates/clips/server/db/index.ts +1 -0
  44. package/corpus/templates/clips/server/db/schema.ts +1 -0
  45. package/corpus/templates/clips/server/lib/calendar-event-meetings.ts +6 -1
  46. package/corpus/templates/clips/server/lib/emails.ts +23 -0
  47. package/corpus/templates/clips/server/lib/public-agent-context.ts +55 -0
  48. package/corpus/templates/clips/server/lib/recordings.ts +2 -1
  49. package/corpus/templates/clips/server/lib/resumable-upload-cleanup.ts +49 -0
  50. package/corpus/templates/clips/server/lib/s3-upload-provider.ts +34 -7
  51. package/corpus/templates/clips/server/lib/upload-interruption.ts +15 -1
  52. package/corpus/templates/clips/server/lib/upload-lease.ts +94 -9
  53. package/corpus/templates/clips/server/routes/api/agent-context.json.get.ts +82 -7
  54. package/corpus/templates/clips/server/routes/api/public-recording.get.ts +1 -0
  55. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +71 -12
  56. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/interrupt.post.ts +4 -3
  57. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +19 -0
  58. package/corpus/templates/clips/server/routes/api/uploads/reap.post.ts +8 -1
  59. package/corpus/templates/clips/shared/recording-link.ts +4 -0
  60. package/corpus/templates/clips/shared/share-loader-response.ts +2 -1
  61. package/corpus/templates/content/app/components/editor/DocumentToolbar.tsx +7 -16
  62. package/corpus/templates/factory/AGENTS.md +5 -1
  63. package/corpus/templates/factory/README.md +6 -2
  64. package/corpus/templates/factory/actions/view-screen.ts +15 -0
  65. package/corpus/templates/factory/app/components/factory/FactoryAgentsView.tsx +58 -189
  66. package/corpus/templates/factory/app/components/layout/Header.tsx +1 -0
  67. package/corpus/templates/factory/app/components/layout/Layout.tsx +1 -1
  68. package/corpus/templates/factory/app/components/layout/Sidebar.tsx +7 -0
  69. package/corpus/templates/factory/app/hooks/use-navigation-state.ts +3 -0
  70. package/corpus/templates/factory/app/i18n/en-US.ts +3 -1
  71. package/corpus/templates/factory/app/routes/agents.tsx +9 -0
  72. package/corpus/templates/mail/server/plugins/auth.ts +0 -2
  73. package/corpus/templates/plan/app/components/plan/LocalCodebasePicker.tsx +50 -14
  74. package/corpus/templates/plan/app/pages/PlansPage.tsx +9 -19
  75. package/corpus/templates/slides/app/components/editor/HistoryPanel.tsx +7 -1
  76. package/corpus/templates/slides/app/context/DeckContext.tsx +71 -22
  77. package/corpus/templates/slides/app/lib/editor-shortcuts.ts +46 -0
  78. package/corpus/templates/slides/app/pages/DeckEditor.tsx +13 -0
  79. package/dist/client/AgentPanel.d.ts +13 -1
  80. package/dist/client/AgentPanel.js +12 -10
  81. package/dist/client/AssistantChat.js +25 -5
  82. package/dist/client/HighlightedCodeBlock.js +12 -2
  83. package/dist/client/agent-chat/index.d.ts +1 -0
  84. package/dist/client/agent-chat/index.js +1 -0
  85. package/dist/client/chat/markdown-renderer.js +16 -3
  86. package/dist/client/chat-first/apps-rail.js +1 -1
  87. package/dist/client/components/ui/message-scroller.js +1 -1
  88. package/dist/client/settings/AccountSettingsCard.js +48 -19
  89. package/dist/client/sharing/ShareButton.d.ts +1 -2
  90. package/dist/client/sharing/ShareButton.js +5 -41
  91. package/dist/client/sharing/ShareDialog.js +5 -4
  92. package/dist/client/use-chat-threads.js +10 -14
  93. package/dist/collab/struct-routes.d.ts +1 -1
  94. package/dist/localization/default-messages.d.ts +1 -0
  95. package/dist/localization/default-messages.js +1 -0
  96. package/dist/progress/routes.d.ts +1 -1
  97. package/dist/provider-api/actions/custom-provider-registration.d.ts +11 -11
  98. package/dist/provider-api/actions/provider-api.d.ts +15 -15
  99. package/dist/server/auth-marketing.d.ts +1 -0
  100. package/dist/server/auth-marketing.js +0 -2
  101. package/dist/server/auth.d.ts +1 -0
  102. package/dist/server/auth.js +18 -0
  103. package/dist/server/onboarding-html.d.ts +1 -0
  104. package/dist/server/onboarding-html.js +2 -88
  105. package/dist/server/realtime-token.d.ts +1 -1
  106. package/dist/shared/markdown-block-split.d.ts +16 -0
  107. package/dist/shared/markdown-block-split.js +81 -5
  108. package/dist/styles/agent-native.css +13 -0
  109. package/dist/voice/voice-cleanup-prompt.js +1 -0
  110. package/package.json +2 -2
  111. package/corpus/templates/clips/shared/mic-audio-cleanup.ts +0 -350
@@ -1206,6 +1206,7 @@ pub(crate) fn prepare_clip_sink(
1206
1206
  height: u32,
1207
1207
  include_mic: bool,
1208
1208
  include_system_audio: bool,
1209
+ voice_cleanup_enabled: bool,
1209
1210
  ) -> Result<PreparedClipSink, String> {
1210
1211
  let mut installed = slot.lock().map_err(|e| e.to_string())?;
1211
1212
  if installed.is_some() {
@@ -1222,6 +1223,7 @@ pub(crate) fn prepare_clip_sink(
1222
1223
  include_mic,
1223
1224
  include_system_audio,
1224
1225
  ),
1226
+ voice_cleanup_enabled,
1225
1227
  CustomWriterOutput::ClipHls,
1226
1228
  None,
1227
1229
  )?;
@@ -1374,6 +1376,7 @@ impl CustomScreenCaptureWriter {
1374
1376
  capture_system_audio: bool,
1375
1377
  include_audio: bool,
1376
1378
  mix_live: bool,
1379
+ voice_cleanup_enabled: bool,
1377
1380
  output: CustomWriterOutput,
1378
1381
  audio_producer: Option<crate::capture_audio_bus::AudioProducer>,
1379
1382
  ) -> Result<Self, String> {
@@ -1524,6 +1527,7 @@ impl CustomScreenCaptureWriter {
1524
1527
  segmented,
1525
1528
  include_audio,
1526
1529
  capture_system_audio,
1530
+ voice_cleanup_enabled,
1527
1531
  )?),
1528
1532
  )
1529
1533
  } else {
@@ -2182,18 +2186,6 @@ const MIC_AGC_BOOST_SECONDS: f32 = 1.5;
2182
2186
  const MIC_AGC_WARMUP_SECONDS: f32 = 0.2;
2183
2187
  /// How close to the target counts as levelled and ends the warmup.
2184
2188
  const MIC_AGC_CONVERGED_TOLERANCE: f32 = 0.05;
2185
- /// Conservative voice cleanup for the realtime microphone path. The browser
2186
- /// recorder gets a comparable Web Audio chain; native live uploads cannot
2187
- /// wait for the later FFmpeg normalization pass, so this stays causal and
2188
- /// source-local here.
2189
- const MIC_CLEANUP_HIGH_PASS_HZ: f32 = 65.0;
2190
- const MIC_CLEANUP_NOTCH_FREQUENCIES_HZ: [f32; 4] = [50.0, 60.0, 100.0, 120.0];
2191
- const MIC_CLEANUP_NOTCH_Q: f32 = 18.0;
2192
- const MIC_EXPANDER_THRESHOLD_DB: f32 = -48.0;
2193
- const MIC_EXPANDER_RATIO: f32 = 2.0;
2194
- const MIC_EXPANDER_MAX_REDUCTION_DB: f32 = 12.0;
2195
- const MIC_EXPANDER_ATTACK_SECONDS: f32 = 0.012;
2196
- const MIC_EXPANDER_RELEASE_SECONDS: f32 = 0.18;
2197
2189
  /// -1.0 dBFS, near the true-peak ceiling the offline loudnorm chain targets.
2198
2190
  const MIX_LIMIT_CEILING: f32 = 0.891;
2199
2191
  const MIX_LIMIT_RELEASE_SECONDS: f32 = 0.15;
@@ -2275,153 +2267,99 @@ impl MicAutoGain {
2275
2267
  }
2276
2268
  }
2277
2269
 
2278
- #[derive(Clone, Copy)]
2279
- struct Biquad {
2280
- b0: f32,
2281
- b1: f32,
2282
- b2: f32,
2283
- a1: f32,
2284
- a2: f32,
2285
- x1: f32,
2286
- x2: f32,
2287
- y1: f32,
2288
- y2: f32,
2270
+ /// RNNoise-derived denoising for the native live-upload path. ScreenCaptureKit
2271
+ /// hands us stereo f32 PCM, while `nnnoiseless` consumes mono 16-bit-scaled
2272
+ /// f32 frames at 48 kHz. Keep the frame buffering here, before the source
2273
+ /// timeline, so the denoiser never sees zero-filled timeline gaps as speech.
2274
+ struct MicDenoiser {
2275
+ state: Box<nnnoiseless::DenoiseState<'static>>,
2276
+ pending: Vec<f32>,
2277
+ pending_start_pts: Option<f64>,
2278
+ first_frame: bool,
2289
2279
  }
2290
2280
 
2291
- impl Biquad {
2292
- fn from_coefficients(b0: f32, b1: f32, b2: f32, a0: f32, a1: f32, a2: f32) -> Self {
2293
- Self {
2294
- b0: b0 / a0,
2295
- b1: b1 / a0,
2296
- b2: b2 / a0,
2297
- a1: a1 / a0,
2298
- a2: a2 / a0,
2299
- x1: 0.0,
2300
- x2: 0.0,
2301
- y1: 0.0,
2302
- y2: 0.0,
2303
- }
2304
- }
2305
-
2306
- fn high_pass(frequency_hz: f32, q: f32, sample_rate: f32) -> Self {
2307
- let omega = 2.0 * std::f32::consts::PI * frequency_hz / sample_rate;
2308
- let cosine = omega.cos();
2309
- let sine = omega.sin();
2310
- let alpha = sine / (2.0 * q);
2311
- Self::from_coefficients(
2312
- (1.0 + cosine) / 2.0,
2313
- -(1.0 + cosine),
2314
- (1.0 + cosine) / 2.0,
2315
- 1.0 + alpha,
2316
- -2.0 * cosine,
2317
- 1.0 - alpha,
2318
- )
2319
- }
2281
+ impl MicDenoiser {
2282
+ const SAMPLE_RATE: f64 = 48_000.0;
2283
+ const SCALE: f32 = 32_768.0;
2320
2284
 
2321
- fn notch(frequency_hz: f32, q: f32, sample_rate: f32) -> Self {
2322
- let omega = 2.0 * std::f32::consts::PI * frequency_hz / sample_rate;
2323
- let cosine = omega.cos();
2324
- let sine = omega.sin();
2325
- let alpha = sine / (2.0 * q);
2326
- Self::from_coefficients(
2327
- 1.0,
2328
- -2.0 * cosine,
2329
- 1.0,
2330
- 1.0 + alpha,
2331
- -2.0 * cosine,
2332
- 1.0 - alpha,
2333
- )
2285
+ fn new() -> Self {
2286
+ Self {
2287
+ state: nnnoiseless::DenoiseState::new(),
2288
+ pending: Vec::new(),
2289
+ pending_start_pts: None,
2290
+ first_frame: true,
2291
+ }
2334
2292
  }
2335
2293
 
2336
- fn process(&mut self, input: f32) -> f32 {
2337
- let output = self.b0 * input + self.b1 * self.x1 + self.b2 * self.x2
2338
- - self.a1 * self.y1
2339
- - self.a2 * self.y2;
2340
- self.x2 = self.x1;
2341
- self.x1 = input;
2342
- self.y2 = self.y1;
2343
- self.y1 = output;
2344
- output
2294
+ fn reset(&mut self) {
2295
+ self.state = nnnoiseless::DenoiseState::new();
2296
+ self.pending.clear();
2297
+ self.pending_start_pts = None;
2298
+ self.first_frame = true;
2345
2299
  }
2346
- }
2347
2300
 
2348
- struct MicCleanupChannel {
2349
- high_pass: Biquad,
2350
- notches: [Biquad; 4],
2351
- }
2301
+ fn process(&mut self, interleaved: &[f32], pts_seconds: f64) -> Vec<(Vec<f32>, f64)> {
2302
+ let frame_count = interleaved.len() / 2;
2303
+ if frame_count == 0 {
2304
+ return Vec::new();
2305
+ }
2352
2306
 
2353
- impl MicCleanupChannel {
2354
- fn new(sample_rate: f32) -> Self {
2355
- Self {
2356
- high_pass: Biquad::high_pass(MIC_CLEANUP_HIGH_PASS_HZ, 0.707, sample_rate),
2357
- notches: std::array::from_fn(|index| {
2358
- Biquad::notch(
2359
- MIC_CLEANUP_NOTCH_FREQUENCIES_HZ[index],
2360
- MIC_CLEANUP_NOTCH_Q,
2361
- sample_rate,
2362
- )
2363
- }),
2307
+ if let Some(start) = self.pending_start_pts {
2308
+ let expected = start + self.pending.len() as f64 / Self::SAMPLE_RATE;
2309
+ if (pts_seconds - expected).abs() > 0.02 {
2310
+ self.reset();
2311
+ }
2364
2312
  }
2365
- }
2313
+ if self.pending_start_pts.is_none() {
2314
+ self.pending_start_pts = Some(pts_seconds);
2315
+ }
2316
+ self.pending
2317
+ .extend(interleaved[..frame_count * 2].chunks_exact(2).map(|frame| {
2318
+ ((frame[0] + frame[1]) * 0.5 * Self::SCALE).clamp(-Self::SCALE, Self::SCALE)
2319
+ }));
2366
2320
 
2367
- fn process(&mut self, mut sample: f32) -> f32 {
2368
- sample = self.high_pass.process(sample);
2369
- for notch in &mut self.notches {
2370
- sample = notch.process(sample);
2321
+ let frame_size = nnnoiseless::DenoiseState::FRAME_SIZE;
2322
+ let mut output = Vec::new();
2323
+ while self.pending.len() >= frame_size {
2324
+ let start = self.pending_start_pts.unwrap_or(pts_seconds);
2325
+ let input = self.pending[..frame_size].to_vec();
2326
+ let mut denoised = vec![0.0_f32; frame_size];
2327
+ self.state.process_frame(&mut denoised, &input);
2328
+ let samples = if self.first_frame { &input } else { &denoised };
2329
+ output.push((Self::stereo_samples(samples), start));
2330
+ self.first_frame = false;
2331
+ self.pending.drain(..frame_size);
2332
+ self.pending_start_pts = Some(start + frame_size as f64 / Self::SAMPLE_RATE);
2371
2333
  }
2372
- sample
2334
+ output
2373
2335
  }
2374
- }
2375
2336
 
2376
- /// Causal microphone cleanup for the native live-upload path. The high-pass
2377
- /// and narrow notches target HVAC/mains hum; the expander only reduces quiet
2378
- /// room tone and never hard-mutes it, so speech tails survive the processing.
2379
- struct MicCleanup {
2380
- left: MicCleanupChannel,
2381
- right: MicCleanupChannel,
2382
- envelope: f32,
2383
- gain: f32,
2384
- envelope_coefficient: f32,
2385
- attack_coefficient: f32,
2386
- release_coefficient: f32,
2387
- }
2388
-
2389
- impl MicCleanup {
2390
- fn new(sample_rate: i32) -> Self {
2391
- let sample_rate = sample_rate as f32;
2392
- Self {
2393
- left: MicCleanupChannel::new(sample_rate),
2394
- right: MicCleanupChannel::new(sample_rate),
2395
- envelope: 0.0,
2396
- gain: 1.0,
2397
- envelope_coefficient: one_pole_coefficient(0.015, sample_rate as i32),
2398
- attack_coefficient: one_pole_coefficient(
2399
- MIC_EXPANDER_ATTACK_SECONDS,
2400
- sample_rate as i32,
2401
- ),
2402
- release_coefficient: one_pole_coefficient(
2403
- MIC_EXPANDER_RELEASE_SECONDS,
2404
- sample_rate as i32,
2405
- ),
2406
- }
2337
+ fn flush(&mut self) -> Vec<(Vec<f32>, f64)> {
2338
+ if self.pending.is_empty() {
2339
+ return Vec::new();
2340
+ }
2341
+ let frame_size = nnnoiseless::DenoiseState::FRAME_SIZE;
2342
+ let count = self.pending.len();
2343
+ let start = self.pending_start_pts.unwrap_or(0.0);
2344
+ let mut input = vec![0.0_f32; frame_size];
2345
+ input[..count].copy_from_slice(&self.pending);
2346
+ let mut denoised = vec![0.0_f32; frame_size];
2347
+ self.state.process_frame(&mut denoised, &input);
2348
+ let samples = if self.first_frame { &input } else { &denoised };
2349
+ let result = vec![(Self::stereo_samples(&samples[..count]), start)];
2350
+ self.pending.clear();
2351
+ self.pending_start_pts = None;
2352
+ self.first_frame = false;
2353
+ result
2407
2354
  }
2408
2355
 
2409
- fn process(&mut self, sample: (f32, f32)) -> (f32, f32) {
2410
- let filtered = (self.left.process(sample.0), self.right.process(sample.1));
2411
- let peak = filtered.0.abs().max(filtered.1.abs());
2412
- self.envelope += (peak - self.envelope) * self.envelope_coefficient;
2413
- let level_db = 20.0 * self.envelope.max(1.0e-6).log10();
2414
- let below_threshold = (MIC_EXPANDER_THRESHOLD_DB - level_db).max(0.0);
2415
- let reduction_db =
2416
- (below_threshold * (1.0 - 1.0 / MIC_EXPANDER_RATIO)).min(MIC_EXPANDER_MAX_REDUCTION_DB);
2417
- let target_gain = 10.0_f32.powf(-reduction_db / 20.0);
2418
- let coefficient = if target_gain > self.gain {
2419
- self.attack_coefficient
2420
- } else {
2421
- self.release_coefficient
2422
- };
2423
- self.gain += (target_gain - self.gain) * coefficient;
2424
- (filtered.0 * self.gain, filtered.1 * self.gain)
2356
+ fn stereo_samples(mono: &[f32]) -> Vec<f32> {
2357
+ let mut stereo = Vec::with_capacity(mono.len() * 2);
2358
+ for &sample in mono {
2359
+ let sample = (sample / Self::SCALE).clamp(-1.0, 1.0);
2360
+ stereo.extend([sample, sample]);
2361
+ }
2362
+ stereo
2425
2363
  }
2426
2364
  }
2427
2365
 
@@ -2458,15 +2396,15 @@ impl PeakLimiter {
2458
2396
  /// is reachable without constructing a `CMFormatDescription` — the reported bug
2459
2397
  /// was a wrong per-frame weight, so that arithmetic is what needs covering.
2460
2398
  struct MixGainStage {
2461
- mic_cleanup: MicCleanup,
2399
+ voice_cleanup_enabled: bool,
2462
2400
  mic: MicAutoGain,
2463
2401
  limiter: PeakLimiter,
2464
2402
  }
2465
2403
 
2466
2404
  impl MixGainStage {
2467
- fn new(sample_rate: i32) -> Self {
2405
+ fn new(sample_rate: i32, voice_cleanup_enabled: bool) -> Self {
2468
2406
  Self {
2469
- mic_cleanup: MicCleanup::new(sample_rate),
2407
+ voice_cleanup_enabled,
2470
2408
  mic: MicAutoGain::new(sample_rate),
2471
2409
  limiter: PeakLimiter::new(sample_rate),
2472
2410
  }
@@ -2476,8 +2414,11 @@ impl MixGainStage {
2476
2414
  /// at unity and the mic is only ever boosted, with the limiter owning peak
2477
2415
  /// safety for the sum.
2478
2416
  fn frame(&mut self, system: (f32, f32), mic: (f32, f32)) -> (f32, f32) {
2479
- let mic = self.mic_cleanup.process(mic);
2480
- let mic_gain = self.mic.next_gain(mic.0, mic.1);
2417
+ let mic_gain = if self.voice_cleanup_enabled {
2418
+ self.mic.next_gain(mic.0, mic.1)
2419
+ } else {
2420
+ 1.0
2421
+ };
2481
2422
  let left = system.0 + mic.0 * mic_gain;
2482
2423
  let right = system.1 + mic.1 * mic_gain;
2483
2424
  let limit = self.limiter.next_gain(left.abs().max(right.abs()));
@@ -2601,6 +2542,7 @@ struct LiveAudioMixer {
2601
2542
  out_pos: i64,
2602
2543
  system: MixerTimeline,
2603
2544
  mic: MixerTimeline,
2545
+ mic_denoiser: Option<MicDenoiser>,
2604
2546
  /// Gain stage, carried across chunks: emitted frames are already uploaded,
2605
2547
  /// so its state is the only memory the mix has.
2606
2548
  gain: MixGainStage,
@@ -2612,6 +2554,7 @@ impl LiveAudioMixer {
2612
2554
  rebase_output: bool,
2613
2555
  include_microphone: bool,
2614
2556
  capture_system_audio: bool,
2557
+ voice_cleanup_enabled: bool,
2615
2558
  ) -> Result<Self, String> {
2616
2559
  let sample_rate = AUDIO_OUTPUT_SAMPLE_RATE as i32;
2617
2560
  let asbd = AudioStreamBasicDescription {
@@ -2657,7 +2600,8 @@ impl LiveAudioMixer {
2657
2600
  out_pos: 0,
2658
2601
  system: MixerTimeline::new(),
2659
2602
  mic: MixerTimeline::new(),
2660
- gain: MixGainStage::new(sample_rate),
2603
+ mic_denoiser: voice_cleanup_enabled.then(MicDenoiser::new),
2604
+ gain: MixGainStage::new(sample_rate, voice_cleanup_enabled),
2661
2605
  created_at: Instant::now(),
2662
2606
  })
2663
2607
  }
@@ -2666,6 +2610,22 @@ impl LiveAudioMixer {
2666
2610
  /// position, zero-filling any gap since the previous push (capped so a
2667
2611
  /// glitched timestamp can't allocate gigabytes).
2668
2612
  fn push(&mut self, source: MixSource, interleaved: &[f32], pts_seconds: f64) {
2613
+ if source == MixSource::Mic {
2614
+ let processed = self
2615
+ .mic_denoiser
2616
+ .as_mut()
2617
+ .map(|denoiser| denoiser.process(interleaved, pts_seconds));
2618
+ if let Some(processed) = processed {
2619
+ for (processed, processed_pts) in processed {
2620
+ self.push_timeline(MixSource::Mic, &processed, processed_pts);
2621
+ }
2622
+ return;
2623
+ }
2624
+ }
2625
+ self.push_timeline(source, interleaved, pts_seconds);
2626
+ }
2627
+
2628
+ fn push_timeline(&mut self, source: MixSource, interleaved: &[f32], pts_seconds: f64) {
2669
2629
  let frames = interleaved.len() / 2;
2670
2630
  if frames == 0 {
2671
2631
  return;
@@ -2694,6 +2654,11 @@ impl LiveAudioMixer {
2694
2654
  /// Update the accumulated pause offset (seconds) applied to incoming
2695
2655
  /// source PTS in `push`. Set from the writer's shared offset on resume.
2696
2656
  fn set_pause_offset(&mut self, seconds: f64) {
2657
+ if seconds > self.pause_offset_seconds + f64::EPSILON {
2658
+ if let Some(denoiser) = self.mic_denoiser.as_mut() {
2659
+ denoiser.reset();
2660
+ }
2661
+ }
2697
2662
  self.pause_offset_seconds = seconds;
2698
2663
  }
2699
2664
 
@@ -2799,6 +2764,14 @@ impl LiveAudioMixer {
2799
2764
  &mut self,
2800
2765
  flush: bool,
2801
2766
  ) -> Result<Vec<screencapturekit::cm::CMSampleBuffer>, String> {
2767
+ if flush {
2768
+ let processed = self.mic_denoiser.as_mut().map(MicDenoiser::flush);
2769
+ if let Some(processed) = processed {
2770
+ for (processed, processed_pts) in processed {
2771
+ self.push_timeline(MixSource::Mic, &processed, processed_pts);
2772
+ }
2773
+ }
2774
+ }
2802
2775
  if self.anchor_seconds.is_none() {
2803
2776
  return Ok(Vec::new());
2804
2777
  }
@@ -4117,6 +4090,7 @@ pub(crate) fn start_custom_screencapturekit_backend_at(
4117
4090
  CustomWriterOutput::Standard
4118
4091
  };
4119
4092
  let mix_live = live_audio_mixing_enabled(output, include_audio, capture_system_audio);
4093
+ let voice_cleanup_enabled = crate::config::feature_config(app).voice_cleanup_enabled;
4120
4094
  // The active custom capture is the single physical owner of mic/system
4121
4095
  // audio for both Rewind and ordinary Clips. Live transcription subscribes
4122
4096
  // to this producer instead of opening a competing SCK/AVAudioEngine input,
@@ -4135,6 +4109,7 @@ pub(crate) fn start_custom_screencapturekit_backend_at(
4135
4109
  capture_system_audio,
4136
4110
  include_audio,
4137
4111
  mix_live,
4112
+ voice_cleanup_enabled,
4138
4113
  output,
4139
4114
  audio_producer,
4140
4115
  )?;
@@ -4343,36 +4318,39 @@ mod fragment_fence_tests {
4343
4318
  }
4344
4319
 
4345
4320
  #[test]
4346
- fn mic_cleanup_reduces_hum_without_hard_muting_speech() {
4347
- let sample_rate = 48_000.0_f32;
4348
- let mut cleanup = MicCleanup::new(sample_rate as i32);
4349
- let mut hum_energy = 0.0_f32;
4350
- let mut speech_energy = 0.0_f32;
4351
- for index in 0..(sample_rate as usize) {
4352
- let time = index as f32 / sample_rate;
4353
- let hum = (2.0 * std::f32::consts::PI * 60.0 * time).sin() * 0.08;
4354
- let speech = if (0.2..0.8).contains(&time) {
4355
- (2.0 * std::f32::consts::PI * 220.0 * time).sin() * 0.08
4356
- } else {
4357
- 0.0
4358
- };
4359
- let output = cleanup.process((hum + speech, hum + speech));
4360
- if time < 0.15 || time > 0.85 {
4361
- hum_energy += output.0.abs();
4362
- } else {
4363
- speech_energy += output.0.abs();
4364
- }
4365
- }
4366
- assert!(hum_energy < 900.0, "hum remained too loud: {hum_energy}");
4367
- assert!(
4368
- speech_energy > 1_000.0,
4369
- "speech was gated out: {speech_energy}"
4370
- );
4321
+ fn mic_denoiser_preserves_frame_boundaries_and_duration() {
4322
+ let mut denoiser = MicDenoiser::new();
4323
+ let first = vec![0.08_f32; 300 * 2];
4324
+ let second = vec![0.12_f32; 300 * 2];
4325
+
4326
+ assert!(denoiser.process(&first, 10.0).is_empty());
4327
+ let processed = denoiser.process(&second, 10.0 + 300.0 / 48_000.0);
4328
+ assert_eq!(processed.len(), 1);
4329
+ assert_eq!(processed[0].0.len(), 480 * 2);
4330
+ assert_eq!(processed[0].1, 10.0);
4331
+ assert!(processed[0].0.iter().all(|sample| sample.is_finite()));
4332
+ assert!(processed[0].0.iter().any(|sample| sample.abs() > 0.01));
4333
+
4334
+ let flushed = denoiser.flush();
4335
+ assert_eq!(flushed.len(), 1);
4336
+ assert_eq!(flushed[0].0.len(), 120 * 2);
4337
+ assert!(flushed[0].0.iter().all(|sample| sample.is_finite()));
4338
+ }
4339
+
4340
+ #[test]
4341
+ fn mic_denoiser_resets_pending_audio_after_pause() {
4342
+ let mut denoiser = MicDenoiser::new();
4343
+ let input = vec![0.1_f32; 120 * 2];
4344
+ assert!(denoiser.process(&input, 10.0).is_empty());
4345
+ denoiser.reset();
4346
+ assert!(denoiser.flush().is_empty());
4347
+ assert!(denoiser.process(&input, 20.0).is_empty());
4348
+ assert_eq!(denoiser.flush()[0].1, 20.0);
4371
4349
  }
4372
4350
 
4373
4351
  #[test]
4374
4352
  fn mic_only_mixer_does_not_wait_for_a_disabled_system_source() {
4375
- let mixer = LiveAudioMixer::new(false, true, false).unwrap();
4353
+ let mixer = LiveAudioMixer::new(false, true, false, true).unwrap();
4376
4354
  assert_eq!(mixer.compute_safe_end(false), 0);
4377
4355
  }
4378
4356