utterpy 0.0.6__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.
@@ -0,0 +1,67 @@
1
+ # Changelog
2
+
3
+ Notable changes to the wheel, newest first. The project follows semantic
4
+ versioning; release dates are recorded by the git tags and the GitHub
5
+ releases.
6
+
7
+ ## 0.0.6
8
+
9
+ - TitaNet-small speaker evidence, from utter 0.0.6: `SpkModel(path)`
10
+ also opens a TitaNet-small directory converted by utter's
11
+ scripts/titanet_convert.py, and `dim()` is 192. Set on a recognizer,
12
+ it embeds each word's span on a thread of the recognizer's own, and
13
+ the word's entry carries `spk`, `spk_frames`, `spk_start` and
14
+ `spk_end`; partials and finals carry no top-level evidence. Needs
15
+ audio at 16 kHz or faster. One word clears a gate at 1% false accept
16
+ 81.2% of the time, against 41.6% with the x-vector, which stays for
17
+ audio below 16 kHz.
18
+ - `SpkModel.embed(data, sample_rate=16000)`: the embedding of a span of
19
+ float32 samples without a recognizer, with the GIL released.
20
+ - The acoustic model's certainty and the sound outside words, on every
21
+ partial and final, always on: `certainty_words`,
22
+ `certainty_outside`, `words_frames`, `outside_frames`, `band_db`,
23
+ `band_sd_db`, `rise_start_sample`, `rise_ms` and `rise_db`. With the
24
+ keys taken out every result is 0.0.5's byte for byte.
25
+ - A stream's heap no longer grows with its length: at 20 minutes
26
+ without a speaker model, 17.9 MiB where 0.0.5 held 246 MiB.
27
+
28
+ ## 0.0.5
29
+
30
+ ### Added
31
+
32
+ - Speaker evidence, from utter 0.0.5, with the vosk wheel's surface:
33
+ `SpkModel(path)` with `dim()`, and `KaldiRecognizer(..., spk_model=)`
34
+ or `SetSpkModel`. While one is set, a span of at least 25 pooled
35
+ 10 ms frames carries `spk`, `spk_frames`, `spk_start` and `spk_end`
36
+ at the top level and on every word entry. Without one, the output is
37
+ unchanged. The vectors are not the vosk wheel's: profiles are rebuilt
38
+ from the enrolment audio, and the wheel's vectors and thresholds do
39
+ not carry over.
40
+ - The release attaches the provenance envelope beside the Sigstore
41
+ bundle.
42
+
43
+ ## 0.0.4
44
+
45
+ ### Fixed
46
+
47
+ - The floor margin reads no span shorter than the bound, so a quiet
48
+ word's first frames after a pause no longer waive the veto or count
49
+ as silence, and a two-word command is not cut in two. From utter
50
+ 0.0.4; the binding is unchanged, and with the margin unset the
51
+ output is identical to 0.0.3.
52
+
53
+ ## 0.0.3
54
+
55
+ First release, numbered for the utter crate it carries: the wheel's
56
+ version is the crate's, and a binding-only fix is a post-release of it.
57
+ The binding: a Python binding for the utter crate with the vosk
58
+ wheel's surface, `Model` and `KaldiRecognizer`, plus the host endpoint
59
+ bound and its floor margin. Wheels for Linux x86_64 and aarch64
60
+ (manylinux 2_28 and musllinux 1.2), Windows x64 and arm64, and macOS
61
+ x86_64 and arm64, on Python 3.9 and later through the stable ABI, with
62
+ a stub and py.typed beside the module. Every wheel carries a
63
+ build-provenance attestation, and the release attaches its Sigstore
64
+ bundle. `UTTER_VERSION` and `UTTER_REVISION` name the crate the wheel
65
+ was built from. A word's `energy_dbfs` is `null` under digital silence
66
+ and `floor_dbfs` is absent while the floor is digital silence, as
67
+ utter 0.0.3 reports them.
@@ -0,0 +1,139 @@
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 4
4
+
5
+ [[package]]
6
+ name = "heck"
7
+ version = "0.5.0"
8
+ source = "registry+https://github.com/rust-lang/crates.io-index"
9
+ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
10
+
11
+ [[package]]
12
+ name = "libc"
13
+ version = "0.2.189"
14
+ source = "registry+https://github.com/rust-lang/crates.io-index"
15
+ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
16
+
17
+ [[package]]
18
+ name = "once_cell"
19
+ version = "1.21.4"
20
+ source = "registry+https://github.com/rust-lang/crates.io-index"
21
+ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
22
+
23
+ [[package]]
24
+ name = "portable-atomic"
25
+ version = "1.15.0"
26
+ source = "registry+https://github.com/rust-lang/crates.io-index"
27
+ checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
28
+
29
+ [[package]]
30
+ name = "proc-macro2"
31
+ version = "1.0.107"
32
+ source = "registry+https://github.com/rust-lang/crates.io-index"
33
+ checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
34
+ dependencies = [
35
+ "unicode-ident",
36
+ ]
37
+
38
+ [[package]]
39
+ name = "pyo3"
40
+ version = "0.29.2"
41
+ source = "registry+https://github.com/rust-lang/crates.io-index"
42
+ checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b"
43
+ dependencies = [
44
+ "libc",
45
+ "once_cell",
46
+ "portable-atomic",
47
+ "pyo3-build-config",
48
+ "pyo3-ffi",
49
+ "pyo3-macros",
50
+ ]
51
+
52
+ [[package]]
53
+ name = "pyo3-build-config"
54
+ version = "0.29.2"
55
+ source = "registry+https://github.com/rust-lang/crates.io-index"
56
+ checksum = "f41027e41b4bd03f6e60f9f417fe24a6341a6bb744edd62b6f709f2a52ea30e9"
57
+ dependencies = [
58
+ "target-lexicon",
59
+ ]
60
+
61
+ [[package]]
62
+ name = "pyo3-ffi"
63
+ version = "0.29.2"
64
+ source = "registry+https://github.com/rust-lang/crates.io-index"
65
+ checksum = "e591a95526fead067432c3b3a33fc74770b87b1e04e73671090d9c2055a2b327"
66
+ dependencies = [
67
+ "libc",
68
+ "pyo3-build-config",
69
+ ]
70
+
71
+ [[package]]
72
+ name = "pyo3-macros"
73
+ version = "0.29.2"
74
+ source = "registry+https://github.com/rust-lang/crates.io-index"
75
+ checksum = "73225868fc1cd84eef2c3c230ddb91273bf1de46aeb8a4248da76d32a0924a1c"
76
+ dependencies = [
77
+ "proc-macro2",
78
+ "pyo3-macros-backend",
79
+ "quote",
80
+ "syn",
81
+ ]
82
+
83
+ [[package]]
84
+ name = "pyo3-macros-backend"
85
+ version = "0.29.2"
86
+ source = "registry+https://github.com/rust-lang/crates.io-index"
87
+ checksum = "571575aa3749fa6216757dd47d2a3e7ef360f329a40f0666a9fbd14889024952"
88
+ dependencies = [
89
+ "heck",
90
+ "proc-macro2",
91
+ "quote",
92
+ "syn",
93
+ ]
94
+
95
+ [[package]]
96
+ name = "quote"
97
+ version = "1.0.47"
98
+ source = "registry+https://github.com/rust-lang/crates.io-index"
99
+ checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
100
+ dependencies = [
101
+ "proc-macro2",
102
+ ]
103
+
104
+ [[package]]
105
+ name = "syn"
106
+ version = "2.0.119"
107
+ source = "registry+https://github.com/rust-lang/crates.io-index"
108
+ checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
109
+ dependencies = [
110
+ "proc-macro2",
111
+ "quote",
112
+ "unicode-ident",
113
+ ]
114
+
115
+ [[package]]
116
+ name = "target-lexicon"
117
+ version = "0.13.5"
118
+ source = "registry+https://github.com/rust-lang/crates.io-index"
119
+ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
120
+
121
+ [[package]]
122
+ name = "unicode-ident"
123
+ version = "1.0.24"
124
+ source = "registry+https://github.com/rust-lang/crates.io-index"
125
+ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
126
+
127
+ [[package]]
128
+ name = "utter"
129
+ version = "0.0.6"
130
+ source = "registry+https://github.com/rust-lang/crates.io-index"
131
+ checksum = "b7c2fcc627bdb733e73bc4a79adf7e943697dc6a4cfb24e79997505675fae232"
132
+
133
+ [[package]]
134
+ name = "utterpy"
135
+ version = "0.0.6"
136
+ dependencies = [
137
+ "pyo3",
138
+ "utter",
139
+ ]
@@ -0,0 +1,26 @@
1
+ [package]
2
+ name = "utterpy"
3
+ version = "0.0.6"
4
+ edition = "2021"
5
+ rust-version = "1.93"
6
+ license = "MIT"
7
+ description = "Python bindings for utter, the zero-dependency pure-Rust streaming speech decoder for Vosk (Kaldi nnet3) models"
8
+ repository = "https://github.com/pyscape/utterpy"
9
+ readme = "README.md"
10
+
11
+ [lib]
12
+ name = "utterpy"
13
+ crate-type = ["cdylib"]
14
+
15
+ [dependencies]
16
+ pyo3 = { version = "0.29", features = ["extension-module", "abi3-py39"] }
17
+ utter = "0.0.6"
18
+
19
+
20
+ [profile.release]
21
+ opt-level = 3
22
+
23
+ [lints.clippy]
24
+ pedantic = { level = "warn", priority = -1 }
25
+ # Backticks in docs only where the bare form breaks, so the lint that demands them is off.
26
+ doc_markdown = "allow"
utterpy-0.0.6/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jared Hall
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
utterpy-0.0.6/PKG-INFO ADDED
@@ -0,0 +1,235 @@
1
+ Metadata-Version: 2.4
2
+ Name: utterpy
3
+ Version: 0.0.6
4
+ Classifier: Programming Language :: Rust
5
+ Classifier: Programming Language :: Python :: Implementation :: CPython
6
+ Classifier: Programming Language :: Python :: 3.9
7
+ Classifier: Programming Language :: Python :: 3.10
8
+ Classifier: Programming Language :: Python :: 3.11
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Classifier: Programming Language :: Python :: 3.14
12
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
13
+ Classifier: Typing :: Typed
14
+ License-File: LICENSE
15
+ Summary: Python bindings for utter, the zero-dependency pure-Rust streaming speech decoder for Vosk (Kaldi nnet3) models
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
18
+
19
+ # utterpy
20
+
21
+ [![PyPI](https://img.shields.io/pypi/v/utterpy)](https://pypi.org/project/utterpy/)
22
+ [![Python](https://img.shields.io/pypi/pyversions/utterpy)](https://pypi.org/project/utterpy/)
23
+ [![Wheels](https://github.com/pyscape/utterpy/actions/workflows/wheels.yml/badge.svg)](https://github.com/pyscape/utterpy/actions/workflows/wheels.yml)
24
+ [![CodeQL](https://github.com/pyscape/utterpy/actions/workflows/codeql.yml/badge.svg)](https://github.com/pyscape/utterpy/actions/workflows/codeql.yml)
25
+ [![cargo-deny](https://github.com/pyscape/utterpy/actions/workflows/deny.yml/badge.svg)](https://github.com/pyscape/utterpy/actions/workflows/deny.yml)
26
+ [![Scorecard](https://api.scorecard.dev/projects/github.com/pyscape/utterpy/badge)](https://scorecard.dev/viewer/?uri=github.com/pyscape/utterpy)
27
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
28
+
29
+ Python bindings for [utter](https://github.com/pyscape/utter), the
30
+ zero-dependency pure-Rust streaming speech decoder for Vosk (Kaldi
31
+ nnet3) models. Drop-in for the vosk
32
+ wheel's API: feed 16 kHz audio, read the partial after every block, the
33
+ live n-best from the beam, word times, endpoints. No C toolchain, no
34
+ Docker, no cloud.
35
+
36
+ ## Why
37
+
38
+ The vosk wheel is a C++ wrapper around Kaldi: no Windows build without
39
+ Docker, and one more field out of the decoder means a fork. utter
40
+ reimplements what a Vosk model needs, in Rust, from a stock model
41
+ directory, and is checked block for block against the wheel. Figures
42
+ are from the [benchmark pages](https://github.com/pyscape/utter/blob/main/docs/benchmarks/README.md),
43
+ paired against the wheel on identical audio.
44
+
45
+ - **Silence is in the stream.** Every partial says `[sil]` or
46
+ `[speech]` when it carries no word; every final names the rule that
47
+ closed it; every word carries its energy against the room floor and
48
+ how long it held. The wheel gives a bare string, so a phantom over
49
+ mic hiss and a spoken word look the same
50
+ ([TD-10](https://github.com/pyscape/utter/blob/main/docs/td/0010-a-wordless-reading-says-whether-a-word-has-begun.md),
51
+ [TD-11](https://github.com/pyscape/utter/blob/main/docs/td/0011-a-final-says-what-closed-it-and-how-long-its-words-held.md)).
52
+ - **Phantoms are tellable, not fewer.** On wordless audio both engines
53
+ read alike: no rank-0 word on cold silence under 92 entries, 0.56 per
54
+ minute after speech. Here each carries `stable_ms` and `energy_dbfs`,
55
+ so a stale word is told from a fresh one, and the partial stream is
56
+ as steady as the wheel's: first word later revised on 12.4% of clips
57
+ against 12.3% ([speech-commands.md](https://github.com/pyscape/utter/blob/main/docs/benchmarks/speech-commands.md)).
58
+ - **A host endpoint bound.** A final after N ms of trailing silence,
59
+ vetoed while a rival is within a margin. Median finish 866 to 660 ms
60
+ on built streams, same finishes called, no word lost or split
61
+ ([partial-states.md](https://github.com/pyscape/utter/blob/main/docs/benchmarks/partial-states.md),
62
+ [TD-8](https://github.com/pyscape/utter/blob/main/docs/td/0008-silence-is-the-hosts-gate.md)).
63
+ - **Distinct rivals with their motion** on every partial, where the
64
+ wheel's alternatives repeat readings
65
+ ([TD-9](https://github.com/pyscape/utter/blob/main/docs/td/0009-readings-carry-their-relation-and-their-leads-motion.md)).
66
+ - **Parity, not a gain, on hearing.** 91.79% against 91.49% on 11,005
67
+ clips, finals identical on 99.2%, word times to the frame, same 40 ms
68
+ first appearance, less compute
69
+ ([word-times.md](https://github.com/pyscape/utter/blob/main/docs/benchmarks/word-times.md)).
70
+ - **Pure Rust, typed.** No C toolchain, no Docker, no cloud. Wheels for
71
+ Linux, macOS and Windows, one abi3 build from Python 3.9, with a stub
72
+ and py.typed checked by stubtest.
73
+
74
+ | Speech Commands v2, 11,005 clips | Vosk 0.3.45 | utter |
75
+ |---|---|---|
76
+ | Accuracy, full grammar | 91.49% | 91.79% |
77
+ | First word shown after the clip ends, p50 / p90 | 40 / 250 ms | 40 / 250 ms |
78
+ | First word shown later revised | 12.3% | 12.4% |
79
+ | Real-time factor, decode only | 0.0168 | 0.0143 |
80
+
81
+ Pre-alpha; the decoder still moves between revisions.
82
+
83
+ ## Use
84
+
85
+ ```python
86
+ import json, utterpy
87
+
88
+ model = utterpy.Model("vosk-model-small-en-us-0.15")
89
+ rec = utterpy.KaldiRecognizer(model, 16000, json.dumps(["yes", "no", "stop"]))
90
+ rec.SetWords(True)
91
+ rec.SetPartialWords(True)
92
+ rec.SetPartialAlternatives(3)
93
+
94
+ for block in blocks_of_16k_mono_int16_pcm: # bytes, 40 ms works well
95
+ if rec.AcceptWaveform(block): # an endpoint fired
96
+ print(json.loads(rec.Result())) # {"result": [...], "text": ...}
97
+ else:
98
+ print(json.loads(rec.PartialResult())) # {"partial": ..., "partial_alternatives": [...], "partial_result": [...]}
99
+ print(json.loads(rec.FinalResult()))
100
+ ```
101
+
102
+ The method names are vosk's, and the JSON keeps vosk's layout. On top of
103
+ it: `partial_alternatives` (distinct readings alive in the beam, with
104
+ `confidence` as the negated cost), `partial_result` entries with
105
+ `start_sample`, `end_sample`, `energy_dbfs` and `stable_ms`, `[sil]` as
106
+ the reading when the best path carries no word and sits on silence and
107
+ `[speech]` when it has entered a word's phones, `endpoint` on a final
108
+ naming what closed it (`rule1` to `rule5`, `bound`, `floor`, `flush`
109
+ or `host`), `stable_ms` on each final word for the hold it had in the
110
+ partial, `floor_dbfs` for the room's noise floor, and an optional
111
+ `unknown_cost` argument to the recognizer that admits the model's
112
+ unknown-word symbol. `DecodedSample()` gives the position, in samples fed,
113
+ of the last decoded frame.
114
+
115
+ With a speaker model, `SpkModel("vosk-model-spk-0.4")` passed as
116
+ `spk_model=` or through `SetSpkModel`, every partial and final and each
117
+ word entry carries `spk`, the speaker vector of its own span, with
118
+ `spk_frames`, `spk_start` and `spk_end`, once the span holds 25 pooled
119
+ 10 ms frames. The wheel puts one vector on a final. These follow Kaldi's
120
+ network but not the wheel's normalisation or frame selection
121
+ ([TD-14](https://github.com/pyscape/utter/blob/main/docs/td/0014-every-word-carries-the-speaker-evidence-of-its-own-span.md)):
122
+ rebuild speaker profiles from your enrolment audio with utterpy, and do
123
+ not reuse vectors or thresholds from the wheel.
124
+
125
+ A TitaNet-small directory, as utter's `scripts/titanet_convert.py`
126
+ writes it, opens the same way and gives 192-number vectors
127
+ ([TD-15](https://github.com/pyscape/utter/blob/main/docs/td/0015-titanet-small-is-a-native-speaker-model.md)).
128
+ Its evidence rides the word entries only, and it is embedded between
129
+ decoder advances, so feed blocks shorter than one decoder chunk: 40 ms
130
+ works. `SpkModel.embed(data, sample_rate=16000)` embeds any span of
131
+ little-endian float32 samples in [-1, 1] without a recognizer, 25 ms to
132
+ 30 s, at 16 kHz or faster; an x-vector model raises ValueError.
133
+
134
+ ## Configure
135
+
136
+ The calls are the wheel's, so switching is the import. The setup the
137
+ pages measure:
138
+
139
+ ```python
140
+ rec = utterpy.KaldiRecognizer(model, 16000, json.dumps(grammar))
141
+ rec.SetWords(True) # word times, energy_dbfs, stable_ms on finals
142
+ rec.SetPartialWords(True) # word entries on partials, with [sil]/[speech]
143
+ rec.SetPartialAlternatives(4) # four distinct readings per partial
144
+ rec.SetMaxAlternatives(4) # n-best finals
145
+ rec.SetEndpointBound(300, 8) # host bound with veto; (ms, 0) bare, (0) off
146
+ rec.SetEndpointFloorMargin(6) # the bound reads a wordless path within 6 dB of the floor as silence
147
+ ```
148
+
149
+ Feed 40 ms blocks, 1280 bytes at 16 kHz mono int16. Two clocks: a
150
+ word shows in the partial 40 ms after it ends, the block size, for
151
+ both engines; the final arrives 866 ms after under the model's rules
152
+ and 660 ms with the bound. Compute is not the wait, at 0.04 ms per
153
+ block. The trailing `[sil]` entry shows in the partial before the
154
+ endpoint fires, so a host can act on a settled partial plus visible
155
+ silence.
156
+
157
+ - Expose the bound as one host setting with an off spelling, and
158
+ measure off against on on your own replays before retuning it.
159
+ - Model conf carries over: `frames_per_chunk` and
160
+ `min-trailing-silence` tuned for the wheel keep their effect.
161
+ - `unknown_cost` does not fight phantoms; the page measured no change
162
+ in rank-0 words. The fields and the bound are the levers.
163
+ - Grammar size sets the stale-partial rate after speech: about 0.6 per
164
+ minute at 92 entries, 3 at 150.
165
+ - Keep a trace of raw partial and final payloads. `SetLogLevel(-1)`
166
+ silences the runtime as with the wheel.
167
+
168
+ ## Silence
169
+
170
+ Host rules that have held up, each keyed off one field:
171
+
172
+ - `[sil]` and `[speech]` are no word: never dispatched, ranked or
173
+ shown. When rank 0 is wordless, offer nothing; the rivals beneath
174
+ it on floor audio are short grammar words at low confidence.
175
+ - Drop a final word with `"endpoint": "rule5"` and `"stable_ms": 0`.
176
+ That is Kaldi's 20 s length cap naming the cheapest word over
177
+ silence, every 21 s on mic hiss, on the wheel too. When the drop
178
+ empties the final, still close the utterance, or the phantom returns
179
+ as a partial. Do not widen this to `rule2`: a never-shown word there
180
+ can be a real rescored reading.
181
+ - Keep the bound on with the veto. Shorter bounds are fine with it
182
+ and cut phrases into pieces without it.
183
+ - On a quiet room, set a floor margin. The model reads mic hiss as a
184
+ word's first phone, no silence rule fires, and the 20 s cap forces
185
+ a word. With `SetEndpointFloorMargin`, a wordless path within the
186
+ margin of `floor_dbfs`, and at least as long as the bound, counts as
187
+ trailing silence and the bound closes it with `"endpoint": "floor"`,
188
+ an empty `result` and the text `[sil]` or `[speech]`. A quiet word's
189
+ first frames after a pause are shorter than that and are left alone.
190
+ Off by default; output is unchanged when unset
191
+ ([TD-12](https://github.com/pyscape/utter/blob/main/docs/td/0012-the-bound-reads-a-wordless-path-at-the-floor-as-silence.md)).
192
+ - Gate on `energy_dbfs` at least 8 dB above `floor_dbfs` only after
193
+ scoring it on your replays: it removes about half the worded finals
194
+ on room silence and costs real words near the floor. `energy_dbfs`
195
+ is `null` when there is no signal under the word, and `floor_dbfs`
196
+ is absent while the quietest windows of the last ten seconds are
197
+ digital silence, so a gate is not handed a number every word sits
198
+ above.
199
+
200
+ ## Build
201
+
202
+ ```sh
203
+ python -m venv .venv && . .venv/bin/activate
204
+ pip install maturin
205
+ maturin develop --release # into the venv
206
+ maturin build --release # a wheel under target/wheels
207
+ ```
208
+
209
+ The core is the utter crate from crates.io at the version Cargo.toml
210
+ names. To build against a checkout beside this one instead, put this
211
+ in `.cargo/config.toml` (ignored by git):
212
+
213
+ ```toml
214
+ [patch.crates-io]
215
+ utter = { path = "../utter" }
216
+ ```
217
+
218
+ The wheel has no runtime dependencies.
219
+
220
+ The wheel carries py.typed and a stub for the module, so a type checker
221
+ sees the API without a separate stubs package.
222
+
223
+ ## Release
224
+
225
+ GitHub Actions builds wheels for Linux x86_64 and aarch64 (manylinux
226
+ 2_28 and musllinux 1.2), Windows x64 and arm64, and macOS x86_64 and
227
+ arm64 on every push, runs the tests on Python 3.9 to 3.14, and on a
228
+ `v*` tag attaches them to a GitHub release with the notes under
229
+ `docs/release-notes` as its body and publishes to PyPI through trusted
230
+ publishing. Every wheel carries a build-provenance attestation, and the
231
+ release attaches its Sigstore bundle and provenance envelope. Publishing works once the PyPI project names this
232
+ repository's `wheels.yml` workflow and the `pypi` environment as a
233
+ trusted publisher and the repository variable `PYPI_PUBLISH` is `true`;
234
+ no token lives in the repository.
235
+
@@ -0,0 +1,216 @@
1
+ # utterpy
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/utterpy)](https://pypi.org/project/utterpy/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/utterpy)](https://pypi.org/project/utterpy/)
5
+ [![Wheels](https://github.com/pyscape/utterpy/actions/workflows/wheels.yml/badge.svg)](https://github.com/pyscape/utterpy/actions/workflows/wheels.yml)
6
+ [![CodeQL](https://github.com/pyscape/utterpy/actions/workflows/codeql.yml/badge.svg)](https://github.com/pyscape/utterpy/actions/workflows/codeql.yml)
7
+ [![cargo-deny](https://github.com/pyscape/utterpy/actions/workflows/deny.yml/badge.svg)](https://github.com/pyscape/utterpy/actions/workflows/deny.yml)
8
+ [![Scorecard](https://api.scorecard.dev/projects/github.com/pyscape/utterpy/badge)](https://scorecard.dev/viewer/?uri=github.com/pyscape/utterpy)
9
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
10
+
11
+ Python bindings for [utter](https://github.com/pyscape/utter), the
12
+ zero-dependency pure-Rust streaming speech decoder for Vosk (Kaldi
13
+ nnet3) models. Drop-in for the vosk
14
+ wheel's API: feed 16 kHz audio, read the partial after every block, the
15
+ live n-best from the beam, word times, endpoints. No C toolchain, no
16
+ Docker, no cloud.
17
+
18
+ ## Why
19
+
20
+ The vosk wheel is a C++ wrapper around Kaldi: no Windows build without
21
+ Docker, and one more field out of the decoder means a fork. utter
22
+ reimplements what a Vosk model needs, in Rust, from a stock model
23
+ directory, and is checked block for block against the wheel. Figures
24
+ are from the [benchmark pages](https://github.com/pyscape/utter/blob/main/docs/benchmarks/README.md),
25
+ paired against the wheel on identical audio.
26
+
27
+ - **Silence is in the stream.** Every partial says `[sil]` or
28
+ `[speech]` when it carries no word; every final names the rule that
29
+ closed it; every word carries its energy against the room floor and
30
+ how long it held. The wheel gives a bare string, so a phantom over
31
+ mic hiss and a spoken word look the same
32
+ ([TD-10](https://github.com/pyscape/utter/blob/main/docs/td/0010-a-wordless-reading-says-whether-a-word-has-begun.md),
33
+ [TD-11](https://github.com/pyscape/utter/blob/main/docs/td/0011-a-final-says-what-closed-it-and-how-long-its-words-held.md)).
34
+ - **Phantoms are tellable, not fewer.** On wordless audio both engines
35
+ read alike: no rank-0 word on cold silence under 92 entries, 0.56 per
36
+ minute after speech. Here each carries `stable_ms` and `energy_dbfs`,
37
+ so a stale word is told from a fresh one, and the partial stream is
38
+ as steady as the wheel's: first word later revised on 12.4% of clips
39
+ against 12.3% ([speech-commands.md](https://github.com/pyscape/utter/blob/main/docs/benchmarks/speech-commands.md)).
40
+ - **A host endpoint bound.** A final after N ms of trailing silence,
41
+ vetoed while a rival is within a margin. Median finish 866 to 660 ms
42
+ on built streams, same finishes called, no word lost or split
43
+ ([partial-states.md](https://github.com/pyscape/utter/blob/main/docs/benchmarks/partial-states.md),
44
+ [TD-8](https://github.com/pyscape/utter/blob/main/docs/td/0008-silence-is-the-hosts-gate.md)).
45
+ - **Distinct rivals with their motion** on every partial, where the
46
+ wheel's alternatives repeat readings
47
+ ([TD-9](https://github.com/pyscape/utter/blob/main/docs/td/0009-readings-carry-their-relation-and-their-leads-motion.md)).
48
+ - **Parity, not a gain, on hearing.** 91.79% against 91.49% on 11,005
49
+ clips, finals identical on 99.2%, word times to the frame, same 40 ms
50
+ first appearance, less compute
51
+ ([word-times.md](https://github.com/pyscape/utter/blob/main/docs/benchmarks/word-times.md)).
52
+ - **Pure Rust, typed.** No C toolchain, no Docker, no cloud. Wheels for
53
+ Linux, macOS and Windows, one abi3 build from Python 3.9, with a stub
54
+ and py.typed checked by stubtest.
55
+
56
+ | Speech Commands v2, 11,005 clips | Vosk 0.3.45 | utter |
57
+ |---|---|---|
58
+ | Accuracy, full grammar | 91.49% | 91.79% |
59
+ | First word shown after the clip ends, p50 / p90 | 40 / 250 ms | 40 / 250 ms |
60
+ | First word shown later revised | 12.3% | 12.4% |
61
+ | Real-time factor, decode only | 0.0168 | 0.0143 |
62
+
63
+ Pre-alpha; the decoder still moves between revisions.
64
+
65
+ ## Use
66
+
67
+ ```python
68
+ import json, utterpy
69
+
70
+ model = utterpy.Model("vosk-model-small-en-us-0.15")
71
+ rec = utterpy.KaldiRecognizer(model, 16000, json.dumps(["yes", "no", "stop"]))
72
+ rec.SetWords(True)
73
+ rec.SetPartialWords(True)
74
+ rec.SetPartialAlternatives(3)
75
+
76
+ for block in blocks_of_16k_mono_int16_pcm: # bytes, 40 ms works well
77
+ if rec.AcceptWaveform(block): # an endpoint fired
78
+ print(json.loads(rec.Result())) # {"result": [...], "text": ...}
79
+ else:
80
+ print(json.loads(rec.PartialResult())) # {"partial": ..., "partial_alternatives": [...], "partial_result": [...]}
81
+ print(json.loads(rec.FinalResult()))
82
+ ```
83
+
84
+ The method names are vosk's, and the JSON keeps vosk's layout. On top of
85
+ it: `partial_alternatives` (distinct readings alive in the beam, with
86
+ `confidence` as the negated cost), `partial_result` entries with
87
+ `start_sample`, `end_sample`, `energy_dbfs` and `stable_ms`, `[sil]` as
88
+ the reading when the best path carries no word and sits on silence and
89
+ `[speech]` when it has entered a word's phones, `endpoint` on a final
90
+ naming what closed it (`rule1` to `rule5`, `bound`, `floor`, `flush`
91
+ or `host`), `stable_ms` on each final word for the hold it had in the
92
+ partial, `floor_dbfs` for the room's noise floor, and an optional
93
+ `unknown_cost` argument to the recognizer that admits the model's
94
+ unknown-word symbol. `DecodedSample()` gives the position, in samples fed,
95
+ of the last decoded frame.
96
+
97
+ With a speaker model, `SpkModel("vosk-model-spk-0.4")` passed as
98
+ `spk_model=` or through `SetSpkModel`, every partial and final and each
99
+ word entry carries `spk`, the speaker vector of its own span, with
100
+ `spk_frames`, `spk_start` and `spk_end`, once the span holds 25 pooled
101
+ 10 ms frames. The wheel puts one vector on a final. These follow Kaldi's
102
+ network but not the wheel's normalisation or frame selection
103
+ ([TD-14](https://github.com/pyscape/utter/blob/main/docs/td/0014-every-word-carries-the-speaker-evidence-of-its-own-span.md)):
104
+ rebuild speaker profiles from your enrolment audio with utterpy, and do
105
+ not reuse vectors or thresholds from the wheel.
106
+
107
+ A TitaNet-small directory, as utter's `scripts/titanet_convert.py`
108
+ writes it, opens the same way and gives 192-number vectors
109
+ ([TD-15](https://github.com/pyscape/utter/blob/main/docs/td/0015-titanet-small-is-a-native-speaker-model.md)).
110
+ Its evidence rides the word entries only, and it is embedded between
111
+ decoder advances, so feed blocks shorter than one decoder chunk: 40 ms
112
+ works. `SpkModel.embed(data, sample_rate=16000)` embeds any span of
113
+ little-endian float32 samples in [-1, 1] without a recognizer, 25 ms to
114
+ 30 s, at 16 kHz or faster; an x-vector model raises ValueError.
115
+
116
+ ## Configure
117
+
118
+ The calls are the wheel's, so switching is the import. The setup the
119
+ pages measure:
120
+
121
+ ```python
122
+ rec = utterpy.KaldiRecognizer(model, 16000, json.dumps(grammar))
123
+ rec.SetWords(True) # word times, energy_dbfs, stable_ms on finals
124
+ rec.SetPartialWords(True) # word entries on partials, with [sil]/[speech]
125
+ rec.SetPartialAlternatives(4) # four distinct readings per partial
126
+ rec.SetMaxAlternatives(4) # n-best finals
127
+ rec.SetEndpointBound(300, 8) # host bound with veto; (ms, 0) bare, (0) off
128
+ rec.SetEndpointFloorMargin(6) # the bound reads a wordless path within 6 dB of the floor as silence
129
+ ```
130
+
131
+ Feed 40 ms blocks, 1280 bytes at 16 kHz mono int16. Two clocks: a
132
+ word shows in the partial 40 ms after it ends, the block size, for
133
+ both engines; the final arrives 866 ms after under the model's rules
134
+ and 660 ms with the bound. Compute is not the wait, at 0.04 ms per
135
+ block. The trailing `[sil]` entry shows in the partial before the
136
+ endpoint fires, so a host can act on a settled partial plus visible
137
+ silence.
138
+
139
+ - Expose the bound as one host setting with an off spelling, and
140
+ measure off against on on your own replays before retuning it.
141
+ - Model conf carries over: `frames_per_chunk` and
142
+ `min-trailing-silence` tuned for the wheel keep their effect.
143
+ - `unknown_cost` does not fight phantoms; the page measured no change
144
+ in rank-0 words. The fields and the bound are the levers.
145
+ - Grammar size sets the stale-partial rate after speech: about 0.6 per
146
+ minute at 92 entries, 3 at 150.
147
+ - Keep a trace of raw partial and final payloads. `SetLogLevel(-1)`
148
+ silences the runtime as with the wheel.
149
+
150
+ ## Silence
151
+
152
+ Host rules that have held up, each keyed off one field:
153
+
154
+ - `[sil]` and `[speech]` are no word: never dispatched, ranked or
155
+ shown. When rank 0 is wordless, offer nothing; the rivals beneath
156
+ it on floor audio are short grammar words at low confidence.
157
+ - Drop a final word with `"endpoint": "rule5"` and `"stable_ms": 0`.
158
+ That is Kaldi's 20 s length cap naming the cheapest word over
159
+ silence, every 21 s on mic hiss, on the wheel too. When the drop
160
+ empties the final, still close the utterance, or the phantom returns
161
+ as a partial. Do not widen this to `rule2`: a never-shown word there
162
+ can be a real rescored reading.
163
+ - Keep the bound on with the veto. Shorter bounds are fine with it
164
+ and cut phrases into pieces without it.
165
+ - On a quiet room, set a floor margin. The model reads mic hiss as a
166
+ word's first phone, no silence rule fires, and the 20 s cap forces
167
+ a word. With `SetEndpointFloorMargin`, a wordless path within the
168
+ margin of `floor_dbfs`, and at least as long as the bound, counts as
169
+ trailing silence and the bound closes it with `"endpoint": "floor"`,
170
+ an empty `result` and the text `[sil]` or `[speech]`. A quiet word's
171
+ first frames after a pause are shorter than that and are left alone.
172
+ Off by default; output is unchanged when unset
173
+ ([TD-12](https://github.com/pyscape/utter/blob/main/docs/td/0012-the-bound-reads-a-wordless-path-at-the-floor-as-silence.md)).
174
+ - Gate on `energy_dbfs` at least 8 dB above `floor_dbfs` only after
175
+ scoring it on your replays: it removes about half the worded finals
176
+ on room silence and costs real words near the floor. `energy_dbfs`
177
+ is `null` when there is no signal under the word, and `floor_dbfs`
178
+ is absent while the quietest windows of the last ten seconds are
179
+ digital silence, so a gate is not handed a number every word sits
180
+ above.
181
+
182
+ ## Build
183
+
184
+ ```sh
185
+ python -m venv .venv && . .venv/bin/activate
186
+ pip install maturin
187
+ maturin develop --release # into the venv
188
+ maturin build --release # a wheel under target/wheels
189
+ ```
190
+
191
+ The core is the utter crate from crates.io at the version Cargo.toml
192
+ names. To build against a checkout beside this one instead, put this
193
+ in `.cargo/config.toml` (ignored by git):
194
+
195
+ ```toml
196
+ [patch.crates-io]
197
+ utter = { path = "../utter" }
198
+ ```
199
+
200
+ The wheel has no runtime dependencies.
201
+
202
+ The wheel carries py.typed and a stub for the module, so a type checker
203
+ sees the API without a separate stubs package.
204
+
205
+ ## Release
206
+
207
+ GitHub Actions builds wheels for Linux x86_64 and aarch64 (manylinux
208
+ 2_28 and musllinux 1.2), Windows x64 and arm64, and macOS x86_64 and
209
+ arm64 on every push, runs the tests on Python 3.9 to 3.14, and on a
210
+ `v*` tag attaches them to a GitHub release with the notes under
211
+ `docs/release-notes` as its body and publishes to PyPI through trusted
212
+ publishing. Every wheel carries a build-provenance attestation, and the
213
+ release attaches its Sigstore bundle and provenance envelope. Publishing works once the PyPI project names this
214
+ repository's `wheels.yml` workflow and the `pypi` environment as a
215
+ trusted publisher and the repository variable `PYPI_PUBLISH` is `true`;
216
+ no token lives in the repository.
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["maturin>=1.5,<2.0"]
3
+ build-backend = "maturin"
4
+
5
+ [project]
6
+ name = "utterpy"
7
+ version = "0.0.6"
8
+ description = "Python bindings for utter, the zero-dependency pure-Rust streaming speech decoder for Vosk (Kaldi nnet3) models"
9
+ readme = "README.md"
10
+ license = { file = "LICENSE" }
11
+ requires-python = ">=3.9"
12
+ classifiers = [
13
+ "Programming Language :: Rust",
14
+ "Programming Language :: Python :: Implementation :: CPython",
15
+ "Programming Language :: Python :: 3.9",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
21
+ "Topic :: Multimedia :: Sound/Audio :: Speech",
22
+ "Typing :: Typed",
23
+ ]
24
+
25
+ [tool.maturin]
26
+ features = ["pyo3/extension-module"]
27
+ # The compiled module sits inside the package so py.typed and the stub ship beside it.
28
+ module-name = "utterpy._utterpy"
29
+ python-source = "python"
30
+ # The sdist is what builds the wheel, not the repository's tooling.
31
+ exclude = [
32
+ ".github/**",
33
+ "docs/**",
34
+ "tests/**",
35
+ ".*",
36
+ "deny.toml",
37
+ "ruff.toml",
38
+ "CODE_OF_CONDUCT.md",
39
+ "CONTRIBUTING.md",
40
+ "GOVERNANCE.md",
41
+ "ROADMAP.md",
42
+ "SECURITY.md",
43
+ ]
44
+
45
+ [tool.mypy]
46
+ # The floor in requires-python, not whichever interpreter CI happens to run mypy on.
47
+ python_version = "3.9"
48
+ # AcceptWaveform downcasts to bytes, so the stub's bytes must not absorb memoryview.
49
+ strict_bytes = true
@@ -0,0 +1,23 @@
1
+ """Python bindings for utter with the vosk wheel's object surface."""
2
+
3
+ from ._utterpy import (
4
+ UTTER_REVISION,
5
+ UTTER_VERSION,
6
+ KaldiRecognizer,
7
+ Model,
8
+ Recognizer,
9
+ SetLogLevel,
10
+ SpkModel,
11
+ __version__,
12
+ )
13
+
14
+ __all__ = [
15
+ "UTTER_REVISION",
16
+ "UTTER_VERSION",
17
+ "KaldiRecognizer",
18
+ "Model",
19
+ "Recognizer",
20
+ "SetLogLevel",
21
+ "SpkModel",
22
+ "__version__",
23
+ ]
@@ -0,0 +1,95 @@
1
+ from typing import Any, final
2
+
3
+ __all__ = [
4
+ "UTTER_REVISION",
5
+ "UTTER_VERSION",
6
+ "KaldiRecognizer",
7
+ "Model",
8
+ "Recognizer",
9
+ "SetLogLevel",
10
+ "SpkModel",
11
+ "__version__",
12
+ ]
13
+
14
+ __version__: str
15
+ UTTER_REVISION: str
16
+ UTTER_VERSION: str
17
+
18
+ @final
19
+ class Model:
20
+ def __new__(cls, path: str) -> Model: ...
21
+ def FindWord(self, word: str) -> int:
22
+ """Word id for a word, -1 when the model does not know it (vosk's FindWord)."""
23
+
24
+ @final
25
+ class SpkModel:
26
+ """vosk's SpkModel: a speaker model directory, the vosk-model-spk-0.4 layout or TitaNet-small."""
27
+
28
+ def __new__(cls, path: str) -> SpkModel: ...
29
+ def dim(self) -> int:
30
+ """The length of the speaker vector."""
31
+
32
+ def embed(self, data: bytes, sample_rate: float = 16000.0) -> list[float]:
33
+ """The TitaNet embedding of a span without a recognizer.
34
+
35
+ Little-endian float32 samples in [-1, 1] at sample_rate Hz, 16 kHz or faster.
36
+ ValueError for an x-vector model, and on a span under 25 ms or over 30 s.
37
+ """
38
+
39
+ @final
40
+ class KaldiRecognizer:
41
+ """vosk's KaldiRecognizer: a streaming recognizer over one model and one grammar."""
42
+
43
+ def __new__(
44
+ cls,
45
+ model: Model,
46
+ sample_rate: float,
47
+ grammar: str,
48
+ unknown_cost: float | None = None,
49
+ spk_model: SpkModel | None = None,
50
+ ) -> KaldiRecognizer:
51
+ """The grammar is a JSON array of strings as vosk takes it."""
52
+
53
+ def SetSpkModel(self, spk_model: SpkModel | None) -> None:
54
+ """vosk's SetSpkModel; None removes it. Audio already fed in the current stream counts.
55
+
56
+ While set, a span of at least 25 pooled 10 ms frames carries "spk", "spk_frames",
57
+ "spk_start" and "spk_end" at the top level and on every word entry. A TitaNet model
58
+ puts them on word entries only.
59
+ """
60
+
61
+ def SetWords(self, on: Any) -> None:
62
+ """Any value Python considers truthy, as the vosk wheel accepts."""
63
+
64
+ def SetPartialWords(self, on: Any) -> None: ...
65
+ def SetEndpointBound(
66
+ self,
67
+ trailing_ms: float | None,
68
+ extending_veto_nats: float | None = None,
69
+ ) -> None:
70
+ """A host endpoint bound: a final once the trailing silence reaches trailing_ms."""
71
+
72
+ def SetEndpointFloorMargin(self, margin_db: float | None) -> None:
73
+ """A margin in dB over the floor within which the bound reads a wordless path as silence.
74
+
75
+ Only a span at least as long as the bound is read that way, so a quiet word's first
76
+ frames are not. Such a final names "floor" as its endpoint. 0 or None removes the margin.
77
+ """
78
+
79
+ def SetPartialAlternatives(self, n: int) -> None: ...
80
+ def SetMaxAlternatives(self, n: int) -> None: ...
81
+ def AcceptWaveform(self, data: bytes) -> bool:
82
+ """16-bit little-endian mono PCM. Returns True when an endpoint fired."""
83
+
84
+ def DecodedSample(self) -> int:
85
+ """The sample position of the last decoded frame after the last AcceptWaveform."""
86
+
87
+ def PartialResult(self) -> str: ...
88
+ def Result(self) -> str: ...
89
+ def FinalResult(self) -> str: ...
90
+ def Reset(self) -> None: ...
91
+
92
+ Recognizer = KaldiRecognizer
93
+
94
+ def SetLogLevel(_level: int) -> None:
95
+ """vosk's SetLogLevel; utter logs nothing by default, so this only records the request."""
File without changes
@@ -0,0 +1,3 @@
1
+ [toolchain]
2
+ channel = "1.96.0"
3
+ components = ["rustfmt", "clippy"]
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env bash
2
+ # Cut a release: check every precondition, sign and verify the tag, push it, watch the workflow,
3
+ # and confirm what it produced. Stops at the first thing that is not as it should be.
4
+ #
5
+ # scripts/release.sh # the version in pyproject.toml
6
+ # scripts/release.sh --check # the preconditions only, no tag
7
+ set -euo pipefail
8
+
9
+ check_only=false
10
+ [ "${1:-}" = "--check" ] && check_only=true
11
+
12
+ fail() { printf 'release: %s\n' "$*" >&2; exit 1; }
13
+ ok() { printf ' ok %s\n' "$*"; }
14
+
15
+ cd "$(git rev-parse --show-toplevel)"
16
+
17
+ # The wheel's version is the utter crate's; a binding-only fix is a post-release of it, so the
18
+ # tag is the pyproject version and its base is the Cargo version and the pinned crate's.
19
+ version=$(sed -n 's/^version = "\(.*\)"$/\1/p' pyproject.toml | head -1)
20
+ [ -n "$version" ] || fail "no version in pyproject.toml"
21
+ base="${version%.post*}"
22
+ tag="v$version"
23
+ printf 'release %s\n' "$tag"
24
+
25
+ [ "$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -1)" = "$base" ] || fail "Cargo.toml is not at $base"
26
+ core=$(awk '/^name = "utter"$/ { getline; sub(/^version = "/, ""); sub(/"$/, ""); print; exit }' Cargo.lock)
27
+ [ "$core" = "$base" ] || fail "Cargo.lock pins utter $core, not $base"
28
+ grep -A2 '^name = "utter"$' Cargo.lock | grep -q '^source = "registry' || fail "Cargo.lock has utter from a path patch, not crates.io; run cargo update -p utter without the patch"
29
+ ok "versions agree: pyproject $version, Cargo $base, utter $core from crates.io"
30
+ [ "$(git branch --show-current)" = main ] || fail "not on main"
31
+ ok "on main"
32
+ [ -z "$(git status --porcelain --untracked-files=no)" ] || fail "tracked files have uncommitted changes"
33
+ ok "tree clean"
34
+ git fetch -q origin main
35
+ [ "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" ] || fail "main is not at origin/main; pull or push first"
36
+ ok "main matches origin"
37
+ [ -f "docs/release-notes/$tag.md" ] || fail "docs/release-notes/$tag.md is missing; the workflow reads the release body from it"
38
+ ok "release notes exist"
39
+ grep -q "^## $version\$" CHANGELOG.md || fail "CHANGELOG.md has no '## $version' heading"
40
+ ok "changelog heading"
41
+ grep -q '^## Unreleased' CHANGELOG.md && fail "CHANGELOG.md still has an Unreleased section"
42
+ git rev-parse -q --verify "refs/tags/$tag" >/dev/null && fail "tag $tag already exists locally"
43
+ git ls-remote --exit-code --tags origin "$tag" >/dev/null 2>&1 && fail "tag $tag already exists on origin"
44
+ ok "tag $tag is free"
45
+ [ "$(git config --get tag.gpgSign)" = true ] || fail "tag.gpgSign is not on; the tag must be signed"
46
+ ok "tags are signed"
47
+ # The workflow publishes only while this variable is true, so the PyPI checks follow it.
48
+ pypi=$(gh variable get PYPI_PUBLISH 2>/dev/null || true)
49
+ if [ "$pypi" = true ]; then
50
+ code=$(curl -s -o /dev/null -w '%{http_code}' -A "utterpy release script" "https://pypi.org/pypi/utterpy/$version/json")
51
+ [ "$code" = 404 ] || fail "PyPI answers $code for utterpy $version; expected 404 before publishing"
52
+ ok "PyPI has no $version"
53
+ else
54
+ ok "PYPI_PUBLISH is not true; the release goes to GitHub only"
55
+ fi
56
+
57
+ if $check_only; then
58
+ printf 'preconditions hold; run without --check to tag\n'
59
+ exit 0
60
+ fi
61
+
62
+ git tag -s "$tag" -m "utterpy $version"
63
+ git tag -v "$tag"
64
+ ok "tag signed and verified"
65
+ git push origin "$tag"
66
+ ok "tag pushed"
67
+
68
+ sleep 10
69
+ run=$(gh run list --workflow wheels.yml --branch "$tag" --limit 1 --json databaseId -q '.[0].databaseId')
70
+ [ -n "$run" ] || fail "no wheels.yml run found for $tag"
71
+ gh run watch "$run" --exit-status || fail "release workflow failed; see gh run view $run --log-failed"
72
+ ok "workflow succeeded"
73
+
74
+ draft=$(gh release view "$tag" --json isDraft -q '.isDraft')
75
+ [ "$draft" = false ] || fail "release $tag is a draft"
76
+ assets=$(gh release view "$tag" --json assets -q '.assets[].name')
77
+ n=$(printf '%s\n' "$assets" | grep -c .)
78
+ printf '%s\n' "$assets" | sed 's/^/ /'
79
+ [ "$n" -eq 11 ] || fail "expected 11 assets (eight wheels, the sdist, the Sigstore bundle, the provenance envelope), found $n"
80
+ printf '%s\n' "$assets" | grep -q "^utterpy-$tag.sigstore.json\$" || fail "the Sigstore bundle is not among the assets"
81
+ printf '%s\n' "$assets" | grep -q "^utterpy-$tag.intoto.jsonl\$" || fail "the provenance envelope is not among the assets"
82
+ ok "release has eleven assets and is published"
83
+
84
+ if [ "$pypi" != true ]; then
85
+ printf 'released %s on GitHub; PyPI publishing is off\n' "$tag"
86
+ exit 0
87
+ fi
88
+
89
+ for _ in 1 2 3 4 5 6; do
90
+ code=$(curl -s -o /dev/null -w '%{http_code}' -A "utterpy release script" "https://pypi.org/pypi/utterpy/$version/json")
91
+ [ "$code" = 200 ] && break
92
+ sleep 10
93
+ done
94
+ [ "$code" = 200 ] || fail "PyPI answers $code for utterpy $version after the workflow"
95
+ ok "PyPI has utterpy $version"
96
+ printf 'released %s\n' "$tag"
@@ -0,0 +1,233 @@
1
+ //! Python bindings for utter with the vosk wheel's object surface: `Model`, `KaldiRecognizer`
2
+ //! (alias `Recognizer`), the same method names, and libvosk-shaped JSON strings, so a host
3
+ //! written against vosk selects this package without an adapter.
4
+
5
+ // The method names are the vosk wheel's, which are CamelCase.
6
+ #![allow(non_snake_case)]
7
+
8
+ use pyo3::exceptions::{PyRuntimeError, PyValueError};
9
+ use pyo3::prelude::*;
10
+ use pyo3::types::PyBytes;
11
+ use std::sync::Arc;
12
+
13
+ #[pyclass(name = "Model", frozen)]
14
+ struct PyModel {
15
+ inner: Arc<utter::Model>,
16
+ }
17
+
18
+ #[pymethods]
19
+ impl PyModel {
20
+ #[new]
21
+ fn new(path: &str) -> PyResult<Self> {
22
+ let model = utter::Model::open(std::path::Path::new(path))
23
+ .map_err(|e| PyRuntimeError::new_err(e.to_string()))?;
24
+ Ok(PyModel {
25
+ inner: Arc::new(model),
26
+ })
27
+ }
28
+
29
+ /// Word id for a word, -1 when the model does not know it (vosk's FindWord).
30
+ fn FindWord(&self, word: &str) -> i64 {
31
+ self.inner.word_ids.get(word).copied().unwrap_or(-1)
32
+ }
33
+ }
34
+
35
+ /// vosk's SpkModel: a speaker model directory, the vosk-model-spk-0.4 layout or TitaNet-small.
36
+ #[pyclass(name = "SpkModel", frozen)]
37
+ struct PySpkModel {
38
+ inner: Arc<utter::SpeakerModel>,
39
+ }
40
+
41
+ #[pymethods]
42
+ impl PySpkModel {
43
+ #[new]
44
+ fn new(path: &str) -> PyResult<Self> {
45
+ let model = utter::SpeakerModel::open(std::path::Path::new(path))
46
+ .map_err(|e| PyRuntimeError::new_err(e.to_string()))?;
47
+ Ok(PySpkModel {
48
+ inner: Arc::new(model),
49
+ })
50
+ }
51
+
52
+ /// The length of the speaker vector.
53
+ fn dim(&self) -> usize {
54
+ self.inner.dim()
55
+ }
56
+
57
+ /// The TitaNet embedding of a span without a recognizer: little-endian float32 samples in
58
+ /// [-1, 1] at sample_rate Hz, 16 kHz or faster. ValueError for an x-vector model, and on a
59
+ /// span under 25 ms or over 30 s.
60
+ #[pyo3(signature = (data, sample_rate = 16000.0))]
61
+ fn embed(
62
+ &self,
63
+ py: Python<'_>,
64
+ data: &Bound<'_, PyBytes>,
65
+ sample_rate: f32,
66
+ ) -> PyResult<Vec<f32>> {
67
+ let bytes = data.as_bytes();
68
+ if bytes.len() % 4 != 0 {
69
+ return Err(PyValueError::new_err(
70
+ "embed wants float32 samples: a multiple of four bytes",
71
+ ));
72
+ }
73
+ let samples: Vec<f32> = bytes
74
+ .chunks_exact(4)
75
+ .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
76
+ .collect();
77
+ py.detach(|| self.inner.embed_at_rate(&samples, sample_rate))
78
+ .map_err(|e| PyValueError::new_err(e.to_string()))
79
+ }
80
+ }
81
+
82
+ /// The recognizer owns clones of the model handles so the borrows live as long as it does.
83
+ #[pyclass(name = "KaldiRecognizer")]
84
+ struct PyRecognizer {
85
+ // Declared first so it is dropped before the model handles it borrows from.
86
+ inner: utter::Recognizer<'static>,
87
+ _model: Arc<utter::Model>,
88
+ spk_model: Option<Arc<utter::SpeakerModel>>,
89
+ }
90
+
91
+ fn options(unknown_cost: Option<f32>) -> utter::recognizer::RecognizerOptions {
92
+ utter::recognizer::RecognizerOptions {
93
+ unknown_cost,
94
+ ..Default::default()
95
+ }
96
+ }
97
+
98
+ #[pymethods]
99
+ impl PyRecognizer {
100
+ /// `KaldiRecognizer(model, sample_rate, grammar_json)`; the grammar is a JSON array of
101
+ /// strings as vosk takes it. `unknown_cost` adds the model's unknown-word symbol.
102
+ #[new]
103
+ #[pyo3(signature = (model, sample_rate, grammar, unknown_cost = None, spk_model = None))]
104
+ fn new(
105
+ model: &PyModel,
106
+ sample_rate: f32,
107
+ grammar: &str,
108
+ unknown_cost: Option<f32>,
109
+ spk_model: Option<&PySpkModel>,
110
+ ) -> PyResult<Self> {
111
+ let words = utter::json::parse_string_array(grammar)
112
+ .map_err(|e| PyValueError::new_err(e.to_string()))?;
113
+ let arc = model.inner.clone();
114
+ // The Arc keeps the model alive for the recognizer's lifetime; the reference handed to
115
+ // the recognizer is derived from it and dropped with it.
116
+ let model_ref: &'static utter::Model = unsafe { &*(Arc::as_ptr(&arc)) };
117
+ let inner =
118
+ utter::Recognizer::with_options(model_ref, sample_rate, &words, &options(unknown_cost))
119
+ .map_err(|e| PyValueError::new_err(e.to_string()))?;
120
+ let mut rec = PyRecognizer {
121
+ inner,
122
+ _model: arc,
123
+ spk_model: None,
124
+ };
125
+ rec.SetSpkModel(spk_model)?;
126
+ Ok(rec)
127
+ }
128
+
129
+ /// vosk's SetSpkModel; None removes it. Audio already fed in the current stream counts.
130
+ fn SetSpkModel(&mut self, spk_model: Option<&PySpkModel>) -> PyResult<()> {
131
+ let arc = spk_model.map(|m| m.inner.clone());
132
+ // As with the model: the Arc stored below outlives the reference the recognizer holds.
133
+ let spk_ref: Option<&'static utter::SpeakerModel> =
134
+ arc.as_ref().map(|a| unsafe { &*Arc::as_ptr(a) });
135
+ self.inner
136
+ .set_spk_model(spk_ref)
137
+ .map_err(|e| PyValueError::new_err(e.to_string()))?;
138
+ self.spk_model = arc;
139
+ Ok(())
140
+ }
141
+
142
+ /// Any value Python considers truthy, as the vosk wheel accepts.
143
+ fn SetWords(&mut self, on: &Bound<'_, PyAny>) -> PyResult<()> {
144
+ self.inner.set_words(on.is_truthy()?);
145
+ Ok(())
146
+ }
147
+ fn SetPartialWords(&mut self, on: &Bound<'_, PyAny>) -> PyResult<()> {
148
+ self.inner.set_partial_words(on.is_truthy()?);
149
+ Ok(())
150
+ }
151
+ /// A host endpoint bound: a final once the trailing silence reaches `trailing_ms` (0 or None
152
+ /// removes it), unless `extending_veto_nats` is set and a reading extending the partial by a
153
+ /// further word is within that many nats of it.
154
+ #[pyo3(signature = (trailing_ms, extending_veto_nats = None))]
155
+ fn SetEndpointBound(&mut self, trailing_ms: Option<f32>, extending_veto_nats: Option<f32>) {
156
+ self.inner.set_endpoint_bound(
157
+ trailing_ms.filter(|&m| m > 0.0),
158
+ extending_veto_nats.filter(|&n| n > 0.0),
159
+ );
160
+ }
161
+ /// A margin in dB over the reported floor within which the bound reads a wordless path as
162
+ /// silence, over a span at least as long as the bound; such a final names `floor` as its
163
+ /// endpoint. 0 or None removes it.
164
+ fn SetEndpointFloorMargin(&mut self, margin_db: Option<f32>) {
165
+ self.inner
166
+ .set_endpoint_floor_margin(margin_db.filter(|&m| m > 0.0));
167
+ }
168
+ fn SetPartialAlternatives(&mut self, n: usize) {
169
+ self.inner.set_alternatives(n);
170
+ }
171
+ fn SetMaxAlternatives(&mut self, n: usize) {
172
+ self.inner.set_max_alternatives(n);
173
+ }
174
+
175
+ /// 16-bit little-endian mono PCM. Returns True when an endpoint fired.
176
+ fn AcceptWaveform(&mut self, py: Python<'_>, data: &Bound<'_, PyBytes>) -> PyResult<bool> {
177
+ let bytes = data.as_bytes();
178
+ if bytes.len() % 2 != 0 {
179
+ return Err(PyValueError::new_err(
180
+ "AcceptWaveform wants 16-bit PCM: an even number of bytes",
181
+ ));
182
+ }
183
+ let samples: Vec<i16> = bytes
184
+ .chunks_exact(2)
185
+ .map(|c| i16::from_le_bytes([c[0], c[1]]))
186
+ .collect();
187
+ let step = py.detach(|| self.inner.accept(&samples));
188
+ Ok(step.endpoint)
189
+ }
190
+
191
+ /// The sample position of the last decoded frame after the last AcceptWaveform, in samples
192
+ /// fed since construction.
193
+ fn DecodedSample(&self) -> u64 {
194
+ self.inner.decoded_sample()
195
+ }
196
+
197
+ fn PartialResult(&mut self) -> String {
198
+ self.inner.partial().to_string()
199
+ }
200
+ fn Result(&mut self) -> String {
201
+ self.inner.result().to_string()
202
+ }
203
+ fn FinalResult(&mut self) -> String {
204
+ self.inner.final_result().to_string()
205
+ }
206
+ fn Reset(&mut self) {
207
+ self.inner.reset();
208
+ }
209
+ }
210
+
211
+ /// vosk's SetLogLevel; utter logs nothing by default, so this only records the request.
212
+ #[pyfunction]
213
+ fn SetLogLevel(_level: i32) {}
214
+
215
+ #[pymodule]
216
+ fn _utterpy(m: &Bound<'_, PyModule>) -> PyResult<()> {
217
+ m.add_class::<PyModel>()?;
218
+ m.add_class::<PySpkModel>()?;
219
+ m.add_class::<PyRecognizer>()?;
220
+ m.add("Recognizer", m.getattr("KaldiRecognizer")?)?;
221
+ m.add_function(wrap_pyfunction!(SetLogLevel, m)?)?;
222
+ m.add("__version__", env!("CARGO_PKG_VERSION"))?;
223
+ // Which runtime decoded, rather than which checkout a benchmark script was read from: the
224
+ // binding is a compiled artifact and can be any age.
225
+ m.add("UTTER_REVISION", utter::REVISION)?;
226
+ // SAFETY: utter_version returns a static NUL-terminated string.
227
+ let version = unsafe { std::ffi::CStr::from_ptr(utter::capi::utter_version()) };
228
+ m.add(
229
+ "UTTER_VERSION",
230
+ version.to_str().expect("utter's version is ASCII"),
231
+ )?;
232
+ Ok(())
233
+ }