<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="ru"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://hints.baimuratov.app/feed.xml" rel="self" type="application/atom+xml" /><link href="https://hints.baimuratov.app/" rel="alternate" type="text/html" hreflang="ru" /><updated>2026-05-27T00:01:10+03:00</updated><id>https://hints.baimuratov.app/feed.xml</id><title type="html">Baimuratov Hints</title><subtitle>Технические хаки, разборы инструментов и подводные камни — кратко и по делу.</subtitle><entry xml:lang="en"><title type="html">Fixing subtitle sync for Studio 60: the full story</title><link href="https://hints.baimuratov.app/en/2026/studio-60-subtitle-sync/" rel="alternate" type="text/html" title="Fixing subtitle sync for Studio 60: the full story" /><published>2026-05-26T21:30:00+03:00</published><updated>2026-05-26T21:30:00+03:00</updated><id>https://hints.baimuratov.app/en/2026/studio-60-subtitle-sync-en</id><content type="html" xml:base="https://hints.baimuratov.app/en/2026/studio-60-subtitle-sync/"><![CDATA[<p><em><a href="/2026/studio-60-subtitle-sync/">Прочитать по-русски</a></em></p>

<p>A walk-through of one specific task and the tools we leaned on: <strong>ffsubsync</strong>, <strong>alass</strong>, the OpenSubtitles API, and the algorithms living inside these tools (FFT cross-correlation vs. dynamic programming).</p>

<!--more-->

<h2 id="the-starting-point">The starting point</h2>

<p>22 episodes of <strong>Studio 60 on the Sunset Strip</strong> (one season, 2006-2007, NBC) in 720p HDTV. Next to each video sat a Russian subtitle file in <strong>windows-1251</strong> encoding, drifting out of sync with the audio. The goals:</p>

<ol>
  <li>Add English subtitles.</li>
  <li>Fix timing for both English and Russian.</li>
  <li>Convert everything to UTF-8.</li>
  <li>Make Plex correctly recognize each language.</li>
</ol>

<hr />

<h2 id="tools-we-used">Tools we used</h2>

<h3 id="1-opensubtitlescom-rest-api-v1">1. OpenSubtitles.com REST API (v1)</h3>

<p>The source for English subtitles. Important quirk: search by <strong>parent_imdb_id</strong> (<code class="language-plaintext highlighter-rouge">485842</code> for Studio 60), not by text title — text search returned “Oats Studios” instead of our show.</p>

<ul>
  <li>Anonymous API key gives 100 downloads per day.</li>
  <li>We preferred official Warner Bros releases (<code class="language-plaintext highlighter-rouge">DVD.NonHI.en.WB</code>) over fan rips (<code class="language-plaintext highlighter-rouge">HDTV.XviD-MiNT</code>, etc.) — they tend to be cleaner and have fewer typos.</li>
  <li>Auth is just the <code class="language-plaintext highlighter-rouge">Api-Key: &lt;KEY&gt;</code> header, no OAuth dance.</li>
</ul>

<h3 id="2-ffsubsync-smackeffsubsync-v0431-november-2025">2. ffsubsync (smacke/ffsubsync, v0.4.31, November 2025)</h3>

<p>Actively maintained, the industry default. It extracts audio from the video, runs VAD (voice activity detection), then uses FFT to find <strong>one global offset</strong> and <strong>one framerate scaling factor</strong>. Perfect for the “PAL 25fps vs. NTSC 23.976fps” case.</p>

<p>Supports several VADs: <code class="language-plaintext highlighter-rouge">webrtcvad</code>, <code class="language-plaintext highlighter-rouge">auditok</code>, <code class="language-plaintext highlighter-rouge">silero</code> (the last requires PyTorch).</p>

<p><strong>Weakness:</strong> only one linear transform across the whole file. If your subtitle’s commercial breaks are cut in different spots than the video, ffsubsync averages and leaves drift. The author himself writes in the README: <em>“Handling breaks and splits in the middle of video… is left to future work”</em> — <a href="https://github.com/smacke/ffsubsync/issues/31">open issue #31</a> since 2019.</p>

<h3 id="3-alass-kaegialass-v200-2019-unmaintained">3. alass (kaegi/alass, v2.0.0, 2019, unmaintained)</h3>

<p>We switched to it once ffsubsync gave “better, but still off.” Algorithmically, alass <strong>detects split-points</strong> and applies different offsets to different segments of the file. On S01E03 it found <strong>4 segments</strong> with shifts of −14.26s → −17.40s → −20.79s → −26.37s — the classic drift pattern caused by missing commercial breaks. ffsubsync can’t do this.</p>

<p><strong>Weakness:</strong> project is abandoned, but the algorithm still works in 2026 — the v2.0.0 Linux binary runs without issues.</p>

<p><strong>Picking rule:</strong> ffsubsync is the default. Reach for alass when ffsubsync fails and you see uneven drift (especially for broadcast content with commercial breaks).</p>

<h3 id="4-ffmpeg--ffprobe">4. ffmpeg / ffprobe</h3>

<p>Both sync tools use them under the hood for audio extraction. ffprobe was also useful standalone — to confirm the video framerate (<code class="language-plaintext highlighter-rouge">r_frame_rate=24000/1001</code> = NTSC 23.976) and check the .mkv had no embedded subtitle streams.</p>

<h3 id="5-iconv">5. iconv</h3>

<p>For windows-1251 → UTF-8 conversion. Though it turned out alass already writes its output in UTF-8 regardless of the input encoding, so the step was a no-op.</p>

<h3 id="6-uv--uvx">6. uv / uvx</h3>

