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

Closed
opened 2026-08-06 10:53:40 -04:00 by agent · 2 comments
Member

Record with the phone locked, and stop losing recordings to audio-route and
interruption events. This issue is the single biggest reliability complaint about v2.

Wes, on what M1 has to be: "It handles microphone grabbing beautifully. It doesn't crash
like the other one, or lock up, like the other one."

The three known failure modes, all observed and documented

1 — The app holds a stale microphone instead of probing the current default. From the
2026-07-30 memo: recorded on AirPods, backgrounded the app, used the phone for an hour
without AirPods, reconnected them, started a new recording. Wes's read: "The app is not
probing to see what the current default settings are... maybe it has like a previous grab on
the mic and speakers from the AirPods."

2 — Swapping to wired headphones produced a recording of nothing. Same day: AirPods put
away, wired headphones plugged in, hit record. The recording toast appeared, nothing was
captured
, a warning about failing to grab the mic appeared, and on hitting stop the whole
app froze on "saving" — for what was four seconds of silence, one or two kilobytes.

3 — A declined phone call ended the recording. 2026-08-06: "this stops recording, if I
get a phone call... I didn't answer it. I declined it. But the recording had already
stopped."
This one forced the morning's scoping memo to be split into two files.

Scope

  • Background audio capability and the correct AVAudioSession category and options.
  • Route-change handling — observe AVAudioSession.routeChangeNotification and follow the
    current default input rather than caching one. Ideally the switch is seamless mid-recording,
    the way the system phone app hands off; if that is not achievable, it must at minimum not
    silently record nothing.
  • Interruption handlingAVAudioSession.interruptionNotification. An interruption that
    ends without the user taking the call must resume the recording, not terminate it.
  • Never freeze on save. Finalization is async and the UI stays responsive.

Done when — Wes validates all four on the device

  • Start recording, lock the phone, walk for several minutes, unlock — still recording.
  • Decline an incoming call mid-recording — recording continues.
  • Swap AirPods for wired headphones mid-session — audio is still captured.
  • Background the app for an hour, reconnect a different input, record — audio is captured.

Milestone 1 of the Stash v3 rebuild, scoped 2026-08-06. M1 is a functioning iOS app on Wes's
phone with no sync at all — the entire goal is recording voice memos reliably and getting
them off the device by hand. Sync arrives in M3.

Working agreement for every issue in this repo: feature branch, tested before the PR opens
(on-device where the label says so), then Wes reviews the PR and we walk the code together.
Docs update in the same commit. A PR that takes more than 20 minutes to review is too big —
say so and split it.

Record with the phone locked, and stop losing recordings to audio-route and interruption events. **This issue is the single biggest reliability complaint about v2.** Wes, on what M1 has to be: *"It handles microphone grabbing beautifully. It doesn't crash like the other one, or lock up, like the other one."* ## The three known failure modes, all observed and documented **1 — The app holds a stale microphone instead of probing the current default.** From the 2026-07-30 memo: recorded on AirPods, backgrounded the app, used the phone for an hour without AirPods, reconnected them, started a new recording. Wes's read: *"The app is not probing to see what the current default settings are... maybe it has like a previous grab on the mic and speakers from the AirPods."* **2 — Swapping to wired headphones produced a recording of nothing.** Same day: AirPods put away, wired headphones plugged in, hit record. The recording toast appeared, **nothing was captured**, a warning about failing to grab the mic appeared, and on hitting stop the whole app **froze on "saving"** — for what was four seconds of silence, one or two kilobytes. **3 — A declined phone call ended the recording.** 2026-08-06: *"this stops recording, if I get a phone call... I didn't answer it. I declined it. But the recording had already stopped."* This one forced the morning's scoping memo to be split into two files. ## Scope - Background audio capability and the correct `AVAudioSession` category and options. - **Route-change handling** — observe `AVAudioSession.routeChangeNotification` and follow the current default input rather than caching one. Ideally the switch is seamless mid-recording, the way the system phone app hands off; if that is not achievable, it must at minimum not silently record nothing. - **Interruption handling** — `AVAudioSession.interruptionNotification`. An interruption that ends without the user taking the call must resume the recording, not terminate it. - Never freeze on save. Finalization is async and the UI stays responsive. ## Done when — Wes validates all four on the device - [ ] Start recording, lock the phone, walk for several minutes, unlock — still recording. - [ ] **Decline an incoming call mid-recording — recording continues.** - [ ] Swap AirPods for wired headphones mid-session — audio is still captured. - [ ] Background the app for an hour, reconnect a different input, record — audio is captured. --- *Milestone 1 of the Stash v3 rebuild, scoped 2026-08-06. M1 is a functioning iOS app on Wes's phone with **no sync at all** — the entire goal is recording voice memos reliably and getting them off the device by hand. Sync arrives in M3.* *Working agreement for every issue in this repo: feature branch, tested before the PR opens (on-device where the label says so), then Wes reviews the PR and we walk the code together. Docs update in the same commit. A PR that takes more than 20 minutes to review is too big — say so and split it.*
Author
Member

