Skip to content

Commit 107aa89

Browse files
author
Zenflow
committed
Fix preparation script: use original word timestamps, optimize waveform alignment, add debug output
1 parent 3b4d3c8 commit 107aa89

11 files changed

Lines changed: 2489 additions & 16 deletions
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
# Final Root Cause Analysis and Fix
2+
3+
## The Problem
4+
5+
Chunk transcript timestamps are inaccurate by ~0.5s. Audio analysis proves:
6+
- Chunk transcript: "Unterfickt" at 0.600s
7+
- Actual audio: Speech starts at 0.100s
8+
- Discrepancy: 0.500s
9+
10+
## Root Cause: Uniform Word Distribution
11+
12+
**Location**: `prepare_german_dataset.py` lines 234-238 in `parse_podcast_turns()`
13+
14+
```python
15+
seg_dur = end_t - start_t
16+
word_dur = seg_dur / len(raw_words) # ← UNIFORM DISTRIBUTION
17+
for idx, w in enumerate(raw_words):
18+
w_start = start_t + idx * word_dur # ← EVENLY SPACED
19+
w_end = start_t + (idx + 1) * word_dur
20+
```
21+
22+
### What Happens:
23+
1. Original podcast JSON has **accurate segment-level timestamps** (e.g., "segment from 10.0s to 15.0s")
24+
2. Script artificially distributes words **evenly** across segment duration
25+
3. Example: 5 words in 5 seconds → 1.0s per word (10.0-11.0, 11.0-12.0, etc.)
26+
4. Reality: Words might be spoken as "Hello...........world" (actual timing: 10.0-10.5s, 14.5-15.0s)
27+
28+
### Why This Causes 0.5s Offset:
29+
- Uniform distribution assumes words are evenly spaced
30+
- Real speech has pauses, varying word lengths, speaking rate changes
31+
- Average error accumulates to ~0.5s for typical speech patterns
32+
33+
## The Padding is Correct
34+
35+
The midpoint-based chunking (lines 854-865) is working as designed:
36+
```python
37+
midpoint = (prev_end + next_start) / 2.0 # Cut at midpoint between turns
38+
chunk_start = split_points[i]
39+
chunk_end = split_points[i + 1]
40+
```
41+
42+
This creates chunks with natural silence padding at boundaries, which is intentional and correct.
43+
44+
## The Fix: Use Whisper for Word-Level Timestamps
45+
46+
Replace uniform distribution with actual Whisper word-level alignment:
47+
48+
### Step 1: Modify `parse_podcast_turns()` to use Whisper
49+
50+
```python
51+
def parse_podcast_turns_with_whisper(json_path, audio_path):
52+
"""
53+
Parse podcast turns using Whisper for accurate word-level timestamps.
54+
"""
55+
import whisper
56+
57+
# Load original segment data
58+
with open(json_path, "r", encoding="utf-8") as f:
59+
data = json.load(f)
60+
segments = data.get("segments", [])
61+
62+
# Load audio
63+
audio = whisper.load_audio(audio_path)
64+
65+
# Run Whisper with word timestamps
66+
model = whisper.load_model("large-v2") # or appropriate model
67+
result = model.transcribe(
68+
audio,
69+
language="de",
70+
word_timestamps=True,
71+
initial_prompt="German podcast conversation"
72+
)
73+
74+
# Map speakers from original segments
75+
speaker_map = {}
76+
turns = []
77+
78+
for seg in result['segments']:
79+
# Find corresponding original segment to get speaker
80+
seg_start = seg['start']
81+
seg_end = seg['end']
82+
83+
# Match to original segment by timestamp overlap
84+
speaker = None
85+
for orig_seg in segments:
86+
if abs(orig_seg['start'] - seg_start) < 2.0: # Within 2s
87+
orig_speaker = orig_seg.get('speaker', '')
88+
if orig_speaker:
89+
if orig_speaker not in speaker_map:
90+
speaker_map[orig_speaker] = len(speaker_map)
91+
speaker = "SPEAKER_MAIN" if speaker_map[orig_speaker] == 0 else "SPEAKER_OTHER"
92+
break
93+
94+
if not speaker:
95+
continue
96+
97+
# Extract words with ACTUAL timestamps from Whisper
98+
words = []
99+
for word_info in seg.get('words', []):
100+
word = word_info['word'].strip()
101+
w_start = word_info['start']
102+
w_end = word_info['end']
103+
104+
# Clean word
105+
w_clean = re.sub(r"[^\w\däöüßÄÖÜ\s-]", "", word)
106+
if w_clean:
107+
words.append((w_clean, w_start, w_end))
108+
109+
if not words:
110+
continue
111+
112+
# Merge consecutive turns from same speaker
113+
if turns and turns[-1][0] == speaker:
114+
prev = turns[-1]
115+
turns[-1] = (prev[0], prev[1], seg_end, prev[3] + words)
116+
else:
117+
turns.append((speaker, seg_start, seg_end, words))
118+
119+
return turns
120+
```
121+
122+
### Step 2: Update `process_podcast()` to use new function
123+
124+
```python
125+
def process_podcast():
126+
# ... existing code ...
127+
128+
for ei, (ep_num, json_p, mp3_path) in enumerate(matched):
129+
try:
130+
# Use Whisper for accurate word timestamps
131+
turns = parse_podcast_turns_with_whisper(json_p, mp3_path)
132+
133+
if not turns:
134+
log_error("podcast", f"ep{ep_num}", "No speaker turns in transcript")
135+
total_errors += 1
136+
continue
137+
138+
# Load and clean audio
139+
mono, sr = librosa.load(mp3_path, sr=TARGET_SR, mono=True)
140+
mono = mono.astype(np.float32)
141+
142+
# Clean ads using waveform matching
143+
cleaned, offset, ad_breaks = clean_podcast_ads_waveform_based(
144+
mono, int(sr), json_p, ep_label=f"ep{ep_num}"
145+
)
146+
147+
# Adjust timestamps for removed regions
148+
turns_adjusted = adjust_timestamps_for_removed_regions(
149+
turns, offset, ad_breaks
150+
)
151+
152+
# Process with adjusted timestamps
153+
n = process_dialogue(
154+
cleaned, int(sr), None, None, "podcast",
155+
f"ep{ep_num}", output_dir, precomputed_turns=turns_adjusted
156+
)
157+
total_chunks += n
158+
159+
except Exception as e:
160+
log_error("podcast", f"ep{ep_num}", str(e))
161+
total_errors += 1
162+
```
163+
164+
### Step 3: Add timestamp adjustment function
165+
166+
```python
167+
def adjust_timestamps_for_removed_regions(turns, offset, ad_breaks):
168+
"""
169+
Adjust all timestamps to account for removed audio regions.
170+
171+
Args:
172+
turns: List of (speaker, start, end, words)
173+
offset: Initial offset (intro removal)
174+
ad_breaks: List of (start, end) tuples for removed ad regions
175+
176+
Returns:
177+
Adjusted turns with corrected timestamps
178+
"""
179+
adjusted_turns = []
180+
181+
for speaker, turn_start, turn_end, words in turns:
182+
# Calculate cumulative time removed before this turn
183+
time_removed_at_turn = offset
184+
for ad_start, ad_end in sorted(ad_breaks):
185+
if ad_end <= turn_start:
186+
time_removed_at_turn += (ad_end - ad_start)
187+
188+
new_turn_start = turn_start - time_removed_at_turn
189+
190+
# Adjust each word timestamp
191+
new_words = []
192+
for word, ws, we in words:
193+
# Calculate time removed before this word
194+
time_removed_at_word = offset
195+
for ad_start, ad_end in sorted(ad_breaks):
196+
if ad_end <= ws:
197+
time_removed_at_word += (ad_end - ad_start)
198+
elif ad_start < ws < ad_end:
199+
# Word starts in removed region - skip it
200+
break
201+
else:
202+
# Word is in kept region
203+
new_ws = ws - time_removed_at_word
204+
new_we = we - time_removed_at_word
205+
206+
# Ensure timestamps are valid
207+
if new_ws >= 0 and new_we > new_ws:
208+
new_words.append((word, new_ws, new_we))
209+
210+
if new_words:
211+
new_turn_end = new_words[-1][2] # End of last word
212+
adjusted_turns.append((speaker, new_turn_start, new_turn_end, new_words))
213+
214+
return adjusted_turns
215+
```
216+
217+
## Expected Results After Fix
218+
219+
1. **Accurate word-level timestamps**: Whisper detects actual speech timing
220+
2. **Proper offset adjustment**: Timestamps account for intro/ad removal
221+
3. **WER ≈ 0%**: Verification should show near-perfect alignment
222+
4. **Chunk timestamps match audio**: No more 0.5s discrepancies
223+
224+
## Verification Script Status
225+
226+
The `verify_podcasts.py` script is **working correctly**. It:
227+
- Re-transcribes with Whisper (gets accurate timestamps)
228+
- Compares to chunk transcripts
229+
- Reveals the inaccuracies in chunk transcripts
230+
231+
**No changes needed to verification script.**
232+
233+
## Implementation Priority
234+
235+
1. Implement `parse_podcast_turns_with_whisper()`
236+
2. Add `adjust_timestamps_for_removed_regions()`
237+
3. Update `process_podcast()` to use both functions
238+
4. Re-run preparation on all episodes
239+
5. Verify with `verify_podcasts.py` - should achieve WER ≈ 0%

0 commit comments

Comments
 (0)