M1-10: assemble the transcript at stop, and after a crash #26

Merged
wk merged 1 commit from m1-10-finalization into main 2026-08-08 12:33:09 -04:00
Member

Closes #10, closes #22.

VTT.assemble landed in the #9 follow-up, tested, and nothing called it. Every capture was
arriving as half the pair the vault consumes — <base>_audio.m4a plus a <base>.parts/ folder,
never <base>_transcription.vtt — and a recording the app was killed during never reached the code
that would have finished it.

Audio needed nothing here. Finalisation landed in #5, and a killed recording is already playable
because of the fragment interval.

What it does

Assembly reads the parts back off disk, not the cues still held in memory. Crash recovery has
no process state, so a memory path would be a second implementation that only ever runs after a
crash — the least-exercised code at the moment it matters most. One path, taken at stop and again by
the launch sweep.

The parts folder is deleted once the transcript is confirmed on disk and the part numbers run
1..n unbroken.
A part that never landed leaves a transcript that reads as whole while missing a
minute of speech, so a sparse folder is assembled anyway — those words are worth having — and then
kept, with the missing numbers named. The folder is the only thing on disk that says the transcript
is short.

Launch finishes what a crash interrupted, once per process rather than from a view task, and
skipping whatever the recorder is currently holding.

Nothing can start while the last recording is still finalising. Pre-existing bug, and this
change lengthened the window: stop() cleared isRecording before awaiting the file, so a fast
second tap replaced the sink and the older stop then tore down the newer recording.

Validated on device

wes-iphone, iPhone 17 Pro, iOS 26.

Normal stop — a capture stops as exactly two files with no .parts folder, and the .vtt
round-trips through FFmpeg to SRT against its audio. This is also the only check that
RecordingTranscription.end() still reaches the assembler at all; no unit test can get there,
because begin() needs the speech engine.

Crash recovery, unplanned and then confirmed by the breadcrumbs. A recording was force-quit from
the app switcher without stopping:

diag.lastStop      Stop button in the app at 2026-08-07T18:12:18Z   <- the day before
diag.lastLaunch    #17 at 2026-08-08T15:41:33Z
diag.lastRecovery  Recovered 1 capture(s) at 2026-08-08T15:41:34Z

No stop was ever recorded for it, and the sweep assembled it one second after relaunch. Its audio
decodes clean end to end at 210.0 s despite never being finalised; the transcript has 34 cues from
0.0 s to 193.7 s with no gap over five seconds between them. The 16-second tail is the analyzer's
unfinalised audio, lost with the process — that is #25.

Six older captures left with .parts folders from #9 were finished by the sweep on install.
Forty files came off the phone and zero .parts folders remain. Every transcript validates:
exactly one WEBVTT, cue numbers 1..n unbroken, starts monotonic, every end after its own start,
no seconds field outside 0...59, audio present beside each.

capture cues length
…182011-f0xwmf 2 0.2 min
…182026-0n4n3o 14 2.2 min
…192914-t722uw 50 9.1 min
…085215-hg1eb0 320 44.6 min
…112239-6zba2t 228 32.1 min
…140926-kednow 21 2.9 min
…113800-9qqy4g 34 3.2 min

The 44.6-minute one is the one worth noting: transcript and audio agree to the tenth of a minute
across roughly forty-five parts.

make test — 70 tests, 5 suites. make lint — 0 violations.

Not validated

Stated plainly, because a claim of coverage that does not exist is worse than no claim.

  • Three of the five device tests did not run. A recording with nothing said; storage exhaustion
    during a recording; and starting a new recording while the previous stop is still saving. That
    last one is the only check of the finalising guard, and nothing else covers it.
  • A recording actually killed by iOS rather than by the app switcher.
  • The unit suite cannot reach three things at all — that end() calls the assembler, that
    StashApp calls the launch sweep, and the finalising guard. All three need the speech engine or a
    live recording, and AGENTS.md holds StashTests to logic only. The first two are covered by the
    device results above; the third is not covered by anything.
  • TranscriptRecovery.run() called twice concurrently. Verified by two simulator relaunches,
    not by a test — a test of it would couple to the app's real documents directory and
    AudioRecorder.shared.
  • Whether cue times match the words by ear. Still open from #8 and #9.

