rek 0.1.1__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.
- rek-0.1.1/PKG-INFO +241 -0
- rek-0.1.1/README.md +225 -0
- rek-0.1.1/pyproject.toml +66 -0
- rek-0.1.1/pyproject.toml.orig +54 -0
- rek-0.1.1/src/rek/__init__.py +5 -0
- rek-0.1.1/src/rek/audio.py +101 -0
- rek-0.1.1/src/rek/cli.py +122 -0
- rek-0.1.1/src/rek/diarization.py +101 -0
- rek-0.1.1/src/rek/model_files.py +29 -0
- rek-0.1.1/src/rek/models.py +109 -0
- rek-0.1.1/src/rek/speaker_cache.py +86 -0
- rek-0.1.1/src/rek/transcript.py +107 -0
rek-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: rek
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Local English transcription and speaker diarization with ONNX Runtime
|
|
5
|
+
Requires-Dist: huggingface-hub>=1.33.0
|
|
6
|
+
Requires-Dist: librosa>=1.0.0
|
|
7
|
+
Requires-Dist: numpy>=2.5.3
|
|
8
|
+
Requires-Dist: onnxruntime>=1.31.1
|
|
9
|
+
Requires-Dist: sherpa-onnx>=2.0.1
|
|
10
|
+
Requires-Dist: sounddevice>=0.5.6
|
|
11
|
+
Requires-Dist: soundfile>=0.14.0
|
|
12
|
+
Requires-Python: >=3.14
|
|
13
|
+
Project-URL: Repository, https://github.com/gardner/rek
|
|
14
|
+
Project-URL: Issues, https://github.com/gardner/rek/issues
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# rek
|
|
18
|
+
|
|
19
|
+
[GitHub repository](https://github.com/gardner/rek) · [Issues](https://github.com/gardner/rek/issues)
|
|
20
|
+
|
|
21
|
+
Record your microphone, then transcribe locally with timestamps and speaker labels.
|
|
22
|
+
Inference uses ONNX Runtime (with [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx) for ASR),
|
|
23
|
+
with Core ML acceleration by default on macOS and CPU inference on Linux/Windows.
|
|
24
|
+
There is no MLX, PyTorch, or CUDA dependency.
|
|
25
|
+
|
|
26
|
+
- [Parakeet TDT 0.6B v2 INT8](https://huggingface.co/csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8)
|
|
27
|
+
transcribes **English only**.
|
|
28
|
+
- [Nemotron 3 Diarization ONNX](https://huggingface.co/onnx-community/Nemotron-3-Diarization-ONNX)
|
|
29
|
+
detects up to eight speakers directly, replacing both Pyannote segmentation and
|
|
30
|
+
TitaNet embeddings/clustering. It runs in 27.2-second chunks with 3.2 seconds of
|
|
31
|
+
look-ahead and an arrival-order speaker cache carried across the full recording.
|
|
32
|
+
The NumPy/librosa frontend and cache follow the model's Transformers implementation;
|
|
33
|
+
PyTorch is not needed. Speaker activity is thresholded at 0.5 on 10 ms frames,
|
|
34
|
+
including overlapping speakers.
|
|
35
|
+
|
|
36
|
+
Nemotron uses the FP32 graph and its external weights (about 398 MB), pinned to
|
|
37
|
+
revision `353b6f8ad2cac3580e982d7fbdf0a010786b0406`. Both files are downloaded into
|
|
38
|
+
the same cache directory so ONNX Runtime can resolve the external weights.
|
|
39
|
+
The model is distributed under the [OpenMDW 1.1 license](https://huggingface.co/onnx-community/Nemotron-3-Diarization-ONNX/blob/main/README.md).
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
The dependencies are intended for macOS, Linux, and Windows. This ONNX migration
|
|
43
|
+
has **not been runtime-tested**, including on Linux or Windows.
|
|
44
|
+
|
|
45
|
+
## Run
|
|
46
|
+
|
|
47
|
+
Install [uv](https://docs.astral.sh/uv/getting-started/installation/) and FFmpeg
|
|
48
|
+
(see below). From this checkout, install the command once:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
uv tool install .
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Then just give it a filename:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
rek "Voice Memo.m4a"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
No model, language, device, speaker-count, or tuning flags are required. The command
|
|
61
|
+
downloads the models if needed, decodes and normalizes the audio, detects speakers,
|
|
62
|
+
transcribes speech, and saves both text and JSON in a new `recordings/<timestamp>/`
|
|
63
|
+
directory under your current working directory. It prints the transcript and output
|
|
64
|
+
location when finished. First use needs internet access for the model downloads.
|
|
65
|
+
If your shell cannot find `rek` after installation, run `uv tool update-shell`
|
|
66
|
+
and reopen your terminal.
|
|
67
|
+
|
|
68
|
+
You can also run directly from the checkout without installing the command:
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
uv run main.py "Voice Memo.m4a"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The defaults are:
|
|
75
|
+
|
|
76
|
+
| Setting | Default |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| Hardware | Core ML on macOS; CPU on Linux/Windows |
|
|
79
|
+
| Speech recognition | English Parakeet v2 INT8, greedy decoding |
|
|
80
|
+
| Speaker detection | Nemotron 3 FP32, up to eight speakers automatically |
|
|
81
|
+
| Diarization windows | Published offline settings: 27.2 s + 3.2 s look-ahead, persistent speaker cache |
|
|
82
|
+
| Speaker activity | Probability above 0.5, at 10 ms resolution |
|
|
83
|
+
| Transcription windows | 30 s with 2 s context on each side |
|
|
84
|
+
| Audio | Automatic mono mixing and resampling to 16 kHz |
|
|
85
|
+
| Output | Timestamped text and JSON; existing output folders are never overwritten |
|
|
86
|
+
|
|
87
|
+
These use the model's published settings and bounded inference windows; they are
|
|
88
|
+
starting defaults, not benchmark-tuned settings for a particular machine.
|
|
89
|
+
|
|
90
|
+
Run `rek` without a filename to record your default microphone until **Ctrl+C**.
|
|
91
|
+
Start speaking when you see **“Recording”**. Ctrl+C finishes saving the WAV and starts
|
|
92
|
+
transcription. There is no duration limit or `--seconds` option.
|
|
93
|
+
|
|
94
|
+
The project uses Python 3.14; uv can install it automatically. Built-in model revisions
|
|
95
|
+
are pinned. Later runs reuse the Hugging Face cache, and audio stays on your computer.
|
|
96
|
+
To relocate the cache, set `HF_HOME`. For offline use, populate the cache first,
|
|
97
|
+
then set `HF_HUB_OFFLINE=1`.
|
|
98
|
+
|
|
99
|
+
Overrides are optional, mainly for troubleshooting or changing the destination:
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
rek meeting.wav --output-dir recordings/meeting
|
|
103
|
+
rek meeting.wav --device cpu
|
|
104
|
+
rek --help
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The older `--input filename` syntax remains supported.
|
|
108
|
+
|
|
109
|
+
`--asr-model` accepts a compatible sherpa-onnx Parakeet v2 INT8 Hugging Face
|
|
110
|
+
repository or local directory containing `encoder.int8.onnx`, `decoder.int8.onnx`,
|
|
111
|
+
`joiner.int8.onnx`, and `tokens.txt`. MLX repositories are no longer compatible.
|
|
112
|
+
|
|
113
|
+
M4A (AAC or Apple Lossless), WAV, FLAC, and other formats supported by libsndfile can
|
|
114
|
+
be used as input. M4A decoding uses FFmpeg automatically, without changing the
|
|
115
|
+
original file or creating an intermediate WAV. FFmpeg must be on PATH:
|
|
116
|
+
|
|
117
|
+
- macOS: `brew install ffmpeg`
|
|
118
|
+
- Debian/Ubuntu: `sudo apt install ffmpeg`
|
|
119
|
+
- Windows: `winget install Gyan.FFmpeg` (reopen your terminal after installation)
|
|
120
|
+
|
|
121
|
+
Stereo files are mixed to mono and resampled to 16 kHz. Parakeet v2 is English-only;
|
|
122
|
+
`--language en` is optional, and other languages are rejected. Transcription starts
|
|
123
|
+
after recording ends. Long audio is decoded in 30-second windows with two seconds
|
|
124
|
+
of context on either side. Words are retained by their timestamp midpoint within
|
|
125
|
+
each window, with timestamps relative to the full recording. Chunk boundaries
|
|
126
|
+
can still affect recognition.
|
|
127
|
+
|
|
128
|
+
## Output
|
|
129
|
+
|
|
130
|
+
Each run creates a new `recordings/<timestamp>/` directory containing:
|
|
131
|
+
|
|
132
|
+
- `recording.wav`: 16 kHz mono audio (microphone mode only).
|
|
133
|
+
- `transcript.txt`: readable timestamped speaker turns, also printed to the terminal.
|
|
134
|
+
- `transcript.json`: transcript, individual word timestamps, raw speaker turns,
|
|
135
|
+
model IDs, diarization graph filename, backends, requested device, and the original audio path.
|
|
136
|
+
|
|
137
|
+
Example format:
|
|
138
|
+
|
|
139
|
+
```text
|
|
140
|
+
[00:00:00.100 - 00:00:02.300] Speaker 1: Hello, everyone.
|
|
141
|
+
[00:00:02.500 - 00:00:03.800] Speaker 2: Hi there.
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Speaker labels are anonymous and apply to one recording; they do not identify
|
|
145
|
+
people by name. Parakeet's timestamped subword pieces are joined into complete
|
|
146
|
+
words, preserving punctuation. Words are assigned to the speaker with the greatest
|
|
147
|
+
total time overlap. Ties, missing word timestamps, and unmatched words are labelled
|
|
148
|
+
`Unknown`. Token end times use TDT durations when available; otherwise they are
|
|
149
|
+
estimated from the next token's start or the end of the chunk.
|
|
150
|
+
Overlapping voices can reduce accuracy: this script does not separate mixed voices.
|
|
151
|
+
When diarization detects no speech, transcription is skipped. Microphone audio is
|
|
152
|
+
written to disk as you record; the completed recording is loaded into memory for
|
|
153
|
+
inference. CPU inference can take longer than the recording.
|
|
154
|
+
|
|
155
|
+
Existing output directories are refused to prevent overwriting recordings. If model
|
|
156
|
+
loading or inference fails, a completed microphone recording remains available;
|
|
157
|
+
retry it with `--input path/to/recording.wav`. Ctrl+C during recording saves and
|
|
158
|
+
transcribes the captured audio; Ctrl+C during model loading or transcription cancels
|
|
159
|
+
processing and preserves the recording. Stopping before any audio arrives reports
|
|
160
|
+
an empty recording instead of starting inference.
|
|
161
|
+
|
|
162
|
+
## Devices and troubleshooting
|
|
163
|
+
|
|
164
|
+
`--device auto` requests the ONNX Runtime Core ML provider on macOS for both
|
|
165
|
+
transcription and diarization, and CPU elsewhere. `--device coreml`, `--device mps`,
|
|
166
|
+
and `--device gpu` explicitly request Core ML on macOS. `--device cpu` forces CPU
|
|
167
|
+
on every platform. CUDA is not configured in this project.
|
|
168
|
+
|
|
169
|
+
[Core ML](https://onnxruntime.ai/docs/execution-providers/CoreML-ExecutionProvider.html)
|
|
170
|
+
can use Apple hardware acceleration; it is not PyTorch's MPS backend. Core ML chooses
|
|
171
|
+
the compute hardware, and unsupported ONNX operations run on CPU. In particular,
|
|
172
|
+
quantized Parakeet operations may remain on CPU: selecting Core ML does not guarantee
|
|
173
|
+
that the whole model runs on the GPU or that inference is faster. The bundled macOS
|
|
174
|
+
runtime includes Core ML support. Nemotron uses the Python ONNX Runtime package
|
|
175
|
+
and falls back to CPU with a warning if Core ML is unavailable in that runtime.
|
|
176
|
+
Custom sherpa-onnx runtimes without Core ML may also fall back to CPU.
|
|
177
|
+
The JSON `device` field records the requested provider, not measured hardware usage.
|
|
178
|
+
Core ML model compilation can add startup time. Use `--device cpu` if initialization
|
|
179
|
+
fails or acceleration performs poorly. No acceleration benchmarks were run.
|
|
180
|
+
|
|
181
|
+
If microphone capture fails, check the system's default input device and grant
|
|
182
|
+
microphone access to your terminal/app. macOS and Windows sounddevice wheels include
|
|
183
|
+
PortAudio. Linux may need `libportaudio2` and `libsndfile1` installed using the system
|
|
184
|
+
package manager. If a device does not support 16 kHz mono capture, record a file
|
|
185
|
+
with another recorder and pass it with `--input`.
|
|
186
|
+
|
|
187
|
+
## Development
|
|
188
|
+
|
|
189
|
+
```sh
|
|
190
|
+
uv run pytest
|
|
191
|
+
uv run ruff check .
|
|
192
|
+
uv run ruff format --check .
|
|
193
|
+
uv run pyright
|
|
194
|
+
uv run python scripts/check_file_length.py
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Tests use synthetic audio and mocked recording/inference; they do not access a
|
|
198
|
+
microphone or download model weights. M4A integration tests generate real AAC and
|
|
199
|
+
ALAC files and are skipped when FFmpeg is unavailable. Tests were updated for ONNX
|
|
200
|
+
but deliberately not run during the migration. Ruff enforces annotations and a
|
|
201
|
+
complexity limit of 10, Pyright checks types, and source files are limited to 500 lines.
|
|
202
|
+
|
|
203
|
+
## Publishing
|
|
204
|
+
|
|
205
|
+
Version tags publish the `rek` distribution through GitHub Actions OIDC. No PyPI API
|
|
206
|
+
token or password is stored in GitHub. Configure a PyPI Trusted Publisher with:
|
|
207
|
+
|
|
208
|
+
| Field | Value |
|
|
209
|
+
| --- | --- |
|
|
210
|
+
| PyPI project | `rek` |
|
|
211
|
+
| GitHub owner | `gardner` |
|
|
212
|
+
| GitHub repository | `rek` |
|
|
213
|
+
| Workflow filename | `publish.yml` |
|
|
214
|
+
| GitHub environment | `pypi` |
|
|
215
|
+
|
|
216
|
+
Create the `pypi` environment in the repository settings. For the first upload,
|
|
217
|
+
register a pending publisher in your PyPI account's Publishing settings. See the
|
|
218
|
+
[PyPI Trusted Publishing guide](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/).
|
|
219
|
+
|
|
220
|
+
To release, update `version` in `pyproject.toml`, run `uv lock`, and commit and push
|
|
221
|
+
the changes. Create and push a matching version tag:
|
|
222
|
+
|
|
223
|
+
```sh
|
|
224
|
+
git tag -a v0.1.1 -m "Release rek 0.1.1"
|
|
225
|
+
git push origin v0.1.1
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
This example assumes `version = "0.1.1"` has already been committed.
|
|
229
|
+
The workflow checks that the tag matches the package version, builds a wheel and
|
|
230
|
+
source distribution, and publishes them from a separate job with `id-token: write`.
|
|
231
|
+
Pushing a `v*` tag triggers publishing automatically; no GitHub release is required.
|
|
232
|
+
Ordinary branch pushes do not publish. The tag must point to a commit containing
|
|
233
|
+
this tag-triggered workflow; tags pushed before that change are not retriggered.
|
|
234
|
+
Each new PyPI release needs a new version. The workflow does not run tests.
|
|
235
|
+
|
|
236
|
+
Once published, users can install and run the command with:
|
|
237
|
+
|
|
238
|
+
```sh
|
|
239
|
+
uv tool install rek
|
|
240
|
+
rek "Voice Memo.m4a"
|
|
241
|
+
```
|
rek-0.1.1/README.md
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# rek
|
|
2
|
+
|
|
3
|
+
[GitHub repository](https://github.com/gardner/rek) · [Issues](https://github.com/gardner/rek/issues)
|
|
4
|
+
|
|
5
|
+
Record your microphone, then transcribe locally with timestamps and speaker labels.
|
|
6
|
+
Inference uses ONNX Runtime (with [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx) for ASR),
|
|
7
|
+
with Core ML acceleration by default on macOS and CPU inference on Linux/Windows.
|
|
8
|
+
There is no MLX, PyTorch, or CUDA dependency.
|
|
9
|
+
|
|
10
|
+
- [Parakeet TDT 0.6B v2 INT8](https://huggingface.co/csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8)
|
|
11
|
+
transcribes **English only**.
|
|
12
|
+
- [Nemotron 3 Diarization ONNX](https://huggingface.co/onnx-community/Nemotron-3-Diarization-ONNX)
|
|
13
|
+
detects up to eight speakers directly, replacing both Pyannote segmentation and
|
|
14
|
+
TitaNet embeddings/clustering. It runs in 27.2-second chunks with 3.2 seconds of
|
|
15
|
+
look-ahead and an arrival-order speaker cache carried across the full recording.
|
|
16
|
+
The NumPy/librosa frontend and cache follow the model's Transformers implementation;
|
|
17
|
+
PyTorch is not needed. Speaker activity is thresholded at 0.5 on 10 ms frames,
|
|
18
|
+
including overlapping speakers.
|
|
19
|
+
|
|
20
|
+
Nemotron uses the FP32 graph and its external weights (about 398 MB), pinned to
|
|
21
|
+
revision `353b6f8ad2cac3580e982d7fbdf0a010786b0406`. Both files are downloaded into
|
|
22
|
+
the same cache directory so ONNX Runtime can resolve the external weights.
|
|
23
|
+
The model is distributed under the [OpenMDW 1.1 license](https://huggingface.co/onnx-community/Nemotron-3-Diarization-ONNX/blob/main/README.md).
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
The dependencies are intended for macOS, Linux, and Windows. This ONNX migration
|
|
27
|
+
has **not been runtime-tested**, including on Linux or Windows.
|
|
28
|
+
|
|
29
|
+
## Run
|
|
30
|
+
|
|
31
|
+
Install [uv](https://docs.astral.sh/uv/getting-started/installation/) and FFmpeg
|
|
32
|
+
(see below). From this checkout, install the command once:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
uv tool install .
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Then just give it a filename:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
rek "Voice Memo.m4a"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
No model, language, device, speaker-count, or tuning flags are required. The command
|
|
45
|
+
downloads the models if needed, decodes and normalizes the audio, detects speakers,
|
|
46
|
+
transcribes speech, and saves both text and JSON in a new `recordings/<timestamp>/`
|
|
47
|
+
directory under your current working directory. It prints the transcript and output
|
|
48
|
+
location when finished. First use needs internet access for the model downloads.
|
|
49
|
+
If your shell cannot find `rek` after installation, run `uv tool update-shell`
|
|
50
|
+
and reopen your terminal.
|
|
51
|
+
|
|
52
|
+
You can also run directly from the checkout without installing the command:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
uv run main.py "Voice Memo.m4a"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The defaults are:
|
|
59
|
+
|
|
60
|
+
| Setting | Default |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| Hardware | Core ML on macOS; CPU on Linux/Windows |
|
|
63
|
+
| Speech recognition | English Parakeet v2 INT8, greedy decoding |
|
|
64
|
+
| Speaker detection | Nemotron 3 FP32, up to eight speakers automatically |
|
|
65
|
+
| Diarization windows | Published offline settings: 27.2 s + 3.2 s look-ahead, persistent speaker cache |
|
|
66
|
+
| Speaker activity | Probability above 0.5, at 10 ms resolution |
|
|
67
|
+
| Transcription windows | 30 s with 2 s context on each side |
|
|
68
|
+
| Audio | Automatic mono mixing and resampling to 16 kHz |
|
|
69
|
+
| Output | Timestamped text and JSON; existing output folders are never overwritten |
|
|
70
|
+
|
|
71
|
+
These use the model's published settings and bounded inference windows; they are
|
|
72
|
+
starting defaults, not benchmark-tuned settings for a particular machine.
|
|
73
|
+
|
|
74
|
+
Run `rek` without a filename to record your default microphone until **Ctrl+C**.
|
|
75
|
+
Start speaking when you see **“Recording”**. Ctrl+C finishes saving the WAV and starts
|
|
76
|
+
transcription. There is no duration limit or `--seconds` option.
|
|
77
|
+
|
|
78
|
+
The project uses Python 3.14; uv can install it automatically. Built-in model revisions
|
|
79
|
+
are pinned. Later runs reuse the Hugging Face cache, and audio stays on your computer.
|
|
80
|
+
To relocate the cache, set `HF_HOME`. For offline use, populate the cache first,
|
|
81
|
+
then set `HF_HUB_OFFLINE=1`.
|
|
82
|
+
|
|
83
|
+
Overrides are optional, mainly for troubleshooting or changing the destination:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
rek meeting.wav --output-dir recordings/meeting
|
|
87
|
+
rek meeting.wav --device cpu
|
|
88
|
+
rek --help
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The older `--input filename` syntax remains supported.
|
|
92
|
+
|
|
93
|
+
`--asr-model` accepts a compatible sherpa-onnx Parakeet v2 INT8 Hugging Face
|
|
94
|
+
repository or local directory containing `encoder.int8.onnx`, `decoder.int8.onnx`,
|
|
95
|
+
`joiner.int8.onnx`, and `tokens.txt`. MLX repositories are no longer compatible.
|
|
96
|
+
|
|
97
|
+
M4A (AAC or Apple Lossless), WAV, FLAC, and other formats supported by libsndfile can
|
|
98
|
+
be used as input. M4A decoding uses FFmpeg automatically, without changing the
|
|
99
|
+
original file or creating an intermediate WAV. FFmpeg must be on PATH:
|
|
100
|
+
|
|
101
|
+
- macOS: `brew install ffmpeg`
|
|
102
|
+
- Debian/Ubuntu: `sudo apt install ffmpeg`
|
|
103
|
+
- Windows: `winget install Gyan.FFmpeg` (reopen your terminal after installation)
|
|
104
|
+
|
|
105
|
+
Stereo files are mixed to mono and resampled to 16 kHz. Parakeet v2 is English-only;
|
|
106
|
+
`--language en` is optional, and other languages are rejected. Transcription starts
|
|
107
|
+
after recording ends. Long audio is decoded in 30-second windows with two seconds
|
|
108
|
+
of context on either side. Words are retained by their timestamp midpoint within
|
|
109
|
+
each window, with timestamps relative to the full recording. Chunk boundaries
|
|
110
|
+
can still affect recognition.
|
|
111
|
+
|
|
112
|
+
## Output
|
|
113
|
+
|
|
114
|
+
Each run creates a new `recordings/<timestamp>/` directory containing:
|
|
115
|
+
|
|
116
|
+
- `recording.wav`: 16 kHz mono audio (microphone mode only).
|
|
117
|
+
- `transcript.txt`: readable timestamped speaker turns, also printed to the terminal.
|
|
118
|
+
- `transcript.json`: transcript, individual word timestamps, raw speaker turns,
|
|
119
|
+
model IDs, diarization graph filename, backends, requested device, and the original audio path.
|
|
120
|
+
|
|
121
|
+
Example format:
|
|
122
|
+
|
|
123
|
+
```text
|
|
124
|
+
[00:00:00.100 - 00:00:02.300] Speaker 1: Hello, everyone.
|
|
125
|
+
[00:00:02.500 - 00:00:03.800] Speaker 2: Hi there.
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Speaker labels are anonymous and apply to one recording; they do not identify
|
|
129
|
+
people by name. Parakeet's timestamped subword pieces are joined into complete
|
|
130
|
+
words, preserving punctuation. Words are assigned to the speaker with the greatest
|
|
131
|
+
total time overlap. Ties, missing word timestamps, and unmatched words are labelled
|
|
132
|
+
`Unknown`. Token end times use TDT durations when available; otherwise they are
|
|
133
|
+
estimated from the next token's start or the end of the chunk.
|
|
134
|
+
Overlapping voices can reduce accuracy: this script does not separate mixed voices.
|
|
135
|
+
When diarization detects no speech, transcription is skipped. Microphone audio is
|
|
136
|
+
written to disk as you record; the completed recording is loaded into memory for
|
|
137
|
+
inference. CPU inference can take longer than the recording.
|
|
138
|
+
|
|
139
|
+
Existing output directories are refused to prevent overwriting recordings. If model
|
|
140
|
+
loading or inference fails, a completed microphone recording remains available;
|
|
141
|
+
retry it with `--input path/to/recording.wav`. Ctrl+C during recording saves and
|
|
142
|
+
transcribes the captured audio; Ctrl+C during model loading or transcription cancels
|
|
143
|
+
processing and preserves the recording. Stopping before any audio arrives reports
|
|
144
|
+
an empty recording instead of starting inference.
|
|
145
|
+
|
|
146
|
+
## Devices and troubleshooting
|
|
147
|
+
|
|
148
|
+
`--device auto` requests the ONNX Runtime Core ML provider on macOS for both
|
|
149
|
+
transcription and diarization, and CPU elsewhere. `--device coreml`, `--device mps`,
|
|
150
|
+
and `--device gpu` explicitly request Core ML on macOS. `--device cpu` forces CPU
|
|
151
|
+
on every platform. CUDA is not configured in this project.
|
|
152
|
+
|
|
153
|
+
[Core ML](https://onnxruntime.ai/docs/execution-providers/CoreML-ExecutionProvider.html)
|
|
154
|
+
can use Apple hardware acceleration; it is not PyTorch's MPS backend. Core ML chooses
|
|
155
|
+
the compute hardware, and unsupported ONNX operations run on CPU. In particular,
|
|
156
|
+
quantized Parakeet operations may remain on CPU: selecting Core ML does not guarantee
|
|
157
|
+
that the whole model runs on the GPU or that inference is faster. The bundled macOS
|
|
158
|
+
runtime includes Core ML support. Nemotron uses the Python ONNX Runtime package
|
|
159
|
+
and falls back to CPU with a warning if Core ML is unavailable in that runtime.
|
|
160
|
+
Custom sherpa-onnx runtimes without Core ML may also fall back to CPU.
|
|
161
|
+
The JSON `device` field records the requested provider, not measured hardware usage.
|
|
162
|
+
Core ML model compilation can add startup time. Use `--device cpu` if initialization
|
|
163
|
+
fails or acceleration performs poorly. No acceleration benchmarks were run.
|
|
164
|
+
|
|
165
|
+
If microphone capture fails, check the system's default input device and grant
|
|
166
|
+
microphone access to your terminal/app. macOS and Windows sounddevice wheels include
|
|
167
|
+
PortAudio. Linux may need `libportaudio2` and `libsndfile1` installed using the system
|
|
168
|
+
package manager. If a device does not support 16 kHz mono capture, record a file
|
|
169
|
+
with another recorder and pass it with `--input`.
|
|
170
|
+
|
|
171
|
+
## Development
|
|
172
|
+
|
|
173
|
+
```sh
|
|
174
|
+
uv run pytest
|
|
175
|
+
uv run ruff check .
|
|
176
|
+
uv run ruff format --check .
|
|
177
|
+
uv run pyright
|
|
178
|
+
uv run python scripts/check_file_length.py
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Tests use synthetic audio and mocked recording/inference; they do not access a
|
|
182
|
+
microphone or download model weights. M4A integration tests generate real AAC and
|
|
183
|
+
ALAC files and are skipped when FFmpeg is unavailable. Tests were updated for ONNX
|
|
184
|
+
but deliberately not run during the migration. Ruff enforces annotations and a
|
|
185
|
+
complexity limit of 10, Pyright checks types, and source files are limited to 500 lines.
|
|
186
|
+
|
|
187
|
+
## Publishing
|
|
188
|
+
|
|
189
|
+
Version tags publish the `rek` distribution through GitHub Actions OIDC. No PyPI API
|
|
190
|
+
token or password is stored in GitHub. Configure a PyPI Trusted Publisher with:
|
|
191
|
+
|
|
192
|
+
| Field | Value |
|
|
193
|
+
| --- | --- |
|
|
194
|
+
| PyPI project | `rek` |
|
|
195
|
+
| GitHub owner | `gardner` |
|
|
196
|
+
| GitHub repository | `rek` |
|
|
197
|
+
| Workflow filename | `publish.yml` |
|
|
198
|
+
| GitHub environment | `pypi` |
|
|
199
|
+
|
|
200
|
+
Create the `pypi` environment in the repository settings. For the first upload,
|
|
201
|
+
register a pending publisher in your PyPI account's Publishing settings. See the
|
|
202
|
+
[PyPI Trusted Publishing guide](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/).
|
|
203
|
+
|
|
204
|
+
To release, update `version` in `pyproject.toml`, run `uv lock`, and commit and push
|
|
205
|
+
the changes. Create and push a matching version tag:
|
|
206
|
+
|
|
207
|
+
```sh
|
|
208
|
+
git tag -a v0.1.1 -m "Release rek 0.1.1"
|
|
209
|
+
git push origin v0.1.1
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
This example assumes `version = "0.1.1"` has already been committed.
|
|
213
|
+
The workflow checks that the tag matches the package version, builds a wheel and
|
|
214
|
+
source distribution, and publishes them from a separate job with `id-token: write`.
|
|
215
|
+
Pushing a `v*` tag triggers publishing automatically; no GitHub release is required.
|
|
216
|
+
Ordinary branch pushes do not publish. The tag must point to a commit containing
|
|
217
|
+
this tag-triggered workflow; tags pushed before that change are not retriggered.
|
|
218
|
+
Each new PyPI release needs a new version. The workflow does not run tests.
|
|
219
|
+
|
|
220
|
+
Once published, users can install and run the command with:
|
|
221
|
+
|
|
222
|
+
```sh
|
|
223
|
+
uv tool install rek
|
|
224
|
+
rek "Voice Memo.m4a"
|
|
225
|
+
```
|
rek-0.1.1/pyproject.toml
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "rek"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Local English transcription and speaker diarization with ONNX Runtime"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"huggingface-hub>=1.33.0",
|
|
9
|
+
"librosa>=1.0.0",
|
|
10
|
+
"numpy>=2.5.3",
|
|
11
|
+
"onnxruntime>=1.31.1",
|
|
12
|
+
"sherpa-onnx>=2.0.1",
|
|
13
|
+
"sounddevice>=0.5.6",
|
|
14
|
+
"soundfile>=0.14.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Repository = "https://github.com/gardner/rek"
|
|
19
|
+
Issues = "https://github.com/gardner/rek/issues"
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
rek = "rek:main"
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["uv_build>=0.12.22,<0.13.0"]
|
|
26
|
+
build-backend = "uv_build"
|
|
27
|
+
|
|
28
|
+
[dependency-groups]
|
|
29
|
+
dev = [
|
|
30
|
+
"pyright>=1.1.414",
|
|
31
|
+
"pytest>=9.1.1",
|
|
32
|
+
"ruff>=0.16.10",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[tool.ruff]
|
|
36
|
+
line-length = 100
|
|
37
|
+
target-version = "py314"
|
|
38
|
+
|
|
39
|
+
[tool.ruff.lint]
|
|
40
|
+
select = [
|
|
41
|
+
"E",
|
|
42
|
+
"F",
|
|
43
|
+
"I",
|
|
44
|
+
"UP",
|
|
45
|
+
"B",
|
|
46
|
+
"C90",
|
|
47
|
+
"ANN",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[tool.ruff.lint.mccabe]
|
|
51
|
+
max-complexity = 10
|
|
52
|
+
|
|
53
|
+
[tool.pyright]
|
|
54
|
+
include = [
|
|
55
|
+
"main.py",
|
|
56
|
+
"src",
|
|
57
|
+
"scripts",
|
|
58
|
+
]
|
|
59
|
+
typeCheckingMode = "basic"
|
|
60
|
+
strict = ["src/rek/transcript.py"]
|
|
61
|
+
pythonVersion = "3.14"
|
|
62
|
+
venvPath = "."
|
|
63
|
+
venv = ".venv"
|
|
64
|
+
|
|
65
|
+
[tool.pytest.ini_options]
|
|
66
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "rek"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Local English transcription and speaker diarization with ONNX Runtime"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"huggingface-hub>=1.33.0",
|
|
9
|
+
"librosa>=1.0.0",
|
|
10
|
+
"numpy>=2.5.3",
|
|
11
|
+
"onnxruntime>=1.31.1",
|
|
12
|
+
"sherpa-onnx>=2.0.1",
|
|
13
|
+
"sounddevice>=0.5.6",
|
|
14
|
+
"soundfile>=0.14.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Repository = "https://github.com/gardner/rek"
|
|
19
|
+
Issues = "https://github.com/gardner/rek/issues"
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
rek = "rek:main"
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["uv_build>=0.12.22,<0.13.0"]
|
|
26
|
+
build-backend = "uv_build"
|
|
27
|
+
|
|
28
|
+
[dependency-groups]
|
|
29
|
+
dev = [
|
|
30
|
+
"pyright>=1.1.414",
|
|
31
|
+
"pytest>=9.1.1",
|
|
32
|
+
"ruff>=0.16.10",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[tool.ruff]
|
|
36
|
+
line-length = 100
|
|
37
|
+
target-version = "py314"
|
|
38
|
+
|
|
39
|
+
[tool.ruff.lint]
|
|
40
|
+
select = ["E", "F", "I", "UP", "B", "C90", "ANN"]
|
|
41
|
+
|
|
42
|
+
[tool.ruff.lint.mccabe]
|
|
43
|
+
max-complexity = 10
|
|
44
|
+
|
|
45
|
+
[tool.pyright]
|
|
46
|
+
include = ["main.py", "src", "scripts"]
|
|
47
|
+
typeCheckingMode = "basic"
|
|
48
|
+
strict = ["src/rek/transcript.py"]
|
|
49
|
+
pythonVersion = "3.14"
|
|
50
|
+
venvPath = "."
|
|
51
|
+
venv = ".venv"
|
|
52
|
+
|
|
53
|
+
[tool.pytest.ini_options]
|
|
54
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Capture or normalize audio to the models' common 16 kHz mono input."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import numpy.typing as npt
|
|
9
|
+
import soundfile as sf
|
|
10
|
+
|
|
11
|
+
SAMPLE_RATE = 16_000
|
|
12
|
+
type Audio = npt.NDArray[np.float32]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def record_audio(path: Path) -> Audio:
|
|
16
|
+
"""Write microphone blocks to disk until Ctrl+C, then return the saved audio."""
|
|
17
|
+
import sounddevice as sd
|
|
18
|
+
|
|
19
|
+
overflowed = False
|
|
20
|
+
try:
|
|
21
|
+
with (
|
|
22
|
+
sf.SoundFile(
|
|
23
|
+
path, mode="x", samplerate=SAMPLE_RATE, channels=1, subtype="PCM_16"
|
|
24
|
+
) as wav,
|
|
25
|
+
sd.InputStream(samplerate=SAMPLE_RATE, channels=1, dtype="float32") as stream,
|
|
26
|
+
):
|
|
27
|
+
print("Recording. Speak now! Press Ctrl+C to stop and transcribe.", flush=True)
|
|
28
|
+
try:
|
|
29
|
+
while True:
|
|
30
|
+
samples, dropped = stream.read(SAMPLE_RATE // 10)
|
|
31
|
+
overflowed |= dropped
|
|
32
|
+
wav.write(samples)
|
|
33
|
+
except KeyboardInterrupt:
|
|
34
|
+
pass # Ctrl+C finishes this recording; model inference continues below.
|
|
35
|
+
except sd.PortAudioError as error:
|
|
36
|
+
raise RuntimeError(
|
|
37
|
+
"Could not record audio. Check your default microphone and microphone permissions."
|
|
38
|
+
) from error
|
|
39
|
+
if overflowed:
|
|
40
|
+
print("Warning: microphone input overflowed; some audio was lost.", file=sys.stderr)
|
|
41
|
+
if sf.info(path).frames == 0:
|
|
42
|
+
raise ValueError("No audio was recorded. Start again and speak before pressing Ctrl+C.")
|
|
43
|
+
print(f"\nRecording stopped. Audio saved to {path}", flush=True)
|
|
44
|
+
return load_audio(path)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _load_m4a(path: Path) -> Audio:
|
|
48
|
+
"""Decode AAC or ALAC in memory without creating an intermediate WAV."""
|
|
49
|
+
try:
|
|
50
|
+
result = subprocess.run(
|
|
51
|
+
[
|
|
52
|
+
"ffmpeg",
|
|
53
|
+
"-nostdin",
|
|
54
|
+
"-v",
|
|
55
|
+
"error",
|
|
56
|
+
"-i",
|
|
57
|
+
str(path.resolve()),
|
|
58
|
+
"-map",
|
|
59
|
+
"0:a:0",
|
|
60
|
+
"-f",
|
|
61
|
+
"f32le",
|
|
62
|
+
"-acodec",
|
|
63
|
+
"pcm_f32le",
|
|
64
|
+
"-ar",
|
|
65
|
+
str(SAMPLE_RATE),
|
|
66
|
+
"-ac",
|
|
67
|
+
"1",
|
|
68
|
+
"-rematrix_maxval",
|
|
69
|
+
"1",
|
|
70
|
+
"pipe:1",
|
|
71
|
+
],
|
|
72
|
+
check=True,
|
|
73
|
+
capture_output=True,
|
|
74
|
+
)
|
|
75
|
+
except FileNotFoundError as error:
|
|
76
|
+
raise RuntimeError(
|
|
77
|
+
"M4A input requires FFmpeg on PATH. Install FFmpeg with your system package manager "
|
|
78
|
+
"(macOS: brew install ffmpeg; Debian/Ubuntu: sudo apt install ffmpeg; "
|
|
79
|
+
"Windows: winget install Gyan.FFmpeg)."
|
|
80
|
+
) from error
|
|
81
|
+
except subprocess.CalledProcessError as error:
|
|
82
|
+
detail = error.stderr.decode(errors="replace").strip()
|
|
83
|
+
raise ValueError(f"Could not decode M4A audio: {detail}") from error
|
|
84
|
+
if not result.stdout:
|
|
85
|
+
raise ValueError("The audio file is empty.")
|
|
86
|
+
return np.frombuffer(result.stdout, dtype="<f4").astype(np.float32)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_audio(path: Path) -> Audio:
|
|
90
|
+
"""Load M4A or a soundfile-supported file as 16 kHz mono audio."""
|
|
91
|
+
if path.suffix.lower() == ".m4a":
|
|
92
|
+
return _load_m4a(path)
|
|
93
|
+
samples, sample_rate = sf.read(path, dtype="float32", always_2d=True)
|
|
94
|
+
if samples.size == 0:
|
|
95
|
+
raise ValueError("The audio file is empty.")
|
|
96
|
+
audio = np.asarray(samples.mean(axis=1), dtype=np.float32)
|
|
97
|
+
if sample_rate != SAMPLE_RATE:
|
|
98
|
+
import librosa
|
|
99
|
+
|
|
100
|
+
audio = librosa.resample(audio, orig_sr=sample_rate, target_sr=SAMPLE_RATE)
|
|
101
|
+
return np.asarray(audio, dtype=np.float32)
|
rek-0.1.1/src/rek/cli.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""A small command-line interface around recording and local model inference."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import cast
|
|
10
|
+
|
|
11
|
+
from rek.audio import load_audio, record_audio
|
|
12
|
+
from rek.models import (
|
|
13
|
+
DEFAULT_ASR_MODEL,
|
|
14
|
+
DIARIZATION_MODEL,
|
|
15
|
+
Device,
|
|
16
|
+
diarize,
|
|
17
|
+
resolve_device,
|
|
18
|
+
transcribe,
|
|
19
|
+
)
|
|
20
|
+
from rek.transcript import align_words, render_transcript
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Options:
|
|
25
|
+
input: Path | None
|
|
26
|
+
output_dir: Path | None
|
|
27
|
+
device: Device
|
|
28
|
+
asr_model: str
|
|
29
|
+
language: str | None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_args(argv: list[str] | None = None) -> Options:
|
|
33
|
+
parser = argparse.ArgumentParser(
|
|
34
|
+
prog="rek",
|
|
35
|
+
description="Transcribe an audio file with timestamps and speaker labels. "
|
|
36
|
+
"Without a file, record your microphone until Ctrl+C.",
|
|
37
|
+
epilog="Example: rek 'Voice Memo.m4a'. Models download automatically on first use. "
|
|
38
|
+
"Defaults: English, Nemotron speaker detection, Parakeet transcription, "
|
|
39
|
+
"Core ML on macOS and CPU elsewhere.",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument("file", nargs="?", type=Path, help="Audio file, including M4A.")
|
|
42
|
+
parser.add_argument("--input", type=Path, help=argparse.SUPPRESS)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--output-dir", type=Path, help="New output directory (default: recordings/<time>)."
|
|
45
|
+
)
|
|
46
|
+
advanced = parser.add_argument_group("optional overrides (defaults need no flags)")
|
|
47
|
+
advanced.add_argument(
|
|
48
|
+
"--device",
|
|
49
|
+
choices=["auto", "gpu", "cpu", "cuda", "mps", "coreml"],
|
|
50
|
+
default="auto",
|
|
51
|
+
help="ONNX provider (auto: Core ML on macOS, CPU elsewhere; mps/gpu alias Core ML).",
|
|
52
|
+
)
|
|
53
|
+
advanced.add_argument(
|
|
54
|
+
"--asr-model",
|
|
55
|
+
default=DEFAULT_ASR_MODEL,
|
|
56
|
+
help="Sherpa ONNX Parakeet v2 INT8 repository ID or local model directory.",
|
|
57
|
+
)
|
|
58
|
+
advanced.add_argument(
|
|
59
|
+
"--language",
|
|
60
|
+
type=str.lower,
|
|
61
|
+
choices=["en", "english"],
|
|
62
|
+
default="en",
|
|
63
|
+
help="Parakeet v2 is English-only (default: en).",
|
|
64
|
+
)
|
|
65
|
+
args = parser.parse_args(argv)
|
|
66
|
+
if args.file is not None and args.input is not None:
|
|
67
|
+
parser.error("Specify the audio file once: either a filename or --input, not both.")
|
|
68
|
+
options = Options(
|
|
69
|
+
args.file if args.file is not None else args.input,
|
|
70
|
+
args.output_dir,
|
|
71
|
+
cast(Device, args.device),
|
|
72
|
+
args.asr_model,
|
|
73
|
+
args.language,
|
|
74
|
+
)
|
|
75
|
+
if options.input is not None and not options.input.is_file():
|
|
76
|
+
parser.error(f"Audio file does not exist: {options.input}")
|
|
77
|
+
return options
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def run(options: Options) -> Path:
|
|
81
|
+
device = resolve_device(options.device)
|
|
82
|
+
output_dir = options.output_dir or Path("recordings") / datetime.now().strftime(
|
|
83
|
+
"%Y%m%d-%H%M%S-%f"
|
|
84
|
+
)
|
|
85
|
+
output_dir.mkdir(parents=True, exist_ok=False)
|
|
86
|
+
audio_path = options.input or output_dir / "recording.wav"
|
|
87
|
+
audio = load_audio(audio_path) if options.input else record_audio(audio_path)
|
|
88
|
+
turns = diarize(audio, device)
|
|
89
|
+
# Skip ASR when diarization detects no speech.
|
|
90
|
+
words = transcribe(audio, device, options.asr_model, options.language) if turns else []
|
|
91
|
+
utterances = align_words(words, turns)
|
|
92
|
+
text = render_transcript(utterances)
|
|
93
|
+
(output_dir / "transcript.txt").write_text(text, encoding="utf-8")
|
|
94
|
+
data = {
|
|
95
|
+
"audio_file": str(audio_path.resolve()),
|
|
96
|
+
"diarization_model": DIARIZATION_MODEL,
|
|
97
|
+
"backend": "onnxruntime",
|
|
98
|
+
"asr_backend": "sherpa-onnx",
|
|
99
|
+
"diarization_file": "onnx/model.onnx",
|
|
100
|
+
"device": device,
|
|
101
|
+
"asr_model": options.asr_model,
|
|
102
|
+
"speaker_turns": [asdict(turn) for turn in turns],
|
|
103
|
+
"words": [asdict(word) for word in words],
|
|
104
|
+
"transcript": [asdict(utterance) for utterance in utterances],
|
|
105
|
+
}
|
|
106
|
+
(output_dir / "transcript.json").write_text(
|
|
107
|
+
json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
|
108
|
+
)
|
|
109
|
+
print(f"\n{text}\nResults saved to {output_dir.resolve()}")
|
|
110
|
+
return output_dir
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def main(argv: list[str] | None = None) -> None:
|
|
114
|
+
options = parse_args(argv)
|
|
115
|
+
try:
|
|
116
|
+
run(options)
|
|
117
|
+
except KeyboardInterrupt:
|
|
118
|
+
print("\nCancelled.", file=sys.stderr)
|
|
119
|
+
raise SystemExit(130) from None
|
|
120
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
121
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
122
|
+
raise SystemExit(1) from None
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Nemotron 3 ONNX diarization with cached speaker identity across chunks."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Literal, cast
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from rek.audio import SAMPLE_RATE, Audio
|
|
9
|
+
from rek.model_files import DIARIZATION_MODEL, model_file
|
|
10
|
+
from rek.speaker_cache import SUBSAMPLING, FloatArray, SpeakerCache
|
|
11
|
+
from rek.transcript import SpeakerTurn
|
|
12
|
+
|
|
13
|
+
_HOP = 160
|
|
14
|
+
_CHUNK_FRAMES = 340 * SUBSAMPLING
|
|
15
|
+
_LOOKAHEAD_FRAMES = 40 * SUBSAMPLING
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def mel_features(audio: Audio) -> FloatArray:
|
|
19
|
+
"""Match the export's unnormalized 128-bin log-mel frontend without Torch.
|
|
20
|
+
|
|
21
|
+
See transformers' NemotronAsrStreamingFeatureExtractor: symmetric Hann window,
|
|
22
|
+
constant centering, preemphasis, Slaney filters, and a 2**-24 log guard.
|
|
23
|
+
"""
|
|
24
|
+
import librosa
|
|
25
|
+
|
|
26
|
+
emphasized = audio.copy()
|
|
27
|
+
emphasized[1:] -= np.float32(0.97) * audio[:-1]
|
|
28
|
+
spectrum = librosa.stft(
|
|
29
|
+
emphasized,
|
|
30
|
+
n_fft=512,
|
|
31
|
+
hop_length=_HOP,
|
|
32
|
+
win_length=400,
|
|
33
|
+
window=np.hanning(400).astype(np.float32),
|
|
34
|
+
center=True,
|
|
35
|
+
pad_mode="constant",
|
|
36
|
+
)
|
|
37
|
+
filters = librosa.filters.mel(sr=SAMPLE_RATE, n_fft=512, n_mels=128, norm="slaney")
|
|
38
|
+
power = np.abs(spectrum) ** 2
|
|
39
|
+
features = np.log(filters @ power + 2**-24).T
|
|
40
|
+
# The upstream mask counts floor(samples / hop) valid frames.
|
|
41
|
+
return np.asarray(features[: len(audio) // _HOP], dtype=np.float32)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def speaker_turns(logits: FloatArray, duration: float) -> list[SpeakerTurn]:
|
|
45
|
+
"""Threshold logits at zero (sigmoid > 0.5), retaining overlapping speakers."""
|
|
46
|
+
changes = np.diff(np.pad((logits > 0).astype(np.int8), ((1, 1), (0, 0))), axis=0)
|
|
47
|
+
turns: list[SpeakerTurn] = []
|
|
48
|
+
for speaker in range(logits.shape[1]):
|
|
49
|
+
starts = np.flatnonzero(changes[:, speaker] == 1)
|
|
50
|
+
ends = np.flatnonzero(changes[:, speaker] == -1)
|
|
51
|
+
for start, end in zip(starts, ends, strict=True):
|
|
52
|
+
turns.append(
|
|
53
|
+
SpeakerTurn(
|
|
54
|
+
float(start) * 0.01, min(float(end) * 0.01, duration), f"Speaker {speaker + 1}"
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
return sorted(turns, key=lambda turn: (turn.start, turn.speaker))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def diarize(audio: Audio, device: Literal["cpu", "coreml"]) -> list[SpeakerTurn]:
|
|
61
|
+
import onnxruntime as ort
|
|
62
|
+
|
|
63
|
+
if len(audio) < _HOP:
|
|
64
|
+
return []
|
|
65
|
+
print(f"Identifying speakers with {DIARIZATION_MODEL} using {device}...", flush=True)
|
|
66
|
+
# The graph references this sibling external-data file by its exact basename.
|
|
67
|
+
graph = model_file(DIARIZATION_MODEL, "onnx/model.onnx")
|
|
68
|
+
model_file(DIARIZATION_MODEL, "onnx/model.onnx_data")
|
|
69
|
+
providers = ["CPUExecutionProvider"]
|
|
70
|
+
if device == "coreml":
|
|
71
|
+
if "CoreMLExecutionProvider" in ort.get_available_providers():
|
|
72
|
+
providers.insert(0, "CoreMLExecutionProvider")
|
|
73
|
+
else:
|
|
74
|
+
print("Core ML is unavailable in ONNX Runtime; diarization uses CPU.", file=sys.stderr)
|
|
75
|
+
options = ort.SessionOptions()
|
|
76
|
+
options.intra_op_num_threads = 4
|
|
77
|
+
session = ort.InferenceSession(graph, sess_options=options, providers=providers)
|
|
78
|
+
features = mel_features(audio)
|
|
79
|
+
cache = SpeakerCache()
|
|
80
|
+
outputs: list[FloatArray] = []
|
|
81
|
+
for start in range(0, len(features), _CHUNK_FRAMES):
|
|
82
|
+
end = min(start + _CHUNK_FRAMES, len(features))
|
|
83
|
+
chunk = features[start : end + _LOOKAHEAD_FRAMES]
|
|
84
|
+
cached = cache.embeds
|
|
85
|
+
frame_count = (len(chunk) + SUBSAMPLING - 1) // SUBSAMPLING
|
|
86
|
+
logits, embeds, silence = cast(
|
|
87
|
+
list[FloatArray],
|
|
88
|
+
session.run(
|
|
89
|
+
["logits", "chunk_embeds", "silence_embeds"],
|
|
90
|
+
{
|
|
91
|
+
"input_features": chunk[None],
|
|
92
|
+
"cached_embeds": cached[None],
|
|
93
|
+
"attention_mask": np.ones((1, len(cached) + frame_count), dtype=np.int64),
|
|
94
|
+
},
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
core_frames = (end - start + SUBSAMPLING - 1) // SUBSAMPLING
|
|
98
|
+
offset = len(cached) * SUBSAMPLING
|
|
99
|
+
outputs.append(logits[0, offset : offset + end - start])
|
|
100
|
+
cache.update(embeds[0, :core_frames], logits[0], silence)
|
|
101
|
+
return speaker_turns(np.concatenate(outputs), len(audio) / SAMPLE_RATE)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Download only required ONNX assets, or use a supplied local model directory."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
DEFAULT_ASR_MODEL = "csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8"
|
|
6
|
+
DIARIZATION_MODEL = "onnx-community/Nemotron-3-Diarization-ONNX"
|
|
7
|
+
|
|
8
|
+
# Keep the built-in exports reproducible; custom repositories use their default revision.
|
|
9
|
+
_REVISIONS = {
|
|
10
|
+
DEFAULT_ASR_MODEL: "1ab9323565ddb038682214b292f588070a538ce2",
|
|
11
|
+
DIARIZATION_MODEL: "353b6f8ad2cac3580e982d7fbdf0a010786b0406",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def model_file(model_id: str, filename: str) -> str:
|
|
16
|
+
"""Resolve an asset without downloading unrelated weights or sample recordings."""
|
|
17
|
+
directory = Path(model_id).expanduser()
|
|
18
|
+
if directory.is_dir():
|
|
19
|
+
path = directory / filename
|
|
20
|
+
if not path.is_file():
|
|
21
|
+
raise ValueError(f"Model directory is missing {filename}: {directory}")
|
|
22
|
+
return str(path.resolve())
|
|
23
|
+
|
|
24
|
+
from huggingface_hub import hf_hub_download
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
return hf_hub_download(model_id, filename, revision=_REVISIONS.get(model_id))
|
|
28
|
+
except Exception as error:
|
|
29
|
+
raise RuntimeError(f"Could not load {filename} from {model_id}: {error}") from error
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Portable ONNX speaker diarization and English Parakeet v2 transcription."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Literal, Protocol
|
|
5
|
+
|
|
6
|
+
from rek.audio import SAMPLE_RATE, Audio
|
|
7
|
+
from rek.diarization import diarize as diarize
|
|
8
|
+
from rek.model_files import (
|
|
9
|
+
DEFAULT_ASR_MODEL as DEFAULT_ASR_MODEL,
|
|
10
|
+
)
|
|
11
|
+
from rek.model_files import (
|
|
12
|
+
DIARIZATION_MODEL as DIARIZATION_MODEL,
|
|
13
|
+
)
|
|
14
|
+
from rek.model_files import (
|
|
15
|
+
model_file,
|
|
16
|
+
)
|
|
17
|
+
from rek.transcript import Word, merge_word_tokens
|
|
18
|
+
|
|
19
|
+
type Device = Literal["auto", "cpu", "gpu", "cuda", "mps", "coreml"]
|
|
20
|
+
type InferenceDevice = Literal["cpu", "coreml"]
|
|
21
|
+
_CHUNK_SAMPLES = 30 * SAMPLE_RATE
|
|
22
|
+
_CONTEXT_SAMPLES = 2 * SAMPLE_RATE
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RecognitionResult(Protocol):
|
|
26
|
+
text: str
|
|
27
|
+
tokens: list[str]
|
|
28
|
+
timestamps: list[float]
|
|
29
|
+
durations: list[float]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def resolve_device(requested: Device) -> InferenceDevice:
|
|
33
|
+
if requested == "cpu":
|
|
34
|
+
return "cpu"
|
|
35
|
+
if requested == "auto":
|
|
36
|
+
return "coreml" if sys.platform == "darwin" else "cpu"
|
|
37
|
+
if requested in {"coreml", "mps", "gpu"} and sys.platform == "darwin":
|
|
38
|
+
return "coreml"
|
|
39
|
+
raise ValueError(
|
|
40
|
+
"Core ML acceleration is available on macOS only; CUDA is not configured. "
|
|
41
|
+
"Use --device cpu or --device auto."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _result_words(result: RecognitionResult, offset: float, duration: float) -> list[Word]:
|
|
46
|
+
"""Convert SentencePiece tokens and TDT durations into absolute word timestamps."""
|
|
47
|
+
if not result.tokens or len(result.tokens) != len(result.timestamps):
|
|
48
|
+
return [Word(None, None, result.text)] if result.text.strip() else []
|
|
49
|
+
tokens: list[Word] = []
|
|
50
|
+
for index, (token, start) in enumerate(zip(result.tokens, result.timestamps, strict=True)):
|
|
51
|
+
if len(result.durations) == len(result.tokens):
|
|
52
|
+
end = start + result.durations[index]
|
|
53
|
+
else:
|
|
54
|
+
# Some exports supply starts only; use the next start as an estimate.
|
|
55
|
+
end = result.timestamps[index + 1] if index + 1 < len(result.tokens) else duration
|
|
56
|
+
start = max(0.0, min(start, duration))
|
|
57
|
+
end = max(start, min(end, duration))
|
|
58
|
+
tokens.append(Word(offset + start, offset + end, token.replace("▁", " ")))
|
|
59
|
+
return merge_word_tokens(tokens)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def transcribe(
|
|
63
|
+
audio: Audio,
|
|
64
|
+
device: InferenceDevice,
|
|
65
|
+
model_id: str = DEFAULT_ASR_MODEL,
|
|
66
|
+
language: str | None = "en",
|
|
67
|
+
) -> list[Word]:
|
|
68
|
+
if language is not None and language.lower() not in {"en", "english"}:
|
|
69
|
+
raise ValueError("Parakeet v2 supports English only.")
|
|
70
|
+
import sherpa_onnx
|
|
71
|
+
|
|
72
|
+
print(f"Transcribing English with {model_id} using ONNX provider {device}...", flush=True)
|
|
73
|
+
recognizer = sherpa_onnx.OfflineRecognizer.from_transducer(
|
|
74
|
+
encoder=model_file(model_id, "encoder.int8.onnx"),
|
|
75
|
+
decoder=model_file(model_id, "decoder.int8.onnx"),
|
|
76
|
+
joiner=model_file(model_id, "joiner.int8.onnx"),
|
|
77
|
+
tokens=model_file(model_id, "tokens.txt"),
|
|
78
|
+
model_type="nemo_transducer",
|
|
79
|
+
sample_rate=SAMPLE_RATE,
|
|
80
|
+
provider=device,
|
|
81
|
+
num_threads=4,
|
|
82
|
+
decoding_method="greedy_search",
|
|
83
|
+
)
|
|
84
|
+
words: list[Word] = []
|
|
85
|
+
for core_start in range(0, len(audio), _CHUNK_SAMPLES):
|
|
86
|
+
core_end = min(core_start + _CHUNK_SAMPLES, len(audio))
|
|
87
|
+
start = max(0, core_start - _CONTEXT_SAMPLES)
|
|
88
|
+
end = min(len(audio), core_end + _CONTEXT_SAMPLES)
|
|
89
|
+
stream = recognizer.create_stream()
|
|
90
|
+
stream.accept_waveform(SAMPLE_RATE, audio[start:end])
|
|
91
|
+
recognizer.decode_stream(stream)
|
|
92
|
+
chunk_words = _result_words(stream.result, start / SAMPLE_RATE, (end - start) / SAMPLE_RATE)
|
|
93
|
+
if any(word.start is None for word in chunk_words) and (start, end) != (
|
|
94
|
+
core_start,
|
|
95
|
+
core_end,
|
|
96
|
+
):
|
|
97
|
+
stream = recognizer.create_stream()
|
|
98
|
+
stream.accept_waveform(SAMPLE_RATE, audio[core_start:core_end])
|
|
99
|
+
recognizer.decode_stream(stream)
|
|
100
|
+
chunk_words = _result_words(
|
|
101
|
+
stream.result, core_start / SAMPLE_RATE, (core_end - core_start) / SAMPLE_RATE
|
|
102
|
+
)
|
|
103
|
+
# Each chunk owns words whose midpoint is inside its non-overlapping core.
|
|
104
|
+
for word in chunk_words:
|
|
105
|
+
if word.start is None or word.end is None:
|
|
106
|
+
words.append(word)
|
|
107
|
+
elif core_start / SAMPLE_RATE <= (word.start + word.end) / 2 < core_end / SAMPLE_RATE:
|
|
108
|
+
words.append(word)
|
|
109
|
+
return words
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Cache policy adapted from Hugging Face Transformers.
|
|
2
|
+
# Copyright 2026 The HuggingFace Inc. team. Licensed under Apache-2.0.
|
|
3
|
+
# https://www.apache.org/licenses/LICENSE-2.0
|
|
4
|
+
|
|
5
|
+
"""NumPy arrival-order speaker cache for the pinned Nemotron ONNX export.
|
|
6
|
+
|
|
7
|
+
Policy follows Hugging Face's Nemotron3DiarizationSpeakerCache:
|
|
8
|
+
https://github.com/huggingface/transformers/blob/main/src/transformers/models/nemotron3_diarization/modeling_nemotron3_diarization.py
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import numpy.typing as npt
|
|
15
|
+
|
|
16
|
+
type FloatArray = npt.NDArray[np.float32]
|
|
17
|
+
CACHE_LENGTH = 264
|
|
18
|
+
FIFO_LENGTH = 40
|
|
19
|
+
UPDATE_PERIOD = 300
|
|
20
|
+
NUM_SPEAKERS = 8
|
|
21
|
+
SUBSAMPLING = 8
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SpeakerCache:
|
|
25
|
+
"""Keep representative speaker frames plus a FIFO across offline chunks."""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self.speakers: FloatArray = np.empty((0, 512), dtype=np.float32)
|
|
29
|
+
self.probabilities: FloatArray = np.empty((0, NUM_SPEAKERS), dtype=np.float32)
|
|
30
|
+
self.fifo: FloatArray = np.empty((0, 512), dtype=np.float32)
|
|
31
|
+
self.compressed = False
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def embeds(self) -> FloatArray:
|
|
35
|
+
return np.concatenate((self.speakers, self.fifo))
|
|
36
|
+
|
|
37
|
+
def update(self, chunk: FloatArray, logits: FloatArray, silence: FloatArray) -> None:
|
|
38
|
+
"""Append core frames only; look-ahead frames must not enter the cache."""
|
|
39
|
+
probabilities = 1 / (1 + np.exp(-np.clip(logits, -80, 80)))
|
|
40
|
+
pooled = (
|
|
41
|
+
probabilities.reshape(-1, SUBSAMPLING, NUM_SPEAKERS).mean(axis=1).astype(np.float32)
|
|
42
|
+
)
|
|
43
|
+
cached = len(self.speakers)
|
|
44
|
+
fifo = np.concatenate((self.fifo, chunk))
|
|
45
|
+
if len(fifo) <= FIFO_LENGTH:
|
|
46
|
+
self.fifo = fifo
|
|
47
|
+
return
|
|
48
|
+
popped = min(len(fifo), max(UPDATE_PERIOD, len(fifo) - FIFO_LENGTH))
|
|
49
|
+
stored = self.probabilities if self.compressed else pooled[:cached]
|
|
50
|
+
self.speakers = np.concatenate((self.speakers, fifo[:popped]))
|
|
51
|
+
self.probabilities = np.concatenate((stored, pooled[cached : cached + popped]))
|
|
52
|
+
self.fifo = fifo[popped:]
|
|
53
|
+
if len(self.speakers) > CACHE_LENGTH:
|
|
54
|
+
self._compress(silence)
|
|
55
|
+
self.compressed = True
|
|
56
|
+
|
|
57
|
+
def _compress(self, silence: FloatArray) -> None:
|
|
58
|
+
scores = _frame_scores(self.probabilities)
|
|
59
|
+
scores[CACHE_LENGTH:] += 0.05
|
|
60
|
+
# Each speaker has a 32-frame budget plus one reserved silence frame.
|
|
61
|
+
for count, boost in ((24, -2 * math.log(0.5)), (48, -math.log(0.5))):
|
|
62
|
+
indices = np.argsort(scores, axis=0, kind="stable")[-count:]
|
|
63
|
+
np.add.at(scores, (indices, np.arange(NUM_SPEAKERS)), boost)
|
|
64
|
+
scores = np.concatenate((scores, np.full((1, NUM_SPEAKERS), np.inf)))
|
|
65
|
+
frames = len(scores)
|
|
66
|
+
flat = scores.T.reshape(-1)
|
|
67
|
+
selected = np.argsort(flat, kind="stable")[-CACHE_LENGTH:]
|
|
68
|
+
selected = np.where(np.isneginf(flat[selected]), frames * NUM_SPEAKERS, selected)
|
|
69
|
+
selected.sort()
|
|
70
|
+
indices = np.where(selected == frames * NUM_SPEAKERS, frames - 1, selected % frames)
|
|
71
|
+
self.speakers = np.concatenate((self.speakers, silence.reshape(1, -1)))[indices]
|
|
72
|
+
self.probabilities = np.concatenate(
|
|
73
|
+
(self.probabilities, np.zeros((1, NUM_SPEAKERS), dtype=np.float32))
|
|
74
|
+
)[indices]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _frame_scores(probabilities: FloatArray) -> FloatArray:
|
|
78
|
+
log_probabilities = np.log(np.maximum(probabilities, 0.25))
|
|
79
|
+
log_complements = np.log(np.maximum(1 - probabilities, 0.25))
|
|
80
|
+
scores = log_probabilities - log_complements + log_complements.sum(axis=1, keepdims=True)
|
|
81
|
+
scores -= math.log(0.5)
|
|
82
|
+
scores[probabilities <= 0.5] = -np.inf
|
|
83
|
+
positive = scores > 0
|
|
84
|
+
enough_positive = positive.sum(axis=0) >= 16
|
|
85
|
+
scores[(~positive) & enough_positive[None, :]] = -np.inf
|
|
86
|
+
return scores
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Match timestamped words to diarization turns and format a readable transcript."""
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from dataclasses import dataclass, replace
|
|
6
|
+
from math import isclose
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class SpeakerTurn:
|
|
11
|
+
start: float
|
|
12
|
+
end: float
|
|
13
|
+
speaker: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Word:
|
|
18
|
+
start: float | None
|
|
19
|
+
end: float | None
|
|
20
|
+
text: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Utterance:
|
|
25
|
+
start: float | None
|
|
26
|
+
end: float | None
|
|
27
|
+
speaker: str
|
|
28
|
+
text: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def merge_word_tokens(tokens: Sequence[Word]) -> list[Word]:
|
|
32
|
+
"""Join Parakeet subword pieces, preserving punctuation and word boundaries."""
|
|
33
|
+
words: list[Word] = []
|
|
34
|
+
new_word = True
|
|
35
|
+
for token in tokens:
|
|
36
|
+
if not token.text:
|
|
37
|
+
continue
|
|
38
|
+
if not token.text.strip():
|
|
39
|
+
new_word = True
|
|
40
|
+
continue
|
|
41
|
+
if new_word or token.text[0].isspace():
|
|
42
|
+
words.append(Word(token.start, token.end, " " + token.text.lstrip()))
|
|
43
|
+
else:
|
|
44
|
+
previous = words[-1]
|
|
45
|
+
words[-1] = replace(previous, end=token.end, text=previous.text + token.text)
|
|
46
|
+
new_word = False
|
|
47
|
+
return words
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _speaker_for(word: Word, turns: Sequence[SpeakerTurn]) -> str:
|
|
51
|
+
if word.start is None or word.end is None or word.end < word.start:
|
|
52
|
+
return "Unknown"
|
|
53
|
+
scores: dict[str, float] = defaultdict(float)
|
|
54
|
+
for turn in turns:
|
|
55
|
+
overlap = max(0.0, min(word.end, turn.end) - max(word.start, turn.start))
|
|
56
|
+
if word.start == word.end and turn.start <= word.start < turn.end:
|
|
57
|
+
overlap = 1.0
|
|
58
|
+
if overlap > 0:
|
|
59
|
+
scores[turn.speaker] += overlap
|
|
60
|
+
if not scores:
|
|
61
|
+
return "Unknown"
|
|
62
|
+
best = max(scores.values())
|
|
63
|
+
winners = [speaker for speaker, score in scores.items() if isclose(score, best)]
|
|
64
|
+
return winners[0] if len(winners) == 1 else "Unknown"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def align_words(words: Sequence[Word], turns: Sequence[SpeakerTurn]) -> list[Utterance]:
|
|
68
|
+
"""Assign by total temporal overlap; retain ambiguous words as Unknown."""
|
|
69
|
+
utterances: list[Utterance] = []
|
|
70
|
+
for word in words:
|
|
71
|
+
if not word.text.strip():
|
|
72
|
+
continue
|
|
73
|
+
speaker = _speaker_for(word, turns)
|
|
74
|
+
previous = utterances[-1] if utterances else None
|
|
75
|
+
if (
|
|
76
|
+
previous is not None
|
|
77
|
+
and previous.speaker == speaker
|
|
78
|
+
and previous.end is not None
|
|
79
|
+
and word.start is not None
|
|
80
|
+
and word.end is not None
|
|
81
|
+
# Adjacent Parakeet boundaries may differ by floating-point roundoff.
|
|
82
|
+
and -1e-6 <= word.start - previous.end <= 1.0
|
|
83
|
+
):
|
|
84
|
+
# Word text retains its leading space and punctuation.
|
|
85
|
+
utterances[-1] = replace(previous, end=word.end, text=previous.text + word.text)
|
|
86
|
+
else:
|
|
87
|
+
utterances.append(Utterance(word.start, word.end, speaker, word.text.strip()))
|
|
88
|
+
return utterances
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _timestamp(seconds: float | None) -> str:
|
|
92
|
+
if seconds is None:
|
|
93
|
+
return "??:??:??.???"
|
|
94
|
+
total_ms = round(seconds * 1000)
|
|
95
|
+
minutes, ms = divmod(total_ms, 60_000)
|
|
96
|
+
hours, minutes = divmod(minutes, 60)
|
|
97
|
+
seconds, ms = divmod(ms, 1000)
|
|
98
|
+
return f"{hours:02}:{minutes:02}:{seconds:02}.{ms:03}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def render_transcript(utterances: Sequence[Utterance]) -> str:
|
|
102
|
+
if not utterances:
|
|
103
|
+
return "No speech detected.\n"
|
|
104
|
+
return "".join(
|
|
105
|
+
f"[{_timestamp(line.start)} - {_timestamp(line.end)}] {line.speaker}: {line.text}\n"
|
|
106
|
+
for line in utterances
|
|
107
|
+
)
|