Skip to content

feat: add Bluetooth HFP mic support with async readiness handling#219

Open
n33pm wants to merge 1 commit into
kitlangton:mainfrom
n33pm:fix/BTHeadset
Open

feat: add Bluetooth HFP mic support with async readiness handling#219
n33pm wants to merge 1 commit into
kitlangton:mainfrom
n33pm:fix/BTHeadset

Conversation

@n33pm

@n33pm n33pm commented May 4, 2026

Copy link
Copy Markdown

Fixes #218

Summary by CodeRabbit

  • New Features

    • Detects and better waits/settles Bluetooth input devices during startup.
    • Adds a "Preparing microphone" UI status with updated tooltip and styling.
  • Bug Fixes

    • Ignored stop requests before recording backend starts (avoids incorrect finalization; media resumes as needed).
    • Improved audio engine recovery with timed retries and interruption-safe handling.
  • Tests

    • Tests updated to assert the new starting / start-failed recording lifecycle.

@coderabbitai

coderabbitai Bot commented May 4, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Recording start now has an explicit "starting microphone" phase. RecordingClient.startRecording returns Bool, selects and probes preferred input (including Bluetooth HFP readiness/settle), SuperFastCaptureController adds guarded rebuild/retries and preserves interrupted recordings, UI gains .preparingMicrophone, and tests assert the two‑phase startup.

Recording Startup, Engine Resilience, and UI State

Recording Startup, Engine Resilience, and UI State

Layer / File(s) Summary
Data Shape
Hex/Features/Transcription/TranscriptionFeature.swift
Added isStartingRecording: Bool and shouldStopWhenRecordingStarts: Bool to state; added actions recordingStarted and recordingStartFailed.
Public API / Client Contract
Hex/Clients/RecordingClient.swift
RecordingClient.startRecording changed from () async -> Void to () async -> Bool. startRecording applies preferred input selection, waits/probes for Bluetooth HFP readiness, performs a settle sequence when needed, and returns success/failure. stopRecording() can early-return with an "ignored stop" URL when no backend recorder became active.
Core Device Detection / Helpers
Hex/Clients/RecordingClient.swift
Added transport-type and readiness helpers (transport detection, nominal sample-rate probe), plus waitForHandsFreeInputIfNeeded and settleHandsFreeCaptureIfNeeded for Bluetooth HFP handling.
Engine Construction & Resilience
Hex/Clients/SuperFastCaptureController.swift
Extracted buildAndStartEngine(reason:) and registerConfigurationChangeObserver(for:); startIfNeeded uses the helper. Configuration-change handling moved to processingQueue with enqueueConfigurationChange() and guarded restartEngineForActiveRecordingOnProcessingQueue(attempt:) that retries up to 10× and preserves interruptedRecordingURL on final failure. stop()/finishRecording() clear interruption state and reset buffer/metrics.
Reducer Flow & Startup Semantics
Hex/Features/Transcription/TranscriptionFeature.swift
handleStartRecording sets isStartingRecording, prevents sleep while recording.startRecording() runs, and dispatches recordingStarted or recordingStartFailed based on the Bool result. handleRecordingStarted completes transition to isRecording, timestamps start, and honors any deferred stop. handleStopRecording defers stopping when starting.
UI & Indicator
Hex/Features/Transcription/TranscriptionIndicatorView.swift
Added .preparingMicrophone status; mapped to orange-themed backgroundColor/strokeColor/innerShadowColor; tooltip shows “Preparing microphone...” when appropriate; preview updated.
Tests
HexTests/RecordingRaceTests.swift
Tests assert intermediate isStartingRecording = true then .recordingStarted to assert isRecording and recordingStartTime. RecordingProbe models queued start outcomes; recordStart() returns Bool.
sequenceDiagram
    participant User as User/Hotkey
    participant TF as TranscriptionFeature
    participant RC as RecordingClient
    participant SFC as SuperFastCaptureController
    participant Device as Bluetooth HFP Device

    User->>TF: startRecording
    TF->>TF: set isStartingRecording = true\nprevent sleep
    TF->>RC: startRecording()
    RC->>Device: apply preferred/default input\nprobe transport & nominal sample rate
    alt Bluetooth HFP requires readiness
        RC->>Device: poll waitForHandsFreeInputIfNeeded (100ms retries)
        Device-->>RC: nominal sample rate > 0 (ready)
        RC->>SFC: settleHandsFreeCaptureIfNeeded()
        SFC->>SFC: buildAndStartEngine / restart flow (with retries)
    else immediate-ready or non-Bluetooth
        RC->>SFC: start capture engine or AVRecorder fallback
    end
    RC-->>TF: return Bool -> recordingStarted or recordingStartFailed
    TF->>TF: set isRecording / clear isStartingRecording\nmaybe defer/execute stop
    TF->>User: UI updates (.preparingMicrophone → recording/idle)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I twitch my whiskers as the mic prepares,
