Some checks failed
Linux Build / Linux cpu (push) Has been cancelled
Linux Build / Linux cpu-clang (push) Has been cancelled
Linux Build / Linux vulkan (push) Has been cancelled
mac-build / macOS CPU (push) Has been cancelled
Nix Build / Nix (cpu) (push) Has been cancelled
Nix Build / Nix (vulkan) (push) Has been cancelled
windows-build / Windows CPU (push) Has been cancelled
119 lines
5.5 KiB
Python
Executable file
119 lines
5.5 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Word error rate for an ASR family against the LibriSpeech clips in this tree.
|
|
|
|
ASR is one of the few audio tasks with a ground truth shipped next to the audio, so it
|
|
can be scored rather than listened to. `assets/asr_validation/librispeech/` holds four
|
|
clips with their reference transcripts; this runs a model over them and reports WER.
|
|
|
|
⚠ FOUR CLIPS IS A SANITY CHECK, NOT A BENCHMARK. About 120 words total, so one bad
|
|
substitution moves the number by nearly a point. It answers "does this model work and
|
|
roughly how does it compare", not "which model is better".
|
|
|
|
tools/asr_wer.py --cli build/bin/audiocpp_cli --family vibevoice_asr_streaming \
|
|
--model path/to.gguf [--backend cuda] [--label 7B]
|
|
"""
|
|
import argparse, json, os, pathlib, re, subprocess, sys
|
|
|
|
|
|
def normalize(text):
|
|
"""LibriSpeech references are uppercase and unpunctuated; match that."""
|
|
text = text.upper().replace("-", " ")
|
|
text = re.sub(r"[^A-Z' ]", " ", text)
|
|
return text.split()
|
|
|
|
|
|
def edit_distance(a, b):
|
|
previous = list(range(len(b) + 1))
|
|
for i, x in enumerate(a, 1):
|
|
current = [i]
|
|
for j, y in enumerate(b, 1):
|
|
current.append(min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (x != y)))
|
|
previous = current
|
|
return previous[-1]
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
parser.add_argument("--cli", required=True)
|
|
parser.add_argument("--family", required=True)
|
|
parser.add_argument("--model", required=True)
|
|
parser.add_argument("--backend", default="cpu")
|
|
parser.add_argument("--threads", default=str(min(os.cpu_count() or 4, 16)))
|
|
parser.add_argument("--label", default="")
|
|
parser.add_argument("--timeout", type=float, default=1800,
|
|
help="Per-clip seconds before the clip is scored as a miss.")
|
|
parser.add_argument("--assets", default="assets/asr_validation/librispeech")
|
|
args = parser.parse_args()
|
|
|
|
clips = sorted(pathlib.Path(args.assets).glob("*.wav"))
|
|
if not clips:
|
|
print(f"no clips under {args.assets}", file=sys.stderr)
|
|
return 2
|
|
|
|
total_errors = total_words = 0
|
|
print(f"{'clip':46s} {'WER':>7s} {'err/words':>10s}")
|
|
for clip in clips:
|
|
transcript = clip.with_suffix(".txt")
|
|
if not transcript.exists():
|
|
print(f"{clip.stem[:46]:46s} {'no ref':>7s} {'-':>10s}")
|
|
continue
|
|
reference = normalize(transcript.read_text())
|
|
try:
|
|
result = subprocess.run(
|
|
[args.cli, "--task", "asr", "--family", args.family, "--model", args.model,
|
|
"--backend", args.backend, "--threads", args.threads, "--audio", str(clip)],
|
|
capture_output=True, text=True, timeout=args.timeout)
|
|
except subprocess.TimeoutExpired:
|
|
# Score it as a total miss and keep going; one slow clip should not throw
|
|
# away the clips already measured.
|
|
print(f"{clip.stem[:46]:46s} {'timeout':>7s} {'-':>10s}")
|
|
total_errors += len(reference)
|
|
total_words += len(reference)
|
|
continue
|
|
# ⚠ Two output shapes. A diarizing family (vibevoice_asr_streaming) emits
|
|
# `speaker_turns=[{...}]` plus a MULTI-LINE text_output with "Speaker N:"
|
|
# prefixes; a plain one (parakeet_tdt) puts everything after `text_output=` on
|
|
# one line. Reading only the remainder of the text_output line scores a working
|
|
# model at 100% WER, which is how this was found.
|
|
hypothesis = ""
|
|
for line in result.stdout.splitlines():
|
|
if line.startswith("speaker_turns="):
|
|
try:
|
|
turns = json.loads(line[len("speaker_turns="):])
|
|
hypothesis = " ".join(turn.get("text", "") for turn in turns)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
if not hypothesis.strip():
|
|
collecting = False
|
|
parts = []
|
|
for line in result.stdout.splitlines():
|
|
if line.startswith("text_output="):
|
|
collecting = True
|
|
parts.append(line[len("text_output="):])
|
|
elif collecting:
|
|
if "=" in line.split(" ")[0] and not line.startswith(" "):
|
|
break # the next key=value field, so the value ended
|
|
parts.append(line)
|
|
hypothesis = " ".join(parts)
|
|
hypothesis = re.sub(r"Speaker\s+\d+\s*:", " ", hypothesis)
|
|
if not hypothesis.strip():
|
|
# ⚠ Print this on a CLEAN exit too. A model that exits 0 and says nothing is
|
|
# the failure mode that scored a working model at 100% WER; silence here is
|
|
# what made it look like a model result instead of a harness bug.
|
|
tail = result.stderr.strip().splitlines()[-3:]
|
|
print(f" (exit {result.returncode}) " + " | ".join(line[:100] for line in tail))
|
|
words = normalize(hypothesis)
|
|
errors = edit_distance(reference, words)
|
|
total_errors += errors
|
|
total_words += len(reference)
|
|
flag = "" if words else " <- NO OUTPUT"
|
|
print(f"{clip.stem[:46]:46s} {100*errors/max(len(reference),1):6.1f}% {errors:4d}/{len(reference):<5d}{flag}")
|
|
|
|
label = f" [{args.label}]" if args.label else ""
|
|
print(f"\nWER{label}: {100*total_errors/max(total_words,1):.2f}% "
|
|
f"({total_errors} errors over {total_words} words, {len(clips)} clips)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|