whisper_refs: normalize transcripts (sentence-case, strip quote artifacts)
normalize_text() handles three Whisper quirks that produced messy F5
ref_texts:
1. Lowercase output with no terminal period — capitalize first letter,
append "." if missing.
2. Hallucinated "' clusters Whisper inserts when it interprets a
fragment as quoted dialogue (cora, ivan, atlas, hope had these).
Strip everywhere; never legitimate English punctuation.
3. Trailing apostrophe-then-period (.'.) from earlier rounds where a
closing-quoted line got an extra "." appended — collapse to single
terminal. Function is now idempotent.
Adds --from-cache flag: skip ASR, re-apply normalize from cached
whisper_refs.json. No GPU needed, useful after tuning the normalizer.
Lazy-imports torch so --from-cache works on any host.
Affects 9 of 40 voices: clara, grace, hazel, iris, felix, hugo
(lowercase fix); cora, ivan, atlas, hope (quote-cluster fix).
This commit is contained in:
parent
72a9553047
commit
c5247cd993
4 changed files with 116 additions and 67 deletions
|
|
@ -13,18 +13,19 @@ 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
|
||||
|
||||
import torch
|
||||
from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor, pipeline
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
VOICES_DIR = REPO_ROOT / "cloned-voices"
|
||||
|
|
@ -34,7 +35,38 @@ 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)
|
||||
|
|
@ -62,8 +94,7 @@ def transcribe_all(asr) -> dict[str, str]:
|
|||
str(wav),
|
||||
generate_kwargs={"language": "en", "task": "transcribe"},
|
||||
)
|
||||
text = result["text"].strip()
|
||||
text = re.sub(r"\s+", " ", text)
|
||||
text = normalize_text(result["text"])
|
||||
voice = wav.stem
|
||||
out[voice] = text
|
||||
print(f"[whisper_refs] [{i:2d}/{len(wavs)}] {voice:10s} -> {text}", flush=True)
|
||||
|
|
@ -120,11 +151,29 @@ def rewrite_metadata(refs: dict[str, str]) -> int:
|
|||
|
||||
|
||||
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
|
||||
asr = load_pipeline()
|
||||
refs = transcribe_all(asr)
|
||||
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue