Skip to content

Commit 78452ba

Browse files
author
Zenflow
committed
fix(pipeline): audit round 5 — F1–F4
F1 (P1) Eliminate per-call norm_words() on w1 tokens in _try_match_seg. Previously, every _try_match_seg call tokenised each w1 word in its window via a list comprehension: w_flat = [(norm_words(w1_words[k]["word"]) or [""])[0] for k in range(w1_pos, win_end)] For a 3-hour episode the anchor-search loop triggers this O(candidates × w1_positions × window_size) times — easily tens of thousands of redundant norm_words() calls on the same w1 text. Fix: pre-compute a flat w1_nwords list (one normalised token per w1 entry) alongside w1_starts in both step2_align and step3_build_skiplist, and pass it as a new w1_nwords parameter to _try_match_seg. Inside _try_match_seg the window is now a zero-copy list slice: w_flat = w1_nwords[w1_pos:win_end]. All four _try_match_seg call sites updated (step2: lines 452, 463; step3: lines 681, 692). No behaviour change — identical normalisation formula preserved. F2 (P2) Add isinstance validation on w2aligned.json cache load in process_episode. The step-2 fast-path did a bare json.load with no shape check; a corrupt or schema-mismatched file produced a confusing TypeError/KeyError deep inside step4_cut. Now raises ValueError with the file path before any key access — consistent with w1 and chunk cache guards added in round 4. F3 (P2) wav_duration(): wrap float() conversion in try/except and re-raise as RuntimeError naming the file path and raw ffprobe output. ffprobe outputs "N/A" for files with no duration metadata (corrupt, zero-byte, or stream-only WAV); float("N/A") previously produced a generic ValueError with no context. F4 (P3) mute_outside(): add defensive sorted(active) at function entry. Cursor-based mute-region logic requires a sorted active list; callers currently always pass merge_intervals() output (which sorts), but there was no contract enforcement. Added sort + updated docstring to make the invariant self-documenting and self-enforcing.
1 parent 8d29499 commit 78452ba

1 file changed

Lines changed: 43 additions & 13 deletions

File tree

scripts/podcast_to_moshi_dataset.py

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,8 @@ def _lcs_ratio(a: list, b: list) -> float:
128128

129129

130130
def _try_match_seg(seg: dict, w1_pos: int,
131-
w1_words: list, w1_starts: list) -> tuple | None:
131+
w1_words: list, w1_starts: list,
132+
w1_nwords: list) -> tuple | None:
132133
"""
133134
Versucht ein w0-Segment ab w1-Position w1_pos zu matchen.
134135
@@ -140,6 +141,10 @@ def _try_match_seg(seg: dict, w1_pos: int,
140141
w1_pos: Startindex in w1_words / w1_starts.
141142
w1_words: Flache Wort-Liste aus whisper-cpp (Dicts mit "word","start","end").
142143
w1_starts: Vorberechnete Start-Zeitstempel-Liste (parallel zu w1_words).
144+
w1_nwords: Vorberechnete normalisierte Einzelwörter (parallel zu w1_words).
145+
Jeder Eintrag = (norm_words(w["word"]) or [""])[0].
146+
Wird als Slice w1_nwords[w1_pos:win_end] genutzt — vermeidet
147+
redundante norm_words()-Aufrufe im engen Suchloop.
143148
"""
144149
t_norm = norm_words(seg.get("text", ""))
145150
nw1 = len(w1_words)
@@ -149,8 +154,7 @@ def _try_match_seg(seg: dict, w1_pos: int,
149154
t0 = w1_words[w1_pos]["start"]
150155
win_end = bisect.bisect_right(w1_starts, t0 + seg_dur + 3.0)
151156
win_end = max(w1_pos + 1, min(win_end, nw1))
152-
w_flat = [(norm_words(w1_words[k]["word"]) or [""])[0]
153-
for k in range(w1_pos, win_end)]
157+
w_flat = w1_nwords[w1_pos:win_end]
154158
score = _lcs_ratio(t_norm, w_flat)
155159
thr = 0.30 if len(t_norm) > 10 else 0.45 if len(t_norm) > 6 else 0.60
156160
if score < thr:
@@ -166,7 +170,14 @@ def wav_duration(path: str) -> float:
166170
"-of", "default=noprint_wrappers=1:nokey=1", path],
167171
capture_output=True, text=True, check=True,
168172
)
169-
return float(r.stdout.strip())
173+
raw = r.stdout.strip()
174+
try:
175+
return float(raw)
176+
except ValueError:
177+
raise RuntimeError(
178+
f"wav_duration: ffprobe gab keine numerische Ausgabe für {path!r}: {raw!r}\n"
179+
f" (Datei beschädigt, leer oder ohne Dauer-Metadaten?)"
180+
) from None
170181

171182

