Skip to content

feat(recorder): the WASAPI capture sidecar - #9

Merged
protonspy merged 3 commits into
mainfrom
feat/recorder
Aug 1, 2026
Merged

feat(recorder): the WASAPI capture sidecar#9
protonspy merged 3 commits into
mainfrom
feat/recorder

Conversation

@protonspy

@protonspy protonspy commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Closes plan tasks 4.1–4.5. 64 Rust tests; the three TDD tasks were built RED first.

Shape

The device sits behind a CaptureSource trait. Everything that can be wrong without a symptom — track alignment, the silence that stands in for frames the API never sends, the pause arithmetic, the time map, the manifest, the JSON-RPC — lives above that line and is tested on every platform. WASAPI is a thin edge.

Two bugs the TDD caught before they shipped:

  • The time-map boundary. A recorded instant landing exactly on a pause belongs to the segment that resumed, not the one that ended. Written the other way, with four ten-second pauses, a citation landed ten seconds off — in the component the plan itself calls "lies with confidence".
  • Double-counted audio. The first session pump padded the track to "now" and appended the second of audio covering that same second, producing a track twice the length of the meeting.

⚠️ This is not verified against hardware

No test here has captured a real frame — CI has no audio device. I compiled and clippy'd the WASAPI path for x86_64-pc-windows-msvc locally, so the FFI type-checks against the real crate API, but green CI means the session logic is right and the binary builds. It does not mean audio works.

That is not a hypothetical caveat. Reading the source, the reviews found three defects that meant the binary as first staged could not record on Windows at all:

  • The loopback stream was opened as (Render, Render). The crate derives AUDCLNT_STREAMFLAGS_LOOPBACK from the pair — device Render, stream Capture. Passing (Render, Render) matched nothing, so the flag was never set, the client initialised as a plain playback stream, and asking it for an IAudioCaptureClient failed — taking the sidecar down at launch. The device direction and the stream direction are two decisions that I had written as one function.
  • open_stream started the stream, so the first start request hit AUDCLNT_E_NOT_STOPPED and dropped the session that had just been built, leaving two empty WAVs.
  • Capture was drained only when an RPC line arrived, one packet at a time, from a buffer of min_period. Continuous capture would have required ~300 requests/second and could not have exceeded ~50. Everything missed was backfilled with manufactured silence — so an hour of nothing would report the right length, the right frame count, and a healthy time map.

All compiled. All were invisible to every test that does not touch a device. The three manual checks the plan's group 4 notes require are the only thing that can close this, and they are outstanding — I've written that onto the plan's 4.1 line so a later reader cannot mistake a green build for working audio.

The rest of the review findings, all fixed

  • Each device is drained on a thread of its own, and opened on that thread — a WASAPI client is COM and not Send, which only surfaced by compiling for Windows.
  • A failed reopen no longer leaves a track dead forever (open the new stream before dropping the old; retry).
  • A read failure is treated as a reason to reopen — that is how a device change usually announces itself, not an event timeout.
  • Pause resets the stream, so pre-pause audio does not land after the resume and the two tracks do not overshoot by different amounts.
  • The time map is never extended past what the tracks hold. Measured before the fix: the map said 1.020 s where the file held 1.000 s, and resume anchored on the inflated figure, making it permanent and cumulative across pauses.
  • first_frames counts real frames rather than manufactured silence — it was null for both tracks in every realistic recording, and 4.4 names it as one of the three things the manifest is for.
  • One failing device no longer freezes the other track or the time map.
  • WAV write failures are counted rather than discarded, so the manifest cannot describe audio the file lacks.
  • A failing header no longer costs the whole manifest — the title, first frames, pauses and time map are not reconstructible from two WAV files.
  • The 4 GiB a WAV header can describe (~3 h per track) is guarded rather than silently wrapped.
  • The manifest is on one time base: pause intervals were monotonic while everything else was Unix-epoch, four u64 fields all named _ns with nothing to tell them apart — a comparison 4.7 and 4.11 would have got wrong by 1.7e18.

Not needed after all

The workspace's unsafe_code = "deny" did not have to be lifted. The plan anticipated group 4 relaxing it for WASAPI; the wasapi crate holds the unsafe, so this crate contains none. I updated the lint's comment rather than leave it promising a change that never happened.

Verified

  • cargo test 64 passing · cargo clippy --all-targets -- -D warnings clean · cargo fmt --check clean — on both the Linux host and x86_64-pc-windows-msvc
  • scc validate 0 findings; all three new dependencies recorded in docs/stack.md
  • The TypeScript suite (464 tests) is untouched and still green

🤖 Generated with Claude Code

https://claude.ai/code/session_016iMM93Wk43o44V5J2AxPgL

Summary by CodeRabbit

  • New Features

    • Added Windows audio recording for microphone and system/loopback capture.
    • Added start, pause, resume, stop, status, and device-listing controls through a JSON-lines interface.
    • Recordings now include synchronized WAV tracks and detailed JSON metadata.
    • Added handling for capture interruptions, silence padding, device changes, and recording size limits.
    • Added reporting for dropped samples, timing discontinuities, and capture faults.
  • Documentation

    • Updated recording and technology stack documentation.
  • Tests

    • Added comprehensive coverage for recording, timing, track output, metadata, and request handling.

Closes plan tasks 4.1-4.5. 64 Rust tests; the three TDD tasks were built
RED first.

The device sits behind a `CaptureSource` trait, so everything that can be
wrong without a symptom — track alignment, the silence that stands in for
frames the API never sends, the pause arithmetic, the time map — is
tested on every platform, and WASAPI is a thin edge.

Two bugs the TDD caught before they shipped:

- A recorded instant landing exactly on a pause belongs to the segment
  that *resumed*, not the one that ended. Written the other way, with
  four ten-second pauses, a citation landed ten seconds off — in the
  component the plan calls "lies with confidence".
- The first session pump padded the track to "now" *and* appended the
  second of audio covering that same second, producing a track twice the
  length of the meeting.

From the two reviews on this branch, which between them found nineteen
issues. The three that meant the binary could not record on Windows:

- The loopback stream was opened as (Render, Render). The crate derives
  AUDCLNT_STREAMFLAGS_LOOPBACK from the *pair* — device Render, stream
  Capture — so the flag was never set, the client initialised as a plain
  playback stream, and asking it for an IAudioCaptureClient failed,
  taking the sidecar down at launch. The device direction and the stream
  direction are two decisions and were one function.
- `open_stream` started the stream, so the first `start` request hit
  AUDCLNT_E_NOT_STOPPED and dropped the session that had just been built.
- Capture was drained only when an RPC line arrived, one packet at a
  time, from a buffer of `min_period`. Continuous capture would have
  needed ~300 requests/second and could not exceed ~50. Everything
  missed was backfilled with manufactured silence, so an hour of nothing
  reported the right length, the right frame count and a healthy time
  map. Each device is now drained on a thread of its own — opened *on*
  that thread, because a WASAPI client is COM and not `Send`.

And the rest: a failed reopen no longer leaves a track dead forever; a
read failure is treated as a reason to reopen, which is how a device
change usually announces itself; pause resets the stream so pre-pause
audio does not land after the resume; the time map is never extended
past what the tracks hold; `first_frames` counts real frames rather than
manufactured silence, which had it null in every realistic recording;
one failing device no longer freezes the other track; WAV write failures
are counted rather than discarded; a failing header no longer costs the
whole manifest; the 4 GiB a WAV header can describe is guarded; and the
manifest is on one time base instead of two indistinguishable ones.

The workspace's `unsafe_code = "deny"` did not need lifting: the wasapi
crate holds the unsafe, so this crate has none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016iMM93Wk43o44V5J2AxPgL
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR replaces the recorder stub with a JSON-lines recording sidecar. It adds synchronized microphone and system capture, pause and resume handling, time mapping, WAV output, manifests, RPC handling, threaded capture, and Windows WASAPI integration.

