Skip to content

Fixed sound sync issue - added new sync settings to sync b/w FV and s…#65

Merged
altic-dev merged 1 commit intomainfrom
b/sound_sync
Dec 21, 2025
Merged

Fixed sound sync issue - added new sync settings to sync b/w FV and s…#65
altic-dev merged 1 commit intomainfrom
b/sound_sync

Conversation

@altic-dev
Copy link
Copy Markdown
Owner

@altic-dev altic-dev commented Dec 21, 2025

…ystem sound ; also have independent control

Description

Brief description of what this PR does.

Type of Change

  • 🐞 Bug fix (non-breaking change which fixes an issue)
  • ✨ New feature (non-breaking change which adds functionality)
  • 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • 📝 Documentation update

Related Issues

Closes #(issue number)

Testing

  • Tested on Intel/Apple Silicon Mac
  • Tested on Apple Silicon Mac
  • Tested on macOS [version]
  • Ran linter locally: brew install swiftlint && swiftlint --strict --config .swiftlint.yml
  • Ran formatter locally: brew install swiftformat && swiftformat --config .swiftformat Sources

Screenshots / Video

Add screenshots or Video recording of the app after you have made your changes

Summary by CodeRabbit

Release Notes

  • New Features

    • Added "Sync with System Settings" toggle in audio device settings to control whether device selections update system defaults or remain independent.
    • Added refresh button in audio device settings to refresh device lists.
  • Bug Fixes

    • Improved audio device initialization and startup sequencing for more reliable device binding.
    • Enhanced handling of system audio device changes to respect user preferences.
    • Fixed event tap timeout handling to automatically re-enable when interrupted.
  • Chores

    • Released version 1.5.1 (stable).

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Dec 21, 2025

Walkthrough

This PR refactors the app's service architecture to introduce lazy initialization with a startup gate via AppServices, adds conditional audio device synchronization with system settings, enhances device binding logic in ASRService, and bumps the version to 1.5.1.

Changes

Cohort / File(s) Change Summary
Version & Manifest
Info.plist
Bumped CFBundleShortVersionString from "1.5.1-beta.3" to "1.5.1" for release.
Service Architecture & Initialization
Sources/Fluid/Services/AppServices.swift, Sources/Fluid/Services/fluidApp.swift, Sources/Fluid/ContentView.swift
Introduced startup gate with isUIReady flag and signalUIReady(); replaced eager service instantiation with lazy properties (audioObserver, asr) backed by private fields; added initializeServicesIfNeeded() for coordinated initialization; added service change forwarding to trigger UI updates. Updated FluidApp to initialize appServices via init-based wrapper. Restructured ContentView startup flow with multi-layer initialization, UI-ready signaling on 1.5s delay, and lazy service initialization.
Audio Device Synchronization
Sources/Fluid/Persistence/SettingsStore.swift, Sources/Fluid/Services/ASRService.swift, Sources/Fluid/UI/SettingsView.swift
Added new syncAudioDevicesWithSystem Bool setting (defaults to false) in SettingsStore. Enhanced ASRService with conditional input device binding: when sync is disabled, uses preferred device via new bindPreferredInputDeviceIfNeeded() and setEngineInputDevice() methods; when enabled, respects system defaults. Updated SettingsView to add "Sync with System Settings" toggle, Refresh button for device lists, and cached default device names.
Audio Device Utilities
Sources/Fluid/Services/AudioDeviceService.swift
Added getInputDevice(byUID:) and getDeviceId(forUID:) utility methods to resolve devices without system state modification.
Event Handling & Managers
Sources/Fluid/Services/GlobalHotkeyManager.swift, Sources/Fluid/Services/MenuBarManager.swift
Added handling in GlobalHotkeyManager for temporarily disabled event taps with re-enabling and retry logic. Minor spacing adjustment in MenuBarManager comment.
Token Limit Formatting
Sources/Fluid/Services/CommandModeService.swift, Sources/Fluid/Services/RewriteModeService.swift
Updated numeric literal format from 32000 to 32_000 for readability (no functional change).

