Skip to content
Merged
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
304 changes: 304 additions & 0 deletions specs/automation/owner_voice_reel.t27
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
// SPDX-License-Identifier: Apache-2.0
; specs/automation/owner_voice_reel.t27 -- the 15 s vertical reel in the owner's own voice, as data.
; On 2026-10-18 the owner asked for a free, best-quality 15 s reel in his voice, reusing 999's
; logic. It was made for $0 by hand (gHashTag/t27#10279):
; - video: 360 frames of the T27F deck look (t27f.wasm, the demo camera, the data of
; specs/t27f/reel_t27_basics.t27), then the CTA card held for 3 s;
; - voice: the 999 voice-clone service (Qwen3-TTS 1.7B) with the owner's own sample, called from
; inside the vibee-render pod; 10.16 s of speech;
; - captions: word timings from the 999 whisper service, one yellow word at a time;
; - assembly: ffmpeg on the Railway lab (drawtext captions, loudnorm, amix with the site's music).
; The numbers and rules of that recipe lived in lab-only JS glue (caps.mjs, frames.mjs), which the
; only-t27 rule keeps out of the repo. They live here now; the next reel's glue reads them.

; WHAT IS MEASURED: the reel file is 1080x1920, 30 fps, 450 frames, 15.000 s, -14.1 LUFS integrated.
; WORD_START_MS and WORD_END_MS are the 21 caption timings of that reel as whisper gave them (before
; the voice offset). WORD_CHARS is each caption's length in characters (the words are Russian, so the
; text stays out of this ASCII file; the length is all the layout needs). LAB_FONT_SIZE is the
; fontsize the lab's ffmpeg filter (fc.txt) drew each caption at; the test
; font_sizes_match_the_lab_reel holds this spec's formula to all 21 of them.

; ONE DEVIATION, RECORDED: the rule is one word per caption, and caption 7 of that reel was two
; words (a preposition and its noun, 12 characters with the space). CAPTION_WORDS keeps it as drawn,
; so the font check still covers it; the test one_word_per_caption counts it and allows no other.

; Not claimed: the reel is not posted anywhere (posting needs the owner's yes); no host reads this
; file yet -- the lab glue still carries its own copy of these numbers until it is regenerated.
; phi^2 + 1/phi^2 = 3 | TRINITY

module owner_voice_reel;

pub const ID : str = "owner-voice-reel";
pub const MEASURED_ON : str = "2026-10-18";

// ---- The frame and the clock ----------------------------------------------------------------
pub const FRAME_W : i32 = 1080;
pub const FRAME_H : i32 = 1920;
pub const FPS : i32 = 30;
pub const REEL_MS : i32 = 15000;
/// The last 3 s hold the CTA card, silent but for the music: the canon of every reel template
/// (split-reel.t27 CTA_PAUSE_SEC, crm-story-reel.t27 PAUSE_SEC).
pub const CTA_HOLD_MS : i32 = 3000;
/// The voice starts 0.4 s in, so its phrases land on the deck's scene cuts.
pub const VOICE_OFFSET_MS : i32 = 400;

// ---- The captions ---------------------------------------------------------------------------
/// The deck's slide rows occupy y = 1100 to 1400 in the tall preset, so the caption sits lower,
/// at 1600 (83% of the frame), not at the usual 70%.
pub const ROWS_TOP_Y : i32 = 1100;
pub const ROWS_BOTTOM_Y : i32 = 1400;
pub const CAPTION_Y : i32 = 1600;
pub const CAPTION_COLOR : u32 = 0xFFFF00;
pub const CAPTION_BORDER_PX : i32 = 9;
pub const CAPTION_SHADOW_Y_PX : i32 = 6;
pub const WORDS_PER_CAPTION : i32 = 1;
/// font size = min(124, 940 / (len * 0.72)): the widest caption keeps 70 px clear on each side
/// of the 1080 frame, at an average Inter ExtraBold capital 0.72 of the font size wide.
pub const CAPTION_FONT_MAX : i32 = 124;
pub const CAPTION_WIDTH_PX : i32 = 940;
pub const CHAR_WIDTH_PERMILLE : i32 = 720;
/// A word stays up until the next word starts or 0.35 s after it ends, whichever comes first.
pub const CAPTION_TAIL_MS : i32 = 350;

// ---- The sound ------------------------------------------------------------------------------
pub const SAMPLE_RATE : i32 = 48000;
/// The voice is normalised to I = -15 LUFS (true peak -1.5 dBTP, LRA 7) before the mix.
pub const VOICE_LUFS : i32 = -15;
pub const VOICE_TRUE_PEAK_TENTHS_DB : i32 = -15;
pub const VOICE_LRA : i32 = 7;
/// The site's music (app.t27.ai bgmusic.mp3) under the voice at volume 0.10, faded in over
/// 0.6 s and out from 13.6 s to the end.
pub const MUSIC_VOLUME_PERMILLE : i32 = 100;
pub const MUSIC_FADE_IN_MS : i32 = 600;
pub const MUSIC_FADE_OUT_START_MS : i32 = 13600;
/// The mix is limited at 0.89 of full scale.
pub const LIMIT_PERMILLE : i32 = 890;

// ---- What the 2026-10-18 reel measured ------------------------------------------------------
pub const WORD_COUNT : i32 = 21;
pub const VOICE_MS : i32 = 10160;
pub const MIX_LUFS_TENTHS : i32 = -141;
pub const WORD_START_MS : [21]i32 = [0, 400, 1440, 1860, 2740, 3080, 3260, 4240, 4800, 5040, 5520, 5960, 6700, 6940, 7460, 7880, 8040, 8760, 9240, 9400, 9660];
pub const WORD_END_MS : [21]i32 = [400, 1120, 1860, 2440, 3080, 3260, 3980, 4800, 5040, 5520, 5960, 6360, 6940, 7460, 7880, 8040, 8520, 9240, 9400, 9660, 10000];
pub const WORD_CHARS : [21]i32 = [6, 3, 2, 6, 6, 1, 12, 6, 4, 9, 9, 5, 3, 2, 5, 1, 8, 5, 1, 7, 5];
pub const CAPTION_WORDS : [21]i32 = [1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1];
pub const LAB_FONT_SIZE : [21]i32 = [124, 124, 124, 124, 124, 124, 108, 124, 124, 124, 124, 124, 124, 124, 124, 124, 124, 124, 124, 124, 124];
/// The enable windows of fc.txt, in ms of the reel (offset included), for three captions.
pub const LAB_FIRST_SHOWN : [2]i32 = [400, 800];
pub const LAB_SEVENTH_SHOWN : [2]i32 = [3660, 4640];
pub const LAB_LAST_SHOWN : [2]i32 = [10060, 10750];

// ---- The rules ------------------------------------------------------------------------------

/// Where the CTA hold starts: the voice and every caption end at or before it.
pub fn cta_start_ms() -> i32 {
return REEL_MS - CTA_HOLD_MS;
}

pub fn frames_of(ms: i32) -> i32 {
return @divTrunc(ms * FPS, 1000);
}

/// min(124, 940 / (len * 0.72)), floored, in integers: 940 * 1000 / (len * 720).
pub fn font_size(chars: i32) -> i32 {
if (chars <= 0) {
return CAPTION_FONT_MAX;
}
const fit : i32 = @divTrunc(CAPTION_WIDTH_PX * 1000, chars * CHAR_WIDTH_PERMILLE);
if (fit < CAPTION_FONT_MAX) {
return fit;
}
return CAPTION_FONT_MAX;
}

/// When a word's caption appears, in reel time.
pub fn shown_from_ms(start_ms: i32) -> i32 {
return start_ms + VOICE_OFFSET_MS;
}

/// When a word's caption goes, in reel time: min(next start, end + tail), shifted by the offset.
/// The last word has no next word and keeps its whole tail.
pub fn shown_until_ms(end_ms: i32, has_next: bool, next_start_ms: i32) -> i32 {
var until : i32 = end_ms + CAPTION_TAIL_MS;
if (has_next and next_start_ms < until) {
until = next_start_ms;
}
return until + VOICE_OFFSET_MS;
}

/// The voice, offset, ends at or before the CTA hold starts.
pub fn voice_fits(voice_ms: i32) -> bool {
return voice_ms > 0 and VOICE_OFFSET_MS + voice_ms <= cta_start_ms();
}

/// The music's gain at a moment of the reel, in permille of full scale.
pub fn music_gain_permille(t_ms: i32) -> i32 {
if (t_ms <= 0 or t_ms >= REEL_MS) {
return 0;
}
var g : i32 = MUSIC_VOLUME_PERMILLE;
if (t_ms < MUSIC_FADE_IN_MS) {
g = @divTrunc(g * t_ms, MUSIC_FADE_IN_MS);
}
if (t_ms > MUSIC_FADE_OUT_START_MS) {
g = @divTrunc(g * (REEL_MS - t_ms), REEL_MS - MUSIC_FADE_OUT_START_MS);
}
return g;
}

/// Every caption is shown for a positive time, and none is still up when the next appears.
pub fn captions_never_overlap(starts: [21]i32, ends: [21]i32) -> bool {
var i : i32 = 0;
while (i < WORD_COUNT) {
const k : usize = @intCast(i);
const has_next : bool = i + 1 < WORD_COUNT;
var next : i32 = 0;
if (has_next) {
next = starts[k + 1];
}
const from : i32 = shown_from_ms(starts[k]);
const until : i32 = shown_until_ms(ends[k], has_next, next);
if (until <= from) {
return false;
}
if (has_next and until > shown_from_ms(next)) {
return false;
}
i = i + 1;
}
return true;
}

/// The last caption goes at or before the CTA hold starts.
pub fn captions_end_before_cta(ends: [21]i32) -> bool {
const last : i32 = shown_until_ms(ends[@intCast(WORD_COUNT - 1)], false, 0);
return last <= cta_start_ms();
}

// ---- Tests ----------------------------------------------------------------------------------

test the_voice_ends_before_the_cta_hold {
assert voice_fits(VOICE_MS);
// 10.16 s of speech from 0.4 s ends at 10.56 s, 1.44 s before the hold
assert VOICE_OFFSET_MS + VOICE_MS == 10560;
// the longest voice the format takes is 11.6 s; one ms more runs into the hold
assert voice_fits(11600);
assert !voice_fits(11601);
assert !voice_fits(12000);
assert !voice_fits(0);
// the last word whisper heard ends inside the voice file
assert WORD_END_MS[20] <= VOICE_MS;
}

test captions_never_overlap_on_the_lab_reel {
assert captions_never_overlap(WORD_START_MS, WORD_END_MS);
assert captions_end_before_cta(WORD_END_MS);
// the windows the lab drew, from fc.txt
assert shown_from_ms(WORD_START_MS[0]) == LAB_FIRST_SHOWN[0];
assert shown_until_ms(WORD_END_MS[0], true, WORD_START_MS[1]) == LAB_FIRST_SHOWN[1];
assert shown_from_ms(WORD_START_MS[6]) == LAB_SEVENTH_SHOWN[0];
assert shown_until_ms(WORD_END_MS[6], true, WORD_START_MS[7]) == LAB_SEVENTH_SHOWN[1];
assert shown_from_ms(WORD_START_MS[20]) == LAB_LAST_SHOWN[0];
assert shown_until_ms(WORD_END_MS[20], false, 0) == LAB_LAST_SHOWN[1];
}

test a_word_shows_until_the_next_start_or_its_tail {
// a long pause after the word: the tail ends it (2000 + 350 < 3000)
assert shown_until_ms(2000, true, 3000) == 2350 + VOICE_OFFSET_MS;
// on the lab reel every pause was shorter than the tail (the longest, 340 ms), so only the
// last word kept its whole tail; 2440 + 350 runs past the next start, 2740
assert shown_until_ms(2440, true, 2740) == 2740 + VOICE_OFFSET_MS;
// the next word comes sooner than the tail: it ends there
assert shown_until_ms(1120, true, 1440) == 1440 + VOICE_OFFSET_MS;
// a tie is the same either way
assert shown_until_ms(1000, true, 1350) == 1350 + VOICE_OFFSET_MS;
}

test overlapping_timings_are_refused {
// a word that starts before the previous one: its window is not after the last
var starts : [21]i32 = WORD_START_MS;
starts[5] = starts[4] - 100;
assert !captions_never_overlap(starts, WORD_END_MS);
// a word with no time on screen is refused too
var ends : [21]i32 = WORD_END_MS;
starts = WORD_START_MS;
starts[3] = WORD_START_MS[2];
assert !captions_never_overlap(starts, ends);
// a last word that runs past the hold
ends[20] = 12000;
assert !captions_end_before_cta(ends);
}

test font_sizes_match_the_lab_reel {
var i : i32 = 0;
while (i < WORD_COUNT) {
const k : usize = @intCast(i);
assert font_size(WORD_CHARS[k]) == LAB_FONT_SIZE[k];
i = i + 1;
}
// the cap holds to 10 characters (940000 / 7200 = 130); the formula takes over from 11
assert font_size(10) == CAPTION_FONT_MAX;
assert font_size(11) == 118;
assert font_size(12) == 108;
assert font_size(20) == 65;
}

test one_word_per_caption {
assert WORDS_PER_CAPTION == 1;
var extra : i32 = 0;
var i : i32 = 0;
while (i < WORD_COUNT) {
const w : i32 = CAPTION_WORDS[@intCast(i)];
assert w >= 1 and w <= 2;
if (w > WORDS_PER_CAPTION) {
extra = extra + 1;
// the one recorded deviation is caption 7
assert i == 6;
}
i = i + 1;
}
assert extra == 1;
assert CAPTION_COLOR == 0xFFFF00;
}

test the_music_sits_under_the_voice_and_fades_out {
assert music_gain_permille(0) == 0;
assert music_gain_permille(300) == 50;
assert music_gain_permille(5000) == MUSIC_VOLUME_PERMILLE;
assert music_gain_permille(MUSIC_FADE_OUT_START_MS) == MUSIC_VOLUME_PERMILLE;
assert music_gain_permille(14300) == 50;
assert music_gain_permille(REEL_MS) == 0;
assert MUSIC_VOLUME_PERMILLE == 100;
assert VOICE_LUFS == -15;
}

test the_frames_add_up {
// 360 deck frames, then the CTA card held for 90
assert frames_of(cta_start_ms()) == 360;
assert frames_of(CTA_HOLD_MS) == 90;
assert frames_of(REEL_MS) == 450;
}

// ---- Invariants -----------------------------------------------------------------------------

invariant the_caption_clears_the_slide_rows {
// the tallest caption, border and all, starts below the rows and ends inside the frame
assert CAPTION_Y - @divTrunc(CAPTION_FONT_MAX, 2) - CAPTION_BORDER_PX > ROWS_BOTTOM_Y;
assert CAPTION_Y + @divTrunc(CAPTION_FONT_MAX, 2) + CAPTION_BORDER_PX + CAPTION_SHADOW_Y_PX < FRAME_H;
assert ROWS_TOP_Y < ROWS_BOTTOM_Y;
assert CAPTION_WIDTH_PX < FRAME_W;
}

invariant the_hold_fits_inside_the_reel {
assert CTA_HOLD_MS > 0 and CTA_HOLD_MS < REEL_MS;
assert VOICE_OFFSET_MS < cta_start_ms();
assert MUSIC_FADE_OUT_START_MS > cta_start_ms() and MUSIC_FADE_OUT_START_MS < REEL_MS;
}

invariant the_mix_is_quieter_than_full_scale {
assert VOICE_LUFS < 0 and VOICE_TRUE_PEAK_TENTHS_DB < 0;
assert LIMIT_PERMILLE < 1000 and MUSIC_VOLUME_PERMILLE < LIMIT_PERMILLE;
// the mix measured louder than the voice target, as music added on top of it should be
assert MIX_LUFS_TENTHS > VOICE_LUFS * 10;
assert SAMPLE_RATE == 48000;
}
Loading