M1-05: Audio capture core — one continuous AAC file #17

Merged
wk merged 3 commits from m1-05-audio-capture into main 2026-08-06 15:36:17 -04:00
Member

Issue #5. Records one continuous AAC file that survives the app being killed.

The decision the issue asked me to verify first

You flagged AVAssetWriter.movieFragmentInterval as asserted from memory. Apple documents it:

Using this feature enables you to open and play a partially written movie in the event that an unexpected error or interruption occurs.

That paragraph names QuickTime as the example container and this needs .m4a, so I measured rather than inferred — a throwaway probe writing 20 seconds and never calling finishWriting():

Result
No movieFragmentInterval Unreadable — "Cannot Open"
movieFragmentInterval = 5s Playable, 17.04s recovered

The shortfall is the tail since the last flush, which is what the interval bounds. Apple's 10-second guidance applies to external storage; this writes to internal flash, so 5 seconds buys a shorter tail. The value cannot be changed once writing starts.

What changed

Stash/Capture/:

  • CaptureNaming.swiftYYYY-MM-DD-HHMMSS-HASH_audio.m4a, carried over byte for byte, stamped with the original capture time rather than the finalise time. en_US_POSIX on the formatter: a non-Gregorian default calendar renders the year as 2569, and nobody testing in English would ever see it.
  • AudioFileSink.swift — AAC mono 48 kbps into the fragmented container. Runs on the audio tap's thread; an NSLock covers the one overlap with stop, since removeTap does not promise an in-flight callback has returned. The locked half of finish() is split out because NSLock may not be held across a suspension point.
  • AudioRecorder.swift — session, permission, engine, lifecycle. The tap is installed from a nonisolated function — see below.
  • DebugCaptureView.swift — reachable from Settings. Not the real UI, and says so; the memo list's record button is still the design stub from #3.

project.yml gains NSMicrophoneUsageDescription.

A filename is claimed, not checked — exclusive create, redraw on collision. Check-then-write has a window, and losing it is one recording silently overwriting another.

The crash worth reviewing

The first version crashed instantly on Record with BUG IN CLIENT OF LIBDISPATCH: Block was expected to execute on queue [com.apple.main-thread]. installTap takes a plain non-Sendable block, so a closure written inline in a @MainActor method inherits main-actor isolation — it compiles, then the render thread traps. Installing from a nonisolated static function fixes it. Written into AGENTS.md, because the next audio callback has the same trap waiting and the compiler will not mention it.

Validated

On hardware (wes-iphone), by Wes: recording works, playback is correct, the force-quit test passes, and swapping microphones mid-session is handled cleanly.

In the simulator, killing the process with SIGKILL mid-recording: playable, 20.05s recovered, 47 kbps, AAC mono 48 kHz. Projects to ~16 MB for 45 minutes. make lint clean at --strict, 11 files.

Not validated

There are no tests — no test target exists in this repository yet, so everything above was verified by hand exactly once and nothing guards it against regression. Also untested: recordings longer than a minute (the case the design exists for), backgrounding and screen lock (no background audio mode is declared, so capture almost certainly stops when the app leaves the foreground), call interruption, and storage filling mid-recording.

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

One thing outside this issue

The issue points at a format spec in stash-docs. That repository is empty — a README and a LICENSE. I took the naming rules from CaptureNaming.swift in the archived v2, which is where they and their reasoning actually live. Worth its own issue; I did not create one.

Issue #5 stays open after this merges.

