voice_audio_multi/tools/asr_wer.py
Fabio c36510c21e
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
Initial import: audio.cpp with Orange Pi documentation
2026-11-04 19:05:22 +01:00

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())