Skip to content

Fix Bluetooth audio artifacts and idle queue noise#107

Open
Kay21T wants to merge 1 commit into
kamillobinski:mainfrom
Kay21T:codex/fix-bluetooth-audio-noise
Open

Fix Bluetooth audio artifacts and idle queue noise#107
Kay21T wants to merge 1 commit into
kamillobinski:mainfrom
Kay21T:codex/fix-bluetooth-audio-noise

Conversation

@Kay21T
Copy link
Copy Markdown

@Kay21T Kay21T commented Apr 5, 2026

Summary

  • move AudioQueue callback work off the main-thread run loop
  • make sound library swaps atomic during soundpack changes
  • avoid idle stop/restart races
  • use a more stable playback profile for Bluetooth outputs
  • always clear idle buffers to prevent stale audio artifacts

Testing

  • reproduced the issue with Bluetooth headphones
  • changing volume and switching sound effects no longer causes crackling/noise
  • idle-related noise is gone
  • verified the fix still works with idle timeout set to 10 seconds
  • reduced the Bluetooth software buffer to 512 frames to improve latency while keeping playback stable

@Kay21T
Copy link
Copy Markdown
Author

Kay21T commented Apr 5, 2026

I was able to reproduce this consistently on Bluetooth headphones.

For the stock build, the noise came back when:

  • Bluetooth output was selected
  • Reduce CPU when idle was set to 10 seconds
  • I changed volume / switched sound effects, or resumed typing after being idle

As a control, setting Reduce CPU when idle to Never made the issue disappear in the original build.

With this patch:

  • the crackling/noise is gone even with Reduce CPU when idle = 10 seconds
  • Bluetooth playback remains stable when changing volume and switching sound effects
  • I reduced the Bluetooth software buffer from 1024 to 512 frames after testing, to keep latency lower while preserving stability

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 6, 2026

Greptile Summary

This PR addresses real Bluetooth audio issues by: (1) moving the AudioQueueOutputCallback off the main run-loop (nil/nil instead of CFRunLoopGetMain()), (2) detecting Bluetooth output devices and applying a conservative software buffer size while keeping the queue permanently warm to avoid codec re-negotiation noise, (3) protecting soundLibrary / mouseSoundLibrary with an NSLock so soundpack swaps are atomic, and (4) always zeroing idle audio buffers to prevent stale sample leakage.

The approach is sound and the new helpers (configurePlaybackProfile, isBluetoothDevice, getTransportType, keyboardSound, mouseSound) are clean and appropriately narrow in scope. A few concerns worth addressing:

  • Data race in fillBuffer log (P1): activeSounds.count is read after activeSoundsLock is released. Moving the callback to AudioQueue's internal thread (a change in this PR) makes this race more observable than before.
  • Redundant resetIdleTimer() ordering in play() and playMouseSound() (P2): calling it before restartQueue() starts an idle countdown before the queue is running; restartQueue() already calls resetIdleTimer() on success.
  • currentBufferSize read without queueStateLock in configurePlaybackProfile (P2): safe today but breaks the stated lock contract.

Confidence Score: 4/5

Safe to merge with minor attention to the data-race in fillBuffer logging

The core audio fixes are well-motivated and logically correct. Lock usage for sound library swaps is a clear improvement. The main concern (activeSounds.count read after lock release) is a pre-existing race made more visible by moving the callback off the main thread, and only affects a debug log line — not actual audio output. The redundant timer ordering is harmless. No correctness-breaking regressions were found.

Thock/Managers/SoundManager.swift — specifically fillBuffer (line 693) and the resetIdleTimer/restartQueue ordering in play() and playMouseSound()

Important Files Changed

Filename Overview
Thock/Managers/SoundManager.swift Adds Bluetooth detection, conservative buffer mode, sound library locking, and idle-queue race guards — changes are correct with a minor data-race in a log statement and redundant timer ordering.

Comments Outside Diff (1)

  1. Thock/Managers/SoundManager.swift, line 692-694 (link)

    P1 activeSounds.count read outside activeSoundsLock

    activeSoundsLock is unlocked on line 690, but self.activeSounds.count is read on line 693 in the log statement without re-acquiring the lock. A concurrent call to play() or playMouseSound() can append to activeSounds between the unlock and this read, causing a data race. This was present before the PR, but moving the callback off the main run-loop (so fillBuffer now runs on AudioQueue's internal thread concurrently with public API calls) makes the race more likely to be observed.

    Capture the count before unlocking:

Reviews (1): Last reviewed commit: "Fix Bluetooth audio artifacts and idle q..." | Re-trigger Greptile

Comment on lines +809 to +811

resetIdleTimer()
restartQueue()
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.

P2 Redundant resetIdleTimer() before restartQueue()

When the queue is stopped (e.g. after an idle timeout), the sequence is:

  1. resetIdleTimer() is called here — starts a new countdown timer immediately.
  2. restartQueue() starts the queue and — on success — calls resetIdleTimer() again (line 396), cancelling and recreating that timer.

This always produces two timer allocations and one cancellation when the queue was stopped. The original code only called resetIdleTimer() when the queue was already running. Swapping the order avoids starting an idle countdown before the queue is actually running:

Suggested change
resetIdleTimer()
restartQueue()
restartQueue()
resetIdleTimer()

Comment on lines 924 to +927
activeSoundsLock.unlock()

resetIdleTimer()
restartQueue()
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.

P2 Same redundant resetIdleTimer() ordering in playMouseSound

Identical issue as in play() (lines 810-811). resetIdleTimer() starts the idle countdown before the queue has been (re)started; restartQueue() already calls resetIdleTimer() on success, so the first call is redundant. Swapping the order is the minimal fix.

Comment on lines +1155 to +1159
private func configurePlaybackProfile(for deviceID: AudioDeviceID) -> Bool {
let isBluetoothOutput = isBluetoothDevice(deviceID)
let effectiveBufferSize = isBluetoothOutput
? max(currentBufferSize, Self.bluetoothMinimumBufferSize)
: currentBufferSize
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.

P2 currentBufferSize read outside queueStateLock

The lock comment on line 27 declares queueStateLock protects several fields; framesPerBuffer is also written under that lock (line 1162). Here currentBufferSize — which feeds directly into framesPerBuffer — is read without holding the lock. This is safe today because configurePlaybackProfile is only called from within reinitializeAudioQueue after currentBufferSize has already been written and the lock released, but it silently breaks the ownership contract and could be misused if the call-site changes.

Consider either acquiring queueStateLock around the read, or passing the effective buffer size as a parameter.

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