M1-09: VTT part writer — one-minute transcript chunks #9

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

Write transcript chunks to disk roughly every minute while recording continues.

Why chunks exist at all

So the vault agent can read transcripts while Wes is still walking, and so a crash costs
a minute rather than the whole memo. This is the entire justification — it is why transcripts
chunk and audio does not.

Layout

2026-08-06-090139-tjgcaz.parts/
  2026-08-06-090139-tjgcaz_transcription.001.vtt
  2026-08-06-090139-tjgcaz_transcription.002.vtt

Parts live in a sibling folder named for the capture, not loose alongside finished files,
so that any consumer sees only completed artifacts at the top level and reads the .parts
folder deliberately when it wants to work ahead. The vault's inbox processing needs no changes.

Rules

  • Absolute timestamps. A cue in part 003 reads 00:02:14.000 --> 00:02:19.000, offset
    from the start of the recording. Every part is therefore valid WebVTT on its own, and
    assembly is concatenation rather than arithmetic.
  • Continuous cue numbering across parts — the writer knows the running count.
  • Atomic writes. Write to a temp name, then rename. A part must not become visible until
    it is complete, or a consumer reads half a file.
  • ⚠️ Flush at cue boundaries, not at the clock. Cutting at exactly sixty seconds cuts
    through a sentence the recognizer has not finalized, producing a truncated cue in one part
    and a duplicate or dropped clause in the next. At the sixty-second mark, write out every
    finalized cue and carry the in-progress one into the next part. Parts end up roughly a
    minute, which matters to nothing.

Done when

A ten-minute recording produces ten well-formed parts, each independently openable, with no
duplicated or dropped text at any boundary. Concatenating them by hand yields the full
transcript.

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.

Write transcript chunks to disk roughly every minute while recording continues. ## Why chunks exist at all So the vault agent can read transcripts **while Wes is still walking**, and so a crash costs a minute rather than the whole memo. This is the entire justification — it is why transcripts chunk and audio does not. ## Layout ``` 2026-08-06-090139-tjgcaz.parts/ 2026-08-06-090139-tjgcaz_transcription.001.vtt 2026-08-06-090139-tjgcaz_transcription.002.vtt ``` Parts live in a sibling folder named for the capture, **not loose alongside finished files**, so that any consumer sees only completed artifacts at the top level and reads the `.parts` folder deliberately when it wants to work ahead. The vault's inbox processing needs no changes. ## Rules - **Absolute timestamps.** A cue in part 003 reads `00:02:14.000 --> 00:02:19.000`, offset from the start of the recording. Every part is therefore valid WebVTT on its own, and assembly is concatenation rather than arithmetic. - **Continuous cue numbering** across parts — the writer knows the running count. - **Atomic writes.** Write to a temp name, then rename. A part must not become visible until it is complete, or a consumer reads half a file. - **⚠️ Flush at cue boundaries, not at the clock.** Cutting at exactly sixty seconds cuts through a sentence the recognizer has not finalized, producing a truncated cue in one part and a duplicate or dropped clause in the next. At the sixty-second mark, write out every finalized cue and carry the in-progress one into the next part. Parts end up *roughly* a minute, which matters to nothing. ## Done when A ten-minute recording produces ten well-formed parts, each independently openable, with no duplicated or dropped text at any boundary. Concatenating them by hand yields the full transcript. --- *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

Validated on wes-iphone, iOS 26.5.2, against four real recordings pulled off the device.

Works

recording audio parts cues untranscribed tail
2026-08-06-182011 0.2 min 1 2 0.4s
2026-08-06-182026 2.5 min 3 14 23.1s
2026-08-06-192914 9.1 min 8 50 0.4s
2026-08-07-085215 44.6 min 40 320 0.8s

The issue asks for a ten-minute recording producing ten well-formed parts. The 44-minute walk
produced forty, across 39 boundaries. Checked on all four:

  • WEBVTT header on every part; each opens on its own.
  • Cue numbers continuous 1..N across parts, no gap and no repeat.
  • Timestamps absolute and monotonic across every seam.
  • No duplicated or dropped cue text at any boundary.
  • Concatenating the parts yields the full transcript.
  • Nothing but .vtt files in .parts/ — no temp file ever became visible.
  • Nothing loose at the top level of the documents directory.

The 23.1s tail on the 2.5-minute recording is not a defect. Measured against the audio it is the
stretch after Wes set the phone down; the other three transcribe to within a second of the end,
so the drain at stop works.

Tests

First test target in this repository. 21 tests, make test, plus a CI step that will run once a
macOS agent exists. TranscriptParts holds the boundary rule and nothing else — no filesystem, no
speech engine — which is what lets ten simulated minutes run in a millisecond.

Covered: the boundary against ten minutes of cues, cue numbering across parts, a cue spanning a
boundary, the cue that crosses one opening the next part whole, a ten-minute silence producing no
empty parts, WebVTT rendered byte for byte, hours and milliseconds in timestamps, reassembly by
concatenation, the file layout and naming, and a write that cannot happen being reported rather
than thrown. Fixtures are synthetic.

