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 ADDED
@@ -0,0 +1,6 @@
1
+ """On-device wake word. No key."""
2
+
3
+ from wakebox.engine import Engine, threshold_for_sensitivity
4
+ from wakebox.microphone import Microphone
5
+
6
+ __all__ = ["Engine", "Microphone", "threshold_for_sensitivity"]
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ wakebox