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 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
+ ```
@@ -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,5 @@
1
+ def main() -> None:
2
+ """Run the command-line recorder."""
3
+ from rek.cli import main as run
4
+
5
+ run()
@@ -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)
@@ -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
+ )