Skip to content
C.W.K.
Stream
Lesson 05 of 07 · published

Replay: the Broadcast Again, on Its Own Clock

~16 min · on-air, replay, timeline, web-audio

Level 0Muted
0 XP0/57 lessons0/14 achievements
0/100 XP to next level100 XP to go0% complete
"It has to come out on a timeline that truly matches what a ScreenFlow recording would have had." — Dad, 2026-10-02

The Broadcast Nobody Recorded

On the morning of 2026-10-02 Dad went on air and talked for over an hour, and ScreenFlow, the app that records the screen, was never started. His camera recorded him. Logic Pro recorded his voice. The screen, her face, her cards, her effects and her voice were not recorded at all, except faintly through his microphone. So he asked for a replay: run the stored broadcast again on the same screen, capture it this time, and lay it over the tracks he already had. Then he set the bar in the quote above. A replay that drifts by half a second against his camera is not a replay he can edit with.

The Same Screen, Driven by a Clock

The tempting build is a video player: render the talk as frames somewhere and play them back. That would be a second screen that looks almost like the first one, and every later change to the live screen would have to be made twice. So a replay is the live voice screen itself: the face, the On Air set and its frame director, the stage and its cards, the effects layer, the lamp, full screen. What changes is what feeds it. Live, a microphone and a stream drive it. In a replay, a timeline does (hooks/useOnAirReplay.ts). Nothing is forked. The live controls (Share, On Air, Cut in, Pause) are absent, and the replay's own bar fades with the rest of the chrome, so the capture shows only what the broadcast showed. The Replay button sits in the composer's voice controls when the open conversation holds On Air turns, on the main WebUI only.

Her Voice Is the Broadcast's Own

A replay that synthesized her replies again would read them differently, and his voice track answers the reading she actually gave. So each spoken unit is cut exactly as the live screen cut it, by the same function over the stored reply and the places where a tool call closed a unit. Same text, same voice, same model: the same identity in Bellows' cache, so a cache hit is the take that aired. On the first broadcast replayed, all 25 takes hit. The replay asks /api/tts/check-cache first and fetches the hits as finished files. A take the cache no longer holds stays silent, timed at her pace (7.8 characters a second that morning), until Dad presses Synthesize. That costs credits and reads differently, so it never happens on its own. And a replay never extends the cache's window: Bellows keeps conversation audio for a set number of days after it was made (30 by its code default), so a lost broadcast is captured inside that window or not at all.

The Facts, and the Clock They Build

The server hands over what the broadcast left behind, read-only, from SQLite and the JSONL together (GET /api/onair/replay/{id}, through the same guard that serves the conversation, so a private one is never on air). For each of his turns: his words, when his row landed, and his kept recordings with the loudness of every 2048-sample chunk, the numbers the live loop drew as the ring. For each of her replies: the stored text and emotion, when the row landed, how the text reached the screen, where tool calls closed units, the effects she fired, and the cut-in with its reported position.

A pure module (lib/onairReplay.ts, tested on that morning's numbers) turns those facts into one clock. Every constant in it was measured on that broadcast against the server's own records:

  • A short recording went up the moment he finished, so his row dates its end: he began at the row's time, minus 0.07 seconds for building and sending the recording, minus the recording's length.
  • A cut-in recording opens 1.024 seconds before the cut, the pre-roll from Track 7. On that morning the two rules agreed to seven thousandths of a second.
  • A recording of 30 seconds or more was transcribed again before it went out, for a time no record keeps, so it is placed after her reading instead: she stopped, and 0.45 seconds later the socket opened.
  • Her take was asked for 0.38 seconds after her reply landed (0.03 after the call, for a line before a tool) and sounded 1.5 seconds later. Where a fixed point says exactly when her voice began, a cut-in or his next recording, that wins over the average, but only if it lands between 0.5 and 3.5 seconds after the ask.
  • Scribe's forced commits, every 35.8 seconds of talk, were her nods and smiles, and an effect plays once the line announcing it has been read.

Checked against the access log, the replayed screen asked for the effect, the nod frame and the smile frame within about a tenth of a second of the times the model gave them.

Timing That Survives a Busy Page

A timer on the page is not a clock you can trust: a garbage collection or a reply streaming in stalls the main thread, and a take started from a stalled timer starts late or skips its first words. So the replay decodes each take 30 seconds ahead and hands it to the audio hardware's own clock 2 seconds ahead of its first sound (Web Audio's start(when)). Any stall shorter than that cannot move her voice. Pacing has two modes: Auto runs on the broadcast's own clock, so one sync point lines the whole capture up with the Logic Pro track; Manual waits after each of his turns, at his last word by the recording's loudness, until Next. "From" starts at any of his turns; Space, → and ← drive it.

What it cannot bring back is written down: her blinks (a random draw, as live), a lightbox Dad opened on air (nothing records it), and his voice, which is the Logic Pro track's job.

Code

