clear-record 0.1.1__py3-none-any.whl
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.
- clear_record/__init__.py +15 -0
- clear_record/cli/__init__.py +7 -0
- clear_record/cli/__main__.py +4 -0
- clear_record/cli/cli.py +403 -0
- clear_record/cli/eval.py +66 -0
- clear_record/cli/stages.py +746 -0
- clear_record/cli/transcription.py +499 -0
- clear_record/cli/workspace.py +358 -0
- clear_record/core/__init__.py +44 -0
- clear_record/core/model.py +143 -0
- clear_record/core/pipeline.py +90 -0
- clear_record/engine/__init__.py +69 -0
- clear_record/engine/align.py +324 -0
- clear_record/engine/attribute.py +451 -0
- clear_record/engine/audio.py +141 -0
- clear_record/engine/chunk.py +77 -0
- clear_record/engine/diarize.py +288 -0
- clear_record/engine/merge.py +135 -0
- clear_record/engine/synth.py +388 -0
- clear_record/engine/text.py +63 -0
- clear_record/providers/__init__.py +49 -0
- clear_record/providers/backends.py +829 -0
- clear_record/providers/base.py +94 -0
- clear_record/providers/paths.py +29 -0
- clear_record/providers/process.py +167 -0
- clear_record-0.1.1.dist-info/METADATA +99 -0
- clear_record-0.1.1.dist-info/RECORD +30 -0
- clear_record-0.1.1.dist-info/WHEEL +4 -0
- clear_record-0.1.1.dist-info/entry_points.txt +3 -0
- clear_record-0.1.1.dist-info/licenses/LICENSE +21 -0
clear_record/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""clear-record: a local-first multitrack transcription and record-reconstruction tool.
|
|
2
|
+
|
|
3
|
+
The published distribution is a single package with four internal layers:
|
|
4
|
+
|
|
5
|
+
- :mod:`clear_record.core` — backend-agnostic domain model (no third-party deps);
|
|
6
|
+
- :mod:`clear_record.engine` — audio I/O, alignment, reconcile (numpy/soundfile);
|
|
7
|
+
- :mod:`clear_record.providers` — per-vendor ASR backend adapters;
|
|
8
|
+
- :mod:`clear_record.cli` — the ``clear-record`` command implementation.
|
|
9
|
+
|
|
10
|
+
This module is deliberately light (a docstring only) so that importing
|
|
11
|
+
``clear_record`` does not pull in the heavy CLI or audio stack. The console
|
|
12
|
+
script targets :func:`clear_record.cli.main` directly.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
__all__: list[str] = []
|
clear_record/cli/cli.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""The `clear-record` CLI.
|
|
2
|
+
|
|
3
|
+
One subcommand per pipeline stage (plus `run`/`calibrate` conveniences). The
|
|
4
|
+
subcommand surface is derived from the pipeline spec so the CLI and domain cannot
|
|
5
|
+
drift; the heavy per-stage logic lives in :mod:`clear_record.cli.stages`.
|
|
6
|
+
|
|
7
|
+
Recordings and model weights are environment-local data — never commit them.
|
|
8
|
+
See docs/architecture.md §6 and ADR-0006.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
from typing import Sequence
|
|
15
|
+
|
|
16
|
+
from clear_record.core import Step, pipeline_spec
|
|
17
|
+
from clear_record.providers import BACKENDS, available_backend_ids, resolve_models_dir
|
|
18
|
+
from clear_record.engine import DEFAULT_CHUNK_S, DEFAULT_OVERLAP_S
|
|
19
|
+
|
|
20
|
+
from clear_record.cli import stages
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
24
|
+
parser = argparse.ArgumentParser(
|
|
25
|
+
prog="clear-record",
|
|
26
|
+
description="clear-record: from many recordings to one clear record.",
|
|
27
|
+
)
|
|
28
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
29
|
+
|
|
30
|
+
def _paths(p: argparse.ArgumentParser) -> None:
|
|
31
|
+
p.add_argument("directory", help="workspace directory (recordings live here)")
|
|
32
|
+
p.add_argument(
|
|
33
|
+
"inputs",
|
|
34
|
+
nargs="*",
|
|
35
|
+
help="explicit audio file(s); default: scan the directory",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def _backend_args(p: argparse.ArgumentParser) -> None:
|
|
39
|
+
default_backend = next(iter(BACKENDS))
|
|
40
|
+
models_dir = resolve_models_dir()
|
|
41
|
+
p.add_argument(
|
|
42
|
+
"--backend",
|
|
43
|
+
"-b",
|
|
44
|
+
default=default_backend,
|
|
45
|
+
choices=tuple(BACKENDS),
|
|
46
|
+
help=f"ASR backend (default {default_backend})",
|
|
47
|
+
)
|
|
48
|
+
p.add_argument(
|
|
49
|
+
"--model",
|
|
50
|
+
"-m",
|
|
51
|
+
help="model checkpoint name/size, e.g. tiny/base/small/medium",
|
|
52
|
+
)
|
|
53
|
+
p.add_argument("--language", "-l", help="language hint for ASR (default auto)")
|
|
54
|
+
p.add_argument(
|
|
55
|
+
"--models-dir",
|
|
56
|
+
default=models_dir,
|
|
57
|
+
help=f"model download dir (default {models_dir})",
|
|
58
|
+
)
|
|
59
|
+
p.add_argument(
|
|
60
|
+
"--glossary",
|
|
61
|
+
help="glossary file (one term/line) used as the ASR initial prompt; "
|
|
62
|
+
"defaults to <directory>/glossary.txt if present",
|
|
63
|
+
)
|
|
64
|
+
p.add_argument(
|
|
65
|
+
"--chunk-seconds",
|
|
66
|
+
type=float,
|
|
67
|
+
default=DEFAULT_CHUNK_S,
|
|
68
|
+
help=f"chunk length for long tape transcription (default {DEFAULT_CHUNK_S:.0f}s)",
|
|
69
|
+
)
|
|
70
|
+
p.add_argument(
|
|
71
|
+
"--overlap-seconds",
|
|
72
|
+
type=float,
|
|
73
|
+
default=DEFAULT_OVERLAP_S,
|
|
74
|
+
help=f"overlap between chunks (default {DEFAULT_OVERLAP_S:.0f}s)",
|
|
75
|
+
)
|
|
76
|
+
p.add_argument(
|
|
77
|
+
"--no-resume",
|
|
78
|
+
dest="resume",
|
|
79
|
+
action="store_false",
|
|
80
|
+
help="ignore cached chunks and re-transcribe from scratch",
|
|
81
|
+
)
|
|
82
|
+
p.set_defaults(resume=True)
|
|
83
|
+
p.add_argument(
|
|
84
|
+
"--jobs",
|
|
85
|
+
"-j",
|
|
86
|
+
type=int,
|
|
87
|
+
default=0,
|
|
88
|
+
help="parallel transcription workers (0 = auto; process-isolated "
|
|
89
|
+
"backends only, e.g. the AMD/NVIDIA whisper-cli)",
|
|
90
|
+
)
|
|
91
|
+
p.add_argument(
|
|
92
|
+
"--check-plugin",
|
|
93
|
+
action="store_true",
|
|
94
|
+
help="one-shot whisper-cli load probe to confirm the ggml GPU plugin "
|
|
95
|
+
"actually loads before transcribing (opt-in; the default probe only "
|
|
96
|
+
"checks that the plugin file is present)",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def _diarize_args(p: argparse.ArgumentParser) -> None:
|
|
100
|
+
g = p.add_mutually_exclusive_group()
|
|
101
|
+
g.add_argument(
|
|
102
|
+
"--diarize",
|
|
103
|
+
dest="diarize",
|
|
104
|
+
action="store_true",
|
|
105
|
+
help="force multi-speaker diarization",
|
|
106
|
+
)
|
|
107
|
+
g.add_argument(
|
|
108
|
+
"--no-diarize",
|
|
109
|
+
dest="diarize",
|
|
110
|
+
action="store_false",
|
|
111
|
+
help="disable diarization",
|
|
112
|
+
)
|
|
113
|
+
p.set_defaults(diarize=None)
|
|
114
|
+
p.add_argument(
|
|
115
|
+
"--speakers",
|
|
116
|
+
type=int,
|
|
117
|
+
default=None,
|
|
118
|
+
help="known number of speakers (default: estimate from the audio)",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def _attribute_args(p: argparse.ArgumentParser) -> None:
|
|
122
|
+
p.add_argument(
|
|
123
|
+
"--attribute-energy",
|
|
124
|
+
action="store_true",
|
|
125
|
+
help="attribute speakers by relative source energy (close-mic "
|
|
126
|
+
"cross-talk) instead of spectral diarization",
|
|
127
|
+
)
|
|
128
|
+
p.add_argument(
|
|
129
|
+
"--mixed-source",
|
|
130
|
+
default=None,
|
|
131
|
+
help="manifest source id to use as the mixed/room reference for "
|
|
132
|
+
"energy attribution",
|
|
133
|
+
)
|
|
134
|
+
p.add_argument(
|
|
135
|
+
"--window-s",
|
|
136
|
+
dest="window_s",
|
|
137
|
+
type=float,
|
|
138
|
+
default=None,
|
|
139
|
+
help="seconds of causal history for a rolling per-source level "
|
|
140
|
+
"(tracks drifting gain); omit for the static whole-recording level",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def _common_args(p: argparse.ArgumentParser) -> None:
|
|
144
|
+
p.add_argument(
|
|
145
|
+
"--reference",
|
|
146
|
+
dest="reference",
|
|
147
|
+
help="reference source id for alignment (default: first)",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def _directory(p: argparse.ArgumentParser) -> None:
|
|
151
|
+
p.add_argument("directory", help="workspace directory (recordings live here)")
|
|
152
|
+
|
|
153
|
+
def _backend(directory_first: bool):
|
|
154
|
+
def fn(p: argparse.ArgumentParser) -> None:
|
|
155
|
+
if directory_first:
|
|
156
|
+
_directory(p)
|
|
157
|
+
_backend_args(p)
|
|
158
|
+
|
|
159
|
+
return fn
|
|
160
|
+
|
|
161
|
+
def _channel_args(p: argparse.ArgumentParser) -> None:
|
|
162
|
+
g = p.add_mutually_exclusive_group()
|
|
163
|
+
g.add_argument(
|
|
164
|
+
"--split-channels",
|
|
165
|
+
dest="split",
|
|
166
|
+
action="store_const",
|
|
167
|
+
const="split",
|
|
168
|
+
help="split every channel of a multichannel file into its own source",
|
|
169
|
+
)
|
|
170
|
+
g.add_argument(
|
|
171
|
+
"--mix-down",
|
|
172
|
+
dest="split",
|
|
173
|
+
action="store_const",
|
|
174
|
+
const="mix",
|
|
175
|
+
help="always downmix multichannel audio to mono",
|
|
176
|
+
)
|
|
177
|
+
p.set_defaults(split="auto")
|
|
178
|
+
|
|
179
|
+
# One CLI arg-builder per declared stage. The stage order, names and help
|
|
180
|
+
# text come from the spec; only the flags (what a stage accepts) are local.
|
|
181
|
+
stage_args = {
|
|
182
|
+
Step.INGEST: lambda p: (_paths(p), _channel_args(p)),
|
|
183
|
+
Step.ALIGN: _directory,
|
|
184
|
+
Step.TRANSCRIBE: _backend(directory_first=True),
|
|
185
|
+
Step.RECONCILE: _directory,
|
|
186
|
+
Step.EXPORT: _directory,
|
|
187
|
+
}
|
|
188
|
+
for stage in pipeline_spec().stages:
|
|
189
|
+
p = sub.add_parser(stage.step.value, help=stage.help)
|
|
190
|
+
stage_args[stage.step](p)
|
|
191
|
+
_common_args(p)
|
|
192
|
+
|
|
193
|
+
# pipeline run: every stage above, in spec order
|
|
194
|
+
run_parser = sub.add_parser("run", help=pipeline_spec().run_help())
|
|
195
|
+
_backend(directory_first=True)(run_parser)
|
|
196
|
+
_channel_args(run_parser)
|
|
197
|
+
_diarize_args(run_parser)
|
|
198
|
+
_attribute_args(run_parser)
|
|
199
|
+
_common_args(run_parser)
|
|
200
|
+
|
|
201
|
+
# calibration convenience
|
|
202
|
+
cal = sub.add_parser(
|
|
203
|
+
"calibrate",
|
|
204
|
+
help="run the pipeline and report transcript quality against a reference if given",
|
|
205
|
+
)
|
|
206
|
+
cal.add_argument("directory", help="workspace directory")
|
|
207
|
+
_backend_args(cal)
|
|
208
|
+
_channel_args(cal)
|
|
209
|
+
_diarize_args(cal)
|
|
210
|
+
_attribute_args(cal)
|
|
211
|
+
_common_args(cal)
|
|
212
|
+
cal.add_argument(
|
|
213
|
+
"--reference-transcript",
|
|
214
|
+
help="a reference transcript text file to compare (WER/similarity)",
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# diarization (multi-speaker attribution for a single mixed stream)
|
|
218
|
+
dia = sub.add_parser(
|
|
219
|
+
"diarize", help="assign speaker labels to already-transcribed segments"
|
|
220
|
+
)
|
|
221
|
+
dia.add_argument("directory", help="workspace directory")
|
|
222
|
+
_diarize_args(dia)
|
|
223
|
+
|
|
224
|
+
# cross-talk-aware attribution (close mics hear more than one speaker)
|
|
225
|
+
attr = sub.add_parser(
|
|
226
|
+
"attribute",
|
|
227
|
+
help="re-attribute speakers by relative source energy (close-mic cross-talk)",
|
|
228
|
+
)
|
|
229
|
+
attr.add_argument("directory", help="workspace directory")
|
|
230
|
+
attr.add_argument(
|
|
231
|
+
"--mixed-source",
|
|
232
|
+
default=None,
|
|
233
|
+
help="manifest source id to use as the mixed/room reference",
|
|
234
|
+
)
|
|
235
|
+
attr.add_argument(
|
|
236
|
+
"--window-s",
|
|
237
|
+
dest="window_s",
|
|
238
|
+
type=float,
|
|
239
|
+
default=None,
|
|
240
|
+
help="seconds of causal history for a rolling per-source level "
|
|
241
|
+
"(tracks drifting gain); omit for the static whole-recording level",
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# glossary (decoder initial prompt; edit while a pass runs in the background)
|
|
245
|
+
glo = sub.add_parser(
|
|
246
|
+
"glossary",
|
|
247
|
+
help="show or append to the workspace glossary (ASR initial prompt)",
|
|
248
|
+
)
|
|
249
|
+
glo.add_argument("directory", help="workspace directory")
|
|
250
|
+
glo.add_argument(
|
|
251
|
+
"--add", nargs="*", default=None, help="term(s)/phrase(s) to append"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# synthesis (owner strategy: build the badness, keep the ground truth)
|
|
255
|
+
syn = sub.add_parser(
|
|
256
|
+
"synth",
|
|
257
|
+
help="generate a clean scene + degraded per-device recordings with exact ground truth",
|
|
258
|
+
)
|
|
259
|
+
syn.add_argument("directory", help="output workspace directory")
|
|
260
|
+
syn.add_argument(
|
|
261
|
+
"--devices", type=int, default=4, help="number of recording devices (default 4)"
|
|
262
|
+
)
|
|
263
|
+
syn.add_argument(
|
|
264
|
+
"--duration",
|
|
265
|
+
type=float,
|
|
266
|
+
default=20.0,
|
|
267
|
+
help="scene duration in seconds (default 20)",
|
|
268
|
+
)
|
|
269
|
+
syn.add_argument(
|
|
270
|
+
"--speakers",
|
|
271
|
+
type=int,
|
|
272
|
+
default=4,
|
|
273
|
+
help="number of speakers in the scene (default 4)",
|
|
274
|
+
)
|
|
275
|
+
syn.add_argument("--seed", type=int, default=0, help="random seed")
|
|
276
|
+
|
|
277
|
+
# backends
|
|
278
|
+
b = sub.add_parser(
|
|
279
|
+
"backends", help="list which ASR backends are currently available"
|
|
280
|
+
)
|
|
281
|
+
b.add_argument(
|
|
282
|
+
"--all",
|
|
283
|
+
action="store_true",
|
|
284
|
+
help="list all known backends, not only available ones",
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
return parser
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _pipeline_options(args: argparse.Namespace) -> stages.PipelineOptions:
|
|
291
|
+
"""Fill the one run-options value from the parsed CLI arguments."""
|
|
292
|
+
return stages.PipelineOptions(
|
|
293
|
+
backend=args.backend,
|
|
294
|
+
model=args.model,
|
|
295
|
+
language=args.language,
|
|
296
|
+
model_dir=args.models_dir,
|
|
297
|
+
split=args.split,
|
|
298
|
+
glossary=args.glossary,
|
|
299
|
+
chunk_seconds=args.chunk_seconds,
|
|
300
|
+
overlap_seconds=args.overlap_seconds,
|
|
301
|
+
resume=args.resume,
|
|
302
|
+
do_diarize=args.diarize,
|
|
303
|
+
speakers=args.speakers,
|
|
304
|
+
reference=args.reference,
|
|
305
|
+
attribute_energy=args.attribute_energy,
|
|
306
|
+
mixed_source=args.mixed_source,
|
|
307
|
+
window_s=args.window_s,
|
|
308
|
+
jobs=args.jobs,
|
|
309
|
+
check_plugin=args.check_plugin,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _main(args: argparse.Namespace) -> int:
|
|
314
|
+
command = args.command
|
|
315
|
+
|
|
316
|
+
if command == "backends":
|
|
317
|
+
known = tuple(BACKENDS)
|
|
318
|
+
available = available_backend_ids()
|
|
319
|
+
for bid in known:
|
|
320
|
+
state = "available" if bid in available else "unavailable"
|
|
321
|
+
if not args.all and state != "available":
|
|
322
|
+
continue
|
|
323
|
+
print(f"{bid:8s} {state}")
|
|
324
|
+
return 0
|
|
325
|
+
|
|
326
|
+
if command == "synth":
|
|
327
|
+
stages.synth(
|
|
328
|
+
args.directory,
|
|
329
|
+
devices=args.devices,
|
|
330
|
+
duration_s=args.duration,
|
|
331
|
+
speakers=args.speakers,
|
|
332
|
+
seed=args.seed,
|
|
333
|
+
)
|
|
334
|
+
return 0
|
|
335
|
+
|
|
336
|
+
if command == "ingest":
|
|
337
|
+
stages.ingest(args.directory, audio_files=args.inputs or None, split=args.split)
|
|
338
|
+
return 0
|
|
339
|
+
|
|
340
|
+
if command == "align":
|
|
341
|
+
stages.align(args.directory, reference=args.reference)
|
|
342
|
+
return 0
|
|
343
|
+
|
|
344
|
+
if command == "transcribe":
|
|
345
|
+
stages.transcribe(
|
|
346
|
+
args.directory,
|
|
347
|
+
args.backend,
|
|
348
|
+
model=args.model,
|
|
349
|
+
language=args.language,
|
|
350
|
+
model_dir=args.models_dir,
|
|
351
|
+
glossary=args.glossary,
|
|
352
|
+
chunk_seconds=args.chunk_seconds,
|
|
353
|
+
overlap_seconds=args.overlap_seconds,
|
|
354
|
+
resume=args.resume,
|
|
355
|
+
jobs=args.jobs,
|
|
356
|
+
check_plugin=args.check_plugin,
|
|
357
|
+
)
|
|
358
|
+
return 0
|
|
359
|
+
|
|
360
|
+
if command == "diarize":
|
|
361
|
+
stages.diarize(args.directory, speakers=args.speakers)
|
|
362
|
+
return 0
|
|
363
|
+
|
|
364
|
+
if command == "attribute":
|
|
365
|
+
stages.attribute(
|
|
366
|
+
args.directory,
|
|
367
|
+
mixed_source=args.mixed_source,
|
|
368
|
+
window_s=args.window_s,
|
|
369
|
+
)
|
|
370
|
+
return 0
|
|
371
|
+
|
|
372
|
+
if command == "glossary":
|
|
373
|
+
stages.glossary(args.directory, add=args.add)
|
|
374
|
+
return 0
|
|
375
|
+
|
|
376
|
+
if command == "reconcile":
|
|
377
|
+
stages.reconcile(args.directory, prefer=args.reference)
|
|
378
|
+
return 0
|
|
379
|
+
|
|
380
|
+
if command == "export":
|
|
381
|
+
stages.export(args.directory)
|
|
382
|
+
return 0
|
|
383
|
+
|
|
384
|
+
if command == "run":
|
|
385
|
+
stages.run(args.directory, _pipeline_options(args))
|
|
386
|
+
return 0
|
|
387
|
+
|
|
388
|
+
if command == "calibrate":
|
|
389
|
+
stages.run(args.directory, _pipeline_options(args))
|
|
390
|
+
stages.calibrate_report(args.directory, reference=args.reference_transcript)
|
|
391
|
+
return 0
|
|
392
|
+
|
|
393
|
+
return 2
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
397
|
+
parser = _build_parser()
|
|
398
|
+
args = parser.parse_args(argv)
|
|
399
|
+
return _main(args)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
if __name__ == "__main__": # pragma: no cover - console-script path
|
|
403
|
+
raise SystemExit(main())
|
clear_record/cli/eval.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Transcript evaluation helpers used by the `calibrate` command.
|
|
2
|
+
|
|
3
|
+
These compare an ASR transcript against a reference transcript. For Latin-script
|
|
4
|
+
text we tokenize on whitespace; for CJK text we tokenize per character (no
|
|
5
|
+
reliable word segmentation without a dictionary). The error metric is a Levenshtein
|
|
6
|
+
edit distance over the token sequences, reported as an error rate (0.0 = perfect).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
_CJK = re.compile(r"[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _tokenize(text: str) -> list[str]:
|
|
17
|
+
text = " ".join(text.split()).lower()
|
|
18
|
+
if _CJK.search(text):
|
|
19
|
+
# keep CJK chars as tokens, treat runs of non-CJK as tokens
|
|
20
|
+
tokens: list[str] = []
|
|
21
|
+
for run in re.findall(
|
|
22
|
+
r"[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]+|[^\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]+",
|
|
23
|
+
text,
|
|
24
|
+
):
|
|
25
|
+
if _CJK.match(run):
|
|
26
|
+
tokens.extend(list(run))
|
|
27
|
+
elif run.strip():
|
|
28
|
+
tokens.append(run.strip())
|
|
29
|
+
return tokens
|
|
30
|
+
return text.split()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def levenshtein(a: list[str], b: list[str]) -> int:
|
|
34
|
+
"""Classic DP edit distance over token sequences."""
|
|
35
|
+
if not a:
|
|
36
|
+
return len(b)
|
|
37
|
+
if not b:
|
|
38
|
+
return len(a)
|
|
39
|
+
prev = list(range(len(b) + 1))
|
|
40
|
+
for i, ca in enumerate(a, start=1):
|
|
41
|
+
cur = [i] + [0] * len(b)
|
|
42
|
+
for j, cb in enumerate(b, start=1):
|
|
43
|
+
cur[j] = min(
|
|
44
|
+
prev[j] + 1, # delete
|
|
45
|
+
cur[j - 1] + 1, # insert
|
|
46
|
+
prev[j - 1] + (0 if ca == cb else 1), # replace/substitute
|
|
47
|
+
)
|
|
48
|
+
prev = cur
|
|
49
|
+
return prev[len(b)]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def error_rates(reference: str, hypothesis: str) -> dict[str, float]:
|
|
53
|
+
"""Return ``wer`` and ``cer``-style error rate + similarity.
|
|
54
|
+
|
|
55
|
+
``wer`` uses word/character tokens (see :func:`_tokenize`); ``similarity`` is
|
|
56
|
+
a ``1 - error`` score in [0, 1].
|
|
57
|
+
"""
|
|
58
|
+
ref_tokens = _tokenize(reference)
|
|
59
|
+
hyp_tokens = _tokenize(hypothesis)
|
|
60
|
+
err = levenshtein(ref_tokens, hyp_tokens)
|
|
61
|
+
denom = max(1, len(ref_tokens))
|
|
62
|
+
wer = err / denom
|
|
63
|
+
return {"wer": round(wer, 4), "similarity": round(1.0 - wer, 4)}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
__all__ = ["error_rates", "levenshtein", "_tokenize"]
|