wakebox 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wakebox/__init__.py +6 -0
- wakebox/engine.py +146 -0
- wakebox/microphone.py +55 -0
- wakebox/models/embedding_model.onnx +0 -0
- wakebox/models/melspectrogram.onnx +0 -0
- wakebox/py.typed +0 -0
- wakebox-0.1.1.dist-info/METADATA +31 -0
- wakebox-0.1.1.dist-info/RECORD +10 -0
- wakebox-0.1.1.dist-info/WHEEL +5 -0
- wakebox-0.1.1.dist-info/top_level.txt +1 -0
wakebox/__init__.py
ADDED
wakebox/engine.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""On-device wake word engine.
|
|
2
|
+
|
|
3
|
+
The shared runtime is the frozen mel frontend and speech embedding. Each
|
|
4
|
+
phrase file is a small head. Sensitivity is chosen here, not stored in the file.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import cast
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import onnxruntime as ort
|
|
14
|
+
|
|
15
|
+
SAMPLE_RATE = 16000
|
|
16
|
+
MEL_WINDOW = 76
|
|
17
|
+
MEL_STRIDE = 8
|
|
18
|
+
EMBEDDING_DIM = 96
|
|
19
|
+
HEAD_FRAMES = 16
|
|
20
|
+
REFRACTORY_SEC = 1.0
|
|
21
|
+
# Scores for a real phrase sit in a narrow band near the top. Map the
|
|
22
|
+
# caller's 0–1 control onto that band. 0 is strict, 1 is loose.
|
|
23
|
+
THRESHOLD_STRICT = 0.995
|
|
24
|
+
THRESHOLD_LOOSE = 0.85
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def threshold_for_sensitivity(sensitivity: float) -> float:
|
|
28
|
+
value = float(np.clip(sensitivity, 0.0, 1.0))
|
|
29
|
+
return THRESHOLD_STRICT + (THRESHOLD_LOOSE - THRESHOLD_STRICT) * value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _session(model: str | Path | bytes) -> ort.InferenceSession:
|
|
33
|
+
options = ort.SessionOptions()
|
|
34
|
+
options.inter_op_num_threads = 1
|
|
35
|
+
options.intra_op_num_threads = 1
|
|
36
|
+
if isinstance(model, bytes):
|
|
37
|
+
return ort.InferenceSession(model, sess_options=options, providers=["CPUExecutionProvider"])
|
|
38
|
+
return ort.InferenceSession(str(model), sess_options=options, providers=["CPUExecutionProvider"])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Engine:
|
|
42
|
+
"""Load phrase files and score 16 kHz mono int16 frames.
|
|
43
|
+
|
|
44
|
+
``process`` returns the index of the phrase that fired, or ``None``.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, phrases: list[str | Path | bytes], sensitivities: list[float] | None = None):
|
|
48
|
+
if not phrases:
|
|
49
|
+
raise ValueError("load at least one phrase file")
|
|
50
|
+
if sensitivities is None:
|
|
51
|
+
sensitivities = [0.5] * len(phrases)
|
|
52
|
+
if len(sensitivities) != len(phrases):
|
|
53
|
+
raise ValueError("sensitivities must match the phrase files")
|
|
54
|
+
here = Path(__file__).resolve().parent / "models"
|
|
55
|
+
self._mel = _session(here / "melspectrogram.onnx")
|
|
56
|
+
self._embedding = _session(here / "embedding_model.onnx")
|
|
57
|
+
self._mel_input = self._mel.get_inputs()[0].name
|
|
58
|
+
self._embedding_input = self._embedding.get_inputs()[0].name
|
|
59
|
+
self._heads = [_session(phrase) for phrase in phrases]
|
|
60
|
+
self._head_input = self._heads[0].get_inputs()[0].name
|
|
61
|
+
self.sensitivities = [float(value) for value in sensitivities]
|
|
62
|
+
self._audio = np.zeros(0, dtype=np.int16)
|
|
63
|
+
self._scored = 0
|
|
64
|
+
self._previous = [0.0] * len(self._heads)
|
|
65
|
+
self._last_fire = [-1e9] * len(self._heads)
|
|
66
|
+
self._samples = 0
|
|
67
|
+
|
|
68
|
+
def set_sensitivity(self, index: int, sensitivity: float) -> None:
|
|
69
|
+
self.sensitivities[index] = float(sensitivity)
|
|
70
|
+
|
|
71
|
+
def process(self, frame: np.ndarray) -> int | None:
|
|
72
|
+
samples = np.asarray(frame, dtype=np.int16).reshape(-1)
|
|
73
|
+
if samples.size == 0:
|
|
74
|
+
return None
|
|
75
|
+
self._samples += int(samples.size)
|
|
76
|
+
self._audio = np.concatenate([self._audio, samples])
|
|
77
|
+
max_samples = SAMPLE_RATE * 4
|
|
78
|
+
if self._audio.size > max_samples:
|
|
79
|
+
self._audio = self._audio[-max_samples:]
|
|
80
|
+
self._scored = 0
|
|
81
|
+
embeddings = self._embed(self._audio)
|
|
82
|
+
fired: int | None = None
|
|
83
|
+
now = self._samples / SAMPLE_RATE
|
|
84
|
+
while self._scored < len(embeddings):
|
|
85
|
+
index = self._scored
|
|
86
|
+
self._scored += 1
|
|
87
|
+
if index + 1 < HEAD_FRAMES:
|
|
88
|
+
continue
|
|
89
|
+
window = embeddings[index + 1 - HEAD_FRAMES : index + 1]
|
|
90
|
+
choice = self._score(window, now)
|
|
91
|
+
if choice is not None:
|
|
92
|
+
fired = choice
|
|
93
|
+
return fired
|
|
94
|
+
|
|
95
|
+
def process_buffer(self, audio: np.ndarray, frame: int = 1280) -> list[int]:
|
|
96
|
+
"""Feed a whole clip and return the phrase index of each detection."""
|
|
97
|
+
samples = np.asarray(audio, dtype=np.int16).reshape(-1)
|
|
98
|
+
hits = []
|
|
99
|
+
for start in range(0, len(samples), frame):
|
|
100
|
+
index = self.process(samples[start : start + frame])
|
|
101
|
+
if index is not None:
|
|
102
|
+
hits.append(index)
|
|
103
|
+
return hits
|
|
104
|
+
|
|
105
|
+
def _embed(self, audio: np.ndarray) -> np.ndarray:
|
|
106
|
+
spec = self._melspectrogram(audio)
|
|
107
|
+
if spec.shape[0] < MEL_WINDOW:
|
|
108
|
+
return np.zeros((0, EMBEDDING_DIM), dtype=np.float32)
|
|
109
|
+
windows = [
|
|
110
|
+
spec[i : i + MEL_WINDOW]
|
|
111
|
+
for i in range(0, spec.shape[0] - MEL_WINDOW + 1, MEL_STRIDE)
|
|
112
|
+
]
|
|
113
|
+
batch = np.asarray(windows, dtype=np.float32)[..., None]
|
|
114
|
+
raw = self._embedding.run(None, {self._embedding_input: batch})[0]
|
|
115
|
+
return cast(np.ndarray, raw.reshape(batch.shape[0], EMBEDDING_DIM))
|
|
116
|
+
|
|
117
|
+
def _melspectrogram(self, audio: np.ndarray) -> np.ndarray:
|
|
118
|
+
data = np.ascontiguousarray(audio.astype(np.float32))[None, :]
|
|
119
|
+
spec = np.squeeze(self._mel.run(None, {self._mel_input: data})[0])
|
|
120
|
+
if spec.ndim == 1:
|
|
121
|
+
spec = spec[None, :]
|
|
122
|
+
if spec.shape[-1] != 32 and spec.shape[0] == 32:
|
|
123
|
+
spec = spec.T
|
|
124
|
+
return cast(np.ndarray, (spec / 10.0 + 2.0).astype(np.float32))
|
|
125
|
+
|
|
126
|
+
def _score(self, window: np.ndarray, now: float) -> int | None:
|
|
127
|
+
features = np.ascontiguousarray(window.astype(np.float32)[None, :])
|
|
128
|
+
best_index = None
|
|
129
|
+
best_score = -1.0
|
|
130
|
+
for index, head in enumerate(self._heads):
|
|
131
|
+
logit = np.asarray(head.run(None, {self._head_input: features})[0]).reshape(-1)
|
|
132
|
+
score = float(1.0 / (1.0 + np.exp(-float(logit[0]))))
|
|
133
|
+
threshold = threshold_for_sensitivity(self.sensitivities[index])
|
|
134
|
+
crossed = self._previous[index] < threshold <= score
|
|
135
|
+
self._previous[index] = score
|
|
136
|
+
if not crossed:
|
|
137
|
+
continue
|
|
138
|
+
if now - self._last_fire[index] < REFRACTORY_SEC:
|
|
139
|
+
continue
|
|
140
|
+
if score > best_score:
|
|
141
|
+
best_score = score
|
|
142
|
+
best_index = index
|
|
143
|
+
if best_index is None:
|
|
144
|
+
return None
|
|
145
|
+
self._last_fire[best_index] = now
|
|
146
|
+
return best_index
|
wakebox/microphone.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Microphone helper. The frame API is what tests drive."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from wakebox.engine import Engine
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _Capture(Protocol):
|
|
14
|
+
def start(self) -> None: ...
|
|
15
|
+
|
|
16
|
+
def stop(self) -> None: ...
|
|
17
|
+
|
|
18
|
+
def close(self) -> None: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Microphone:
|
|
22
|
+
"""Read the default input and call back with the phrase index."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, engine: Engine, on_detection: Callable[[int], None]):
|
|
25
|
+
self.engine = engine
|
|
26
|
+
self.on_detection = on_detection
|
|
27
|
+
self._stream: _Capture | None = None
|
|
28
|
+
|
|
29
|
+
def start(self) -> None:
|
|
30
|
+
import sounddevice as sd
|
|
31
|
+
|
|
32
|
+
def callback(indata: np.ndarray, frames: int, time: object, status: object) -> None:
|
|
33
|
+
del frames, time, status
|
|
34
|
+
samples = np.asarray(indata, dtype=np.int16).reshape(-1)
|
|
35
|
+
index = self.engine.process(samples)
|
|
36
|
+
if index is not None:
|
|
37
|
+
self.on_detection(index)
|
|
38
|
+
|
|
39
|
+
stream = sd.InputStream(
|
|
40
|
+
samplerate=16000,
|
|
41
|
+
channels=1,
|
|
42
|
+
dtype="int16",
|
|
43
|
+
blocksize=1280,
|
|
44
|
+
callback=callback,
|
|
45
|
+
)
|
|
46
|
+
self._stream = stream
|
|
47
|
+
stream.start()
|
|
48
|
+
|
|
49
|
+
def stop(self) -> None:
|
|
50
|
+
stream = self._stream
|
|
51
|
+
self._stream = None
|
|
52
|
+
if stream is None:
|
|
53
|
+
return
|
|
54
|
+
stream.stop()
|
|
55
|
+
stream.close()
|
|
Binary file
|
|
Binary file
|
wakebox/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wakebox
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: On-device wake word. No key.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: numpy
|
|
9
|
+
Requires-Dist: onnxruntime
|
|
10
|
+
Requires-Dist: sounddevice
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest; extra == "dev"
|
|
13
|
+
Requires-Dist: soundfile; extra == "dev"
|
|
14
|
+
|
|
15
|
+
On-device wake word, no key.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install wakebox
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Create a phrase file at [https://wakebox.ai](https://wakebox.ai), then:
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from wakebox import Engine
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
engine = Engine(["phrase.onnx"], sensitivities=[0.5])
|
|
28
|
+
index = engine.process(np.zeros(1280, dtype=np.int16)) # 0, or None
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`process` takes 16 kHz mono int16 audio and returns the phrase index, or `None`. Sensitivity is a number from 0 to 1 on each file. It is not stored in the file.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
wakebox/__init__.py,sha256=dSxcJf8M9xjNeQEmb8zCeFPF7phrnIB5_zimS7esPF0,204
|
|
2
|
+
wakebox/engine.py,sha256=oWrUp1E7JI5cYcHJ61sIgbQNRl3LD9dnAbwTv4O-K-Y,5934
|
|
3
|
+
wakebox/microphone.py,sha256=LJ0FYWM_hS_tw2EuLsTdaAF8QhKvogH-YCPnz4i9KEY,1430
|
|
4
|
+
wakebox/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
wakebox/models/embedding_model.onnx,sha256=cNFkKQwdCV0dTuFJvF4AVDJQpzFrWfMdBWz_e9MHXB8,1326578
|
|
6
|
+
wakebox/models/melspectrogram.onnx,sha256=uisOD4t7h1NposicsTNg_1O6xDbyiVzO2fR5-mXrF28,1087958
|
|
7
|
+
wakebox-0.1.1.dist-info/METADATA,sha256=rhJNV08uvF8SRvR88ZeOHqX5Dn3jcH6gmgnqqdMb9k8,834
|
|
8
|
+
wakebox-0.1.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
wakebox-0.1.1.dist-info/top_level.txt,sha256=N2fNG_ATcdq0JdNMNUSKwYxHq3iJeM8VEKGpKZYx3B8,8
|
|
10
|
+
wakebox-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
wakebox
|