|
| 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