The Codex passes

Four rounds, all four artifacts in .agent/m1-10-finalization/. The test pass earned its place this
time: it caught a process abort, not a weak assertion. My fix for a code-review finding used
[.atomic, .withoutOverwriting], which is a fatalError inside Foundation rather than an error —
it would have killed the app on every stop where somebody spoke, and the simulator pass had already
happened before that line existed. Publishing is a temp write plus link(2) now.

Writing those tests also turned up that one unreadable part was aborting the whole assembly, costing
the other thirty-nine.

Every disposition, including the rejections and why, is in .agent/m1-10-finalization/SUMMARY.md.

One change outside the issue, worth your call

.swiftlint.yml now sets file_length: ignore_comment_only_lines: true.

AudioRecorder.swift was at 396 lines of a 400-line limit before this branch, so the finalising fix
could not be added without breaking lint. The file is now 415 lines of which 177 are code — the
other 238 are why the microphone-swap and background-kill failures are handled the way they are.
Counting documentation toward a code-length limit pushes against what the build standard asks for,
and the pressure it creates is to delete the explanation rather than split the file.

The alternative was extracting the route-repair machinery into its own file, which needs a dozen of
the recorder's private members opened up to the module — a refactor of #6 and #7 code inside an
issue about finalisation. Flagged rather than done quietly, and easy to reverse if you would rather
take the split.

Follow-up filed

#25 — keep the unfinalised transcript tail, so a crash costs seconds rather than the last thing
said. It is written so it does not reverse the volatile-results decision in AGENTS.md, and says to
close it if that separation cannot be held cleanly.

🤖 Generated with Claude Code