Bluetooth brightens, sampled in careful flares,
We settle, re-arm, retry the little song,
The engine hums, the buffers right the wrong,
Hooray — the rabbit nods; the capture's strong.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 9.09% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: adding Bluetooth HFP microphone support with async readiness handling, which aligns with the substantial changes across RecordingClient, SuperFastCaptureController, and TranscriptionFeature.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Hex/Clients/RecordingClient.swift`:
- Around line 1188-1190: The current code calls applyPreferredInputDevice() and
then passes its result into waitForHandsFreeInputIfNeeded(targetDeviceID:), but
applyPreferredInputDevice() returns the post-set default (which may still be the
old device) so the HFP wait can be skipped; change the call to compute the
actual requested target (either by calling resolvePreferredInputDevice() ??
getDefaultInputDevice() and passing THAT into
waitForHandsFreeInputIfNeeded(targetDeviceID:) or by changing
applyPreferredInputDevice() to return the requested target ID instead of the
current default) so waitForHandsFreeInputIfNeeded always waits on the intended
Bluetooth target. Ensure the unique symbols mentioned
(applyPreferredInputDevice(), waitForHandsFreeInputIfNeeded(targetDeviceID:),
resolvePreferredInputDevice(), getDefaultInputDevice()) are updated accordingly.

In `@Hex/Clients/SuperFastCaptureController.swift`:
- Around line 297-307: When rebuild retries are exhausted in the catch block
after buildAndStartEngine fails (the branch guarded by `attempt < 10`), treat
this as a fatal recording interruption rather than routing through the normal
configuration-change path: clear or finalize the controller's recording state
(e.g., set `activeRecording`/equivalent to nil/false) or invoke a dedicated
fatal-interruption callback instead of calling `onEngineConfigurationChange()`,
so `RecordingClient.handleCaptureEnvironmentChange` (which checks
`captureController.isRecording`) does not defer restart and leave a recording
session active with no engine.

In `@Hex/Features/Transcription/TranscriptionFeature.swift`:
- Around line 306-314: The effect currently calls recording.startRecording()
(and then plays soundEffect.play(.startRecording) and sends .recordingStarted)
even though RecordingClient.startRecording() only returns Void and may fail;
change RecordingClient.startRecording() to report success/failure (either async
throws or return Bool) and update the effect to await the result and only on
success play the start sound and send(.recordingStarted); on failure ensure you
clear isStartingRecording and call sleepManagement.preventSleep()'s release
(i.e., undo the preventSleep assertion) so the UI/state does not flip to
isRecording when the backend never started.
🪄 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

Run ID: 5afd436f-d37f-4061-a0f5-517ab3665ed8

📥 Commits

Reviewing files that changed from the base of the PR and between f4764d5 and 335355e.

📒 Files selected for processing (5)
  • Hex/Clients/RecordingClient.swift
  • Hex/Clients/SuperFastCaptureController.swift
  • Hex/Features/Transcription/TranscriptionFeature.swift
  • Hex/Features/Transcription/TranscriptionIndicatorView.swift
  • HexTests/RecordingRaceTests.swift

Comment thread Hex/Clients/RecordingClient.swift Outdated
Comment thread Hex/Clients/SuperFastCaptureController.swift
Comment thread Hex/Features/Transcription/TranscriptionFeature.swift
@n33pm
n33pm force-pushed the fix/BTHeadset branch from 335355e to 80f352f Compare May 4, 2026 15:32
@coderabbitai

coderabbitai Bot commented May 4, 2026

Copy link
Copy Markdown
Contributor

Caution

Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted.