<p>Python package manager by Astral. Installed ffsubsync, then added torch+torchaudio (for silero VAD) via <code class="language-plaintext highlighter-rouge">uv tool install --with torch --with torchaudio ffsubsync</code>.</p>

<p>Side note: the machine had a broken pyright from an old pipx install — it pointed at a removed <code class="language-plaintext highlighter-rouge">~/miniforge3/bin/python3.10</code>. Fixed via <code class="language-plaintext highlighter-rouge">uv tool install pyright</code>.</p>

<hr />

<h2 id="the-trap-we-didnt-see-coming">The trap we didn’t see coming</h2>

<p>Warner Bros DVD subtitles are structured this way: <strong>every two-line dialogue is split into two SRT entries with identical timestamps</strong>:</p>

<pre><code class="language-srt">3
00:00:03,804 --&gt; 00:00:06,291
You're one of the highest-ranking

4
00:00:03,804 --&gt; 00:00:06,291
female executives...
</code></pre>

<p>Many players (including Plex in some clients) render both blocks simultaneously — you get visual stacking, “the end of the phrase shows on top of the beginning.” This is a <strong>source problem</strong>, not a sync problem.</p>

<p>Fix is a Python script that merges adjacent blocks with identical timestamps into one multi-line block:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">merge_pairs</span><span class="p">(</span><span class="n">entries</span><span class="p">):</span>
    <span class="n">merged</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">start</span><span class="p">,</span> <span class="n">end</span><span class="p">,</span> <span class="n">body</span> <span class="ow">in</span> <span class="n">entries</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">merged</span> <span class="ow">and</span> <span class="n">merged</span><span class="p">[</span><span class="o">-</span><span class="mi">1</span><span class="p">][</span><span class="mi">0</span><span class="p">]</span> <span class="o">==</span> <span class="n">start</span> <span class="ow">and</span> <span class="n">merged</span><span class="p">[</span><span class="o">-</span><span class="mi">1</span><span class="p">][</span><span class="mi">1</span><span class="p">]</span> <span class="o">==</span> <span class="n">end</span><span class="p">:</span>
            <span class="n">ps</span><span class="p">,</span> <span class="n">pe</span><span class="p">,</span> <span class="n">pb</span> <span class="o">=</span> <span class="n">merged</span><span class="p">[</span><span class="o">-</span><span class="mi">1</span><span class="p">]</span>
            <span class="n">merged</span><span class="p">[</span><span class="o">-</span><span class="mi">1</span><span class="p">]</span> <span class="o">=</span> <span class="p">(</span><span class="n">ps</span><span class="p">,</span> <span class="n">pe</span><span class="p">,</span> <span class="n">pb</span> <span class="o">+</span> <span class="s">"</span><span class="se">\n</span><span class="s">"</span> <span class="o">+</span> <span class="n">body</span><span class="p">)</span>
        <span class="k">else</span><span class="p">:</span>
            <span class="n">merged</span><span class="p">.</span><span class="n">append</span><span class="p">((</span><span class="n">start</span><span class="p">,</span> <span class="n">end</span><span class="p">,</span> <span class="n">body</span><span class="p">))</span>
    <span class="k">return</span> <span class="n">merged</span>
</code></pre></div></div>

<p>Across 22 files this removed 500-650 such duplicates per episode.</p>

<hr />

<h2 id="the-final-pipeline">The final pipeline</h2>

<ol>
  <li>Search for subtitles via the OpenSubtitles API by <code class="language-plaintext highlighter-rouge">parent_imdb_id</code> + season + episode + language.</li>
  <li>Download (POST <code class="language-plaintext highlighter-rouge">/download</code> with <code class="language-plaintext highlighter-rouge">file_id</code>).</li>
  <li>Sync: ffsubsync first; if the result is “close, but not quite” — alass with <code class="language-plaintext highlighter-rouge">--speed-optimization 0 --interval 1</code> (max accuracy).</li>
  <li>Post-process: merge entries with duplicate timestamps (for DVD sources).</li>
  <li>Naming convention for Plex: <code class="language-plaintext highlighter-rouge">&lt;video_basename&gt;.en.srt</code>, <code class="language-plaintext highlighter-rouge">&lt;video_basename&gt;.ru.srt</code> (ISO 639-1 two-letter code — Plex auto-detects).</li>
  <li>Originals into <code class="language-plaintext highlighter-rouge">_backup/</code> (Plex does not scan subdirectories for sidecar subtitles).</li>
</ol>

<hr />

<h2 id="the-algorithms-inside">The algorithms inside</h2>

<h3 id="ffsubsync--fft-cross-correlation">ffsubsync — FFT cross-correlation</h3>

<p><strong>Step 1: discretize into binary sequences.</strong>
Splits the video’s audio track into 10 ms windows. For each window, VAD outputs <strong>0</strong> (silence/music) or <strong>1</strong> (speech). You get a binary string of length N (around 252 000 bits for a 42-minute episode).</p>

<p>Same with the subtitle: on the 10 ms grid, <strong>1</strong> where there should be text per the timestamps, <strong>0</strong> otherwise.</p>

<p><strong>Step 2: cross-correlation via FFT.</strong>
To find the optimal shift between two sequences <code class="language-plaintext highlighter-rouge">a</code> (video) and <code class="language-plaintext highlighter-rouge">b</code> (subtitle), you need:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>corr(τ) = Σ a[i] · b[i + τ]   for all τ from -max_offset to +max_offset
</code></pre></div></div>

