diff --git a/.claude/skills/t27f/SKILL.md b/.claude/skills/t27f/SKILL.md index 187a88c8ab..9fcccf74ae 100644 --- a/.claude/skills/t27f/SKILL.md +++ b/.claude/skills/t27f/SKILL.md @@ -73,6 +73,14 @@ the reels. Describing T27F as #1677 is the error this section exists to stop (ow When asked "how is T27F going", report against the core rows first. Progress on the applications is not progress on the core. +**Trap: a name declared in two specs cannot be imported (t27#10556).** Measured on t27c 0.5.2: +`use t27f::lesson_reel::phrase_start` returns `UNRESOLVED ... ambiguous, not spliced`, because +`token_floor_mux.t27` declares a private `fn phrase_start`. The same happens to `cta_from` and +`voice_fits`. The explicit path does not help. Before you plan a new module on top of a reel spec, +grep `specs/` for every name it would import. If any of them collides, put the new code inside the +spec that owns those names, under a prefix such as `beat_`. The beat clock (t27#10596, PR #10598) +lives in `lesson_reel.t27` for this reason. + ## Show a spec (the core: exporter for what it reads, by hand for the rest) 1. Load the spec: `mcp__t27__t27_spec` with `path` and `source: true`, or the file from a diff --git a/.trinity/seals/t27f_lesson_reel.json b/.trinity/seals/t27f_lesson_reel.json index 490e46ad7c..5a1643fc9e 100644 --- a/.trinity/seals/t27f_lesson_reel.json +++ b/.trinity/seals/t27f_lesson_reel.json @@ -5,6 +5,10 @@ "spec": "specs/automation/owner_voice_reel.t27", "spec_hash": "sha256:76c0df8e57686a90b3bce85bcb0148cbed7fd05da724b217ef53fa468664f151" }, + { + "spec": "specs/t27f/reel.t27", + "spec_hash": "sha256:c47b21cea1d7169da6528e321cf2d2c513d3f552c0cd04fe9d37bbd5314ccacf" + }, { "spec": "specs/t27f/reel_marketing.t27", "spec_hash": "sha256:ab07f6476d900cc0e089ba1d851f0b610a77b778f00fd906d1b98a29281941c7" @@ -27,23 +31,23 @@ } ], "config": "gen=c,rust,verilog,zig;tests=zig", - "gen_hash_c": "sha256:74d5f5d8fad98ead782f6c26309ed349da58471650642503a13a8e32cb7427ae", - "gen_hash_rust": "sha256:ccc5014bd6bf09ba55b21e4d5bd5fb80383c36cce4d83468812bdd4fadd04835", - "gen_hash_verilog": "sha256:f956226ada7459fc5d856060971f9160ab41ee78b1a5dbf713ad8531e3d41abd", - "gen_hash_zig": "sha256:42635d596d160c7c49521929d8e4f8f640f89a3180b5e1ac118c0177c4d14545", + "gen_hash_c": "sha256:6a381e722c19647a8f05c723633503d1d931b143538c6183f8832fec577ebeb7", + "gen_hash_rust": "sha256:bf7b0432da8ec2e1bcf0f139aa271e9df74986d67c81dc1bb0bcacd19357d1db", + "gen_hash_verilog": "sha256:836331bc2320ec6d80ce1580e30f1d3a9cc9fbd89325995f141c090282a68ec7", + "gen_hash_zig": "sha256:50a202697595b03abac33b3707f25a239343ae7aa2256b0c43fec31b950cf61b", "module": "lesson_reel", "ring": 12, "seal_schema": 2, - "sealed_at": "2026-10-18T15:14:32Z", + "sealed_at": "2026-10-18T18:33:33Z", "sealed_by": "t27c-bootstrap@0.5.2", - "spec_hash": "sha256:0c527e6415d5ffe600cd425db2456e1691e47bcaaf69cf5af0b856bb8bfec66e", + "spec_hash": "sha256:87165b892aaaba0647fe1657669524eca65c31547f11f4e4994abb830c673b0e", "spec_path": "specs/t27f/lesson_reel.t27", "tests": { "failed": 0, "failing": [], "forced": false, - "passed": 6, - "total": 6, + "passed": 13, + "total": 13, "vacuous": 0 }, "toolchain": { diff --git a/specs/t27f/lesson_reel.t27 b/specs/t27f/lesson_reel.t27 index 29726fc24b..b6a0455048 100644 --- a/specs/t27f/lesson_reel.t27 +++ b/specs/t27f/lesson_reel.t27 @@ -24,9 +24,21 @@ // title and the goal. A caption chunk is a run of whole words up to CHUNK_CHARS, timed by its // share of its phrase's letters -- an estimate, not a word-level alignment. // +// BEATS (t27#10596, R4a of t27#10560): reel_beats.t27 gives every reel three scenes, open, main +// and close; the beat clock below says when each is on screen. Cut 0 is the goal phrase's start +// and cut 1 is where the address takes over, so the picture turns with the sentence rather than at +// fixed thirds of the reel, which on lesson 1 land inside a spoken phrase (the control test). A +// title too short to read holds the open beat to BEAT_OPEN_FLOOR_MS, unless the voice reaches the +// address before BEAT_SPOKEN_MIN_MS: then the open beat gives way and the main beat keeps its +// scene (one point on the test grid, 200/1500/13100 ms, where the task is dropped). Every cut is a crossfade of +// reel.t27's TRANSITION_FRAMES, centred on the cut. The clock lives here, not in a module of its +// own, because a module that imports phrase_start, cta_from or voice_fits from this file gets +// none of them: t27c's splicer finds a second declaration of each in this file's imports and +// drops both (t27#10556). +// // NOT CLAIMED: the backdrop is token_floor's physics world, not a picture of the lesson; its frame -// labels are the physics reel's. A lesson-specific scene (the spec as a T27F diagram) is the next -// step and is not here. +// labels are the physics reel's. The beat clock is not yet wired into the graph: the frames that +// follow it are R4b. // phi^2 + 1/phi^2 = 3 | TRINITY module lesson_reel; @@ -95,6 +107,9 @@ use t27f::reel_topics::code_lines; use t27f::reel_topics::line_len; use t27f::reel_topics::code_byte; use t27f::reel_topics::lesson_n; +use t27f::reel::BASE_FPS; +use t27f::reel::TRANSITION_FRAMES; +use t27f::reel::MIN_SCENE_FRAMES; pub const ID : str = "lesson-reel"; // JetBrains Mono ExtraBold with both scripts and the digits, so one face sets every line. @@ -598,6 +613,108 @@ pub fn graph(o: usize, t: usize, lang: usize, m0: i32, m1: i32, m2: i32) -> void std.debug.print("[vo][mu]amix=inputs=2:normalize=0:duration=first,alimiter=limit=0.{d:0>3}[a]\n", .{millis(LIMIT_PERMILLE)}); } +// ---- the beat clock ---- +// Which of the reel's three beats is on screen at ms x, and how much of each when two crossfade. +// The middle beat's weight is computed on its own, not as the rest of 1000, so the test that the +// three sum to 1000 can fail: it fails when two crossfades overlap. + +pub const BEAT_OPEN : usize = 0; +pub const BEAT_MAIN : usize = 1; +pub const BEAT_CLOSE : usize = 2; + +// The crossfade and the shortest time a beat is alone on screen, in ms: reel.t27's frame counts +// at its frame rate, which the first beat test checks. +pub const BEAT_XFADE_MS : i32 = 500; +pub const BEAT_HALF_MS : i32 = 250; +pub const BEAT_MIN_SOLO_MS : i32 = 1000; +// The earliest cut 0 may fall: the open beat alone for BEAT_MIN_SOLO_MS, then half the crossfade. +pub const BEAT_OPEN_FLOOR_MS : i32 = 1250; + +// The time of base frame f, and how many base frames the reel has. +pub fn beat_frame_ms(f: i32) -> i32 { + return @divTrunc(f * 1000, BASE_FPS); +} + +pub fn beat_frames() -> i32 { + return @divTrunc(REEL_MS * BASE_FPS, 1000); +} + +// The shortest time from the reel's start to the address that leaves every beat a scene of its own: +// the open floor, a crossfade, and the main beat alone for BEAT_MIN_SOLO_MS. +pub const BEAT_SPOKEN_MIN_MS : i32 = 2750; + +// Cut k of lesson t's reel in language lang: 0 open to main, 1 main to close. Cut 0 is the goal's +// start, held to the open floor, but never so late that the main beat loses its scene: when the +// voice reaches the address before BEAT_SPOKEN_MIN_MS the open beat gives way, not the main one. +pub fn beat_cut_ms(t: usize, lang: usize, m0: i32, m1: i32, m2: i32, k: usize) -> i32 { + const c1 = cta_from(t, lang, m0, m1, m2); + if (k == 0) { + var s : i32 = phrase_start(m0, m1, m2, 1); + if (s < BEAT_OPEN_FLOOR_MS) { + s = BEAT_OPEN_FLOOR_MS; + } + const last = c1 - BEAT_XFADE_MS - BEAT_MIN_SOLO_MS; + if (s > last) { + s = last; + } + return s; + } + return c1; +} + +// How far the incoming beat has faded in at ms x for a cut at c, per mille. +pub fn beat_ramp(x: i32, c: i32) -> i32 { + const a = c - BEAT_HALF_MS; + if (x <= a) { + return 0; + } + if (x >= a + BEAT_XFADE_MS) { + return 1000; + } + return @divTrunc((x - a) * 1000, BEAT_XFADE_MS); +} + +fn beat_min(a: i32, b: i32) -> i32 { + if (a < b) { + return a; + } + return b; +} + +// Beat b's weight at ms x, per mille, for cuts c0 and c1. +pub fn beat_weight(b: usize, x: i32, c0: i32, c1: i32) -> i32 { + if (b == BEAT_OPEN) { + return 1000 - beat_ramp(x, c0); + } + if (b == BEAT_MAIN) { + return beat_min(beat_ramp(x, c0), 1000 - beat_ramp(x, c1)); + } + return beat_ramp(x, c1); +} + +// The beat a still at ms x shows: the one after the last cut reached. At a cut both weigh 500 and +// the incoming beat wins. +pub fn beat_at(x: i32, c0: i32, c1: i32) -> usize { + if (x < c0) { + return BEAT_OPEN; + } + if (x < c1) { + return BEAT_MAIN; + } + return BEAT_CLOSE; +} + +// How long beat b is alone on screen (weight 1000), in ms. +pub fn beat_solo_ms(b: usize, c0: i32, c1: i32) -> i32 { + if (b == BEAT_OPEN) { + return c0 - BEAT_HALF_MS; + } + if (b == BEAT_MAIN) { + return c1 - c0 - BEAT_XFADE_MS; + } + return REEL_MS - c1 - BEAT_HALF_MS; +} + // ---- main ---- // The environment variable `name` as a number of up to six digits; dflt when unset. Only main @@ -821,3 +938,169 @@ test "the files are numbered in order, and the tags read in upper case" { t = t + 1; } } + +// ---- the beat clock's tests ---- + +// Phrase lengths the grid test sweeps: a title from barely a word to long, a goal from short to +// one the voice rule refuses after a long title (14000), a task from short to one the reel drops +// (13100 after a 200 ms title and a 1500 ms goal leaves the voice at the address by 2700 ms). +pub const BEAT_GRID_M0 : [4]i32 = [200, 700, 1300, 2500]; +pub const BEAT_GRID_M1 : [4]i32 = [1500, 4200, 8000, 14000]; +pub const BEAT_GRID_M2 : [5]i32 = [800, 4000, 6800, 9000, 13100]; + +test "the crossfade and the floor are the reel's own frame counts" { + assert(BEAT_XFADE_MS == beat_frame_ms(TRANSITION_FRAMES)); + assert(BEAT_MIN_SOLO_MS == beat_frame_ms(MIN_SCENE_FRAMES)); + assert(2 * BEAT_HALF_MS == BEAT_XFADE_MS); + assert(BEAT_OPEN_FLOOR_MS == BEAT_MIN_SOLO_MS + BEAT_HALF_MS); + assert(beat_frames() == 450); +} + +test "on lesson 1's measured voice the cuts fall where the sentence changes" { + const c0 = beat_cut_ms(0, 0, M_EN0, M_EN1, M_EN2, 0); + const c1 = beat_cut_ms(0, 0, M_EN0, M_EN1, M_EN2, 1); + assert(c0 == phrase_start(M_EN0, M_EN1, M_EN2, 1)); + assert(c0 > BEAT_OPEN_FLOOR_MS); + assert(c1 == cta_from(0, 0, M_EN0, M_EN1, M_EN2)); + // the open beat ends after the title is spoken, and the close starts after the task is + assert(c0 > phrase_start(M_EN0, M_EN1, M_EN2, 0) + played(M_EN0, M_EN1, M_EN2, 0)); + assert(c1 >= phrase_start(M_EN0, M_EN1, M_EN2, 2) + played(M_EN0, M_EN1, M_EN2, 2)); +} + +test "a title too short to read holds the open beat to the floor" { + assert(voice_fits(200, 4200, 6800)); + assert(phrase_start(200, 4200, 6800, 1) < BEAT_OPEN_FLOOR_MS); + const c0 = beat_cut_ms(0, 0, 200, 4200, 6800, 0); + const c1 = beat_cut_ms(0, 0, 200, 4200, 6800, 1); + assert(c0 == BEAT_OPEN_FLOOR_MS); + assert(beat_solo_ms(BEAT_OPEN, c0, c1) == BEAT_MIN_SOLO_MS); +} + +test "every beat is alone on screen for a scene's length, every lesson, both languages, every voice on the grid" { + var checked : usize = 0; + var early : usize = 0; + var refused : usize = 0; + var t : usize = 0; + while (t < TOPICS) { + var lang : usize = 0; + while (lang < 2) { + var i : usize = 0; + while (i < 4) { + var j : usize = 0; + while (j < 4) { + var k : usize = 0; + while (k < 5) { + const m0 = BEAT_GRID_M0[i]; + const m1 = BEAT_GRID_M1[j]; + const m2 = BEAT_GRID_M2[k]; + if (voice_fits(m0, m1, m2)) { + const c0 = beat_cut_ms(t, lang, m0, m1, m2, 0); + const c1 = beat_cut_ms(t, lang, m0, m1, m2, 1); + // the main and close beats keep their scene on every voice the rule takes + assert(beat_solo_ms(BEAT_MAIN, c0, c1) >= BEAT_MIN_SOLO_MS); + assert(beat_solo_ms(BEAT_CLOSE, c0, c1) >= BEAT_MIN_SOLO_MS); + assert(c0 >= phrase_start(m0, m1, m2, 1) || c1 < BEAT_SPOKEN_MIN_MS); + if (c1 >= BEAT_SPOKEN_MIN_MS) { + assert(beat_solo_ms(BEAT_OPEN, c0, c1) >= BEAT_MIN_SOLO_MS); + checked = checked + 1; + } else { + // a voice at the address this early cannot give the open beat a scene too + assert(beat_solo_ms(BEAT_OPEN, c0, c1) < BEAT_MIN_SOLO_MS); + early = early + 1; + } + } else { + refused = refused + 1; + } + k = k + 1; + } + j = j + 1; + } + i = i + 1; + } + lang = lang + 1; + } + t = t + 1; + } + // the grid reaches all three outcomes: a full clock, a voice too short for an open scene, and + // a voice the rule refuses; the short case is one grid point, 200/1500/13100, per lesson + assert(checked > 0); + assert(refused > 0); + assert(early == TOPICS * 2); + assert(checked + early + refused == TOPICS * 2 * 80); +} + +test "the three weights sum to a whole at every 10 ms and each beat fades one way" { + var sel : usize = 0; + var seen : [3]usize = [0, 0, 0]; + while (sel < 2) { + var m0 : i32 = M_EN0; + if (sel == 1) { + m0 = 200; + } + const c0 = beat_cut_ms(0, 0, m0, M_EN1, M_EN2, 0); + const c1 = beat_cut_ms(0, 0, m0, M_EN1, M_EN2, 1); + assert(beat_weight(BEAT_OPEN, c0, c0, c1) == 500); + assert(beat_weight(BEAT_MAIN, c0, c0, c1) == 500); + assert(beat_weight(BEAT_MAIN, c1, c0, c1) == 500); + assert(beat_weight(BEAT_CLOSE, c1, c0, c1) == 500); + var last0 : i32 = 1000; + var last2 : i32 = 0; + var lastb : usize = BEAT_OPEN; + var x : i32 = 0; + while (x <= REEL_MS) { + const w0 = beat_weight(BEAT_OPEN, x, c0, c1); + const w1 = beat_weight(BEAT_MAIN, x, c0, c1); + const w2 = beat_weight(BEAT_CLOSE, x, c0, c1); + assert(w0 + w1 + w2 == 1000); + assert(w0 <= last0); + assert(w2 >= last2); + const b = beat_at(x, c0, c1); + assert(b >= lastb); + // the beat a still shows is never the one that has faded out + assert(beat_weight(b, x, c0, c1) >= 500); + seen[b] = seen[b] + 1; + last0 = w0; + last2 = w2; + lastb = b; + x = x + 10; + } + sel = sel + 1; + } + assert(seen[0] > 0); + assert(seen[1] > 0); + assert(seen[2] > 0); +} + +test "over the reel's 450 frames the picture changes beat exactly twice" { + const c0 = beat_cut_ms(0, 0, M_EN0, M_EN1, M_EN2, 0); + const c1 = beat_cut_ms(0, 0, M_EN0, M_EN1, M_EN2, 1); + var changes : usize = 0; + var last : usize = BEAT_OPEN; + var f : i32 = 0; + while (f < beat_frames()) { + const b = beat_at(beat_frame_ms(f), c0, c1); + if (b != last) { + changes = changes + 1; + } + last = b; + f = f + 1; + } + assert(changes == 2); + assert(last == BEAT_CLOSE); +} + +test "control: a cut at fixed thirds of the reel lands inside a spoken phrase" { + const third = @divTrunc(REEL_MS, 3); + const g0 = phrase_start(M_EN0, M_EN1, M_EN2, 1); + const g1 = g0 + played(M_EN0, M_EN1, M_EN2, 1); + const k0 = phrase_start(M_EN0, M_EN1, M_EN2, 2); + const k1 = k0 + played(M_EN0, M_EN1, M_EN2, 2); + // the first third falls inside the goal, the second inside the task + assert(third > g0 && third < g1); + assert(2 * third > k0 && 2 * third < k1); + // the voice clock's cuts fall on no spoken word + const c0 = beat_cut_ms(0, 0, M_EN0, M_EN1, M_EN2, 0); + const c1 = beat_cut_ms(0, 0, M_EN0, M_EN1, M_EN2, 1); + assert(c0 <= g0); + assert(c1 >= k1); +}