Drive the read-aloud duration loop by hand — forward pass through the API, backward pass
watched in the database. Steps follow the app's Record-from-a-script flow.
Configuration
The token and the webhook key are held in these fields for this page load only; nothing is
written to disk. Start node tools/learning-loop-client/serve.mjs for the
database snapshot panel and the S3 PUT relay — without it the page still records,
uploads and publishes.
Learning Loop Flow
0. Publish a script1. Pick a role2. Prompter3. Record a take4. Processing5. Save or Publish6. Learning7. Prompter again
Step 0 is optional and has no app screen: it is the script-authoring side (validate,
create, publish) that the backend annotates at publish time, and the only way to get a
script whose durations come from the algorithm rather than the authored seconds.
Steps 1–5 and 7 each match a screen in the app's Record-from-a-script flow, named
below. Step 6 has no screen: the backward pass runs entirely in the backend, which is
why this page reads the database for it. The 3/2/1 countdown named in step 3 is the
receiver-side flow; the sender side uses three Rehearse/Record frames instead.
0. Publish a script optional — no app screen
Same three calls the authoring tools make, in order. The template below is the
end-to-end test's document: one location, two characters, one shot each, one dialogue
line each. Edit the lines to what you will actually say on camera; the second
character's line is there for the wrong-words variant. Every reset mints a new
manifest id, so the same page can publish as many scripts as you need.
Script ID-
Version-
Status-
Served shot durations-
What to look at now.Validate touches no table: the response's
valid, errors and extracted synopsis are the whole
result. Create writes spml_script (status = draft) and one
spml_script_version holding the JSON; no character, shot or event rows exist
yet, and the version's neutral_sum_ms is null. Publish is
the forward pass's write: one transaction flips the status, fans the JSON out into
spml_character_definition / spml_shot_definition /
spml_event_definition, stamps every dialogue event's
neutral_text_ms from the estimator, and rolls those into the shot and
version neutral_sum_ms. The "Served shot durations" line above re-reads
the shots after publish: a value that is not a round number of seconds is the
algorithm's; the authored expectedDuration only shows when a sum is missing.
1. Pick a role 01 Choose a script → 05 Picking a role
What to look at now. Snapshot: a performance row appears with
status = draft, learning_state = null,
attempts = 0. No shot, no media and
no calibration row yet — that row is born at the speaker's first stored
prediction, not at sign-up.
Active Performance
Performance ID-
Status-
Learning state-
Shots-
2. Prompter 10 Prompter with their dialog → 14 full screen prompter view
The served duration below is what paces the prompter on the phone. It is the forward
pass's output: the shot's neutral text estimate scaled by the platform tuning factor
and this speaker's personal scale.
What to look at now. Each row shows the served
expectedDurationMs. The neutral basis and the two factors are
not exposed over HTTP — they are read from the snapshot
(spml_shot_definition.neutral_sum_ms, the shot's
platform_tuning_used, and the calibration's
personal_scale) and shown greyed beneath each duration once a
performance exists. Before this speaker has any calibration row, the served value is
neutral × live platform T × 1.0.
3. Record a take 07 Toast Recording As → 09 Timer → 13 prompter recording
Each row in the prompter list above carries a Record button (camera, 3-2-1
countdown, prompter paced by the served duration, elapsed timer, preview, then upload)
and an Upload file button for a clip from disk such as the e2e fixture at
apps/api-gateway/test/fixtures/learning-loop-clip.mp4. Both run the same
client upload flow: upload-url → presigned part URL → PUT →
upload-complete. The served duration paces the prompter only; recording
runs until you stop it, as in the app.
What to look at now. Snapshot after the upload completes. The shot row
carries the stamped prediction basis:
neutral_duration_ms, platform_tuning_used and
predicted_duration_ms, with
learning_status = not_evaluated. The calibration row now exists
with reading_count = 0, personal_scale = 1 and a frozen
platform_tuning_snapshot. The media row walks toward
processing and then grows a processing_job_id once the real
transcode submission returns.
4. Processing 13 (a) performance submitted
On the phone this is where the app shows the take processing. If the transcode
webhook is diverted to this machine (ngrok), the completion event arrives on its own
and the button below is not needed: snapshot and watch media then shot flip to ready.
Without a divert the event bus cannot reach a laptop, so the button posts the real
event shape to the real webhook with the real job id the transcode submission minted.
Everything the completion path does then runs as production code either way.
What to look at now. Snapshot: media moves to
status = ready with processing_metadata.status = COMPLETE,
and the shot follows to status = ready with a
playback_url. The parent's completed_shots ticks up. An
empty processing_job_id means the submission has not returned yet
— snapshot again rather than inventing an id.
5. Save as draft or Publish 12 Performance privacy → 15 Publishing → 16 Scene published
Publish this performance
Saving as a draft is simply not pressing this. A draft never enqueues the
backward pass, so nothing in step 6 will move until you publish.
What to look at now. Only a response of status: "published"
enqueues the backward pass. processing means a shot was not
ready yet; learning is enqueued later when the remaining shots turn
ready and the parent is auto-published, but nothing on this page will hurry it.
Snapshot: learning_state = pending with a next_attempt_at.
6. Learning no app screen — backend only
What to look at now.learning_state leaves
pending for one of learned,
nothing_to_learn or failed. On learned: the
shot gets learning_status = learned and a non-null
measured_ms; exactly one contribution row appears with its
ratio, delta_num, delta_den and the
personal_scale_at_contribution it folded against; the
calibration row's reading_count and personal_scale
move; and platform_tuning's num/den move by exactly
those deltas.
A pending sitting on a next_attempt_at far in the future is
parked, not slow: nothing re-drives it while the sweep is off. That is the shape of a
measurement call that failed — a measurement service that is down, a token
mismatch, or an allowed-clip-host list that does not match the presign host.
7. Prompter again 14 full screen prompter view
The prompter list in step 2 updates in place: the new served duration in green, the
previous one struck through beside it, and the difference in amber.
What to look at now. The served duration for the learned shot should differ
from before, and should equal
neutral_sum_ms × platform_tuning_snapshot × personal_scale
using the calibration row's frozen snapshot, not the live platform factor. If
it comes back at the platform value instead, the personalised read degraded: the
calibration lookup has a deliberately tight cap and a cold connection can exceed it.
Variants
Sweep
The reconciliation sweep has no HTTP surface, so there is no button for it. It
runs inside content-service behind LEARNING_SWEEP_ENABLED (off by
default), and its lane takes a Postgres advisory lock — a second
content-service on the same database turns a tick into a silent no-op.
To watch it by hand: strand a shot the way the sweep's first duty selects it, enable
the sweep, restart content-service, then snapshot.
Press the button to fill in the current ids.
What to look at now. After a tick, the stranded shot is back to
status = ready and the parent's completed_shots is restored.
Assert from the rows, never from the sweep's return value: every duty swallows its
own exceptions, so a throw and a quiet hour look identical from outside.