<p>Brute force is O(N²) — billions of ops. Via FFT: <code class="language-plaintext highlighter-rouge">corr = IFFT(FFT(a) · conj(FFT(b)))</code> — O(N log N). For a typical episode ~50 million ops, seconds of CPU time.</p>

<p><strong>The peak of the correlation function is the optimal shift.</strong> That’s the “offset seconds: -8.250” in ffsubsync output.</p>

<p><strong>Step 3: framerate scaling (optional).</strong>
Tries a handful of reasonable ratios (1.0, 23.976/25, 25/23.976, 24/23.976, etc.), recomputes the subtitle for each, picks the best cross-correlation. With <code class="language-plaintext highlighter-rouge">--gss</code> it uses <strong>golden-section search</strong> — a numerical method for finding the extremum of a unimodal function, converging to the optimum in log₁.₆₁₈(N) iterations without exhaustive search.</p>

<p><strong>VAD options:</strong></p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">webrtcvad</code> (default) — Google’s WebRTC, uses a <strong>GMM</strong> (Gaussian Mixture Model) trained on telephony speech. Fast, decent.</li>
  <li><code class="language-plaintext highlighter-rouge">auditok</code> — energy-based detector: RMS energy above threshold = speech. Sensitive to background music (often flags it as speech).</li>
  <li><code class="language-plaintext highlighter-rouge">silero</code> — a <strong>neural net</strong> (LSTM over MFCC features, ~1 MB of weights from the Silero company). Significantly more accurate, but requires PyTorch and has ~3 sec cold start.</li>
</ul>

<p><strong>What ffsubsync structurally cannot do:</strong> find the optimum is to find <em>one</em> τ maximizing correlation. By construction it applies that one τ to the entire file. Different τ for different sections requires a different algorithm.</p>

<h3 id="alass--dynamic-programming-with-a-split-penalty">alass — dynamic programming with a split penalty</h3>

<p><strong>Step 1: “rated intervals.”</strong>
Video → binary VAD sequence (like ffsubsync, but with <strong>1 ms</strong> intervals by default, not 10 ms). Subtitle → sequence of “has text / no text” intervals.</p>

<p><strong>Step 2: optimization problem.</strong>
Let the subtitle have N lines. For each line <code class="language-plaintext highlighter-rouge">i</code> we choose a shift <code class="language-plaintext highlighter-rouge">δᵢ</code>. The optimal solution maximizes:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>J = Σᵢ score(line_i, δᵢ) − P · (number of split-points)
</code></pre></div></div>

<p>where <code class="language-plaintext highlighter-rouge">score</code> measures how well the shifted line falls on speech in the video (overlap with the VAD mask), and <strong>P</strong> is <code class="language-plaintext highlighter-rouge">--split-penalty</code> (default 7). A “split-point” is a place where <code class="language-plaintext highlighter-rouge">δᵢ ≠ δᵢ₊₁</code>.</p>

