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.
@@ -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] = []
@@ -0,0 +1,7 @@
1
+ """clear_record.cli: the CLI implementation for the `clear-record` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from clear_record.cli.cli import main
6
+
7
+ __all__ = ["main"]
@@ -0,0 +1,4 @@
1
+ from clear_record.cli.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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())
@@ -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"]