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.
- utterpy-0.0.6/CHANGELOG.md +67 -0
- utterpy-0.0.6/Cargo.lock +139 -0
- utterpy-0.0.6/Cargo.toml +26 -0
- utterpy-0.0.6/LICENSE +21 -0
- utterpy-0.0.6/PKG-INFO +235 -0
- utterpy-0.0.6/README.md +216 -0
- utterpy-0.0.6/pyproject.toml +49 -0
- utterpy-0.0.6/python/utterpy/__init__.py +23 -0
- utterpy-0.0.6/python/utterpy/__init__.pyi +95 -0
- utterpy-0.0.6/python/utterpy/py.typed +0 -0
- utterpy-0.0.6/rust-toolchain.toml +3 -0
- utterpy-0.0.6/scripts/release.sh +96 -0
- utterpy-0.0.6/src/lib.rs +233 -0
|
@@ -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.
|
utterpy-0.0.6/Cargo.lock
ADDED
|
@@ -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
|
+
]
|
utterpy-0.0.6/Cargo.toml
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/utterpy/)
|
|
22
|
+
[](https://pypi.org/project/utterpy/)
|
|
23
|
+
[](https://github.com/pyscape/utterpy/actions/workflows/wheels.yml)
|
|
24
|
+
[](https://github.com/pyscape/utterpy/actions/workflows/codeql.yml)
|
|
25
|
+
[](https://github.com/pyscape/utterpy/actions/workflows/deny.yml)
|
|
26
|
+
[](https://scorecard.dev/viewer/?uri=github.com/pyscape/utterpy)
|
|
27
|
+
[](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
|
+
|
utterpy-0.0.6/README.md
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# utterpy
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/utterpy/)
|
|
4
|
+
[](https://pypi.org/project/utterpy/)
|
|
5
|
+
[](https://github.com/pyscape/utterpy/actions/workflows/wheels.yml)
|
|
6
|
+
[](https://github.com/pyscape/utterpy/actions/workflows/codeql.yml)
|
|
7
|
+
[](https://github.com/pyscape/utterpy/actions/workflows/deny.yml)
|
|
8
|
+
[](https://scorecard.dev/viewer/?uri=github.com/pyscape/utterpy)
|
|
9
|
+
[](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,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"
|
utterpy-0.0.6/src/lib.rs
ADDED
|
@@ -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
|
+
}
|