<p><strong>Step 3: dynamic programming.</strong>
Solved <strong>bottom-up</strong> via a table <code class="language-plaintext highlighter-rouge">DP[i][δ]</code> = “best total score for the first i lines if the last one is shifted by δ.” The recurrence:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>DP[i][δ] = score(i, δ) + max over δ' of (DP[i-1][δ'] − P · [δ ≠ δ'])
</code></pre></div></div>

<p>Classic DP with memory O(N · D), where D = number of candidate shifts (D = <code class="language-plaintext highlighter-rouge">max_offset / interval</code>). At <code class="language-plaintext highlighter-rouge">--interval 1ms</code> and <code class="language-plaintext highlighter-rouge">max_offset</code> of a couple minutes, D ≈ 120 000. N for a 42-minute episode is ~1300 lines. That’s ~150M cells. With <code class="language-plaintext highlighter-rouge">--speed-optimization 1</code> (default) the space is compressed; with <code class="language-plaintext highlighter-rouge">--speed-optimization 0</code> (what we used) — exact search, slower but no accuracy loss.</p>

<p><strong>Step 4: recovering segments.</strong>
After filling the table — backtrace via <code class="language-plaintext highlighter-rouge">argmax</code> gives the points where the optimal <code class="language-plaintext highlighter-rouge">δ</code> changes. Those are the “shifted block of 435 subtitles by -14.263s; shifted block of 249 subtitles by -17.400s…” lines — each block is a segment between split-points.</p>

<p><strong>Why <code class="language-plaintext highlighter-rouge">--split-penalty</code>:</strong></p>

<ul>
  <li>At P → ∞ the algorithm degenerates to a single segment (behaves like ffsubsync — one global shift).</li>
  <li>At P → 0 it allows a different shift for each line — overfitting, lines “snap” to the nearest speech with no logic.</li>
  <li>Default 7 is a practical compromise. On S01E03 we got 4 segments (typical for an episode with 3-4 commercial breaks); on S01E07 — 1 segment (commercials were cut in the same places in both the subtitle source and the video).</li>
</ul>

<p><strong>More:</strong></p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">--disable-fps-guessing</code> turns off the built-in framerate ratio search. By default alass tries 24/23.976, 23.976/25 and a few others.</li>
  <li>alass uses its own VAD — an energy-based detector built on <strong>STFT</strong> (short-time Fourier transform), no neural nets.</li>
</ul>

<h3 id="complexity-comparison">Complexity comparison</h3>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>ffsubsync</th>
      <th>alass</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Task</strong></td>
      <td>argmax over 1D</td>
      <td>argmax over a sequence, with regularization</td>
    </tr>
    <tr>
      <td><strong>Method</strong></td>
      <td>FFT cross-correlation</td>
      <td>Dynamic programming</td>
    </tr>
    <tr>
      <td><strong>Output parameters</strong></td>
      <td>2 (offset + scale)</td>
      <td>2N (one shift per line)</td>
    </tr>
    <tr>
      <td><strong>Complexity</strong></td>
      <td>O(N log N)</td>
      <td>O(N · D)</td>
    </tr>
    <tr>
      <td><strong>Time per 42-min ep</strong></td>
      <td>10-30 sec</td>
      <td>10-60 sec</td>
    </tr>
  </tbody>
</table>

<h3 id="why-silero-vad-is-worth-mentioning">Why silero VAD is worth mentioning</h3>

<p>Sync quality is bottlenecked on VAD quality. If VAD picks up background music as “speech” — e.g., the Studio 60 musical opener “I Am the Very Model of a Modern Network TV Show” — but the subtitle is silent there, cross-correlation gets a false peak. silero is trained to distinguish speech from music and background noise, which matters for drama with a soundtrack. We didn’t need it here (alass handled it), but for cases like “syncing subs to a concert recording” silero is critical.</p>

<p>If your friends want the academic side — the <strong>kaegi/alass</strong> repo explains the DP recurrence in more depth, and ffsubsync points to the classic Lewis (1995) <em>Fast Normalized Cross-Correlation</em> paper for its FFT part.</p>

<hr />

<h2 id="claude-code-prompt-english">Claude Code prompt (English)</h2>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Sync subtitles for the video files in this directory using the best-quality
pipeline:

1. Identify the show via filename. Resolve its IMDB parent_id and find video
   files (mkv/mp4/avi) that need subtitles.

2. For each episode that lacks a subtitle in the requested language, fetch one
   from the OpenSubtitles.com REST API (https://api.opensubtitles.com/api/v1).
   Auth: header "Api-Key: &lt;KEY&gt;". Search /subtitles with parent_imdb_id,
   season_number, episode_number, languages=&lt;lang&gt;. Prefer official releases
   (e.g., "DVD.NonHI.&lt;lang&gt;.WB") over fan rips; fall back to the non-HI sub
   with highest download_count. Download via POST /download with file_id
   (anonymous, 100/day quota). Save as &lt;video_base&gt;.&lt;lang&gt;.unsynced.srt.

3. Detect encoding with chardet or `file`; if not UTF-8, transcode to UTF-8.

4. Sync with ffsubsync first (single-offset model, actively maintained):
       ffsubsync &lt;video.mkv&gt; -i &lt;sub.unsynced.srt&gt; -o &lt;out.srt&gt; --gss
   If you suspect commercial-break drift (typical for HDTV/NBC airings of older
   shows) OR the user reports the result is "better but still off", re-run with
   alass (split-aware, downloadable binary from
   github.com/kaegi/alass/releases/latest):
       alass &lt;video.mkv&gt; &lt;sub.unsynced.srt&gt; &lt;out.srt&gt; \
             --speed-optimization 0 --interval 1
   alass reports "shifted block of N subtitles by Xs" per segment. Multiple
   segments mean it found split-points ffsubsync would have averaged out.

5. Post-process the output:
   - If the source SRT splits multi-line dialogue into separate entries with
     IDENTICAL timestamps (common for Warner Bros DVD subs), merge consecutive
     entries that share start/end timestamps into one multi-line block.
     Otherwise Plex/VLC may stack them visually.
   - Re-number entries 1..N.
   - Ensure UTF-8 output and \n line endings.

6. Name files for Plex auto-language detection: &lt;video_base&gt;.&lt;iso639-1&gt;.srt
   (e.g., .en.srt, .ru.srt). Stash original sources in a _backup/ subdirectory
   - Plex does not scan subdirectories for sidecar subtitles, so backups won't
   show up as phantom tracks.

7. After processing, each video should have exactly one synced .srt per
   language alongside it - no .unsynced.srt, .tmp, or duplicate-suffix files
   left behind, since Plex would surface them as additional tracks.

8. Verify by reading (not just parsing) a sample of the output: check first
   entry starts at real dialogue time, scan for adjacent entries with
   identical timestamps, confirm encoding renders correctly.

Tools to install if missing: uv tool install ffsubsync (add --with torch
--with torchaudio for silero VAD); download alass-linux64 from its GitHub
releases page and chmod +x. Use ffprobe to confirm video framerate and audio
language streams before syncing.
</code></pre></div></div>

<hr />

<h2 id="the-takeaway">The takeaway</h2>

<p>One tool solves 90% of cases; for the remaining 10% you need the right second tool. And always read the output yourself — even when both synchronizers report “success,” it can turn out that the source was broken.</p>]]></content><author><name></name></author><category term="media" /><category term="subtitles" /><category term="ffsubsync" /><category term="alass" /><category term="opensubtitles" /><category term="plex" /><category term="jekyll" /><category term="vad" /><summary type="html"><![CDATA[Прочитать по-русски]]></summary></entry><entry xml:lang="ru"><title type="html">Синхронизация субтитров: как мы починили Studio 60</title><link href="https://hints.baimuratov.app/2026/studio-60-subtitle-sync/" rel="alternate" type="text/html" title="Синхронизация субтитров: как мы починили Studio 60" /><published>2026-05-26T21:30:00+03:00</published><updated>2026-05-26T21:30:00+03:00</updated><id>https://hints.baimuratov.app/2026/studio-60-subtitle-sync</id><content type="html" xml:base="https://hints.baimuratov.app/2026/studio-60-subtitle-sync/"><![CDATA[<p><em><a href="/en/2026/studio-60-subtitle-sync/">Read in English</a></em></p>

