diff --git a/specs/automation/owner_voice_reel.t27 b/specs/automation/owner_voice_reel.t27 new file mode 100644 index 0000000000..b6e37d3ade --- /dev/null +++ b/specs/automation/owner_voice_reel.t27 @@ -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; +}