Sequence Diagram(s)

sequenceDiagram
    participant App as fluidApp
    participant Content as ContentView
    participant AppSvc as AppServices
    participant AudioObs as AudioObserver
    participant ASR as ASRService
    participant Device as AudioDeviceService
    participant Settings as SettingsStore

    App->>AppSvc: `@EnvironmentObject` initialization
    Note over Content: ContentView loads
    
    rect rgb(220, 240, 255)
    Note over Content,AppSvc: Startup Sequence (1.5s delay)
    Content->>AppSvc: signalUIReady()
    AppSvc->>AppSvc: isUIReady = true
    end
    
    rect rgb(240, 255, 240)
    Note over AppSvc,ASR: Lazy Service Initialization
    Content->>AppSvc: access appServices.audioObserver
    AppSvc->>AudioObs: create instance (lazy)
    AppSvc->>AppSvc: setupAudioObserverForwarding()
    
    Content->>AppSvc: access appServices.asr
    AppSvc->>ASR: create instance (lazy)
    AppSvc->>AppSvc: setupASRForwarding()
    end
    
    rect rgb(255, 245, 220)
    Note over ASR,Settings: Device Binding (sync-aware)
    ASR->>Settings: check syncAudioDevicesWithSystem
    
    alt syncAudioDevicesWithSystem = false
        ASR->>Device: getInputDevice(byUID: preferred)
        Device-->>ASR: Device or nil
        ASR->>ASR: bindPreferredInputDeviceIfNeeded()
        ASR->>ASR: setEngineInputDevice(deviceID)
    else syncAudioDevicesWithSystem = true
        ASR->>ASR: use system defaults
    end
    end
    
    rect rgb(255, 230, 230)
    Note over ASR,Settings: Hardware Change Handling
    ASR->>Settings: check syncAudioDevicesWithSystem
    alt sync enabled
        ASR->>ASR: restart engine, bind to new default
    else sync disabled
        ASR->>ASR: log & ignore (preserve user preference)
    end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Areas requiring extra attention:

  • AppServices.swift: Verify lazy initialization, property forwarding logic, and correct closure capture in computed properties to avoid retain cycles.
  • ContentView.swift: Ensure startup sequencing (UI-ready signal, service initialization, menuBar configuration) is thread-safe and respects timing constraints.
  • ASRService.swift: Review device binding logic for both sync-enabled and sync-disabled paths; verify AudioUnit property application and error handling during engine resets.
  • SettingsView.swift: Confirm cached device name population in onAppear avoids CoreAudio race conditions; validate toggle behavior for enabling/disabling system sync.
  • Interaction between SettingsStore, ASRService, and SettingsView: Ensure setting changes trigger correct service behavior and UI updates through forwarding mechanisms.

Possibly related PRs

Poem

🐰 A startup gate and services lazy-born,
Audio devices dance with sync or scorn,
From beta third to one-point-five we hop,
With cached device names and refresh at the top! ✨

Pre-merge checks and finishing touches

❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Description check ⚠️ Warning The description is largely incomplete. It uses the template structure but leaves the main 'Description' section blank, does not specify the related issue number, omits testing details (macOS version, Intel/Apple Silicon selection clarity), and has no screenshots/video despite multiple functional changes. Complete the Description section with a clear summary of changes. Specify the related issue number, clarify which macOS versions were tested, and add screenshots or video demonstrating the new sync settings UI and behavior.
Docstring Coverage ⚠️ Warning Docstring coverage is 47.37% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title is partially related to the changeset. It mentions 'sound sync' and 'independent control', which aligns with the core feature (syncAudioDevicesWithSystem setting), but is truncated and vague about the scope of changes (version bump, startup refactoring, UI additions).
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch b/sound_sync

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.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
Sources/Fluid/ContentView.swift (1)

