Skip to content

Keep tap sound playback off the main thread and recover from output device changes - #22

Merged
nilbuild merged 1 commit into
nilbuild:mainfrom
ulentini:fix/tap-sound-freeze
Nov 10, 2026
Merged

nilbuild merged 1 commit into
nilbuild:mainfrom
ulentini:fix/tap-sound-freeze

Conversation

@ulentini

@ulentini ulentini commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #23.

Problem

With the tap sound on, sharing the screen with system audio in Google Meet freezes the pointer for about 5 seconds per click. Queued clicks are processed one every 5 seconds, which shows in ~/Library/Logs/TinyHand.log as presses spaced exactly 5 s apart, each with its release logged a few ms later.

Cause

Meet's system audio capture changes the output device's buffer size under the running AVAudioEngine. The engine had been prepared at launch for the previous size, so every render cycle fails from that point on. The system log shows kAudioUnitErr_TooManyFramesToProcess: inFramesToProcess=960, mMaxFramesPerSlice=941 about 50 times a second. TapSound.play() runs on the main thread, and scheduling a buffer with .interrupts waits on a render thread that makes no progress, so the click handler blocks and the pointer stops drawing. No AVAudioEngineConfigurationChange was observed, so the engine never recovers.

Changes (Sources/TinyHand/TapSound.swift only)

  • All AVAudioEngine work runs on a dedicated serial queue. play() only dispatches to it, so a misbehaving audio device can cost a tap sound but can no longer block the pointer.
  • The engine is rebuilt instead of reused: a fresh AVAudioEngine is built when sound resumes after the idle stop, on AVAudioEngineConfigurationChange, and when a scheduled buffer is not rendered within its duration plus 1 s. This last case covers device changes that send no notification. maximumFramesToRender on the player and mixer nodes is raised to 4096 as extra headroom.
  • Buffers are scheduled without .interrupts, round-robin over a pool of 4 player nodes, so taps that come quickly overlap instead of waiting on the render thread. The sounds are 50 to 140 ms long.

Testing

Built locally and tested on macOS 27.2 during a Google Meet screen share with system audio: the pointer answers every click immediately and the tap sound still plays while sharing. Behavior without screen sharing is unchanged.

Sharing a screen with audio in Google Meet changes the output device's
buffer size under the running AVAudioEngine, which then fails every render
cycle (kAudioUnitErr_TooManyFramesToProcess). Playing a tap on the main
thread waited on that broken render loop, freezing the pointer for about
five seconds per click.

- Run every engine call on a dedicated serial queue, never on the main thread.
- Build a fresh engine when sound resumes, when the audio configuration
  changes, and when a scheduled tap is never rendered, instead of reusing
  one prepared for an old device setup; leave headroom in the frames per
  render cycle.
- Schedule taps without .interrupts, round-robin over a small pool of
  player nodes so quick taps overlap.
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.

TinyHand freezes when I share my screen with audio, and my team now thinks I present screenshots

2 participants