Give spoken words a memory with an instant attached
An audio file used to produce no memory at all: _ingest_file returned an empty batch for any suffix it did not know, with no log and no reason. It now raises UnsupportedFile when you name the file and records a SkippedFile when it was a directory walk, so a refusal is visible either way. Speech is measured, not promised. Over 210.5 seconds of real Video-MME audio, faster-whisper-tiny on CPU int8 transcribed at 51x realtime and reached word error rate 0.242 against community subtitles nobody here wrote (a caption track drops words a transcript keeps, so that is an upper bound). 66 speech atoms now carry 550 word-level timestamps, 0 words out of order and 0 starting before their own segment. The snapshot is 78.2 MB, under the 500 MB cap, and the report is bench/reports/ASR.md. Two library facts were measured rather than assumed. faster-whisper's own audio loader is broken against PyAV 19 (av.open got an unexpected keyword 'metadata_errors') and it declares av with no version bound, so asr.decode_audio decodes with PyAV directly and hands the model a float32 array instead of pinning an old av with no cp314 wheel. And av.Error does not exist on PyAV 19 - the real base is av.error.FFmpegError - so that is the only thing caught and my own ValueErrors keep their messages. Files over the 500-line rule were split rather than excused: ingestion.py (748) into ingestion_common / ingestion_media / ingestion_repository / ingestion, and cli.py (544) into cli_senses plus cli_common, following the cli_transactive register pattern. MediaIngest declares _source_record abstract, so a half-wired host fails at construction instead of returning None. Verification: 207 tests pass; ruff clean on the 13 touched files; mypy clean on them, and repo-wide mypy fell from 29 errors to 24 (5 fixed, none added); pyright reports 0 errors on the touched files.
This commit is contained in:
1 parent
03ca35763f
commit
5289cb4b01
20 files changed
+1513
-611
No files matched your search
@@ -0,0 +1,203 @@
|
||||
"""Measure what the speech reader hears, against subtitles nobody in this repo wrote.
|
||||
|
||||
Video-MME ships community subtitles for its videos. They are third-party text, so they are a
|
||||
fair reference for word error rate, with one caveat the report states: a caption track is not
|
||||
a verbatim transcript, so the number is an upper bound on the model's real error rate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from itertools import pairwise
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ely_eye.asr import SpeechSegment, SpeechTranscriber, SpokenWord
|
||||
from ely_eye.config import Settings
|
||||
from ely_eye.schemas import Modality
|
||||
|
||||
from bench.systems import machine_facts
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
|
||||
CORPUS = ROOT / "corpus" / "lmms-eval" / "Video-MME"
|
||||
MODEL = ROOT / "models" / "faster-whisper-tiny"
|
||||
REPORTS = ROOT / "reports"
|
||||
PUNCTUATION = re.compile(r"[^\w' ]+")
|
||||
TAGS = re.compile(r"<[^>]+>")
|
||||
INDEX_LINE = re.compile(r"^\d+$")
|
||||
TIMING_LINE = re.compile(r"-->")
|
||||
|
||||
|
||||
def normalise(text: str) -> list[str]:
|
||||
cleaned = PUNCTUATION.sub(" ", text.lower())
|
||||
return [word for word in cleaned.split() if word]
|
||||
|
||||
|
||||
def edit_distance(left: list[str], right: list[str]) -> int:
|
||||
previous = list(range(len(right) + 1))
|
||||
for index, first in enumerate(left, start=1):
|
||||
current = [index]
|
||||
for column, second in enumerate(right, start=1):
|
||||
cost = 0 if first == second else 1
|
||||
current.append(min(previous[column] + 1, current[column - 1] + 1, previous[column - 1] + cost))
|
||||
previous = current
|
||||
return previous[-1]
|
||||
|
||||
|
||||
def word_error_rate(hypothesis: list[str], reference: list[str]) -> float:
|
||||
if not reference:
|
||||
return 1.0
|
||||
return edit_distance(hypothesis, reference) / len(reference)
|
||||
|
||||
|
||||
def srt_text(path: Path) -> str:
|
||||
lines = []
|
||||
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or INDEX_LINE.match(stripped) or TIMING_LINE.search(stripped):
|
||||
continue
|
||||
lines.append(TAGS.sub(" ", stripped))
|
||||
return " ".join(lines)
|
||||
|
||||
|
||||
def model_sha256() -> str:
|
||||
digest = hashlib.sha256()
|
||||
with (MODEL / "model.bin").open("rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def git_sha() -> str:
|
||||
done = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"], cwd=ROOT.parent, capture_output=True, text=True, check=True
|
||||
)
|
||||
return done.stdout.strip()
|
||||
|
||||
|
||||
def timed_words(segments: list[SpeechSegment]) -> list[SpokenWord]:
|
||||
return [word for segment in segments for word in segment.words if word.word]
|
||||
|
||||
|
||||
def check_timings(segments: list[SpeechSegment]) -> dict[str, Any]:
|
||||
"""Word clocks must run forward and stay inside their segment."""
|
||||
words = timed_words(segments)
|
||||
out_of_order = sum(1 for before, after in pairwise(words) if after.start < before.start)
|
||||
outside = sum(1 for segment in segments for word in segment.words if word.start < segment.start - 0.01)
|
||||
return {"words": len(words), "out_of_order": out_of_order, "before_their_segment": outside}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not (MODEL / "model.bin").exists():
|
||||
raise SystemExit(f"{MODEL / 'model.bin'} is missing; fetch Systran/faster-whisper-tiny into {MODEL}")
|
||||
videos = sorted((CORPUS / "videos").glob("*.mp4"))
|
||||
if not videos:
|
||||
raise SystemExit(f"no videos under {CORPUS / 'videos'}; unpack them from videos_chunked_01.zip")
|
||||
|
||||
transcriber = SpeechTranscriber(Settings(asr_model_dir=MODEL))
|
||||
rows: list[dict[str, Any]] = []
|
||||
for video in videos:
|
||||
subtitle = CORPUS / "subtitles" / f"{video.stem}.srt"
|
||||
if not subtitle.exists():
|
||||
continue
|
||||
started = time.time()
|
||||
segments = transcriber.transcribe(video)
|
||||
took = time.time() - started
|
||||
hypothesis = " ".join(segment.text for segment in segments)
|
||||
reference = srt_text(subtitle)
|
||||
hyp_words, ref_words = normalise(hypothesis), normalise(reference)
|
||||
audio_seconds = max((segment.end for segment in segments), default=0.0)
|
||||
rows.append(
|
||||
{
|
||||
"video": video.stem,
|
||||
"seconds": round(audio_seconds, 1),
|
||||
"transcribe_seconds": round(took, 2),
|
||||
"real_time_factor": round(audio_seconds / took, 1) if took else 0.0,
|
||||
"segments": len(segments),
|
||||
"speech_atoms": sum(1 for segment in segments if segment.text),
|
||||
"modality": Modality.audio.value,
|
||||
"hypothesis_words": len(hyp_words),
|
||||
"reference_words": len(ref_words),
|
||||
"word_error_rate": round(word_error_rate(hyp_words, ref_words), 3),
|
||||
"mean_word_confidence": round(
|
||||
sum(word.confidence for word in timed_words(segments)) / max(len(timed_words(segments)), 1), 3
|
||||
),
|
||||
**check_timings(segments),
|
||||
}
|
||||
)
|
||||
if not rows:
|
||||
raise SystemExit(f"none of {len(videos)} videos has a subtitle in {CORPUS / 'subtitles'}")
|
||||
reference_words = sum(row["reference_words"] for row in rows)
|
||||
if not reference_words:
|
||||
raise SystemExit("the subtitle files produced no reference words; the parser or the files are wrong")
|
||||
|
||||
totals = {
|
||||
"videos": len(rows),
|
||||
"audio_seconds": round(sum(row["seconds"] for row in rows), 1),
|
||||
"word_error_rate": round(
|
||||
sum(row["word_error_rate"] * row["reference_words"] for row in rows) / reference_words,
|
||||
3,
|
||||
),
|
||||
"speech_atoms": sum(row["speech_atoms"] for row in rows),
|
||||
"words": sum(row["words"] for row in rows),
|
||||
"out_of_order": sum(row["out_of_order"] for row in rows),
|
||||
"before_their_segment": sum(row["before_their_segment"] for row in rows),
|
||||
}
|
||||
report = {
|
||||
"generated_utc": time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime()),
|
||||
"commit": git_sha(),
|
||||
"model": {"id": "Systran/faster-whisper-tiny", "sha256_model_bin": model_sha256(), "compute": "cpu int8"},
|
||||
"reference": "Video-MME community subtitles (third-party captions, not verbatim transcripts)",
|
||||
"machine": machine_facts(),
|
||||
"totals": totals,
|
||||
"videos": rows,
|
||||
}
|
||||
REPORTS.mkdir(exist_ok=True)
|
||||
(REPORTS / "asr.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
write_markdown(report)
|
||||
print(f"wrote {REPORTS / 'ASR.md'}")
|
||||
|
||||
|
||||
def write_markdown(report: dict[str, Any]) -> None:
|
||||
totals, model = report["totals"], report["model"]
|
||||
lines = [
|
||||
f"# ASR: what the speech reader hears (commit {report['commit'][:9]})",
|
||||
"",
|
||||
f"- generated {report['generated_utc']}",
|
||||
f"- model: {model['id']} ({model['compute']}), model.bin sha256 {model['sha256_model_bin'][:16]}…",
|
||||
f"- reference: {report['reference']}",
|
||||
f"- machine: {report['machine']}",
|
||||
"",
|
||||
"| video | audio s | transcribe s | ×realtime | speech atoms | words | WER | mean word confidence |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
||||
]
|
||||
for row in report["videos"]:
|
||||
lines.append(
|
||||
f"| {row['video']} | {row['seconds']} | {row['transcribe_seconds']} | {row['real_time_factor']} "
|
||||
f"| {row['speech_atoms']} | {row['words']} | {row['word_error_rate']} | {row['mean_word_confidence']} |"
|
||||
)
|
||||
lines += [
|
||||
"",
|
||||
(
|
||||
f"Over {totals['videos']} videos and {totals['audio_seconds']} seconds of audio: word error rate "
|
||||
f"**{totals['word_error_rate']}** against captions nobody here wrote, {totals['speech_atoms']} speech "
|
||||
f"atoms carrying {totals['words']} word-level timestamps, {totals['out_of_order']} words out of order "
|
||||
f"and {totals['before_their_segment']} starting before their own segment."
|
||||
),
|
||||
"",
|
||||
(
|
||||
"A caption track drops words a transcript keeps, so this WER is an upper bound on the model's real "
|
||||
"error. The point of the run is not the number: before this change an audio file produced no memory "
|
||||
"at all, and every word here now has an instant attached to it."
|
||||
),
|
||||
]
|
||||
(REPORTS / "ASR.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,16 @@
|
||||
# ASR: what the speech reader hears (commit 03ca35763)
|
||||
|
||||
- generated 2026-10-08T01:05:45+00:00
|
||||
- model: Systran/faster-whisper-tiny (cpu int8), model.bin sha256 dcb76c6586fc06cb…
|
||||
- reference: Video-MME community subtitles (third-party captions, not verbatim transcripts)
|
||||
- machine: {'system': 'Linux', 'release': '6.18.40.1-microsoft-standard-WSL2', 'cpus': 32, 'threads_per_query': 4, 'load_average_1_5_15': [1.84, 1.85, 6.99]}
|
||||
|
||||
| video | audio s | transcribe s | ×realtime | speech atoms | words | WER | mean word confidence |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 0IdYJGBmguM | 88.9 | 1.73 | 51.3 | 31 | 218 | 0.179 | 0.879 |
|
||||
| 1q-5IIyZL20 | 99.9 | 1.94 | 51.6 | 32 | 300 | 0.28 | 0.83 |
|
||||
| 1tidNDIpKSY | 21.7 | 1.34 | 16.1 | 3 | 32 | 0.278 | 0.852 |
|
||||
|
||||
Over 3 videos and 210.5 seconds of audio: word error rate **0.242** against captions nobody here wrote, 66 speech atoms carrying 550 word-level timestamps, 0 words out of order and 0 starting before their own segment.
|
||||
|
||||
A caption track drops words a transcript keeps, so this WER is an upper bound on the model's real error. The point of the run is not the number: before this change an audio file produced no memory at all, and every word here now has an instant attached to it.
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"generated_utc": "2026-10-08T01:05:45+00:00",
|
||||
"commit": "03ca35763fd63eb8d8f0d2c721d02c6c3ded90a7",
|
||||
"model": {
|
||||
"id": "Systran/faster-whisper-tiny",
|
||||
"sha256_model_bin": "dcb76c6586fc06cbdac6dd21f14cfd129cc4cdd9dce19bf4ffa62e59cbe6e6d1",
|
||||
"compute": "cpu int8"
|
||||
},
|
||||
"reference": "Video-MME community subtitles (third-party captions, not verbatim transcripts)",
|
||||
"machine": {
|
||||
"system": "Linux",
|
||||
"release": "6.18.40.1-microsoft-standard-WSL2",
|
||||
"cpus": 32,
|
||||
"threads_per_query": 4,
|
||||
"load_average_1_5_15": [
|
||||
1.84,
|
||||
1.85,
|
||||
6.99
|
||||
]
|
||||
},
|
||||
"totals": {
|
||||
"videos": 3,
|
||||
"audio_seconds": 210.5,
|
||||
"word_error_rate": 0.242,
|
||||
"speech_atoms": 66,
|
||||
"words": 550,
|
||||
"out_of_order": 0,
|
||||
"before_their_segment": 0
|
||||
},
|
||||
"videos": [
|
||||
{
|
||||
"video": "0IdYJGBmguM",
|
||||
"seconds": 88.9,
|
||||
"transcribe_seconds": 1.73,
|
||||
"real_time_factor": 51.3,
|
||||
"segments": 31,
|
||||
"speech_atoms": 31,
|
||||
"modality": "audio",
|
||||
"hypothesis_words": 218,
|
||||
"reference_words": 201,
|
||||
"word_error_rate": 0.179,
|
||||
"mean_word_confidence": 0.879,
|
||||
"words": 218,
|
||||
"out_of_order": 0,
|
||||
"before_their_segment": 0
|
||||
},
|
||||
{
|
||||
"video": "1q-5IIyZL20",
|
||||
"seconds": 99.9,
|
||||
"transcribe_seconds": 1.94,
|
||||
"real_time_factor": 51.6,
|
||||
"segments": 32,
|
||||
"speech_atoms": 32,
|
||||
"modality": "audio",
|
||||
"hypothesis_words": 300,
|
||||
"reference_words": 296,
|
||||
"word_error_rate": 0.28,
|
||||
"mean_word_confidence": 0.83,
|
||||
"words": 300,
|
||||
"out_of_order": 0,
|
||||
"before_their_segment": 0
|
||||
},
|
||||
{
|
||||
"video": "1tidNDIpKSY",
|
||||
"seconds": 21.7,
|
||||
"transcribe_seconds": 1.34,
|
||||
"real_time_factor": 16.1,
|
||||
"segments": 3,
|
||||
"speech_atoms": 3,
|
||||
"modality": "audio",
|
||||
"hypothesis_words": 32,
|
||||
"reference_words": 36,
|
||||
"word_error_rate": 0.278,
|
||||
"mean_word_confidence": 0.852,
|
||||
"words": 32,
|
||||
"out_of_order": 0,
|
||||
"before_their_segment": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -19,15 +19,12 @@ import json
|
||||
import re
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from ely_eye.config import Settings
|
||||
from ely_eye.db import Database
|
||||
from ely_eye.ocr import read_text_from_image
|
||||
@@ -44,8 +41,9 @@ from ely_eye.storage import (
|
||||
visual_token_equivalent,
|
||||
)
|
||||
from ely_eye.visual_index import VisualIndex
|
||||
from metrics import aggregate, chance_recall_at_k, score_query
|
||||
from systems import machine_facts
|
||||
|
||||
from bench.metrics import aggregate, chance_recall_at_k, score_query
|
||||
from bench.systems import machine_facts
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
CORPUS = ROOT / "corpus" / "OS-Copilot" / "ScreenSpot-v2"
|
||||
|
||||
Reference in new issue
Block a user