diff --git a/specs/t27f/reel_particles_mux.t27 b/specs/t27f/reel_particles_mux.t27 index 21c093ddf9..91ecc64ac5 100644 --- a/specs/t27f/reel_particles_mux.t27 +++ b/specs/t27f/reel_particles_mux.t27 @@ -67,11 +67,11 @@ fn until_ms(k: usize) -> i32 { return shown_until_ms(WORD_END_MS[k], false, 0); } -fn secs(ms: i32) -> i32 { +pub fn secs(ms: i32) -> i32 { return @divTrunc(ms, 1000); } -fn millis(ms: i32) -> u32 { +pub fn millis(ms: i32) -> u32 { // unsigned: zig prints a signed int under a width with a + sign return @as(u32, @intCast(@mod(ms, 1000))); } diff --git a/specs/t27f/token_floor.t27 b/specs/t27f/token_floor.t27 index d94ab99281..500992ca59 100644 --- a/specs/t27f/token_floor.t27 +++ b/specs/t27f/token_floor.t27 @@ -369,7 +369,7 @@ fn hertz_overlap(load: f64) -> f64 { return @exp(@log(load / 100000.0) * 2.0 / 3.0); } -fn rest_y(k: usize) -> f64 { +pub fn rest_y(k: usize) -> f64 { const w = M_BALL * G_STD; var y : f64 = R_BALL - hertz_overlap(@as(f64, @floatFromInt(BALLS)) * w); var j : usize = 1; @@ -380,12 +380,12 @@ fn rest_y(k: usize) -> f64 { return y; } -fn rest_px(k: usize) -> f64 { +pub fn rest_px(k: usize) -> f64 { return FLOOR_PX - rest_y(k) * PXM; } // Field f of label l: 0 x, 1 y, 2 pitch, 3 colour, 4 trigger, 5 delay, 6 start. -fn lbl_f(l: usize, f: usize) -> f64 { +pub fn lbl_f(l: usize, f: usize) -> f64 { if (l < FIXED) { return LT[l * 7 + f]; } @@ -411,7 +411,7 @@ fn lbl_f(l: usize, f: usize) -> f64 { } // The ball whose landing starts label l, or -1 for the clock. -fn lbl_trig(l: usize) -> i64 { +pub fn lbl_trig(l: usize) -> i64 { return @as(i64, @intFromFloat(lbl_f(l, 4))) - 1; } @@ -433,7 +433,7 @@ fn counter_need() -> i64 { // ---- the balls ---- -fn release_frame(i: usize) -> i32 { +pub fn release_frame(i: usize) -> i32 { if (i == 0) { return REL0; } @@ -508,7 +508,7 @@ fn stack_step(y: *[20]f64, v: *[20]f64, a: *[20]f64, landed: *[20]i32, ext: *[2] } // The frame a ball dropped from rest at H_DROP first touches an uncompressed stack, by free fall. -fn land_frame(k: usize) -> i32 { +pub fn land_frame(k: usize) -> i32 { const h = H_DROP - R_BALL - 2.0 * R_BALL * @as(f64, @floatFromInt(k)); const t = @sqrt(2.0 * h / G_STD); return release_frame(k) + @as(i32, @intFromFloat(@ceil(t * @as(f64, @floatFromInt(FPS))))) - 1; diff --git a/specs/t27f/token_floor_mux.t27 b/specs/t27f/token_floor_mux.t27 new file mode 100644 index 0000000000..88c108f12b --- /dev/null +++ b/specs/t27f/token_floor_mux.t27 @@ -0,0 +1,282 @@ +// SPDX-License-Identifier: Apache-2.0 +// specs/t27f/token_floor_mux.t27 -- the owner's voice, the yellow word captions and the site's +// music over the frames of specs/t27f/token_floor.t27, each phrase spoken at the physics event it +// names. +// +// WHY: the owner asked for the token-floor infographic to be voiced the way the first reel was: +// their own voice, one-word captions, the music underneath. The recipe of that reel (loudness, the +// caption look, the tail, the music fades, the limiter) is specs/automation/owner_voice_reel.t27; +// the time printers are specs/t27f/reel_particles_mux.t27. Neither is restated here. +// +// THE SCRIPT (Russian; English gloss): P0 "one token against physics", P1 "the white ball is the +// Landauer floor", P2 "each gold ball: times ten", P3 "19 balls, 19 decades", P4 "a real token: +// 1.7 joules", P5 "the rest is engineering". Each phrase waits for its event: P1 for the white +// ball's landing (the PHYSICS FLOOR label), P2 for the first gold ball's (the X10 counter), P3 for +// the tenth gold ball's (counter exponent 10), P4 for the 1.7 J label, P5 for the bill label. The +// event frames are token_floor's own closed forms (land_frame, the label table), not copied +// numbers; a phrase starts at its event or, if the previous phrase is still speaking, PHRASE_GAP_MS +// after it ends. +// +// WHAT IS MEASURED (Railway, 2026-10-18): the six phrases were synthesized one call each by the +// 999 voice-clone service (Qwen3-TTS 1.7B, ICL mode, the owner's own sample); PHRASE_MS is each +// wav's length. The 999 whisper service read every phrase back word for word, and WORD_START_MS / +// WORD_END_MS are its word timings inside the phrase. One whisper token was a dash (P1, dropped: +// it is not a word) and P4's "1,7" came as two tokens, joined into one caption. The words are +// Russian, so they live in data files WORD_DIR/wKK.txt and only their lengths are here. +// +// THE CAPTIONS: the v1 reel centred them at y=1600; here that is the floor's labels. The free +// band on this frame is the left column between the bill label and the floor labels, left of the +// tube, so captions are left-aligned at BAND_X, centred on CAPTION_Y, and sized to fit BAND_W. +// +// THE OUTPUT: `main` writes the graph to stderr, for `ffmpeg -filter_complex_script`. Inputs: +// 0 = the frames, 1..6 = the phrases p0.wav..p5.wav, 7 = the music (bgmusic.mp3). +// +// NOT CLAIMED: nothing in CI runs the graph; it is checked by the reel it makes. +// phi^2 + 1/phi^2 = 3 | TRINITY + +module token_floor_mux; + +use automation::owner_voice_reel::REEL_MS; +use automation::owner_voice_reel::VOICE_OFFSET_MS; +use automation::owner_voice_reel::CAPTION_BORDER_PX; +use automation::owner_voice_reel::CAPTION_SHADOW_Y_PX; +use automation::owner_voice_reel::CAPTION_TAIL_MS; +use automation::owner_voice_reel::CAPTION_FONT_MAX; +use automation::owner_voice_reel::CHAR_WIDTH_PERMILLE; +use automation::owner_voice_reel::VOICE_LUFS; +use automation::owner_voice_reel::VOICE_LRA; +use automation::owner_voice_reel::MUSIC_VOLUME_PERMILLE; +use automation::owner_voice_reel::MUSIC_FADE_IN_MS; +use automation::owner_voice_reel::MUSIC_FADE_OUT_START_MS; +use automation::owner_voice_reel::LIMIT_PERMILLE; +use automation::owner_voice_reel::font_size; +use t27f::reel_particles_mux::CAPTION_FONT; +use t27f::reel_particles_mux::MUSIC_FADE_OUT_MS; +use t27f::reel_particles_mux::secs; +use t27f::reel_particles_mux::millis; +use t27f::token_floor::FPS; +use t27f::token_floor::FIXED; +use t27f::token_floor::TUBE_X; +use t27f::token_floor::TUBE_HALF; +use t27f::token_floor::lbl_f; +use t27f::token_floor::lbl_trig; +use t27f::token_floor::land_frame; + +pub const ID : str = "token-floor-mux"; +pub const WORD_DIR : str = "/root/tf/w"; + +// ---- the caption band (design choices, px) ---- +pub const BAND_X : i32 = 60; +pub const BAND_W : i32 = 720; +pub const CAPTION_Y : i32 = 1250; +pub const GLYPH_ROWS : f64 = 7.0; // token_floor's dot font is seven rows tall + +// ---- the phrases ---- +pub const PHRASES : usize = 6; +pub const PHRASE_GAP_MS : i32 = 150; +// The label whose appearance each phrase waits for: title, PHYSICS FLOOR, X10, counter exponent 10, +// 1.7 J PER TOKEN, THE BILL IS ENGINEERING. +pub const EVENT_LABEL : [6]usize = [0, 2, 5, 23, 6, 9]; +pub const PHRASE_MS : [6]i32 = [1760, 1760, 2240, 2160, 2640, 1280]; + +// ---- the words, whisper's timings inside their phrase ---- +pub const WORDS : usize = 24; +pub const WORD_PHRASE : [24]usize = [0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5]; +pub const WORD_START_MS : [24]i32 = [10, 630, 910, 1150, 0, 520, 860, 1100, 0, 680, 1020, 1220, 1660, 1800, 0, 560, 1160, 1420, 0, 760, 1120, 1960, 0, 520]; +pub const WORD_END_MS : [24]i32 = [630, 910, 1150, 1650, 520, 740, 1100, 1660, 680, 1020, 1220, 1660, 1800, 2060, 560, 1000, 1420, 2080, 760, 1120, 1960, 2560, 520, 1080]; +pub const WORD_CHARS : [24]i32 = [4, 5, 6, 6, 5, 3, 6, 9, 6, 7, 3, 8, 2, 6, 2, 5, 2, 8, 8, 5, 3, 6, 9, 9]; + +// ---- the events ---- + +// The frame label l lights: its delay after the clock or after its ball lands. +fn label_on_frame(l: usize) -> i32 { + const delay = @as(i32, @intFromFloat(lbl_f(l, 5))); + const trig = lbl_trig(l); + if (trig < 0) { + return delay; + } + return land_frame(@as(usize, @intCast(trig))) + delay; +} + +fn frame_ms(frame: i32) -> i32 { + return @divTrunc(frame * 1000, FPS); +} + +fn event_ms(p: usize) -> i32 { + return frame_ms(label_on_frame(EVENT_LABEL[p])); +} + +// When phrase p starts: at its event, never before the voice offset, never on top of the last one. +fn phrase_start(p: usize) -> i32 { + var t : i32 = VOICE_OFFSET_MS; + var q : usize = 0; + while (q <= p) { + if (q > 0) { + t = t + PHRASE_MS[q - 1] + PHRASE_GAP_MS; + } + if (event_ms(q) > t) { + t = event_ms(q); + } + q = q + 1; + } + return t; +} + +// ---- the captions ---- + +fn word_from(k: usize) -> i32 { + return phrase_start(WORD_PHRASE[k]) + WORD_START_MS[k]; +} + +// A caption stays for the tail after its word, and goes the moment the next word starts. +fn word_until(k: usize) -> i32 { + var u : i32 = phrase_start(WORD_PHRASE[k]) + WORD_END_MS[k] + CAPTION_TAIL_MS; + if (k + 1 < WORDS && word_from(k + 1) < u) { + u = word_from(k + 1); + } + return u; +} + +// The v1 size rule, narrowed to the band. +fn band_font(chars: i32) -> i32 { + const fit : i32 = @divTrunc(BAND_W * 1000, chars * CHAR_WIDTH_PERMILLE); + const v1 = font_size(chars); + if (fit < v1) { + return fit; + } + return v1; +} + +// True when a box starting at x, from top to bottom, stays clear of the caption band: either right +// of it, or wholly above or below the tallest caption. +fn box_clear(x: f64, top: f64, bottom: f64) -> bool { + const band_top = @as(f64, @floatFromInt(CAPTION_Y - CAPTION_FONT_MAX)); + const band_bottom = @as(f64, @floatFromInt(CAPTION_Y + CAPTION_FONT_MAX)); + if (x >= @as(f64, @floatFromInt(BAND_X + BAND_W))) { + return true; + } + return bottom < band_top || top > band_bottom; +} + +fn label_clear(l: usize) -> bool { + return box_clear(lbl_f(l, 0), lbl_f(l, 1), lbl_f(l, 1) + GLYPH_ROWS * lbl_f(l, 2)); +} + +pub fn main() -> void { + std.debug.print("[0:v]format=yuv420p", .{}); + var k : usize = 0; + while (k < WORDS) { + const a = word_from(k); + const b = word_until(k); + std.debug.print(",drawtext=fontfile={s}:textfile={s}/w{d:0>2}.txt:fontsize={d}:fontcolor=0xFFFF00: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_FONT, WORD_DIR, k + 1, band_font(WORD_CHARS[k]), CAPTION_BORDER_PX, CAPTION_SHADOW_Y_PX, BAND_X, CAPTION_Y, secs(a), millis(a), secs(b), millis(b) }); + k = k + 1; + } + std.debug.print("[v];", .{}); + var p : usize = 0; + while (p < PHRASES) { + const s = phrase_start(p); + std.debug.print("[{d}:a]aresample=48000,pan=stereo|c0=c0|c1=c0,adelay={d}|{d}[p{d}];", .{ p + 1, s, s, p }); + p = p + 1; + } + std.debug.print("[p0][p1][p2][p3][p4][p5]amix=inputs=6:normalize=0:duration=longest,loudnorm=I={d}:TP=-1.5:LRA={d},aresample=48000,apad=whole_dur={d}[vo];", .{ VOICE_LUFS, VOICE_LRA, secs(REEL_MS) }); + std.debug.print("[7: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("[vo][mu]amix=inputs=2:normalize=0:duration=first,alimiter=limit=0.{d:0>3}[a]\n", .{millis(LIMIT_PERMILLE)}); +} + +// ---- tests ---- + +test "the events are the physics: landings and labels" { + assert(event_ms(0) == 100); + assert(event_ms(1) == 2566); + assert(event_ms(2) == 3766); + assert(event_ms(3) == 6333); + assert(event_ms(4) == 9000); + assert(event_ms(5) == 10866); +} + +test "no phrase names an event before it happens" { + var p : usize = 0; + while (p < PHRASES) { + assert(phrase_start(p) >= event_ms(p)); + p = p + 1; + } +} + +test "phrases never overlap and keep the gap" { + var p : usize = 1; + while (p < PHRASES) { + assert(phrase_start(p) >= phrase_start(p - 1) + PHRASE_MS[p - 1] + PHRASE_GAP_MS); + p = p + 1; + } +} + +test "the schedule as rendered" { + assert(phrase_start(0) == 400); + assert(phrase_start(1) == 2566); + assert(phrase_start(2) == 4476); + assert(phrase_start(3) == 6866); + assert(phrase_start(4) == 9176); + assert(phrase_start(5) == 11966); +} + +test "every word lies inside its phrase, in order" { + var k : usize = 0; + while (k < WORDS) { + assert(WORD_START_MS[k] < WORD_END_MS[k]); + assert(WORD_END_MS[k] <= PHRASE_MS[WORD_PHRASE[k]]); + if (k > 0 && WORD_PHRASE[k] == WORD_PHRASE[k - 1]) { + assert(WORD_START_MS[k] >= WORD_END_MS[k - 1]); + } + if (k > 0) { + assert(WORD_PHRASE[k] >= WORD_PHRASE[k - 1]); + } + k = k + 1; + } + assert(WORD_PHRASE[WORDS - 1] == PHRASES - 1); +} + +test "one caption at a time, each shown" { + var k : usize = 0; + while (k < WORDS) { + assert(word_from(k) < word_until(k)); + if (k + 1 < WORDS) { + assert(word_until(k) <= word_from(k + 1)); + } + k = k + 1; + } +} + +test "the voice and the last caption end before the music fades" { + assert(phrase_start(PHRASES - 1) + PHRASE_MS[PHRASES - 1] <= MUSIC_FADE_OUT_START_MS); + assert(word_until(WORDS - 1) == 13396); + assert(word_until(WORDS - 1) <= MUSIC_FADE_OUT_START_MS); + assert(MUSIC_FADE_OUT_START_MS + MUSIC_FADE_OUT_MS == REEL_MS); +} + +test "the widest caption fits the band" { + var k : usize = 0; + while (k < WORDS) { + assert(WORD_CHARS[k] * CHAR_WIDTH_PERMILLE * band_font(WORD_CHARS[k]) <= BAND_W * 1000); + k = k + 1; + } + assert(band_font(9) == 111); + assert(band_font(8) == CAPTION_FONT_MAX); + assert(band_font(2) == CAPTION_FONT_MAX); +} + +test "the band is clear of every label and of the tube" { + var l : usize = 0; + while (l < FIXED) { + assert(label_clear(l)); + l = l + 1; + } + assert(@as(f64, @floatFromInt(BAND_X + BAND_W)) < TUBE_X - TUBE_HALF); +} + +test "the band check bites: boxes inside or across it are refused" { + assert(!box_clear(60.0, 1200.0, 1230.0)); + assert(!box_clear(60.0, 1100.0, 1130.0)); + assert(!box_clear(60.0, 1370.0, 1400.0)); + assert(box_clear(60.0, 1036.0, 1064.0)); + assert(box_clear(822.0, 1200.0, 1230.0)); +}