diff --git a/.claude/skills/t27f/SKILL.md b/.claude/skills/t27f/SKILL.md index 187a88c8ab..0d529a6abd 100644 --- a/.claude/skills/t27f/SKILL.md +++ b/.claude/skills/t27f/SKILL.md @@ -73,6 +73,33 @@ 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. + +**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 diff --git a/.trinity/seals/t27f_lesson_reel.json b/.trinity/seals/t27f_lesson_reel.json index 490e46ad7c..ae4cace0cf 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: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-18T15:14:32Z", + "sealed_at": "2026-10-18T18:44:43Z", "sealed_by": "t27c-bootstrap@0.5.2", - "spec_hash": "sha256:0c527e6415d5ffe600cd425db2456e1691e47bcaaf69cf5af0b856bb8bfec66e", + "spec_hash": "sha256:69663c90a7ad61dbdf5a017d97f28d2dc0c885dae0643a361d43bbae31787a9a", "spec_path": "specs/t27f/lesson_reel.t27", "tests": { "failed": 0, "failing": [], "forced": false, - "passed": 6, - "total": 6, + "passed": 17, + "total": 17, "vacuous": 0 }, "toolchain": { diff --git a/specs/t27f/lesson_reel.t27 b/specs/t27f/lesson_reel.t27 index 29726fc24b..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). // @@ -24,9 +26,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. 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; @@ -95,6 +109,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 +615,148 @@ 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; +} + +// ---- 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 @@ -652,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)}); } @@ -821,3 +989,260 @@ 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); +} + +// ---- 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); +}