mate-workload-stt 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mate_workload_stt-0.1.0/.gitignore +30 -0
- mate_workload_stt-0.1.0/PKG-INFO +50 -0
- mate_workload_stt-0.1.0/README.md +33 -0
- mate_workload_stt-0.1.0/pyproject.toml +35 -0
- mate_workload_stt-0.1.0/src/mate_workload_stt/__init__.py +123 -0
- mate_workload_stt-0.1.0/src/mate_workload_stt/_download.py +56 -0
- mate_workload_stt-0.1.0/src/mate_workload_stt/_measure.py +109 -0
- mate_workload_stt-0.1.0/src/mate_workload_stt/_profiles.py +47 -0
- mate_workload_stt-0.1.0/src/mate_workload_stt/py.typed +0 -0
- mate_workload_stt-0.1.0/tests/fixtures.py +23 -0
- mate_workload_stt-0.1.0/tests/test_measure.py +86 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
.venv/
|
|
3
|
+
__pycache__/
|
|
4
|
+
*.py[cod]
|
|
5
|
+
*.egg-info/
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
.pytest_cache/
|
|
9
|
+
|
|
10
|
+
# uv
|
|
11
|
+
uv.lock
|
|
12
|
+
|
|
13
|
+
# Node / Cloudflare Worker
|
|
14
|
+
worker/node_modules/
|
|
15
|
+
worker/.wrangler/
|
|
16
|
+
worker/dist/
|
|
17
|
+
|
|
18
|
+
# Results (local benchmark output)
|
|
19
|
+
results/
|
|
20
|
+
|
|
21
|
+
# Secrets / local config
|
|
22
|
+
.env
|
|
23
|
+
*.env.local
|
|
24
|
+
|
|
25
|
+
# OS
|
|
26
|
+
.DS_Store
|
|
27
|
+
Thumbs.db
|
|
28
|
+
|
|
29
|
+
# Internal planning notes
|
|
30
|
+
BENCH_NOTES.md
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mate-workload-stt
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Speech-to-text workload plugin for mate-bench
|
|
5
|
+
Project-URL: Homepage, https://github.com/T0nd3/mate-bench
|
|
6
|
+
Project-URL: Repository, https://github.com/T0nd3/mate-bench
|
|
7
|
+
Author-email: Benjamin Fäuster <benjamin.faeuster@web.de>
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Topic :: System :: Benchmark
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Requires-Dist: mate-bench<0.2,>=0.1
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# mate-workload-stt
|
|
19
|
+
|
|
20
|
+
Speech-to-text workload plugin for [mate-bench](https://github.com/T0nd3/mate-bench).
|
|
21
|
+
|
|
22
|
+
Benchmarks STT transcription speed and accuracy using LibriSpeech test-clean audio clips
|
|
23
|
+
with [faster-whisper](https://github.com/SYSTRAN/faster-whisper).
|
|
24
|
+
|
|
25
|
+
## Metrics
|
|
26
|
+
|
|
27
|
+
| Metric | Description |
|
|
28
|
+
|--------|-------------|
|
|
29
|
+
| `rtf` | Real-Time Factor — `processing_time / audio_duration` (lower is better) |
|
|
30
|
+
| `wer` | Word Error Rate — edit distance / total reference words (lower is better) |
|
|
31
|
+
| `total_audio_seconds` | Total audio processed per run |
|
|
32
|
+
|
|
33
|
+
## Profiles
|
|
34
|
+
|
|
35
|
+
| Profile | Clips | Audio | Model |
|
|
36
|
+
|---------|-------|-------|-------|
|
|
37
|
+
| `quick` | 5 | ~50 s | whisper-large-v3 |
|
|
38
|
+
| `standard` | 20 | ~200 s | whisper-large-v3 |
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
mate-bench run stt --profile quick
|
|
44
|
+
mate-bench run stt --profile standard
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Test data
|
|
48
|
+
|
|
49
|
+
[LibriSpeech test-clean](https://openslr.org/12) (Panayotov et al., 2015).
|
|
50
|
+
Licensed [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# mate-workload-stt
|
|
2
|
+
|
|
3
|
+
Speech-to-text workload plugin for [mate-bench](https://github.com/T0nd3/mate-bench).
|
|
4
|
+
|
|
5
|
+
Benchmarks STT transcription speed and accuracy using LibriSpeech test-clean audio clips
|
|
6
|
+
with [faster-whisper](https://github.com/SYSTRAN/faster-whisper).
|
|
7
|
+
|
|
8
|
+
## Metrics
|
|
9
|
+
|
|
10
|
+
| Metric | Description |
|
|
11
|
+
|--------|-------------|
|
|
12
|
+
| `rtf` | Real-Time Factor — `processing_time / audio_duration` (lower is better) |
|
|
13
|
+
| `wer` | Word Error Rate — edit distance / total reference words (lower is better) |
|
|
14
|
+
| `total_audio_seconds` | Total audio processed per run |
|
|
15
|
+
|
|
16
|
+
## Profiles
|
|
17
|
+
|
|
18
|
+
| Profile | Clips | Audio | Model |
|
|
19
|
+
|---------|-------|-------|-------|
|
|
20
|
+
| `quick` | 5 | ~50 s | whisper-large-v3 |
|
|
21
|
+
| `standard` | 20 | ~200 s | whisper-large-v3 |
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
mate-bench run stt --profile quick
|
|
27
|
+
mate-bench run stt --profile standard
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Test data
|
|
31
|
+
|
|
32
|
+
[LibriSpeech test-clean](https://openslr.org/12) (Panayotov et al., 2015).
|
|
33
|
+
Licensed [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mate-workload-stt"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Speech-to-text workload plugin for mate-bench"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = {text = "MIT"}
|
|
8
|
+
authors = [{name = "Benjamin Fäuster", email = "benjamin.faeuster@web.de"}]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"License :: OSI Approved :: MIT License",
|
|
11
|
+
"Programming Language :: Python :: 3",
|
|
12
|
+
"Programming Language :: Python :: 3.11",
|
|
13
|
+
"Programming Language :: Python :: 3.12",
|
|
14
|
+
"Topic :: System :: Benchmark",
|
|
15
|
+
]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"mate-bench>=0.1,<0.2",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://github.com/T0nd3/mate-bench"
|
|
22
|
+
Repository = "https://github.com/T0nd3/mate-bench"
|
|
23
|
+
|
|
24
|
+
[project.entry-points."mate_bench.workload"]
|
|
25
|
+
stt = "mate_workload_stt:SttWorkload"
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = ["pytest>=8.0", "ruff>=0.4"]
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["hatchling"]
|
|
32
|
+
build-backend = "hatchling.build"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/mate_workload_stt"]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Speech-to-text workload plugin for mate-bench.
|
|
2
|
+
|
|
3
|
+
Benchmarks STT transcription speed (RTF) and accuracy (WER) using
|
|
4
|
+
LibriSpeech test-clean audio clips with a faster-whisper backend.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from mate_bench._utils import sha256_file
|
|
14
|
+
from mate_bench.paths import TEST_SETS_DIR
|
|
15
|
+
from mate_bench.plugin import (
|
|
16
|
+
EnginePlugin,
|
|
17
|
+
Measurement,
|
|
18
|
+
Mode,
|
|
19
|
+
PluginManifest,
|
|
20
|
+
ProfileConfig,
|
|
21
|
+
TestSetSpec,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from ._download import fetch_clips, fetch_manifest
|
|
25
|
+
from ._measure import measure
|
|
26
|
+
from ._profiles import PROFILES, TEST_SETS
|
|
27
|
+
|
|
28
|
+
__all__ = ["SttWorkload"]
|
|
29
|
+
|
|
30
|
+
_STT_DIR = TEST_SETS_DIR / "stt"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SttWorkload:
|
|
34
|
+
name = "stt"
|
|
35
|
+
manifest = PluginManifest(requires_mate_bench=">=0.1,<0.2", api_version=1)
|
|
36
|
+
profiles: dict[str, ProfileConfig] = PROFILES
|
|
37
|
+
test_sets: dict[str, TestSetSpec] = TEST_SETS
|
|
38
|
+
|
|
39
|
+
def estimate_download(self, profile: str) -> int:
|
|
40
|
+
return PROFILES[profile].download_size_bytes
|
|
41
|
+
|
|
42
|
+
def estimate_vram(self, profile: str) -> int:
|
|
43
|
+
return int(PROFILES[profile].vram_required_gb * 1024**3)
|
|
44
|
+
|
|
45
|
+
def estimate_runtime(self, profile: str) -> int:
|
|
46
|
+
return PROFILES[profile].estimated_runtime_seconds
|
|
47
|
+
|
|
48
|
+
def required_models(self, profile: str) -> list[str]:
|
|
49
|
+
return [PROFILES[profile].reference_engine_config["model"]]
|
|
50
|
+
|
|
51
|
+
def _manifest_path(self, test_set_id: str) -> Path:
|
|
52
|
+
spec = TEST_SETS[test_set_id]
|
|
53
|
+
name = spec.url.rsplit("/", 1)[-1]
|
|
54
|
+
return _STT_DIR / "manifests" / name
|
|
55
|
+
|
|
56
|
+
def setup_closed(self, profile: str) -> None:
|
|
57
|
+
spec = TEST_SETS[PROFILES[profile].test_set_id]
|
|
58
|
+
cached_manifest = fetch_manifest(spec.url, spec.sha256, _STT_DIR)
|
|
59
|
+
fetch_clips(cached_manifest, _STT_DIR / "clips")
|
|
60
|
+
|
|
61
|
+
def setup_open(self, profile: str, user_inputs: dict[str, Any]) -> None:
|
|
62
|
+
raise NotImplementedError("open mode not supported for stt workload")
|
|
63
|
+
|
|
64
|
+
def _load_clips_and_paths(self, profile: str) -> tuple[list[dict], dict[str, Path]]:
|
|
65
|
+
test_set_id = PROFILES[profile].test_set_id
|
|
66
|
+
spec = TEST_SETS[test_set_id]
|
|
67
|
+
cached_manifest = fetch_manifest(spec.url, spec.sha256, _STT_DIR)
|
|
68
|
+
clip_paths = fetch_clips(cached_manifest, _STT_DIR / "clips")
|
|
69
|
+
return cached_manifest["clips"], clip_paths
|
|
70
|
+
|
|
71
|
+
def run(
|
|
72
|
+
self,
|
|
73
|
+
profile: str,
|
|
74
|
+
mode: Mode,
|
|
75
|
+
engine: EnginePlugin,
|
|
76
|
+
runs: int,
|
|
77
|
+
warmup_runs: int,
|
|
78
|
+
) -> Measurement:
|
|
79
|
+
if mode != Mode.CLOSED:
|
|
80
|
+
raise NotImplementedError("open mode not supported for stt workload")
|
|
81
|
+
|
|
82
|
+
cfg = PROFILES[profile].reference_engine_config
|
|
83
|
+
model = cfg["model"]
|
|
84
|
+
clips, clip_paths = self._load_clips_and_paths(profile)
|
|
85
|
+
|
|
86
|
+
median_stats, std_dev_stats, throttling_detected = measure(
|
|
87
|
+
engine, # type: ignore[arg-type]
|
|
88
|
+
model,
|
|
89
|
+
clips,
|
|
90
|
+
clip_paths,
|
|
91
|
+
runs,
|
|
92
|
+
warmup_runs,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return Measurement(
|
|
96
|
+
runs=runs,
|
|
97
|
+
warmup_runs=warmup_runs,
|
|
98
|
+
median=median_stats,
|
|
99
|
+
std_dev=std_dev_stats,
|
|
100
|
+
vram_peak_gb=0.0,
|
|
101
|
+
throttling_detected=throttling_detected,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def test_set_hash(self, profile: str) -> str:
|
|
105
|
+
path = self._manifest_path(PROFILES[profile].test_set_id)
|
|
106
|
+
if not path.exists():
|
|
107
|
+
self.setup_closed(profile)
|
|
108
|
+
return sha256_file(path)
|
|
109
|
+
|
|
110
|
+
def cleanup(self, profile: str) -> None:
|
|
111
|
+
test_set_id = PROFILES[profile].test_set_id
|
|
112
|
+
spec = TEST_SETS[test_set_id]
|
|
113
|
+
name = spec.url.rsplit("/", 1)[-1]
|
|
114
|
+
manifest_path = _STT_DIR / "manifests" / name
|
|
115
|
+
if manifest_path.exists():
|
|
116
|
+
with manifest_path.open() as f:
|
|
117
|
+
manifest = json.load(f)
|
|
118
|
+
for clip in manifest.get("clips", []):
|
|
119
|
+
fname = clip["url"].rsplit("/", 1)[-1]
|
|
120
|
+
p = _STT_DIR / "clips" / fname
|
|
121
|
+
if p.exists():
|
|
122
|
+
p.unlink()
|
|
123
|
+
manifest_path.unlink()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import urllib.request
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _sha256_file(path: Path) -> str:
|
|
10
|
+
h = hashlib.sha256()
|
|
11
|
+
with path.open("rb") as f:
|
|
12
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
13
|
+
h.update(chunk)
|
|
14
|
+
return f"sha256:{h.hexdigest()}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _download(url: str, dst: Path) -> None:
|
|
18
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
with urllib.request.urlopen(url) as resp, dst.open("wb") as f:
|
|
20
|
+
while chunk := resp.read(65536):
|
|
21
|
+
f.write(chunk)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def fetch_manifest(url: str, expected_sha256: str, cache_dir: Path) -> dict:
|
|
25
|
+
"""Download and cache the test-set manifest JSON; verify hash."""
|
|
26
|
+
name = url.rsplit("/", 1)[-1]
|
|
27
|
+
dst = cache_dir / "manifests" / name
|
|
28
|
+
if not dst.exists():
|
|
29
|
+
_download(url, dst)
|
|
30
|
+
if expected_sha256 != "sha256:PENDING":
|
|
31
|
+
actual = _sha256_file(dst)
|
|
32
|
+
if actual != expected_sha256:
|
|
33
|
+
dst.unlink()
|
|
34
|
+
raise ValueError(f"Manifest hash mismatch: expected {expected_sha256}, got {actual}")
|
|
35
|
+
with dst.open() as f:
|
|
36
|
+
return json.load(f)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def fetch_clips(manifest: dict, clips_dir: Path) -> dict[str, Path]:
|
|
40
|
+
"""Download all clips referenced in the manifest; return {clip_id: local_path}."""
|
|
41
|
+
clips_dir.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
result: dict[str, Path] = {}
|
|
43
|
+
for clip in manifest["clips"]:
|
|
44
|
+
clip_id = clip["id"]
|
|
45
|
+
fname = clip["url"].rsplit("/", 1)[-1]
|
|
46
|
+
dst = clips_dir / fname
|
|
47
|
+
if not dst.exists():
|
|
48
|
+
_download(clip["url"], dst)
|
|
49
|
+
actual = _sha256_file(dst)
|
|
50
|
+
if actual != clip["sha256"]:
|
|
51
|
+
dst.unlink()
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"Clip {clip_id} hash mismatch: expected {clip['sha256']}, got {actual}"
|
|
54
|
+
)
|
|
55
|
+
result[clip_id] = dst
|
|
56
|
+
return result
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import statistics
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Protocol
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _TranscribeResult(Protocol):
|
|
10
|
+
text: str
|
|
11
|
+
audio_duration_s: float
|
|
12
|
+
processing_time_s: float
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _SttEngine(Protocol):
|
|
16
|
+
def transcribe(self, audio_path: Path, model: str) -> _TranscribeResult: ...
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class _RunStats:
|
|
21
|
+
total_audio_s: float
|
|
22
|
+
total_processing_s: float
|
|
23
|
+
rtf: float
|
|
24
|
+
wer: float
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _word_edit_distance(ref: list[str], hyp: list[str]) -> int:
|
|
28
|
+
m, n = len(ref), len(hyp)
|
|
29
|
+
dp = list(range(n + 1))
|
|
30
|
+
for i in range(1, m + 1):
|
|
31
|
+
prev = dp[:]
|
|
32
|
+
dp[0] = i
|
|
33
|
+
for j in range(1, n + 1):
|
|
34
|
+
if ref[i - 1] == hyp[j - 1]:
|
|
35
|
+
dp[j] = prev[j - 1]
|
|
36
|
+
else:
|
|
37
|
+
dp[j] = 1 + min(prev[j], dp[j - 1], prev[j - 1])
|
|
38
|
+
return dp[n]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def corpus_wer(references: list[str], hypotheses: list[str]) -> float:
|
|
42
|
+
"""Word Error Rate over a corpus (lower is better)."""
|
|
43
|
+
total_errors = 0
|
|
44
|
+
total_words = 0
|
|
45
|
+
for ref, hyp in zip(references, hypotheses, strict=False):
|
|
46
|
+
r = ref.upper().split()
|
|
47
|
+
h = hyp.upper().split()
|
|
48
|
+
total_errors += _word_edit_distance(r, h)
|
|
49
|
+
total_words += len(r)
|
|
50
|
+
return total_errors / max(total_words, 1)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _aggregate_run(
|
|
54
|
+
results: list[_TranscribeResult],
|
|
55
|
+
references: list[str],
|
|
56
|
+
) -> _RunStats:
|
|
57
|
+
total_audio = sum(r.audio_duration_s for r in results)
|
|
58
|
+
total_proc = sum(r.processing_time_s for r in results)
|
|
59
|
+
rtf = total_proc / total_audio if total_audio > 0 else 0.0
|
|
60
|
+
wer = corpus_wer(references, [r.text for r in results])
|
|
61
|
+
return _RunStats(
|
|
62
|
+
total_audio_s=total_audio,
|
|
63
|
+
total_processing_s=total_proc,
|
|
64
|
+
rtf=rtf,
|
|
65
|
+
wer=wer,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def measure(
|
|
70
|
+
engine: _SttEngine,
|
|
71
|
+
model: str,
|
|
72
|
+
clips: list[dict[str, Any]],
|
|
73
|
+
clip_paths: dict[str, Path],
|
|
74
|
+
runs: int,
|
|
75
|
+
warmup_runs: int,
|
|
76
|
+
) -> tuple[dict[str, Any], dict[str, Any], bool]:
|
|
77
|
+
"""Run the STT benchmark loop; return (median, std_dev, throttling_detected)."""
|
|
78
|
+
references = [c["reference"] for c in clips]
|
|
79
|
+
all_stats: list[_RunStats] = []
|
|
80
|
+
|
|
81
|
+
for i in range(warmup_runs + runs):
|
|
82
|
+
run_results: list[_TranscribeResult] = []
|
|
83
|
+
for clip in clips:
|
|
84
|
+
path = clip_paths[clip["id"]]
|
|
85
|
+
result = engine.transcribe(path, model)
|
|
86
|
+
run_results.append(result)
|
|
87
|
+
|
|
88
|
+
if i >= warmup_runs:
|
|
89
|
+
all_stats.append(_aggregate_run(run_results, references))
|
|
90
|
+
|
|
91
|
+
rtf_values = [s.rtf for s in all_stats]
|
|
92
|
+
wer_values = [s.wer for s in all_stats]
|
|
93
|
+
|
|
94
|
+
median_rtf = statistics.median(rtf_values) if rtf_values else 0.0
|
|
95
|
+
median_wer = statistics.median(wer_values) if wer_values else 0.0
|
|
96
|
+
std_rtf = statistics.stdev(rtf_values) if len(rtf_values) > 1 else 0.0
|
|
97
|
+
std_wer = statistics.stdev(wer_values) if len(wer_values) > 1 else 0.0
|
|
98
|
+
|
|
99
|
+
cv = std_rtf / median_rtf if median_rtf > 0 else 0.0
|
|
100
|
+
throttling_detected = cv > 0.15
|
|
101
|
+
|
|
102
|
+
median_stats = {
|
|
103
|
+
"rtf": median_rtf,
|
|
104
|
+
"wer": median_wer,
|
|
105
|
+
"total_audio_seconds": all_stats[0].total_audio_s if all_stats else 0.0,
|
|
106
|
+
}
|
|
107
|
+
std_dev_stats = {"rtf": std_rtf, "wer": std_wer}
|
|
108
|
+
|
|
109
|
+
return median_stats, std_dev_stats, throttling_detected
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from mate_bench.plugin import ProfileConfig, TestSetSpec
|
|
4
|
+
|
|
5
|
+
# Public R2 CDN
|
|
6
|
+
CDN_BASE = "https://pub-f27eb09940c14a8dac6ae7fe10e789f3.r2.dev"
|
|
7
|
+
|
|
8
|
+
# SHA256 values are filled in by scripts/prepare_stt_testset.py after upload.
|
|
9
|
+
TEST_SETS: dict[str, TestSetSpec] = {
|
|
10
|
+
"stt-librispeech-quick-v1": TestSetSpec(
|
|
11
|
+
id="stt-librispeech-quick-v1",
|
|
12
|
+
url=f"{CDN_BASE}/stt/stt-librispeech-quick-v1.json",
|
|
13
|
+
sha256="sha256:420a49113d77e4e72a1740eeb9fff9b1918def6821c994b1d2c7e59990b8adc3",
|
|
14
|
+
size_bytes=754300,
|
|
15
|
+
license="CC BY 4.0",
|
|
16
|
+
source="LibriSpeech test-clean (Panayotov et al., 2015) — openslr.org/12",
|
|
17
|
+
),
|
|
18
|
+
"stt-librispeech-standard-v1": TestSetSpec(
|
|
19
|
+
id="stt-librispeech-standard-v1",
|
|
20
|
+
url=f"{CDN_BASE}/stt/stt-librispeech-standard-v1.json",
|
|
21
|
+
sha256="sha256:66a200da99544095c68b90fff5287ed4460b61c854ef29a29524373973a2915b",
|
|
22
|
+
size_bytes=3184083,
|
|
23
|
+
license="CC BY 4.0",
|
|
24
|
+
source="LibriSpeech test-clean (Panayotov et al., 2015) — openslr.org/12",
|
|
25
|
+
),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
PROFILES: dict[str, ProfileConfig] = {
|
|
29
|
+
"quick": ProfileConfig(
|
|
30
|
+
name="quick",
|
|
31
|
+
description="5 LibriSpeech clips (~50 s audio) with whisper-large-v3 (~3 min, ~3 GB VRAM)",
|
|
32
|
+
test_set_id="stt-librispeech-quick-v1",
|
|
33
|
+
reference_engine_config={"engine": "faster-whisper", "model": "large-v3"},
|
|
34
|
+
vram_required_gb=3.0,
|
|
35
|
+
download_size_bytes=1_500_000_000, # large-v3 ~1.5 GB
|
|
36
|
+
estimated_runtime_seconds=180,
|
|
37
|
+
),
|
|
38
|
+
"standard": ProfileConfig(
|
|
39
|
+
name="standard",
|
|
40
|
+
description="20 LibriSpeech clips (~200 s audio) with whisper-large-v3 (~8 min, ~3 GB VRAM)",
|
|
41
|
+
test_set_id="stt-librispeech-standard-v1",
|
|
42
|
+
reference_engine_config={"engine": "faster-whisper", "model": "large-v3"},
|
|
43
|
+
vram_required_gb=3.0,
|
|
44
|
+
download_size_bytes=1_500_000_000,
|
|
45
|
+
estimated_runtime_seconds=480,
|
|
46
|
+
),
|
|
47
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
MOCK_CLIPS = [
|
|
4
|
+
{
|
|
5
|
+
"id": "1089-134686-0000",
|
|
6
|
+
"url": "https://example.com/stt/clips/1089-134686-0000.flac",
|
|
7
|
+
"sha256": "sha256:aabbcc",
|
|
8
|
+
"duration_s": 11.4,
|
|
9
|
+
"reference": "HE HOPED THERE WOULD BE STEW FOR DINNER TURNIPS AND CARROTS",
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"id": "1221-135766-0001",
|
|
13
|
+
"url": "https://example.com/stt/clips/1221-135766-0001.flac",
|
|
14
|
+
"sha256": "sha256:ddeeff",
|
|
15
|
+
"duration_s": 6.3,
|
|
16
|
+
"reference": "AFTER EARLY NIGHTFALL THE YELLOW LAMPS WOULD LIGHT UP",
|
|
17
|
+
},
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
MOCK_CLIP_PATHS: dict[str, Path] = {
|
|
21
|
+
"1089-134686-0000": Path("/tmp/1089-134686-0000.flac"),
|
|
22
|
+
"1221-135766-0001": Path("/tmp/1221-135766-0001.flac"),
|
|
23
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from unittest.mock import MagicMock
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from mate_workload_stt._measure import corpus_wer, measure
|
|
8
|
+
|
|
9
|
+
from .fixtures import MOCK_CLIP_PATHS, MOCK_CLIPS
|
|
10
|
+
|
|
11
|
+
# ── corpus_wer ────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestCorpusWer:
|
|
15
|
+
def test_perfect_match(self):
|
|
16
|
+
assert corpus_wer(["HELLO WORLD"], ["HELLO WORLD"]) == 0.0
|
|
17
|
+
|
|
18
|
+
def test_one_substitution_of_two(self):
|
|
19
|
+
result = corpus_wer(["HELLO WORLD"], ["HELLO EARTH"])
|
|
20
|
+
assert result == pytest.approx(0.5)
|
|
21
|
+
|
|
22
|
+
def test_full_deletion(self):
|
|
23
|
+
result = corpus_wer(["ONE TWO THREE"], [""])
|
|
24
|
+
assert result == pytest.approx(1.0)
|
|
25
|
+
|
|
26
|
+
def test_empty_reference(self):
|
|
27
|
+
assert corpus_wer([""], [""]) == 0.0
|
|
28
|
+
|
|
29
|
+
def test_case_insensitive(self):
|
|
30
|
+
assert corpus_wer(["hello world"], ["HELLO WORLD"]) == 0.0
|
|
31
|
+
|
|
32
|
+
def test_multi_clip_corpus(self):
|
|
33
|
+
refs = ["ONE TWO", "THREE FOUR"]
|
|
34
|
+
hyps = ["ONE TWO", "THREE FIVE"]
|
|
35
|
+
# 0 errors + 1 error over 4 total words = 0.25
|
|
36
|
+
assert corpus_wer(refs, hyps) == pytest.approx(0.25)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ── measure ───────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _make_engine(processing_time: float, text: str) -> MagicMock:
|
|
43
|
+
result = MagicMock()
|
|
44
|
+
result.text = text
|
|
45
|
+
result.audio_duration_s = 10.0
|
|
46
|
+
result.processing_time_s = processing_time
|
|
47
|
+
engine = MagicMock()
|
|
48
|
+
engine.transcribe.return_value = result
|
|
49
|
+
return engine
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TestMeasure:
|
|
53
|
+
def test_rtf_is_correct(self):
|
|
54
|
+
engine = _make_engine(
|
|
55
|
+
processing_time=1.0, text="HE HOPED THERE WOULD BE STEW FOR DINNER TURNIPS AND CARROTS"
|
|
56
|
+
)
|
|
57
|
+
median, _std_dev, _throttling = measure(
|
|
58
|
+
engine, "large-v3", MOCK_CLIPS, MOCK_CLIP_PATHS, runs=1, warmup_runs=0
|
|
59
|
+
)
|
|
60
|
+
# processing_time=1.0, audio_duration=10.0 per clip → RTF = 0.1
|
|
61
|
+
assert median["rtf"] == pytest.approx(0.1)
|
|
62
|
+
|
|
63
|
+
def test_warmup_excluded(self):
|
|
64
|
+
call_count = 0
|
|
65
|
+
engine = MagicMock()
|
|
66
|
+
|
|
67
|
+
def side_effect(path, model):
|
|
68
|
+
nonlocal call_count
|
|
69
|
+
call_count += 1
|
|
70
|
+
r = MagicMock()
|
|
71
|
+
r.text = "HE HOPED THERE WOULD BE STEW FOR DINNER TURNIPS AND CARROTS"
|
|
72
|
+
r.audio_duration_s = 10.0
|
|
73
|
+
r.processing_time_s = 1.0
|
|
74
|
+
return r
|
|
75
|
+
|
|
76
|
+
engine.transcribe.side_effect = side_effect
|
|
77
|
+
measure(engine, "large-v3", MOCK_CLIPS, MOCK_CLIP_PATHS, runs=1, warmup_runs=2)
|
|
78
|
+
# 2 warmup + 1 run, each with 2 clips → 6 total calls
|
|
79
|
+
assert call_count == 6
|
|
80
|
+
|
|
81
|
+
def test_no_throttling_stable_run(self):
|
|
82
|
+
engine = _make_engine(1.0, "HE HOPED THERE WOULD BE STEW FOR DINNER TURNIPS AND CARROTS")
|
|
83
|
+
_, _, throttling = measure(
|
|
84
|
+
engine, "large-v3", MOCK_CLIPS, MOCK_CLIP_PATHS, runs=3, warmup_runs=0
|
|
85
|
+
)
|
|
86
|
+
assert throttling is False
|