Branch m1-06-background-audio, one commit, installed on your phone. No PR — this is needs-hardware and three of the four done-when boxes need you and real hardware.

The route-change fix is structural, not a handler

Failure mode 2 — swap headphones, record nothing — was a design fault. The writer used to be configured from whatever format the input node reported at start, which pins the whole recording to the first microphone connected. A new route arrives at a different sample rate, every buffer is rejected, and you get a file of silence.

AudioFileSink now writes one fixed format, 48 kHz mono, and converts everything into it. A route change costs a new AVAudioConverter 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 now 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 you started deliberately is not other audio.

Timestamps changed too, and this one is easy to miss in review. They now count frames written rather than reading 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.

A trap worth knowing about

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 everywhere except in the one file that decides it. I caught it by checking the built plist rather than the build setting. project.yml now generates a real Info.plist. This is in AGENTS.md: check plutil -p …/Stash.app/Info.plist, not the setting.

What I validated

  • Background recording works. Simulator: started recording, backgrounded the app for 15 seconds, came back. Still recording, and the resulting file was 95.04s of continuous audio for ~95s of wall clock — no gap, no drift at this rate.
  • UIBackgroundModes: [audio] confirmed present in the built plist, for both the simulator and the device build.
  • Resampling, with a throwaway 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. Every input format converts, amplitude preserved:
Input Output Peak
16 kHz mono (Bluetooth HFP — AirPods) 48 kHz mono 0.500 in → 0.500 out
8 kHz mono (narrowband HFP) 48 kHz mono preserved
44.1 kHz mono / stereo (wired) 48 kHz mono preserved
48 kHz stereo 48 kHz mono preserved
  • make lint clean, builds, installed and confirmed running on wes-iphone.
  • New: the debug view names the current microphone while recording, read from the session's route rather than remembered.

What I did NOT validate

Three of your four done-when boxes. I cannot swap AirPods, take a call, or lock your phone:

  • Lock the phone, walk several minutes, unlock — still recording. (Only 15 seconds backgrounded in a simulator, never locked, never on a real device.)
  • Decline a call mid-recording. Completely untested — nothing here has taken a phone call. The interruption code is reasoned from the documented notification order, not observed.
  • Swap AirPods for wired headphones mid-session. The conversion is proven in isolation; the actual switch — engine rebuild, timing, whether audio is lost across it — is not.
  • Background an hour, reconnect a different input, record.

Two more gaps worth naming:

  • Drift is only checked at the identity rate. 95s wall clock gave 95.04s of audio at 48 kHz in, where the converter does nothing. Whether a 40-minute AirPods recording ends up 40 minutes long is unverified, and a slow drift is exactly the kind of thing that would not show up in a short test. If a long memo comes out noticeably short, that is where to look.
  • "Never freeze on save"finishWriting() is awaited rather than blocked on, so the main thread is not held, but I have not reproduced v2's freeze to confirm this is the same code path that caused it.

Worth doing the long walk test before this merges, since that is the one M1 exists for.