Error details
{"name":"HttpError","status":502,"request":{"method":"PATCH","url":"https://api.github.com/repos/kitlangton/Hex/issues/comments/4372211707","headers":{"accept":"application/vnd.github.v3+json","user-agent":"octokit.js/0.0.0-development octokit-core.js/7.0.6 Node.js/24","authorization":"token [REDACTED]","content-type":"application/json; charset=utf-8"},"body":{"body":"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->\nNo actionable comments were generated in the recent review. 🎉\n\n<details>\n<summary>ℹ️ Recent review info</summary>\n\n<details>\n<summary>⚙️ Run configuration</summary>\n\n**Configuration used**: defaults\n\n**Review profile**: CHILL\n\n**Plan**: Pro\n\n**Run ID**: `64f80ba6-bde1-4f22-80e8-63fa2eb6e851`\n\n</details>\n\n<details>\n<summary>📥 Commits</summary>\n\nReviewing files that changed from the base of the PR and between 335355e6c17eb9a6304f0df5a356ee8cb92025b6 and 80f352f260ff7280748cb9111133661872ba4b49.\n\n</details>\n\n<details>\n<summary>📒 Files selected for processing (5)</summary>\n\n* `Hex/Clients/RecordingClient.swift`\n* `Hex/Clients/SuperFastCaptureController.swift`\n* `Hex/Features/Transcription/TranscriptionFeature.swift`\n* `Hex/Features/Transcription/TranscriptionIndicatorView.swift`\n* `HexTests/RecordingRaceTests.swift`\n\n</details>\n\n</details>\n\n---\n\n\n<!-- walkthrough_start -->\n\n<details>\n<summary>📝 Walkthrough</summary>\n\n## Walkthrough\n\nThis PR introduces an explicit microphone \"starting\" phase for transcription startup, updates recording startup to detect and wait for Bluetooth HFP devices (including a settle flow), makes the audio engine resilient to configuration changes with a guarded retry/rebuild loop, converts `startRecording` to return success/failure, and exposes a new UI status `.preparingMicrophone`.\n\n## Changes\n\n**Recording Startup with Bluetooth HFP Support & State Refactoring**\n\n|Layer / File(s)|Summary|\n|---|---|\n|**Data Shape** <br> `Hex/Features/Transcription/TranscriptionFeature.swift`|`State` adds `isStartingRecording` and `shouldStopWhenRecordingStarts`; `Action` adds `recordingStarted` and `recordingStartFailed`.|\n|**Core Recording & Device Detection** <br> `Hex/Clients/RecordingClient.swift`|`startRecording` signature changed to return `Bool`. Added CoreAudio helpers (`getTransportType`, `isBluetoothDevice`, `inputDeviceIsReady`) and Bluetooth HFP flows: `waitForHandsFreeInputIfNeeded` (poll readiness) and `settleHandsFreeCaptureIfNeeded` (force/settle capture). `startRecording` now selects preferred input, waits/settles for HFP when needed, and returns explicit success/failure; `stopRecording` gains an early-return \"ignored stop\" path when no backend is active.|\n|**Engine Management & Resilience** <br> `Hex/Clients/SuperFastCaptureController.swift`|Extracted `buildAndStartEngine(reason:)` and observer registration; `startIfNeeded` uses it. `handleConfigurationChange()` branches on whether a recording is active. Added guarded `restartEngineForActiveRecording(attempt:)` with re-entrancy guard `isReconfiguringEngine`, retry loop (up to 10 attempts, 200ms delay), and `interruptedRecordingURL` to preserve target URL on final failure. Updated `stop()`/`finishRecording()` to handle `interruptedRecordingURL`.|\n|**State Transition Refactor** <br> `Hex/Features/Transcription/TranscriptionFeature.swift`|`handleStartRecording` sets `isStartingRecording`, prevents system sleep, begins mic startup, and emits `recordingStarted` on success or `recordingStartFailed` on failure. `handleRecordingStarted` flips to `isRecording`, timestamps start, clears deferred-stop, and may immediately stop if requested during startup. `handleStopRecording` defers stop while starting by setting `shouldStopWhenRecordingStarts`. Cancel/discard handlers reset the starting-phase flags.|\n|**UI Status & Rendering** <br> `Hex/Features/Transcription/TranscriptionIndicatorView.swift`|Added `.preparingMicrophone` to `Status` and mapped it to orange-themed `backgroundColor`, `strokeColor`, and `innerShadowColor`. Tooltip logic updated to show \"Preparing microphone...\" vs \"Model prewarming...\". Preview list updated to include `.preparingMicrophone`.|\n|**State Transition Tests** <br> `HexTests/RecordingRaceTests.swift`|Tests updated to reflect two-phase start: after `.startRecording` expect `isStartingRecording = true` and `recordingStartTime = nil`, then receive `.recordingStarted` which sets `isRecording = true` and timestamps `recordingStartTime`. Also updated dependency closure to return `true` from `startRecording`.|\n\n## Sequence Diagram\n\n```mermaid\nsequenceDiagram\n    participant User as User/Hotkey\n    participant TF as TranscriptionFeature\n    participant RC as RecordingClient\n    participant SFC as SuperFastCaptureController\n    participant Device as Bluetooth HFP Device\n\n    User->>TF: startRecording\n    TF->>TF: Set isStartingRecording = true<br/>Prevent system sleep\n    TF->>RC: startRecording()\n    RC->>Device: Select preferred input, detect transport<br/>waitForHandsFreeInputIfNeeded (poll)\n    Device-->>RC: Ready (nominal sample rate > 0)\n    RC->>SFC: settleHandsFreeCaptureIfNeeded()\n    SFC->>SFC: buildAndStartEngine / restartEngineForActiveRecording (guarded retry)\n    SFC-->>RC: Engine/capture ready\n    RC->>TF: Emit recordingStarted (success)\n    TF->>TF: Set isRecording = true<br/>Set recordingStartTime, clear starting flags\n    TF->>User: UI transitions (.preparingMicrophone → recording)\n```\n\n## Estimated code review effort\n\n🎯 4 (Complex) | ⏱️ ~45 minutes\n\n## Poem\n\n> 🐰 I wait with twitching ear and hop,  \n> The mic prepares—no bumble, no flop,  \n> Engines rebuild, Bluetooth finds its cue,  \n> Two-phase start, then capture sings through,  \n> A rabbit cheers: recording starts—hooray, woo!\n\n</details>\n\n<!-- walkthrough_end -->\n\n<!-- pre_merge_checks_walkthrough_start -->\n\n<details>\n<summary>🚥 Pre-merge checks | ✅ 4 | ❌ 1</summary>\n\n### ❌ Failed checks (1 warning)\n\n|     Check name     | Status     | Explanation                                                                           | Resolution                                                                         |\n| :----------------: | :--------- | :------------------------------------------------------------------------------------ | :--------------------------------------------------------------------------------- |\n| Docstring Coverage | ⚠️ Warning | Docstring coverage is 10.34% which is insufficient. The required threshold is 80.00%. | Write docstrings for the functions missing them to satisfy the coverage threshold. |\n\n<details>\n<summary>✅ Passed checks (4 passed)</summary>\n\n|         Check name         | Status   | Explanation                                                                                                                                                                                                                                                                      |\n| :------------------------: | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n|      Description Check     | ✅ Passed | Check skipped - CodeRabbit’s high-level summary is enabled.                                                                                                                                                                                                                      |\n|         Title check        | ✅ Passed | The PR title accurately describes the main objective: adding Bluetooth HFP microphone support with asynchronous readiness handling, which is reflected across all modified files (RecordingClient, SuperFastCaptureController, TranscriptionFeature, and related UI components). |\n|     Linked Issues check    | ✅ Passed | Check skipped because no linked issues were found for this pull request.                                                                                                                                                                                                         |\n| Out of Scope Changes check | ✅ Passed | Check skipped because no linked issues were found for this pull request.                                                                                                                                                                                                         |\n\n</details>\n\n<sub>✏️ Tip: You can configure your own custom pre-merge checks in the settings.</sub>\n\n</details>\n\n<!-- pre_merge_checks_walkthrough_end -->\n\n<!-- finishing_touch_checkbox_start -->\n\n<details>\n<summary>✨ Finishing Touches</summary>\n\n<details>\n<summary>🧪 Generate unit tests (beta)</summary>\n\n- [ ] <!-- {\"checkboxId\": \"f47ac10b-58cc-4372-a567-0e02b2c3d479\", \"radioGroupId\": \"utg-output-choice-group-unknown_comment_id\"} -->   Create PR with unit tests\n\n</details>\n\n</details>\n\n<!-- finishing_touch_checkbox_end -->\n\n<!-- This is an auto-generated comment: resource warnings by coderabbit.ai -->\n\n> [!WARNING]\n> ## Review ran into problems\n> \n> <details>\n> <summary>🔥 Problems</summary>\n> \n> Git: Failed to clone repository. Please run the `@coderabbitai full review` command to re-trigger a full review. If the issue persists, set `path_filters` to include or exclude specific files.\n> \n> </details>\n\n<!-- end of auto-generated comment: resource warnings by coderabbit.ai -->\n<!-- announcements_start -->\n\n> [!TIP]\n> <details>\n> <summary>💬 Introducing Slack Agent: The best way for teams to turn conversations into code.</summary>\n> \n> [Slack Agent](https://www.coderabbit.ai/agent) is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.\n> \n> - Generate code and open pull requests\n> - Plan features and break down work\n> - Investigate incidents and troubleshoot customer tickets together\n> - Automate recurring tasks and respond to alerts with triggers\n> - Summarize progress and report instantly\n> \n> Built for teams:\n> \n> - **Shared memory** across your entire org—no repeating context\n> - **Per-thread sandboxes** to safely plan and execute work\n> - **Governance built-in**—scoped access, auditability, and budget controls\n> \n> One agent for your entire SDLC. Right inside Slack.\n> \n> 👉 [Get started](https://agent.coderabbit.ai/)\n> \n> </details>\n\n<!-- announcements_end -->\n\n<!-- tips_start -->\n\n---\n\nThanks for using [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=kitlangton/Hex&utm_content=219)! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.\n\n<details>\n<summary>❤️ Share</summary>\n\n- [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)\n- [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)\n- [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)\n- [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)\n\n</details>\n<!-- review_rate_limit_status_start -->\n<sub>Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.</sub>\n<!-- review_rate_limit_status_end -->\n\n<sub>Comment `@coderabbitai help` to get the list of available commands and usage tips.</sub>\n\n<!-- tips_end -->\n\n<!-- internal state start -->\n\n\n<!-- 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 -->\n\n<!-- internal state end -->"},"request":{"retryCount":3,"signal":{},"retries":3,"retryAfter":16}},"response":{"url":"https://api.github.com/repos/kitlangton/Hex/issues/comments/4372211707","status":502,"headers":{"content-length":"32","content-type":"application/json","date":"Mon, 04 May 2026 15:42:48 GMT","etag":"\"69f8a3e0-20\"","server":"github.com","vary":"Accept-Encoding, Accept, X-Requested-With","x-github-request-id":"8417:79992:191E045:643D0EE:69F8BE6E","x-ratelimit-limit":"5000","x-ratelimit-remaining":"4911","x-ratelimit-reset":"1777911284","x-ratelimit-resource":"core","x-ratelimit-used":"89"},"data":{"message":"Server Error"}}}

