Repository: toeverything/AFFiNE
Pull request #12246: feat(electron): better recording quality after device change
Author: login: pengx17
Labels: name: rust
name: mod:native
Created: 2025-05-13T05:24:17Z | Merged: 2025-05-13T09:00:19Z

fix AF-2614

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
	- Improved audio processing with persistent buffered resampling and thread-local caching for smoother and more reliable sample rate conversion.
- **Bug Fixes**
	- Enhanced handling of stereo and mono audio input, ensuring accurate channel extraction and upmixing.
	- Corrected audio channel reporting to reflect stereo input accurately.
- **Refactor**
	- Updated internal audio processing logic for better performance and reduced artifacts during audio capture.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


Diff:
diff --git a/packages/frontend/native/media_capture/src/macos/tap_audio.rs b/packages/frontend/native/media_capture/src/macos/tap_audio.rs
index b304d8b9ad08b..a93e65e6532ec 100644
--- a/packages/frontend/native/media_capture/src/macos/tap_audio.rs
+++ b/packages/frontend/native/media_capture/src/macos/tap_audio.rs
@@ -193,7 +193,7 @@ impl AggregateDevice {
 
     let audio_stats = AudioStats {
       sample_rate,
-      channels: 1, // we combined the stereo pcm data into a single channel
+      channels: 2,
     };
 
     Ok(audio_stats)
@@ -654,7 +654,7 @@ impl AggregateDeviceManager {
       .get_aggregate_device_stats()
       .unwrap_or(AudioStats {
         sample_rate: 48000.0, // Match fallback in setup_device_change_listeners
-        channels: 1,
+        channels: 2,
       });
     self.original_audio_stats = Some(original_audio_stats); // Store for listener use
 
diff --git a/packages/frontend/native/media_capture/src/macos/utils.rs b/packages/frontend/native/media_capture/src/macos/utils.rs
index 83858a4bdce0c..229576824aa8d 100644
--- a/packages/frontend/native/media_capture/src/macos/utils.rs
+++ b/packages/frontend/native/media_capture/src/macos/utils.rs
@@ -1,4 +1,4 @@
-use std::{ffi::c_void, mem::size_of};
+use std::{cell::RefCell, collections::HashMap, ffi::c_void, mem::size_of};
 
 use core_foundation::string::CFString;
 use coreaudio::sys::{
@@ -9,6 +9,89 @@ use rubato::{FastFixedIn, PolynomialDegree, Resampler};
 
 use crate::error::CoreAudioError;
 
+// ------------------------------------------------------------
+// A simple wrapper that buffers incoming planar frames so that we always feed
+// the Rubato resampler its preferred fixed block-length. This avoids the
+// artefacts caused by recreating the resampler every callback.
+// ------------------------------------------------------------
+
+const RESAMPLER_INPUT_CHUNK: usize = 1024; // samples per channel
+
+struct BufferedResampler {
+  resampler: FastFixedIn<f32>,
+  channels: usize,
+  fifo: Vec<Vec<f32>>,            // per-channel queue
+  initial_output_discarded: bool, // Flag to track if the first output has been discarded
+}
+
+impl BufferedResampler {
+  fn new(from_sr: f64, to_sr: f64, channels: usize) -> Self {
+    let ratio = to_sr / from_sr;
+    let resampler = FastFixedIn::<f32>::new(
+      ratio,
+      1.0, // max_resample_ratio_relative (must be >= 1.0, use 1.0 for fixed ratio)
+      PolynomialDegree::Linear, // Use Linear interpolation quality
+      RESAMPLER_INPUT_CHUNK,
+      channels,
+    )
+    .expect("Failed to create FastFixedIn resampler (5-arg attempt)");
+
+    BufferedResampler {
+      resampler,
+      channels,
+      fifo: vec![Vec::<f32>::new(); channels],
+      initial_output_discarded: false,
+    }
+  }
+
+  // feed planar samples; returns interleaved output (may be empty if not
+  // enough samples accumulated yet).
+  fn feed(&mut self, planar_in: &[Vec<f32>]) -> Vec<f32> {
+    // Append incoming to fifo
+    for (ch, data) in planar_in.iter().enumerate() {
+      self.fifo[ch].extend_from_slice(data);
+    }
+
+    let mut interleaved_out: Vec<f32> = Vec::new();
+
+    while self.fifo[0].len() >= RESAMPLER_INPUT_CHUNK {
+      // Drain exactly one chunk per channel
+      let mut chunk: Vec<Vec<f32>> = Vec::with_capacity(self.channels);
+      for ch in 0..self.channels {
+        let tail = self.fifo[ch]
+          .drain(..RESAMPLER_INPUT_CHUNK)
+          .collect::<Vec<_>>();
+        chunk.push(tail);
+      }
+
+      if let Ok(out_blocks) = self.resampler.process(&chunk, None) {
+        // out_blocks is Vec<Vec<f32>> planar
+        if !out_blocks.is_empty() && out_blocks.len() == self.channels {
+          // Check if we should discard the initial output
+          if !self.initial_output_discarded {
+            self.initial_output_discarded = true;
+          } else {
+            // interleave
+            let out_len = out_blocks[0].len();
+            for i in 0..out_len {
+              for ch in 0..self.channels {
+                interleaved_out.push(out_blocks[ch][i]);
+              }
+            }
+          }
+        }
+      }
+    }
+
+    interleaved_out
+  }
+}
+
+// thread-local cache so that each audio‐tap thread keeps its own resamplers
+thread_local! {
+  static RESAMPLER_CACHE: RefCell<HashMap<(u32,u32,usize), BufferedResampler>> = RefCell::new(HashMap::new());
+}
+
 pub fn cfstring_from_bytes_with_nul(bytes: &[u8]) -> CFString {
   CFString::new(
     unsafe { std::ffi::CStr::from_bytes_with_nul_unchecked(bytes) }
@@ -56,6 +139,7 @@ pub fn process_audio_frame(
   target_sample_rate: f64,
 ) -> Option<Vec<f32>> {
   // Only create slice if we have valid data
+
   if m_data.is_null() || m_data_byte_size == 0 {
     return None;
   }
@@ -68,47 +152,84 @@ pub fn process_audio_frame(
   // Check the channel count and data format
   let channel_count = m_number_channels as usize;
 
-  let processed_samples = if channel_count > 1 {
-    // For stereo, samples are interleaved: [L, R, L, R, ...]
-    // We need to average each pair to get mono
-    samples
-      .chunks(channel_count)
-      .map(|chunk| chunk.iter().sum::<f32>() / channel_count as f32)
-      .collect()
+  // If the audio has two or more channels, keep (at most) the first two channels
+  // and return them in interleaved stereo format. Otherwise keep mono as-is.
+
+  let interleaved_samples: Vec<f32> = if channel_count >= 2 {
+    // Split interleaved input into the first two channels (L, R)
+    let mut left: Vec<f32> = Vec::with_capacity(total_samples / channel_count);
+    let mut right: Vec<f32> = Vec::with_capacity(total_samples / channel_count);
+
+    for chunk in samples.chunks(channel_count) {
+      // SAFETY: chunk has at least 2 items because channel_count >= 2
+      left.push(chunk[0]);
+      right.push(chunk[1]);
+    }
+
+    if current_sample_rate != target_sample_rate {
+      // Use (or create) a persistent BufferedResampler
+
+      let out_vec = RESAMPLER_CACHE.with(|cache| {
+        let mut map = cache.borrow_mut();
+        let key = (
+          current_sample_rate as u32,
+          target_sample_rate as u32,
+          2usize,
+        );
+        let resampler = map
+          .entry(key)
+          .or_insert_with(|| BufferedResampler::new(current_sample_rate, target_sample_rate, 2));
+        resampler.feed(&[left, right])
+      });
+
+      out_vec
+    } else {
+      // No resampling needed, just interleave existing left/right data
+      let mut interleaved: Vec<f32> = Vec::with_capacity(left.len() * 2);
+      for i in 0..left.len() {
+        interleaved.push(left[i]);
+        interleaved.push(right[i]);
+      }
+
+      interleaved
+    }
   } else {
-    // For mono, just copy the samples
-    samples.to_vec()
+    // Mono path – behave as before (optionally resample)
+    let mut mono_samples = samples.to_vec();
+
+    if current_sample_rate != target_sample_rate {
+      let out_vec = RESAMPLER_CACHE.with(|cache| {
+        let mut map = cache.borrow_mut();
+        let key = (
+          current_sample_rate as u32,
+          target_sample_rate as u32,
+          1usize,
+        );
+        let resampler = map
+          .entry(key)
+          .or_insert_with(|| BufferedResampler::new(current_sample_rate, target_sample_rate, 1));
+        resampler.feed(&[mono_samples])
+      });
+      // resampler returns interleaved (1 channel) but we still need planar mono
+      // (vector of samples) before upmix; since feed returns interleaved single
+      // channel, it is planar already.
+      mono_samples = out_vec;
+    }
+
+    // Upmix mono to stereo by duplicating each sample so that mixing with
+    // interleaved stereo streams keeps channel counts aligned.
+    let mut stereo_samples: Vec<f32> = Vec::with_capacity(mono_samples.len() * 2);
+    for s in &mono_samples {
+      stereo_samples.push(*s);
+      stereo_samples.push(*s);
+    }
+
+    stereo_samples
   };
 
-  if current_sample_rate != target_sample_rate {
-    // TODO: may use SincFixedOut to improve the sample quality
-    // however, it's not working as expected if we only process samples in chunks
-    // e.g., even with ratio 1.0, resampling 512 samples will result in 382 samples,
-    // which will produce very bad quality. The reason is that the resampler is
-    // meant to be used for dealing with larger input size. The reduced number
-    // of samples is a "delay" of the resampler for better quality.
-    let mut resampler = match FastFixedIn::<f32>::new(
-      target_sample_rate / current_sample_rate,
-      2.0,
-      PolynomialDegree::Cubic,
-      processed_samples.len(),
-      1,
-    ) {
-      Ok(r) => r,
-      Err(e) => {
-        eprintln!("Error creating resampler: {:?}", e);
-        return None;
-      }
-    };
-    let mut waves_out = match resampler.process(&[processed_samples], None) {
-      Ok(w) => w,
-      Err(e) => {
-        eprintln!("Error processing audio with resampler: {:?}", e);
-        return None;
-      }
-    };
-    waves_out.pop()
+  if interleaved_samples.is_empty() {
+    None
   } else {
-    Some(processed_samples)
+    Some(interleaved_samples)
   }
 }


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 2**

<details>
<summary>🧹 Nitpick comments (3)</summary><blockquote>

<details>
<summary>packages/frontend/native/media_capture/src/macos/tap_audio.rs (1)</summary><blockquote>

`194-197`: **Comment/doc mismatch — “2 channels” but comment claims *single* channel**

You correctly updated the `channels` field to `2`, however the inline comment still says “combined … into a single channel”, which now contradicts the code and may confuse future readers.

```diff
-      channels: 2, // we combined the stereo pcm data into a single channel
+      channels: 2, // stereo PCM (L + R)
```

</blockquote></details>
<details>
<summary>packages/frontend/native/media_capture/src/macos/audio_buffer.rs (1)</summary><blockquote>

`240-245`: **Redundant bounds check inside loop**

`for i in 0..output_len` already guarantees `i < output_len ≤ max_len`, so the `if i < max_len` branch is always true.

Removing the check avoids an extra branch per sample:

```diff
-          if i < max_len {
-            mixed_samples[i] += output_samples[i] * AUDIO_MIX_OUTPUT_WEIGHT;
-          }
+          mixed_samples[i] += output_samples[i] * AUDIO_MIX_OUTPUT_WEIGHT;
```

</blockquote></details>
<details>
<summary>packages/frontend/native/media_capture/src/macos/utils.rs (1)</summary><blockquote>

`175-186`: **Sample-rate key truncation may cache wrong resampler**

The cache key casts `current_sample_rate`/`target_sample_rate` to `u32`.  
On macOS, non-integer rates such as 44 100.0 and 48 000.0 are fine, but higher-precision values (e.g. 44 056.179 Hz from some USB devices) will be truncated and could re-use an incompatible resampler.

Consider rounding instead:

```diff
-          current_sample_rate as u32,
-          target_sample_rate as u32,
+          current_sample_rate.round() as u32,
+          target_sample_rate.round() as u32,
```

or store as `u64` with `to_bits()` for full fidelity.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 824f862a75217ee0010efdb7dbd6dcb06b280f6b and 9fd6c32ee6fb015273acfa597c2c0a7a7a1e84db.

</details>

<details>
<summary>📒 Files selected for processing (3)</summary>

* `packages/frontend/native/media_capture/src/macos/audio_buffer.rs` (2 hunks)
* `packages/frontend/native/media_capture/src/macos/tap_audio.rs` (2 hunks)
* `packages/frontend/native/media_capture/src/macos/utils.rs` (3 hunks)

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (57)</summary>

* GitHub Check: test-build-mobile-app / build-android-web
* GitHub Check: test-build-mobile-app / build-ios-web
* GitHub Check: y-octo binding test on aarch64-pc-windows-msvc
* GitHub Check: y-octo binding test on x86_64-pc-windows-msvc
* GitHub Check: y-octo binding test on aarch64-unknown-linux-gnu
* GitHub Check: y-octo binding test on aarch64-apple-darwin
* GitHub Check: y-octo binding test on x86_64-apple-darwin
* GitHub Check: loom thread test
* GitHub Check: Build AFFiNE native (x86_64-pc-windows-msvc)
* GitHub Check: E2E BlockSuite Test (5)
* GitHub Check: Run native tests
* GitHub Check: E2E BlockSuite Test (7)
* GitHub Check: fuzzing
* GitHub Check: Build AFFiNE native (aarch64-pc-windows-msvc)
* GitHub Check: Build Server native
* GitHub Check: E2E BlockSuite Test (9)
* GitHub Check: Build @affine/electron renderer
* GitHub Check: E2E BlockSuite Test (8)
* GitHub Check: E2E BlockSuite Test (10)
* GitHub Check: E2E BlockSuite Test (6)
* GitHub Check: E2E Mobile Test (3)
* GitHub Check: E2E BlockSuite Test (1)
* GitHub Check: E2E Mobile Test (4)
* GitHub Check: E2E BlockSuite Test (3)
* GitHub Check: E2E BlockSuite Test (2)
* GitHub Check: E2E Mobile Test (2)
* GitHub Check: E2E Mobile Test (5)
* GitHub Check: E2E BlockSuite Cross Browser Test (2, firefox)
* GitHub Check: E2E Mobile Test (1)
* GitHub Check: E2E BlockSuite Cross Browser Test (1, webkit)
* GitHub Check: E2E BlockSuite Cross Browser Test (2, chromium)
* GitHub Check: E2E BlockSuite Cross Browser Test (2, webkit)
* GitHub Check: E2E BlockSuite Cross Browser Test (1, chromium)
* GitHub Check: E2E BlockSuite Cross Browser Test (1, firefox)
* GitHub Check: E2E Test (10)
* GitHub Check: E2E Test (9)
* GitHub Check: E2E Test (6)
* GitHub Check: E2E Test (8)
* GitHub Check: E2E Test (7)
* GitHub Check: E2E Test (4)
* GitHub Check: E2E Test (5)
* GitHub Check: E2E Test (3)
* GitHub Check: E2E Test (1)
* GitHub Check: Check Git Status
* GitHub Check: E2E Test (2)
* GitHub Check: Analyze (typescript, blocksuite)
* GitHub Check: Typecheck
* GitHub Check: Analyze (javascript, blocksuite)
* GitHub Check: Build AFFiNE native (x86_64-unknown-linux-gnu)
* GitHub Check: Build AFFiNE native (aarch64-apple-darwin)
* GitHub Check: Analyze (typescript, affine)
* GitHub Check: Analyze (javascript, affine)
* GitHub Check: Build AFFiNE native (x86_64-apple-darwin)
* GitHub Check: Lint
* GitHub Check: Graphite / mergeability_check
* GitHub Check: Graphite / mergeability_check
* GitHub Check: Graphite / mergeability_check

</details>

<details>
<summary>🔇 Additional comments (1)</summary><blockquote>

<details>
<summary>packages/frontend/native/media_capture/src/macos/tap_audio.rs (1)</summary>

`652-659`: **Hard-coded fallback stats: verify callers really expect stereo**

You changed the fallback value from `channels: 1` to `channels: 2`.  
Please double-check any JavaScript/TypeScript code that consumes `get_channels()`; if it still assumes mono you will get an off-by-one buffer length and indexing crash once capture starts before `get_aggregate_device_stats()` succeeds.

If downstream is already updated, feel free to ignore.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T05:29:38Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T05:39:59Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T05:44:48Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T05:45:44Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (1)</summary><blockquote>

<details>
<summary>packages/frontend/native/media_capture/src/macos/utils.rs (1)</summary><blockquote>

`230-234`: **Minor suggestion for empty check**

The empty check and conditional return is correct, but could be more concisely written using the `Option` type's built-in methods.


```diff
-  if interleaved_samples.is_empty() {
-    None
-  } else {
-    Some(interleaved_samples)
-  }
+  (!interleaved_samples.is_empty()).then_some(interleaved_samples)
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 9fd6c32ee6fb015273acfa597c2c0a7a7a1e84db and a48afd2171be73d9d9c8fbd81a19bd962033e71e.

</details>

<details>
<summary>📒 Files selected for processing (2)</summary>

* `packages/frontend/native/media_capture/src/macos/tap_audio.rs` (2 hunks)
* `packages/frontend/native/media_capture/src/macos/utils.rs` (4 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (1)</summary>

* packages/frontend/native/media_capture/src/macos/tap_audio.rs

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (56)</summary>

* GitHub Check: test-build-mobile-app / build-android-web
* GitHub Check: test-build-mobile-app / build-ios-web
* GitHub Check: y-octo binding test on aarch64-unknown-linux-gnu
* GitHub Check: fuzzing
* GitHub Check: Run native tests
* GitHub Check: y-octo binding test on x86_64-pc-windows-msvc
* GitHub Check: y-octo binding test on aarch64-apple-darwin
* GitHub Check: y-octo binding test on x86_64-unknown-linux-gnu
* GitHub Check: y-octo binding test on aarch64-pc-windows-msvc
* GitHub Check: E2E BlockSuite Test (9)
* GitHub Check: loom thread test
* GitHub Check: y-octo binding test on x86_64-apple-darwin
* GitHub Check: E2E BlockSuite Test (10)
* GitHub Check: E2E BlockSuite Test (6)
* GitHub Check: E2E BlockSuite Test (2)
* GitHub Check: E2E BlockSuite Test (4)
* GitHub Check: E2E BlockSuite Test (7)
* GitHub Check: E2E BlockSuite Test (5)
* GitHub Check: E2E Mobile Test (5)
* GitHub Check: E2E BlockSuite Test (8)
* GitHub Check: E2E Mobile Test (3)
* GitHub Check: E2E Mobile Test (2)
* GitHub Check: E2E Mobile Test (1)
* GitHub Check: E2E BlockSuite Test (3)
* GitHub Check: Build @affine/electron renderer
* GitHub Check: E2E BlockSuite Test (1)
* GitHub Check: E2E Mobile Test (4)
* GitHub Check: Build AFFiNE native (aarch64-pc-windows-msvc)
* GitHub Check: Build Server native
* GitHub Check: E2E Test (6)
* GitHub Check: E2E Test (9)
* GitHub Check: Build AFFiNE native (x86_64-pc-windows-msvc)
* GitHub Check: E2E Test (8)
* GitHub Check: E2E Test (7)
* GitHub Check: E2E BlockSuite Cross Browser Test (2, firefox)
* GitHub Check: E2E Test (2)
* GitHub Check: E2E Test (5)
* GitHub Check: E2E Test (4)
* GitHub Check: E2E Test (10)
* GitHub Check: E2E Test (3)
* GitHub Check: Typecheck
* GitHub Check: E2E Test (1)
* GitHub Check: E2E BlockSuite Cross Browser Test (1, webkit)
* GitHub Check: E2E BlockSuite Cross Browser Test (1, firefox)
* GitHub Check: E2E BlockSuite Cross Browser Test (2, chromium)
* GitHub Check: E2E BlockSuite Cross Browser Test (2, webkit)
* GitHub Check: Build AFFiNE native (aarch64-apple-darwin)
* GitHub Check: Build AFFiNE native (x86_64-apple-darwin)
* GitHub Check: E2E BlockSuite Cross Browser Test (1, chromium)
* GitHub Check: Analyze (javascript, blocksuite)
* GitHub Check: Build AFFiNE native (x86_64-unknown-linux-gnu)
* GitHub Check: Analyze (typescript, blocksuite)
* GitHub Check: Analyze (typescript, affine)
* GitHub Check: Analyze (javascript, affine)
* GitHub Check: miri code check
* GitHub Check: Lint

</details>

<details>
<summary>🔇 Additional comments (6)</summary><blockquote>

<details>
<summary>packages/frontend/native/media_capture/src/macos/utils.rs (6)</summary>

`1-1`: **Import organization looks good**

The import structure correctly places standard library imports first, followed by external crates and then internal modules, addressing the previous code formatting issues identified in CI.

---

`18-45`: **BufferedResampler implementation looks solid**

The implementation nicely handles audio frame buffering to ensure the Rubato resampler receives its preferred fixed-length input blocks, preventing artifacts from recreating the resampler on every callback. The initial flag is now correctly set to `false`, addressing the previous review comment about the inverted logic.

---

`47-88`: **Effective frame buffering and processing implementation**

The `feed` method correctly:
1. Accumulates incoming samples in the FIFO buffer
2. Processes complete chunks when available
3. Discards the initial output to avoid Rubato startup artifacts
4. Interleaves the output channels efficiently

The discarding of the first output block with the `initial_output_discarded` flag is properly implemented now.

---

`90-93`: **Good use of thread-local caching**

The thread-local cache for resamplers is an excellent design choice that ensures each audio thread maintains its own set of persistent resamplers, keyed by sample rates and channel count. This avoids creating new resamplers on every callback, which would cause audio artifacts.

---

`155-196`: **Improved stereo channel handling**

The updated implementation now correctly extracts and preserves the first two channels from multi-channel audio, rather than averaging all channels to mono. This will maintain stereo separation and significantly improve audio quality, especially for voice recordings with background music or environmental sounds.

---

`197-228`: **Better mono handling with stereo upmixing**

The code now properly handles mono input by optionally resampling it and then upmixing to stereo by duplicating samples. This ensures consistent stereo output even from mono sources, which simplifies downstream audio processing by guaranteeing a consistent channel format.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T07:11:41Z
user: login: Brooooooklyn
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-13T08:46:20Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (2)</summary><blockquote>

<details>
<summary>packages/frontend/native/media_capture/src/macos/utils.rs (2)</summary><blockquote>

`90-93`: **Good use of thread-local caching for resamplers**

Using a thread-local cache for resamplers is an excellent approach for audio processing, as it:
1. Avoids expensive resampler recreation
2. Prevents thread synchronization issues
3. Maintains persistent state for better audio quality

Consider adding a mechanism to clean up stale resamplers if many different sample rate combinations might be used over time.

---

`158-196`: **Well-implemented stereo processing path**

The stereo processing path correctly:
1. Extracts the first two channels from multi-channel audio
2. Uses the cached resampler when sample rates differ
3. Efficiently interleaves the channels when no resampling is needed

Consider extracting the hardcoded value `2` for stereo channels to a named constant at the top of the file for better code maintenance.


```diff
+ const STEREO_CHANNEL_COUNT: usize = 2;
```

Then replace occurrences of literal `2` with this constant where it refers to stereo channels.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between a48afd2171be73d9d9c8fbd81a19bd962033e71e and 843f1e34c6ccc842b2d671357b7326bd4b02aa34.

</details>

<details>
<summary>📒 Files selected for processing (2)</summary>

* `packages/frontend/native/media_capture/src/macos/tap_audio.rs` (2 hunks)
* `packages/frontend/native/media_capture/src/macos/utils.rs` (4 hunks)

</details>

<details>
<summary>✅ Files skipped from review due to trivial changes (1)</summary>

* packages/frontend/native/media_capture/src/macos/tap_audio.rs

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (53)</summary>

* GitHub Check: test-build-mobile-app / build-android-web
* GitHub Check: test-build-mobile-app / build-ios-web
* GitHub Check: y-octo binding test on x86_64-pc-windows-msvc
* GitHub Check: y-octo binding test on aarch64-pc-windows-msvc
* GitHub Check: loom thread test
* GitHub Check: y-octo binding test on aarch64-apple-darwin
* GitHub Check: y-octo binding test on x86_64-apple-darwin
* GitHub Check: y-octo binding test on aarch64-unknown-linux-gnu
* GitHub Check: Run native tests
* GitHub Check: miri code check
* GitHub Check: Build @affine/electron renderer
* GitHub Check: E2E BlockSuite Cross Browser Test (1, webkit)
* GitHub Check: E2E BlockSuite Cross Browser Test (2, chromium)
* GitHub Check: fuzzing
* GitHub Check: E2E BlockSuite Cross Browser Test (2, firefox)
* GitHub Check: E2E BlockSuite Cross Browser Test (2, webkit)
* GitHub Check: E2E BlockSuite Cross Browser Test (1, chromium)
* GitHub Check: E2E BlockSuite Test (7)
* GitHub Check: E2E BlockSuite Test (9)
* GitHub Check: E2E BlockSuite Cross Browser Test (1, firefox)
* GitHub Check: E2E BlockSuite Test (4)
* GitHub Check: E2E BlockSuite Test (8)
* GitHub Check: E2E BlockSuite Test (6)
* GitHub Check: E2E BlockSuite Test (10)
* GitHub Check: Build Server native
* GitHub Check: E2E BlockSuite Test (5)
* GitHub Check: E2E BlockSuite Test (3)
* GitHub Check: E2E BlockSuite Test (2)
* GitHub Check: Build AFFiNE native (x86_64-pc-windows-msvc)
* GitHub Check: Build AFFiNE native (aarch64-pc-windows-msvc)
* GitHub Check: E2E BlockSuite Test (1)
* GitHub Check: E2E Test (10)
* GitHub Check: E2E Test (6)
* GitHub Check: E2E Test (3)
* GitHub Check: E2E Test (8)
* GitHub Check: E2E Test (2)
* GitHub Check: E2E Test (9)
* GitHub Check: E2E Test (4)
* GitHub Check: E2E Test (1)
* GitHub Check: E2E Test (5)
* GitHub Check: E2E Mobile Test (3)
* GitHub Check: E2E Mobile Test (5)
* GitHub Check: E2E Mobile Test (4)
* GitHub Check: E2E Test (7)
* GitHub Check: Typecheck
* GitHub Check: E2E Mobile Test (1)
* GitHub Check: Analyze (javascript, blocksuite)
* GitHub Check: Analyze (typescript, affine)
* GitHub Check: E2E Mobile Test (2)
* GitHub Check: Build AFFiNE native (x86_64-apple-darwin)
* GitHub Check: Analyze (typescript, blocksuite)
* GitHub Check: Analyze (javascript, affine)
* GitHub Check: Lint

</details>

<details>
<summary>🔇 Additional comments (8)</summary><blockquote>

<details>
<summary>packages/frontend/native/media_capture/src/macos/utils.rs (8)</summary>

`1-1`: **Good job grouping standard library imports**

The standard library imports have been properly grouped on a single line, which resolves the formatting issues mentioned in previous reviews.

---

`12-16`: **Well-documented buffered resampler implementation**

The comments clearly explain the purpose of the new resampling approach - buffering incoming planar frames to feed Rubato resampler with its preferred fixed block-length to avoid artifacts caused by recreating the resampler every callback. This is a significant architectural improvement.

---

`20-25`: **Good data structure design for the BufferedResampler**

The structure is well-designed with the necessary components: the underlying resampler, channel count tracking, per-channel FIFO queue, and a flag to track initialization state.

---

`43-44`: **Initial output discarded flag is now correctly initialized**

This correctly addresses the previous feedback by initializing `initial_output_discarded` to `false`, ensuring the first block is properly discarded to avoid Rubato startup artifacts.

---

`71-73`: **First output block is now properly discarded**

The conditional logic correctly discards the first output block from the resampler, which helps eliminate startup artifacts commonly encountered with the Rubato resampler.

---

`155-157`: **Improved channel handling for multi-channel audio**

The approach of explicitly handling different channel configurations (extracting L/R for stereo and upmixing for mono) is much better than the previous implementation's channel averaging approach.

---

`197-228`: **Appropriate mono-to-stereo upmixing implementation**

The mono path now correctly:
1. Resamples mono input when needed
2. Upmixes mono to stereo by duplicating samples
3. Returns properly interleaved stereo data

This ensures consistent channel count handling across the application, which is critical for maintaining audio quality when devices change.

---

`230-234`: **Proper handling of empty sample cases**

The final check correctly returns `None` for empty sample arrays, preventing downstream processing from handling empty data.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T08:49:23Z

Review comments:
diff_hunk: @@ -202,55 +202,79 @@ impl InputAndOutputAudioBufferList {
     input_sample_rate: f64,
     output_sample_rate: f64,
   ) -> Result<Vec<f32>, CoreAudioError> {
-    let mut mixed_samples = Vec::new();
-
     // Directly access buffers from the list
     let [input_buffer, output_buffer] = self.0.mBuffers;
 
-    if let Some(processed_input) = process_audio_frame(
+    let processed_input = process_audio_frame(
       input_buffer.mData,
       input_buffer.mDataByteSize,
       input_buffer.mNumberChannels,
       input_sample_rate,
       target_sample_rate,
-    ) {
-      mixed_samples = processed_input;
-    }
+    );
 
-    if let Some(processed_output) = process_audio_frame(
+    let processed_output = process_audio_frame(
       output_buffer.mData,
       output_buffer.mDataByteSize,
       output_buffer.mNumberChannels,
       output_sample_rate,
       target_sample_rate,
-    ) {
-      if mixed_samples.is_empty() {
-        mixed_samples = processed_output;
-      } else {
-        let len1 = mixed_samples.len();
-        let len2 = processed_output.len();
-        match len1.cmp(&len2) {
-          Ordering::Less => {
-            mixed_samples.resize(len2, 0.0);
-          }
-          Ordering::Greater => {
-            let mut padded_output = processed_output;
-            padded_output.resize(len1, 0.0);
-            for (sample1, sample2) in mixed_samples.iter_mut().zip(padded_output.iter()) {
-              *sample1 = (*sample1 + *sample2) / 2.0;
-            }
-            return Ok(mixed_samples);
+    );
+
+    match (processed_input, processed_output) {
+      (Some(input_samples), Some(output_samples)) => {
+        // Mix input and output using the maximum length and padding
+        let input_len = input_samples.len();
+        let output_len = output_samples.len();
+        let max_len = input_len.max(output_len);
+
+        // Create the final mixed buffer with the maximum length
+        let mut mixed_samples = vec![0.0f32; max_len];
+
+        // Add input samples (weighted)
+        for i in 0..input_len {
+          mixed_samples[i] += input_samples[i] * AUDIO_MIX_OUTPUT_WEIGHT;
+        }
+
+        // Add output samples (weighted)
+        for i in 0..output_len {
+          // Ensure index is within bounds in case output_len < max_len, though it shouldn't matter with vec![0.0; max_len]
+          if i < max_len {
+            mixed_samples[i] += output_samples[i] * AUDIO_MIX_OUTPUT_WEIGHT;
           }
-          _ => {}
         }
 
-        for (sample1, sample2) in mixed_samples.iter_mut().zip(processed_output.iter()) {
-          *sample1 = (*sample1 + *sample2) / 2.0;
+        // Clip samples (ensure they are within [-1.0, 1.0])
+        for sample in mixed_samples.iter_mut() {
+          *sample = sample.max(-1.0).min(1.0);
+        }
+
+        Ok(mixed_samples) // Return the mixed buffer (length = max(len))
+      }
path: packages/frontend/native/media_capture/src/macos/audio_buffer.rs
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Input is now scaled as well — intentional change?**

Previously `input + output * AUDIO_MIX_OUTPUT_WEIGHT` preserved the microphone level and only attenuated the loop-back.  
The refactor multiplies **both** streams by the same weight:

```rust
mixed_samples[i] += input_samples[i] * AUDIO_MIX_OUTPUT_WEIGHT;   // <─ new
...
mixed_samples[i] += output_samples[i] * AUDIO_MIX_OUTPUT_WEIGHT;  // unchanged
```

This lowers the perceived volume ~6 dB and may break existing gain staging.

If you meant to keep the old behaviour, revert to:

```diff
-          mixed_samples[i] += input_samples[i] * AUDIO_MIX_OUTPUT_WEIGHT;
+          mixed_samples[i] += input_samples[i];
```

and keep the weighting only for the output path.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-13T05:29:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -9,6 +9,92 @@ use rubato::{FastFixedIn, PolynomialDegree, Resampler};
 
 use crate::error::CoreAudioError;
 
+use std::cell::RefCell;
+use std::collections::HashMap;
+
+// ------------------------------------------------------------
+// A simple wrapper that buffers incoming planar frames so that we always feed
+// the Rubato resampler its preferred fixed block-length. This avoids the
+// artefacts caused by recreating the resampler every callback.
+// ------------------------------------------------------------
+
+const RESAMPLER_INPUT_CHUNK: usize = 1024; // samples per channel
+
+struct BufferedResampler {
+  resampler: FastFixedIn<f32>,
+  channels: usize,
+  fifo: Vec<Vec<f32>>,            // per-channel queue
+  initial_output_discarded: bool, // Flag to track if the first output has been discarded
+}
+
+impl BufferedResampler {
+  fn new(from_sr: f64, to_sr: f64, channels: usize) -> Self {
+    let ratio = to_sr / from_sr;
+    let resampler = FastFixedIn::<f32>::new(
+      ratio,
+      1.0, // max_resample_ratio_relative (must be >= 1.0, use 1.0 for fixed ratio)
+      PolynomialDegree::Linear, // Use Linear interpolation quality
+      RESAMPLER_INPUT_CHUNK,
+      channels,
+    )
+    .expect("Failed to create FastFixedIn resampler (5-arg attempt)");
+
+    BufferedResampler {
+      resampler,
+      channels,
+      fifo: vec![Vec::<f32>::new(); channels],
+      initial_output_discarded: true, // Keep this as true (no discard) for now
+    }
+  }
path: packages/frontend/native/media_capture/src/macos/utils.rs
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**`initial_output_discarded` flag logic is inverted**

`initial_output_discarded` is set to `true` in `new()` and the check in `feed()` is:

```rust
if !self.initial_output_discarded {
    self.initial_output_discarded = true; // discard first block
} else {
    /* use the block */
}
```

Because the flag starts as `true`, the very first block is *not* discarded, defeating the purpose of the guard against the Rubato start-up artefact.

```diff
-      initial_output_discarded: true, // Keep this as true (no discard) for now
+      initial_output_discarded: false, // First block will be dropped
```

You can then keep the `if !flag { flag = true; continue; }` pattern or adjust as preferred.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-13T05:29:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -9,6 +9,92 @@ use rubato::{FastFixedIn, PolynomialDegree, Resampler};
 
 use crate::error::CoreAudioError;
 
+use std::cell::RefCell;
+use std::collections::HashMap;
path: packages/frontend/native/media_capture/src/macos/utils.rs
user: login: graphite-app[bot]
body: **Import Order Suggestion**

Standard library imports should appear before internal crate imports according to common Rust conventions. Consider reordering these imports:

```rust
use std::cell::RefCell;
use std::collections::HashMap;
use rubato::{FastFixedIn, PolynomialDegree, Resampler};

use crate::error::CoreAudioError;
```

This maintains a clean separation between external and internal dependencies, making the import section more organized and consistent with Rust best practices.

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=toeverything&ref=ai-review-comment) (based on [custom rules](https://app.graphite.dev/diamond/?org=toeverything&ref=ai-review-comment&view=settings))*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-05-13T05:39:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -193,7 +193,7 @@ impl AggregateDevice {
 
     let audio_stats = AudioStats {
       sample_rate,
-      channels: 1, // we combined the stereo pcm data into a single channel
+      channels: 2, // we combined the stereo pcm data into a single channel
path: packages/frontend/native/media_capture/src/macos/tap_audio.rs
user: login: graphite-app[bot]
body: The comment "we combined the stereo pcm data into a single channel" contradicts the code which sets `channels: 2`. The comment should be updated to accurately reflect that the audio is being handled as stereo rather than being downmixed to mono. Consider updating it to something like "we're maintaining stereo audio with 2 channels" to align with the implementation.
```suggestion
      channels: 2, // maintaining stereo audio with 2 channels
```
  

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=toeverything&ref=ai-review-comment) (based on [custom rules](https://app.graphite.dev/diamond/?org=toeverything&ref=ai-review-comment&view=settings))*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-05-13T05:44:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -9,6 +9,92 @@ use rubato::{FastFixedIn, PolynomialDegree, Resampler};
 
 use crate::error::CoreAudioError;
 
+use std::cell::RefCell;
+use std::collections::HashMap;
path: packages/frontend/native/media_capture/src/macos/utils.rs
user: login: graphite-app[bot]
body: These lines cause CI to fail because they add separate import statements for std modules. They should be combined with existing std imports at the top of the file using the grouped import syntax like `use std::{cell::RefCell, collections::HashMap};`. Running `cargo fmt` locally would fix this formatting issue.

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=toeverything&ref=ai-review-comment) (based on CI logs)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-05-13T05:45:43Z
author_association: CONTRIBUTOR

PR comments:
user: login: coderabbitai[bot]
created_at: 2025-05-13T05:24:23Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

"""
## Walkthrough

The changes update audio channel handling and resampling in the macOS media capture module. Channel count reporting is corrected from mono to stereo. The audio frame processing now uses a persistent, buffered resampler with thread-local caching, explicit stereo extraction, and upmixing for mono input, replacing the previous per-frame resampling and channel averaging approach.

## Changes

| File(s)                                                                 | Change Summary                                                                                                                                                                                                                  |
|-------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| .../media_capture/src/macos/tap_audio.rs                                | Updated reported audio channel count from 1 to 2 in device stats and fallback values.                                                                                                   |
| .../media_capture/src/macos/utils.rs                                    | Introduced `BufferedResampler` for persistent, chunked audio resampling with thread-local cache. Refactored `process_audio_frame` for explicit stereo extraction/upmixing and improved resampling logic.                       |

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant Caller
    participant process_audio_frame
    participant RESAMPLER_CACHE
    participant BufferedResampler

    Caller->>process_audio_frame: Provide audio frame data
    process_audio_frame->>RESAMPLER_CACHE: Lookup or insert BufferedResampler (by rates, channels)
    process_audio_frame->>BufferedResampler: Feed planar audio data
    BufferedResampler-->>process_audio_frame: Return interleaved resampled samples
    process_audio_frame-->>Caller: Return processed audio (stereo/interleaved)
```

## Assessment against linked issues

| Objective                                                                 | Addressed | Explanation                                                                 |
|---------------------------------------------------------------------------|-----------|-----------------------------------------------------------------------------|
| 1. Switching to headphone device results in only mono output (AF-2614)    | ✅        |                                                                             |
| 2. Sometimes after starting recording, headphones produce no sound but tap intercepts audio (AF-2614) | ❓        | Changes improve channel handling and resampling but do not address output device routing or playback issues. |

## Suggested labels

`app:electron`

## Poem

> In the warren, we hop with glee,  
> For stereo sound now comes in twos, not one, you see!  
> Buffered resamplers keep the rhythm in line,  
> Caching each chunk, making audio divine.  
> With every frame, our ears delight—  
> Stereo bunnies, dancing through the night!  
> 🐰🎶
"""

<!-- walkthrough_end -->
<!-- internal state start -->


<!-- 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= -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details open="true">
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=toeverything/AFFiNE&utm_content=12246):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Explain this complex logic.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai explain this code block.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and explain its main purpose.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

### Support

Need help? Create a ticket on our [support page](https://www.coderabbit.ai/contact-us/support) for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai generate sequence diagram` to generate a sequence diagram of the changes in this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: CONTRIBUTOR
user: login: pengx17
created_at: 2025-05-13T05:24:35Z
body: * **#12249** <a href="https://app.graphite.dev/github/pr/toeverything/AFFiNE/12249?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#12247** <a href="https://app.graphite.dev/github/pr/toeverything/AFFiNE/12247?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#12246** <a href="https://app.graphite.dev/github/pr/toeverything/AFFiNE/12246?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/toeverything/AFFiNE/12246?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* **#12232** <a href="https://app.graphite.dev/github/pr/toeverything/AFFiNE/12232?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#12231** <a href="https://app.graphite.dev/github/pr/toeverything/AFFiNE/12231?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#12226** <a href="https://app.graphite.dev/github/pr/toeverything/AFFiNE/12226?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* `canary`
<hr />

## How to use the Graphite Merge Queue

Add either label to this PR to merge it via the merge queue:
- <kbd>merge</kbd> - adds this PR to the back of the merge queue
- <kbd>hotfix</kbd> - for urgent hot fixes, skip the queue and merge this PR next

You must have a Graphite account in order to use the merge queue. Sign up using [this link](https://app.graphite.dev/invite/github/toeverything?ref=merge-queue-instructions-in-stack-comment&prId=12246).

<h2></h2>
<sub>

An organization admin has enabled the [Graphite Merge Queue](https://graphite.dev/docs/graphite-merge-queue) in this repository.

</sub>

<sub>

Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue.

</sub>

<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: COLLABORATOR
user: login: pengx17
created_at: 2025-05-13T05:26:43Z
body: @coderabbitai review
author_association: COLLABORATOR
user: login: coderabbitai[bot]
created_at: 2025-05-13T05:26:48Z
body: <!-- This is an auto-generated reply by CodeRabbit -->
<details>
<summary>✅ Actions performed</summary>

Review triggered.

> Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

</details>
author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-05-13T05:34:26Z
body: ## [Codecov](https://app.codecov.io/gh/toeverything/AFFiNE/pull/12246?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=toeverything) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 54.13%. Comparing base [(`13d882d`)](https://app.codecov.io/gh/toeverything/AFFiNE/commit/13d882d6d8043980bc313db44ca3c7c9a112bd3a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=toeverything) to head [(`843f1e3`)](https://app.codecov.io/gh/toeverything/AFFiNE/commit/843f1e34c6ccc842b2d671357b7326bd4b02aa34?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=toeverything).
> Report is 1 commits behind head on canary.

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff            @@
##           canary   #12246    +/-   ##
========================================
  Coverage   54.12%   54.13%            
========================================
  Files        2585     2585            
  Lines      118174   118174            
  Branches    19150    19150            
========================================
+ Hits        63965    63968     +3     
+ Misses      52667    52143   -524     
- Partials     1542     2063   +521     
```

| [Flag](https://app.codecov.io/gh/toeverything/AFFiNE/pull/12246/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=toeverything) | Coverage Δ | |
|---|---|---|
| [server-test](https://app.codecov.io/gh/toeverything/AFFiNE/pull/12246/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=toeverything) | `77.55% <ø> (ø)` | |
| [unittest](https://app.codecov.io/gh/toeverything/AFFiNE/pull/12246/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=toeverything) | `31.02% <ø> (+<0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=toeverything#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/toeverything/AFFiNE/pull/12246?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=toeverything).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=toeverything).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: graphite-app[bot]
created_at: 2025-05-13T08:46:29Z
body: ### Merge activity

* **May 13, 4:46 AM EDT**: `pengx17` added this pull request to the [Graphite merge queue](https://app.graphite.dev/merges?org=toeverything&repo=AFFiNE).
* **May 13, 5:00 AM EDT**: Merged by the [Graphite merge queue](https://app.graphite.dev/merges?org=toeverything&repo=AFFiNE).

author_association: CONTRIBUTOR