M1-06: Background recording and audio session hardening #18

Merged
wk merged 1 commit from m1-06-background-audio into main 2026-08-06 15:55:47 -04:00
Member

Issue #6. Background recording, route-change handling, and interruption resume.

The route-change fix is structural, not a handler

The writer used to be configured from whatever format the input node reported at start, which pins the recording to the first microphone connected. A new route arrives at a different sample rate, every buffer is rejected, and the result is a file of silence — failure mode 2 in the issue, and a design fault rather than a missing observer.

AudioFileSink now writes one fixed format, 48 kHz mono, and converts everything into it with an AVAudioConverter. A route change costs a new converter and nothing else; the writer never learns anything happened, so one memo stays one file across as many microphones as it likes.

Failure mode 1 (stale microphone) falls out of the same change — the input format is read fresh from the current route on every rebuild instead of captured once and trusted.

Failure mode 3 (declined call) — .began/.ended is always resumed, never treated as a stop. .ended is acted on whether or not it carries shouldResume: that option is advice about other audio politely resuming, and a memo started deliberately is not other audio.

Two changes that are easy to miss in review

Timestamps count frames written, not AVAudioTime.sampleTime. The audio clock does not advance while an interruption holds the engine, so clock-derived timestamps leave a silent gap the length of the call — and restarting the engine can reset it entirely, producing timestamps that run backwards and a file no player will open. Counting frames splices the interruption out and cannot go backwards.

UIBackgroundModes needed a real Info.plist. INFOPLIST_KEY_UIBackgroundModes lands in the pbxproj and Xcode never writes it into the plist, so background audio read as configured while being absent from the only file that decides it. Caught by checking the built plist rather than the build setting. project.yml now generates Stash/Info.plist from info.properties, which is gitignored like the project itself. AGENTS.md records the check: plutil -p …/Stash.app/Info.plist.

Losing the microphone no longer ends a recording either — the file stays open, isInterrupted says so, and the next route change gets another attempt.

Also adds the current microphone to the debug view while recording, read from the session's route rather than remembered. v2's worst failure was recording from a microphone nobody knew it still held.

Validated

On device by Wes: started on the built-in microphone, plugged in wired headphones mid-recording, backgrounded the app, reopened. All worked, and the microphone display tracked the route correctly.

In the simulator: backgrounded 15 seconds mid-recording and got 95.04s of continuous audio for ~95s of wall clock — no gap, no drift at that rate. UIBackgroundModes: [audio] confirmed in both the simulator and device built plists.

Resampling, by probe, because the simulator's input is already 48 kHz mono and the converter is an identity there — proving nothing about the case that matters. 16 kHz mono (Bluetooth HFP), 8 kHz, 44.1 kHz mono and stereo, and 48 kHz stereo all convert with amplitude preserved.

make lint clean at --strict.

Not validated

Declining a call has no evidence behind it at all. The interruption path is reasoned from the documented notification order; nothing here has taken a phone call. Wes is testing it later today with a second phone, and it gets its own issue if it fails.

  • The locked-phone walk. Backgrounding was verified over seconds, not minutes, and never with the screen locked.
  • Backgrounding for an hour and reconnecting a different input.
  • Drift is only measured at the identity rate. Whether a 40-minute Bluetooth recording ends up 40 minutes long is unverified, and slow drift would not show in a short test.
  • "Never freeze on save" — finishWriting() is awaited rather than blocked on, so the main thread is not held, but v2's freeze was never reproduced to confirm this is the same path.
  • Still no test target in this repository, so none of the above is guarded against regression.

CI has not run this; the simulator build workflow still has no macOS agent.

Issue #6 stays open after this merges — two done-when boxes are still unticked.

