Skip to content

Commit 5be2e70

Browse files
author
Zenflow
committed
add PODCAST_ALIGNMENT.md: full pipeline documentation
Covers: - Problem statement (transcript/raw-audio offset mismatch) - Step 1: full transcription with CoreML large-v3 - Step 2: bootstrap, forward scan, recalibration, back-fill, interpolation, crack-closing, gap detection, word assignment - Step 3: stripped audio rerun + timestamp refinement - Usage examples and --skip-step3 flag - File layout - Design decisions and audit history (why true LCS, why LCS bootstrap over 4-gram, why recalibration, why crack-closing) - ep152 results table: 99.8% words assigned, 2 real gap regions
1 parent cb3957c commit 5be2e70

1 file changed

Lines changed: 253 additions & 0 deletions

File tree

scripts/PODCAST_ALIGNMENT.md

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
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

Comments
 (0)