<p>История одной задачи и разбор инструментов: <strong>ffsubsync</strong>, <strong>alass</strong>, OpenSubtitles API, а также внутренних алгоритмов (FFT кросс-корреляция vs. динамическое программирование).</p>

<!--more-->

<h2 id="исходная-задача">Исходная задача</h2>

<p>22 эпизода сериала <strong>Studio 60 on the Sunset Strip</strong> (один сезон, 2006-2007, NBC) в формате 720p HDTV. Рядом лежали русские субтитры в кодировке <strong>windows-1251</strong>, рассинхронизированные с видео. Нужно было:</p>

<ol>
  <li>Добавить английские субтитры.</li>
  <li>Починить тайминг на английских и русских.</li>
  <li>Привести всё к UTF-8.</li>
  <li>Чтобы Plex корректно различал языки.</li>
</ol>

<hr />

<h2 id="использованные-инструменты">Использованные инструменты</h2>

<h3 id="1-opensubtitlescom-rest-api-v1">1. OpenSubtitles.com REST API (v1)</h3>

<p>Источник английских субтитров. Важный момент: искать по <strong>parent_imdb_id</strong> (485842 для Studio 60), а не по текстовому названию — текстовый поиск выдал «Oats Studios» вместо нашего сериала.</p>

<ul>
  <li>Анонимный API-ключ даёт 100 загрузок в сутки.</li>
  <li>Предпочитали официальные релизы Warner Bros (<code class="language-plaintext highlighter-rouge">DVD.NonHI.en.WB</code>), а не фанатские (<code class="language-plaintext highlighter-rouge">HDTV.XviD-MiNT</code> и т.п.) — они чище по тексту и реже содержат опечатки.</li>
  <li>Авторизация через заголовок <code class="language-plaintext highlighter-rouge">Api-Key: &lt;KEY&gt;</code>, никакого OAuth-токена не нужно.</li>
</ul>

<h3 id="2-ffsubsync-smackeffsubsync-v0431-ноябрь-2025">2. ffsubsync (smacke/ffsubsync, v0.4.31, ноябрь 2025)</h3>

<p>Активно поддерживается, является «дефолтом» в индустрии. Принцип: извлекает аудио из видео, делает VAD (детекцию речи), потом через FFT находит <strong>один глобальный сдвиг</strong> и <strong>один коэффициент framerate</strong>. Для случая «PAL 25fps vs NTSC 23.976fps» работает идеально.</p>

<p>Поддерживает разные VAD: <code class="language-plaintext highlighter-rouge">webrtcvad</code>, <code class="language-plaintext highlighter-rouge">auditok</code>, <code class="language-plaintext highlighter-rouge">silero</code> (последний требует PyTorch).</p>

<p><strong>Слабость</strong>: только одно линейное преобразование на весь файл. Если в субтитрах рекламные паузы вырезаны в одних местах, а в видео в других — ffsubsync усреднит и оставит дрейф. Автор сам пишет в README: <em>«Handling breaks and splits in the middle of video… is left to future work»</em> — <a href="https://github.com/smacke/ffsubsync/issues/31">open issue #31</a> с 2019 года.</p>

<h3 id="3-alass-kaegialass-v200-2019-не-обновляется">3. alass (kaegi/alass, v2.0.0, 2019, не обновляется)</h3>

<p>К нему перешли, когда ffsubsync дал «лучше, но всё ещё рассинхрон». Алгоритмически alass <strong>детектирует точки разрыва</strong> и применяет разные смещения к разным сегментам файла. На S01E03 он нашёл <strong>4 сегмента</strong> со сдвигами −14.26s → −17.40s → −20.79s → −26.37s — типичный дрейф от вырезанных реклам. ffsubsync такое не умеет.</p>

<p><strong>Слабость</strong>: проект заброшен, но алгоритм работает и в 2026 — бинарь под Linux v2.0.0 запускается без проблем.</p>

<p><strong>Правило выбора</strong>: ffsubsync — дефолт. alass — когда ffsubsync не справился и виден неравномерный дрейф (особенно для эфирного контента с рекламными паузами).</p>

<h3 id="4-ffmpeg--ffprobe">4. ffmpeg / ffprobe</h3>

<p>Используются обоими инструментами под капотом для извлечения аудио. ffprobe пригодился отдельно — проверить framerate видео (<code class="language-plaintext highlighter-rouge">r_frame_rate=24000/1001</code> = NTSC 23.976) и убедиться, что в .mkv нет встроенных субтитров.</p>

<h3 id="5-iconv">5. iconv</h3>

<p>Для конвертации windows-1251 → UTF-8. Хотя в итоге выяснилось, что alass пишет вывод в UTF-8 независимо от кодировки входа, так что шаг оказался лишним.</p>

<h3 id="6-uv--uvx">6. uv / uvx</h3>

<p>Менеджер Python-пакетов от Astral. Установили ffsubsync, потом torch+torchaudio (для silero VAD) через <code class="language-plaintext highlighter-rouge">uv tool install --with torch --with torchaudio ffsubsync</code>.</p>

<p>Замечание: на машине был сломанный pyright из старого pipx — указывал на удалённый <code class="language-plaintext highlighter-rouge">~/miniforge3/bin/python3.10</code>. Починили через <code class="language-plaintext highlighter-rouge">uv tool install pyright</code>.</p>

<hr />

<h2 id="подводный-камень-которого-не-было-видно-сразу">Подводный камень, которого не было видно сразу</h2>