Branch `m1-06-background-audio`, one commit, **installed on your phone**. No PR — this is `needs-hardware` and three of the four done-when boxes need you and real hardware. ## The route-change fix is structural, not a handler Failure mode 2 — swap headphones, record nothing — was a design fault. The writer used to be configured from whatever format the input node reported at start, which pins the whole recording to the first microphone connected. A new route arrives at a different sample rate, every buffer is rejected, and you get a file of silence. `AudioFileSink` now writes **one fixed format, 48 kHz mono**, and converts everything into it. A route change costs a new `AVAudioConverter` 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 now 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 you started deliberately is not other audio. **Timestamps changed too, and this one is easy to miss in review.** They now count frames written rather than reading `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. ## A trap worth knowing about `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 everywhere except in the one file that decides it. I caught it by checking the built plist rather than the build setting. `project.yml` now generates a real `Info.plist`. This is in `AGENTS.md`: check `plutil -p …/Stash.app/Info.plist`, not the setting. ## What I validated - **Background recording works.** Simulator: started recording, backgrounded the app for 15 seconds, came back. Still recording, and the resulting file was **95.04s of continuous audio for ~95s of wall clock** — no gap, no drift at this rate. - **`UIBackgroundModes: [audio]`** confirmed present in the built plist, for both the simulator and the device build. - **Resampling**, with a throwaway 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. Every input format converts, amplitude preserved: | Input | Output | Peak | |---|---|---| | 16 kHz mono (Bluetooth HFP — AirPods) | 48 kHz mono | 0.500 in → 0.500 out | | 8 kHz mono (narrowband HFP) | 48 kHz mono | preserved | | 44.1 kHz mono / stereo (wired) | 48 kHz mono | preserved | | 48 kHz stereo | 48 kHz mono | preserved | - `make lint` clean, builds, installed and confirmed running on `wes-iphone`. - **New:** the debug view names the current microphone while recording, read from the session's route rather than remembered. ## What I did NOT validate **Three of your four done-when boxes.** I cannot swap AirPods, take a call, or lock your phone: - [ ] Lock the phone, walk several minutes, unlock — still recording. *(Only 15 seconds backgrounded in a simulator, never locked, never on a real device.)* - [ ] Decline a call mid-recording. **Completely untested** — nothing here has taken a phone call. The interruption code is reasoned from the documented notification order, not observed. - [ ] Swap AirPods for wired headphones mid-session. The conversion is proven in isolation; the actual switch — engine rebuild, timing, whether audio is lost across it — is not. - [ ] Background an hour, reconnect a different input, record. Two more gaps worth naming: - **Drift is only checked at the identity rate.** 95s wall clock gave 95.04s of audio at 48 kHz in, where the converter does nothing. Whether a 40-minute AirPods recording ends up 40 minutes long is unverified, and a slow drift is exactly the kind of thing that would not show up in a short test. If a long memo comes out noticeably short, that is where to look. - **"Never freeze on save"** — `finishWriting()` is awaited rather than blocked on, so the main thread is not held, but I have not reproduced v2's freeze to confirm this is the same code path that caused it. Worth doing the long walk test before this merges, since that is the one M1 exists for.
Author
Member

Device pass, from Wes on wes-iphone: started on the built-in microphone, plugged in wired headphones mid-recording, backgrounded the app, reopened it — all worked, and the microphone display tracked the route correctly throughout.

That covers two of the four boxes:

  • Swap microphones mid-session — audio still captured. Verified on device.
  • Background the app and come back — still recording. Verified on device, over a short interval rather than an hour.
  • Lock the phone and walk for several minutes. Not yet.
  • Decline a call mid-recording. Not yet — Wes is testing this later today with a second phone. If it fails it gets its own issue.

PR is open on that basis. The call path remains the one piece of this issue with no evidence behind it: it is reasoned from the documented .began/.ended order, and nothing here has taken a phone call.

Also still open, and worth repeating because a short test cannot show it: drift is only measured where the converter is an identity. A 40-minute recording over Bluetooth could come out short without anything looking wrong along the way.

Device pass, from Wes on `wes-iphone`: started on the built-in microphone, plugged in wired headphones mid-recording, backgrounded the app, reopened it — all worked, and the microphone display tracked the route correctly throughout. That covers two of the four boxes: - [x] Swap microphones mid-session — audio still captured. **Verified on device.** - [x] Background the app and come back — still recording. **Verified on device**, over a short interval rather than an hour. - [ ] Lock the phone and walk for several minutes. Not yet. - [ ] **Decline a call mid-recording.** Not yet — Wes is testing this later today with a second phone. If it fails it gets its own issue. PR is open on that basis. The call path remains the one piece of this issue with **no** evidence behind it: it is reasoned from the documented `.began`/`.ended` order, and nothing here has taken a phone call. Also still open, and worth repeating because a short test cannot show it: drift is only measured where the converter is an identity. A 40-minute recording over Bluetooth could come out short without anything looking wrong along the way.
wk closed this issue 2026-08-06 15:55:48 -04:00
Sign in to join this conversation.
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#6
No description provided.