Issue #6. Background recording, route-change handling, and interruption resume. ## The route-change fix is structural, not a handler The writer used to be configured from whatever format the input node reported at start, which pins the recording to the first microphone connected. A new route arrives at a different sample rate, every buffer is rejected, and the result is a file of silence — **failure mode 2 in the issue, and a design fault rather than a missing observer.** `AudioFileSink` now writes **one fixed format, 48 kHz mono**, and converts everything into it with an `AVAudioConverter`. A route change costs a new converter and nothing else; the writer never learns anything happened, so one memo stays one file across as many microphones as it likes. **Failure mode 1** (stale microphone) falls out of the same change — the input format is read fresh from the current route on every rebuild instead of captured once and trusted. **Failure mode 3** (declined call) — `.began`/`.ended` is always resumed, never treated as a stop. `.ended` is acted on whether or not it carries `shouldResume`: that option is advice about *other* audio politely resuming, and a memo started deliberately is not other audio. ## Two changes that are easy to miss in review **Timestamps count frames written, not `AVAudioTime.sampleTime`.** The audio clock does not advance while an interruption holds the engine, so clock-derived timestamps leave a silent gap the length of the call — and restarting the engine can reset it entirely, producing timestamps that run backwards and a file no player will open. Counting frames splices the interruption out and cannot go backwards. **`UIBackgroundModes` needed a real Info.plist.** `INFOPLIST_KEY_UIBackgroundModes` lands in the pbxproj and Xcode never writes it into the plist, so background audio read as configured while being absent from the only file that decides it. Caught by checking the built plist rather than the build setting. `project.yml` now generates `Stash/Info.plist` from `info.properties`, which is gitignored like the project itself. `AGENTS.md` records the check: `plutil -p …/Stash.app/Info.plist`. Losing the microphone no longer ends a recording either — the file stays open, `isInterrupted` says so, and the next route change gets another attempt. Also adds the current microphone to the debug view while recording, read from the session's route rather than remembered. v2's worst failure was recording from a microphone nobody knew it still held. ## Validated **On device by Wes:** started on the built-in microphone, plugged in wired headphones mid-recording, backgrounded the app, reopened. All worked, and the microphone display tracked the route correctly. **In the simulator:** backgrounded 15 seconds mid-recording and got 95.04s of continuous audio for ~95s of wall clock — no gap, no drift at that rate. `UIBackgroundModes: [audio]` confirmed in both the simulator and device built plists. **Resampling, by probe**, because the simulator's input is already 48 kHz mono and the converter is an identity there — proving nothing about the case that matters. 16 kHz mono (Bluetooth HFP), 8 kHz, 44.1 kHz mono and stereo, and 48 kHz stereo all convert with amplitude preserved. `make lint` clean at `--strict`. ## Not validated **Declining a call has no evidence behind it at all.** The interruption path is reasoned from the documented notification order; nothing here has taken a phone call. Wes is testing it later today with a second phone, and it gets its own issue if it fails. - The locked-phone walk. Backgrounding was verified over seconds, not minutes, and never with the screen locked. - Backgrounding for an hour and reconnecting a different input. - **Drift is only measured at the identity rate.** Whether a 40-minute Bluetooth recording ends up 40 minutes long is unverified, and slow drift would not show in a short test. - "Never freeze on save" — `finishWriting()` is awaited rather than blocked on, so the main thread is not held, but v2's freeze was never reproduced to confirm this is the same path. - Still no test target in this repository, so none of the above is guarded against regression. CI has not run this; the simulator build workflow still has no macOS agent. Issue #6 stays open after this merges — two done-when boxes are still unticked.
Records with the phone locked, follows the microphone across route changes, and
resumes after an interruption instead of ending.

The route-change fix is structural rather than a handler. AudioFileSink now
writes one fixed format — 48 kHz mono — and converts everything into it. The old
version configured the writer from whatever the input node reported at start,
which pinned the recording to the first microphone connected: swap AirPods for
wired headphones, the new route arrives at a different sample rate, every buffer
is rejected, and you get a recording of nothing. That is failure mode 2 in the
issue, and it was a design fault, not a missing observer.

Presentation timestamps now count frames written rather than reading
AVAudioTime.sampleTime. The audio clock does not advance while an interruption
holds the engine, so timestamps taken from it leave a silent gap the length of
the call, and restarting the engine can reset it entirely and produce timestamps
that run backwards. Counting frames splices the interruption out.

Route changes and interruptions rebuild the AVAudioEngine graph and leave the
sink alone, so a memo stays one file across as many microphones as it likes. An
interruption that ends is always resumed; treating .began/.ended as a stop is
why declining a call split a v2 memo in two.

Losing the microphone no longer ends the recording. The file stays open, the
state says so, and the next route change gets another attempt.

UIBackgroundModes could not be set the way it looked like it could.
INFOPLIST_KEY_UIBackgroundModes lands in the pbxproj and Xcode never writes it
into the Info.plist, so background audio read as configured while being absent
from the file that decides it — caught by checking the built plist rather than
the build setting. project.yml now generates a real Info.plist.

Also adds the current microphone to the debug view while recording, read from
the session's route rather than remembered. v2's worst failure was recording
from a microphone nobody knew it still held.
wk merged commit 2f49b0d5cb into main 2026-08-06 15:55:47 -04:00
wk deleted branch m1-06-background-audio 2026-08-06 15:55:48 -04:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Stash/stash-ios!18
No description provided.