Closes #10, closes #22. `VTT.assemble` landed in the #9 follow-up, tested, and nothing called it. Every capture was arriving as half the pair the vault consumes — `<base>_audio.m4a` plus a `<base>.parts/` folder, never `<base>_transcription.vtt` — and a recording the app was killed during never reached the code that would have finished it. Audio needed nothing here. Finalisation landed in #5, and a killed recording is already playable because of the fragment interval. ## What it does **Assembly reads the parts back off disk**, not the cues still held in memory. Crash recovery has no process state, so a memory path would be a second implementation that only ever runs after a crash — the least-exercised code at the moment it matters most. One path, taken at stop and again by the launch sweep. **The parts folder is deleted once the transcript is confirmed on disk and the part numbers run 1..n unbroken.** A part that never landed leaves a transcript that reads as whole while missing a minute of speech, so a sparse folder is assembled anyway — those words are worth having — and then kept, with the missing numbers named. The folder is the only thing on disk that says the transcript is short. **Launch finishes what a crash interrupted**, once per process rather than from a view task, and skipping whatever the recorder is currently holding. **Nothing can start while the last recording is still finalising.** Pre-existing bug, and this change lengthened the window: `stop()` cleared `isRecording` before awaiting the file, so a fast second tap replaced the sink and the older stop then tore down the newer recording. ## Validated on device `wes-iphone`, iPhone 17 Pro, iOS 26. **Normal stop** — a capture stops as exactly two files with no `.parts` folder, and the `.vtt` round-trips through FFmpeg to SRT against its audio. This is also the only check that `RecordingTranscription.end()` still reaches the assembler at all; no unit test can get there, because `begin()` needs the speech engine. **Crash recovery, unplanned and then confirmed by the breadcrumbs.** A recording was force-quit from the app switcher without stopping: ``` diag.lastStop Stop button in the app at 2026-08-07T18:12:18Z <- the day before diag.lastLaunch #17 at 2026-08-08T15:41:33Z diag.lastRecovery Recovered 1 capture(s) at 2026-08-08T15:41:34Z ``` No stop was ever recorded for it, and the sweep assembled it one second after relaunch. Its audio decodes clean end to end at 210.0 s despite never being finalised; the transcript has 34 cues from 0.0 s to 193.7 s with no gap over five seconds between them. The 16-second tail is the analyzer's unfinalised audio, lost with the process — that is #25. **Six older captures** left with `.parts` folders from #9 were finished by the sweep on install. Forty files came off the phone and **zero `.parts` folders remain**. Every transcript validates: exactly one `WEBVTT`, cue numbers 1..n unbroken, starts monotonic, every end after its own start, no seconds field outside `0...59`, audio present beside each. | capture | cues | length | |---|---|---| | `…182011-f0xwmf` | 2 | 0.2 min | | `…182026-0n4n3o` | 14 | 2.2 min | | `…192914-t722uw` | 50 | 9.1 min | | `…085215-hg1eb0` | 320 | 44.6 min | | `…112239-6zba2t` | 228 | 32.1 min | | `…140926-kednow` | 21 | 2.9 min | | `…113800-9qqy4g` | 34 | 3.2 min | The 44.6-minute one is the one worth noting: transcript and audio agree to the tenth of a minute across roughly forty-five parts. `make test` — 70 tests, 5 suites. `make lint` — 0 violations. ## Not validated Stated plainly, because a claim of coverage that does not exist is worse than no claim. - **Three of the five device tests did not run.** A recording with nothing said; storage exhaustion during a recording; and starting a new recording while the previous stop is still saving. That last one is the only check of the finalising guard, and nothing else covers it. - **A recording actually killed by iOS** rather than by the app switcher. - **The unit suite cannot reach three things at all** — that `end()` calls the assembler, that `StashApp` calls the launch sweep, and the finalising guard. All three need the speech engine or a live recording, and `AGENTS.md` holds `StashTests` to logic only. The first two are covered by the device results above; the third is not covered by anything. - **`TranscriptRecovery.run()` called twice concurrently.** Verified by two simulator relaunches, not by a test — a test of it would couple to the app's real documents directory and `AudioRecorder.shared`. - **Whether cue times match the words by ear.** Still open from #8 and #9. ## The Codex passes Four rounds, all four artifacts in `.agent/m1-10-finalization/`. The test pass earned its place this time: it caught a **process abort**, not a weak assertion. My fix for a code-review finding used `[.atomic, .withoutOverwriting]`, which is a `fatalError` inside Foundation rather than an error — it would have killed the app on every stop where somebody spoke, and the simulator pass had already happened before that line existed. Publishing is a temp write plus `link(2)` now. Writing those tests also turned up that one unreadable part was aborting the whole assembly, costing the other thirty-nine. Every disposition, including the rejections and why, is in `.agent/m1-10-finalization/SUMMARY.md`. ## One change outside the issue, worth your call `.swiftlint.yml` now sets `file_length: ignore_comment_only_lines: true`. `AudioRecorder.swift` was at 396 lines of a 400-line limit before this branch, so the finalising fix could not be added without breaking lint. The file is now 415 lines of which **177 are code** — the other 238 are why the microphone-swap and background-kill failures are handled the way they are. Counting documentation toward a code-length limit pushes against what the build standard asks for, and the pressure it creates is to delete the explanation rather than split the file. The alternative was extracting the route-repair machinery into its own file, which needs a dozen of the recorder's private members opened up to the module — a refactor of #6 and #7 code inside an issue about finalisation. Flagged rather than done quietly, and easy to reverse if you would rather take the split. ## Follow-up filed #25 — keep the unfinalised transcript tail, so a crash costs seconds rather than the last thing said. It is written so it does not reverse the volatile-results decision in `AGENTS.md`, and says to close it if that separation cannot be held cleanly. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Issues #10 and #22. `VTT.assemble` landed in the #9 follow-up and nothing called
it, so every capture arrived as half the pair the vault expects — audio and a
`.parts` folder, never `<base>_transcription.vtt`.

Assembly reads the parts back off disk rather than the cues still in memory: a
recording the app was killed during leaves no process state, so a memory path
would be a second implementation that only ever runs after a crash. One path,
taken at stop and again by the launch sweep.

The parts folder is deleted once the transcript is confirmed and the part
numbers run unbroken. A part that never landed leaves a transcript that reads as
whole while missing a minute of speech, so a sparse folder is assembled anyway
and then kept — it is the only thing on disk that says the transcript is short.

Also closes the window where starting a recording during the previous one's
finalisation let the older stop tear down the newer recording.

Checkpoint commit, not a claim that it works: the whole path still needs the
device, because no unit test can reach the speech engine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wk merged commit b37d053080 into main 2026-08-08 12:33:09 -04:00
wk deleted branch m1-10-finalization 2026-08-08 12:33:09 -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!26
No description provided.