The measured constants, from frontend/src/lib/onairReplay.ts:28-44 (trimmed after firstSoundMax)·typescript
export const REPLAY = {
  /** A final unit's take was asked for this long after its reply row
   *  landed: the done event, the queue, the prepare call (0.33-0.46 s). */
  requestAfterDone: 0.38,
  /** A line before a tool was asked for this long after the tool call
   *  began streaming (0.01-0.04 s). */
  requestAfterTool: 0.03,
  /** From asking for a take to her first sound: the stream's first chunk
   *  (about 0.9 s by the access log) and the browser's start. The two
   *  cut-ins put it at 1.39 and 1.95 s, the replies before a listen known
   *  to the millisecond at 1.4-1.6 s; the session's very first take was
   *  colder, at 2.45 s. */
  firstSound: 1.5,
  /** A first sound derived from a fixed point (a cut-in, or the listen that
   *  followed) is believed only this long after its take was asked for. */
  firstSoundMin: 0.5,
  firstSoundMax: 3.5,
  // … poll, listenAgain, socketAfterCut, commitToRow, preroll and the rest (trimmed)
} as const;
A minimal sketch: placing his turns and her takes on the broadcast's clock·python
from dataclasses import dataclass

# Measured on the broadcast it was built for (frontend/src/lib/onairReplay.ts, REPLAY).
COMMIT_TO_ROW = 0.07          # his commit -> his row: the recording built, kept and sent
PREROLL = 8 * 2048 / 16000    # a cut-in recording opens 1.024 s before the cut
LONG_UTTERANCE = 30.0         # recordings this long were transcribed again first
LISTEN_AGAIN = 0.45           # her reading ended -> the socket opened
REQUEST_AFTER_DONE = 0.38     # her reply landed -> its take was asked for
FIRST_SOUND = 1.5             # take asked for -> her first sound
CHARS_PER_SECOND = 7.8        # her pace, for a take the cache no longer holds


@dataclass
class Turn:
    sent_at: float            # his row's time, on the broadcast's clock
    seconds: float            # his recording's length
    reply_at: float           # her reply row landed
    reply: str
    take_seconds: float | None  # the cached take's length, or None when it is gone
    cut_at: float | None = None # he cut in during THIS reply, at this moment


def timeline(turns: list[Turn]) -> list[str]:
    lines: list[str] = []
    her_end: float | None = None
    previous_cut: float | None = None
    for n, turn in enumerate(turns, 1):
        if previous_cut is not None:
            began, why = previous_cut - PREROLL, "the cut, minus the pre-roll"
        elif turn.seconds < LONG_UTTERANCE:
            began, why = turn.sent_at - COMMIT_TO_ROW - turn.seconds, "his row dates its end"
        else:
            began, why = (her_end or 0.0) + LISTEN_AGAIN, "it follows her reading"
        lines.append(f"turn {n}: he talks {began:7.2f} -> {began + turn.seconds:7.2f}  ({why})")
        sound = turn.reply_at + REQUEST_AFTER_DONE + FIRST_SOUND
        length = turn.take_seconds or len(turn.reply) / CHARS_PER_SECOND
        stop = min(sound + length, turn.cut_at) if turn.cut_at else sound + length
        source = "the broadcast's own take" if turn.take_seconds else "silent, at her pace"
        lines.append(f"        she reads {sound:7.2f} -> {stop:7.2f}  ({source})")
        her_end, previous_cut = stop, turn.cut_at
    return lines


broadcast = [
    Turn(sent_at=12.40, seconds=6.2, reply_at=17.10, reply="Morning! Coffee first, news second?", take_seconds=3.4),
    Turn(sent_at=32.75, seconds=9.8, reply_at=37.60, reply="A long answer he stopped halfway through. " * 4,
         take_seconds=21.0, cut_at=47.00),
    Turn(sent_at=54.15, seconds=8.1, reply_at=58.90, reply="Fair point, the short version then.", take_seconds=None),
    Turn(sent_at=108.25, seconds=41.0, reply_at=113.00, reply="That was a lot. Let me take it in order.", take_seconds=4.0),
]
print("\n".join(timeline(broadcast)))

External links

Exercise

Run the sketch and check each placement against the rule that made it: turn 3 should begin 1.024 seconds before the 47.00 cut, and turn 4, a 41-second recording, should follow her reading. Then add the rule the sketch leaves out: a first sound taken from a fixed point (his next recording starting) wins over the 1.5-second average, but only when it falls between 0.5 and 3.5 seconds after the take was asked for. Finally, decide what your own replay should do with a take the cache no longer holds, and write the one line of UI copy that tells the person why it is silent.
Hint
Turn 3 prints 45.98, which is 47.00 minus 1.024: the cut-in recording carries the second before the cut. For the fixed-point rule, compute the candidate first sound from the next turn's start, check it against the window, and fall back to the average when it fails; a candidate outside the window usually means the fixed point belongs to something else. For the UI copy, say that the take is gone from the cache, that synthesizing it costs money, and that it will not sound the way it did on air.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.