From 1daf310faf4292271f280b9ff7b109d7a297c097 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Mon, 19 Oct 2026 01:33:45 +0700 Subject: [PATCH 01/13] t27f: beat clock -- the reel's three beats cut where the voice changes, crossfaded (Closes #10596) R4a of epic #10560. lesson_reel.t27 gains a beat clock: cut 0 at the goal phrase's start (held to a 1250 ms open floor), cut 1 at cta_from, each a crossfade of reel.t27's TRANSITION_FRAMES centred on the cut. Seven tests: frame constants, lesson 1's measured cuts, the short-title floor, a grid of 80 voices x every lesson x both languages (full clock, voice too short for an open scene, refused), weights summing to 1000 at every 10 ms, exactly two beat changes over 450 frames, and a fixed-thirds control that lands mid-phrase. The clock lives in lesson_reel.t27, not in the beat_clock.t27 module the issue names: importing phrase_start or cta_from into another module leaves them UNRESOLVED because token_floor_mux.t27 declares the same names, and the splicer drops ambiguous imports even on an explicit path (#10556). Lab: 13/13 tests pass, 0 vacuous; seal saved and verified. t27b still blocked on type [N]T, as the ledger row already says; max_not_pass unchanged. Co-Authored-By: Claude Opus 5.5 --- .trinity/seals/t27f_lesson_reel.json | 20 +- specs/t27f/lesson_reel.t27 | 287 ++++++++++++++++++++++++++- 2 files changed, 297 insertions(+), 10 deletions(-) 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); +} From 0abe94638bb05a9b4dfef6a94565946e20834bf5 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Mon, 19 Oct 2026 01:35:09 +0700 Subject: [PATCH 02/13] t27f skill: the splicer trap that kept the beat clock inside lesson_reel (Refs #10596) Co-Authored-By: Claude Opus 5.5 --- .claude/skills/t27f/SKILL.md | 8 ++++++++ 1 file changed, 8 insertions(+) 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 From 8aa6f917877af70de08d83dc1a8f60b9a52d64ac Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Mon, 19 Oct 2026 01:46:29 +0700 Subject: [PATCH 03/13] t27f: beat graph -- the reel's three scenes crossfaded at the beat clock's cuts, on whole frames (Closes #10603) lesson_reel.t27 gains: - beat_xfade_frame: the last base frame at or before c - BEAT_HALF_MS. - beat_solo_frames: a floor on how many frames each beat shows alone. - beat_graph: inputs 0..2 (the open, main and close scene clips), crossfaded with xfade at offset beat_frame_ms(f) in AV_TIME_BASE, then trimmed to 450 frames. - LR_MODE=4, which prints the graph and refuses a voice that does not fit, as mode 3 does. Tests: 17/17 pass, 0 vacuous. - New: every cut from 1250 to 13500 ms lands on its frame. - New: the offset for every frame 0..450 selects that frame. - New control: an offset rounded up misses 300 of 451 frames. - New: on R4a's voice grid, every beat keeps MIN_SCENE_FRAMES whole frames, except the counted early case. Measured with ffmpeg 5.1 on the Railway lab, on solid-colour inputs, 450 frames each: - 1300/4200/6800 ms: runs 50 / 332 / 38. - 2500/8000/9000 ms: runs 88 / 235 / 98. - 200/1500/13100 ms: runs 28 / 30 / 362. - 9000/9000/9000 ms: refused. Every run equals beat_solo_frames, with one extra frame when the offset falls on a whole ms. Co-Authored-By: Claude Opus 5.5 --- .trinity/seals/t27f_lesson_reel.json | 16 +-- specs/t27f/lesson_reel.t27 | 146 ++++++++++++++++++++++++++- 2 files changed, 152 insertions(+), 10 deletions(-) diff --git a/.trinity/seals/t27f_lesson_reel.json b/.trinity/seals/t27f_lesson_reel.json index 5a1643fc9e..ae4cace0cf 100644 --- a/.trinity/seals/t27f_lesson_reel.json +++ b/.trinity/seals/t27f_lesson_reel.json @@ -31,23 +31,23 @@ } ], "config": "gen=c,rust,verilog,zig;tests=zig", - "gen_hash_c": "sha256:6a381e722c19647a8f05c723633503d1d931b143538c6183f8832fec577ebeb7", - "gen_hash_rust": "sha256:bf7b0432da8ec2e1bcf0f139aa271e9df74986d67c81dc1bb0bcacd19357d1db", - "gen_hash_verilog": "sha256:836331bc2320ec6d80ce1580e30f1d3a9cc9fbd89325995f141c090282a68ec7", - "gen_hash_zig": "sha256:50a202697595b03abac33b3707f25a239343ae7aa2256b0c43fec31b950cf61b", + "gen_hash_c": "sha256:45fe54cd4be6d62ca81d3decf493c9999a451c7bc4149e49f86c0edc676e6265", + "gen_hash_rust": "sha256:1b2673482a4af8a938ef6146a88f87aa619a7e4e4d5b9c1155b91cf20eb65e01", + "gen_hash_verilog": "sha256:00b75242dd4b9b49a1bbb2720d3a7b817dd8715d1d8a6f36b80cdf83145f0a04", + "gen_hash_zig": "sha256:a45ecc01256a49e9d61781595043045a7ff98f34abfe22df6ae3b6bb8f7dd8a9", "module": "lesson_reel", "ring": 12, "seal_schema": 2, - "sealed_at": "2026-10-18T18:33:33Z", + "sealed_at": "2026-10-18T18:44:43Z", "sealed_by": "t27c-bootstrap@0.5.2", - "spec_hash": "sha256:87165b892aaaba0647fe1657669524eca65c31547f11f4e4994abb830c673b0e", + "spec_hash": "sha256:69663c90a7ad61dbdf5a017d97f28d2dc0c885dae0643a361d43bbae31787a9a", "spec_path": "specs/t27f/lesson_reel.t27", "tests": { "failed": 0, "failing": [], "forced": false, - "passed": 13, - "total": 13, + "passed": 17, + "total": 17, "vacuous": 0 }, "toolchain": { diff --git a/specs/t27f/lesson_reel.t27 b/specs/t27f/lesson_reel.t27 index b6a0455048..424f04e743 100644 --- a/specs/t27f/lesson_reel.t27 +++ b/specs/t27f/lesson_reel.t27 @@ -15,6 +15,8 @@ // 3 the two ffmpeg -filter_complex_script graphs, tall then wide, given LR_MS0..LR_MS2, the // phrases' lengths in ms as the voice service returned them. Inputs: 0 the frames, // 1..3 the phrases' wavs, 4 the music. +// 4 the beat graph (t27#10603), given LR_MS0..LR_MS2: inputs 0..2 the open, main and close +// scene clips, crossfaded on whole frames at the beat clock's cuts and trimmed to the reel // Every text that comes from the lesson goes through a textfile, so no quote, colon or percent in // a lesson can break the graph (and drawtext runs with expansion=none). // @@ -37,8 +39,8 @@ // 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. The beat clock is not yet wired into the graph: the frames that -// follow it are R4b. +// labels are the physics reel's. Mode 4 cuts the three scenes but lays nothing over them: mode 3's +// captions and address on top of the beat video are R4c, and the scenes themselves are R3. // phi^2 + 1/phi^2 = 3 | TRINITY module lesson_reel; @@ -715,6 +717,46 @@ pub fn beat_solo_ms(b: usize, c0: i32, c1: i32) -> i32 { return REEL_MS - c1 - BEAT_HALF_MS; } +// ---- the beat graph ---- +// The clock's cuts are in ms and fall between frames. A crossfade starts on a frame, so each cut +// takes the last base frame at or before c - BEAT_HALF_MS; both cuts move the same way, so neither +// can take a frame from the other's beat. + +// The base frame where the crossfade for a cut at ms c starts. +pub fn beat_xfade_frame(c: i32) -> i32 { + return @divTrunc((c - BEAT_HALF_MS) * BASE_FPS, 1000); +} + +// How many base frames beat b shows alone, for crossfades starting at frames f0 and f1. The frame a +// crossfade starts on is still the outgoing beat whole; it is counted to neither, so this is a floor. +pub fn beat_solo_frames(b: usize, f0: i32, f1: i32) -> i32 { + if (b == BEAT_OPEN) { + return f0; + } + if (b == BEAT_MAIN) { + return f1 - f0 - TRANSITION_FRAMES; + } + return beat_frames() - f1 - TRANSITION_FRAMES; +} + +// The ffmpeg graph that crossfades the three scene clips, inputs 0 open, 1 main, 2 close, at the +// clock's cuts and trims the result to the reel. Each clip starts at its own frame 0 when its beat +// comes in. xfade starts on the first frame whose time is at or after its offset, so the offset is +// beat_frame_ms(f), which never runs past frame f (the offset test); the timebase is AV_TIME_BASE +// so the offset is not rounded again. +pub fn beat_graph(t: usize, lang: usize, m0: i32, m1: i32, m2: i32) -> void { + var b : usize = 0; + while (b < 3) { + std.debug.print("[{d}:v]fps={d},settb=AVTB,format=yuv420p[b{d}];", .{ b, BASE_FPS, b }); + b = b + 1; + } + const d = beat_frame_ms(TRANSITION_FRAMES); + const a0 = beat_frame_ms(beat_xfade_frame(beat_cut_ms(t, lang, m0, m1, m2, 0))); + const a1 = beat_frame_ms(beat_xfade_frame(beat_cut_ms(t, lang, m0, m1, m2, 1))); + std.debug.print("[b0][b1]xfade=transition=fade:duration={d}.{d:0>3}:offset={d}.{d:0>3}[x0];", .{ secs(d), millis(d), secs(a0), millis(a0) }); + std.debug.print("[x0][b2]xfade=transition=fade:duration={d}.{d:0>3}:offset={d}.{d:0>3},trim=end_frame={d},setpts=PTS-STARTPTS[v]\n", .{ secs(d), millis(d), secs(a1), millis(a1), beat_frames() }); +} + // ---- main ---- // The environment variable `name` as a number of up to six digits; dflt when unset. Only main @@ -769,6 +811,15 @@ pub fn main() -> void { } else { std.debug.print("lesson_reel: the voice does not fit the reel\n", .{}); } + } else if (mode == 4) { + const m0 = env_num("LR_MS0", 0); + const m1 = env_num("LR_MS1", 0); + const m2 = env_num("LR_MS2", 0); + if (voice_fits(m0, m1, m2)) { + beat_graph(t, lang, m0, m1, m2); + } else { + std.debug.print("lesson_reel: the voice does not fit the reel\n", .{}); + } } else { std.debug.print("{d}\n", .{files(t, lang)}); } @@ -1104,3 +1155,94 @@ test "control: a cut at fixed thirds of the reel lands inside a spoken phrase" { assert(c0 <= g0); assert(c1 >= k1); } + +// ---- the beat graph's tests ---- + +test "a crossfade starts on the last frame at or before half a crossfade ahead of its cut" { + var c : i32 = BEAT_OPEN_FLOOR_MS; + var n : usize = 0; + while (c <= REEL_MS - REEL_CTA_MIN_MS) { + const f = beat_xfade_frame(c); + // frame f's time, f * 1000 / BASE_FPS, is at or before c - BEAT_HALF_MS; frame f + 1's is after + assert(f * 1000 <= (c - BEAT_HALF_MS) * BASE_FPS); + assert((f + 1) * 1000 > (c - BEAT_HALF_MS) * BASE_FPS); + n = n + 1; + c = c + 1; + } + assert(n == 12251); + assert(beat_xfade_frame(BEAT_OPEN_FLOOR_MS) == MIN_SCENE_FRAMES); +} + +test "the offset printed for frame f selects frame f and no earlier frame" { + var f : i32 = 0; + while (f <= beat_frames()) { + const a = beat_frame_ms(f) * BASE_FPS; + assert(a <= f * 1000); + assert(a > (f - 1) * 1000); + if (f > 0) { + assert(beat_frame_ms(f) > beat_frame_ms(f - 1)); + } + f = f + 1; + } +} + +test "control: an offset rounded up to the next ms misses its frame on two frames in three" { + var missed : usize = 0; + var f : i32 = 0; + while (f <= beat_frames()) { + const up = @divTrunc(f * 1000 + BASE_FPS - 1, BASE_FPS); + if (up * BASE_FPS > f * 1000) { + missed = missed + 1; + } + f = f + 1; + } + // frames 0..450: the 151 multiples of 3 are on a whole ms, the other 300 are not + assert(missed == 300); +} + +test "on whole frames every beat keeps a scene on every voice the clock gives three" { + var checked : usize = 0; + var early : 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); + const f0 = beat_xfade_frame(c0); + const f1 = beat_xfade_frame(c1); + assert(f0 >= 0); + assert(f1 + TRANSITION_FRAMES <= beat_frames()); + assert(beat_solo_frames(BEAT_MAIN, f0, f1) >= MIN_SCENE_FRAMES); + assert(beat_solo_frames(BEAT_CLOSE, f0, f1) >= MIN_SCENE_FRAMES); + if (c1 >= BEAT_SPOKEN_MIN_MS) { + assert(beat_solo_frames(BEAT_OPEN, f0, f1) >= MIN_SCENE_FRAMES); + checked = checked + 1; + } else { + assert(beat_solo_frames(BEAT_OPEN, f0, f1) < MIN_SCENE_FRAMES); + early = early + 1; + } + } + k = k + 1; + } + j = j + 1; + } + i = i + 1; + } + lang = lang + 1; + } + t = t + 1; + } + assert(checked > 0); + assert(early == TOPICS * 2); +} From 20a9a6a6f7080d8daa7088cb8d9867fa9d207639 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Mon, 19 Oct 2026 01:47:47 +0700 Subject: [PATCH 04/13] t27f skill: cut video on a frame and check it by counting solid-colour runs (Refs #10603) Co-Authored-By: Claude Opus 5.5 --- .claude/skills/t27f/SKILL.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.claude/skills/t27f/SKILL.md b/.claude/skills/t27f/SKILL.md index 9fcccf74ae..0d529a6abd 100644 --- a/.claude/skills/t27f/SKILL.md +++ b/.claude/skills/t27f/SKILL.md @@ -81,6 +81,25 @@ grep `specs/` for every name it would import. If any of them collides, put the n 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. +**Cutting video at a ms time: land on a frame, then measure the runs (t27#10603, PR #10605).** + +ffmpeg `xfade` starts on the first frame whose time is at or after `offset`. This is how to get the cut you planned: + +- Floor the cut to a base frame: `f = (c - half) * fps / 1000`. +- Print the offset as `trunc(f * 1000 / fps)` ms. Rounding up misses frame f on 2 frames in 3. +- Put every input through `fps=30,settb=AVTB` so the offset is not rounded again. +- Chained `xfade` offsets are absolute reel times when each clip starts at its own frame 0. + +To check a graph without looking at pictures: + +1. Feed it solid `color=` inputs at `s=2x2`. +2. Write `-f rawvideo -pix_fmt rgb24`. +3. Count the runs with `od -An -tu1 -w12 -v | cut -c1-12 | uniq -c`. + +The runs are the scene lengths and must equal the spec's frame counts. Expect one extra frame when the offset falls on a whole ms. + +Lesson main functions read `std.c.getenv`, so build them on the lab with `zig build-exe -lc`. + ## 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 From 0d4ad98f779006a9e585b3a2d8fa103ddd85b6e8 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Mon, 19 Oct 2026 02:01:58 +0700 Subject: [PATCH 05/13] t27f: lay the reel's captions, cards and address over the beat video, LR_MODE=5 (Closes #10609) lesson_reel.t27: - graph() takes a beats flag. With it, the picture is the beat chain over inputs 0..2 instead of input 0, and the voice and music inputs move past the three clips (3..5 and 6). - beat_graph() is now beat_chain() plus its [v] label, so mode 5 goes on filtering the same stream. - beat_video_inputs / beat_voice_input / beat_music_input number the inputs. - LR_MODE=5 prints both orientations and refuses a voice that does not fit. Tests: 19/19 pass, 0 vacuous. New: every input from the picture to the music is used exactly once in both layouts. New control: without beats the inputs are mode 3's. Regression on the Railway lab: 96 graphs (4 lessons, 2 languages, 4 voices; mode 3 against master and the beat-graph branch, mode 4 against the beat-graph branch) are byte-identical. Mode 5 rendered with ffmpeg 5.1 from solid-colour clips, sine phrases and music. Tall and wide each give 450 frames and 15.000 s of audio. Pixels outside the text run 50 red, 332 green and 38 blue frames, which equals beat_solo_frames for 1300/4200/6800 ms. Co-Authored-By: Claude Opus 5.5 --- .trinity/seals/t27f_lesson_reel.json | 16 ++-- specs/t27f/lesson_reel.t27 | 105 ++++++++++++++++++++++++--- 2 files changed, 104 insertions(+), 17 deletions(-) diff --git a/.trinity/seals/t27f_lesson_reel.json b/.trinity/seals/t27f_lesson_reel.json index ae4cace0cf..02c9158cdc 100644 --- a/.trinity/seals/t27f_lesson_reel.json +++ b/.trinity/seals/t27f_lesson_reel.json @@ -31,23 +31,23 @@ } ], "config": "gen=c,rust,verilog,zig;tests=zig", - "gen_hash_c": "sha256:45fe54cd4be6d62ca81d3decf493c9999a451c7bc4149e49f86c0edc676e6265", - "gen_hash_rust": "sha256:1b2673482a4af8a938ef6146a88f87aa619a7e4e4d5b9c1155b91cf20eb65e01", - "gen_hash_verilog": "sha256:00b75242dd4b9b49a1bbb2720d3a7b817dd8715d1d8a6f36b80cdf83145f0a04", - "gen_hash_zig": "sha256:a45ecc01256a49e9d61781595043045a7ff98f34abfe22df6ae3b6bb8f7dd8a9", + "gen_hash_c": "sha256:6cf89f561d2471d4ea92d3f00e18aac2a2587b2788098e8d7e552ec83de63dbe", + "gen_hash_rust": "sha256:f6920ee461def04b74ff3634e89c24b41717f1ad44b8dfdc94a16f883ca9d5a3", + "gen_hash_verilog": "sha256:2694b8d7796169dfba074e585072006e53d2230160d1fba51094c4819a653e8c", + "gen_hash_zig": "sha256:b1479ef2a482771dd7f65dbc217a6e92afe1e49ed06a10b009329a8ec6265d6d", "module": "lesson_reel", "ring": 12, "seal_schema": 2, - "sealed_at": "2026-10-18T18:44:43Z", + "sealed_at": "2026-10-18T18:59:43Z", "sealed_by": "t27c-bootstrap@0.5.2", - "spec_hash": "sha256:69663c90a7ad61dbdf5a017d97f28d2dc0c885dae0643a361d43bbae31787a9a", + "spec_hash": "sha256:714fad38cc4f8d4a1d31544730ce7a5678eab133c825ecd89fdaa5352134c612", "spec_path": "specs/t27f/lesson_reel.t27", "tests": { "failed": 0, "failing": [], "forced": false, - "passed": 17, - "total": 17, + "passed": 19, + "total": 19, "vacuous": 0 }, "toolchain": { diff --git a/specs/t27f/lesson_reel.t27 b/specs/t27f/lesson_reel.t27 index 424f04e743..b405ddfc5a 100644 --- a/specs/t27f/lesson_reel.t27 +++ b/specs/t27f/lesson_reel.t27 @@ -17,6 +17,8 @@ // 1..3 the phrases' wavs, 4 the music. // 4 the beat graph (t27#10603), given LR_MS0..LR_MS2: inputs 0..2 the open, main and close // scene clips, crossfaded on whole frames at the beat clock's cuts and trimmed to the reel +// 5 mode 3's two graphs laid over mode 4's beat video (t27#10609): inputs 0..2 the scene +// clips, 3..5 the phrases' wavs, 6 the music // Every text that comes from the lesson goes through a textfile, so no quote, colon or percent in // a lesson can break the graph (and drawtext runs with expansion=none). // @@ -39,8 +41,8 @@ // 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. Mode 4 cuts the three scenes but lays nothing over them: mode 3's -// captions and address on top of the beat video are R4c, and the scenes themselves are R3. +// labels are the physics reel's. Mode 5 lays the same captions and address over every scene, in +// the same places; nothing on top changes with the beat. The scenes themselves are R3. // phi^2 + 1/phi^2 = 3 | TRINITY module lesson_reel; @@ -566,8 +568,15 @@ fn print_cta(o: usize, t: usize, lang: usize, a: i32) -> void { std.debug.print(":x={d}+({d}-text_w)/2:y={d}:alpha='clip((t-{d}.{d:0>3})/0.{d:0>3},0,1)':enable='gte(t,{d}.{d:0>3})'", .{ band_x(o), band_w(o), page_top(o), secs(a), millis(a), millis(TF_CTA_FADE_MS), secs(a), millis(a) }); } -pub fn graph(o: usize, t: usize, lang: usize, m0: i32, m1: i32, m2: i32) -> void { - std.debug.print("[0:v]format=yuv420p", .{}); +// With beats the picture is the beat chain over inputs 0..2 instead of input 0, and the voice and +// music inputs follow the three clips (beat_voice_input, beat_music_input). +pub fn graph(o: usize, t: usize, lang: usize, m0: i32, m1: i32, m2: i32, beats: bool) -> void { + if (beats) { + beat_chain(t, lang, m0, m1, m2); + std.debug.print(",format=yuv420p", .{}); + } else { + std.debug.print("[0:v]format=yuv420p", .{}); + } const adv = @divTrunc(TAG_FONT[o] * MONO_ADVANCE_PERMILLE, 1000); var s : usize = 0; while (s < SHOTS) { @@ -602,7 +611,7 @@ pub fn graph(o: usize, t: usize, lang: usize, m0: i32, m1: i32, m2: i32) -> void p = 0; while (p < n) { const at = phrase_start(m0, m1, m2, p); - std.debug.print("[{d}:a]aresample=48000,atempo={d}.{d:0>3},pan=stereo|c0=c0|c1=c0,adelay={d}|{d}[p{d}];", .{ p + 1, secs(tp), millis(tp), at, at, p }); + std.debug.print("[{d}:a]aresample=48000,atempo={d}.{d:0>3},pan=stereo|c0=c0|c1=c0,adelay={d}|{d}[p{d}];", .{ beat_voice_input(beats, p), secs(tp), millis(tp), at, at, p }); p = p + 1; } p = 0; @@ -611,7 +620,7 @@ pub fn graph(o: usize, t: usize, lang: usize, m0: i32, m1: i32, m2: i32) -> void p = p + 1; } std.debug.print("amix=inputs={d}:normalize=0:duration=longest,loudnorm=I={d}:TP=-1.5:LRA={d},aresample=48000,apad=whole_dur={d}[vo];", .{ n, VOICE_LUFS, VOICE_LRA, secs(REEL_MS) }); - std.debug.print("[4:a]atrim=0:{d},volume=0.{d:0>3},afade=t=in:d=0.{d:0>3},afade=t=out:st={d}.{d:0>3}:d={d}.{d:0>3}[mu];", .{ secs(REEL_MS), millis(MUSIC_VOLUME_PERMILLE), millis(MUSIC_FADE_IN_MS), secs(MUSIC_FADE_OUT_START_MS), millis(MUSIC_FADE_OUT_START_MS), secs(MUSIC_FADE_OUT_MS), millis(MUSIC_FADE_OUT_MS) }); + std.debug.print("[{d}:a]atrim=0:{d},volume=0.{d:0>3},afade=t=in:d=0.{d:0>3},afade=t=out:st={d}.{d:0>3}:d={d}.{d:0>3}[mu];", .{ beat_music_input(beats), secs(REEL_MS), millis(MUSIC_VOLUME_PERMILLE), millis(MUSIC_FADE_IN_MS), secs(MUSIC_FADE_OUT_START_MS), millis(MUSIC_FADE_OUT_START_MS), secs(MUSIC_FADE_OUT_MS), millis(MUSIC_FADE_OUT_MS) }); std.debug.print("[vo][mu]amix=inputs=2:normalize=0:duration=first,alimiter=limit=0.{d:0>3}[a]\n", .{millis(LIMIT_PERMILLE)}); } @@ -745,6 +754,12 @@ pub fn beat_solo_frames(b: usize, f0: i32, f1: i32) -> i32 { // beat_frame_ms(f), which never runs past frame f (the offset test); the timebase is AV_TIME_BASE // so the offset is not rounded again. pub fn beat_graph(t: usize, lang: usize, m0: i32, m1: i32, m2: i32) -> void { + beat_chain(t, lang, m0, m1, m2); + std.debug.print("[v]\n", .{}); +} + +// The beat graph without its output label, so mode 5 can go on filtering the same stream. +pub fn beat_chain(t: usize, lang: usize, m0: i32, m1: i32, m2: i32) -> void { var b : usize = 0; while (b < 3) { std.debug.print("[{d}:v]fps={d},settb=AVTB,format=yuv420p[b{d}];", .{ b, BASE_FPS, b }); @@ -754,7 +769,24 @@ pub fn beat_graph(t: usize, lang: usize, m0: i32, m1: i32, m2: i32) -> void { const a0 = beat_frame_ms(beat_xfade_frame(beat_cut_ms(t, lang, m0, m1, m2, 0))); const a1 = beat_frame_ms(beat_xfade_frame(beat_cut_ms(t, lang, m0, m1, m2, 1))); std.debug.print("[b0][b1]xfade=transition=fade:duration={d}.{d:0>3}:offset={d}.{d:0>3}[x0];", .{ secs(d), millis(d), secs(a0), millis(a0) }); - std.debug.print("[x0][b2]xfade=transition=fade:duration={d}.{d:0>3}:offset={d}.{d:0>3},trim=end_frame={d},setpts=PTS-STARTPTS[v]\n", .{ secs(d), millis(d), secs(a1), millis(a1), beat_frames() }); + std.debug.print("[x0][b2]xfade=transition=fade:duration={d}.{d:0>3}:offset={d}.{d:0>3},trim=end_frame={d},setpts=PTS-STARTPTS", .{ secs(d), millis(d), secs(a1), millis(a1), beat_frames() }); +} + +// The graph's inputs: the picture first (one frame stream, or with beats the three scene clips), +// then one wav per phrase, then the music. +pub fn beat_video_inputs(beats: bool) -> usize { + if (beats) { + return 3; + } + return 1; +} + +pub fn beat_voice_input(beats: bool, p: usize) -> usize { + return beat_video_inputs(beats) + p; +} + +pub fn beat_music_input(beats: bool) -> usize { + return beat_video_inputs(beats) + PHRASES; } // ---- main ---- @@ -806,8 +838,8 @@ pub fn main() -> void { const m1 = env_num("LR_MS1", 0); const m2 = env_num("LR_MS2", 0); if (voice_fits(m0, m1, m2)) { - graph(TALL, t, lang, m0, m1, m2); - graph(WIDE, t, lang, m0, m1, m2); + graph(TALL, t, lang, m0, m1, m2, false); + graph(WIDE, t, lang, m0, m1, m2, false); } else { std.debug.print("lesson_reel: the voice does not fit the reel\n", .{}); } @@ -820,6 +852,16 @@ pub fn main() -> void { } else { std.debug.print("lesson_reel: the voice does not fit the reel\n", .{}); } + } else if (mode == 5) { + const m0 = env_num("LR_MS0", 0); + const m1 = env_num("LR_MS1", 0); + const m2 = env_num("LR_MS2", 0); + if (voice_fits(m0, m1, m2)) { + graph(TALL, t, lang, m0, m1, m2, true); + graph(WIDE, t, lang, m0, m1, m2, true); + } else { + std.debug.print("lesson_reel: the voice does not fit the reel\n", .{}); + } } else { std.debug.print("{d}\n", .{files(t, lang)}); } @@ -1246,3 +1288,48 @@ test "on whole frames every beat keeps a scene on every voice the clock gives th assert(checked > 0); assert(early == TOPICS * 2); } + +// ---- the overlay's tests ---- + +test "with and without beats every input from the picture to the music is used exactly once" { + var layout : usize = 0; + while (layout < 2) { + const beats = layout == 1; + var used : [8]usize = undefined; + var z : usize = 0; + while (z < 8) { + used[z] = 0; + z = z + 1; + } + var v : usize = 0; + while (v < beat_video_inputs(beats)) { + used[v] = used[v] + 1; + v = v + 1; + } + var p : usize = 0; + while (p < PHRASES) { + used[beat_voice_input(beats, p)] = used[beat_voice_input(beats, p)] + 1; + p = p + 1; + } + used[beat_music_input(beats)] = used[beat_music_input(beats)] + 1; + var i : usize = 0; + while (i <= beat_music_input(beats)) { + assert(used[i] == 1); + i = i + 1; + } + while (i < 8) { + assert(used[i] == 0); + i = i + 1; + } + layout = layout + 1; + } +} + +test "control: without beats the inputs are mode 3's, the frames at 0, the wavs at 1..3, the music at 4" { + assert(beat_video_inputs(false) == 1); + assert(beat_voice_input(false, 0) == 1); + assert(beat_voice_input(false, PHRASES - 1) == 3); + assert(beat_music_input(false) == 4); + assert(beat_voice_input(true, 0) == 3); + assert(beat_music_input(true) == 6); +} From 092db843bb43fa298876a73ed71d6357243e7dc5 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Mon, 19 Oct 2026 02:03:23 +0700 Subject: [PATCH 06/13] t27f skill: add a graph mode without moving the old ones, prove it with cmp, and three lab traps (Refs #10609) Co-Authored-By: Claude Opus 5.5 --- .claude/skills/t27f/SKILL.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.claude/skills/t27f/SKILL.md b/.claude/skills/t27f/SKILL.md index 0d529a6abd..09ff941fc6 100644 --- a/.claude/skills/t27f/SKILL.md +++ b/.claude/skills/t27f/SKILL.md @@ -100,6 +100,19 @@ The runs are the scene lengths and must equal the spec's frame counts. Expect on Lesson main functions read `std.c.getenv`, so build them on the lab with `zig build-exe -lc`. +**Adding a mode to a graph printer without moving the old ones (t27#10609, PR #10610).** + +- Pass the old callers a flag (`graph(..., false)`) instead of copying the printer. +- Give the input numbers a function with a test that every input is used exactly once, in each layout. +- Prove the old output did not move: on the lab, build the old spec (from `git show :path`) and the new one, run both on a grid of lessons, languages and voices, and `cmp -s` every graph. PR #10610 compared 96 graphs and 0 differed. +- For a large frame, render lossless (`-c:v libx264 -qp 0`) and crop a 2x2 pixel outside every text box (`crop=2:2:x:y,format=rgb24`). The solid runs must still equal the spec's frame counts. + +Traps from this tick: + +- An untyped array literal in a test (`var a = [_]usize{...}`) becomes a Zig tuple, which cannot be indexed at runtime ("tuple field index must be comptime-known"). Declare `var a : [8]usize = undefined;` and fill it in a loop. +- `fs-lab.sh "echo $b64a ... ; echo $b64b ..."` with two large files exits 0 and writes nothing. Upload one file per call and compare md5 on both sides. +- zsh does not word-split `set -- $p`. Write one call per file. + ## 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 From d8c4c28630182ff363220ea0f011ef238b39f41b Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Mon, 19 Oct 2026 02:17:29 +0700 Subject: [PATCH 07/13] skills: t27/reel-beat card -- TRI CLAW cuts a lesson reel on the beat clock (Refs #10609) specs/skills/t27-reel-beat.t27 is a spec-only skill card, in the same shape as the four t27-social-visual cards from #10570. It points at specs/t27f/lesson_reel.t27 (beat_* functions, LR_MODE=4 and LR_MODE=5) and copies none of it. It does not cite reel_factory.t27: lesson_reel does not read the scene plan yet, and the card says so. Railway lab: 1/1 test, 9 runtime asserts, 0 vacuous. Seal saved and verified. t27b's front end accepts it (x86 lab: JIT-only CRASH, as for t27-self-host), so the ledger row is pass. The ledger row sits between providers-steward and self-host. That keeps it one line away from the four rows #10613 adds after self-host, so the two PRs do not conflict. Co-Authored-By: Claude Opus 5.5 --- .../seals/skills_skill_t27_reel_beat.json | 28 +++++++++++ docs/reports/t27b_expectations.json | 1 + specs/skills/t27-reel-beat.t27 | 48 +++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 .trinity/seals/skills_skill_t27_reel_beat.json create mode 100644 specs/skills/t27-reel-beat.t27 diff --git a/.trinity/seals/skills_skill_t27_reel_beat.json b/.trinity/seals/skills_skill_t27_reel_beat.json new file mode 100644 index 0000000000..cc0c73bb2a --- /dev/null +++ b/.trinity/seals/skills_skill_t27_reel_beat.json @@ -0,0 +1,28 @@ +{ + "built_by": "t27c-bootstrap@0.5.2+9448ab4be", + "closure": [], + "config": "gen=c,rust,verilog,zig;tests=zig", + "gen_hash_c": "sha256:5642bc71a3f58fb401c34d2e9260f2190b2335994b58272bfd47bdb2e50cca07", + "gen_hash_rust": "sha256:706879bc5d6542378435b0982dbb44e52cc1b9b85b34d7f06149ec7668b5fdd7", + "gen_hash_verilog": "sha256:596ced743ed9651f967ac93bae97d97f44b882ea6ed659d4ebc6ec67a16829b8", + "gen_hash_zig": "sha256:24befaf4ec44f81b268d7a1f58e3e7312c16da94c50b0442ac76e23e5ea1440a", + "module": "skill_t27_reel_beat", + "ring": 12, + "seal_schema": 2, + "sealed_at": "2026-10-18T19:16:57Z", + "sealed_by": "t27c-bootstrap@0.5.2", + "spec_hash": "sha256:b609e2d632860b22d61a44f12e3d8e3bf11663d080dada9cec88c19ac9e5becd", + "spec_path": "specs/skills/t27-reel-beat.t27", + "tests": { + "failed": 0, + "failing": [], + "forced": false, + "passed": 1, + "total": 1, + "vacuous": 0 + }, + "toolchain": { + "test_runner": "sha256:8512b80da533b79289110fca4a65f3336abd7a059ca4b72ea595d5e33ff8ad10", + "zig": "0.16.0" + } +} \ No newline at end of file diff --git a/docs/reports/t27b_expectations.json b/docs/reports/t27b_expectations.json index 09b79809ce..8a45a8567b 100644 --- a/docs/reports/t27b_expectations.json +++ b/docs/reports/t27b_expectations.json @@ -1406,6 +1406,7 @@ {"path": "specs/skills/t27-measure-corpus.t27", "t27b": "pass"}, {"path": "specs/skills/t27-phi-loop.t27", "t27b": "pass"}, {"path": "specs/skills/t27-providers-steward.t27", "t27b": "pass"}, + {"path": "specs/skills/t27-reel-beat.t27", "t27b": "pass"}, {"path": "specs/skills/t27-self-host.t27", "t27b": "pass"}, {"path": "specs/skills/t27-t27f.t27", "t27b": "pass"}, {"path": "specs/skills/t27-tri-pipeline.t27", "t27b": "pass"}, diff --git a/specs/skills/t27-reel-beat.t27 b/specs/skills/t27-reel-beat.t27 new file mode 100644 index 0000000000..8b7d3bd7ab --- /dev/null +++ b/specs/skills/t27-reel-beat.t27 @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/skills/t27-reel-beat.t27 -- skill t27/reel-beat +; Source of truth for the skill card on t27.ai (#/skills?skill=t27/reel-beat). +; How TRI CLAW cuts a lesson reel on the beat: LR_MODE=4 crossfades the open, main and close +; scene clips at the beat clock's cuts, on whole frames, and LR_MODE=5 lays mode 3's captions, +; cards and address over that video. Mode 5's inputs are 0..2 the scene clips, 3..5 the +; phrases' wavs and 6 the music. The law lives once, in specs/t27f/lesson_reel.t27 (beat_* +; functions and their tests); this card points at it and copies none. The scene clips are +; inputs: lesson_reel does not read the content factory's scene plan yet. There is no +; SKILL.md behind it yet, so the card is spec-only (see "Witness labels" in +; specs/skills/README.md). Issues gHashTag/t27#10603 and +; gHashTag/t27#10609, epic gHashTag/t27#10560. +; The JSON the site serves is generated from this file by the real compiler; +; nothing below is parsed with a regex. +; phi^2 + 1/phi^2 = 3 | TRINITY + +module skill_t27_reel_beat; + +pub const KIND : str = "skill"; +pub const ID : str = "t27/reel-beat"; +pub const NAME : str = "Reel Beat"; +pub const REPO : str = "t27"; +; No SKILL.md exists for this card. +pub const SOURCE : str = ""; +pub const SUMMARY_EN : str = "Cut a 15 second lesson reel on the beat clock: crossfade the open, main and close scenes at whole-frame cuts that never fall inside a spoken phrase, lay the captions and address on top, and refuse a voice that does not fit"; +pub const COMMAND : str = "/reel-beat"; +; Spec this skill stands on: the beat clock and the graph printer. +pub const SPECS : [1]str = ["specs/t27f/lesson_reel.t27"]; +pub const TAGS : [4]str = ["social-visual", "reel", "beat", "t27f"]; +pub const ENABLED : bool = true; +; Default budget; no SKILL.md declares a timeout. +pub const TIMEOUT_MIN : u16 = 30; + +test basic_constants { + assert KIND == "skill"; + assert ID == "t27/reel-beat"; + assert NAME == "Reel Beat"; + assert REPO == "t27"; + assert SOURCE == ""; + assert COMMAND == "/reel-beat"; + assert ENABLED == true; + assert TIMEOUT_MIN == 30; + assert TAGS.len == 4; + assert TAGS[0] == "social-visual"; + assert TAGS[2] == "beat"; + assert SPECS.len == 1; + assert SPECS[0] == "specs/t27f/lesson_reel.t27"; +} From 342733cb9d4646c57c17bd7e3e0b6bb0cf16703e Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Mon, 19 Oct 2026 02:49:05 +0700 Subject: [PATCH 08/13] t27f: mode 5 overlays live inside one scene -- no caption, tag or address on a beat crossfade (Closes #10614) LR_MODE=5 now takes every caption, tag and address window from the beat clock, on whole frames. beat_scene_lo/hi give the frames each scene owns outside its crossfades; beat_piece_lo/hi clip an overlay to them; beat_piece_kept drops a piece with no room (fewer than BEAT_OVERLAY_MIN_FRAMES and not the whole overlay) instead of squeezing it; and print_pieces prints exactly those frames as between(n,lo,hi) terms. Modes 3 and 4 print the same bytes as before. Railway lab: - t27c test-report: 25/25, 0 vacuous; seal saved and verified. - The grid test covers every lesson, both languages and every voice the clock accepts: 85437 asserts that no enabled frame is on a crossfade. A control shows mode 3's windows over mode 4's cuts put the goal's first caption on one. - Regression: 216 graphs (modes 3 and 4, 27 lessons, 2 languages, 2 voices) cmp equal to the #10610 binary. - ffmpeg, mode 5 rendered from solid-colour clips, both orientations: a 40-row band over the captions shows text on 405 frames and on 0 of the 30 crossfade frames; the tag band, 404 and 0. Costs, named in tests and the header: a short caption whose scene ends under it is dropped (every phrase keeps one); on a voice the reel speeds up the address is held for less than REEL_CTA_MIN_MS; the cards are still on their own frames, since token_floor_mux prints their enable. Co-Authored-By: Claude Opus 5.5 --- .trinity/seals/t27f_lesson_reel.json | 18 +- specs/t27f/lesson_reel.t27 | 447 ++++++++++++++++++++++++++- 2 files changed, 441 insertions(+), 24 deletions(-) diff --git a/.trinity/seals/t27f_lesson_reel.json b/.trinity/seals/t27f_lesson_reel.json index 02c9158cdc..6555450c57 100644 --- a/.trinity/seals/t27f_lesson_reel.json +++ b/.trinity/seals/t27f_lesson_reel.json @@ -31,27 +31,27 @@ } ], "config": "gen=c,rust,verilog,zig;tests=zig", - "gen_hash_c": "sha256:6cf89f561d2471d4ea92d3f00e18aac2a2587b2788098e8d7e552ec83de63dbe", - "gen_hash_rust": "sha256:f6920ee461def04b74ff3634e89c24b41717f1ad44b8dfdc94a16f883ca9d5a3", - "gen_hash_verilog": "sha256:2694b8d7796169dfba074e585072006e53d2230160d1fba51094c4819a653e8c", - "gen_hash_zig": "sha256:b1479ef2a482771dd7f65dbc217a6e92afe1e49ed06a10b009329a8ec6265d6d", + "gen_hash_c": "sha256:30f4f017bc4dd4c32c1ec195d83f827f10e9d260f30056ebd6f444cf034ca3ea", + "gen_hash_rust": "sha256:cd6858c82060106802b3cced271e84811d8ee02b8c4f2ed18ba75cf8068861eb", + "gen_hash_verilog": "sha256:d9d8bbee11770df310cf863654c914b318ea03940780b5a9e116cec3f4e33991", + "gen_hash_zig": "sha256:f5762a5368a29d6b2978d7ea365deed7c9c7a0d4994814a3d6586c039fdcbf25", "module": "lesson_reel", "ring": 12, "seal_schema": 2, - "sealed_at": "2026-10-18T18:59:43Z", + "sealed_at": "2026-10-18T19:36:34Z", "sealed_by": "t27c-bootstrap@0.5.2", - "spec_hash": "sha256:714fad38cc4f8d4a1d31544730ce7a5678eab133c825ecd89fdaa5352134c612", + "spec_hash": "sha256:b1ca187bbf04f6595be7dbf25b6a656286b07a86c50def9a0605c9ec56f017ec", "spec_path": "specs/t27f/lesson_reel.t27", "tests": { "failed": 0, "failing": [], "forced": false, - "passed": 19, - "total": 19, + "passed": 25, + "total": 25, "vacuous": 0 }, "toolchain": { - "test_runner": "sha256:1982ae948e24d00c5d221ccec12dfce1eb01204b53e37b0f857f93b16d64629d", + "test_runner": "sha256:8512b80da533b79289110fca4a65f3336abd7a059ca4b72ea595d5e33ff8ad10", "zig": "0.16.0" } } \ No newline at end of file diff --git a/specs/t27f/lesson_reel.t27 b/specs/t27f/lesson_reel.t27 index b405ddfc5a..fcc8cd1800 100644 --- a/specs/t27f/lesson_reel.t27 +++ b/specs/t27f/lesson_reel.t27 @@ -18,7 +18,8 @@ // 4 the beat graph (t27#10603), given LR_MS0..LR_MS2: inputs 0..2 the open, main and close // scene clips, crossfaded on whole frames at the beat clock's cuts and trimmed to the reel // 5 mode 3's two graphs laid over mode 4's beat video (t27#10609): inputs 0..2 the scene -// clips, 3..5 the phrases' wavs, 6 the music +// clips, 3..5 the phrases' wavs, 6 the music. The captions, tags and address are shown on +// whole frames inside the scenes and never on a crossfade (t27#10614, beat_piece_*) // Every text that comes from the lesson goes through a textfile, so no quote, colon or percent in // a lesson can break the graph (and drawtext runs with expansion=none). // @@ -42,7 +43,12 @@ // // NOT CLAIMED: the backdrop is token_floor's physics world, not a picture of the lesson; its frame // labels are the physics reel's. Mode 5 lays the same captions and address over every scene, in -// the same places; nothing on top changes with the beat. The scenes themselves are R3. +// the same places, and only their windows change with the beat. The cards are still on their +// own frames over a crossfade: token_floor_mux prints their enable= and this file does not reach +// it. Both cuts are centred on a sentence change, so a crossfade can begin under the last words +// of a phrase: mode 5 then drops a caption with too little room left (the cost tests), and on a +// voice the reel had to speed up it holds the address for less than REEL_CTA_MIN_MS. The scenes +// themselves are R3. // phi^2 + 1/phi^2 = 3 | TRINITY module lesson_reel; @@ -518,9 +524,17 @@ pub fn page_chars(t: usize) -> usize { // ---- the graph ---- -fn print_tag(o: usize, s: usize, dx: i32, row: usize, f: usize, lang: usize) -> void { - const a = SHOT_START[s]; +// With beats (f0, f1 the crossfades' first frames) a tag is shown on its kept pieces and slides in +// from the first of them; one with no piece is not printed. +fn print_tag(o: usize, s: usize, dx: i32, row: usize, f: usize, lang: usize, beats: bool, f0: i32, f1: i32) -> void { + var a : i32 = SHOT_START[s]; const b = SHOT_START[s + 1] - 1; + if (beats) { + if (beat_pieces(a, b, f0, f1) == 0) { + return; + } + a = beat_first_lo(a, b, f0, f1); + } if (f == 99) { std.debug.print(",drawtext=fontfile={s}:expansion=none:text='{d:0>2}':fontsize={d}:fontcolor=", .{ FONT, s + 1, TAG_FONT[o] }); } else if (f == 98) { @@ -529,7 +543,12 @@ fn print_tag(o: usize, s: usize, dx: i32, row: usize, f: usize, lang: usize) -> std.debug.print(",drawtext=fontfile={s}:expansion=none:textfile={s}/{s}/f{d:0>2}.txt:fontsize={d}:fontcolor=", .{ FONT, FILE_DIR, lang_dir(lang), f, TAG_FONT[o] }); } print_col(row); - std.debug.print(":x='{d}+{d}*pow(1-clip((n-{d})/{d},0,1),3)':y={d}:alpha='clip((n-{d})/{d},0,1)':enable='between(n,{d},{d})'", .{ TAG_X[o] + dx, TAG_SLIDE_PX, a, TAG_SLIDE_F, TAG_Y[o], a, TAG_SLIDE_F, a, b }); + std.debug.print(":x='{d}+{d}*pow(1-clip((n-{d})/{d},0,1),3)':y={d}:alpha='clip((n-{d})/{d},0,1)'", .{ TAG_X[o] + dx, TAG_SLIDE_PX, a, TAG_SLIDE_F, TAG_Y[o], a, TAG_SLIDE_F }); + if (beats) { + print_pieces(SHOT_START[s], b, f0, f1); + } else { + std.debug.print(":enable='between(n,{d},{d})'", .{ a, b }); + } } fn print_card_text(o: usize, c: usize, lang: usize, f: usize, font: i32, row: usize, dy: f64) -> void { @@ -559,13 +578,34 @@ fn print_card(o: usize, c: usize, t: usize, lang: usize) -> void { print_fade(c); } -fn print_cta(o: usize, t: usize, lang: usize, a: i32) -> void { +// The address and its page from ms a to the end. With beats they fade in from the first frame of +// their first kept piece and are on only on the pieces. +fn print_cta(o: usize, t: usize, lang: usize, a: i32, beats: bool, f0: i32, f1: i32) -> void { + const lo = beat_frame_from(a); + const hi = beat_frames() - 1; + var at : i32 = a; + if (beats) { + if (beat_pieces(lo, hi, f0, f1) == 0) { + return; + } + at = beat_frame_ms(beat_first_lo(lo, hi, f0, f1)); + } std.debug.print(",drawtext=fontfile={s}:text='{s}':fontsize={d}:fontcolor=", .{ FONT, CTA_TEXT, CTA_FONT }); print_col(GOLD_ROW); - std.debug.print(":borderw={d}:bordercolor=0x000000:shadowx=0:shadowy={d}:shadowcolor=0x000000@0.6:x={d}+({d}-text_w)/2:y={d}-text_h/2:alpha='clip((t-{d}.{d:0>3})/0.{d:0>3},0,1)':enable='gte(t,{d}.{d:0>3})'", .{ CAPTION_BORDER_PX, CAPTION_SHADOW_Y_PX, band_x(o), band_w(o), cta_y(o), secs(a), millis(a), millis(TF_CTA_FADE_MS), secs(a), millis(a) }); + std.debug.print(":borderw={d}:bordercolor=0x000000:shadowx=0:shadowy={d}:shadowcolor=0x000000@0.6:x={d}+({d}-text_w)/2:y={d}-text_h/2:alpha='clip((t-{d}.{d:0>3})/0.{d:0>3},0,1)'", .{ CAPTION_BORDER_PX, CAPTION_SHADOW_Y_PX, band_x(o), band_w(o), cta_y(o), secs(at), millis(at), millis(TF_CTA_FADE_MS) }); + print_cta_enable(a, beats, lo, hi, f0, f1); std.debug.print(",drawtext=fontfile={s}:expansion=none:textfile={s}/{s}/f{d:0>2}.txt:fontsize={d}:fontcolor=", .{ FONT, FILE_DIR, lang_dir(lang), F_PAGE, PAGE_FONT }); print_col(GREY_ROW); - std.debug.print(":x={d}+({d}-text_w)/2:y={d}:alpha='clip((t-{d}.{d:0>3})/0.{d:0>3},0,1)':enable='gte(t,{d}.{d:0>3})'", .{ band_x(o), band_w(o), page_top(o), secs(a), millis(a), millis(TF_CTA_FADE_MS), secs(a), millis(a) }); + std.debug.print(":x={d}+({d}-text_w)/2:y={d}:alpha='clip((t-{d}.{d:0>3})/0.{d:0>3},0,1)'", .{ band_x(o), band_w(o), page_top(o), secs(at), millis(at), millis(TF_CTA_FADE_MS) }); + print_cta_enable(a, beats, lo, hi, f0, f1); +} + +fn print_cta_enable(a: i32, beats: bool, lo: i32, hi: i32, f0: i32, f1: i32) -> void { + if (beats) { + print_pieces(lo, hi, f0, f1); + } else { + std.debug.print(":enable='gte(t,{d}.{d:0>3})'", .{ secs(a), millis(a) }); + } } // With beats the picture is the beat chain over inputs 0..2 instead of input 0, and the voice and @@ -578,11 +618,17 @@ pub fn graph(o: usize, t: usize, lang: usize, m0: i32, m1: i32, m2: i32, beats: std.debug.print("[0:v]format=yuv420p", .{}); } const adv = @divTrunc(TAG_FONT[o] * MONO_ADVANCE_PERMILLE, 1000); + var f0 : i32 = 0; + var f1 : i32 = 0; + if (beats) { + f0 = beat_xfade_frame(beat_cut_ms(t, lang, m0, m1, m2, 0)); + f1 = beat_xfade_frame(beat_cut_ms(t, lang, m0, m1, m2, 1)); + } var s : usize = 0; while (s < SHOTS) { - print_tag(o, s, 0, GOLD_ROW, 99, lang); - print_tag(o, s, 3 * adv, GREY_ROW, 98, lang); - print_tag(o, s, @as(i32, @intCast(TAG_TEXT_AT)) * adv, GREY_ROW, tag_file(s), lang); + print_tag(o, s, 0, GOLD_ROW, 99, lang, beats, f0, f1); + print_tag(o, s, 3 * adv, GREY_ROW, 98, lang, beats, f0, f1); + print_tag(o, s, @as(i32, @intCast(TAG_TEXT_AT)) * adv, GREY_ROW, tag_file(s), lang, beats, f0, f1); s = s + 1; } var c : usize = 0; @@ -598,14 +644,23 @@ pub fn graph(o: usize, t: usize, lang: usize, m0: i32, m1: i32, m2: i32, beats: while (k < chunks(i)) { const a = chunk_from(t, lang, m0, m1, m2, p, k); const b = chunk_until(t, lang, m0, m1, m2, p, k); - std.debug.print(",drawtext=fontfile={s}:expansion=none:textfile={s}/{s}/f{d:0>2}.txt:fontsize={d}:fontcolor=", .{ FONT, FILE_DIR, lang_dir(lang), chunk_file(t, lang, p, k), band_font(o, @as(i32, @intCast(chunk_chars(i, k)))) }); - print_col(GOLD_ROW); - std.debug.print(":borderw={d}:bordercolor=0x000000:shadowx=0:shadowy={d}:shadowcolor=0x000000@0.6:x={d}:y={d}-text_h/2:enable='between(t,{d}.{d:0>3},{d}.{d:0>3})'", .{ CAPTION_BORDER_PX, CAPTION_SHADOW_Y_PX, band_x(o), caption_y(o), secs(a), millis(a), secs(b), millis(b) }); + const fa = beat_frame_from(a); + const fb = beat_frame_until(b); + if (beats == false || beat_pieces(fa, fb, f0, f1) > 0) { + std.debug.print(",drawtext=fontfile={s}:expansion=none:textfile={s}/{s}/f{d:0>2}.txt:fontsize={d}:fontcolor=", .{ FONT, FILE_DIR, lang_dir(lang), chunk_file(t, lang, p, k), band_font(o, @as(i32, @intCast(chunk_chars(i, k)))) }); + print_col(GOLD_ROW); + std.debug.print(":borderw={d}:bordercolor=0x000000:shadowx=0:shadowy={d}:shadowcolor=0x000000@0.6:x={d}:y={d}-text_h/2", .{ CAPTION_BORDER_PX, CAPTION_SHADOW_Y_PX, band_x(o), caption_y(o) }); + if (beats) { + print_pieces(fa, fb, f0, f1); + } else { + std.debug.print(":enable='between(t,{d}.{d:0>3},{d}.{d:0>3})'", .{ secs(a), millis(a), secs(b), millis(b) }); + } + } k = k + 1; } p = p + 1; } - print_cta(o, t, lang, cta_from(t, lang, m0, m1, m2)); + print_cta(o, t, lang, cta_from(t, lang, m0, m1, m2), beats, f0, f1); std.debug.print("[v];", .{}); const tp = tempo(m0, m1, m2); p = 0; @@ -789,6 +844,114 @@ pub fn beat_music_input(beats: bool) -> usize { return beat_video_inputs(beats) + PHRASES; } +// ---- the overlay's windows (t27#10614) ---- +// Crossfade k covers base frames f_k .. f_k + TRANSITION_FRAMES, f_k = beat_xfade_frame(cut k). +// Its first and last frames are the outgoing and the incoming beat whole but for under a ms of +// fade, and both count as crossfade. Beat b's own frames are the rest. In mode 5 an overlay over +// frames lo..hi is on only where that window meets a beat's own frames, one piece per beat. A +// piece shorter than BEAT_OVERLAY_MIN_FRAMES is dropped, not squeezed, unless it is the whole +// overlay: text on for less than a crossfade reads as a flash, and an overlay that was that short +// in mode 3 stays as it was. +pub const BEAT_OVERLAY_MIN_FRAMES : i32 = 15; + +// The first and the last frame of beat b's own frames, for crossfades starting at f0 and f1. +pub fn beat_scene_lo(b: usize, f0: i32, f1: i32) -> i32 { + if (b == BEAT_OPEN) { + return 0; + } + if (b == BEAT_MAIN) { + return f0 + TRANSITION_FRAMES + 1; + } + return f1 + TRANSITION_FRAMES + 1; +} + +pub fn beat_scene_hi(b: usize, f0: i32, f1: i32) -> i32 { + if (b == BEAT_OPEN) { + return f0 - 1; + } + if (b == BEAT_MAIN) { + return f1 - 1; + } + return beat_frames() - 1; +} + +// The frames between(t, a, b) shows, a and b in ms: the first at or after a, the last at or before b. +pub fn beat_frame_from(a: i32) -> i32 { + return @divTrunc(a * BASE_FPS + 999, 1000); +} + +pub fn beat_frame_until(b: i32) -> i32 { + return @divTrunc(b * BASE_FPS, 1000); +} + +fn beat_max(a: i32, b: i32) -> i32 { + if (a > b) { + return a; + } + return b; +} + +// Overlay lo..hi's piece in beat b, and whether it is kept. +pub fn beat_piece_lo(lo: i32, b: usize, f0: i32, f1: i32) -> i32 { + return beat_max(lo, beat_scene_lo(b, f0, f1)); +} + +pub fn beat_piece_hi(hi: i32, b: usize, f0: i32, f1: i32) -> i32 { + return beat_min(hi, beat_scene_hi(b, f0, f1)); +} + +pub fn beat_piece_kept(lo: i32, hi: i32, b: usize, f0: i32, f1: i32) -> bool { + const n = beat_piece_hi(hi, b, f0, f1) - beat_piece_lo(lo, b, f0, f1) + 1; + if (n <= 0) { + return false; + } + return n >= BEAT_OVERLAY_MIN_FRAMES || n == hi - lo + 1; +} + +// How many pieces of overlay lo..hi are kept; none means mode 5 drops it. +pub fn beat_pieces(lo: i32, hi: i32, f0: i32, f1: i32) -> usize { + var k : usize = 0; + var b : usize = 0; + while (b < 3) { + if (beat_piece_kept(lo, hi, b, f0, f1)) { + k = k + 1; + } + b = b + 1; + } + return k; +} + +// The first frame of the first kept piece, where an overlay's own fade or slide starts in mode 5; +// lo when none is kept. +pub fn beat_first_lo(lo: i32, hi: i32, f0: i32, f1: i32) -> i32 { + var b : usize = 0; + while (b < 3) { + if (beat_piece_kept(lo, hi, b, f0, f1)) { + return beat_piece_lo(lo, b, f0, f1); + } + b = b + 1; + } + return lo; +} + +// Mode 5's enable= for overlay lo..hi: exactly its kept pieces' frames. +fn print_pieces(lo: i32, hi: i32, f0: i32, f1: i32) -> void { + std.debug.print(":enable='", .{}); + var first : bool = true; + var b : usize = 0; + while (b < 3) { + if (beat_piece_kept(lo, hi, b, f0, f1)) { + if (first == false) { + std.debug.print("+", .{}); + } + std.debug.print("between(n,{d},{d})", .{ beat_piece_lo(lo, b, f0, f1), beat_piece_hi(hi, b, f0, f1) }); + first = false; + } + b = b + 1; + } + std.debug.print("'", .{}); +} + // ---- main ---- // The environment variable `name` as a number of up to six digits; dflt when unset. Only main @@ -1333,3 +1496,257 @@ test "control: without beats the inputs are mode 3's, the frames at 0, the wavs assert(beat_voice_input(true, 0) == 3); assert(beat_music_input(true) == 6); } + +// ---- the overlay windows' tests (t27#10614) ---- + +// Whether frames lo..hi meet crossfade 0 or 1. +fn on_xfade(lo: i32, hi: i32, f0: i32, f1: i32) -> bool { + if (hi >= f0 && lo <= f0 + TRANSITION_FRAMES) { + return true; + } + return hi >= f1 && lo <= f1 + TRANSITION_FRAMES; +} + +// Every kept piece of lo..hi is inside the window and the reel, off both crossfades, and as long as +// the rule asks; checked against the crossfades themselves, not against beat_scene_*. +fn pieces_clear(lo: i32, hi: i32, f0: i32, f1: i32) -> bool { + var b : usize = 0; + while (b < 3) { + if (beat_piece_kept(lo, hi, b, f0, f1)) { + const a = beat_piece_lo(lo, b, f0, f1); + const z = beat_piece_hi(hi, b, f0, f1); + if (a < lo || z > hi || a < 0 || z >= beat_frames() || z < a) { + return false; + } + if (on_xfade(a, z, f0, f1)) { + return false; + } + if (z - a + 1 < BEAT_OVERLAY_MIN_FRAMES && z - a + 1 != hi - lo + 1) { + return false; + } + } + b = b + 1; + } + return true; +} + +test "the frames mode 5 enables are the frames between(t, a, b) shows" { + // frame f is at f / 30 s: 1000 ms is frame 30, and 1001 ms falls between 30 and 31 + assert(beat_frame_from(0) == 0); + assert(beat_frame_from(1000) == 30); + assert(beat_frame_until(1000) == 30); + assert(beat_frame_from(1001) == 31); + assert(beat_frame_until(1001) == 30); + assert(beat_frame_from(34) == 2); + assert(beat_frame_until(33) == 0); + var f : i32 = 0; + while (f < beat_frames()) { + // frame f's time in us, against an edge a ms: shown from a exactly when the frame is not early + var a : i32 = 0; + while (a <= 100) { + const ms = 100 * @divTrunc(f, 3) + a; + const shown = f * 1000000 >= ms * 1000 * BASE_FPS; + assert(shown == (f >= beat_frame_from(ms))); + a = a + 17; + } + f = f + 1; + } +} + +test "an overlay with no room in its scene is dropped, not squeezed" { + const f0 : i32 = 100; + const f1 : i32 = 300; + // the beats' own frames: 0..99, 116..299, 316..449 + assert(beat_scene_hi(BEAT_OPEN, f0, f1) == 99); + assert(beat_scene_lo(BEAT_MAIN, f0, f1) == 116); + assert(beat_scene_hi(BEAT_MAIN, f0, f1) == 299); + assert(beat_scene_lo(BEAT_CLOSE, f0, f1) == 316); + assert(beat_scene_hi(BEAT_CLOSE, f0, f1) == 449); + // wholly inside a crossfade: dropped + assert(beat_pieces(102, 110, f0, f1) == 0); + // across a crossfade with five frames on each side: both pieces are too short, so dropped + assert(beat_pieces(95, 120, f0, f1) == 0); + // five frames clear of every crossfade: left as it was + assert(beat_pieces(95, 99, f0, f1) == 1); + assert(beat_piece_lo(95, BEAT_OPEN, f0, f1) == 95); + // across a crossfade with room on both sides: two pieces, each cut at the crossfade + assert(beat_pieces(80, 140, f0, f1) == 2); + assert(beat_piece_hi(140, BEAT_OPEN, f0, f1) == 99); + assert(beat_first_lo(80, 140, f0, f1) == 80); + assert(beat_piece_lo(80, BEAT_MAIN, f0, f1) == 116); + // the short side alone is dropped and the long one starts the overlay + assert(beat_pieces(95, 160, f0, f1) == 1); + assert(beat_first_lo(95, 160, f0, f1) == 116); + assert(pieces_clear(95, 160, f0, f1)); +} + +test "control: mode 3's windows over mode 4's cuts put the goal's first caption on a crossfade" { + const c0 = beat_cut_ms(0, 0, M_EN0, M_EN1, M_EN2, 0); + const f0 = beat_xfade_frame(c0); + const f1 = beat_xfade_frame(beat_cut_ms(0, 0, M_EN0, M_EN1, M_EN2, 1)); + const lo = beat_frame_from(chunk_from(0, 0, M_EN0, M_EN1, M_EN2, 1, 0)); + const hi = beat_frame_until(chunk_until(0, 0, M_EN0, M_EN1, M_EN2, 1, 0)); + // the goal is spoken from cut 0, the middle of crossfade 0 + assert(on_xfade(lo, hi, f0, f1)); + assert(lo > f0); + assert(lo < f0 + TRANSITION_FRAMES); + // mode 5 shows it from the main beat's first own frame + assert(beat_pieces(lo, hi, f0, f1) == 1); + assert(beat_first_lo(lo, hi, f0, f1) == f0 + TRANSITION_FRAMES + 1); +} + +test "in mode 5 no caption, tag or address is on a crossfade, every lesson, both languages, every voice the clock accepts" { + var voices : usize = 0; + var windows : usize = 0; + var crossing : usize = 0; + var dropped : 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)) { + voices = voices + 1; + const f0 = beat_xfade_frame(beat_cut_ms(t, lang, m0, m1, m2, 0)); + const f1 = beat_xfade_frame(beat_cut_ms(t, lang, m0, m1, m2, 1)); + var s : usize = 0; + while (s < SHOTS) { + const lo = SHOT_START[s]; + const hi = SHOT_START[s + 1] - 1; + assert(pieces_clear(lo, hi, f0, f1)); + windows = windows + 1; + if (on_xfade(lo, hi, f0, f1)) { + crossing = crossing + 1; + } + if (beat_pieces(lo, hi, f0, f1) == 0) { + dropped = dropped + 1; + } + s = s + 1; + } + var p : usize = 0; + while (p < phrases_for(m0, m1, m2)) { + var c : usize = 0; + while (c < chunks(phrase_item(t, lang, p))) { + const lo = beat_frame_from(chunk_from(t, lang, m0, m1, m2, p, c)); + const hi = beat_frame_until(chunk_until(t, lang, m0, m1, m2, p, c)); + assert(pieces_clear(lo, hi, f0, f1)); + windows = windows + 1; + if (on_xfade(lo, hi, f0, f1)) { + crossing = crossing + 1; + } + if (beat_pieces(lo, hi, f0, f1) == 0) { + dropped = dropped + 1; + } + c = c + 1; + } + p = p + 1; + } + const lo = beat_frame_from(cta_from(t, lang, m0, m1, m2)); + assert(pieces_clear(lo, beat_frames() - 1, f0, f1)); + // the address is on in the close beat alone, to the reel's last frame + assert(beat_pieces(lo, beat_frames() - 1, f0, f1) == 1); + assert(beat_first_lo(lo, beat_frames() - 1, f0, f1) == beat_scene_lo(BEAT_CLOSE, f0, f1)); + windows = windows + 1; + } + k = k + 1; + } + j = j + 1; + } + i = i + 1; + } + lang = lang + 1; + } + t = t + 1; + } + assert(voices > 0); + assert(windows > voices * SHOTS); + // mode 3's windows do meet the crossfades: the rule has work to do + assert(crossing > voices); + assert(dropped < windows); +} + +test "the cost on lesson 1's measured voice: a caption across a crossfade with no room is dropped, and every phrase keeps one" { + var dropped : usize = 0; + var t : usize = 0; + while (t < TOPICS) { + var lang : usize = 0; + while (lang < 2) { + const f0 = beat_xfade_frame(beat_cut_ms(t, lang, M_EN0, M_EN1, M_EN2, 0)); + const f1 = beat_xfade_frame(beat_cut_ms(t, lang, M_EN0, M_EN1, M_EN2, 1)); + var p : usize = 0; + while (p < phrases_for(M_EN0, M_EN1, M_EN2)) { + var kept : usize = 0; + var c : usize = 0; + while (c < chunks(phrase_item(t, lang, p))) { + const lo = beat_frame_from(chunk_from(t, lang, M_EN0, M_EN1, M_EN2, p, c)); + const hi = beat_frame_until(chunk_until(t, lang, M_EN0, M_EN1, M_EN2, p, c)); + if (beat_pieces(lo, hi, f0, f1) == 0) { + // only a caption a crossfade cuts is ever dropped + assert(on_xfade(lo, hi, f0, f1)); + dropped = dropped + 1; + } else { + kept = kept + 1; + } + c = c + 1; + } + assert(kept >= 1); + p = p + 1; + } + lang = lang + 1; + } + t = t + 1; + } + // crossfade 0 is centred on the goal's start, so it begins under the title's last words + assert(dropped > 0); +} + +test "the cost: on a voice the reel speeds up, mode 5 holds the address for less than REEL_CTA_MIN_MS" { + var short : usize = 0; + var least : i32 = beat_frames(); + 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 f0 = beat_xfade_frame(beat_cut_ms(t, lang, m0, m1, m2, 0)); + const f1 = beat_xfade_frame(beat_cut_ms(t, lang, m0, m1, m2, 1)); + const lo = beat_frame_from(cta_from(t, lang, m0, m1, m2)); + const held = beat_frames() - beat_first_lo(lo, beat_frames() - 1, f0, f1); + if (held < least) { + least = held; + } + if (held * 1000 < REEL_CTA_MIN_MS * BASE_FPS) { + short = short + 1; + } + } + k = k + 1; + } + j = j + 1; + } + i = i + 1; + } + lang = lang + 1; + } + t = t + 1; + } + assert(short > 0); + // never less than the close beat's own frames: its solo floor less the crossfade's last frame + assert(least >= MIN_SCENE_FRAMES - 1); +} From 4e280b21604d6d019231ff100dc198720bac5674 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Mon, 19 Oct 2026 02:51:15 +0700 Subject: [PATCH 09/13] t27f skill: keep overlays off a crossfade on whole frames, count what is dropped, and two lab traps (Refs #10614) Co-Authored-By: Claude Opus 5.5 --- .claude/skills/t27f/SKILL.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.claude/skills/t27f/SKILL.md b/.claude/skills/t27f/SKILL.md index 09ff941fc6..df930cbf09 100644 --- a/.claude/skills/t27f/SKILL.md +++ b/.claude/skills/t27f/SKILL.md @@ -113,6 +113,18 @@ Traps from this tick: - `fs-lab.sh "echo $b64a ... ; echo $b64b ..."` with two large files exits 0 and writes nothing. Upload one file per call and compare md5 on both sides. - zsh does not word-split `set -- $p`. Write one call per file. +**Keeping overlays off a crossfade (t27#10614, PR #10627).** + +- Work in whole frames, not seconds. Give each scene a function for the frames it owns outside its crossfades, clip each overlay to it, and print exactly those frames as `between(n,lo,hi)` terms joined with `+`. A test checks that the printed frames equal the frames `between(t,a,b)` showed before. +- Drop a piece that has no room; never squeeze it. Name the cost in a test: count what is dropped, assert that each drop is on a crossfade, and assert that every phrase keeps at least one caption. +- To check the pixels, render lossless from solid-colour clips. Crop a full-width band 40 rows tall over the text, split the raw file per frame, and count the distinct colours per frame with `od -An -tu1 -w3 -v | sort -u | wc -l`. Crossfade frames must show 1. The frames with text are the control: they must be many. + +Traps from this tick: + +- A minimum-length rule silently drops spoken captions. A short chunk that straddles a crossfade leaves two pieces under the minimum, and the voice speaks words that are never shown. A grid test that says "no overlay on a crossfade" stays green while this happens. Only a count of what was dropped finds it. +- Lab scripts run under `sh`, which is dash: `$((10#$x))` fails with "expecting EOF". Strip leading zeros with `sed 's/^0*//'` and default with `${i:-0}`. +- The Mac hook reads the whole Bash command. A heredoc PR body that names the report command gets refused. Write the body with the Write tool and pass `--body-file`. + ## 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 From 14858b7682d1d989be0633e266dc691727c0ca10 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Mon, 19 Oct 2026 03:09:49 +0700 Subject: [PATCH 10/13] t27f: R4f -- mode 5 holds the address for REEL_CTA_MIN_MS, cut 1 capped at BEAT_CUT1_LAST_MS (Closes #10629) On a voice that runs long the reel speeds it up so the address starts at REEL_MS - REEL_CTA_MIN_MS, but the beat clock centred crossfade 1 on that start, so the close beat owned fewer than 45 frames and mode 5 held the address for less than REEL_CTA_MIN_MS. beat_cut_ms now caps cut 1 at BEAT_CUT1_LAST_MS = 13249, the latest cut whose close beat still owns 45 frames (one ms later it owns 44). Tests: the bound itself with a +1 ms control; every lesson, both languages, every grid voice the clock accepts holds the address for the floor; the cost -- on a moved cut crossfade 1 starts under the task's last words, by at most 184 ms, and lesson 1's measured voice is one of them. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/t27f/SKILL.md | 4 + .trinity/seals/t27f_lesson_reel.json | 16 +-- specs/t27f/lesson_reel.t27 | 144 +++++++++++++++++++++++---- 3 files changed, 136 insertions(+), 28 deletions(-) diff --git a/.claude/skills/t27f/SKILL.md b/.claude/skills/t27f/SKILL.md index a875efbf0a..a93e606975 100644 --- a/.claude/skills/t27f/SKILL.md +++ b/.claude/skills/t27f/SKILL.md @@ -126,6 +126,10 @@ Traps from this tick: - Lab scripts run under `sh`, which is dash: `$((10#$x))` fails with "expecting EOF". Strip leading zeros with `sed 's/^0*//'` and default with `${i:-0}`. - The Mac hook reads the whole Bash command. A heredoc PR body that names the report command gets refused. Write the body with the Write tool and pass `--body-file`. +**Holding the address when the voice runs long (t27#10629).** If an overlay must stay on screen for a minimum time and must also stay off a crossfade, then the crossfade has to move; the overlay cannot. Work out in frames the latest cut that still leaves the last scene its minimum: cut 1 is capped at `BEAT_CUT1_LAST_MS` = 13249. Prove it is the latest with a one-ms-later control. Only the voices the reel speeds up are affected, so the `cmp` diff is limited to those voices and to crossfade 1's offset. The cost is real: the crossfade starts up to 184 ms under the task's last word, and lesson 1's measured voice (1300/4200/6800) is one of the affected voices. Write a test that bounds that overlap. + +- Before trusting a "fast voice" grid result, work out which fixture voices are fast. `voice_room_ms(3)` is 12250 ms, so the measured lesson 1 voice (raw 12300) is already sped up. A change aimed at "fast voices" therefore changes the reel everyone actually sees. + ## 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 6555450c57..ba43902687 100644 --- a/.trinity/seals/t27f_lesson_reel.json +++ b/.trinity/seals/t27f_lesson_reel.json @@ -31,23 +31,23 @@ } ], "config": "gen=c,rust,verilog,zig;tests=zig", - "gen_hash_c": "sha256:30f4f017bc4dd4c32c1ec195d83f827f10e9d260f30056ebd6f444cf034ca3ea", - "gen_hash_rust": "sha256:cd6858c82060106802b3cced271e84811d8ee02b8c4f2ed18ba75cf8068861eb", - "gen_hash_verilog": "sha256:d9d8bbee11770df310cf863654c914b318ea03940780b5a9e116cec3f4e33991", - "gen_hash_zig": "sha256:f5762a5368a29d6b2978d7ea365deed7c9c7a0d4994814a3d6586c039fdcbf25", + "gen_hash_c": "sha256:97c582c40a1e76a50e35c68414ce2653739784f9b0e3b58006deede027640fb3", + "gen_hash_rust": "sha256:f3b086a0827512fdebe957c9bc73b30592914373a1dd9137e57efb851ebf720f", + "gen_hash_verilog": "sha256:cc184e56898034ed556e2e7c9d111c028f56d60bb6adb21952a6b105d9baa0c9", + "gen_hash_zig": "sha256:aa16bbf44971ec5a6eacbd80389bca963b739a76f888cf401df14cb51abea29d", "module": "lesson_reel", "ring": 12, "seal_schema": 2, - "sealed_at": "2026-10-18T19:36:34Z", + "sealed_at": "2026-10-18T20:05:15Z", "sealed_by": "t27c-bootstrap@0.5.2", - "spec_hash": "sha256:b1ca187bbf04f6595be7dbf25b6a656286b07a86c50def9a0605c9ec56f017ec", + "spec_hash": "sha256:a380a3fec6de690924f36c45f40ba7aac1e002f4482fdb23d71e472357b16f19", "spec_path": "specs/t27f/lesson_reel.t27", "tests": { "failed": 0, "failing": [], "forced": false, - "passed": 25, - "total": 25, + "passed": 27, + "total": 27, "vacuous": 0 }, "toolchain": { diff --git a/specs/t27f/lesson_reel.t27 b/specs/t27f/lesson_reel.t27 index fcc8cd1800..8cc273e699 100644 --- a/specs/t27f/lesson_reel.t27 +++ b/specs/t27f/lesson_reel.t27 @@ -46,9 +46,11 @@ // the same places, and only their windows change with the beat. The cards are still on their // own frames over a crossfade: token_floor_mux prints their enable= and this file does not reach // it. Both cuts are centred on a sentence change, so a crossfade can begin under the last words -// of a phrase: mode 5 then drops a caption with too little room left (the cost tests), and on a -// voice the reel had to speed up it holds the address for less than REEL_CTA_MIN_MS. The scenes -// themselves are R3. +// of a phrase: mode 5 then drops a caption with too little room left (the cost tests). Cut 1 is +// capped at BEAT_CUT1_LAST_MS so mode 5 holds the address for REEL_CTA_MIN_MS (t27#10629): on a +// voice the reel had to speed up, lesson 1's measured voice among them, crossfade 1 then starts +// up to 184 ms before the task's last word ends, in modes 4 and 5 alike. The scenes themselves +// are R3. // phi^2 + 1/phi^2 = 3 | TRINITY module lesson_reel; @@ -709,11 +711,21 @@ pub fn beat_frames() -> i32 { // the open floor, a crossfade, and the main beat alone for BEAT_MIN_SOLO_MS. pub const BEAT_SPOKEN_MIN_MS : i32 = 2750; +// The latest cut 1 that still leaves the close beat's own frames REEL_CTA_MIN_MS to the reel's end +// (t27#10629): its crossfade starts on frame 389, so the close beat owns frames 405..449, 45 frames, +// 1500 ms. Mode 5 shows the address on those frames only, so a later cut holds it for less. +pub const BEAT_CUT1_LAST_MS : i32 = 13249; + // 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. +// Cut 1 is where the address takes over, but never after BEAT_CUT1_LAST_MS: on a voice the reel +// had to speed up the close beat comes in early, so the address keeps its hold in mode 5. 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); + var c1 : i32 = cta_from(t, lang, m0, m1, m2); + if (c1 > BEAT_CUT1_LAST_MS) { + c1 = BEAT_CUT1_LAST_MS; + } if (k == 0) { var s : i32 = phrase_start(m0, m1, m2, 1); if (s < BEAT_OPEN_FLOOR_MS) { @@ -1217,8 +1229,10 @@ test "on lesson 1's measured voice the cuts fall where the sentence changes" { 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 + // the reel speeds this voice up to the address's floor, so cut 1 is capped (t27#10629) + assert(cta_from(0, 0, M_EN0, M_EN1, M_EN2) > BEAT_CUT1_LAST_MS); + assert(c1 == BEAT_CUT1_LAST_MS); + // the open beat ends after the title is spoken, and the close is at least half in 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)); } @@ -1708,9 +1722,40 @@ test "the cost on lesson 1's measured voice: a caption across a crossfade with n assert(dropped > 0); } -test "the cost: on a voice the reel speeds up, mode 5 holds the address for less than REEL_CTA_MIN_MS" { - var short : usize = 0; - var least : i32 = beat_frames(); +// ---- the address hold's tests (t27#10629) ---- + +// The voice's last spoken ms: the end of the last phrase, before its caption's tail. +fn spoken_end(m0: i32, m1: i32, m2: i32) -> i32 { + const n = phrases_for(m0, m1, m2); + return phrase_start(m0, m1, m2, n - 1) + played(m0, m1, m2, n - 1); +} + +// How many frames mode 5 shows the address: from its first kept frame to the reel's last. +fn cta_held(t: usize, lang: usize, m0: i32, m1: i32, m2: i32) -> i32 { + const f0 = beat_xfade_frame(beat_cut_ms(t, lang, m0, m1, m2, 0)); + const f1 = beat_xfade_frame(beat_cut_ms(t, lang, m0, m1, m2, 1)); + const lo = beat_frame_from(cta_from(t, lang, m0, m1, m2)); + return beat_frames() - beat_first_lo(lo, beat_frames() - 1, f0, f1); +} + +test "BEAT_CUT1_LAST_MS is the latest cut 1 whose close beat owns REEL_CTA_MIN_MS" { + const f1 = beat_xfade_frame(BEAT_CUT1_LAST_MS); + assert(f1 == 389); + const own = beat_frames() - beat_scene_lo(BEAT_CLOSE, 0, f1); + assert(own == 45); + assert(own * 1000 >= REEL_CTA_MIN_MS * BASE_FPS); + // one ms later the crossfade starts a frame later and the close beat owns 44 frames + const g1 = beat_xfade_frame(BEAT_CUT1_LAST_MS + 1); + assert(g1 == 390); + assert((beat_frames() - beat_scene_lo(BEAT_CLOSE, 0, g1)) * 1000 < REEL_CTA_MIN_MS * BASE_FPS); + // the reel's address starts no later than the close beat could + assert(BEAT_CUT1_LAST_MS < REEL_MS - REEL_CTA_MIN_MS); +} + +test "mode 5 holds the address for REEL_CTA_MIN_MS, every lesson, both languages, every voice the clock accepts" { + var voices : usize = 0; + var moved : usize = 0; + var short_before : usize = 0; var t : usize = 0; while (t < TOPICS) { var lang : usize = 0; @@ -1725,15 +1770,26 @@ test "the cost: on a voice the reel speeds up, mode 5 holds the address for less const m1 = BEAT_GRID_M1[j]; const m2 = BEAT_GRID_M2[k]; if (voice_fits(m0, m1, m2)) { - const f0 = beat_xfade_frame(beat_cut_ms(t, lang, m0, m1, m2, 0)); - const f1 = beat_xfade_frame(beat_cut_ms(t, lang, m0, m1, m2, 1)); - const lo = beat_frame_from(cta_from(t, lang, m0, m1, m2)); - const held = beat_frames() - beat_first_lo(lo, beat_frames() - 1, f0, f1); - if (held < least) { - least = held; + voices = voices + 1; + const cta = cta_from(t, lang, m0, m1, m2); + const c1 = beat_cut_ms(t, lang, m0, m1, m2, 1); + assert(cta_held(t, lang, m0, m1, m2) * 1000 >= REEL_CTA_MIN_MS * BASE_FPS); + if (c1 < cta) { + moved = moved + 1; + assert(c1 == BEAT_CUT1_LAST_MS); + } else { + assert(c1 == cta); + } + // control: the cut centred on the address, as before t27#10629 + const lo = beat_frame_from(cta); + var old_lo : i32 = beat_scene_lo(BEAT_CLOSE, 0, beat_xfade_frame(cta)); + if (lo > old_lo) { + old_lo = lo; } - if (held * 1000 < REEL_CTA_MIN_MS * BASE_FPS) { - short = short + 1; + if ((beat_frames() - old_lo) * 1000 < REEL_CTA_MIN_MS * BASE_FPS) { + short_before = short_before + 1; + // every voice the old cut held short is one the cap moves + assert(c1 < cta); } } k = k + 1; @@ -1746,7 +1802,55 @@ test "the cost: on a voice the reel speeds up, mode 5 holds the address for less } t = t + 1; } - assert(short > 0); - // never less than the close beat's own frames: its solo floor less the crossfade's last frame - assert(least >= MIN_SCENE_FRAMES - 1); + assert(voices > 0); + assert(short_before > 0); + assert(moved >= short_before); + // most voices end early enough that the cut stays on the address + assert(moved < voices); +} + +test "the cost: on a voice the cap moves, crossfade 1 starts under the task's last words, by at most 184 ms" { + const bound = REEL_MS - REEL_CTA_MIN_MS - CAPTION_TAIL_MS - beat_frame_ms(beat_xfade_frame(BEAT_CUT1_LAST_MS)); + assert(bound == 184); + var under : 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 c1 = beat_cut_ms(t, lang, m0, m1, m2, 1); + const x1 = beat_frame_ms(beat_xfade_frame(c1)); + const e = spoken_end(m0, m1, m2); + if (x1 < e) { + under = under + 1; + // only a moved cut starts under the voice, and never by more than the bound + assert(c1 < cta_from(t, lang, m0, m1, m2)); + assert(e - x1 <= bound); + } + } + k = k + 1; + } + j = j + 1; + } + i = i + 1; + } + lang = lang + 1; + } + t = t + 1; + } + assert(under > 0); + // lesson 1's measured voice is one of them: the reel speeds it up to the address's floor + const c1 = beat_cut_ms(0, 0, M_EN0, M_EN1, M_EN2, 1); + assert(c1 == BEAT_CUT1_LAST_MS); + assert(cta_from(0, 0, M_EN0, M_EN1, M_EN2) > BEAT_CUT1_LAST_MS); + assert(spoken_end(M_EN0, M_EN1, M_EN2) > beat_frame_ms(beat_xfade_frame(c1))); } From db3695327bbe8d8a3f65866e64b037190b842342 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Mon, 19 Oct 2026 03:17:43 +0700 Subject: [PATCH 11/13] ledger: t27b row for specs/claw/token_shape.t27 (Refs #10633) Same line as #10634, so the ratchet on this stack reports UNLISTED 0 before #10634 lands. Co-Authored-By: Claude Opus 5.5 --- docs/reports/t27b_expectations.json | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/reports/t27b_expectations.json b/docs/reports/t27b_expectations.json index 105c8f3c2b..77feba9cdc 100644 --- a/docs/reports/t27b_expectations.json +++ b/docs/reports/t27b_expectations.json @@ -209,6 +209,7 @@ {"path": "specs/claw/t27f_gate.t27", "t27b": "pass"}, {"path": "specs/claw/t27f_gate_pins.t27", "t27b": "pass"}, {"path": "specs/claw/text.t27", "t27b": "pass"}, + {"path": "specs/claw/token_shape.t27", "t27b": "pass"}, {"path": "specs/compiler/c_array_repeat_wrapper.t27", "t27b": "pass"}, {"path": "specs/compiler/c_fixed_array_return.t27", "t27b": "pass"}, {"path": "specs/compiler/codegen_conformance.t27", "t27b": "pass"}, From baa97327c81ddac198849c55df6b80890fd51c23 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Mon, 19 Oct 2026 03:38:45 +0700 Subject: [PATCH 12/13] t27f: R4e -- mode 5 cards stay inside one scene, token_floor_mux's card printer takes a caller window (Closes #10628) In LR_MODE=5 the captions, tags and address were clipped to their scene's own frames, but the cards kept token_floor_mux's enable= and rose in under crossfade 1 (frames 393..404 on lesson 1's voice). token_floor_mux: print_ramp, print_rise_from and print_alpha take the window; print_rise(o, c, dy) and print_fade(c) are thin wrappers that pass CARD_ON, so token_floor_mux's own graph is byte-identical. lesson_reel: with beats a card is clipped with beat_piece_* like a caption, dropped when no piece is kept, rises from its first kept frame and fades out on its last (new beat_last_hi); card_hi clamps CARD_ON's 450 to the reel's last frame. Modes 3 and 4 are unchanged. Tests: card windows and beat_last_hi; every lesson, both languages, every voice the clock accepts puts no card piece on a crossfade, with a control that token_floor_mux's own windows do meet one (lesson 1's last card from frame 392 under crossfade 1 at 389, moved to 405). Co-Authored-By: Claude Opus 5.5 --- .claude/skills/t27f/SKILL.md | 5 + .trinity/seals/t27f_lesson_reel.json | 18 +-- .trinity/seals/t27f_token_floor_mux.json | 16 +-- specs/t27f/lesson_reel.t27 | 171 +++++++++++++++++++++-- specs/t27f/token_floor_mux.t27 | 34 +++-- 5 files changed, 202 insertions(+), 42 deletions(-) diff --git a/.claude/skills/t27f/SKILL.md b/.claude/skills/t27f/SKILL.md index 157e44b470..c0ad2fcc48 100644 --- a/.claude/skills/t27f/SKILL.md +++ b/.claude/skills/t27f/SKILL.md @@ -130,6 +130,11 @@ Traps from this tick: - Before trusting a "fast voice" grid result, work out which fixture voices are fast. `voice_room_ms(3)` is 12250 ms, so the measured lesson 1 voice (raw 12300) is already sped up. A change aimed at "fast voices" therefore changes the reel everyone actually sees. +**Letting a caller reach a printer in another spec (t27#10628).** When an overlay's `enable=` is printed in an imported spec, the importing spec cannot clip it. Split the printer into a form that takes the window (`print_alpha(lo, hi)`, `print_rise_from(o, lo, dy)` in token_floor_mux) and the old form as a thin wrapper that passes the spec's own window (`print_fade(c)`, `print_rise(o, c, dy)`). Then run `cmp` on the imported spec's own graph: it must be byte-identical (token_floor_mux: 28336 bytes, 0 differences). Run `cmp` on every mode of the importer that does not use the window too (modes 3 and 4: 0 of 162 graphs differ). + +- Move the ramp along with the window. A card's rise and fade-in start at its first kept frame (`beat_first_lo`), and its fade-out ends at its last kept frame (`beat_last_hi`). If you clip only `enable=`, the card pops in fully risen on the close beat's first frame, because the ramp started on frame 392, under the crossfade. +- A window can end past the reel. `CARD_ON` gives the last card frames 392..450, but the reel's last frame is 449. Clamp the window (`card_hi`) and assert the clamp in a test. + ## 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 ba43902687..8d2236dfd7 100644 --- a/.trinity/seals/t27f_lesson_reel.json +++ b/.trinity/seals/t27f_lesson_reel.json @@ -23,7 +23,7 @@ }, { "spec": "specs/t27f/token_floor_mux.t27", - "spec_hash": "sha256:55c02c60e581640d2f3d6db3d9ba2cdfb21d74b172290b386023e8f264e59a04" + "spec_hash": "sha256:36088094a533d70fcb7e995c53ce129b21367394057c4407f400b9dff9be6d6f" }, { "spec": "specs/t27f/token_floor_scenes.t27", @@ -31,23 +31,23 @@ } ], "config": "gen=c,rust,verilog,zig;tests=zig", - "gen_hash_c": "sha256:97c582c40a1e76a50e35c68414ce2653739784f9b0e3b58006deede027640fb3", - "gen_hash_rust": "sha256:f3b086a0827512fdebe957c9bc73b30592914373a1dd9137e57efb851ebf720f", - "gen_hash_verilog": "sha256:cc184e56898034ed556e2e7c9d111c028f56d60bb6adb21952a6b105d9baa0c9", - "gen_hash_zig": "sha256:aa16bbf44971ec5a6eacbd80389bca963b739a76f888cf401df14cb51abea29d", + "gen_hash_c": "sha256:5b85cb24996a66cf14d5816dfc07ffda9653e1acd9922c2b9f73526b5c37c626", + "gen_hash_rust": "sha256:b9edb32d279aaeef4388bc6c5e765676c9d86d86a434fbe019aab7c2ebc38d8e", + "gen_hash_verilog": "sha256:1847abc2cfd9f4c7c65492b702f962329e92c0477455360673e15be927c1ab5a", + "gen_hash_zig": "sha256:8a4ce7a647d698dc6d5e0f0f4999e8a523dbcf599597b89b17988f36b88f7508", "module": "lesson_reel", "ring": 12, "seal_schema": 2, - "sealed_at": "2026-10-18T20:05:15Z", + "sealed_at": "2026-10-18T20:27:01Z", "sealed_by": "t27c-bootstrap@0.5.2", - "spec_hash": "sha256:a380a3fec6de690924f36c45f40ba7aac1e002f4482fdb23d71e472357b16f19", + "spec_hash": "sha256:6097e5dd188754d6c72205a95322c542ab6c3304b1d4259d22dc9a2f5af92862", "spec_path": "specs/t27f/lesson_reel.t27", "tests": { "failed": 0, "failing": [], "forced": false, - "passed": 27, - "total": 27, + "passed": 29, + "total": 29, "vacuous": 0 }, "toolchain": { diff --git a/.trinity/seals/t27f_token_floor_mux.json b/.trinity/seals/t27f_token_floor_mux.json index 6f7f575dc7..c48d58df98 100644 --- a/.trinity/seals/t27f_token_floor_mux.json +++ b/.trinity/seals/t27f_token_floor_mux.json @@ -1,5 +1,5 @@ { - "built_by": "t27c-bootstrap@0.5.2+5e4d3b998", + "built_by": "t27c-bootstrap@0.5.2+9448ab4be", "closure": [ { "spec": "specs/automation/owner_voice_reel.t27", @@ -27,16 +27,16 @@ } ], "config": "gen=c,rust,verilog,zig;tests=zig", - "gen_hash_c": "sha256:18710ba70409c54ce28a6baa8da85579904bf6eeec5e91f4913496216a7fb9cb", - "gen_hash_rust": "sha256:c7240d2db316d11bbff444fed4725b84a77bd566a92e6286dcc4966ae9f2bf06", - "gen_hash_verilog": "sha256:7c6af1b05a4ecfb5560ff39e4728fd96b7ab95c825c428289ab330456bc44dd7", - "gen_hash_zig": "sha256:bcd5f63e11e3fe7de9b9e2dbbe844987430961ac73d8048404c8c42e0c9d6e1c", + "gen_hash_c": "sha256:d4a9f6f33414d4a5e9f62de06f0e27f86ea182916737d818df43995b3fdfe306", + "gen_hash_rust": "sha256:b5bdd6788752a87454d7e36271b94ac41884d08cd230a6256e51fb3c00f2698f", + "gen_hash_verilog": "sha256:3b54ee703bf10b10410b6380c589dc798eadfb590b49be296353c70a2817ccb8", + "gen_hash_zig": "sha256:320cdac0773bf3a9fe387107c00513f82ab6aaa40c8d08dfef746d7bd6d84c80", "module": "token_floor_mux", "ring": 12, "seal_schema": 2, - "sealed_at": "2026-10-18T15:24:22Z", + "sealed_at": "2026-10-18T20:26:58Z", "sealed_by": "t27c-bootstrap@0.5.2", - "spec_hash": "sha256:55c02c60e581640d2f3d6db3d9ba2cdfb21d74b172290b386023e8f264e59a04", + "spec_hash": "sha256:36088094a533d70fcb7e995c53ce129b21367394057c4407f400b9dff9be6d6f", "spec_path": "specs/t27f/token_floor_mux.t27", "tests": { "failed": 0, @@ -47,7 +47,7 @@ "vacuous": 0 }, "toolchain": { - "test_runner": "sha256:1982ae948e24d00c5d221ccec12dfce1eb01204b53e37b0f857f93b16d64629d", + "test_runner": "sha256:8512b80da533b79289110fca4a65f3336abd7a059ca4b72ea595d5e33ff8ad10", "zig": "0.16.0" } } \ No newline at end of file diff --git a/specs/t27f/lesson_reel.t27 b/specs/t27f/lesson_reel.t27 index 8cc273e699..7a67961f17 100644 --- a/specs/t27f/lesson_reel.t27 +++ b/specs/t27f/lesson_reel.t27 @@ -18,8 +18,9 @@ // 4 the beat graph (t27#10603), given LR_MS0..LR_MS2: inputs 0..2 the open, main and close // scene clips, crossfaded on whole frames at the beat clock's cuts and trimmed to the reel // 5 mode 3's two graphs laid over mode 4's beat video (t27#10609): inputs 0..2 the scene -// clips, 3..5 the phrases' wavs, 6 the music. The captions, tags and address are shown on -// whole frames inside the scenes and never on a crossfade (t27#10614, beat_piece_*) +// clips, 3..5 the phrases' wavs, 6 the music. The captions, tags, cards and address are shown +// on whole frames inside the scenes and never on a crossfade (t27#10614, t27#10628, +// beat_piece_*) // Every text that comes from the lesson goes through a textfile, so no quote, colon or percent in // a lesson can break the graph (and drawtext runs with expansion=none). // @@ -43,9 +44,9 @@ // // NOT CLAIMED: the backdrop is token_floor's physics world, not a picture of the lesson; its frame // labels are the physics reel's. Mode 5 lays the same captions and address over every scene, in -// the same places, and only their windows change with the beat. The cards are still on their -// own frames over a crossfade: token_floor_mux prints their enable= and this file does not reach -// it. Both cuts are centred on a sentence change, so a crossfade can begin under the last words +// the same places, and only their windows change with the beat. The cards keep token_floor_mux's +// windows, cut to their scene's frames as the captions are; a card with no room is dropped. +// Both cuts are centred on a sentence change, so a crossfade can begin under the last words // of a phrase: mode 5 then drops a caption with too little room left (the cost tests). Cut 1 is // capped at BEAT_CUT1_LAST_MS so mode 5 holds the address for REEL_CTA_MIN_MS (t27#10629): on a // voice the reel had to speed up, lesson 1's measured voice among them, crossfade 1 then starts @@ -77,6 +78,7 @@ use t27f::token_floor_scenes::TAG_X; use t27f::token_floor_scenes::TAG_Y; use t27f::token_floor_scenes::TAG_FONT; use t27f::token_floor_scenes::CARDS; +use t27f::token_floor_scenes::CARD_ON; use t27f::token_floor_scenes::CARD_LY; use t27f::token_floor_scenes::CARD_PAD; use t27f::token_floor_scenes::PILL_H; @@ -107,6 +109,8 @@ use t27f::token_floor_mux::print_col; use t27f::token_floor_mux::band_font; use t27f::token_floor_mux::print_rise; use t27f::token_floor_mux::print_fade; +use t27f::token_floor_mux::print_rise_from; +use t27f::token_floor_mux::print_alpha; use t27f::reel_topics::TOPICS; use t27f::reel_topics::item; use t27f::reel_topics::id_item; @@ -553,17 +557,54 @@ fn print_tag(o: usize, s: usize, dx: i32, row: usize, f: usize, lang: usize, bea } } -fn print_card_text(o: usize, c: usize, lang: usize, f: usize, font: i32, row: usize, dy: f64) -> void { +// Card c's window on the reel's frames: token_floor_mux's CARD_ON, its last frame inside the reel. +pub fn card_lo(c: usize) -> i32 { + return CARD_ON[2 * c]; +} + +pub fn card_hi(c: usize) -> i32 { + return beat_min(CARD_ON[2 * c + 1], beat_frames() - 1); +} + +// A card text's rise and fade. Without beats, token_floor_mux's own (print_rise, print_fade). With +// beats the card rises from the first frame of its first kept piece, fades out on the last frame +// of its last, and is on only on its pieces (t27#10628). +fn print_card_on(o: usize, c: usize, dy: f64, beats: bool, f0: i32, f1: i32) -> void { + if (beats) { + const lo = card_lo(c); + const hi = card_hi(c); + print_rise_from(o, beat_first_lo(lo, hi, f0, f1), dy); + } else { + print_rise(o, c, dy); + } +} + +fn print_card_off(c: usize, beats: bool, f0: i32, f1: i32) -> void { + if (beats) { + const lo = card_lo(c); + const hi = card_hi(c); + print_alpha(beat_first_lo(lo, hi, f0, f1), beat_last_hi(lo, hi, f0, f1)); + print_pieces(lo, hi, f0, f1); + } else { + print_fade(c); + } +} + +fn print_card_text(o: usize, c: usize, lang: usize, f: usize, font: i32, row: usize, dy: f64, beats: bool, f0: i32, f1: i32) -> void { std.debug.print(",drawtext=fontfile={s}:expansion=none:textfile={s}/{s}/f{d:0>2}.txt:fontsize={d}:fontcolor=", .{ FONT, FILE_DIR, lang_dir(lang), f, font }); print_col(row); std.debug.print(":x={d:.1}", .{card_f(o, 0) + CARD_PAD}); - print_rise(o, c, dy); + print_card_on(o, c, dy, beats, f0, f1); std.debug.print("'", .{}); - print_fade(c); + print_card_off(c, beats, f0, f1); } -fn print_card(o: usize, c: usize, t: usize, lang: usize) -> void { - print_card_text(o, c, lang, F_CARD_LABEL, label_font(o), GREY_ROW, CARD_LY[o * 3]); +// With beats a card with no kept piece is not printed, as a tag or a caption is not. +fn print_card(o: usize, c: usize, t: usize, lang: usize, beats: bool, f0: i32, f1: i32) -> void { + if (beats && beat_pieces(card_lo(c), card_hi(c), f0, f1) == 0) { + return; + } + print_card_text(o, c, lang, F_CARD_LABEL, label_font(o), GREY_ROW, CARD_LY[o * 3], beats, f0, f1); var j : usize = 0; while (j < 2) { const font = code_font(o, line_len(card_code_line(t, c, j))); @@ -571,13 +612,13 @@ fn print_card(o: usize, c: usize, t: usize, lang: usize) -> void { if (j == 1) { row = GOLD_ROW; } - print_card_text(o, c, lang, F_CARD0 + 2 * c + j, font, row, CARD_LY[o * 3 + 1 + j]); + print_card_text(o, c, lang, F_CARD0 + 2 * c + j, font, row, CARD_LY[o * 3 + 1 + j], beats, f0, f1); j = j + 1; } std.debug.print(",drawtext=fontfile={s}:text='PASS':fontsize={d}:fontcolor=0x000000:x={d:.1}", .{ FONT, PASS_FONT[o], pill_x(o) + PASS_DX }); - print_rise(o, c, pill_dy(o) + 0.5 * PILL_H); + print_card_on(o, c, pill_dy(o) + 0.5 * PILL_H, beats, f0, f1); std.debug.print("-text_h/2'", .{}); - print_fade(c); + print_card_off(c, beats, f0, f1); } // The address and its page from ms a to the end. With beats they fade in from the first frame of @@ -635,7 +676,7 @@ pub fn graph(o: usize, t: usize, lang: usize, m0: i32, m1: i32, m2: i32, beats: } var c : usize = 0; while (c < CARDS) { - print_card(o, c, t, lang); + print_card(o, c, t, lang, beats, f0, f1); c = c + 1; } const n = phrases_for(m0, m1, m2); @@ -946,6 +987,19 @@ pub fn beat_first_lo(lo: i32, hi: i32, f0: i32, f1: i32) -> i32 { return lo; } +// The last frame of the last kept piece, where a card's fade out ends in mode 5; hi when none is +// kept. +pub fn beat_last_hi(lo: i32, hi: i32, f0: i32, f1: i32) -> i32 { + var b : usize = 3; + while (b > 0) { + b = b - 1; + if (beat_piece_kept(lo, hi, b, f0, f1)) { + return beat_piece_hi(hi, b, f0, f1); + } + } + return hi; +} + // Mode 5's enable= for overlay lo..hi: exactly its kept pieces' frames. fn print_pieces(lo: i32, hi: i32, f0: i32, f1: i32) -> void { std.debug.print(":enable='", .{}); @@ -1687,6 +1741,95 @@ test "in mode 5 no caption, tag or address is on a crossfade, every lesson, both assert(dropped < windows); } +test "a card's window is token_floor_mux's, inside the reel, and its last kept piece ends where it should" { + assert(card_lo(0) == 98); + assert(card_hi(0) == 176); + assert(card_lo(2) == 392); + // CARD_ON says 450, a frame past the reel's last + assert(CARD_ON[5] == beat_frames()); + assert(card_hi(2) == beat_frames() - 1); + const f0 : i32 = 100; + const f1 : i32 = 300; + // one kept piece: first and last are its own ends + assert(beat_last_hi(95, 160, f0, f1) == 160); + assert(beat_first_lo(95, 160, f0, f1) == 116); + // two kept pieces: the last ends with the window, the first starts with it + assert(beat_last_hi(80, 140, f0, f1) == 140); + assert(beat_last_hi(80, 108, f0, f1) == 99); + // none kept: the window's own end + assert(beat_pieces(102, 110, f0, f1) == 0); + assert(beat_last_hi(102, 110, f0, f1) == 110); +} + +test "in mode 5 no card is on a crossfade, every lesson, both languages, every voice the clock accepts" { + var voices : usize = 0; + var crossing : usize = 0; + var kept : 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)) { + voices = voices + 1; + const f0 = beat_xfade_frame(beat_cut_ms(t, lang, m0, m1, m2, 0)); + const f1 = beat_xfade_frame(beat_cut_ms(t, lang, m0, m1, m2, 1)); + var c : usize = 0; + while (c < CARDS) { + const lo = card_lo(c); + const hi = card_hi(c); + assert(pieces_clear(lo, hi, f0, f1)); + if (on_xfade(lo, hi, f0, f1)) { + crossing = crossing + 1; + } + if (beat_pieces(lo, hi, f0, f1) > 0) { + kept = kept + 1; + // the rise starts and the fade ends on frames the card is on + const a = beat_first_lo(lo, hi, f0, f1); + const z = beat_last_hi(lo, hi, f0, f1); + assert(a >= lo); + assert(z <= hi); + assert(z >= a); + assert(on_xfade(a, a, f0, f1) == false); + assert(on_xfade(z, z, f0, f1) == false); + } + c = c + 1; + } + // the last card is on in the close beat, which owns 45 frames + assert(beat_pieces(card_lo(2), card_hi(2), f0, f1) == 1); + } + k = k + 1; + } + j = j + 1; + } + i = i + 1; + } + lang = lang + 1; + } + t = t + 1; + } + assert(voices > 0); + assert(kept >= voices); + // control: token_floor_mux's own card windows do meet the crossfades, so the rule has work to do + assert(crossing > 0); + // on lesson 1's measured voice cut 1 is capped, crossfade 1 starts on frame 389, and mode 3's + // last card, on from frame 392, rises under it; mode 5 starts it on the close beat's first frame + const f0 = beat_xfade_frame(beat_cut_ms(0, 0, M_EN0, M_EN1, M_EN2, 0)); + const f1 = beat_xfade_frame(beat_cut_ms(0, 0, M_EN0, M_EN1, M_EN2, 1)); + assert(f1 == 389); + assert(on_xfade(card_lo(2), card_hi(2), f0, f1)); + assert(beat_first_lo(card_lo(2), card_hi(2), f0, f1) == 405); + assert(beat_last_hi(card_lo(2), card_hi(2), f0, f1) == 449); +} + test "the cost on lesson 1's measured voice: a caption across a crossfade with no room is dropped, and every phrase keeps one" { var dropped : usize = 0; var t : usize = 0; diff --git a/specs/t27f/token_floor_mux.t27 b/specs/t27f/token_floor_mux.t27 index cdcb184870..cd63d1eb2b 100644 --- a/specs/t27f/token_floor_mux.t27 +++ b/specs/t27f/token_floor_mux.t27 @@ -321,25 +321,37 @@ fn line_row(l: usize) -> usize { return GOLD_ROW; } -// v, the card's ramp, as an ffmpeg expression in the frame number n: the same clip as card_opacity -fn print_ramp(c: usize) -> void { - std.debug.print("clip(min((n-{d})/{d:.1},({d}-n)/{d:.1}),0,1)", .{ CARD_ON[2 * c], CARD_IN, CARD_ON[2 * c + 1] + 1, CARD_OUT }); +// v, the ramp of a card up on frames lo..hi, as an ffmpeg expression in the frame number n: the +// same clip as card_opacity. A caller with its own window passes it (lesson_reel's mode 5, +// t27#10628); card c's own is CARD_ON. +fn print_ramp(lo: i32, hi: i32) -> void { + std.debug.print("clip(min((n-{d})/{d:.1},({d}-n)/{d:.1}),0,1)", .{ lo, CARD_IN, hi + 1, CARD_OUT }); +} + +// the text's top: its place below the card's top, plus card_drop from frame lo +pub fn print_rise_from(o: usize, lo: i32, dy: f64) -> void { + std.debug.print(":y='{d:.1}+{d:.1}*(1+{d:.1}*max(0,(n-{d})/{d}))*exp(-{d:.1}*max(0,(n-{d})/{d}))", .{ card_f(o, 1) + dy, CARD_RISE, CARD_W, lo, FPS, CARD_W, lo, FPS }); } -// the text's top: its place below the card's top, plus card_drop pub fn print_rise(o: usize, c: usize, dy: f64) -> void { - std.debug.print(":y='{d:.1}+{d:.1}*(1+{d:.1}*max(0,(n-{d})/{d}))*exp(-{d:.1}*max(0,(n-{d})/{d}))", .{ card_f(o, 1) + dy, CARD_RISE, CARD_W, CARD_ON[2 * c], FPS, CARD_W, CARD_ON[2 * c], FPS }); + print_rise_from(o, CARD_ON[2 * c], dy); } -// the smootherstep of the ramp, as card_opacity has it, and the frames the card is up -pub fn print_fade(c: usize) -> void { +// the smootherstep of the ramp over lo..hi, as card_opacity has it, without an enable= +pub fn print_alpha(lo: i32, hi: i32) -> void { std.debug.print(":alpha='pow(", .{}); - print_ramp(c); + print_ramp(lo, hi); std.debug.print(",3)*(", .{}); - print_ramp(c); + print_ramp(lo, hi); std.debug.print("*(6*", .{}); - print_ramp(c); - std.debug.print("-15)+10)':enable='between(n,{d},{d})'", .{ CARD_ON[2 * c], CARD_ON[2 * c + 1] }); + print_ramp(lo, hi); + std.debug.print("-15)+10)'", .{}); +} + +// card c's own fade and the frames it is up +pub fn print_fade(c: usize) -> void { + print_alpha(CARD_ON[2 * c], CARD_ON[2 * c + 1]); + std.debug.print(":enable='between(n,{d},{d})'", .{ CARD_ON[2 * c], CARD_ON[2 * c + 1] }); } fn print_card(o: usize, c: usize) -> void { From ee0489b3d0fe1562f53a58cace47167f8eb6c2c4 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Mon, 19 Oct 2026 03:50:22 +0700 Subject: [PATCH 13/13] ledger: t27b row for specs/analog/sar_comparator.t27, UNLISTED 1 -> 0 (Refs #10649) Same row as #10650 on master, so this PR's ratchet goes green first. Co-Authored-By: Claude Opus 5.5 --- docs/reports/t27b_expectations.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/reports/t27b_expectations.json b/docs/reports/t27b_expectations.json index 77feba9cdc..074f9e335a 100644 --- a/docs/reports/t27b_expectations.json +++ b/docs/reports/t27b_expectations.json @@ -48,6 +48,7 @@ {"path": "specs/analog/linear_zoh.t27", "t27b": "blocked", "blocker": "ExprCall(@sin)", "reason": "unimplemented"}, {"path": "specs/analog/mos_table.t27", "t27b": "pass"}, {"path": "specs/analog/sar_cdac.t27", "t27b": "pass"}, + {"path": "specs/analog/sar_comparator.t27", "t27b": "pass"}, {"path": "specs/api/c_abi.t27", "t27b": "pass"}, {"path": "specs/api/tri_api_context.t27", "t27b": "pass"}, {"path": "specs/api/tri_api_loop.t27", "t27b": "pass"}, @@ -1457,6 +1458,7 @@ {"path": "specs/t27f/deck.t27", "t27b": "pass"}, {"path": "specs/t27f/frame_cache.t27", "t27b": "pass"}, {"path": "specs/t27f/frame_shard.t27", "t27b": "blocked", "blocker": "type [N]T", "reason": "unimplemented"}, + {"path": "specs/t27f/frontend.t27", "t27b": "pass"}, {"path": "specs/t27f/hot.t27", "t27b": "pass"}, {"path": "specs/t27f/lesson_reel.t27", "t27b": "blocked", "blocker": "type [N]T", "reason": "unimplemented"}, {"path": "specs/t27f/outfit_atlas.t27", "t27b": "pass"},