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

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

Record audio to a single continuous file that survives the app being killed.

The decision, and the reason

Audio is not chunked. Transcripts are chunked so the vault agent can read them mid-walk;
audio is not, because it is not synced mid-walk and there is nothing to gain.

The problem chunking would have solved is real: an .m4a is an MPEG-4 container whose index
is written at the end of the file on finalize. If iOS kills the app mid-recording — memory
pressure, watchdog, crash — the result is a file with audio in it that nothing will play.
This is likely related to the "saving" freeze observed on 2026-07-30.

Chunking bounds that loss to a minute but introduces a seam problem: stopping one writer and
starting another can drop samples at the boundary, giving a memo with a click and a missing
syllable every sixty seconds. It also owes a concatenation step at stop — another thing that
can fail at exactly the moment it matters most.

So: one continuous file, written in a truncation-tolerant way. AVAssetWriter with
movieFragmentInterval set flushes a playable index periodically, so a recording killed at
minute 40 still yields 40 minutes of audio. No seams, no concat, no chunk bookkeeping.

⚠️ Verify the current AVAssetWriter fragmented-MP4 behaviour against Apple's docs before
building on it.
This mechanism is the load-bearing claim in this issue and it was asserted
from memory during scoping, not checked.

Format

AAC, mono, ~48 kbps. Not ALAC. v2 wrote lossless, which put a 45-minute memo in the
hundreds of megabytes — fine when nothing synced, not fine once M3 moves audio over a cell
connection. AAC at this bitrate is transparent for speech and lands the same memo around
15 MB. AVAssetWriter writes AAC directly into the fragmented container, so compression and
crash-tolerance come from one path.

Naming

YYYY-MM-DD-HHMMSS-<hash>_audio.m4a, stamped with the original capture time. This
convention is carried over from v2 deliberately — the vault's CLAUDE.md documents it and
inbox processing depends on it. See the format spec in stash-docs.

Scope

  • AVAudioEngine tap feeding AVAssetWriter.
  • Start and stop, writing into the app's Documents directory.
  • A debug button only — no real UI. Issue 03 owns the UI.

Done when

A recording plays back correctly, and force-quitting the app mid-recording still leaves a
playable file containing everything up to the kill.

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 audio to a single continuous file that survives the app being killed. ## The decision, and the reason **Audio is not chunked.** Transcripts are chunked so the vault agent can read them mid-walk; audio is not, because it is not synced mid-walk and there is nothing to gain. The problem chunking would have solved is real: an `.m4a` is an MPEG-4 container whose index is written at the *end* of the file on finalize. If iOS kills the app mid-recording — memory pressure, watchdog, crash — the result is a file with audio in it that nothing will play. This is likely related to the "saving" freeze observed on 2026-07-30. Chunking bounds that loss to a minute but introduces a seam problem: stopping one writer and starting another can drop samples at the boundary, giving a memo with a click and a missing syllable every sixty seconds. It also owes a concatenation step at stop — another thing that can fail at exactly the moment it matters most. **So: one continuous file, written in a truncation-tolerant way.** `AVAssetWriter` with `movieFragmentInterval` set flushes a playable index periodically, so a recording killed at minute 40 still yields 40 minutes of audio. No seams, no concat, no chunk bookkeeping. ⚠️ **Verify the current `AVAssetWriter` fragmented-MP4 behaviour against Apple's docs before building on it.** This mechanism is the load-bearing claim in this issue and it was asserted from memory during scoping, not checked. ## Format **AAC, mono, ~48 kbps.** Not ALAC. v2 wrote lossless, which put a 45-minute memo in the hundreds of megabytes — fine when nothing synced, not fine once M3 moves audio over a cell connection. AAC at this bitrate is transparent for speech and lands the same memo around 15 MB. `AVAssetWriter` writes AAC directly into the fragmented container, so compression and crash-tolerance come from one path. ## Naming `YYYY-MM-DD-HHMMSS-<hash>_audio.m4a`, stamped with the **original capture time**. This convention is carried over from v2 deliberately — the vault's `CLAUDE.md` documents it and inbox processing depends on it. See the format spec in `stash-docs`. ## Scope - `AVAudioEngine` tap feeding `AVAssetWriter`. - Start and stop, writing into the app's Documents directory. - A debug button only — no real UI. Issue 03 owns the UI. ## Done when A recording plays back correctly, and force-quitting the app mid-recording still leaves a playable file containing everything up to the kill. --- *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-05-audio-capture, one commit, already installed on your phone. No PR yet — this issue is needs-hardware and AGENTS.md says a PR waits for the device pass. The device pass is yours, and the steps are at the bottom.

