gemma1b-tts-integration / scripts /gop_logit_bplus.py
marcos
Add B+ GOP-logit pronunciation scorer + calibration
b2853ea
Raw
History Blame Contribute Delete
7.31 kB
from __future__ import annotations
import argparse
import json
import random
import sys
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import librosa
import soundfile as sf
import torch
from speech_bridge_gemma.ctc_gop import (
calibrate_gop_thresholds,
ctc_gop_phone_scores,
gop_word_events,
)
PHONE_RECOGNIZER = "facebook/wav2vec2-xlsr-53-espeak-cv-ft"
def load_audio_16k(path: str) -> Any:
wav, sr = sf.read(path)
if getattr(wav, "ndim", 1) > 1:
wav = wav.mean(axis=1)
wav = wav.astype("float32")
if sr != 16000:
wav = librosa.resample(wav, orig_sr=sr, target_sr=16000)
return wav
class PhoneGop:
def __init__(self, device: str):
from transformers import AutoModelForCTC, Wav2Vec2FeatureExtractor, Wav2Vec2PhonemeCTCTokenizer
self.device = device
self.extractor = Wav2Vec2FeatureExtractor.from_pretrained(PHONE_RECOGNIZER)
self.tokenizer = Wav2Vec2PhonemeCTCTokenizer.from_pretrained(PHONE_RECOGNIZER)
self.model = AutoModelForCTC.from_pretrained(PHONE_RECOGNIZER).to(device).eval()
self.vocab = dict(self.tokenizer.get_vocab())
self.blank_id = int(self.tokenizer.pad_token_id)
self.candidates = [p for p, i in self.vocab.items() if int(i) != self.blank_id and not p.startswith("<") and p not in ("|",) and not p.isdigit()]
def log_probs(self, audio_path: str) -> torch.Tensor:
wav = load_audio_16k(audio_path)
values = self.extractor(wav, sampling_rate=16000, return_tensors="pt").input_values.to(self.device)
with torch.inference_mode():
logits = self.model(values).logits[0]
return logits.log_softmax(dim=-1).detach().cpu()
def realized(self, audio_path: str) -> list[str]:
wav = load_audio_16k(audio_path)
values = self.extractor(wav, sampling_rate=16000, return_tensors="pt").input_values.to(self.device)
with torch.inference_mode():
ids = self.model(values).logits.argmax(dim=-1)
return [p for p in self.tokenizer.batch_decode(ids)[0].split() if p]
def score(self, log_probs: torch.Tensor, expected: list[str], normalization: str | None = None) -> list[dict[str, Any]]:
words = [str(index) for index in range(len(expected))]
expected_word_phonemes = [[phone] for phone in expected]
return ctc_gop_phone_scores(log_probs, words, expected_word_phonemes, self.vocab, normalization, self.blank_id)
def perturb(expected: list[str], candidates: list[str], rate: float, seed: int) -> tuple[list[str], set[int]]:
rng = random.Random(seed)
out = list(expected)
errors: set[int] = set()
for index, phone in enumerate(expected):
if rng.random() < rate:
alt = rng.choice(candidates)
tries = 0
while alt == phone and tries < 5:
alt = rng.choice(candidates)
tries += 1
out[index] = alt
errors.add(index)
return out, errors
def read_jsonl(path: str) -> list[dict[str, Any]]:
rows = []
with Path(path).open(encoding="utf-8") as handle:
for line in handle:
line = line.strip()
if line:
rows.append(json.loads(line))
return rows
def calibrate(args: argparse.Namespace) -> int:
gop = PhoneGop(args.device)
manifest = read_jsonl(args.manifest)
if args.max_rows:
manifest = manifest[: args.max_rows]
scores_by_id: dict[str, list[dict[str, Any]]] = {}
rows: list[dict[str, Any]] = []
for index, item in enumerate(manifest):
audio = str(item["audio"])
expected = gop.realized(audio)
if len(expected) < 3:
continue
log_probs = gop.log_probs(audio)
perturbed, error_positions = perturb(expected, gop.candidates, args.perturb_rate, args.seed + index)
scores = gop.score(log_probs, perturbed, args.normalization)
item_id = str(item.get("id") or Path(audio).stem)
scores_by_id[item_id] = scores
expected_errors = [{"word": str(pos), "expected": perturbed[pos]} for pos in sorted(error_positions)]
rows.append({"id": item_id, "expected_errors": expected_errors})
print(json.dumps({"id": item_id, "phones": len(expected), "perturbed": len(error_positions)}, ensure_ascii=False), flush=True)
thresholds = calibrate_gop_thresholds(scores_by_id, rows, args.normalization)
Path(args.out).write_text(json.dumps(thresholds, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
summary = {k: thresholds[k] for k in ("positive_scores", "negative_scores")}
summary["default"] = thresholds["default"]
summary["per_phone_count"] = len(thresholds["per_phone"])
print(json.dumps(summary, ensure_ascii=False), flush=True)
return 0
def assess(args: argparse.Namespace) -> int:
gop = PhoneGop(args.device)
thresholds = json.loads(Path(args.thresholds).read_text(encoding="utf-8"))
expected = gop.realized(args.reference_audio) if args.reference_audio else None
if expected is None:
raise SystemExit("provide --reference-audio for B+ assess")
log_probs = gop.log_probs(args.audio)
scores = gop.score(log_probs, expected, args.normalization)
words = [str(index) for index in range(len(expected))]
expected_word_phonemes = [[phone] for phone in expected]
events = gop_word_events(words, expected_word_phonemes, scores, thresholds, args.normalization)
flagged = [{"phone": s["expected"], "heard": s["alternative"], "gop": round(s["gop"], 3)} for s in scores if s["gop"] <= float((thresholds["per_phone"].get(s["expected"]) or thresholds["default"])["threshold"])]
result = {
"audio": args.audio,
"reference_audio": args.reference_audio,
"expected": expected,
"flagged": flagged,
"event_count": len(events),
}
print(json.dumps(result, ensure_ascii=False, indent=2))
if args.out:
Path(args.out).write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return 0
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="cmd", required=True)
cal = sub.add_parser("calibrate")
cal.add_argument("--manifest", required=True)
cal.add_argument("--out", default="job_output/omni-train-130/gop_thresholds.json")
cal.add_argument("--perturb-rate", type=float, default=0.25)
cal.add_argument("--normalization", default=None)
cal.add_argument("--max-rows", type=int)
cal.add_argument("--seed", type=int, default=1337)
cal.add_argument("--device", default="cuda")
cal.set_defaults(func=calibrate)
asn = sub.add_parser("assess")
asn.add_argument("--audio", required=True)
asn.add_argument("--reference-audio", required=True)
asn.add_argument("--thresholds", default="job_output/omni-train-130/gop_thresholds.json")
asn.add_argument("--out")
asn.add_argument("--normalization", default=None)
asn.add_argument("--device", default="cuda")
asn.set_defaults(func=assess)
return parser.parse_args()
def main() -> int:
args = parse_args()
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())