| … | … | @@ -0,0 +1,135 @@ |
| 1 | """Aligning a subtitle against a few audio WINDOWS of a stream (2026-10-18). |
| 2 | |
| 3 | A live stream has no local file to extract speech from, and taking all of |
| 4 | its audio costs as much as the download (Hope: 767 s). So a handful of |
| 5 | windows are fetched and each is measured on its own: at each candidate frame |
| 6 | rate scale, the lag that best lines the subtitle up with that window's |
| 7 | speech. A scale is believed only when enough good windows AGREE on one |
| 8 | offset; otherwise there is no answer and the track is left as it was. |
| 9 | |
| 10 | ffsubsync's own search over a mostly-empty reference was tried first and |
| 11 | failed on real data (Hope live: 59.19 s at 1.043, truth 0.13 s at 1.000) -- |
| 12 | one window of music the VAD called 97% speech was enough. Per-window lags |
| 13 | with an agreement rule recovered all three known answers: |
| 14 | No Other Choice en 7.06/7.99/7.04 s @1.001 (full reference: 7.01 @1.001) |
| 15 | No Other Choice fr 0.37/1.32/0.36 s @1.000 (0.33 @1.000) |
| 16 | Hope live 0.17/0.11/0.16 s @1.000 (library copy: 0.13 @1.000) |
| 17 | |
| 18 | Run: .venv/bin/python3 -m pytest test_window_align.py -q |
| 19 | """ |
| 20 | |
| 21 | import numpy as np |
| 22 | import pytest |
| 23 | |
| 24 | import window_align as wa |
| 25 | |
| 26 | |
| 27 | def _cues_every(seconds_total, period=7.0, length=2.5, start=3.0): |
| 28 | t, out = start, [] |
| 29 | rng = np.random.default_rng(7) |
| 30 | while t < seconds_total: |
| 31 | out.append((t, t + length + rng.uniform(0, 1.5))) |
| 32 | t += period + rng.uniform(-2.5, 2.5) |
| 33 | return out |
| 34 | |
| 35 | |
| 36 | def _speech_window(cues, pos, seconds, scale=1.0, offset=0.0): |
| 37 | """What VAD would hear in [pos, pos+seconds) if the subtitle needed |
| 38 | `scale * t + offset` to match: speech where the CORRECTED cues are.""" |
| 39 | n = int(seconds * wa.SAMPLE_RATE) |
| 40 | w = np.zeros(n) |
| 41 | for a, b in cues: |
| 42 | i = int(round((a * scale + offset - pos) * wa.SAMPLE_RATE)) |
| 43 | j = int(round((b * scale + offset - pos) * wa.SAMPLE_RATE)) |
| 44 | i, j = max(i, 0), min(j, n) |
| 45 | if i < j: |
| 46 | w[i:j] = 1.0 |
| 47 | return w |
| 48 | |
| 49 | |
| 50 | FILM = 7200.0 |
| 51 | CUES = _cues_every(FILM) |
| 52 | POSITIONS = [720.0, 1800.0, 2880.0, 3960.0, 5040.0, 6120.0] |
| 53 | |
| 54 | |
| 55 | def _windows(scale=1.0, offset=0.0, seconds=150.0): |
| 56 | return [(p, _speech_window(CUES, p, seconds, scale, offset)) for p in POSITIONS] |
| 57 | |
| 58 | |
| 59 | # ------------------------------------------------------------------ parsing |
| 60 | |
| 61 | def test_cues_are_read_from_srt(): |
| 62 | srt = ("1\n00:00:01,500 --> 00:00:03,000\nhi\n\n" |
| 63 | "2\n01:02:03.250 --> 01:02:04,000\nthere\n") |
| 64 | assert wa.srt_cues(srt) == [(1.5, 3.0), (3723.25, 3724.0)] |
| 65 | |
| 66 | |
| 67 | # ---------------------------------------------------------------- aligning |
| 68 | |
| 69 | def test_a_track_in_sync_measures_zero(): |
| 70 | m = wa.align(CUES, _windows()) |
| 71 | assert m["scale"] == 1.0 and abs(m["offset"]) <= 0.02 |
| 72 | |
| 73 | |
| 74 | def test_a_constant_offset_is_found(): |
| 75 | m = wa.align(CUES, _windows(offset=7.0)) |
| 76 | assert m["scale"] == 1.0 and m["offset"] == pytest.approx(7.0, abs=0.02) |
| 77 | |
| 78 | |
| 79 | def test_frame_rate_drift_is_found_with_its_offset(): |
| 80 | """No Other Choice's English: 24 vs 23.976 fps plus a shift.""" |
| 81 | s = 24 / 23.976 |
| 82 | m = wa.align(CUES, _windows(scale=s, offset=-2.0)) |
| 83 | assert m["scale"] == pytest.approx(s) and m["offset"] == pytest.approx(-2.0, abs=0.05) |
| 84 | |
| 85 | |
| 86 | def test_windows_the_vad_got_wrong_are_not_counted(): |
| 87 | """Hope's window at 85% was music the VAD called 97% speech.""" |
| 88 | wins = _windows(offset=3.0) |
| 89 | wins[2] = (wins[2][0], np.ones_like(wins[2][1])) |
| 90 | wins[4] = (wins[4][0], np.zeros_like(wins[4][1])) |
| 91 | m = wa.align(CUES, wins) |
| 92 | assert m["offset"] == pytest.approx(3.0, abs=0.02) |
| 93 | assert m["windows"] == 4 |
| 94 | |
| 95 | |
| 96 | def test_a_different_cut_gets_no_answer(): |
| 97 | """Each window wants a different offset: no offset+scale repairs that, |
| 98 | and guessing one would be worse than leaving the track alone.""" |
| 99 | wins = [(p, _speech_window(CUES, p, 150.0, 1.0, off)) |
| 100 | for p, off in zip(POSITIONS, (0.0, 11.0, -23.0, 37.0, -5.0, 29.0))] |
| 101 | assert wa.align(CUES, wins) is None |
| 102 | |
| 103 | |
| 104 | def test_one_good_window_is_not_enough(): |
| 105 | wins = _windows(offset=3.0)[:1] + [(p, np.ones(15000)) for p in POSITIONS[1:]] |
| 106 | assert wa.align(CUES, wins) is None |
| 107 | |
| 108 | |
| 109 | def test_no_windows_is_no_answer(): |
| 110 | assert wa.align(CUES, []) is None |
| 111 | assert wa.align([], _windows()) is None |
| 112 | |
| 113 | |
| 114 | # ---------------------------------------------------------------- applying |
| 115 | |
| 116 | def test_the_correction_is_applied_to_every_timestamp(): |
| 117 | srt = ("1\n00:00:10,000 --> 00:00:12,500\nhi\n\n" |
| 118 | "2\n01:00:00,000 --> 01:00:02,000\nthere\n") |
| 119 | out = wa.apply(srt, scale=1.001, offset=-2.0) |
| 120 | assert "00:00:08,010 --> 00:00:10,512" in out |
| 121 | assert "01:00:01,600 --> 01:00:03,602" in out |
| 122 | assert "hi" in out and "there" in out |
| 123 | |
| 124 | |
| 125 | def test_a_cue_pushed_before_zero_is_clamped(): |
| 126 | out = wa.apply("1\n00:00:01,000 --> 00:00:02,000\nx\n", scale=1.0, offset=-1.5) |
| 127 | assert "00:00:00,000 --> 00:00:00,500" in out |
| 128 | |
| 129 | |
| 130 | def test_a_corrected_track_measures_in_sync(): |
| 131 | s = 24 / 23.976 |
| 132 | m = wa.align(CUES, _windows(scale=s, offset=-2.0)) |
| 133 | fixed = [(a * m["scale"] + m["offset"], b * m["scale"] + m["offset"]) for a, b in CUES] |
| 134 | again = wa.align(fixed, _windows(scale=s, offset=-2.0)) |
| 135 | assert again["scale"] == 1.0 and abs(again["offset"]) <= 0.05 |