538-573: Minor: Consider extracting device sync logic into a dedicated method.

The hardware change handler correctly implements conditional behavior based on syncAudioDevicesWithSystem. However, the nested conditionals with similar patterns for input and output devices could be extracted into a helper method to reduce duplication.

Also, on lines 555 and 565, inputDevices and outputDevices are accessed directly without self., unlike other property accesses in this file. This is a minor inconsistency.

🔎 Suggested consistency fix
             } else {
                 // Independent mode: Only update if preferred device is no longer available
                 if let prefIn = SettingsStore.shared.preferredInputDeviceUID,
-                   inputDevices.contains(where: { $0.uid == prefIn })
+                   self.inputDevices.contains(where: { $0.uid == prefIn })
                 {
                     self.selectedInputUID = prefIn
                 } else if let sysIn = AudioDevice.getDefaultInputDevice()?.uid {
                     // Fallback to system default if preferred device disconnected
                     self.selectedInputUID = sysIn
                     SettingsStore.shared.preferredInputDeviceUID = sysIn
                 }

                 if let prefOut = SettingsStore.shared.preferredOutputDeviceUID,
-                   outputDevices.contains(where: { $0.uid == prefOut })
+                   self.outputDevices.contains(where: { $0.uid == prefOut })
                 {
                     self.selectedOutputUID = prefOut
                 } else if let sysOut = AudioDevice.getDefaultOutputDevice()?.uid {
Sources/Fluid/Services/AppServices.swift (1)

101-115: initializeServicesIfNeeded() provides defense-in-depth but may be redundant.

This method provides an explicit initialization path, but the lazy getters already handle initialization on first access. The method is useful as documentation and for explicit initialization ordering, but the side-effect-only access (_ = self.audioObserver) could be clearer with a comment.

Consider whether this method adds value beyond what ContentView already does by directly accessing audioObserver.startObserving() and asr.initialize().

Sources/Fluid/Services/ASRService.swift (1)

530-537: Blocking Thread.sleep on MainActor could cause brief UI freezes.

Thread.sleep(forTimeInterval: 0.1) on line 531 blocks the main thread for 100ms per retry attempt (up to 300ms total for 3 attempts). While this is a retry path for engine start failures, it could cause noticeable UI stutter.

Consider using Task.sleep instead for non-blocking delays, though this would require making startEngine() async. Given that this is in a retry path for rare failures, the current implementation may be acceptable as a pragmatic tradeoff.

🔎 Alternative using async sleep (requires method signature change)

If you want to avoid blocking the main thread:

-    private func startEngine() throws {
+    private func startEngine() async throws {
         self.engine.reset()
         var attempts = 0
         while attempts < 3 {
             do {
                 try self.engine.start()
                 return
             } catch {
                 attempts += 1
-                Thread.sleep(forTimeInterval: 0.1)
+                try? await Task.sleep(nanoseconds: 100_000_000)
                 self.engine.reset()
                 // After a reset, the underlying AUHAL unit may revert to system-default input.
                 // Re-create the input node and re-bind the preferred device (independent mode).
                 _ = self.engine.inputNode
                 self.bindPreferredInputDeviceIfNeeded()
             }
         }
         throw NSError(domain: "ASRService", code: -1)
     }

This would require updating callers to await the method.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 305bbde and 6ef68cc.

📒 Files selected for processing (12)
  • Info.plist (1 hunks)
  • Sources/Fluid/ContentView.swift (3 hunks)
  • Sources/Fluid/Persistence/SettingsStore.swift (2 hunks)
  • Sources/Fluid/Services/ASRService.swift (4 hunks)
  • Sources/Fluid/Services/AppServices.swift (2 hunks)
  • Sources/Fluid/Services/AudioDeviceService.swift (1 hunks)
  • Sources/Fluid/Services/CommandModeService.swift (1 hunks)
  • Sources/Fluid/Services/GlobalHotkeyManager.swift (1 hunks)
  • Sources/Fluid/Services/MenuBarManager.swift (1 hunks)
  • Sources/Fluid/Services/RewriteModeService.swift (1 hunks)
  • Sources/Fluid/UI/SettingsView.swift (4 hunks)
  • Sources/Fluid/fluidApp.swift (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (5)
Sources/Fluid/UI/SettingsView.swift (3)
Sources/Fluid/ContentView.swift (1)
  • refreshDevices (960-963)
Sources/Fluid/Services/AudioDeviceService.swift (4)
  • getDefaultInputDevice (78-81)
  • getDefaultOutputDevice (83-86)
  • setDefaultInputDevice (88-92)
  • setDefaultOutputDevice (94-98)
Sources/Fluid/Services/DebugLogger.swift (1)
  • info (141-143)
Sources/Fluid/Services/CommandModeService.swift (1)
Sources/Fluid/Persistence/SettingsStore.swift (1)
  • isReasoningModel (600-609)
Sources/Fluid/Services/GlobalHotkeyManager.swift (1)
Sources/Fluid/Services/DebugLogger.swift (1)
  • warning (145-147)
Sources/Fluid/Services/ASRService.swift (2)
Sources/Fluid/Services/AudioDeviceService.swift (1)
  • getInputDevice (101-103)
Sources/Fluid/Services/DebugLogger.swift (4)
  • warning (145-147)
  • error (149-151)
  • info (141-143)
  • debug (153-155)
Sources/Fluid/Services/AppServices.swift (1)
Sources/Fluid/Services/DebugLogger.swift (2)
  • info (141-143)
  • warning (145-147)
🔇 Additional comments (22)
Sources/Fluid/Services/RewriteModeService.swift (1)

217-217: Numeric literal formatting improves readability.

The change from 32000 to 32_000 follows Swift idioms for formatting large numbers. This cosmetic improvement has no functional impact and enhances code clarity.

Sources/Fluid/Services/CommandModeService.swift (1)

834-834: Numeric literal formatting improves readability.

The change from 32000 to 32_000 follows Swift idioms for formatting large numbers. This cosmetic improvement has no functional impact and enhances code clarity.

Sources/Fluid/Services/MenuBarManager.swift (1)

290-290: LGTM!

Formatting-only change with no functional impact.

Info.plist (1)

14-14: LGTM!

Version bump from beta to release (1.5.1-beta.31.5.1) is appropriate for the feature additions in this PR.

Sources/Fluid/Services/AudioDeviceService.swift (1)

100-108: LGTM!

Clean helper methods that extend the API without modifying existing behavior. The read-only design correctly avoids side effects on system audio settings.

Sources/Fluid/fluidApp.swift (1)

15-22: LGTM!

This is the correct SwiftUI pattern for wrapping a shared singleton in @StateObject. The explicit init() with _appServices = StateObject(wrappedValue:) ensures proper ownership semantics and avoids potential issues with inline initialization of shared instances.

Sources/Fluid/Persistence/SettingsStore.swift (2)

32-32: LGTM!

New key follows the existing naming convention and is properly placed in the Keys enum.


294-305: LGTM!

The property follows established patterns in this file: uses object(forKey:) to handle nil vs false distinction, calls objectWillChange.send() before mutation, and has clear documentation explaining the sync behavior.

Sources/Fluid/UI/SettingsView.swift (6)

451-468: LGTM!

Clean refactor moving the refresh functionality to the header. CoreAudio calls happen on user action (button tap) rather than in view body, which is the correct pattern.


490-493: Core feature: Conditional system sync.

This correctly gates the system audio device change behind the new syncAudioDevicesWithSystem setting. When disabled, FluidVoice maintains independent device selection.


542-545: Consistent implementation for output device.

Matches the input device pattern at lines 490-493.


568-578: Good fix for CoreAudio race condition.

Using cached state variables instead of direct AudioDevice.getDefaultInputDevice() calls in the view body avoids the HALSystem::InitializeShell() race with SwiftUI's AttributeGraph.


580-606: Sync toggle implementation looks correct.

When sync is enabled, adopting the system's current devices as source of truth is the right behavior. The nil checks on lines 592 and 596 provide safe fallback when devices are unavailable.

One minor observation: when sync is toggled ON and the system default differs from the current selection, this triggers the onChange(of: selectedInputUID) handler which will restart ASR if running—this is correct behavior to apply the new device immediately.


749-752: Critical fix applied correctly.

Populating cached device names inside onAppear (after AudioStartupGate.shared.waitUntilOpen()) ensures CoreAudio calls happen after UI is settled, avoiding the race condition documented in the comments.

Sources/Fluid/ContentView.swift (3)

39-49: Service accessor pattern looks good.

The transition from @StateObject to @EnvironmentObject with computed property accessors correctly centralizes service lifecycle management while maintaining backward compatibility with existing code. This approach avoids duplicate service instantiation that was causing startup crashes.


168-190: Startup gate and lazy initialization strategy is well-documented.

The multi-layer defensive strategy (consolidation, lazy init, startup gate, delayed audio init) with clear comments explains the rationale for the 1.5s delay. The sequence correctly signals UI readiness before accessing services, allowing lazy initialization to proceed safely.


527-537: Alert binding refactor is correct.

The two-way binding using Binding(get:set:) properly synchronizes the alert presentation state with asr.showError, allowing the alert dismissal to update the underlying state.

Sources/Fluid/Services/AppServices.swift (1)

46-56: Lazy initialization pattern is sound but has a minor ordering consideration.

The lazy initialization works correctly. However, setupAudioObserverForwarding() is called after assigning to _audioObserver but before returning. Since setupAudioObserverForwarding() checks guard let observer = _audioObserver, this works correctly.

One observation: if audioObserver is accessed concurrently from multiple call sites on the main actor, the first caller creates the instance and subsequent callers get the cached one. This is correct behavior given the @MainActor constraint.

Sources/Fluid/Services/ASRService.swift (4)

453-459: Good defensive instantiation of inputNode before binding.

Forcing _ = self.engine.inputNode ensures the AUHAL AudioUnit exists before attempting to bind to a specific device. This prevents potential crashes when the AudioUnit hasn't been created yet.


461-482: Device binding logic is well-structured with appropriate fallback.

The method correctly:

  1. Guards against sync-with-system mode
  2. Guards against empty/nil preferred UID
  3. Logs a warning and falls back gracefully when the preferred device isn't found
  4. Logs binding failures without crashing

555-575: Sync flag check in handleDefaultInputChanged is correct.

The early return when syncAudioDevicesWithSystem is false correctly prevents the engine from restarting to follow system default changes in independent mode. The debug log provides visibility into why the change was ignored.


484-520: AudioUnit device binding implementation is correct.

The use of kAudioOutputUnitProperty_CurrentDevice with kAudioUnitScope_Global is the standard approach for binding an AUHAL unit to a specific device. Error handling with OSStatus checking is appropriate and necessary.

The nil-check for inputNode.audioUnit is important—if the audioUnit returns nil, it indicates the input node hasn't been properly initialized. While the current error logging handles this, it's worth monitoring if this occurs frequently as it may signal a deeper initialization issue.

Comment on lines +217 to +235
// macOS can temporarily disable event taps (e.g. timeouts, user input protection).
// If we don't immediately re-enable here, hotkeys will silently stop working until our
// periodic health check kicks in, and the OS may handle the key (e.g. system dictation).
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
let reason = (type == .tapDisabledByTimeout) ? "timeout" : "user input"
DebugLogger.shared.warning("Event tap disabled by \(reason) — attempting immediate re-enable", source: "GlobalHotkeyManager")

if let tap = self.eventTap {
CGEvent.tapEnable(tap: tap, enable: true)
}

// If re-enable failed, recreate the tap.
if !self.isEventTapEnabled() {
DebugLogger.shared.warning("Event tap re-enable failed — recreating tap", source: "GlobalHotkeyManager")
self.setupGlobalHotkeyWithRetry()
}

return nil
}
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.

⚠️ Potential issue | 🔴 Critical

Critical: Fix concurrency violation when recreating the event tap.

The call to setupGlobalHotkeyWithRetry() on line 231 is problematic because:

  • handleKeyEvent is nonisolated (invoked from a C callback)
  • setupGlobalHotkeyWithRetry() is implicitly @MainActor (the class is marked @MainActor)
  • Synchronously calling a @MainActor method from a nonisolated context violates Swift concurrency rules and can cause runtime errors or deadlocks
🔎 Proposed fix: Dispatch the retry call asynchronously to MainActor
         // If re-enable failed, recreate the tap.
         if !self.isEventTapEnabled() {
             DebugLogger.shared.warning("Event tap re-enable failed — recreating tap", source: "GlobalHotkeyManager")
-            self.setupGlobalHotkeyWithRetry()
+            Task { @MainActor in
+                self.setupGlobalHotkeyWithRetry()
+            }
         }
📝 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
// macOS can temporarily disable event taps (e.g. timeouts, user input protection).
// If we don't immediately re-enable here, hotkeys will silently stop working until our
// periodic health check kicks in, and the OS may handle the key (e.g. system dictation).
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
let reason = (type == .tapDisabledByTimeout) ? "timeout" : "user input"
DebugLogger.shared.warning("Event tap disabled by \(reason) — attempting immediate re-enable", source: "GlobalHotkeyManager")
if let tap = self.eventTap {
CGEvent.tapEnable(tap: tap, enable: true)
}
// If re-enable failed, recreate the tap.
if !self.isEventTapEnabled() {
DebugLogger.shared.warning("Event tap re-enable failed — recreating tap", source: "GlobalHotkeyManager")
self.setupGlobalHotkeyWithRetry()
}
return nil
}
// macOS can temporarily disable event taps (e.g. timeouts, user input protection).
// If we don't immediately re-enable here, hotkeys will silently stop working until our
// periodic health check kicks in, and the OS may handle the key (e.g. system dictation).
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
let reason = (type == .tapDisabledByTimeout) ? "timeout" : "user input"
DebugLogger.shared.warning("Event tap disabled by \(reason) — attempting immediate re-enable", source: "GlobalHotkeyManager")
if let tap = self.eventTap {
CGEvent.tapEnable(tap: tap, enable: true)
}
// If re-enable failed, recreate the tap.
if !self.isEventTapEnabled() {
DebugLogger.shared.warning("Event tap re-enable failed — recreating tap", source: "GlobalHotkeyManager")
Task { @MainActor in
self.setupGlobalHotkeyWithRetry()
}
}
return nil
}
🤖 Prompt for AI Agents
Sources/Fluid/Services/GlobalHotkeyManager.swift around lines 217-235: the code
synchronously calls the @MainActor method setupGlobalHotkeyWithRetry() from the
nonisolated C callback context in handleKeyEvent, violating Swift concurrency
rules; instead, dispatch the retry asynchronously to the MainActor (for example,
create a Task that calls await MainActor.run { await
self.setupGlobalHotkeyWithRetry() } or Task { await
self.setupGlobalHotkeyWithRetry() } so the call happens asynchronously on the
actor, avoiding synchronous cross-actor calls and potential deadlocks.

@altic-dev altic-dev merged commit 6415d45 into main Dec 21, 2025
2 checks passed
@coderabbitai coderabbitai bot mentioned this pull request Dec 29, 2025
9 tasks
@altic-dev altic-dev deleted the b/sound_sync branch March 15, 2026 07:49
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