172183
def run_whisper_words(wav_path: str) -> list:
@@ -398,8 +409,12 @@ def step2_align(ep_num: int, w1_words: list, transcript_segs: list) -> tuple:
398409
"""
399410
out_path = os.path.join(WHISPER_CACHE, f"ep{ep_num}_w2aligned.json")
400411

401-
w1_starts = [w["start"] for w in w1_words]
402-
nw1 = len(w1_words)
412+
w1_starts = [w["start"] for w in w1_words]
413+
# Pre-compute normalised single-word tokens for each w1 entry once per
414+
# episode. _try_match_seg builds a window slice from this list instead of
415+
# calling norm_words(w["word"]) on every (wi, window_position) combination.
416+
w1_nwords = [(norm_words(w["word"]) or [""])[0] for w in w1_words]
417+
nw1 = len(w1_words)
403418

404419
MIN_SEG_WORDS = 5 # Mindestanzahl Wörter im Ankersegment
405420
CONFIRM_N = 6 # Anzahl nachfolgender Segmente zur Bestätigung
@@ -434,7 +449,7 @@ def step2_align(ep_num: int, w1_words: list, transcript_segs: list) -> tuple:
434449
for cand_si in candidates:
435450
seg0 = transcript_segs[cand_si]
436451
for wi in range(end_wi):
437-
r0 = _try_match_seg(seg0, wi, w1_words, w1_starts)
452+
r0 = _try_match_seg(seg0, wi, w1_words, w1_starts, w1_nwords)
438453
if r0 is None:
439454
continue
440455
# Bestätigung: CONFIRM_N nachfolgende lange Segmente müssen matchen
@@ -445,7 +460,7 @@ def step2_align(ep_num: int, w1_words: list, transcript_segs: list) -> tuple:
445460
break
446461
if len(seg_nwords[si2]) < MIN_SEG_WORDS:
447462
continue
448-
r2 = _try_match_seg(transcript_segs[si2], w_pos, w1_words, w1_starts)
463+
r2 = _try_match_seg(transcript_segs[si2], w_pos, w1_words, w1_starts, w1_nwords)
449464
if r2 is None:
450465
break
451466
w_pos = r2[0]
@@ -593,8 +608,12 @@ def step3_build_skiplist(transcript_segs: list, w1_words: list,
593608
print(f" seg[{si}] {kind} w0={t_w0:.2f}s → erwartet_w1={t_w0 + anchor_offset:.2f}s "
594609
f"{repr(transcript_segs[si].get('text','').strip()[:55])}", flush=True)
595610

596-
w1_starts = [w["start"] for w in w1_words]
597-
w1_ends = [w["end"] for w in w1_words]
611+
w1_starts = [w["start"] for w in w1_words]
612+
w1_ends = [w["end"] for w in w1_words]
613+
# Pre-compute normalised single-word tokens for each w1 entry once.
614+
# The find_post_ad_anchor inner loop passes this to _try_match_seg as a
615+
# slice instead of calling norm_words(w["word"]) on every iteration.
616+
w1_nwords = [(norm_words(w["word"]) or [""])[0] for w in w1_words]
598617

599618
# raw_ad_start: Timestamp-Lookup-Fenster um t_exp(START)
600619
NARROW_S = 30.0
@@ -659,7 +678,7 @@ def find_post_ad_anchor(si_e: int, t_exp_end: float) -> tuple | None:
659678
for cand_si in candidates:
660679
seg0 = transcript_segs[cand_si]
661680
for wi in range(lo_wi, hi_wi):
662-
r0 = _try_match_seg(seg0, wi, w1_words, w1_starts)
681+
r0 = _try_match_seg(seg0, wi, w1_words, w1_starts, w1_nwords)
663682
if r0 is None:
664683
continue
665684
# Bestätigung: CONFIRM_N nachfolgende lange Segmente müssen ebenfalls matchen
@@ -670,7 +689,7 @@ def find_post_ad_anchor(si_e: int, t_exp_end: float) -> tuple | None:
670689
break
671690
if len(seg_nwords[si2]) < MIN_SEG_WORDS:
672691
continue
673-
r2 = _try_match_seg(transcript_segs[si2], w_pos, w1_words, w1_starts)
692+
r2 = _try_match_seg(transcript_segs[si2], w_pos, w1_words, w1_starts, w1_nwords)
674693
if r2 is None:
675694
break
676695
w_pos = r2[0]
@@ -1002,7 +1021,13 @@ def merge_intervals(ivs: list) -> list:
10021021
# Mute für ch1 = alle Lücken zwischen b_active-Intervallen
10031022

10041023
def mute_outside(ch: bytearray, active: list, n_frames: int, framerate: int) -> None:
1005-
"""Setzt alle Samples in ch auf 0, die NICHT in einem aktiven Intervall liegen."""
1024+
"""Setzt alle Samples in ch auf 0, die NICHT in einem aktiven Intervall liegen.
1025+
1026+
active muss nach Startzeit sortiert sein (merge_intervals() garantiert das).
1027+
Defensive sort hier stellt sicher, dass unsortierte Listen kein stilles
1028+
Fehlverhalten erzeugen.
1029+
"""
1030+
active = sorted(active) # defensive: garantiert korrekte Lückenberechnung
10061031
# Lücken zwischen aktiven Intervallen ermitteln
10071032
mute_regions = []
10081033
cursor = 0.0
@@ -1144,6 +1169,11 @@ def process_episode(ep_num: int, transcript_path: str, mp3_path: str):
11441169
if os.path.exists(w2aligned_path) and os.path.exists(anchor_cache_path):
11451170
with open(w2aligned_path, encoding="utf-8") as fh:
11461171
aligned = json.load(fh)
1172+
if not isinstance(aligned, list):
1173+
raise ValueError(
1174+
f"step2: w2aligned-Cache hat unerwartetes Format (keine Liste): "
1175+
f"{w2aligned_path}"
1176+
)
11471177
with open(anchor_cache_path, encoding="utf-8") as fh:
11481178
anchor_offset = json.load(fh)["anchor_offset"]
11491179
print(f"\n [Step 2] aus Cache geladen — {len(aligned)} w0-Wörter, "

0 commit comments

Comments
 (0)