The verification the issue asked for, first

You flagged the AVAssetWriter mechanism as asserted from memory. Apple's documentation on movieFragmentInterval says it plainly:

Some container formats, such as QuickTime movies, support writing movies in fragments. Using this feature enables you to open and play a partially written movie in the event that an unexpected error or interruption occurs.

Two things that paragraph does not settle: it names QuickTime as the example, and this needs .m4a. So I measured it rather than inferring — a throwaway probe that writes 20 seconds of synthetic audio into an .m4a and deliberately never calls finishWriting():

Written Result
No movieFragmentInterval 20s UnreadableAVURLAsset fails with "Cannot Open"
movieFragmentInterval = 5s 20s Playable, 1 audio track, 17.04s recovered

So .m4a does honour fragmentation, and the design holds. The 3-second shortfall is the tail since the last flush — that is what the interval bounds, and it is the real cost of the approach. The probe was throwaway and is not in the branch.

Two other constraints from the same docs, both respected: the value cannot be changed once writing has started, and Apple asks for 10 seconds or more only for external storage. This writes to internal flash, so 5 seconds buys a shorter tail.

What's in it

Stash/Capture/:

  • CaptureNaming.swiftYYYY-MM-DD-HHMMSS-HASH_audio.m4a, carried over byte for byte, stamped with the original capture time. en_US_POSIX on the formatter, because a non-Gregorian default calendar renders the year as 2569 and nobody testing in English would ever see it.
  • AudioFileSink.swift — the encoder and writer. AAC mono 48 kbps. 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.
  • AudioRecorder.swift — session, engine, permission, lifecycle.
  • DebugCaptureView.swift — reachable from Settings. Start/stop, and a ShareLink per recording so files come off the phone before sync exists. Says in its own doc comment that it is not the real UI.

project.yml gains NSMicrophoneUsageDescription; without it iOS kills the app the first time the mic is touched.

A filename is claimed, not checked. createFile exclusively, draw a new hash and retry if taken. Checking whether a path is free and then writing has a window, and losing that race is one recording silently overwriting another.

What I validated

  • The fragmentation behaviour above, empirically, on .m4a.
  • make lint clean at --strict, 11 files. Simulator build succeeds.
  • make install — on your phone, launched, confirmed alive in the process list.

What I did NOT validate — all of the "done when"

Nothing has actually recorded audio. I cannot tap a button on your phone, and the simulator has no dependable microphone path. So every one of these is unverified:

  • That a real recording plays back correctly.
  • That force-quitting mid-recording leaves a playable file. The probe proves the container behaves; it does not prove this code drives it correctly — the sample-buffer conversion, the timestamps, and the engine tap are all untested against real audio.
  • The microphone permission prompt.
  • Whether the first buffer's timestamp anchoring produces a file that starts at zero rather than with a gap.
  • Bitrate and file size against the ~15 MB per 45 minutes the issue predicts.

The test, when you have a minute

  1. Open Stash → Settings → Capture (debug) → Start Recording. Grant the mic prompt.
  2. Talk for a bit. Stop. The file appears under Recordings — tap it to share it out and check it plays and sounds right.
  3. Start another. While it is still recording, swipe up and force-quit the app.
  4. Reopen, go back to Capture (debug). The file should be listed, and should play everything up to a few seconds before the kill.

Step 4 is the one that matters. If that file will not open, the design is wrong and I would rather find out now.

One thing outside this issue

The issue says "see the format spec in stash-docs". stash-docs is empty — it has a README and a LICENSE and nothing else. I took the naming convention from CaptureNaming.swift in the archived v2 instead, which is where the rules and their reasoning actually live. Worth its own issue; I did not create one.