<p>Официальные субтитры Warner Bros с DVD устроены так: <strong>каждая двухстрочная реплика разбита на два SRT-блока с одинаковыми тайм-кодами</strong>:</p>

<pre><code class="language-srt">3
00:00:03,804 --&gt; 00:00:06,291
You're one of the highest-ranking

4
00:00:03,804 --&gt; 00:00:06,291
female executives...
</code></pre>

<p>Многие плееры (включая Plex в некоторых клиентах) рендерят оба блока одновременно — получается визуальное наслоение «конец фразы поверх начала». Это <strong>проблема исходника</strong>, не синхронизатора.</p>

<p>Решение — Python-скрипт, который сливает соседние блоки с идентичными тайм-кодами в один многострочный блок:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">merge_pairs</span><span class="p">(</span><span class="n">entries</span><span class="p">):</span>
    <span class="n">merged</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">start</span><span class="p">,</span> <span class="n">end</span><span class="p">,</span> <span class="n">body</span> <span class="ow">in</span> <span class="n">entries</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">merged</span> <span class="ow">and</span> <span class="n">merged</span><span class="p">[</span><span class="o">-</span><span class="mi">1</span><span class="p">][</span><span class="mi">0</span><span class="p">]</span> <span class="o">==</span> <span class="n">start</span> <span class="ow">and</span> <span class="n">merged</span><span class="p">[</span><span class="o">-</span><span class="mi">1</span><span class="p">][</span><span class="mi">1</span><span class="p">]</span> <span class="o">==</span> <span class="n">end</span><span class="p">:</span>
            <span class="n">ps</span><span class="p">,</span> <span class="n">pe</span><span class="p">,</span> <span class="n">pb</span> <span class="o">=</span> <span class="n">merged</span><span class="p">[</span><span class="o">-</span><span class="mi">1</span><span class="p">]</span>
            <span class="n">merged</span><span class="p">[</span><span class="o">-</span><span class="mi">1</span><span class="p">]</span> <span class="o">=</span> <span class="p">(</span><span class="n">ps</span><span class="p">,</span> <span class="n">pe</span><span class="p">,</span> <span class="n">pb</span> <span class="o">+</span> <span class="s">"</span><span class="se">\n</span><span class="s">"</span> <span class="o">+</span> <span class="n">body</span><span class="p">)</span>
        <span class="k">else</span><span class="p">:</span>
            <span class="n">merged</span><span class="p">.</span><span class="n">append</span><span class="p">((</span><span class="n">start</span><span class="p">,</span> <span class="n">end</span><span class="p">,</span> <span class="n">body</span><span class="p">))</span>
    <span class="k">return</span> <span class="n">merged</span>
</code></pre></div></div>

<p>На 22 файлах это убрало 500-650 «дубликатов» на эпизод.</p>

<hr />

<h2 id="итоговый-пайплайн">Итоговый пайплайн</h2>

<ol>
  <li>Поиск субтитров через OpenSubtitles API по <code class="language-plaintext highlighter-rouge">parent_imdb_id</code> + сезон + серия + язык.</li>
  <li>Скачивание (POST <code class="language-plaintext highlighter-rouge">/download</code> с <code class="language-plaintext highlighter-rouge">file_id</code>).</li>
  <li>Синхронизация: сначала ffsubsync; если результат «почти», но не точно — alass с <code class="language-plaintext highlighter-rouge">--speed-optimization 0 --interval 1</code> (максимальная точность).</li>
  <li>Постобработка: слияние блоков с дублированными тайм-кодами (для DVD-источников).</li>
  <li>Соглашение об именовании для Plex: <code class="language-plaintext highlighter-rouge">&lt;имя_видео&gt;.en.srt</code>, <code class="language-plaintext highlighter-rouge">&lt;имя_видео&gt;.ru.srt</code> (двухбуквенный ISO 639-1 — Plex автоматически распознаёт язык).</li>
  <li>Оригиналы в <code class="language-plaintext highlighter-rouge">_backup/</code> (Plex не сканирует поддиректории на sidecar-субтитры).</li>
</ol>

<hr />

<h2 id="алгоритмы-внутри">Алгоритмы внутри</h2>

<h3 id="ffsubsync--fft-кросс-корреляция">ffsubsync — FFT-кросс-корреляция</h3>

<p><strong>Шаг 1: дискретизация в бинарные последовательности.</strong>
Аудиодорожку видео разбивает на окна по 10 мс. Для каждого окна VAD (Voice Activity Detection) выдаёт <strong>0</strong> (тишина/музыка) или <strong>1</strong> (речь). Получается бинарная строка длиной N (для 42-минутного эпизода — ~252 000 бит).</p>

<p>То же самое со субтитрами: на сетке 10 мс ставит <strong>1</strong> там, где по тайм-кодам должен быть текст, и <strong>0</strong> где его нет.</p>

<p><strong>Шаг 2: кросс-корреляция через FFT.</strong>
Чтобы найти оптимальный сдвиг между двумя последовательностями <code class="language-plaintext highlighter-rouge">a</code> (видео) и <code class="language-plaintext highlighter-rouge">b</code> (субтитры), нужно вычислить:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>corr(τ) = Σ a[i] · b[i + τ]   для всех τ от -max_offset до +max_offset
</code></pre></div></div>

<p>Прямым перебором это O(N²) — миллиарды операций. Через FFT: <code class="language-plaintext highlighter-rouge">corr = IFFT(FFT(a) · conj(FFT(b)))</code> — O(N log N). На стандартном эпизоде ~50 миллионов операций, секунды CPU.</p>

<p><strong>Пик функции корреляции = оптимальный сдвиг.</strong> Это и есть «offset seconds: -8.250».</p>