@coderabbitai

coderabbitai Bot commented May 4, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Full review triggered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
HexTests/RecordingRaceTests.swift (1)

11-78: ⚡ Quick win

Please add a startup-cancellation/failure regression here too.

This covers the happy-path startRecording → recordingStarted transition, but the new behavior also depends on .recordingStartFailed and stop/discard while startup is still pending. That is the path most likely to regress with the async HFP wait/settle logic.

🤖 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 `@HexTests/RecordingRaceTests.swift` around lines 11 - 78, Add a second test to
cover the startup-cancellation/failure path: create a test similar to
newRecordingCancelsPendingDiscardCleanup (e.g.,
newRecordingCancelsPendingDiscardCleanup_onStartFailure) that uses
RecordingProbe and TestStore, sends .startRecording, then sends .discard while
the probe is pending, but instead of allowing the probe to complete to
.recordingStarted simulate a startup failure by sending .recordingStartFailed
(or invoking the store's failure path) while the pending stop is outstanding;
then resume the pending stop and finish the store and assert the probe counts
and file state match expectations (verify startCalls and stopCalls reflect the
failed startup case and that the discard cleanup was still canceled
appropriately). Ensure you reference the same symbols: RecordingProbe,
TestStore, .startRecording, .discard, .recordingStartFailed, and
probe.resumePendingStop()/waitForPendingStop() when adding this test.
🤖 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 `@Hex/Clients/RecordingClient.swift`:
- Around line 906-913: The loop currently returns the first ready Bluetooth
input it finds (using getDefaultInputDevice(), isBluetoothDevice(deviceID:),
inputDeviceIsReady(deviceID:)), which lets startup pick the wrong mic; change
the readiness check to wait for the specific requested headset ID (e.g. compare
currentInput to the requested/preferred headset identifier used by your startup
flow—match the variable name used in this class, e.g. requestedHeadsetID or
preferredInputDeviceID) before returning: if currentInput is nil or not equal to
the requested ID, or not ready, continue the loop; only call
recordingLogger.notice and return when currentInput == requestedHeadsetID &&
isBluetoothDevice(...) && inputDeviceIsReady(...), and add a separate log when
another Bluetooth device is ready but not the requested one.
- Around line 898-923: The waitForHandsFreeInputIfNeeded loop currently swallows
cancellation by using try? await Task.sleep and never propagates cancellation to
callers, so change the sleep call to a throwing sleep (remove try?) so
CancellationError can propagate, and after the loop and before returning a
device ensure you check Task.isCancelled (or explicitly throw CancellationError)
so callers can abort startup; also update the downstream recording startup path
that arms the capture engine (the caller that invokes
waitForHandsFreeInputIfNeeded / the capture arming segment in startRecording) to
check Task.isCancelled (or handle thrown CancellationError) and abort/clean up
instead of proceeding to arm the engine. Ensure references to
getDefaultInputDevice(), inputDeviceIsReady(deviceID:), describeDevice(_:) and
recordingLogger remain consistent while allowing cancellation to propagate.

In `@Hex/Clients/SuperFastCaptureController.swift`:
- Around line 230-242: The configuration-change observer currently dispatches on
.main and mutates engine lifecycle state unsafely; change
registerConfigurationChangeObserver to route its handler through the same
serialized context used for lifecycle mutations (i.e., dispatch work onto the
existing processingQueue or invoke the RecordingClientLive actor/task that owns
startIfNeeded(), stop(), and beginRecording()) so
restartEngineForActiveRecording() runs serialized with those methods; ensure
mutations to engine, converter, configurationChangeObserver, and
isReconfiguringEngine happen only within that serialized context and remove
direct main-queue mutations; also replace DispatchQueue.main.asyncAfter retry
scheduling with an async/await Task.sleep-based retry executed inside the same
serialized context so ordering is preserved.

---

Nitpick comments:
In `@HexTests/RecordingRaceTests.swift`:
- Around line 11-78: Add a second test to cover the startup-cancellation/failure
path: create a test similar to newRecordingCancelsPendingDiscardCleanup (e.g.,
newRecordingCancelsPendingDiscardCleanup_onStartFailure) that uses
RecordingProbe and TestStore, sends .startRecording, then sends .discard while
the probe is pending, but instead of allowing the probe to complete to
.recordingStarted simulate a startup failure by sending .recordingStartFailed
(or invoking the store's failure path) while the pending stop is outstanding;
then resume the pending stop and finish the store and assert the probe counts
and file state match expectations (verify startCalls and stopCalls reflect the
failed startup case and that the discard cleanup was still canceled
appropriately). Ensure you reference the same symbols: RecordingProbe,
TestStore, .startRecording, .discard, .recordingStartFailed, and
probe.resumePendingStop()/waitForPendingStop() when adding this test.
🪄 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

Run ID: c31074e6-22d4-4ac2-89ba-5212e622ff4f

📥 Commits

Reviewing files that changed from the base of the PR and between f4764d5 and 80f352f.

📒 Files selected for processing (5)
  • Hex/Clients/RecordingClient.swift
  • Hex/Clients/SuperFastCaptureController.swift
  • Hex/Features/Transcription/TranscriptionFeature.swift
  • Hex/Features/Transcription/TranscriptionIndicatorView.swift
  • HexTests/RecordingRaceTests.swift

Comment thread Hex/Clients/RecordingClient.swift Outdated
Comment thread Hex/Clients/RecordingClient.swift
Comment thread Hex/Clients/SuperFastCaptureController.swift
@n33pm
n33pm force-pushed the fix/BTHeadset branch from 80f352f to 8431cd8 Compare May 4, 2026 19:14
@coderabbitai

coderabbitai Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Full review triggered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

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)
Hex/Clients/RecordingClient.swift (1)

1201-1249: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Cancellation handlers can clobber a freshly started session.

endRecordingSession() and the stopCaptureController(reason: "start-cancelled") calls in the catch branches do not check whether sessionID is still the current session before mutating shared actor state. Because this method awaits several times (waitForHandsFreeInputIfNeeded, settleHandsFreeCaptureIfNeeded, internal Task.sleep), the actor is free to service a new startRecording() call between awaits. If the caller cancels the older effect after a newer session has been installed (recordingSessionID = newID), the cancellation catch will run endRecordingSession() which sets recordingSessionID = nil, cancels mediaControlTask, and on the second handler also calls stopCaptureController(...) — tearing down state owned by the newer session.

Guard each cleanup branch with isCurrentSession(sessionID) before mutating, mirroring the post-wait guard at Lines 1217–1220 and 1227–1230.

Suggested guards
     } catch is CancellationError {
       recordingLogger.notice("Recording start cancelled while waiting for Bluetooth HFP input")
-      endRecordingSession()
-      await resumeMediaIfNeeded()
+      if isCurrentSession(sessionID) {
+        endRecordingSession()
+        await resumeMediaIfNeeded()
+      }
       return false
     } catch {
       recordingLogger.error("Failed while waiting for Bluetooth HFP input: \(error.localizedDescription)")
-      endRecordingSession()
-      await resumeMediaIfNeeded()
+      if isCurrentSession(sessionID) {
+        endRecordingSession()
+        await resumeMediaIfNeeded()
+      }
       return false
     }
