Skip to content

fix: add @MainActor isolation and detect mic contention#3

Open
ygivenx wants to merge 2 commits intomainfrom
fix/mainactor-isolation-and-mic-contention
Open

fix: add @MainActor isolation and detect mic contention#3
ygivenx wants to merge 2 commits intomainfrom
fix/mainactor-isolation-and-mic-contention

Conversation

@ygivenx
Copy link
Owner

@ygivenx ygivenx commented Mar 12, 2026

WhisperTranscriber and AudioRecorder had @published properties but no actor isolation. After async suspension points (e.g. await whisper.transcribe), objectWillChange could fire from background threads, corrupting SwiftUI's view graph. This led to a SIGSEGV in DesignLibrary during view rendering after prolonged use.

Changes:

  • Mark WhisperTranscriber and AudioRecorder as @mainactor
  • Add nonisolated(unsafe) isCapturing flag for audio tap callback to avoid cross-isolation read of @published isRecording
  • Detect mic in use by another process via CoreAudio kAudioDevicePropertyDeviceIsRunningSomewhere and skip recording instead of disrupting the other app's audio
  • Show brief mic.slash / dimmed icon when mic is busy
  • Guard isMicBusy timeout Task against clobbering active recording state
  • Add LocalizedError conformance to AudioRecorderError

WhisperTranscriber and AudioRecorder had @published properties but no
actor isolation. After async suspension points (e.g. await whisper.transcribe),
objectWillChange could fire from background threads, corrupting SwiftUI's
view graph. This led to a SIGSEGV in DesignLibrary during view rendering
after prolonged use.

Changes:
- Mark WhisperTranscriber and AudioRecorder as @mainactor
- Add nonisolated(unsafe) isCapturing flag for audio tap callback to
  avoid cross-isolation read of @published isRecording
- Detect mic in use by another process via CoreAudio
  kAudioDevicePropertyDeviceIsRunningSomewhere and skip recording
  instead of disrupting the other app's audio
- Show brief mic.slash / dimmed icon when mic is busy
- Guard isMicBusy timeout Task against clobbering active recording state
- Add LocalizedError conformance to AudioRecorderError

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 12, 2026 03:00
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds main-actor isolation to UI-facing observable objects and introduces microphone-contention detection to prevent SwiftUI crashes and avoid disrupting other apps.

Changes:

  • Annotate WhisperTranscriber and AudioRecorder (and related tests) with @MainActor to keep @Published updates on the main actor.
  • Add mic-contention detection (CoreAudio kAudioDevicePropertyDeviceIsRunningSomewhere) and surface “mic busy” state through AppState + menu bar icon.
  • Add LocalizedError conformance for AudioRecorderError and a cross-thread isCapturing flag for the tap callback.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
FreeWispr/Tests/FreeWisprTests/WhisperTranscriberTests.swift Runs transcriber tests on the main actor to match new isolation.
FreeWispr/Tests/FreeWisprTests/AudioRecorderTests.swift Runs recorder tests on the main actor to match new isolation.
FreeWispr/Sources/FreeWispr/WhisperTranscriber.swift Main-actor isolates transcriber observable state.
FreeWispr/Sources/FreeWispr/FreeWisprApp.swift Plumbs isMicBusy into menu bar icon and updates icon rendering.
FreeWispr/Sources/FreeWispr/AudioRecorder.swift Adds mic busy detection, introduces isCapturing, and improves error typing.
FreeWispr/Sources/FreeWispr/AppState.swift Updates UI state when mic is busy and schedules automatic reset.
Comments suppressed due to low confidence (1)

FreeWispr/Sources/FreeWispr/AudioRecorder.swift:139

  • isCapturing = false is written while the engine may still be running and the tap callback may still be reading isCapturing. This contradicts the safety comment on isCapturing and increases the likelihood of a data race. If you keep the non-atomic approach, move the flag transition to a point where the tap is guaranteed quiesced (e.g., stop engine/remove tap first), or (preferred) switch isCapturing to an atomic so ordering doesn’t rely on engine timing.
    private func resetEngine() {
        isCapturing = false
        audioEngine.stop()
        if isTapInstalled {
            audioEngine.inputNode.removeTap(onBus: 0)
            isTapInstalled = false

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +105 to +115
} catch AudioRecorderError.micInUse {
statusMessage = "Mic in use by another app"
isMicBusy = true
Task { [weak self] in
try? await Task.sleep(for: .seconds(2))
guard let self, self.isMicBusy else { return }
self.isMicBusy = false
if !self.isRecording && !self.isTranscribing {
self.statusMessage = "Ready"
}
}
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

Task { ... } is not guaranteed to run/resume on the main actor, but it mutates @Published state (isMicBusy, statusMessage, etc.). This can reintroduce the same “background thread publishing changes” issue this PR is addressing. Make the task main-actor bound (e.g., Task { @MainActor in ... }) or do the post-sleep mutations via await MainActor.run { ... } (alternatively, annotate AppState as @MainActor if appropriate for the class).

Copilot uses AI. Check for mistakes.
Comment on lines +28 to +31
// Written on main thread before engine start / after engine stop.
// Read from audio tap callback. Safe because writes happen only while
// the engine is stopped, creating a happens-before relationship.
nonisolated(unsafe) private var isCapturing = false
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

isCapturing is read from the audio tap callback thread while being written from the main actor. As implemented, writes do not consistently happen only while the engine is stopped (see stopRecording()/resetEngine()), and the flag is a plain Bool with nonisolated(unsafe), which can still produce a data race/undefined behavior. Prefer a lock-free atomic (e.g., Swift Atomics ManagedAtomic<Bool>) for the tap’s fast-path read/write, or otherwise ensure synchronization such that the tap thread never concurrently accesses the flag during mutations.

Copilot uses AI. Check for mistakes.
Comment on lines 143 to 147
func stopRecording() {
guard isRecording else { return }
isCapturing = false
isRecording = false
audioEngine.stop()
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

Same issue as resetEngine(): this writes isCapturing concurrently with the tap thread potentially reading it, since audioEngine.stop() happens after the write. This should be addressed together with the isCapturing design (atomic flag recommended) to avoid undefined behavior under load.

Copilot uses AI. Check for mistakes.
Comment on lines +109 to +111
if Self.isMicInUseByAnotherProcess() {
throw AudioRecorderError.micInUse
}
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

New behavior changes startRecording() to throw .micInUse based on a CoreAudio query, but the updated tests shown only add @MainActor and don’t cover this branch. Add a unit test that verifies the .micInUse path and that isRecording remains false when this error is thrown; to make this testable, consider injecting the “mic usage check” behind a protocol/closure rather than calling a static CoreAudio function directly.

Copilot uses AI. Check for mistakes.
Comment on lines +11 to +12
case .micInUse: "Microphone is in use by another app"
case .formatError: "Cannot create audio format converter"
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

LocalizedError conformance implies user-facing strings; returning hard-coded English strings makes localization harder and can create inconsistencies with the rest of the app. Consider using String(localized:) (or NSLocalizedString) for these messages so they can be localized and centrally managed.

Suggested change
case .micInUse: "Microphone is in use by another app"
case .formatError: "Cannot create audio format converter"
case .micInUse:
return String(localized: "Microphone is in use by another app")
case .formatError:
return String(localized: "Cannot create audio format converter")

Copilot uses AI. Check for mistakes.
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.

2 participants