<p><strong>Шаг 3: коэффициент framerate (опционально).</strong>
Перебирает несколько разумных коэффициентов (1.0, 23.976/25, 25/23.976, 24/23.976 и т.д.), для каждого пересчитывает субтитры и ищет лучшую кросс-корреляцию. С флагом <code class="language-plaintext highlighter-rouge">--gss</code> использует <strong>golden-section search</strong> — численный метод поиска экстремума унимодальной функции, который за log₁.₆₁₈(N) итераций сходится к оптимуму без перебора.</p>

<p><strong>VAD-варианты:</strong></p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">webrtcvad</code> (дефолт) — Google WebRTC, использует <strong>GMM</strong> (Gaussian Mixture Model) обученную на телефонной речи. Быстро, неплохо.</li>
  <li><code class="language-plaintext highlighter-rouge">auditok</code> — энергетический детектор: RMS-энергия в окне выше порога = речь. Чувствителен к фоновой музыке (часто видит её как речь).</li>
  <li><code class="language-plaintext highlighter-rouge">silero</code> — <strong>нейросеть</strong> (LSTM поверх MFCC-фичей, ~1 МБ весов от компании Silero). Сильно точнее, но требует PyTorch и cold-start ~3 сек.</li>
</ul>

<p><strong>Что ffsubsync принципиально не умеет:</strong> найти оптимум — это найти <strong>один</strong> τ, который максимизирует корреляцию. По построению алгоритма он применяется ко всему файлу. Чтобы получить разные τ для разных кусков, нужен другой алгоритм.</p>

<h3 id="alass--динамическое-программирование-с-штрафом-за-разрывы">alass — динамическое программирование с штрафом за разрывы</h3>

<p><strong>Шаг 1: «rated intervals».</strong>
Видео → бинарная VAD-последовательность (как у ffsubsync, но интервал по умолчанию <strong>1 мс</strong>, не 10 мс). Субтитры → последовательность интервалов «есть текст / нет».</p>

<p><strong>Шаг 2: задача оптимизации.</strong>
Пусть субтитры состоят из N реплик. Для каждой реплики <code class="language-plaintext highlighter-rouge">i</code> нужно выбрать сдвиг <code class="language-plaintext highlighter-rouge">δᵢ</code>. Оптимальное решение максимизирует:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>J = Σᵢ score(reply_i, δᵢ) − P · (число точек разрыва)
</code></pre></div></div>

<p>где <code class="language-plaintext highlighter-rouge">score</code> — насколько хорошо сдвинутая реплика попадает на речь в видео (мера перекрытия с VAD-маской), а <strong>P</strong> — это <code class="language-plaintext highlighter-rouge">--split-penalty</code> (дефолт 7). «Точка разрыва» — место, где <code class="language-plaintext highlighter-rouge">δᵢ ≠ δᵢ₊₁</code>.</p>