Changes

Recorder sidecar

Layer / File(s) Summary
Recorder contracts and crate foundation
Cargo.toml, crates/recorder/Cargo.toml, crates/recorder/src/{lib,capture,clock,rpc,manifest}.rs, crates/recorder/tests/rpc.rs
Defines public capture, clock, RPC, manifest, and scripted-source contracts.
Track output and time mapping
crates/recorder/src/{track,timemap}.rs, crates/recorder/tests/{track,timemap}.rs
Adds float WAV and in-memory track writing, silence padding, segment tracking, and bidirectional recorded-time mapping.
Synchronized recording sessions
crates/recorder/src/session.rs, crates/recorder/tests/session.rs
Coordinates two capture sources, pauses, resumes, device changes, first frames, track alignment, finalization, and stop behavior.
Threaded and WASAPI capture
crates/recorder/src/{pump,wasapi_source}.rs, crates/recorder/tests/session.rs
Adds asynchronous capture draining and Windows WASAPI device enumeration, stream management, polling, reconnection, discontinuity tracking, and event-order validation.
RPC service and sidecar entrypoint
crates/recorder/src/{service,main}.rs, crates/recorder/tests/service.rs, docs/stack.md, plans/open-wiki.md
Adds request handling, status and device responses, manifest persistence, JSON-lines processing, startup errors, integration tests, dependency documentation, and recording-plan status updates.

Estimated code review effort: 5 (Critical) | ~90 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Stdin
  participant Main
  participant Service
  participant Session
  participant CaptureSource
  Stdin->>Main: Send JSON-lines request
  Main->>Service: Parse and handle request
  Service->>Session: Start, pause, resume, stop, or report status
  Service->>CaptureSource: Poll microphone and system capture
  Session->>Session: Update tracks and time map
  Service-->>Main: Return JSON response
  Main-->>Stdin: Flush response line
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the main change: adding a WASAPI capture sidecar for the recorder.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/recorder

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

🧹 Nitpick comments (10)
crates/recorder/src/rpc.rs (1)

78-82: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Escape the error text in the render fallback.

Line 80 interpolates the serde_json::Error display text directly into a JSON string literal. If that text ever contains " or \, the fallback emits invalid JSON — which is the one thing this fallback exists to prevent.

The current Payload and StatusPayload types cannot realistically fail to serialize, so this is not reachable today. Building the fallback through serde_json keeps the guarantee true if the payload types grow.