Branch `m1-05-audio-capture`, one commit, **already installed on your phone**. No PR yet — this issue is `needs-hardware` and `AGENTS.md` says a PR waits for the device pass. The device pass is yours, and the steps are at the bottom. ## The verification the issue asked for, first You flagged the `AVAssetWriter` mechanism as asserted from memory. Apple's documentation on `movieFragmentInterval` says it plainly: > Some container formats, such as QuickTime movies, support writing movies in fragments. Using this feature enables you to open and play a partially written movie in the event that an unexpected error or interruption occurs. Two things that paragraph does **not** settle: it names QuickTime as the example, and this needs `.m4a`. So I measured it rather than inferring — a throwaway probe that writes 20 seconds of synthetic audio into an `.m4a` and deliberately never calls `finishWriting()`: | | Written | Result | |---|---|---| | No `movieFragmentInterval` | 20s | **Unreadable** — `AVURLAsset` fails with "Cannot Open" | | `movieFragmentInterval = 5s` | 20s | **Playable**, 1 audio track, **17.04s** recovered | So `.m4a` does honour fragmentation, and the design holds. The 3-second shortfall is the tail since the last flush — that is what the interval bounds, and it is the real cost of the approach. The probe was throwaway and is not in the branch. Two other constraints from the same docs, both respected: the value cannot be changed once writing has started, and Apple asks for 10 seconds or more only for **external** storage. This writes to internal flash, so 5 seconds buys a shorter tail. ## What's in it `Stash/Capture/`: - **`CaptureNaming.swift`** — `YYYY-MM-DD-HHMMSS-HASH_audio.m4a`, carried over byte for byte, stamped with the original capture time. `en_US_POSIX` on the formatter, because a non-Gregorian default calendar renders the year as 2569 and nobody testing in English would ever see it. - **`AudioFileSink.swift`** — the encoder and writer. AAC mono 48 kbps. 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. - **`AudioRecorder.swift`** — session, engine, permission, lifecycle. - **`DebugCaptureView.swift`** — reachable from Settings. Start/stop, and a `ShareLink` per recording so files come off the phone before sync exists. Says in its own doc comment that it is not the real UI. `project.yml` gains `NSMicrophoneUsageDescription`; without it iOS kills the app the first time the mic is touched. **A filename is claimed, not checked.** `createFile` exclusively, draw a new hash and retry if taken. Checking whether a path is free and then writing has a window, and losing that race is one recording silently overwriting another. ## What I validated - The fragmentation behaviour above, empirically, on `.m4a`. - `make lint` clean at `--strict`, 11 files. Simulator build succeeds. - `make install` — on your phone, launched, confirmed alive in the process list. ## What I did NOT validate — all of the "done when" **Nothing has actually recorded audio.** I cannot tap a button on your phone, and the simulator has no dependable microphone path. So every one of these is unverified: - That a real recording plays back correctly. - That force-quitting mid-recording leaves a playable file. **The probe proves the container behaves; it does not prove this code drives it correctly** — the sample-buffer conversion, the timestamps, and the engine tap are all untested against real audio. - The microphone permission prompt. - Whether the first buffer's timestamp anchoring produces a file that starts at zero rather than with a gap. - Bitrate and file size against the ~15 MB per 45 minutes the issue predicts. ## The test, when you have a minute 1. Open Stash → Settings → **Capture (debug)** → Start Recording. Grant the mic prompt. 2. Talk for a bit. Stop. The file appears under Recordings — tap it to share it out and check it plays and sounds right. 3. Start another. **While it is still recording**, swipe up and force-quit the app. 4. Reopen, go back to Capture (debug). The file should be listed, and should play everything up to a few seconds before the kill. Step 4 is the one that matters. If that file will not open, the design is wrong and I would rather find out now. ## One thing outside this issue The issue says "see the format spec in `stash-docs`". **`stash-docs` is empty** — it has a README and a LICENSE and nothing else. I took the naming convention from `CaptureNaming.swift` in the archived v2 instead, which is where the rules and their reasoning actually live. Worth its own issue; I did not create one.
Author
Member

Device pass done. Wes confirmed on wes-iphone: recording works, playback is right, the force-quit test passes, and swapping between microphones mid-session is handled cleanly.

That last one is beyond this issue's scope — route changes belong to a later issue — but it is a useful data point that the AVAudioEngine tap survives a route change rather than silently producing a dead file. It is an observation, not a tested guarantee; nobody has tried unplugging mid-sentence and checking the resulting audio for a seam.

