|
| 1 | +# Podcast → Moshi Dataset: Word-Level Timestamp Alignment |
| 2 | + |
| 3 | +`scripts/podcast_to_moshi_dataset.py` — Phase 1 of the Moshi fine-tune pipeline. |
| 4 | + |
| 5 | +Converts raw Gemischtes Hack Podcast MP3s into word-level aligned transcripts |
| 6 | +by fusing whisper-cpp's audio-grounded timestamps with the existing diarized |
| 7 | +transcript (which has correct speaker labels and clean text but timestamps |
| 8 | +relative to ad-stripped audio). |
| 9 | + |
| 10 | +--- |
| 11 | + |
| 12 | +## The problem |
| 13 | + |
| 14 | +The diarized transcripts were produced on clean audio (intro jingle and ad |
| 15 | +breaks already removed). The raw MP3 files contain the full broadcast including |
| 16 | +intro (~34s for ep152) and one or more ad breaks. This means every transcript |
| 17 | +timestamp is shifted by an unknown amount relative to the raw audio, and the |
| 18 | +shift increases by the length of each ad break. |
| 19 | + |
| 20 | +``` |
| 21 | +Raw MP3: [jingle 34s][content...][StepStone ad 9s][more content...] |
| 22 | +Transcript: [content...t=0 ][more content...] |
| 23 | + ↑ offset ≈ +34s ↑ offset ≈ +43s |
| 24 | +``` |
| 25 | + |
| 26 | +--- |
| 27 | + |
| 28 | +## Pipeline |
| 29 | + |
| 30 | +### Step 1 — Full transcription (`step1_transcribe`) |
| 31 | + |
| 32 | +Runs whisper-cpp on the entire raw MP3 (no stripping, no chunking). Produces a |
| 33 | +flat word list with audio-grounded timestamps. Saved to |
| 34 | +`whisper_cache/ep{N}_w1.json`. Cached — skipped on re-runs if the file exists. |
| 35 | + |
| 36 | +**Model:** `ggml-large-v3.bin` with CoreML encoder |
| 37 | +(`bin/models/ggml-large-v3-encoder.mlmodelc`) for Apple Neural Engine |
| 38 | +acceleration. The binary at `whisper-cpp/build/bin/whisper-cli` was compiled |
| 39 | +with `-DWHISPER_COREML=ON`; CoreML is activated automatically when the |
| 40 | +`-encoder.mlmodelc` directory exists next to the model file. |
| 41 | + |
| 42 | +**Token merging:** whisper-cpp emits sub-word BPE tokens. A space-prefix rule |
| 43 | +merges them back into words: a new word starts whenever a token begins with a |
| 44 | +space character. |
| 45 | + |
| 46 | +**w1.json schema:** |
| 47 | +```json |
| 48 | +[{"word": "Gemischtes", "start": 0.12, "end": 0.51, "p": 0.97}, ...] |
| 49 | +``` |
| 50 | + |
| 51 | +--- |
| 52 | + |
| 53 | +### Step 2 — Alignment (`step2_align`) |
| 54 | + |
| 55 | +The core of the pipeline. Produces `aligned_cache/ep{N}_aligned.json`. |
| 56 | + |
| 57 | +#### 2a. Bootstrap offset |
| 58 | + |
| 59 | +Before scanning segments, we need an initial estimate of the |
| 60 | +raw\_time − transcript\_time offset (δ). |
| 61 | + |
| 62 | +**Primary strategy** — LCS first-segment alignment: |
| 63 | +For each of the first 40 transcript segments with ≥4 words, run a true LCS |
| 64 | +search within the first 180 s of whisper output. Collect all candidates and |
| 65 | +return the one with the **earliest raw timestamp** — the first content to appear |
| 66 | +in the whisper stream is the most reliable anchor because it is as close to |
| 67 | +the jingle boundary as possible. |
| 68 | + |
| 69 | +Searching only the first 180 s prevents a false match from a repeated phrase |
| 70 | +deep in the episode (which previously produced an offset of +2034 s with the |
| 71 | +turbo model). |
| 72 | + |
| 73 | +**Fallback** — exact 4-gram scan: if LCS finds nothing (all early segments are |
| 74 | +very short), scan for the first exact 4-word run shared between transcript and |
| 75 | +whisper within the same 180 s window. |
| 76 | + |
| 77 | +#### 2b. Forward scan with recalibration |
| 78 | + |
| 79 | +Iterates transcript segments in order. For each segment with ≥4 words: |
| 80 | + |
| 81 | +1. **Tight window search** (±12 s around `t_segment + offset`): |
| 82 | + Uses true DP LCS over all whisper words in the window. Returns the whisper |
| 83 | + span whose LCS score against the transcript segment exceeds a per-length |
| 84 | + threshold: |
| 85 | + |
| 86 | + | segment words | min score | |
| 87 | + |---|---| |
| 88 | + | ≤ 4 | 0.75 | |
| 89 | + | ≤ 6 | 0.67 | |
| 90 | + | ≤ 10 | 0.60 | |
| 91 | + | > 10 | 0.55 | |
| 92 | + |
| 93 | + On a match: update offset from `whisper_match_start − t_segment`, advance |
| 94 | + cursor to 2 s before match end (boundary overlap). |
| 95 | + |
| 96 | +2. **Recalibration** (after ≥8 consecutive misses): |
| 97 | + The offset has likely drifted (gradual whisper timing drift) or an ad break |
| 98 | + has shifted it. Search ±60 s around the expected position, using the current |
| 99 | + cursor as a floor (never rewinds). Accept the match regardless of jump |
| 100 | + direction or magnitude and update offset + cursor. This fires ~once per real |
| 101 | + ad break and self-corrects without oscillation. |
| 102 | + |
| 103 | +3. **Queue for back-fill**: if both tight and recal searches fail, append to |
| 104 | + `pending_miss`. |
| 105 | + |
| 106 | +4. **Back-fill** on every successful match: walk `pending_miss` in reverse |
| 107 | + order (closest to the new anchor first), search each with a ±12 s window |
| 108 | + capped at `abs_start` of the current anchor to enforce segment ordering. |
| 109 | + |
| 110 | +**Segments with <4 words** are never queued; they are handled entirely by the |
| 111 | +interpolation pass (see below). |
| 112 | + |
| 113 | +#### 2c. Interpolation |
| 114 | + |
| 115 | +After the forward scan, any segment that still has no raw timestamp (missed by |
| 116 | +both tight search and back-fill) gets its position linearly interpolated between |
| 117 | +its nearest matched neighbours, using transcript time as the parameter. |
| 118 | + |
| 119 | +#### 2d. Segment windows and gap closure |
| 120 | + |
| 121 | +Each segment is assigned a raw time window `[raw_start, raw_end]`. After |
| 122 | +building all windows, **cracks** between adjacent windows are closed by |
| 123 | +extending each window's end to the next window's start. Without this step, |
| 124 | +whisper words belonging to unmatched-but-real content would fall into cracks |
| 125 | +and be misclassified as out-of-transcript, creating false gap regions. |
| 126 | + |
| 127 | +Gap detection then runs on the closed windows: any span between consecutive |
| 128 | +windows ≥8 s that is not covered by any window is a real gap (intro jingle or |
| 129 | +ad break). |
| 130 | + |
| 131 | +#### 2e. Word assignment |
| 132 | + |
| 133 | +Each whisper word is assigned to a segment window by bisect lookup. Words in |
| 134 | +detected gap regions (intro/ad/outro) get `out_of_transcript: True`; all others |
| 135 | +get the speaker label and segment text of their containing window. |
| 136 | + |
| 137 | +**aligned.json schema:** |
| 138 | +```json |
| 139 | +[ |
| 140 | + { |
| 141 | + "word": "goes,", |
| 142 | + "start": 34.74, |
| 143 | + "end": 34.99, |
| 144 | + "p": 0.92, |
| 145 | + "speaker": "SPEAKER_00", |
| 146 | + "seg_text": "But so it goes, turning into some so-and-sos...", |
| 147 | + "out_of_transcript": false |
| 148 | + }, |
| 149 | + ... |
| 150 | +] |
| 151 | +``` |
| 152 | + |
| 153 | +**ep152 results (large-v3 + CoreML):** |
| 154 | + |
| 155 | +| Metric | Value | |
| 156 | +|---|---| |
| 157 | +| Words assigned | 11,996 / 12,021 (99.8%) | |
| 158 | +| Words excluded | 25 (StepStone ad only) | |
| 159 | +| Gap regions | 2 (36 s intro + 9 s ad) | |
| 160 | +| Recalibrations | 1 (at known ad break t≈1403 s) | |
| 161 | +| Bootstrap offset | +36.17 s | |
| 162 | + |
| 163 | +--- |
| 164 | + |
| 165 | +### Step 3 — Stripped audio re-run (`step3_rerun_compare`) |
| 166 | + |
| 167 | +Builds a stripped MP3 (ffmpeg concat of keep-regions from step 2), re-runs |
| 168 | +whisper-cpp on it, then refines each assigned word's timestamp by matching |
| 169 | +it to the closest w2 word within ±3 s. Words that can't be matched keep their |
| 170 | +w1 timestamps. |
| 171 | + |
| 172 | +w2 timestamps (relative to stripped audio start) are remapped to raw MP3 time |
| 173 | +via the keep-region boundaries. The refined alignment overwrites |
| 174 | +`aligned_cache/ep{N}_aligned.json`. |
| 175 | + |
| 176 | +Skip with `--skip-step3` when iteration speed matters (step 3 takes ~70 min |
| 177 | +for large-v3 on a 70-min episode). |
| 178 | + |
| 179 | +--- |
| 180 | + |
| 181 | +## Usage |
| 182 | + |
| 183 | +```bash |
| 184 | +# Single episode — all 3 steps |
| 185 | +python3 scripts/podcast_to_moshi_dataset.py --episodes 152 |
| 186 | + |
| 187 | +# Range |
| 188 | +python3 scripts/podcast_to_moshi_dataset.py --episodes 150-152 |
| 189 | + |
| 190 | +# Comma list |
| 191 | +python3 scripts/podcast_to_moshi_dataset.py --episodes 150,152 |
| 192 | + |
| 193 | +# Skip step 3 (fast iteration on alignment only) |
| 194 | +python3 scripts/podcast_to_moshi_dataset.py --episodes 152 --skip-step3 |
| 195 | +``` |
| 196 | + |
| 197 | +Set `DEBUG = True` inside `step2_align` for per-segment `MATCH` / `MISS` / |
| 198 | +`BACKFILL` output. |
| 199 | + |
| 200 | +--- |
| 201 | + |
| 202 | +## File layout |
| 203 | + |
| 204 | +``` |
| 205 | +bin/models/ |
| 206 | + ggml-large-v3.bin # unquantized whisper large-v3 (2.9 GB) |
| 207 | + ggml-large-v3-encoder.mlmodelc/ # CoreML encoder compiled from mlpackage |
| 208 | +
|
| 209 | +whisper-cpp/ |
| 210 | + build/bin/whisper-cli # built with -DWHISPER_COREML=ON |
| 211 | + models/ |
| 212 | + coreml-encoder-large-v3.mlpackage/ # source mlpackage (1.2 GB) |
| 213 | +
|
| 214 | +/Volumes/eHDD/moshi-rag-data/datasets/ |
| 215 | + Gemischtes.Hack.Podcast/ |
| 216 | + transcripts/episode_NNN_*.json # diarized transcripts (read-only) |
| 217 | + #NNN *.mp3 # raw episode MP3s (read-only) |
| 218 | + whisper_cache/ |
| 219 | + ep{N}_w1.json # step 1 output (word list, cached) |
| 220 | + ep{N}_w2.json # step 3 output (stripped-audio words) |
| 221 | + aligned_cache/ |
| 222 | + ep{N}_aligned.json # final aligned word list |
| 223 | +``` |
| 224 | + |
| 225 | +--- |
| 226 | + |
| 227 | +## Design decisions and audit history |
| 228 | + |
| 229 | +**Why true DP LCS, not greedy subsequence matching?** |
| 230 | +The original `lcs_score` used a greedy forward scan that required `t_norm[0]` |
| 231 | +to appear in the whisper chunk. If whisper dropped the first word(s) of a |
| 232 | +segment (common at intro/ad boundaries), the segment scored 0.0. True DP LCS |
| 233 | +matches in any order-preserving subsequence, scoring correctly regardless of |
| 234 | +which words whisper dropped. |
| 235 | + |
| 236 | +**Why LCS bootstrap over 4-gram scan?** |
| 237 | +The 4-gram scan was a workaround for broken LCS. With true LCS, first-segment |
| 238 | +alignment is strictly better: it scores the best-matching position in the |
| 239 | +window, tolerates dropped words, and is immune to accidental 4-gram repeats |
| 240 | +mid-episode (which caused a +2034 s false offset with the large-v3 model). |
| 241 | + |
| 242 | +**Why recalibration instead of a forward-only wide window?** |
| 243 | +A forward-only wide window anchored at `cursor_time + WIDE_WIN` oscillated |
| 244 | +wildly: each ad-break false-positive pushed cursor forward, making the next |
| 245 | +search center too far ahead. Consecutive-miss recalibration fires exactly once |
| 246 | +per real structural shift (ad break or accumulated drift) and self-corrects. |
| 247 | + |
| 248 | +**Why crack-closing on segment windows?** |
| 249 | +Without it, whisper words belonging to unmatched segments between two matched |
| 250 | +anchors fell into cracks between windows and were mis-classified as |
| 251 | +out-of-transcript. This inflated the excluded word count from ~25 (correct) to |
| 252 | +~1,365 and produced 5 false gap regions. The fix: extend each window's end to |
| 253 | +the next window's start before gap detection. |
0 commit comments