Found chasing a reported "Apple TV crashes on launch". There is no tvOS crash — the reports are macOS, and this one is not at launch but at session start.
installTap(onBus:bufferSize:format:) validates a non-nil format against the bus and raises an Objective-C exception on any mismatch. Swift cannot catch that, so it reaches the terminate handler and aborts the process — the whole app, for a microphone that failed to attach.
The format we hand it is necessarily read a moment earlier (input.outputFormat(forBus: 0)), and on macOS the input can move underneath it in that window:
the default input device changes (AudioDeviceWatcher exists precisely because this happens mid-session),
the device changes clock/rate,
or the kAudioOutputUnitProperty_CurrentDevice swap that startCapture itself performs two lines before this call.
The existing guard only rejected sampleRate == 0 || channelCount == 0. That is a device that is absent — a different failure from one that changed, which is what the crash is.
The fix
Install with format: nil, which is the documented "use the bus's own format". The mismatch becomes unrepresentable rather than merely unlikely — there is no snapshot left to go stale.
The tap then has to follow the real format. The rate-dependent pieces (mono bus, resampler, and both scratch buffers — they are only ever valid as a set) move into a MicChain that the tap rebuilds when buffer.format.sampleRate differs from what the chain was built for. A chain pinned to a stale rate would resample by the wrong ratio and pitch-shift the mic, so following the format is not optional once the tap is installed with nil.
That rebuild subsumes the old grow-on-larger-quantum branch (same trigger shape, one condition wider). The steady state still allocates nothing.
Channel count and interleaving already came from buffer.format inside the tap, and foldToMono already range-checks the pinned channel per call, so those need no change.
Tests
AudioMicChainTests — 4 cases over the sizing arithmetic the rebuild rests on, no engine, device or mic grant required.
Worth calling out one: staging holds the resampled 48 kHz mono, so it must fit the upward ratio. Sized for the input rate instead, it silently truncates every packet on any device below 48 kHz — a quiet-mic bug rather than a crash, so nothing else would have caught it.
Gates
swift build clean
full suite: 384 tests, 0 failures (8 skipped)
macOS shell launches and runs 90s+
Not covered here
The mic tap was the one installTap site, so this closes that specific abort. It does not make AVFAudio's other raising APIs (connect, attach, converter setup) non-fatal — Swift still cannot catch those, and doing so needs an Objective-C @try shim. Worth a follow-up if more _AVAE_CheckAndReturnErr aborts show up in the reports.
Found chasing a reported "Apple TV crashes on launch". There is no tvOS crash — the reports are macOS, and this one is not at launch but at **session start**.
## The crash
```
Exception Type: EXC_CRASH (SIGABRT)
AVAudioNode installTapOnBus:bufferSize:format:error:
→ _AVAE_CheckAndReturnErr → +[NSException raise:format:] → abort
SessionAudio.installMicTap(on:micUID:micChannel:) SessionAudio.swift:1536
SessionAudio.startCapture(micUID:micChannel:) SessionAudio.swift:1391
SessionAudio.startEngines(...) SessionAudio.swift:450
```
Reported against 0.31.0 (14460) on macOS 27.0.
## Why
`installTap(onBus:bufferSize:format:)` validates a **non-nil** format against the bus and raises an Objective-C exception on any mismatch. Swift cannot catch that, so it reaches the terminate handler and aborts the process — the whole app, for a microphone that failed to attach.
The format we hand it is necessarily read a moment earlier (`input.outputFormat(forBus: 0)`), and on macOS the input can move underneath it in that window:
- the default input device changes (`AudioDeviceWatcher` exists precisely because this happens mid-session),
- the device changes clock/rate,
- or the `kAudioOutputUnitProperty_CurrentDevice` swap that `startCapture` itself performs **two lines before** this call.
The existing guard only rejected `sampleRate == 0 || channelCount == 0`. That is a device that is *absent* — a different failure from one that *changed*, which is what the crash is.
## The fix
Install with `format: nil`, which is the documented "use the bus's own format". The mismatch becomes unrepresentable rather than merely unlikely — there is no snapshot left to go stale.
The tap then has to follow the real format. The rate-dependent pieces (mono bus, resampler, and both scratch buffers — they are only ever valid as a set) move into a `MicChain` that the tap rebuilds when `buffer.format.sampleRate` differs from what the chain was built for. A chain pinned to a stale rate would resample by the wrong ratio and pitch-shift the mic, so following the format is not optional once the tap is installed with nil.
That rebuild subsumes the old grow-on-larger-quantum branch (same trigger shape, one condition wider). The steady state still allocates nothing.
Channel count and interleaving already came from `buffer.format` inside the tap, and `foldToMono` already range-checks the pinned channel per call, so those need no change.
## Tests
`AudioMicChainTests` — 4 cases over the sizing arithmetic the rebuild rests on, no engine, device or mic grant required.
Worth calling out one: `staging` holds the *resampled* 48 kHz mono, so it must fit the **upward** ratio. Sized for the input rate instead, it silently truncates every packet on any device below 48 kHz — a quiet-mic bug rather than a crash, so nothing else would have caught it.
## Gates
- `swift build` clean
- full suite: **384 tests, 0 failures** (8 skipped)
- macOS shell launches and runs 90s+
## Not covered here
The mic tap was the one `installTap` site, so this closes that specific abort. It does not make AVFAudio's *other* raising APIs (`connect`, `attach`, converter setup) non-fatal — Swift still cannot catch those, and doing so needs an Objective-C `@try` shim. Worth a follow-up if more `_AVAE_CheckAndReturnErr` aborts show up in the reports.
`installTap(onBus:bufferSize:format:)` validates a non-nil format against the
bus and raises an Objective-C exception on ANY mismatch. Swift cannot catch
that, so it reached the terminate handler and aborted the process — SIGABRT in
`AVAudioEngineGraph::InstallTapOnNode`, crashing macOS 0.31.0 at session start,
not at launch.
The format handed to the tap is necessarily read a moment earlier
(`input.outputFormat(forBus: 0)`), and on macOS the input can move underneath
it in that window: a device switch, a clock/rate change, or the
`kAudioOutputUnitProperty_CurrentDevice` swap `startCapture` itself performs two
lines before. The existing guard only rejected the 0 Hz / 0-channel case, which
is a different failure — a device that is absent, not one that changed.
Install with `format: nil` instead, which is the documented "use the bus's own
format" and makes the mismatch unrepresentable rather than merely unlikely.
The tap then has to follow the real format, so the rate-dependent pieces (mono
bus, resampler, both scratch buffers) move into a `MicChain` the tap rebuilds
when `buffer.format.sampleRate` differs — a chain pinned to a stale rate would
resample by the wrong ratio and pitch-shift the mic. That rebuild subsumes the
old grow-on-larger-quantum branch; the steady state still allocates nothing.
Tests cover the sizing arithmetic the rebuild rests on — including that
`staging` fits the UPWARD ratio, which silently truncates every packet on any
device below 48 kHz if it is sized for the input rate instead.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Found chasing a reported "Apple TV crashes on launch". There is no tvOS crash — the reports are macOS, and this one is not at launch but at session start.
The crash
Reported against 0.31.0 (14460) on macOS 27.0.
Why
installTap(onBus:bufferSize:format:)validates a non-nil format against the bus and raises an Objective-C exception on any mismatch. Swift cannot catch that, so it reaches the terminate handler and aborts the process — the whole app, for a microphone that failed to attach.The format we hand it is necessarily read a moment earlier (
input.outputFormat(forBus: 0)), and on macOS the input can move underneath it in that window:AudioDeviceWatcherexists precisely because this happens mid-session),kAudioOutputUnitProperty_CurrentDeviceswap thatstartCaptureitself performs two lines before this call.The existing guard only rejected
sampleRate == 0 || channelCount == 0. That is a device that is absent — a different failure from one that changed, which is what the crash is.The fix
Install with
format: nil, which is the documented "use the bus's own format". The mismatch becomes unrepresentable rather than merely unlikely — there is no snapshot left to go stale.The tap then has to follow the real format. The rate-dependent pieces (mono bus, resampler, and both scratch buffers — they are only ever valid as a set) move into a
MicChainthat the tap rebuilds whenbuffer.format.sampleRatediffers from what the chain was built for. A chain pinned to a stale rate would resample by the wrong ratio and pitch-shift the mic, so following the format is not optional once the tap is installed with nil.That rebuild subsumes the old grow-on-larger-quantum branch (same trigger shape, one condition wider). The steady state still allocates nothing.
Channel count and interleaving already came from
buffer.formatinside the tap, andfoldToMonoalready range-checks the pinned channel per call, so those need no change.Tests
AudioMicChainTests— 4 cases over the sizing arithmetic the rebuild rests on, no engine, device or mic grant required.Worth calling out one:
stagingholds the resampled 48 kHz mono, so it must fit the upward ratio. Sized for the input rate instead, it silently truncates every packet on any device below 48 kHz — a quiet-mic bug rather than a crash, so nothing else would have caught it.Gates
swift buildcleanNot covered here
The mic tap was the one
installTapsite, so this closes that specific abort. It does not make AVFAudio's other raising APIs (connect,attach, converter setup) non-fatal — Swift still cannot catch those, and doing so needs an Objective-C@tryshim. Worth a follow-up if more_AVAE_CheckAndReturnErraborts show up in the reports.