...
     } catch is CancellationError {
       recordingLogger.notice("Recording start cancelled before capture engine became active")
-      stopCaptureController(reason: "start-cancelled")
-      endRecordingSession()
-      await resumeMediaIfNeeded()
+      if isCurrentSession(sessionID) {
+        stopCaptureController(reason: "start-cancelled")
+        endRecordingSession()
+        await resumeMediaIfNeeded()
+      }
       return false
🤖 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 `@Hex/Clients/RecordingClient.swift` around lines 1201 - 1249, The
cancellation/error catch blocks currently call endRecordingSession() and
stopCaptureController(reason:) unconditionally which can tear down a newer
session if this task was preempted; update each catch (both CancellationError
and general error paths) to first check isCurrentSession(sessionID) and only
perform mutations (endRecordingSession(), stopCaptureController(reason:
"start-cancelled"), cancelling media/resuming media) when that check passes,
otherwise just return false/exit the catch; apply the same guard pattern used
after awaits (the isCurrentSession(sessionID) guards at the top of the method)
to prevent clobbering newer sessions.
🧹 Nitpick comments (3)
Hex/Clients/SuperFastCaptureController.swift (1)

287-326: 💤 Low value

Detached retry Task is not cancellable on stop/cleanup.

The retry uses an unstructured Task { ... } with Task.sleep that is never tracked or cancelled. If stop(reason:) or deinit is invoked between attempts, the in-flight retry will still wake up 200 ms later, dispatch back onto processingQueue, and re-enter restartEngineForActiveRecordingOnProcessingQueue. The early guard activeRecording != nil else { return } saves correctness, but during normal app teardown this can keep the controller alive past cleanup() and produces noisy log output. Consider holding a Task handle (e.g. private var pendingRebuildTask: Task<Void, Never>?) and cancelling it from stop(reason:).

🤖 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 `@Hex/Clients/SuperFastCaptureController.swift` around lines 287 - 326, The
retry Task in restartEngineForActiveRecordingOnProcessingQueue is untracked and
never cancelled; add a private var pendingRebuildTask: Task<Void, Never>? and
replace the unstructured Task { ... } with assigning that Task to
pendingRebuildTask (use [weak self] capture, await Task.sleep, then dispatch to
processingQueue and call restartEngineForActiveRecordingOnProcessingQueue).
Cancel and nil out pendingRebuildTask from stop(reason:) and deinit (and also
clear it on successful rebuild or when giving up) so pending retries cannot keep
the controller alive or produce logs after cleanup.
Hex/Features/Transcription/TranscriptionFeature.swift (1)

347-356: 💤 Low value

Drop the redundant assignment after the early-return guard.

By the time execution reaches Line 354, state.isStartingRecording is already false (the guard on Line 348 returns when it is true). The assignment is dead code — minor, but worth removing for clarity.

Suggested fix
     if state.isStartingRecording {
       state.shouldStopWhenRecordingStarts = true
       transcriptionFeatureLogger.notice("Deferring stop until microphone is ready")
       return .none
     }

-    state.isStartingRecording = false
     state.isRecording = false
🤖 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 `@Hex/Features/Transcription/TranscriptionFeature.swift` around lines 347 -
356, In handleStopRecording(_:), remove the redundant assignment to
state.isStartingRecording after the early-return guard — the guard already
returns when state.isStartingRecording is true, so the line
"state.isStartingRecording = false" is dead code; keep the early-return branch
that sets shouldStopWhenRecordingStarts and the remaining state updates (e.g.,
state.isRecording = false) but delete the redundant state.isStartingRecording =
false assignment to clarify intent.
Hex/Clients/RecordingClient.swift (1)

906-926: 💤 Low value

Tighten the “other Bluetooth input ready” log to once per occurrence.

Line 918–923 emits a recordingLogger.notice(...) on every 100 ms iteration as long as another Bluetooth input is ready but doesn't match targetDeviceID. Over the 3 s window this can produce up to 30 near-identical entries per startup. Either gate it on a state change (only log on transition) or downgrade to .debug.

Suggested fix
+      var lastIgnoredInput: AudioDeviceID?
       for attempt in 0 ..< 30 {
         let currentInput = getDefaultInputDevice()
         if let currentInput,
@@
         if let currentInput,
            currentInput != targetDeviceID,
            isBluetoothDevice(deviceID: currentInput),
            inputDeviceIsReady(deviceID: currentInput) {
-          recordingLogger.notice("Ignoring ready Bluetooth input \(self.describeDevice(currentInput)); waiting for requested \(self.describeDevice(targetDeviceID))")
+          if lastIgnoredInput != currentInput {
+            recordingLogger.notice("Ignoring ready Bluetooth input \(self.describeDevice(currentInput)); waiting for requested \(self.describeDevice(targetDeviceID))")
+            lastIgnoredInput = currentInput
+          }
         }
🤖 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 `@Hex/Clients/RecordingClient.swift` around lines 906 - 926, Inside the for
attempt loop that checks getDefaultInputDevice() (the 0 ..< 30 retry loop in
RecordingClient.swift), avoid emitting recordingLogger.notice repeatedly for the
"other Bluetooth input ready" case by either (a) adding a local boolean flag
(e.g., sawOtherBluetoothReady) initialized before the loop and only call
recordingLogger.notice/notice(...) when the flag transitions from false to true
(and set it true afterwards), or (b) change the recordingLogger.notice call for
the non-target Bluetooth-ready branch to recordingLogger.debug(...); update the
branch that calls isBluetoothDevice(deviceID:) && inputDeviceIsReady(deviceID:)
&& currentInput != targetDeviceID to use the flag or debug level so the message
is logged once per occurrence instead of every retry.
🤖 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 `@Hex/Clients/RecordingClient.swift`:
- Around line 1201-1249: The cancellation/error catch blocks currently call
endRecordingSession() and stopCaptureController(reason:) unconditionally which
can tear down a newer session if this task was preempted; update each catch
(both CancellationError and general error paths) to first check
isCurrentSession(sessionID) and only perform mutations (endRecordingSession(),
stopCaptureController(reason: "start-cancelled"), cancelling media/resuming
media) when that check passes, otherwise just return false/exit the catch; apply
the same guard pattern used after awaits (the isCurrentSession(sessionID) guards
at the top of the method) to prevent clobbering newer sessions.

---

Nitpick comments:
In `@Hex/Clients/RecordingClient.swift`:
- Around line 906-926: Inside the for attempt loop that checks
getDefaultInputDevice() (the 0 ..< 30 retry loop in RecordingClient.swift),
avoid emitting recordingLogger.notice repeatedly for the "other Bluetooth input
ready" case by either (a) adding a local boolean flag (e.g.,
sawOtherBluetoothReady) initialized before the loop and only call
recordingLogger.notice/notice(...) when the flag transitions from false to true
(and set it true afterwards), or (b) change the recordingLogger.notice call for
the non-target Bluetooth-ready branch to recordingLogger.debug(...); update the
branch that calls isBluetoothDevice(deviceID:) && inputDeviceIsReady(deviceID:)
&& currentInput != targetDeviceID to use the flag or debug level so the message
is logged once per occurrence instead of every retry.

In `@Hex/Clients/SuperFastCaptureController.swift`:
- Around line 287-326: The retry Task in
restartEngineForActiveRecordingOnProcessingQueue is untracked and never
cancelled; add a private var pendingRebuildTask: Task<Void, Never>? and replace
the unstructured Task { ... } with assigning that Task to pendingRebuildTask
(use [weak self] capture, await Task.sleep, then dispatch to processingQueue and
call restartEngineForActiveRecordingOnProcessingQueue). Cancel and nil out
pendingRebuildTask from stop(reason:) and deinit (and also clear it on
successful rebuild or when giving up) so pending retries cannot keep the
controller alive or produce logs after cleanup.

In `@Hex/Features/Transcription/TranscriptionFeature.swift`:
- Around line 347-356: In handleStopRecording(_:), remove the redundant
assignment to state.isStartingRecording after the early-return guard — the guard
already returns when state.isStartingRecording is true, so the line
"state.isStartingRecording = false" is dead code; keep the early-return branch
that sets shouldStopWhenRecordingStarts and the remaining state updates (e.g.,
state.isRecording = false) but delete the redundant state.isStartingRecording =
false assignment to clarify intent.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5d0fb7f5-88af-425b-aa1f-3cd7b8f8fda5

📥 Commits

Reviewing files that changed from the base of the PR and between f4764d5 and 8431cd8.

📒 Files selected for processing (5)
  • Hex/Clients/RecordingClient.swift
  • Hex/Clients/SuperFastCaptureController.swift
  • Hex/Features/Transcription/TranscriptionFeature.swift
  • Hex/Features/Transcription/TranscriptionIndicatorView.swift
  • HexTests/RecordingRaceTests.swift

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.

Bluetooth headset mic does not record when switching from A2DP to HFP

1 participant