Two bugs found and fixed since the first push

The app crashed the instant you tapped Record. SIGTRAP, and the reason was mine:

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 in AudioRecorder.start() — a @MainActor method — inherited main-actor isolation. It compiled clean; the audio render thread trapped on the first buffer. Installing the tap from a nonisolated static function leaves nothing to inherit. This is now written into AGENTS.md, because nothing about it is visible at build time and the next audio callback will have the same trap waiting.

A failed start left a zero-byte .m4a behind, which appeared in the recordings list looking like a memo that had lost its audio. The failure path removes it now. My first attempt read the path back off currentFileURL, which still holds the previous recording at that point — that version would have deleted the wrong file.

What the simulator run established

Rather than guess at the crash, I drove capture in the simulator with a temporary auto-start harness and killed the process with SIGKILL mid-recording. The harness was reverted; the committed tree is what built and installed.

  • Playable after SIGKILL, finishWriting() never called: 20.05s recovered.
  • 47 kbps, AAC, 1 channel, 48 kHz — the requested bitrate is honoured, which projects to roughly 16 MB for a 45-minute memo, in line with the ~15 MB in the issue.
  • No stray zero-byte file.

So both halves are now confirmed: the container behaves, and this code drives it correctly. The earlier probe only established the first.

Still not covered

  • No tests. There is no test target in this repository yet, so none of this is guarded against regression — the fragmentation behaviour, the naming rules and the exclusive-create claim are all verified by hand exactly once.
  • Long recordings. The longest run was under a minute; nothing has recorded for 40 minutes, which is the case the design exists for.
  • Backgrounding and screen lock. Not in this issue, and no background audio mode is declared, so a recording almost certainly stops when the app leaves the foreground.
  • Phone-call interruption, which you flagged as a v2 defect.
  • Storage exhaustion mid-recording.

PR is open.

Device pass done. Wes confirmed on `wes-iphone`: recording works, playback is right, **the force-quit test passes**, and swapping between microphones mid-session is handled cleanly. That last one is beyond this issue's scope — route changes belong to a later issue — but it is a useful data point that the `AVAudioEngine` tap survives a route change rather than silently producing a dead file. It is an observation, not a tested guarantee; nobody has tried unplugging mid-sentence and checking the resulting audio for a seam. ## Two bugs found and fixed since the first push **The app crashed the instant you tapped Record.** SIGTRAP, and the reason was mine: ``` 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 in `AudioRecorder.start()` — a `@MainActor` method — inherited main-actor isolation. It compiled clean; the audio render thread trapped on the first buffer. Installing the tap from a `nonisolated static` function leaves nothing to inherit. This is now written into `AGENTS.md`, because nothing about it is visible at build time and the next audio callback will have the same trap waiting. **A failed start left a zero-byte `.m4a` behind**, which appeared in the recordings list looking like a memo that had lost its audio. The failure path removes it now. My first attempt read the path back off `currentFileURL`, which still holds the *previous* recording at that point — that version would have deleted the wrong file. ## What the simulator run established Rather than guess at the crash, I drove capture in the simulator with a temporary auto-start harness and killed the process with SIGKILL mid-recording. The harness was reverted; the committed tree is what built and installed. - **Playable after SIGKILL**, `finishWriting()` never called: 20.05s recovered. - **47 kbps, AAC, 1 channel, 48 kHz** — the requested bitrate is honoured, which projects to roughly 16 MB for a 45-minute memo, in line with the ~15 MB in the issue. - No stray zero-byte file. So both halves are now confirmed: the container behaves, *and* this code drives it correctly. The earlier probe only established the first. ## Still not covered - **No tests.** There is no test target in this repository yet, so none of this is guarded against regression — the fragmentation behaviour, the naming rules and the exclusive-create claim are all verified by hand exactly once. - Long recordings. The longest run was under a minute; nothing has recorded for 40 minutes, which is the case the design exists for. - Backgrounding and screen lock. Not in this issue, and no background audio mode is declared, so a recording almost certainly stops when the app leaves the foreground. - Phone-call interruption, which you flagged as a v2 defect. - Storage exhaustion mid-recording. PR is open.
wk closed this issue 2026-08-06 15:36:19 -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#5
No description provided.