#!/usr/bin/env python3 """Transcribe cloned-voices/*.wav with whisper-large-v3 and write ref_texts. Outputs: - cloned-voices/whisper_refs.json (raw transcripts keyed by voice name) - voice_to_speaker.default.yaml (ref_text rewritten in place) - cloned-voices/voices_metadata.json (ref_text rewritten in place) F5-TTS cloning quality depends on ref_text matching the prosody of ref_audio (commas, periods, casing). LibriSpeech ground-truth labels are ALL CAPS with no punctuation, which is the wrong signal for a flow-matching TTS conditioned on text. Whisper hears what F5 will hear, so its transcript is the better ref. Run on a GPU host (4090 / 3090). Idempotent: rerun any time cloned-voices/ changes. Use `make whisper-refs` rather than calling this directly. Pass --from-cache to skip ASR and only re-apply normalization from an existing whisper_refs.json — useful after tweaking normalize_text(). No GPU needed. """ from __future__ import annotations import argparse import json import os import re import sys from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent VOICES_DIR = REPO_ROOT / "cloned-voices" YAML_PATH = REPO_ROOT / "voice_to_speaker.default.yaml" METADATA_PATH = VOICES_DIR / "voices_metadata.json" WHISPER_JSON = VOICES_DIR / "whisper_refs.json" MODEL_ID = os.environ.get("WHISPER_MODEL", "openai/whisper-large-v3") def normalize_text(text: str) -> str: """Clean Whisper output for use as F5 ref_text. Whisper occasionally hallucinates `"'` cluster characters when it interprets a fragment as quoted dialogue — at sentence start, mid-clause, anywhere. It also returns lowercase output and drops terminal punctuation on some clips. F5 tokenizes the raw string, so messy ref_text → noisy conditioning signal. Designed to be idempotent: running it twice on the same input is a no-op. """ text = re.sub(r"\s+", " ", text).strip() # `"'` never appears as legitimate English punctuation — always Whisper noise text = text.replace('"\'', "") # strip leading whitespace and opening-quote junk text = re.sub(r'^[\s"\'`]+', "", text) # strip trailing whitespace and any quote/apostrophe chars (legit closing quote # after a terminal `.` is also dropped — F5 only cares about the prosody marker) text = re.sub(r'[\s"\'`]+$', "", text) # if a terminal punct is followed by stray apostrophe-then-period (`.'.`), # collapse to the terminal — happens when a closing-quoted line gets a `.` appended text = re.sub(r"([.!?])['\"`]+\.?$", r"\1", text) text = re.sub(r"\s+", " ", text).strip() if text and text[0].isalpha(): text = text[0].upper() + text[1:] if text and text[-1] not in ".!?": text = text + "." return text def load_pipeline(): import torch from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor, pipeline device = "cuda:0" if torch.cuda.is_available() else "cpu" dtype = torch.float16 if device.startswith("cuda") else torch.float32 print(f"[whisper_refs] loading {MODEL_ID} on {device} ({dtype})", flush=True) model = AutoModelForSpeechSeq2Seq.from_pretrained( MODEL_ID, torch_dtype=dtype, low_cpu_mem_usage=True, use_safetensors=True ).to(device) processor = AutoProcessor.from_pretrained(MODEL_ID) return pipeline( "automatic-speech-recognition", model=model, tokenizer=processor.tokenizer, feature_extractor=processor.feature_extractor, torch_dtype=dtype, device=device, return_timestamps=False, ) def transcribe_all(asr) -> dict[str, str]: wavs = sorted(VOICES_DIR.glob("*.wav")) print(f"[whisper_refs] {len(wavs)} wavs to transcribe", flush=True) out: dict[str, str] = {} for i, wav in enumerate(wavs, 1): result = asr( str(wav), generate_kwargs={"language": "en", "task": "transcribe"}, ) text = normalize_text(result["text"]) voice = wav.stem out[voice] = text print(f"[whisper_refs] [{i:2d}/{len(wavs)}] {voice:10s} -> {text}", flush=True) return out def rewrite_yaml(refs: dict[str, str]) -> int: """Surgical replace of ref_text values keyed by ref_audio filename. Avoids a full YAML round-trip so comments/order/whitespace stay untouched. Each voice block contains a `ref_audio: cloned-voices/.wav` line followed (next non-blank line) by `ref_text: "..."`. Match on the audio path and rewrite the next ref_text line. """ lines = YAML_PATH.read_text().splitlines(keepends=True) audio_re = re.compile(r"^(\s*)ref_audio:\s*cloned-voices/([^\s.]+)\.wav\s*$") text_re = re.compile(r"^(\s*)ref_text:\s*.*$") changed = 0 i = 0 while i < len(lines): m = audio_re.match(lines[i].rstrip("\n")) if not m: i += 1 continue voice = m.group(2) if voice not in refs: i += 1 continue # find the next ref_text line within the same block (no blank line break) j = i + 1 while j < len(lines) and lines[j].strip() != "": tm = text_re.match(lines[j].rstrip("\n")) if tm: indent = tm.group(1) escaped = refs[voice].replace("\\", "\\\\").replace('"', '\\"') lines[j] = f'{indent}ref_text: "{escaped}"\n' changed += 1 break j += 1 i = j + 1 YAML_PATH.write_text("".join(lines)) return changed def rewrite_metadata(refs: dict[str, str]) -> int: data = json.loads(METADATA_PATH.read_text()) changed = 0 for voice, entry in data.items(): if voice in refs and entry.get("ref_text") != refs[voice]: entry["ref_text"] = refs[voice] changed += 1 METADATA_PATH.write_text(json.dumps(data, indent=2) + "\n") return changed def main() -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument( "--from-cache", action="store_true", help="skip ASR; re-apply normalize_text() to cached whisper_refs.json", ) args = parser.parse_args() if not VOICES_DIR.is_dir(): print(f"[whisper_refs] no cloned-voices dir at {VOICES_DIR}", file=sys.stderr) return 2 if args.from_cache: if not WHISPER_JSON.is_file(): print(f"[whisper_refs] --from-cache but {WHISPER_JSON} missing", file=sys.stderr) return 2 raw = json.loads(WHISPER_JSON.read_text()) refs = {voice: normalize_text(text) for voice, text in raw.items()} print(f"[whisper_refs] re-normalized {len(refs)} entries from cache", flush=True) else: asr = load_pipeline() refs = transcribe_all(asr) WHISPER_JSON.write_text(json.dumps(refs, indent=2, ensure_ascii=False) + "\n") print(f"[whisper_refs] wrote {WHISPER_JSON} ({len(refs)} entries)", flush=True) yaml_changed = rewrite_yaml(refs) meta_changed = rewrite_metadata(refs) print( f"[whisper_refs] yaml: {yaml_changed} ref_text replaced | " f"metadata: {meta_changed} ref_text replaced", flush=True, ) return 0 if __name__ == "__main__": sys.exit(main())