♻️ Proposed fix
 pub fn render(response: &Response) -> String {
     serde_json::to_string(response).unwrap_or_else(|e| {
-        format!("{{\"ok\":\"false\",\"error\":\"could not serialise the response: {e}\"}}")
+        // Build the fallback through serde so the error text is escaped.
+        serde_json::to_string(&Response::Err {
+            error: format!("could not serialise the response: {e}"),
+        })
+        .unwrap_or_else(|_| {
+            r#"{"ok":"false","error":"could not serialise the response"}"#.to_string()
+        })
     })
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/recorder/src/rpc.rs` around lines 78 - 82, Update the render fallback
to construct the error response through serde_json serialization rather than
interpolating serde_json::Error directly into a JSON string; ensure any quotes,
backslashes, or other special characters in the error text are escaped while
preserving the existing fallback message and return type.
crates/recorder/tests/rpc.rs (1)

89-95: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a render test for Payload::Done.

Payload has three variants. These tests cover Status and Devices, but not Done. Done is the response service.rs returns for stop, which is the one response that reports whether the recording was written.

Payload is #[serde(untagged)] nested inside an internally tagged Response, so the flattened key layout is worth pinning for every variant.

💚 Proposed test
#[test]
fn a_finished_recording_renders_as_done() {
    let parsed: serde_json::Value =
        serde_json::from_str(&render(&Response::Ok(Payload::Done { done: true })))
            .expect("valid json");
    assert_eq!(parsed["ok"], "true");
    assert_eq!(parsed["done"], true);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/recorder/tests/rpc.rs` around lines 89 - 95, Add a render test
alongside the existing `Status` and `Devices` tests that constructs
`Response::Ok(Payload::Done { done: true })`, parses the rendered JSON, and
asserts the flattened `ok` and boolean `done` fields. Keep the test focused on
pinning the serialized layout for the `Done` variant.
crates/recorder/src/pump.rs (1)

113-117: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Report repeated poll errors instead of retrying silently forever.

On a poll error the thread sleeps 5 ms and retries, with no counter and no upper bound. If the device fails persistently, this loop runs about 200 times per second for the whole recording and nothing reports it. The session sees only Poll::Idle and pads the track.

The comment is right that the thread should not give up. Count the consecutive errors and publish the count or the last error text, so status can show that capture is failing. Increasing the backoff also avoids a busy loop on a permanently broken device.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/recorder/src/pump.rs` around lines 113 - 117, Update the poll-error
handling loop in the recorder pump to track consecutive errors without
terminating the thread, publish the count or latest error text through the
existing status mechanism, and increase the retry backoff to avoid a tight loop
during persistent device failure. Reset the consecutive-error state after a
successful poll and preserve normal recovery behavior.
crates/recorder/src/wasapi_source.rs (1)

382-389: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Use drain instead of a per-byte pop_front loop, and derive the sample width from BITS.

Lines 383-385 move whole bytes one at a time. self.pending holds at least whole elements, so unwrap_or(0) never fires and hides nothing. VecDeque::drain expresses the same operation and copies in bulk. For 500 ms of 48 kHz stereo f32 this loop handles about 768,000 bytes per drain.

Line 387 hard-codes the sample width as 4. That matches BITS = 32 at line 29 and SampleType::Float at line 247, but the two are coupled implicitly.

♻️ Proposed refactor
+/// Bytes per sample, from the format requested at line 244.
+const SAMPLE_BYTES: usize = BITS / 8;
-        let mut bytes = Vec::with_capacity(whole);
-        for _ in 0..whole {
-            bytes.push(self.pending.pop_front().unwrap_or(0));
-        }
-        let samples = bytes
-            .chunks_exact(4)
-            .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
-            .collect();
+        let bytes: Vec<u8> = self.pending.drain(..whole).collect();
+        let samples = bytes
+            .chunks_exact(SAMPLE_BYTES)
+            .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
+            .collect();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/recorder/src/wasapi_source.rs` around lines 382 - 389, Update the byte
extraction in the recorder method around self.pending and samples to drain whole
bytes from the VecDeque in bulk instead of repeatedly calling pop_front,
preserving the existing ordering and length. Replace the hard-coded 4-byte chunk
width with a value derived from the existing BITS constant so sample decoding
remains coupled to the configured sample size.
crates/recorder/src/capture.rs (1)

13-14: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Align wall_ns with its actual use. Session::apply ignores the device timestamp and uses the session clock, while real producers emit 0. Update the doc comment or remove the unused field and its callers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/recorder/src/capture.rs` around lines 13 - 14, Align the Frames
variant’s wall_ns contract with Session::apply: either update its documentation
to state that the value is ignored and the session clock is used, or remove
wall_ns and update all constructors and callers, including real producers that
currently pass 0.
crates/recorder/tests/track.rs (1)

165-173: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add tests for the new WAV paths.

This file covers frames and padding well. It does not cover attach_wav, finalize, failed_samples, or at_size_limit, which this PR adds. at_size_limit is pure arithmetic and cheap to test at the boundary. attach_wav followed by finalize in a temp directory would pin the "buffered samples move into the file" behaviour that lines 58-61 of track.rs describe. I can draft these tests if you want.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/recorder/tests/track.rs` around lines 165 - 173, Add focused tests in
the track test module for the new WAV paths: verify at_size_limit at its
boundary, cover failed_samples behavior, and use a temporary directory to test
attach_wav followed by finalize, confirming buffered samples are moved into the
output file as described by TrackWriter.
crates/recorder/src/service.rs (1)

168-192: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Record the failed-sample count in the manifest.

frames comes from frames_written, which counts samples the WAV refused. finalize_files reports those in the stop response, but the response is not persisted. Stage 4.7 and 4.11 read manifest.json alone, so they cannot tell that the file is shorter than the declared frame count. Add the failed count per track to TrackInfo, or subtract it from frames.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/recorder/src/service.rs` around lines 168 - 192, Update manifest_of so
each TrackInfo records the number of samples actually accepted by the WAV,
rather than relying on frames_written alone. Use the existing failed-sample
count from the session/finalization state for both mic and system tracks, either
by adding that field to TrackInfo or subtracting it from frames, while
preserving the manifest’s per-track data.
crates/recorder/tests/session.rs (1)

307-320: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extend the failing-source coverage to resume.

BrokenSource only fails on poll. A source that fails on start is the case that exposes the asymmetric resume path flagged in crates/recorder/src/session.rs lines 303-329. Add a variant whose start returns Err, then assert that a failed resume leaves both devices stopped and the state unchanged.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/recorder/tests/session.rs` around lines 307 - 320, Extend the test
fixtures around BrokenSource with a source variant whose start method returns
Err, then add resume coverage using it. Assert that when resume fails, both
devices remain stopped and the session state is unchanged, while preserving the
existing poll-failure tests.
crates/recorder/src/session.rs (1)

142-149: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Use one arithmetic form for recorded nanoseconds.

recorded_ns at lines 136-139 promotes to u128 before multiplying. recorded_now_ns uses saturating_mul(1_000_000_000) on a u64, which saturates instead of producing a correct value, and it derives the position from expected_frames_at rather than frames_written. The two therefore report different offsets for the same instant. A DeviceChange.recorded_ns stamped from the second form does not line up with the recorded_ms that status reports. Compute both through the same helper.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/recorder/src/session.rs` around lines 142 - 149, Update
recorded_now_ns to use the same arithmetic and frame-position source as
recorded_ns: derive the offset from frames_written, promote to u128 before
multiplying by 1_000_000_000, then divide by the sample rate and convert safely
to u64. Reuse the existing recorded_ns helper or centralize the calculation so
DeviceChange timestamps and status recorded_ms remain aligned.
crates/recorder/src/track.rs (1)

151-156: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Rename the anchor field to match the clock the callers pass.

Session::start, Session::resume, and Session::pad_to all pass monotonic readings into begin_segment, expected_frames_at, and pad_to, while the parameter and the field are named wall_start_ns and wall_ns. The comment in session.rs lines 45-52 states that mixing two _ns bases under one name is the defect this crate already paid for once. Rename these to mono_* or a neutral at_ns to keep the two bases distinguishable.

Also applies to: 168-177

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/recorder/src/track.rs` around lines 151 - 156, Rename the segment
anchor timestamp parameter and field used by begin_segment and related
expected_frames_at/pad_to paths from wall_* to mono_* or a neutral at_ns,
preserving the monotonic readings supplied by Session::start, Session::resume,
and Session::pad_to. Update all corresponding references, including
SegmentAnchor construction and access, so wall-clock and monotonic nanosecond
bases remain distinguishable.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/recorder/src/main.rs`:
- Around line 85-112: Change the recorder loop around service.pump() so capture
data is drained on a fixed timer independently of stdin requests, using a
dedicated pumping thread or timed stdin-read approach. Ensure pumping continues
while the parent sends no requests and occurs before rendering responses so
status reflects current session state rather than the previous pump.

In `@crates/recorder/src/pump.rs`:
- Line 24: Replace the unbounded channel used by the recorder capture thread
with a bounded sync_channel sized for a few seconds of packets. In the capture
send loop around the channel producer, use try_send and increment a shared
thread_dropped counter when the queue is full; update lost_frames() to add this
counter to the source’s loss count so the manifest reports discarded audio.
- Around line 81-85: Update crates/recorder/src/pump.rs lines 81-85 in the
capture startup handling to store the error returned by source.start() in shared
state instead of discarding it, and update ThreadedSource::start around lines
212-216 to wait briefly for and return that error so service.rs rejects failed
starts. Also update crates/recorder/src/pump.rs lines 193-195 to distinguish
TryRecvError::Disconnected from Empty; after returning any accumulated samples,
report a terminal CaptureError for a disconnected capture thread rather than
falling through to Poll::Idle.
- Around line 180-191: Update ThreadedSource::poll to retain a DeviceChanged
event when preceding samples must be returned first, instead of dropping the
consumed event. Add and initialize a pending device-change field in
ThreadedSource::spawn, then have poll return that pending event before draining
later frames so the documented ordering and device-change reporting are
preserved.

In `@crates/recorder/src/service.rs`:
- Around line 74-79: The sequential capture startup paths lack rollback when
system capture fails. In crates/recorder/src/service.rs lines 74-79, stop the
microphone and call session.finalize_files() before returning the startup error;
in crates/recorder/src/session.rs lines 303-329, stop the microphone before
returning the system.start() error while preserving the existing state and open
PauseInterval.

In `@crates/recorder/src/track.rs`:
- Around line 204-212: Update append to truncate the input slice to a whole
number of interleaved frames before calling emit: calculate the usable sample
count from frames.len() and the effective channel count, write only that prefix,
and increment frames_written by the matching frame count. Preserve the existing
no-segment and empty-input early return behavior, and avoid emitting any partial
trailing frame.
- Around line 85-88: Update the recording flow in Service::pump to check
Session::at_size_limit() after pumping frames and stop or rotate the session
when it returns true, ensuring no further frames are written beyond the WAV size
threshold. Keep Session::at_size_limit() as the single size-limit calculation
and preserve normal pumping when the limit has not been reached.

In `@crates/recorder/src/wasapi_source.rs`:
- Around line 334-346: Throttle the default-device comparison in the
event-timeout branch around default_device_changed() to at most once per second
by storing and checking the last comparison time in the source state. Keep the
existing reopen and DeviceChanged behavior when the check detects a change,
while allowing timeout polls between checks to return Poll::Idle; leave the
needs_reopen path unchanged.
- Around line 303-305: Align the WASAPI discontinuity count with the public API
contract by renaming the `lost_frames` method and all propagation/manifest
consumers to `lost_events`, preserving the value as an event count rather than
presenting it as frames. Update the relevant trait, `WasapiSource`,
`ThreadedSource`, and manifest recording paths consistently; do not estimate
frame counts since WASAPI does not provide them.
- Around line 397-411: Update the `start` and `stop` methods to maintain the
`running` field: set it to true only after `start_stream()` succeeds, and set it
to false when stopping the stream. Preserve the existing error and closed-stream
behavior so `reopen()` can use `running` to restart previously active streams.
- Around line 192-196: Update reopen to recreate the stream when needs_reopen is
set even if default_device_changed reports the same device id, rather than
clearing the flag and returning immediately. Preserve the existing device-switch
behavior, and set self.changed = Some(name) only when the default device
actually moved so in-place stream recovery does not report a device change.
- Around line 256-271: Update the shared event-driven StreamMode::EventsShared
configuration in the recorder initialization flow to pass buffer_duration_hns as
zero, rather than deriving it from min_period and BUFFER_DURATION_HNS. Leave the
autoconvert setting and initialize_client call unchanged.
- Around line 19-22: Update the COM setup around initialize_mta in the affected
recorder entry points to require successful MTA initialization, propagating or
handling RPC_E_CHANGED_MODE as failure rather than accepting it; remove any
“safe to call twice” assumption and call wasapi::deinitialize() exactly once for
each successful initialization, including cleanup on early exits.

---

Nitpick comments:
In `@crates/recorder/src/capture.rs`:
- Around line 13-14: Align the Frames variant’s wall_ns contract with
Session::apply: either update its documentation to state that the value is
ignored and the session clock is used, or remove wall_ns and update all
constructors and callers, including real producers that currently pass 0.

In `@crates/recorder/src/pump.rs`:
- Around line 113-117: Update the poll-error handling loop in the recorder pump
to track consecutive errors without terminating the thread, publish the count or
latest error text through the existing status mechanism, and increase the retry
backoff to avoid a tight loop during persistent device failure. Reset the
consecutive-error state after a successful poll and preserve normal recovery
behavior.

In `@crates/recorder/src/rpc.rs`:
- Around line 78-82: Update the render fallback to construct the error response
through serde_json serialization rather than interpolating serde_json::Error
directly into a JSON string; ensure any quotes, backslashes, or other special
characters in the error text are escaped while preserving the existing fallback
message and return type.

In `@crates/recorder/src/service.rs`:
- Around line 168-192: Update manifest_of so each TrackInfo records the number
of samples actually accepted by the WAV, rather than relying on frames_written
alone. Use the existing failed-sample count from the session/finalization state
for both mic and system tracks, either by adding that field to TrackInfo or
subtracting it from frames, while preserving the manifest’s per-track data.

In `@crates/recorder/src/session.rs`:
- Around line 142-149: Update recorded_now_ns to use the same arithmetic and
frame-position source as recorded_ns: derive the offset from frames_written,
promote to u128 before multiplying by 1_000_000_000, then divide by the sample
rate and convert safely to u64. Reuse the existing recorded_ns helper or
centralize the calculation so DeviceChange timestamps and status recorded_ms
remain aligned.

In `@crates/recorder/src/track.rs`:
- Around line 151-156: Rename the segment anchor timestamp parameter and field
used by begin_segment and related expected_frames_at/pad_to paths from wall_* to
mono_* or a neutral at_ns, preserving the monotonic readings supplied by
Session::start, Session::resume, and Session::pad_to. Update all corresponding
references, including SegmentAnchor construction and access, so wall-clock and
monotonic nanosecond bases remain distinguishable.

In `@crates/recorder/src/wasapi_source.rs`:
- Around line 382-389: Update the byte extraction in the recorder method around
self.pending and samples to drain whole bytes from the VecDeque in bulk instead
of repeatedly calling pop_front, preserving the existing ordering and length.
Replace the hard-coded 4-byte chunk width with a value derived from the existing
BITS constant so sample decoding remains coupled to the configured sample size.

In `@crates/recorder/tests/rpc.rs`:
- Around line 89-95: Add a render test alongside the existing `Status` and
`Devices` tests that constructs `Response::Ok(Payload::Done { done: true })`,
parses the rendered JSON, and asserts the flattened `ok` and boolean `done`
fields. Keep the test focused on pinning the serialized layout for the `Done`
variant.

In `@crates/recorder/tests/session.rs`:
- Around line 307-320: Extend the test fixtures around BrokenSource with a
source variant whose start method returns Err, then add resume coverage using
it. Assert that when resume fails, both devices remain stopped and the session
state is unchanged, while preserving the existing poll-failure tests.

In `@crates/recorder/tests/track.rs`:
- Around line 165-173: Add focused tests in the track test module for the new
WAV paths: verify at_size_limit at its boundary, cover failed_samples behavior,
and use a temporary directory to test attach_wav followed by finalize,
confirming buffered samples are moved into the output file as described by
TrackWriter.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c4f79be2-cbfa-469d-84f9-97635ca52386

📥 Commits

Reviewing files that changed from the base of the PR and between c170413 and 7f54c8c.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (21)
  • Cargo.toml
  • crates/recorder/Cargo.toml
  • crates/recorder/src/capture.rs
  • crates/recorder/src/clock.rs
  • crates/recorder/src/lib.rs
  • crates/recorder/src/main.rs
  • crates/recorder/src/manifest.rs
  • crates/recorder/src/pump.rs
  • crates/recorder/src/rpc.rs
  • crates/recorder/src/service.rs
  • crates/recorder/src/session.rs
  • crates/recorder/src/timemap.rs
  • crates/recorder/src/track.rs
  • crates/recorder/src/wasapi_source.rs
  • crates/recorder/tests/rpc.rs
  • crates/recorder/tests/service.rs
  • crates/recorder/tests/session.rs
  • crates/recorder/tests/timemap.rs
  • crates/recorder/tests/track.rs
  • docs/stack.md
  • plans/open-wiki.md

Comment thread crates/recorder/src/main.rs Outdated
Comment thread crates/recorder/src/pump.rs
Comment thread crates/recorder/src/pump.rs
Comment thread crates/recorder/src/pump.rs
Comment thread crates/recorder/src/service.rs
Comment thread crates/recorder/src/wasapi_source.rs
Comment on lines +256 to +271
// A buffer measured in hundreds of milliseconds, not the device minimum.
// The minimum is a few milliseconds, and anything the drain loop does not
// collect inside that window WASAPI overwrites — which the track then
// backfills with silence that looks, in every number this program
// reports, exactly like a healthy recording.
let buffer_duration_hns = min_period.max(BUFFER_DURATION_HNS);

// Shared mode with autoconvert: the meeting is not the only thing using the
// sound card, and exclusive mode would take it from whatever is.
let mode = StreamMode::EventsShared {
autoconvert: true,
buffer_duration_hns,
};
client
.initialize_client(&wave, &which.stream_direction(), &mode)
.map_err(|e| err("could not initialise the stream", e))?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

wasapi crate StreamMode EventsShared buffer_duration_hns IAudioClient Initialize shared mode event callback hnsBufferDuration must be 0

💡 Result:

In the context of the Windows WASAPI API, the requirement that hnsBufferDuration must be 0 for shared-mode streams using event-driven buffering is a specific constraint imposed by the underlying IAudioClient::Initialize method [1][2]. According to official Microsoft documentation, for a shared-mode stream that uses the AUDCLNT_STREAMFLAGS_EVENTCALLBACK flag (event-driven buffering), the caller must set both the hnsPeriodicity and hnsBufferDuration parameters to 0 [1][2]. When these are set to 0, the Initialize method automatically determines the appropriate buffer size based on the audio engine's scheduling period [1][2]. In the Rust wasapi crate, the StreamMode::EventsShared variant allows you to specify a buffer_duration_hns [3][4]. While the crate provides this field, it is important to note that the WASAPI backend implementation will generally pass these values to the underlying IAudioClient::Initialize call [4][5]. If you are targeting Windows requirements for shared event-driven streams, you should be aware that passing a non-zero value may not behave as expected or could lead to initialization errors depending on how the underlying Windows audio engine interprets that specific value for a shared-mode request [1][2]. For optimal shared-mode performance and to follow recommended Windows practices, developers often use 0 to allow the system to handle buffer allocation [6]. If you require specific latency, the audio engine's default period (obtained via IAudioClient::GetDevicePeriod) is often used as a reference, but for strictly compliant event-driven shared-mode initialization as defined by Microsoft, setting the buffer duration to 0 is the documented standard [1][2][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- candidate files ---'
git ls-files | rg '(^|/)(wasapi_source\.rs|pump\.rs|Cargo\.toml|Cargo\.lock)$|wasapi'
printf '%s\n' '--- relevant symbols and dependency declarations ---'
rg -n -C 4 'BUFFER_DURATION_HNS|EventsShared|initialize_client|wasapi' crates Cargo.toml Cargo.lock 2>/dev/null || true

Repository: protonspy/open-wiki

Length of output: 7557


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- wasapi_source.rs relevant implementation ---'
sed -n '1,70p;220,290p' crates/recorder/src/wasapi_source.rs
printf '%s\n' '--- pump.rs ---'
sed -n '1,240p' crates/recorder/src/pump.rs
printf '%s\n' '--- wasapi 0.23.0 source ---'
python3 - <<'PY'
import io, tarfile, urllib.request
url = "https://crates.io/api/v1/crates/wasapi/0.23.0/download"
data = urllib.request.urlopen(url, timeout=20).read()
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as t:
    for name in t.getnames():
        if name.endswith(("src/api.rs", "src/stream.rs", "src/lib.rs")):
            print(f"--- {name} ---")
            text = t.extractfile(name).read().decode()
            for i, line in enumerate(text.splitlines(), 1):
                if ("EventsShared" in line or "buffer_duration_hns" in line or
                    "initialize_client" in line or "Initialize" in line or
                    "EVENTCALLBACK" in line):
                    lo, hi = max(1, i-8), min(len(text.splitlines()), i+12)
                    lines = text.splitlines()
                    print("\n".join(f"{n}: {lines[n-1]}" for n in range(lo, hi+1)))
                    print()
PY

Repository: protonspy/open-wiki

Length of output: 16458


🏁 Script executed:

#!/bin/bash
set -eu
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
curl -kfsSL https://crates.io/api/v1/crates/wasapi/0.23.0/download -o "$tmp/wasapi.tgz"
tar -xzf "$tmp/wasapi.tgz" -C "$tmp"
src="$tmp/wasapi-0.23.0"
printf '%s\n' '--- source files ---'
find "$src/src" -maxdepth 2 -type f -print
printf '%s\n' '--- StreamMode and initialization definitions ---'
rg -n -C 12 'enum StreamMode|EventsShared|initialize_client|Initialize\(' "$src/src"
printf '%s\n' '--- read-only semantic verifier ---'
python3 - "$src" <<'PY'
from pathlib import Path
import re, sys
root = Path(sys.argv[1])
files = list(root.glob("src/**/*.rs"))
text = "\n".join(p.read_text(errors="replace") for p in files)
for needle in ("EventsShared", "buffer_duration_hns", "IAudioClient::Initialize", "Initialize("):
    print(f"{needle}: {text.count(needle)} occurrence(s)")
m = re.search(r"EventsShared\s*\{.*?buffer_duration_hns.*?\}", text, re.S)
print("EventsShared declaration/call context found:", bool(m))
if m:
    print(m.group(0)[:1000])
PY

Repository: protonspy/open-wiki

Length of output: 259


🌐 Web query:

HEnquist wasapi-rs 0.23.0 src api.rs EventsShared buffer_duration_hns

💡 Result:

In the wasapi-rs crate, specifically within the StreamMode enum, EventsShared is a variant used to configure a WASAPI audio client for shared mode using event-driven timing [1][2]. The buffer_duration_hns field within this variant represents the desired device buffer duration measured in units of 100 nanoseconds [1][2]. When initializing an audio client with StreamMode::EventsShared, you provide a structure containing [2]: - autoconvert: A boolean indicating whether automatic format conversion should be enabled [2]. - buffer_duration_hns: An i64 value specifying the buffer duration in 100-nanosecond units [2]. In this mode, the audio engine determines the actual period, and this cannot be modified by the application [1][2]. Developers typically use the value returned by audio_client.get_device_period (specifically the default or minimum period) to populate the buffer_duration_hns field when initializing the client [3][4]. For example, a duration of 20 milliseconds is represented as 200,000 in this field (since 20ms = 20,000,000ns = 200,000 units of 100ns) [5][6].

Citations:


🌐 Web query:

site:github.com/HEnquist/wasapi-rs "buffer_duration_hns" "Initialize"

💡 Result:

In the context of the wasapi-rs Rust library, buffer_duration_hns is a parameter used to specify the audio device buffer duration in units of 100 nanoseconds [1]. It is not a standalone function but a field within the StreamMode enum variants (such as PollingShared, PollingExclusive, and EventsShared), which defines how an audio stream is configured [1]. To initialize a stream with this parameter, you include it within a StreamMode struct passed to the initialize_client method of an AudioClient [2][3]. Example initialization: let (def_time, min_time) = audio_client.get_device_period.unwrap; let mode = StreamMode::EventsShared { autoconvert: true, buffer_duration_hns: def_time, // Or min_time, depending on latency requirements }; audio_client.initialize_client(&desired_format, &Direction::Render, &mode).unwrap; The value for buffer_duration_hns is typically obtained from the audio device's capabilities via get_device_period, which returns the default and minimum supported periods [2][3]. Using the minimum period results in lower latency but requires more frequent processing [2].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
python3 - "$tmp/wasapi.tgz" <<'PY'
import ssl, sys, urllib.request
ctx = ssl._create_unverified_context()
with urllib.request.urlopen(
    "https://crates.io/api/v1/crates/wasapi/0.23.0/download",
    context=ctx,
    timeout=30,
) as response:
    open(sys.argv[1], "wb").write(response.read())
PY
tar -xzf "$tmp/wasapi.tgz" -C "$tmp"
src="$tmp/wasapi-0.23.0"
printf '%s\n' '--- exact source contexts ---'
rg -n -C 16 'enum StreamMode|EventsShared|buffer_duration_hns|initialize_client|Initialize\(' "$src/src"
printf '%s\n' '--- read-only semantic verifier ---'
python3 - "$src" <<'PY'
from pathlib import Path
import sys
root = Path(sys.argv[1])
for path in root.glob("src/**/*.rs"):
    lines = path.read_text(errors="replace").splitlines()
    for i, line in enumerate(lines):
        if any(x in line for x in ("EventsShared", "buffer_duration_hns", "initialize_client", "Initialize(")):
            print(f"{path.relative_to(root)}:{i+1}: {line.strip()}")
PY

Repository: protonspy/open-wiki

Length of output: 30600


🌐 Web query:

site:learn.microsoft.com IAudioClient Initialize shared mode AUDCLNT_STREAMFLAGS_EVENTCALLBACK hnsBufferDuration 0

💡 Result:

When calling IAudioClient::Initialize in shared mode with the AUDCLNT_STREAMFLAGS_EVENTCALLBACK flag, setting the hnsBufferDuration parameter to 0 is a standard practice to achieve the lowest possible latency [1][2]. In this configuration, the audio engine automatically determines the buffer size based on its own scheduling period [1]. When hnsBufferDuration is set to 0, the Initialize method assumes the client's processing period matches the audio engine's period and allocates a buffer sized for the minimum latency [2]. Effectively, any hnsBufferDuration value provided between 0 and the sum of the audio engine's period and device latency will result in the same minimum buffer size [2]. Key requirements for this implementation include: 1. Shared Mode: The hnsPeriodicity parameter must be set to 0 in shared mode [3][1]. 2. Event Callback: Because the AUDCLNT_STREAMFLAGS_EVENTCALLBACK flag is used, you must call IAudioClient::SetEventHandle after initializing the client but before starting the stream to provide the event handle that the audio engine will signal when a buffer is ready [4][5]. 3. Processing: Even with event-driven buffering, the client must still call IAudioClient::GetCurrentPadding inside its processing loop to determine the amount of data available to read (for capture) or space available to write (for rendering) [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu
python3 - <<'PY'
import ssl, urllib.request
from html.parser import HTMLParser
url = "https://learn.microsoft.com/en-us/windows/win32/api/audioclient/nf-audioclient-iaudioclient-initialize"
html = urllib.request.urlopen(url, context=ssl._create_unverified_context(), timeout=30).read().decode("utf-8", "replace")
text = HTMLParser().unescape(html)
for needle in ("hnsBufferDuration", "EVENTCALLBACK", "shared-mode", "AUDCLNT_E_INVALID_DEVICE_PERIOD"):
    print(f"--- {needle} ---")
    start = 0
    found = False
    while True:
        i = text.find(needle, start)
        if i < 0:
            break
        found = True
        print(" ".join(text[max(0, i-500):i+1000].split()))
        start = i + len(needle)
    if not found:
        print("not found")
PY

Repository: protonspy/open-wiki

Length of output: 294


🏁 Script executed:

#!/bin/bash
set -eu
python3 - <<'PY'
import html, re, ssl, urllib.request
url = "https://learn.microsoft.com/en-us/windows/win32/api/audioclient/nf-audioclient-iaudioclient-initialize"
raw = urllib.request.urlopen(url, context=ssl._create_unverified_context(), timeout=30).read().decode("utf-8", "replace")
text = html.unescape(re.sub(r"<[^>]+>", " ", raw))
text = re.sub(r"\s+", " ", text)
for needle in ("hnsBufferDuration", "EVENTCALLBACK", "shared-mode", "AUDCLNT_E_INVALID_DEVICE_PERIOD"):
    print(f"--- {needle} ---")
    start = 0
    count = 0
    while True:
        i = text.find(needle, start)
        if i < 0:
            break
        print(text[max(0, i-500):i+1200])
        start = i + len(needle)
        count += 1
    if not count:
        print("not found")
PY

Repository: protonspy/open-wiki

Length of output: 50375


Pass zero duration to shared event-driven WASAPI streams. wasapi 0.23.0 passes buffer_duration_hns directly to IAudioClient::Initialize with hnsPeriodicity = 0 and AUDCLNT_STREAMFLAGS_EVENTCALLBACK. Windows requires both values to be 0 in shared event-driven mode. Set buffer_duration_hns to 0; otherwise initialize_client can fail and prevent recording.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/recorder/src/wasapi_source.rs` around lines 256 - 271, Update the
shared event-driven StreamMode::EventsShared configuration in the recorder
initialization flow to pass buffer_duration_hns as zero, rather than deriving it
from min_period and BUFFER_DURATION_HNS. Leave the autoconvert setting and
initialize_client call unchanged.

Comment thread crates/recorder/src/wasapi_source.rs Outdated
Comment on lines +303 to +305
fn lost_frames(&self) -> u64 {
self.discontinuities
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

lost_frames() returns a count of discontinuity events, not a count of frames.

Line 372 increments discontinuities by one per data_discontinuity flag. Line 304 returns that value from lost_frames().

The trait contract at capture.rs lines 52-54 defines the return value as "How many frames the device reported it overwrote". The field doc at lines 152-154 correctly describes a count of occurrences. The two units disagree, and the trait boundary is the one consumers read. pump.rs line 107 propagates the value unchanged into ThreadedSource::lost_frames.

A single discontinuity can cover thousands of frames. Any consumer that converts lost_frames() into a duration, or reconciles it against frames_written in the manifest, produces a number that understates the loss by orders of magnitude. The stated purpose of the field is that a lossy recording can say so.

WASAPI does not report the overwritten frame count, so an exact figure is not available. Align the units instead: rename the trait method to lost_events and record the event count in the manifest, or estimate frames from the gap between the device position and the frames received.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/recorder/src/wasapi_source.rs` around lines 303 - 305, Align the
WASAPI discontinuity count with the public API contract by renaming the
`lost_frames` method and all propagation/manifest consumers to `lost_events`,
preserving the value as an event count rather than presenting it as frames.
Update the relevant trait, `WasapiSource`, `ThreadedSource`, and manifest
recording paths consistently; do not estimate frame counts since WASAPI does not
provide them.

Comment on lines +334 to +346
if stream.event.wait_for_event(20).is_err() {
// The stream may have died with the device. Check before giving up.
if self.default_device_changed().is_some() {
// A failure here is not fatal: `needs_reopen` makes the next
// poll try again rather than leaving the track dead.
let _ = self.reopen();
return Ok(self
.changed
.take()
.map_or(Poll::Idle, |device| Poll::DeviceChanged { device }));
}
return Ok(Poll::Idle);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Throttle the default-device check on the event timeout; it runs on the common path.

Line 334 waits 20 ms for the event. Line 336 then calls default_device_changed(), which builds a new DeviceEnumerator, fetches the default device, and reads its id — three COM calls per invocation, per default_device at lines 229-236.

For the loopback source this timeout is the normal state, not the exception. capture.rs lines 15-17 state that a silent machine produces nothing at all. So during a quiet meeting this runs about 50 times per second for each source, which is roughly 180,000 device enumerations per hour per source, on the capture thread.

A device change does not need 20 ms detection latency. Record the last check time and run the comparison at most once per second. The needs_reopen path at line 364 still catches an invalidated device immediately, because that path does not depend on this poll.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/recorder/src/wasapi_source.rs` around lines 334 - 346, Throttle the
default-device comparison in the event-timeout branch around
default_device_changed() to at most once per second by storing and checking the
last comparison time in the source state. Keep the existing reopen and
DeviceChanged behavior when the check detects a change, while allowing timeout
polls between checks to return Poll::Idle; leave the needs_reopen path
unchanged.

Comment on lines +397 to +411
fn stop(&mut self) {
if let Some(stream) = self.stream.as_mut() {
let _ = stream.client.stop_stream();
}
}

fn start(&mut self) -> Result<(), CaptureError> {
match self.stream.as_mut() {
Some(stream) => stream
.client
.start_stream()
.map_err(|e| err("could not restart the stream", e)),
None => Err(CaptureError("the stream is closed".into())),
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

self.running is never set, so a stream reopened after a device change never restarts.

running is declared at line 156 and initialised to false at line 179. No code assigns it after that. start() starts the stream but does not set running = true. stop() stops the stream but does not set running = false.

The only reader is line 219 in reopen(). That guard restarts the freshly opened stream only when running is true. Because running is always false, reopen() installs a stream that is never started. poll() then finds no packets, the session pads both tracks with silence, and the recording reports as healthy for the rest of the meeting.

That is the failure the comment at lines 197-201 describes and the case plan 4.2 exists to survive. Maintain the flag in start and stop.

🐛 Proposed fix
     fn stop(&mut self) {
         if let Some(stream) = self.stream.as_mut() {
             let _ = stream.client.stop_stream();
         }
+        self.running = false;
     }
 
     fn start(&mut self) -> Result<(), CaptureError> {
         match self.stream.as_mut() {
-            Some(stream) => stream
-                .client
-                .start_stream()
-                .map_err(|e| err("could not restart the stream", e)),
+            Some(stream) => {
+                stream
+                    .client
+                    .start_stream()
+                    .map_err(|e| err("could not restart the stream", e))?;
+                // `reopen` restarts the new stream only when this is set.
+                self.running = true;
+                Ok(())
+            }
             None => Err(CaptureError("the stream is closed".into())),
         }
     }

A test that reopens a source while it is started would have caught this. The running field has no coverage today.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn stop(&mut self) {
if let Some(stream) = self.stream.as_mut() {
let _ = stream.client.stop_stream();
}
}
fn start(&mut self) -> Result<(), CaptureError> {
match self.stream.as_mut() {
Some(stream) => stream
.client
.start_stream()
.map_err(|e| err("could not restart the stream", e)),
None => Err(CaptureError("the stream is closed".into())),
}
}
fn stop(&mut self) {
if let Some(stream) = self.stream.as_mut() {
let _ = stream.client.stop_stream();
}
self.running = false;
}
fn start(&mut self) -> Result<(), CaptureError> {
match self.stream.as_mut() {
Some(stream) => {
stream
.client
.start_stream()
.map_err(|e| err("could not restart the stream", e))?;
// `reopen` restarts the new stream only when this is set.
self.running = true;
Ok(())
}
None => Err(CaptureError("the stream is closed".into())),
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/recorder/src/wasapi_source.rs` around lines 397 - 411, Update the
`start` and `stop` methods to maintain the `running` field: set it to true only
after `start_stream()` succeeds, and set it to false when stopping the stream.
Preserve the existing error and closed-stream behavior so `reopen()` can use
`running` to restart previously active streams.

… dropping it

Taking it out of the channel and then returning the audio that preceded
it let the event fall out of scope: the recording carried on with no
record that the device had moved, which is the one thing 4.2 exists to
write down. The comment claimed it stayed queued; it did not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016iMM93Wk43o44V5J2AxPgL
@protonspy

Copy link
Copy Markdown
Owner Author

Not merging this yet — outstanding review findings

The Critical is fixed (a DeviceChanged taken out of the channel behind frames was being dropped; the comment claimed it stayed queued and it did not). CI is green on Windows.

But CodeRabbit raised five Majors on the capture-thread code that I added late in this branch, and they share a failure mode this component must not have — losing audio while reporting success. Merging with them open would contradict the reason the threading exists. Listed here so they are not lost:

  1. ThreadedSource uses an unbounded mpsc::channel. The capture threads push continuously; poll() drains only when a request arrives. At 48 kHz stereo f32 that is ~384 KB/s/device accumulating between requests. Either bound the queue and count what it drops (a lossy recording must say so), or pump on a timer rather than once per request — main.rs currently blocks on stdin.lock().lines().
  2. A dead capture thread is indistinguishable from a silent device. The only channel back carries Poll, so a thread that failed and returned looks exactly like loopback with nothing playing. Needs a health signal that reaches status.
  3. No rollback when the second device fails to start. service.rs starts mic then system; if system fails, mic is left running, so the next start/resume hits AUDCLNT_E_NOT_STOPPED.
  4. A stream invalidated on the still-default device is never reopened. reopen() only acts when the device id differs, and clears needs_reopen when it does not — so AUDCLNT_E_DEVICE_INVALIDATED on the same endpoint leaves the track dead.
  5. lost_frames() returns a count of discontinuity events, not frames. The name and the manifest field it feeds both imply frames.

Plus two minors: truncate frames to whole frames before writing (a partial frame reaches the file but is not counted), and the wasapi_source.rs:22/:271 items.

Also still outstanding, and not something CI can close: the three manual checks on an hour-long recording that this group's notes make an acceptance criterion. Two of the three defects that meant the binary could not record on Windows were only findable by reading the source — a device is the only thing that can confirm the rest.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/recorder/src/pump.rs (1)

186-211: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Preserve timestamps and idle boundaries while batching.

The Poll contract in crates/recorder/src/capture.rs:12-22 requires frame timestamps and uses Idle to represent silence. This code discards the input wall_ns, ignores Idle events, and returns wall_ns: 0. A queue containing Frames, Idle, Frames becomes one contiguous packet, which removes silence and corrupts time mapping.

Stop batching at non-frame events, retain them as deferred events, and return the first frame timestamp.

🐛 Preserve packet metadata and event boundaries
-                Ok(Poll::Frames { samples: more, .. }) => samples.extend_from_slice(&more),
+                Ok(Poll::Frames {
+                    wall_ns: first_wall_ns,
+                    samples: more,
+                }) => {
+                    wall_ns.get_or_insert(first_wall_ns);
+                    samples.extend_from_slice(&more);
+                }
...
-                Ok(Poll::Idle) => {}
+                Ok(event @ Poll::Idle) => {
+                    if samples.is_empty() {
+                        return Ok(event);
+                    }
+                    self.deferred = Some(event);
+                    break;
+                }
...
-                wall_ns: 0,
+                wall_ns: wall_ns.unwrap_or(0),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/recorder/src/pump.rs` around lines 186 - 211, Update the batching
logic in the receiver loop around self.rx.try_recv() to stop when encountering
Poll::Idle or Poll::DeviceChanged, preserving those events for the next call
instead of consuming them. Retain the wall_ns from the first Poll::Frames event
and return it with the accumulated samples, so frame batches preserve timestamps
and do not cross idle or device-change boundaries.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@crates/recorder/src/pump.rs`:
- Around line 186-211: Update the batching logic in the receiver loop around
self.rx.try_recv() to stop when encountering Poll::Idle or Poll::DeviceChanged,
preserving those events for the next call instead of consuming them. Retain the
wall_ns from the first Poll::Frames event and return it with the accumulated
samples, so frame batches preserve timestamps and do not cross idle or
device-change boundaries.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1fe9254e-c490-4dc1-8281-e62f2dd01620

📥 Commits

Reviewing files that changed from the base of the PR and between 7f54c8c and 6878a2a.

📒 Files selected for processing (2)
  • crates/recorder/src/pump.rs
  • crates/recorder/tests/session.rs

The previous commit claimed the dropped `DeviceChanged` was fixed and it
was not: `cargo fmt` had reformatted the block, so the edit that adds
`self.deferred = Some(...)` never matched and only the field and the
`take()` landed. The event was still consumed and discarded. The
regression test written this round is what caught it — the earlier
commit had none, which is why the claim went unchallenged.

Also, from the same review:

- The capture queue is bounded. Unbounded, the threads pushed ~384 KB/s
  per device for as long as the session went without polling, and
  nothing capped it. `try_send`, never `send`, because a blocked drain
  is exactly how WASAPI comes to overwrite frames nobody collected — the
  oldest audio is lost instead, and counted.
- The root cause of that queue filling is gone too: stdin now has a
  thread of its own and the loop pumps on a 50 ms timer. Blocking on
  `lines()` and pumping once per request meant capture only advanced
  when the parent spoke, and the parent has no reason to say anything
  between `start` and `stop`.
- A capture thread that dies is visible. The only channel back carried
  `Poll`, so a thread that failed and returned looked exactly like
  loopback with nothing playing. `status` now carries the fault, the
  dropped-sample count and the discontinuity count: silence nobody
  flagged, presented as a healthy hour, is the failure this sidecar
  exists to avoid.
- A stream invalidated on the *still-default* device is reopened.
  `reopen` only acted when the device id differed and cleared the retry
  flag when it did not, so a sleep/resume or a driver reset left the
  track dead for the rest of the meeting.
- A half-started pair is rolled back, instead of leaving one client
  running for the next start to trip over.
- `lost_frames` is `discontinuities`, which is what it counts: Windows
  reports that a gap happened, not how large it was.
- `append` truncates to whole frames, so a partial frame cannot reach
  the file uncounted and skew every later `pad_to`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016iMM93Wk43o44V5J2AxPgL

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/recorder/src/capture.rs (1)

1-33: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Preserve Poll::DeviceChanged when the audio queue is full.

A full bounded queue discards Poll::DeviceChanged without recording it. The session can then receive frames from the new device without the device-change boundary. Store control events in a deferred or separate priority queue. Add a regression test that fills the audio queue before sending Poll::DeviceChanged.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/recorder/src/capture.rs` around lines 1 - 33, The bounded audio-queue
handling must preserve Poll::DeviceChanged instead of dropping it when capacity
is exhausted. Update the queue/send logic around Poll and DeviceChanged to defer
control events or route them through a separate priority queue, ensuring the
session observes the device-change boundary before subsequent frames. Add a
regression test that fills the audio queue, sends Poll::DeviceChanged, and
verifies the event is recorded.
🧹 Nitpick comments (1)
crates/recorder/src/capture.rs (1)

1-33: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Remove the unused Poll::Frames.wall_ns field. Session ignores it and uses its shared monotonic clock for alignment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/recorder/src/capture.rs` around lines 1 - 33, Remove the wall_ns field
from the Poll::Frames variant and update its documentation to omit the
wall-clock timestamp. Adjust all construction and pattern-matching sites for
Poll::Frames to use only the samples payload, while preserving Session’s shared
monotonic-clock alignment.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/recorder/src/capture.rs`:
- Around line 64-68: Make the source health contract required and update the
capture implementation’s health() to return the shared worker fault instead of
always returning Ok. In the Command::Start handling in pump.rs, propagate
source.start() failures into the shared fault state so a failed resume is
reported by Service::status(); add coverage for a second start() failure and its
health result.
- Around line 52-63: Scope loss counters to each recording by capturing
per-source baselines when handling Request::Start, or resetting both counters
only after both sources start successfully, and have Service::status() report
deltas for the active recording. Ensure retained sources and
ThreadedSource::start do not carry prior recording losses into later results,
and add a regression test covering sequential recordings.

---

Outside diff comments:
In `@crates/recorder/src/capture.rs`:
- Around line 1-33: The bounded audio-queue handling must preserve
Poll::DeviceChanged instead of dropping it when capacity is exhausted. Update
the queue/send logic around Poll and DeviceChanged to defer control events or
route them through a separate priority queue, ensuring the session observes the
device-change boundary before subsequent frames. Add a regression test that
fills the audio queue, sends Poll::DeviceChanged, and verifies the event is
recorded.

---

Nitpick comments:
In `@crates/recorder/src/capture.rs`:
- Around line 1-33: Remove the wall_ns field from the Poll::Frames variant and
update its documentation to omit the wall-clock timestamp. Adjust all
construction and pattern-matching sites for Poll::Frames to use only the samples
payload, while preserving Session’s shared monotonic-clock alignment.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4b23e577-9f4e-4bfb-95f7-94982369514b

📥 Commits

Reviewing files that changed from the base of the PR and between 6878a2a and 3e5fdd8.

📒 Files selected for processing (9)
  • crates/recorder/src/capture.rs
  • crates/recorder/src/main.rs
  • crates/recorder/src/pump.rs
  • crates/recorder/src/rpc.rs
  • crates/recorder/src/service.rs
  • crates/recorder/src/track.rs
  • crates/recorder/src/wasapi_source.rs
  • crates/recorder/tests/rpc.rs
  • crates/recorder/tests/service.rs
🚧 Files skipped from review as they are similar to previous changes (7)
  • crates/recorder/tests/rpc.rs
  • crates/recorder/tests/service.rs
  • crates/recorder/src/track.rs
  • crates/recorder/src/main.rs
  • crates/recorder/src/service.rs
  • crates/recorder/src/pump.rs
  • crates/recorder/src/wasapi_source.rs

Comment on lines +52 to +63
/// How many times the device reported it had overwritten frames nobody
/// collected. An **event** count, not a frame count — Windows says that a
/// gap happened, not how big it was.
fn discontinuities(&self) -> u64 {
0
}
/// Samples that reached this process and were then thrown away because the
/// queue was full. A recording that lost audio must be able to say so
/// rather than presenting manufactured silence as the real thing.
fn dropped_samples(&self) -> u64 {
0
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 10 \
  'AtomicU64|fn start|dropped_samples|discontinuities|Request::Start' \
  crates/recorder/src/pump.rs \
  crates/recorder/src/service.rs \
  crates/recorder/src/capture.rs

Repository: protonspy/open-wiki

Length of output: 17735


🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline crates/recorder/src/pump.rs
ast-grep outline crates/recorder/src/service.rs
ast-grep outline crates/recorder/src/session.rs 2>/dev/null || true

printf '\n--- pump.rs ---\n'
sed -n '55,225p' crates/recorder/src/pump.rs
sed -n '260,292p' crates/recorder/src/pump.rs

printf '\n--- service.rs ---\n'
sed -n '54,145p' crates/recorder/src/service.rs

printf '\n--- session references ---\n'
rg -n -C 8 'struct Session|impl.*Session|fn start|fn stop|fn state|mic\(|system\(' crates/recorder/src

Repository: protonspy/open-wiki

Length of output: 29734


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- service construction and tests ---'
sed -n '1,55p' crates/recorder/src/service.rs
rg -n -C 12 'Service::new|ThreadedSource::spawn|Request::Start|dropped_samples|discontinuities|sequential|recording' crates/recorder --glob '*.rs'

printf '%s\n' '--- source implementations and counter updates ---'
rg -n -C 10 'discontinu|lost|dropped_samples|dropped\.|AtomicU64' crates/recorder/src --glob '*.rs'

printf '%s\n' '--- service and pump tests ---'
fd -i -t f 'test|tests' crates/recorder
rg -n -C 8 '#\[test\]' crates/recorder/src crates/recorder/tests 2>/dev/null || true

Repository: protonspy/open-wiki

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- WASAPI counter implementation ---'
rg -n -C 14 'discontinu|lost|dropped|capture_lost|GetNextPacketSize|GetBuffer' \
  crates/recorder/src/wasapi_source.rs

printf '%s\n' '--- focused lifecycle verifier ---'
python3 - <<'PY'
from pathlib import Path
import re

pump = Path("crates/recorder/src/pump.rs").read_text()
service = Path("crates/recorder/src/service.rs").read_text()

spawn = pump[pump.index("pub fn spawn"):pump.index("impl CaptureSource for ThreadedSource")]
start = pump[pump.index("    fn start(&mut self) -> Result<(), CaptureError>"):pump.index("impl Drop for ThreadedSource")]
status = service[service.index("    pub fn status"):service.index("    }\n}", service.index("    pub fn status")) + 5]
handle = service[service.index("    pub fn handle"):service.index("    pub fn status")]

checks = {
    "counters initialized in spawn": "AtomicU64::new(0)" in spawn,
    "start has no counter reset": not re.search(r"(lost|dropped).*(store|swap|fetch_sub)", start, re.S),
    "start only sends Start": "send(Command::Start)" in start,
    "status reads raw source counters": "self.mic.dropped_samples()" in status
        and "self.system.dropped_samples()" in status
        and "self.mic.discontinuities()" in status
        and "self.system.discontinuities()" in status,
    "session is cleared on stop": "self.session = None;" in handle,
    "capture sources are not recreated on stop": "ThreadedSource::spawn" not in handle,
}
for name, result in checks.items():
    print(f"{name}: {result}")
assert all(checks.values())
print("result: one ThreadedSource counter lifetime can span multiple Service sessions")
PY

Repository: protonspy/open-wiki

Length of output: 6934


Scope loss counters to one recording.

Service::status() reads cumulative source counters. Request::Stop retains the sources, and ThreadedSource::start does not reset them. A later recording therefore reports loss from earlier recordings.

Store per-source baselines at Request::Start, or reset both counters after both sources start successfully. Add a sequential-recording regression test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/recorder/src/capture.rs` around lines 52 - 63, Scope loss counters to
each recording by capturing per-source baselines when handling Request::Start,
or resetting both counters only after both sources start successfully, and have
Service::status() report deltas for the active recording. Ensure retained
sources and ThreadedSource::start do not carry prior recording losses into later
results, and add a regression test covering sequential recordings.

Comment on lines +64 to +68
/// `Err` when capture has stopped working. A source that has died must not
/// look like one that is merely silent.
fn health(&self) -> Result<(), String> {
Ok(())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Propagate start failures through health().

Service::status() relies on health() to distinguish a dead source from silence. In crates/recorder/src/pump.rs, the Command::Start branch ignores source.start() errors and does not set the shared fault. A failed resume can therefore leave capture stopped while capture_fault remains unset.

Record the error in the worker fault state and return it from health(). Consider making health() required so a failing source cannot inherit the healthy default. Add a test where the second start() call fails.

#!/bin/bash
set -euo pipefail

rg -n -C 12 \
  'Command::Start|source\.start\(\)|thread_fault|fn health' \
  crates/recorder/src/pump.rs \
  crates/recorder/src/service.rs
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/recorder/src/capture.rs` around lines 64 - 68, Make the source health
contract required and update the capture implementation’s health() to return the
shared worker fault instead of always returning Ok. In the Command::Start
handling in pump.rs, propagate source.start() failures into the shared fault
state so a failed resume is reported by Service::status(); add coverage for a
second start() failure and its health result.

@protonspy
protonspy merged commit ee572f1 into main Aug 1, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant