M1-05: Audio capture core — one continuous AAC file #5
Labels
No labels
area/agent
area/capture
area/editor
area/geo
area/security
area/sync
area/transcription
area/ui
area/vault
area/voice-memos
blocked
milestone/M1
milestone/M2
milestone/M3
milestone/M4
needs-decision
needs-hardware
p0
p1
p2
type/bug
type/enhancement
type/feature
type/idea
type/infrastructure
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Stash/stash-ios#5
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
.m4ais an MPEG-4 container whose indexis 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.
AVAssetWriterwithmovieFragmentIntervalset flushes a playable index periodically, so a recording killed atminute 40 still yields 40 minutes of audio. No seams, no concat, no chunk bookkeeping.
⚠️ Verify the current
AVAssetWriterfragmented-MP4 behaviour against Apple's docs beforebuilding 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.
AVAssetWriterwrites AAC directly into the fragmented container, so compression andcrash-tolerance come from one path.
Naming
YYYY-MM-DD-HHMMSS-<hash>_audio.m4a, stamped with the original capture time. Thisconvention is carried over from v2 deliberately — the vault's
CLAUDE.mddocuments it andinbox processing depends on it. See the format spec in
stash-docs.Scope
AVAudioEnginetap feedingAVAssetWriter.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.
Branch
m1-05-audio-capture, one commit, already installed on your phone. No PR yet — this issue isneeds-hardwareandAGENTS.mdsays 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
AVAssetWritermechanism as asserted from memory. Apple's documentation onmovieFragmentIntervalsays it plainly: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.m4aand deliberately never callsfinishWriting():movieFragmentIntervalAVURLAssetfails with "Cannot Open"movieFragmentInterval = 5sSo
.m4adoes 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_POSIXon 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; anNSLockcovers the one overlap with stop, sinceremoveTapdoes not promise an in-flight callback has returned.AudioRecorder.swift— session, engine, permission, lifecycle.DebugCaptureView.swift— reachable from Settings. Start/stop, and aShareLinkper recording so files come off the phone before sync exists. Says in its own doc comment that it is not the real UI.project.ymlgainsNSMicrophoneUsageDescription; without it iOS kills the app the first time the mic is touched.A filename is claimed, not checked.
createFileexclusively, 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
.m4a.make lintclean 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:
The test, when you have a minute
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-docsis empty — it has a README and a LICENSE and nothing else. I took the naming convention fromCaptureNaming.swiftin the archived v2 instead, which is where the rules and their reasoning actually live. Worth its own issue; I did not create one.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
AVAudioEnginetap 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:
installTaptakes a plain non-Sendableblock, so the closure written inline inAudioRecorder.start()— a@MainActormethod — inherited main-actor isolation. It compiled clean; the audio render thread trapped on the first buffer. Installing the tap from anonisolated staticfunction leaves nothing to inherit. This is now written intoAGENTS.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
.m4abehind, 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 offcurrentFileURL, 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.
finishWriting()never called: 20.05s recovered.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
PR is open.