<p><strong>Шаг 3: динамическое программирование.</strong>
Решается <strong>снизу вверх</strong> по таблице <code class="language-plaintext highlighter-rouge">DP[i][δ]</code> = «лучший суммарный score для первых i реплик, если последняя сдвинута на δ». Рекуррентность:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>DP[i][δ] = score(i, δ) + max over δ' of (DP[i-1][δ'] − P · [δ ≠ δ'])
</code></pre></div></div>

<p>Это классический алгоритм с памятью O(N · D), где D — число возможных сдвигов (D = <code class="language-plaintext highlighter-rouge">max_offset / interval</code>). При <code class="language-plaintext highlighter-rouge">--interval 1ms</code> и <code class="language-plaintext highlighter-rouge">max_offset</code> в пару минут, D ≈ 120 000. N для 42-минутного эпизода — ~1300 реплик. Итого ~150M клеток таблицы. С <code class="language-plaintext highlighter-rouge">--speed-optimization 1</code> (дефолт) пространство сжимается; с <code class="language-plaintext highlighter-rouge">--speed-optimization 0</code> (что мы использовали) — точный поиск, медленнее, но без потери точности.</p>

<p><strong>Шаг 4: восстановление сегментов.</strong>
После заполнения таблицы — backtrace через <code class="language-plaintext highlighter-rouge">argmax</code>, получаются точки, где оптимальное <code class="language-plaintext highlighter-rouge">δ</code> меняется. Это и есть «shifted block of 435 subtitles by -14.263s; shifted block of 249 subtitles by -17.400s…» — каждый блок это сегмент между точками разрыва.</p>

<p><strong>Зачем <code class="language-plaintext highlighter-rouge">--split-penalty</code>:</strong></p>

<ul>
  <li>При P → ∞ алгоритм вырождается в один сегмент (поведение как у ffsubsync, только один глобальный сдвиг).</li>
  <li>При P → 0 алгоритм разрешает разный сдвиг для каждой реплики — переобучение, реплики «прилипают» к ближайшей речи без логики.</li>
  <li>Дефолт 7 — практический компромисс. На S01E03 нашли 4 сегмента (типично для эпизода с 3-4 рекламными паузами); на S01E07 — 1 сегмент (видимо, реклама была вырезана в тех же местах в субтитрах и в видео).</li>
</ul>

<p><strong>Дополнительно:</strong></p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">--disable-fps-guessing</code> — выключает встроенный поиск коэффициента framerate. По умолчанию alass перебирает 24/23.976, 23.976/25 и пару других.</li>
  <li>VAD внутри alass — собственный энергетический детектор на основе <strong>STFT</strong> (short-time Fourier transform), без нейросетей.</li>
</ul>

<h3 id="разница-в-сложности">Разница в сложности</h3>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>ffsubsync</th>
      <th>alass</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Задача</strong></td>
      <td>argmax по 1D</td>
      <td>argmax по последовательности с регуляризацией</td>
    </tr>
    <tr>
      <td><strong>Метод</strong></td>
      <td>FFT кросс-корреляция</td>
      <td>Dynamic programming</td>
    </tr>
    <tr>
      <td><strong>Параметров на выход</strong></td>
      <td>2 (offset + scale)</td>
      <td>2N (по сдвигу на каждую реплику)</td>
    </tr>
    <tr>
      <td><strong>Сложность</strong></td>
      <td>O(N log N)</td>
      <td>O(N · D)</td>
    </tr>
    <tr>
      <td><strong>Время на эпизод (42 мин)</strong></td>
      <td>10–30 сек</td>
      <td>10–60 сек</td>
    </tr>
  </tbody>
</table>

<h3 id="почему-силеро-vad-вообще-обсуждается">Почему силеро VAD вообще обсуждается</h3>

<p>Качество синхронизации напрямую упирается в качество VAD. Если VAD ловит фоновую музыку (например, мьюзикл-вставка Studio 60 как раз начинается с песни «I Am the Very Model of a Modern Network TV Show») как «речь», а в субтитрах в этом месте тишина — кросс-корреляция получит ложный пик. silero обучен отличать речь от музыки и фонового шума, что для драматических сериалов с саундтреком даёт заметно более чистый сигнал. Для нашего кейса не пригодился — alass сам справился — но для случаев типа «синхронизация субтитров к концертному видео» силеро критичен.</p>

<p>Если интересна академическая сторона — репозиторий <strong>kaegi/alass</strong> объясняет DP-рекуррентность подробнее, а ffsubsync ссылается на классику Lewis (1995) <em>Fast Normalized Cross-Correlation</em> для своей FFT-части.</p>

<hr />

<h2 id="промпт-для-claude-code-на-английском">Промпт для Claude Code (на английском)</h2>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Sync subtitles for the video files in this directory using the best-quality
pipeline:

1. Identify the show via filename. Resolve its IMDB parent_id and find video
   files (mkv/mp4/avi) that need subtitles.

2. For each episode that lacks a subtitle in the requested language, fetch one
   from the OpenSubtitles.com REST API (https://api.opensubtitles.com/api/v1).
   Auth: header "Api-Key: &lt;KEY&gt;". Search /subtitles with parent_imdb_id,
   season_number, episode_number, languages=&lt;lang&gt;. Prefer official releases
   (e.g., "DVD.NonHI.&lt;lang&gt;.WB") over fan rips; fall back to the non-HI sub
   with highest download_count. Download via POST /download with file_id
   (anonymous, 100/day quota). Save as &lt;video_base&gt;.&lt;lang&gt;.unsynced.srt.

3. Detect encoding with chardet or `file`; if not UTF-8, transcode to UTF-8.

4. Sync with ffsubsync first (single-offset model, actively maintained):
       ffsubsync &lt;video.mkv&gt; -i &lt;sub.unsynced.srt&gt; -o &lt;out.srt&gt; --gss
   If you suspect commercial-break drift (typical for HDTV/NBC airings of older
   shows) OR the user reports the result is "better but still off", re-run with
   alass (split-aware, downloadable binary from
   github.com/kaegi/alass/releases/latest):
       alass &lt;video.mkv&gt; &lt;sub.unsynced.srt&gt; &lt;out.srt&gt; \
             --speed-optimization 0 --interval 1
   alass reports "shifted block of N subtitles by Xs" per segment. Multiple
   segments mean it found split-points ffsubsync would have averaged out.

5. Post-process the output:
   - If the source SRT splits multi-line dialogue into separate entries with
     IDENTICAL timestamps (common for Warner Bros DVD subs), merge consecutive
     entries that share start/end timestamps into one multi-line block.
     Otherwise Plex/VLC may stack them visually.
   - Re-number entries 1..N.
   - Ensure UTF-8 output and \n line endings.

6. Name files for Plex auto-language detection: &lt;video_base&gt;.&lt;iso639-1&gt;.srt
   (e.g., .en.srt, .ru.srt). Stash original sources in a _backup/ subdirectory
   - Plex does not scan subdirectories for sidecar subtitles, so backups won't
   show up as phantom tracks.

7. After processing, each video should have exactly one synced .srt per
   language alongside it - no .unsynced.srt, .tmp, or duplicate-suffix files
   left behind, since Plex would surface them as additional tracks.

8. Verify by reading (not just parsing) a sample of the output: check first
   entry starts at real dialogue time, scan for adjacent entries with
   identical timestamps, confirm encoding renders correctly.

Tools to install if missing: uv tool install ffsubsync (add --with torch
--with torchaudio for silero VAD); download alass-linux64 from its GitHub
releases page and chmod +x. Use ffprobe to confirm video framerate and audio
language streams before syncing.
</code></pre></div></div>

<hr />

<h2 id="главный-урок">Главный урок</h2>

<p>Один инструмент решает 90% случаев, но для оставшихся 10% нужен правильный второй. И всегда читать выхлоп самому — даже когда оба синхронизатора отчитались «успешно», может оказаться, что битый исходник.</p>]]></content><author><name></name></author><category term="media" /><category term="subtitles" /><category term="ffsubsync" /><category term="alass" /><category term="opensubtitles" /><category term="plex" /><category term="jekyll" /><category term="vad" /><summary type="html"><![CDATA[Read in English]]></summary></entry></feed>