Issue #5. Records one continuous AAC file that survives the app being killed. ## The decision the issue asked me to verify first You flagged `AVAssetWriter.movieFragmentInterval` as asserted from memory. Apple documents it: > Using this feature enables you to open and play a partially written movie in the event that an unexpected error or interruption occurs. That paragraph names QuickTime as the example container and this needs `.m4a`, so I measured rather than inferred — a throwaway probe writing 20 seconds and never calling `finishWriting()`: | | Result | |---|---| | No `movieFragmentInterval` | **Unreadable** — "Cannot Open" | | `movieFragmentInterval = 5s` | **Playable**, 17.04s recovered | The shortfall is the tail since the last flush, which is what the interval bounds. Apple's 10-second guidance applies to external storage; this writes to internal flash, so 5 seconds buys a shorter tail. The value cannot be changed once writing starts. ## What changed `Stash/Capture/`: - **`CaptureNaming.swift`** — `YYYY-MM-DD-HHMMSS-HASH_audio.m4a`, carried over byte for byte, stamped with the original capture time rather than the finalise time. `en_US_POSIX` on the formatter: a non-Gregorian default calendar renders the year as 2569, and nobody testing in English would ever see it. - **`AudioFileSink.swift`** — AAC mono 48 kbps into the fragmented container. Runs on the audio tap's thread; an `NSLock` covers the one overlap with stop, since `removeTap` does not promise an in-flight callback has returned. The locked half of `finish()` is split out because `NSLock` may not be held across a suspension point. - **`AudioRecorder.swift`** — session, permission, engine, lifecycle. **The tap is installed from a `nonisolated` function** — see below. - **`DebugCaptureView.swift`** — reachable from Settings. Not the real UI, and says so; the memo list's record button is still the design stub from #3. `project.yml` gains `NSMicrophoneUsageDescription`. **A filename is claimed, not checked** — exclusive create, redraw on collision. Check-then-write has a window, and losing it is one recording silently overwriting another. ## The crash worth reviewing The first version crashed instantly on Record with `BUG IN CLIENT OF LIBDISPATCH: Block was expected to execute on queue [com.apple.main-thread]`. `installTap` takes a plain non-`Sendable` block, so a closure written inline in a `@MainActor` method inherits main-actor isolation — it compiles, then the render thread traps. Installing from a `nonisolated static` function fixes it. Written into `AGENTS.md`, because the next audio callback has the same trap waiting and the compiler will not mention it. ## Validated **On hardware** (`wes-iphone`), by Wes: recording works, playback is correct, the force-quit test passes, and swapping microphones mid-session is handled cleanly. **In the simulator**, killing the process with SIGKILL mid-recording: playable, 20.05s recovered, 47 kbps, AAC mono 48 kHz. Projects to ~16 MB for 45 minutes. `make lint` clean at `--strict`, 11 files. ## Not validated **There are no tests** — no test target exists in this repository yet, so everything above was verified by hand exactly once and nothing guards it against regression. Also untested: recordings longer than a minute (the case the design exists for), backgrounding and screen lock (no background audio mode is declared, so capture almost certainly stops when the app leaves the foreground), call interruption, and storage filling mid-recording. CI has not run this; the simulator build workflow still has no macOS agent. ## One thing outside this issue The issue points at a format spec in `stash-docs`. **That repository is empty** — a README and a LICENSE. I took the naming rules from `CaptureNaming.swift` in the archived v2, which is where they and their reasoning actually live. Worth its own issue; I did not create one. Issue #5 stays open after this merges.
AVAudioEngine tap feeding AVAssetWriter, AAC mono at 48 kbps, into a single
.m4a with movieFragmentInterval set to 5 seconds.

The fragmentation claim was checked before anything was built on it, because
the issue asked for that and because it is the whole design. Apple documents
the mechanism but names QuickTime as the example container, and this needs
.m4a, so it was measured rather than inferred: 20 seconds written with
finishWriting() never called gives an unreadable file with fragments off, and
a playable 17-second file with them on. The missing tail is the span since the
last flush, which is exactly what the interval bounds.

Not chunked. Chunking would bound the same loss but drops samples where one
writer stops and the next starts, and owes a concatenation step at stop —
another thing that can fail at the moment it matters most.

Filenames carry over from the shipped Stash exactly, stamped with the original
capture time rather than the finalise time. The name is claimed by creating the
file exclusively rather than by checking and then writing; the gap between
those two is how one recording silently destroys another.

Capture is reachable from Settings as a debug harness with a ShareLink to get
files off the phone. It is not the real UI — the record button on the memo list
is still the design stub from issue 03.
Tapping Record crashed the app instantly with SIGTRAP:

  BUG IN CLIENT OF LIBDISPATCH: Assertion failed: Block was expected to
  execute on queue [com.apple.main-thread]

installTap takes a plain non-Sendable block, so the closure written inline
inside AudioRecorder.start() — a @MainActor method — inherited main-actor
isolation. The compiler accepted it and the audio render thread trapped on the
first buffer. Installing the tap from a nonisolated static function leaves the
closure with no isolation to inherit.

Also: a failed start left a zero-byte .m4a behind, which showed up in the
recordings list looking like a memo that had lost its audio. The failure path
now removes it, tracking the pending URL in a local rather than reading it back
off currentFileURL — that property still holds the PREVIOUS recording at that
point, so cleaning up through it would have deleted the wrong file.

Verified by driving capture in the simulator and killing the process
mid-recording: 20.05s recovered from a file that never had finishWriting()
called, 47 kbps, AAC mono 48 kHz, and no stray zero-byte file.
Backgrounding the app stopped the audio while the UI kept showing Stop
Recording, so a memo silently ended and nothing said so.

The fix is not the one it looks like. Backgrounding an app that has no
background audio mode posts NO AVAudioSession interruption at all, and
AVAudioEngine.isRunning keeps returning true long after the tap has stopped
delivering buffers — both measured with a throwaway probe rather than assumed.
An interruption observer alone would have changed nothing. The only signal
available is UIApplication.didEnterBackgroundNotification.

On losing the microphone the recording is finalised, so the audio up to that
point keeps a proper index, and the message says it was saved before it says
why it stopped — 'did I lose it' is the only question that matters in the
moment.

The interruption observer is wired up as well, for calls and Siri. It is not
verified; nothing here has taken a phone call. It can only end a recording that
has already lost its microphone, so it cannot make things worse.

Second bug from the same test: the debug list refreshed only when the stop
button was tapped, so a recording the recorder ended by itself was saved to
disk and shown as 'None yet' directly under a message saying it was saved. It
now refreshes off the recorder's own state.

This does NOT make background recording work. That needs the background audio
mode and belongs to its own issue.
wk merged commit 95f43e0159 into main 2026-08-06 15:36:17 -04:00
wk deleted branch m1-05-audio-capture 2026-08-06 15:36:17 -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!17
No description provided.