Not validated

  • Whether cue times match the words by ear. Carried over from #8 and still open. The
    timestamps are self-consistent and the parts line up, but nobody has played a recording against
    its transcript and confirmed a cue names the moment the words were said.
  • A recording that ends by being killed. The parts written so far survive, but nothing
    assembles them and nothing marks the capture as unfinished. Filed as #22.
  • A microphone swap or a call mid-recording, and what the part boundaries look like across
    one.
  • Storage running out while parts are being written. lastError carries the failure to the
    screen and is covered by a test, but only against a synthetic failure.
  • Past 999 parts — around sixteen hours. The name gives way rather than wrapping, and it is
    untested.

Follow-on

#22 covers writing the assembled <base>_transcription.vtt beside the audio at stop, which is
what the vault expects and what this issue leaves to be done by hand.

Validated on `wes-iphone`, iOS 26.5.2, against four real recordings pulled off the device. ## Works | recording | audio | parts | cues | untranscribed tail | |---|---|---|---|---| | 2026-08-06-182011 | 0.2 min | 1 | 2 | 0.4s | | 2026-08-06-182026 | 2.5 min | 3 | 14 | 23.1s | | 2026-08-06-192914 | 9.1 min | 8 | 50 | 0.4s | | 2026-08-07-085215 | 44.6 min | 40 | 320 | 0.8s | The issue asks for a ten-minute recording producing ten well-formed parts. The 44-minute walk produced forty, across 39 boundaries. Checked on all four: - `WEBVTT` header on every part; each opens on its own. - Cue numbers continuous 1..N across parts, no gap and no repeat. - Timestamps absolute and monotonic across every seam. - No duplicated or dropped cue text at any boundary. - Concatenating the parts yields the full transcript. - Nothing but `.vtt` files in `.parts/` — no temp file ever became visible. - Nothing loose at the top level of the documents directory. The 23.1s tail on the 2.5-minute recording is not a defect. Measured against the audio it is the stretch after Wes set the phone down; the other three transcribe to within a second of the end, so the drain at stop works. ## Tests First test target in this repository. 21 tests, `make test`, plus a CI step that will run once a macOS agent exists. `TranscriptParts` holds the boundary rule and nothing else — no filesystem, no speech engine — which is what lets ten simulated minutes run in a millisecond. Covered: the boundary against ten minutes of cues, cue numbering across parts, a cue spanning a boundary, the cue that crosses one opening the next part whole, a ten-minute silence producing no empty parts, WebVTT rendered byte for byte, hours and milliseconds in timestamps, reassembly by concatenation, the file layout and naming, and a write that cannot happen being reported rather than thrown. Fixtures are synthetic. ## Not validated - **Whether cue times match the words by ear.** Carried over from #8 and still open. The timestamps are self-consistent and the parts line up, but nobody has played a recording against its transcript and confirmed a cue names the moment the words were said. - **A recording that ends by being killed.** The parts written so far survive, but nothing assembles them and nothing marks the capture as unfinished. Filed as #22. - **A microphone swap or a call mid-recording**, and what the part boundaries look like across one. - **Storage running out** while parts are being written. `lastError` carries the failure to the screen and is covered by a test, but only against a synthetic failure. - **Past 999 parts** — around sixteen hours. The name gives way rather than wrapping, and it is untested. ## Follow-on #22 covers writing the assembled `<base>_transcription.vtt` beside the audio at stop, which is what the vault expects and what this issue leaves to be done by hand.
wk closed this issue 2026-08-07 09:51:39 -04:00
agent reopened this issue 2026-08-07 10:38:04 -04:00
Author
Member

Reopened. The close-out comment above checked that concatenating the parts yields the full
transcript, and that check was wrong — it looked for the text being present, and the text was
present. What it could not see is that the parts do not join into a well-formed file.

A rendered part ended on its last spoken word rather than on a blank line. A WebVTT cue block runs
until a blank line, so joining two parts puts the next part's WEBVTT signature inside the
previous cue. FFmpeg reads it exactly that way:

$ ffmpeg -i concatenated.vtt -f webvtt -
WEBVTT

00:00.000 --> 00:02.000
one
WEBVTT          <- read as words that were spoken

01:10.000 --> 01:12.000
two

The signature is also only a signature at the very start of a file, so even with the blank line
fixed, a repeated one is a block a conforming reader discards.

Three more in the same code, all from the same Codex review:

  • Cue text was written raw. A &, a < or a --> in what somebody actually said could change the
    line or truncate it, and a blank line inside a cue ended the cue and took the rest of its own
    words with it.
  • Timestamps rounded the seconds field after splitting off hours and minutes, so 59.9996
    rendered 00:00:60.000. WebVTT holds that field to 0...59.
  • A part that failed to write was dropped rather than retried. TranscriptParts has already
    released those cues by the time a write can fail, so a moment of full storage cost a minute of
    transcript permanently, with nothing marking the hole.

