Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions specs/t27f/reel_particles_mux.t27
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
}
Expand Down
12 changes: 6 additions & 6 deletions specs/t27f/token_floor.t27
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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];
}
Expand All @@ -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;
}

Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
282 changes: 282 additions & 0 deletions specs/t27f/token_floor_mux.t27
Original file line number Diff line number Diff line change
@@ -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));
}
Loading