The fix

Branch m1-09-vtt-assembly, six commits, pushed. No PR yet — device testing first.

Assembly is now VTT.assemble rather than cat: the first part keeps its signature and the rest
give theirs up at assembly time. Every part on disk still carries one, because being openable on
its own mid-walk is the only reason parts exist. Cue text is escaped. Timestamps round before they
are split, and an end is always at least a millisecond after its own start. A failed part is
retried at every later boundary and at stop, the written list stays in part order so a retry
cannot land out of sequence, and a stuck part is now reported while the recording runs — storage
is the failure that happens here and it is the one somebody can act on, but only while there is
still a recording for the freed space to take.

Tests

50, up from 21. The new ones are built on a WebVTT conformance reader written from the grammar
with no knowledge of VTT.render, plus a test proving that reader rejects all four pre-fix
behaviours — so the assertions on top of it can actually fail. The byte-comparisons that existed
before were written from the same mental model as the code and could only pin its output.

Not validated

  • Nothing on device yet. Simulator and unit tests only.
  • The writer's wiring through RecordingTranscription. No unit test can reach it — begin()
    needs the speech engine, and this test target has none. Production could stop feeding the writer
    entirely and the suite would stay green. The device pass is the only thing that covers it.
  • Real storage exhaustion. The retry is tested against a synthetic block, not a full disk.
  • Whether cue times match the words by ear. Still open from #8, unchanged.

Full disposition of every Codex finding across the four passes is in
.agent/m1-09-vtt-assembly/SUMMARY.md.

Filed alongside: Stash/stash-docs#3, to set up the decisions convention the build standard calls
for — there is nowhere to record a design decision in stash-docs today.

Reopened. The close-out comment above checked that concatenating the parts yields the full transcript, and that check was wrong — it looked for the text being present, and the text *was* present. What it could not see is that the parts do not join into a well-formed file. A rendered part ended on its last spoken word rather than on a blank line. A WebVTT cue block runs until a blank line, so joining two parts puts the next part's `WEBVTT` signature inside the previous cue. FFmpeg reads it exactly that way: ``` $ ffmpeg -i concatenated.vtt -f webvtt - WEBVTT 00:00.000 --> 00:02.000 one WEBVTT <- read as words that were spoken 01:10.000 --> 01:12.000 two ``` The signature is also only a signature at the very start of a file, so even with the blank line fixed, a repeated one is a block a conforming reader discards. Three more in the same code, all from the same Codex review: - Cue text was written raw. A `&`, a `<` or a `-->` in what somebody actually said could change the line or truncate it, and a blank line inside a cue ended the cue and took the rest of its own words with it. - Timestamps rounded the seconds field after splitting off hours and minutes, so `59.9996` rendered `00:00:60.000`. WebVTT holds that field to `0...59`. - A part that failed to write was dropped rather than retried. `TranscriptParts` has already released those cues by the time a write can fail, so a moment of full storage cost a minute of transcript permanently, with nothing marking the hole. ## The fix Branch `m1-09-vtt-assembly`, six commits, pushed. No PR yet — device testing first. Assembly is now `VTT.assemble` rather than `cat`: the first part keeps its signature and the rest give theirs up at assembly time. Every part on disk still carries one, because being openable on its own mid-walk is the only reason parts exist. Cue text is escaped. Timestamps round before they are split, and an end is always at least a millisecond after its own start. A failed part is retried at every later boundary and at stop, the written list stays in part order so a retry cannot land out of sequence, and a stuck part is now reported *while the recording runs* — storage is the failure that happens here and it is the one somebody can act on, but only while there is still a recording for the freed space to take. ## Tests 50, up from 21. The new ones are built on a WebVTT conformance reader written from the grammar with no knowledge of `VTT.render`, plus a test proving that reader rejects all four pre-fix behaviours — so the assertions on top of it can actually fail. The byte-comparisons that existed before were written from the same mental model as the code and could only pin its output. ## Not validated - **Nothing on device yet.** Simulator and unit tests only. - **The writer's wiring through `RecordingTranscription`.** No unit test can reach it — `begin()` needs the speech engine, and this test target has none. Production could stop feeding the writer entirely and the suite would stay green. The device pass is the only thing that covers it. - **Real storage exhaustion.** The retry is tested against a synthetic block, not a full disk. - **Whether cue times match the words by ear.** Still open from #8, unchanged. Full disposition of every Codex finding across the four passes is in `.agent/m1-09-vtt-assembly/SUMMARY.md`. Filed alongside: Stash/stash-docs#3, to set up the decisions convention the build standard calls for — there is nowhere to record a design decision in `stash-docs` today.
wk closed this issue 2026-08-07 14:18:37 -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#9
No description provided.