@staff0rd/assist 0.750.0 → 0.752.0
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.
- package/README.md +10 -26
- package/allowed.cli-reads +0 -1
- package/allowed.cli-writes +0 -1
- package/claude/settings.json +1 -22
- package/claude/skills/review-ci/scripts/review.mjs +111 -111
- package/dist/allowed.cli-reads +0 -1
- package/dist/allowed.cli-writes +0 -1
- package/dist/commands/sessions/web/bundle.js +1 -1
- package/dist/index.js +1835 -2276
- package/package.json +1 -1
- package/claude/commands/handover.md +0 -65
- package/claude/commands/journal.md +0 -74
- package/claude/commands/recall.md +0 -55
- package/claude/commands/standup.md +0 -35
- package/claude/commands/voice-logs.md +0 -5
- package/claude/commands/voice-setup.md +0 -5
- package/claude/commands/voice-start.md +0 -5
- package/claude/commands/voice-status.md +0 -5
- package/claude/commands/voice-stop.md +0 -5
- package/dist/commands/voice/python/CLAUDE.md +0 -11
- package/dist/commands/voice/python/audio_capture.py +0 -52
- package/dist/commands/voice/python/check_mi.py +0 -21
- package/dist/commands/voice/python/dispatch.py +0 -14
- package/dist/commands/voice/python/keyboard.py +0 -73
- package/dist/commands/voice/python/list_devices.py +0 -20
- package/dist/commands/voice/python/logger.py +0 -38
- package/dist/commands/voice/python/pyproject.toml +0 -26
- package/dist/commands/voice/python/setup_models.py +0 -91
- package/dist/commands/voice/python/smart_turn.py +0 -63
- package/dist/commands/voice/python/stt.py +0 -54
- package/dist/commands/voice/python/uv.lock +0 -5946
- package/dist/commands/voice/python/vad.py +0 -50
- package/dist/commands/voice/python/voice_daemon.py +0 -528
- package/dist/commands/voice/python/wake_word.py +0 -26
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
"""Silero VAD wrapper (ONNX)."""
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
|
|
5
|
-
import numpy as np
|
|
6
|
-
import onnxruntime as ort
|
|
7
|
-
|
|
8
|
-
from logger import log
|
|
9
|
-
|
|
10
|
-
DEFAULT_THRESHOLD = 0.5
|
|
11
|
-
CONTEXT_SIZE = 64 # v5/v6 requires 64 context samples prepended at 16kHz
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
class SileroVAD:
|
|
15
|
-
def __init__(self):
|
|
16
|
-
model_path = os.environ.get("VOICE_MODEL_VAD")
|
|
17
|
-
if not model_path:
|
|
18
|
-
models_dir = os.environ.get(
|
|
19
|
-
"VOICE_MODELS_DIR",
|
|
20
|
-
os.path.expanduser("~/.assist/voice/models"),
|
|
21
|
-
)
|
|
22
|
-
model_path = os.path.join(models_dir, "silero_vad.onnx")
|
|
23
|
-
|
|
24
|
-
log("vad_init", f"model={model_path}")
|
|
25
|
-
self._session = ort.InferenceSession(
|
|
26
|
-
model_path, providers=["CPUExecutionProvider"]
|
|
27
|
-
)
|
|
28
|
-
self._state = np.zeros((2, 1, 128), dtype=np.float32)
|
|
29
|
-
self._context = np.zeros(CONTEXT_SIZE, dtype=np.float32)
|
|
30
|
-
self._sample_rate = np.array(16000, dtype=np.int64)
|
|
31
|
-
self.threshold = DEFAULT_THRESHOLD
|
|
32
|
-
|
|
33
|
-
def process(self, audio: np.ndarray) -> float:
|
|
34
|
-
"""Process a chunk of audio, return speech probability."""
|
|
35
|
-
chunk = audio.astype(np.float32)
|
|
36
|
-
# Prepend context (last 64 samples from previous chunk)
|
|
37
|
-
input_data = np.concatenate([self._context, chunk]).reshape(1, -1)
|
|
38
|
-
ort_inputs = {
|
|
39
|
-
"input": input_data,
|
|
40
|
-
"state": self._state,
|
|
41
|
-
"sr": self._sample_rate,
|
|
42
|
-
}
|
|
43
|
-
out, state = self._session.run(None, ort_inputs)
|
|
44
|
-
self._state = state
|
|
45
|
-
self._context = chunk[-CONTEXT_SIZE:]
|
|
46
|
-
return float(out[0][0])
|
|
47
|
-
|
|
48
|
-
def reset(self) -> None:
|
|
49
|
-
self._state = np.zeros((2, 1, 128), dtype=np.float32)
|
|
50
|
-
self._context = np.zeros(CONTEXT_SIZE, dtype=np.float32)
|
|
@@ -1,528 +0,0 @@
|
|
|
1
|
-
"""Voice daemon entry point — main loop and signal handling."""
|
|
2
|
-
|
|
3
|
-
import ctypes
|
|
4
|
-
import json
|
|
5
|
-
import os
|
|
6
|
-
import signal
|
|
7
|
-
import subprocess
|
|
8
|
-
import sys
|
|
9
|
-
import time
|
|
10
|
-
|
|
11
|
-
import numpy as np
|
|
12
|
-
|
|
13
|
-
from audio_capture import AudioCapture, BLOCK_SIZE
|
|
14
|
-
from logger import DEBUG, log
|
|
15
|
-
from smart_turn import SmartTurn
|
|
16
|
-
from stt import ParakeetSTT
|
|
17
|
-
from vad import SileroVAD
|
|
18
|
-
from wake_word import check_wake_word
|
|
19
|
-
|
|
20
|
-
import keyboard
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def _load_voice_config() -> dict:
|
|
24
|
-
"""Load voice config by calling ``assist config get voice``.
|
|
25
|
-
|
|
26
|
-
Sets environment variables so other Python modules (audio_capture,
|
|
27
|
-
vad, smart_turn, stt, wake_word) can read them as before.
|
|
28
|
-
Returns the parsed config dict.
|
|
29
|
-
"""
|
|
30
|
-
try:
|
|
31
|
-
result = subprocess.run(
|
|
32
|
-
["assist", "config", "get", "voice"],
|
|
33
|
-
capture_output=True,
|
|
34
|
-
text=True,
|
|
35
|
-
check=True,
|
|
36
|
-
shell=True,
|
|
37
|
-
)
|
|
38
|
-
config = json.loads(result.stdout)
|
|
39
|
-
except (subprocess.CalledProcessError, json.JSONDecodeError) as exc:
|
|
40
|
-
log("config_error", str(exc), level="error")
|
|
41
|
-
return {}
|
|
42
|
-
|
|
43
|
-
env_map: dict[str, str | None] = {
|
|
44
|
-
"VOICE_WAKE_WORDS": ",".join(config.get("wakeWords", [])) or None,
|
|
45
|
-
"VOICE_MIC": config.get("mic"),
|
|
46
|
-
"VOICE_MODELS_DIR": os.path.expanduser(v)
|
|
47
|
-
if (v := config.get("modelsDir"))
|
|
48
|
-
else None,
|
|
49
|
-
"VOICE_MODEL_VAD": (config.get("models") or {}).get("vad"),
|
|
50
|
-
"VOICE_MODEL_SMART_TURN": (config.get("models") or {}).get("smartTurn"),
|
|
51
|
-
}
|
|
52
|
-
for key, value in env_map.items():
|
|
53
|
-
if value:
|
|
54
|
-
os.environ[key] = value
|
|
55
|
-
|
|
56
|
-
log("config_loaded", json.dumps(config))
|
|
57
|
-
return config
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def _get_foreground_window_info() -> str:
|
|
61
|
-
"""Return the title and process name of the currently focused window."""
|
|
62
|
-
user32 = ctypes.windll.user32
|
|
63
|
-
kernel32 = ctypes.windll.kernel32
|
|
64
|
-
hwnd = user32.GetForegroundWindow()
|
|
65
|
-
|
|
66
|
-
# Window title
|
|
67
|
-
buf = ctypes.create_unicode_buffer(256)
|
|
68
|
-
user32.GetWindowTextW(hwnd, buf, 256)
|
|
69
|
-
title = buf.value
|
|
70
|
-
|
|
71
|
-
# Process name from window handle
|
|
72
|
-
pid = ctypes.c_ulong()
|
|
73
|
-
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
|
74
|
-
process_name = ""
|
|
75
|
-
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
76
|
-
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid.value)
|
|
77
|
-
if handle:
|
|
78
|
-
exe_buf = ctypes.create_unicode_buffer(260)
|
|
79
|
-
size = ctypes.c_ulong(260)
|
|
80
|
-
if kernel32.QueryFullProcessImageNameW(handle, 0, exe_buf, ctypes.byref(size)):
|
|
81
|
-
process_name = exe_buf.value.rsplit("\\", 1)[-1]
|
|
82
|
-
kernel32.CloseHandle(handle)
|
|
83
|
-
|
|
84
|
-
if process_name:
|
|
85
|
-
return f"{process_name}: {title}"
|
|
86
|
-
return title
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
# States
|
|
90
|
-
IDLE = "idle"
|
|
91
|
-
LISTENING = "listening"
|
|
92
|
-
ACTIVATED = "activated" # wake word heard (alone), waiting for command utterance
|
|
93
|
-
|
|
94
|
-
# Max seconds of speech before forced processing
|
|
95
|
-
MAX_SPEECH_SECONDS = 30
|
|
96
|
-
|
|
97
|
-
# How often (in samples) to run partial STT during speech
|
|
98
|
-
PARTIAL_STT_INTERVAL = 16000 # every 1 second of audio
|
|
99
|
-
|
|
100
|
-
# Trailing silence (in ms) required before sending segment to smart turn.
|
|
101
|
-
# Matches the reference implementation (record_and_predict.py STOP_MS=1000).
|
|
102
|
-
STOP_MS = 1000
|
|
103
|
-
STOP_CHUNKS = (STOP_MS * 16000) // (BLOCK_SIZE * 1000) # ~31 chunks
|
|
104
|
-
|
|
105
|
-
# How long (seconds) to wait for a command after a wake-word-only utterance
|
|
106
|
-
ACTIVATED_TIMEOUT = 10.0
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def _print_state(state: str) -> None:
|
|
110
|
-
"""Print the current daemon state to stderr when debug mode is on."""
|
|
111
|
-
print(f"\r {state:10s}", end="", file=sys.stderr, flush=True)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
class VoiceDaemon:
|
|
115
|
-
def __init__(self):
|
|
116
|
-
self._running = True
|
|
117
|
-
self._state = IDLE
|
|
118
|
-
self._audio_buffer: list[np.ndarray] = []
|
|
119
|
-
|
|
120
|
-
config = _load_voice_config()
|
|
121
|
-
self._submit_windows: set[str] = set(config.get("submitWindows") or [])
|
|
122
|
-
|
|
123
|
-
log("daemon_init", "Initializing models...")
|
|
124
|
-
self._mic = AudioCapture()
|
|
125
|
-
self._vad = SileroVAD()
|
|
126
|
-
self._smart_turn = SmartTurn()
|
|
127
|
-
self._stt = ParakeetSTT()
|
|
128
|
-
if self._submit_windows:
|
|
129
|
-
log("daemon_init", f"Submit windows: {self._submit_windows}")
|
|
130
|
-
log("daemon_ready")
|
|
131
|
-
|
|
132
|
-
# Incremental typing state
|
|
133
|
-
self._wake_detected = False
|
|
134
|
-
self._typed_text = ""
|
|
135
|
-
self._last_partial_at = 0
|
|
136
|
-
self._activated_at = 0.0
|
|
137
|
-
|
|
138
|
-
def _handle_signal(self, signum, frame) -> None:
|
|
139
|
-
log("daemon_signal", f"Received signal {signum}")
|
|
140
|
-
self._running = False
|
|
141
|
-
|
|
142
|
-
def _run_partial_stt(self) -> None:
|
|
143
|
-
"""Run STT on accumulated audio and type incrementally."""
|
|
144
|
-
if not self._audio_buffer:
|
|
145
|
-
return
|
|
146
|
-
|
|
147
|
-
audio = np.concatenate(self._audio_buffer)
|
|
148
|
-
text = self._stt.transcribe(audio)
|
|
149
|
-
if not text.strip():
|
|
150
|
-
return
|
|
151
|
-
|
|
152
|
-
if DEBUG:
|
|
153
|
-
print(f"\n Partial: {text}", file=sys.stderr)
|
|
154
|
-
|
|
155
|
-
if self._state == ACTIVATED:
|
|
156
|
-
# Already activated — everything is the command, no wake word needed
|
|
157
|
-
partial = text.strip()
|
|
158
|
-
if partial and partial != self._typed_text:
|
|
159
|
-
if self._typed_text:
|
|
160
|
-
self._update_typed_text(partial)
|
|
161
|
-
else:
|
|
162
|
-
keyboard.type_text(partial)
|
|
163
|
-
self._typed_text = partial
|
|
164
|
-
elif not self._wake_detected:
|
|
165
|
-
found, command = check_wake_word(text)
|
|
166
|
-
if found and command:
|
|
167
|
-
self._wake_detected = True
|
|
168
|
-
log("wake_word_detected", command)
|
|
169
|
-
if DEBUG:
|
|
170
|
-
print(f" Wake word! Typing: {command}", file=sys.stderr)
|
|
171
|
-
keyboard.type_text(command)
|
|
172
|
-
self._typed_text = command
|
|
173
|
-
else:
|
|
174
|
-
found, command = check_wake_word(text)
|
|
175
|
-
if found and command:
|
|
176
|
-
if command != self._typed_text:
|
|
177
|
-
self._update_typed_text(command)
|
|
178
|
-
|
|
179
|
-
def _should_submit(self) -> bool:
|
|
180
|
-
"""Check if the foreground window matches the submit allowlist."""
|
|
181
|
-
if not self._submit_windows:
|
|
182
|
-
return True
|
|
183
|
-
info = _get_foreground_window_info()
|
|
184
|
-
process_name = info.split(":")[0].strip() if ":" in info else ""
|
|
185
|
-
return process_name in self._submit_windows
|
|
186
|
-
|
|
187
|
-
def _update_typed_text(self, new_text: str) -> None:
|
|
188
|
-
"""Diff old typed text vs new at word level, minimising deletions.
|
|
189
|
-
|
|
190
|
-
Only deletes back to the first word that changed; appends everything
|
|
191
|
-
after the common word prefix. This avoids the jarring full-delete
|
|
192
|
-
that character-level diffing causes when earlier words shift slightly.
|
|
193
|
-
"""
|
|
194
|
-
old = self._typed_text
|
|
195
|
-
if old == new_text:
|
|
196
|
-
return
|
|
197
|
-
|
|
198
|
-
old_words = old.split()
|
|
199
|
-
new_words = new_text.split()
|
|
200
|
-
|
|
201
|
-
# Find the longest common word prefix
|
|
202
|
-
common_words = 0
|
|
203
|
-
for a, b in zip(old_words, new_words):
|
|
204
|
-
if a == b:
|
|
205
|
-
common_words += 1
|
|
206
|
-
else:
|
|
207
|
-
break
|
|
208
|
-
|
|
209
|
-
# Character position where the common word prefix ends (including
|
|
210
|
-
# the trailing space after the last common word, if any).
|
|
211
|
-
if common_words == 0:
|
|
212
|
-
keep_chars = 0
|
|
213
|
-
else:
|
|
214
|
-
# Rejoin the common words and add one space (the separator before
|
|
215
|
-
# the next word that was already typed).
|
|
216
|
-
keep = " ".join(old_words[:common_words])
|
|
217
|
-
# Only count the trailing space if there were more old words after
|
|
218
|
-
# the common prefix (meaning that space is already on screen).
|
|
219
|
-
if common_words < len(old_words):
|
|
220
|
-
keep_chars = len(keep) + 1 # +1 for the space
|
|
221
|
-
else:
|
|
222
|
-
keep_chars = len(keep)
|
|
223
|
-
|
|
224
|
-
to_delete = len(old) - keep_chars
|
|
225
|
-
if to_delete > 0:
|
|
226
|
-
keyboard.backspace(to_delete)
|
|
227
|
-
|
|
228
|
-
# Build the new suffix to type from the first divergent word onward
|
|
229
|
-
if common_words == 0:
|
|
230
|
-
to_type = new_text
|
|
231
|
-
else:
|
|
232
|
-
suffix = " ".join(new_words[common_words:])
|
|
233
|
-
if suffix:
|
|
234
|
-
# Need a space separator if we kept text and are appending
|
|
235
|
-
if keep_chars > 0 and common_words < len(old_words):
|
|
236
|
-
to_type = suffix
|
|
237
|
-
elif keep_chars > 0:
|
|
238
|
-
to_type = " " + suffix
|
|
239
|
-
else:
|
|
240
|
-
to_type = suffix
|
|
241
|
-
else:
|
|
242
|
-
to_type = ""
|
|
243
|
-
|
|
244
|
-
if to_type:
|
|
245
|
-
keyboard.type_text(to_type)
|
|
246
|
-
self._typed_text = new_text
|
|
247
|
-
|
|
248
|
-
def _process_audio_chunk(
|
|
249
|
-
self, chunk, prob: float, sample_count: int, trailing_silence: int
|
|
250
|
-
) -> tuple[int, int]:
|
|
251
|
-
"""Buffer audio chunk, run partial STT, and check for segment end."""
|
|
252
|
-
self._audio_buffer.append(chunk)
|
|
253
|
-
sample_count += len(chunk)
|
|
254
|
-
|
|
255
|
-
if prob > self._vad.threshold:
|
|
256
|
-
trailing_silence = 0
|
|
257
|
-
else:
|
|
258
|
-
trailing_silence += 1
|
|
259
|
-
|
|
260
|
-
if sample_count - self._last_partial_at >= PARTIAL_STT_INTERVAL:
|
|
261
|
-
self._last_partial_at = sample_count
|
|
262
|
-
self._run_partial_stt()
|
|
263
|
-
|
|
264
|
-
is_end, reset_silence = self._check_segment_end(sample_count, trailing_silence)
|
|
265
|
-
if is_end:
|
|
266
|
-
self._finalize_utterance()
|
|
267
|
-
sample_count = 0
|
|
268
|
-
trailing_silence = 0
|
|
269
|
-
elif reset_silence:
|
|
270
|
-
trailing_silence = 0
|
|
271
|
-
|
|
272
|
-
return sample_count, trailing_silence
|
|
273
|
-
|
|
274
|
-
def _check_segment_end(
|
|
275
|
-
self, sample_count: int, trailing_silence: int
|
|
276
|
-
) -> tuple[bool, bool]:
|
|
277
|
-
"""Check if the current segment is done.
|
|
278
|
-
|
|
279
|
-
Follows the reference smart-turn implementation:
|
|
280
|
-
1. Accumulate speech + trailing silence.
|
|
281
|
-
2. After STOP_MS of continuous silence, send the full segment to smart turn.
|
|
282
|
-
3. If smart turn says "Incomplete", keep listening.
|
|
283
|
-
4. If smart turn says "Complete", finalize.
|
|
284
|
-
5. Hard cap at MAX_SPEECH_SECONDS always finalizes.
|
|
285
|
-
|
|
286
|
-
Returns (is_end, reset_silence). When reset_silence is True the caller
|
|
287
|
-
must zero trailing_silence so that another full STOP_MS of silence is
|
|
288
|
-
required before re-checking smart turn.
|
|
289
|
-
"""
|
|
290
|
-
max_samples = MAX_SPEECH_SECONDS * 16000
|
|
291
|
-
|
|
292
|
-
if sample_count >= max_samples:
|
|
293
|
-
log("max_speech", "Reached max speech duration")
|
|
294
|
-
return True, False
|
|
295
|
-
|
|
296
|
-
if trailing_silence >= STOP_CHUNKS:
|
|
297
|
-
audio_so_far = np.concatenate(self._audio_buffer)
|
|
298
|
-
is_complete = self._smart_turn.is_end_of_turn(audio_so_far)
|
|
299
|
-
if DEBUG:
|
|
300
|
-
label = "Complete" if is_complete else "Incomplete"
|
|
301
|
-
print(f"\n Smart turn: {label}", file=sys.stderr)
|
|
302
|
-
if is_complete:
|
|
303
|
-
return True, False
|
|
304
|
-
else:
|
|
305
|
-
log("smart_turn_incomplete", "Continuing to listen...")
|
|
306
|
-
return False, True
|
|
307
|
-
return False, False
|
|
308
|
-
|
|
309
|
-
def _dispatch_result(self, text: str) -> None:
|
|
310
|
-
"""Log and optionally submit a recognized command."""
|
|
311
|
-
should_submit = self._should_submit()
|
|
312
|
-
if should_submit:
|
|
313
|
-
log("dispatch_enter", text)
|
|
314
|
-
if DEBUG:
|
|
315
|
-
print(f" Final: {text} [Enter]", file=sys.stderr)
|
|
316
|
-
keyboard.press_enter()
|
|
317
|
-
else:
|
|
318
|
-
log("dispatch_typed", text)
|
|
319
|
-
if DEBUG:
|
|
320
|
-
print(f" Final: {text} (no submit)", file=sys.stderr)
|
|
321
|
-
|
|
322
|
-
def _finalize_utterance(self) -> None:
|
|
323
|
-
"""End of turn: final STT, correct typed text, press Enter."""
|
|
324
|
-
if not self._audio_buffer:
|
|
325
|
-
self._reset_listening()
|
|
326
|
-
return
|
|
327
|
-
|
|
328
|
-
audio = np.concatenate(self._audio_buffer)
|
|
329
|
-
duration = len(audio) / 16000
|
|
330
|
-
log("end_of_turn", f"audio_length={duration:.1f}s")
|
|
331
|
-
|
|
332
|
-
if DEBUG:
|
|
333
|
-
print(file=sys.stderr)
|
|
334
|
-
|
|
335
|
-
text = self._stt.transcribe(audio)
|
|
336
|
-
|
|
337
|
-
if self._state == ACTIVATED:
|
|
338
|
-
self._finalize_activated_command(text)
|
|
339
|
-
self._reset_listening()
|
|
340
|
-
return
|
|
341
|
-
|
|
342
|
-
if self._wake_detected:
|
|
343
|
-
self._finalize_streamed_wake_word(text)
|
|
344
|
-
elif not self._finalize_check_final_text(text):
|
|
345
|
-
return # entered ACTIVATED state, don't reset to IDLE
|
|
346
|
-
|
|
347
|
-
self._reset_listening()
|
|
348
|
-
|
|
349
|
-
def _finalize_activated_command(self, text: str) -> None:
|
|
350
|
-
"""Finalize utterance in ACTIVATED state (full text is the command)."""
|
|
351
|
-
command = text.strip()
|
|
352
|
-
if command:
|
|
353
|
-
if command != self._typed_text:
|
|
354
|
-
if self._typed_text:
|
|
355
|
-
self._update_typed_text(command)
|
|
356
|
-
else:
|
|
357
|
-
keyboard.type_text(command)
|
|
358
|
-
self._dispatch_result(command)
|
|
359
|
-
else:
|
|
360
|
-
if self._typed_text:
|
|
361
|
-
keyboard.backspace(len(self._typed_text))
|
|
362
|
-
log("dispatch_cancelled", "Empty command in activated mode")
|
|
363
|
-
|
|
364
|
-
def _finalize_streamed_wake_word(self, text: str) -> None:
|
|
365
|
-
"""Finalize when wake word was detected during streaming."""
|
|
366
|
-
found, command = check_wake_word(text)
|
|
367
|
-
if found and command:
|
|
368
|
-
if command != self._typed_text:
|
|
369
|
-
self._update_typed_text(command)
|
|
370
|
-
self._dispatch_result(command)
|
|
371
|
-
elif found:
|
|
372
|
-
if self._typed_text:
|
|
373
|
-
keyboard.backspace(len(self._typed_text))
|
|
374
|
-
log("dispatch_cancelled", "No command after wake word")
|
|
375
|
-
elif self._typed_text:
|
|
376
|
-
# Final transcription lost the wake word (e.g. audio clipping
|
|
377
|
-
# turned "computer" into "uter"); fall back to the command
|
|
378
|
-
# captured during streaming
|
|
379
|
-
self._dispatch_result(self._typed_text)
|
|
380
|
-
|
|
381
|
-
def _finalize_check_final_text(self, text: str) -> bool:
|
|
382
|
-
"""Check final transcription for wake word.
|
|
383
|
-
|
|
384
|
-
Returns True if caller should reset to IDLE, False if entering ACTIVATED.
|
|
385
|
-
"""
|
|
386
|
-
found, command = check_wake_word(text)
|
|
387
|
-
if found and command:
|
|
388
|
-
log("wake_word_detected", command)
|
|
389
|
-
if DEBUG:
|
|
390
|
-
print(f" Wake word! Final: {command}", file=sys.stderr)
|
|
391
|
-
keyboard.type_text(command)
|
|
392
|
-
self._dispatch_result(command)
|
|
393
|
-
if found and not command:
|
|
394
|
-
# Wake word only — enter ACTIVATED state for next utterance
|
|
395
|
-
log("wake_word_only", "Listening for command...")
|
|
396
|
-
if DEBUG:
|
|
397
|
-
print(" Wake word heard — listening for command...", file=sys.stderr)
|
|
398
|
-
self._audio_buffer.clear()
|
|
399
|
-
self._vad.reset()
|
|
400
|
-
self._wake_detected = False
|
|
401
|
-
self._typed_text = ""
|
|
402
|
-
self._last_partial_at = 0
|
|
403
|
-
self._activated_at = time.monotonic()
|
|
404
|
-
self._state = ACTIVATED
|
|
405
|
-
return False
|
|
406
|
-
elif not found:
|
|
407
|
-
log("no_wake_word", text)
|
|
408
|
-
if DEBUG:
|
|
409
|
-
print(f" No wake word: {text}", file=sys.stderr)
|
|
410
|
-
return True
|
|
411
|
-
|
|
412
|
-
def _reset_listening(self) -> None:
|
|
413
|
-
self._audio_buffer.clear()
|
|
414
|
-
self._vad.reset()
|
|
415
|
-
self._wake_detected = False
|
|
416
|
-
self._typed_text = ""
|
|
417
|
-
self._last_partial_at = 0
|
|
418
|
-
self._activated_at = 0.0
|
|
419
|
-
self._state = IDLE
|
|
420
|
-
|
|
421
|
-
def _check_activated_timeout(self) -> bool:
|
|
422
|
-
"""If in ACTIVATED state with no audio buffered, check for timeout.
|
|
423
|
-
|
|
424
|
-
Returns True if timed out and state was reset.
|
|
425
|
-
"""
|
|
426
|
-
if self._state != ACTIVATED or self._audio_buffer:
|
|
427
|
-
return False
|
|
428
|
-
if time.monotonic() - self._activated_at <= ACTIVATED_TIMEOUT:
|
|
429
|
-
return False
|
|
430
|
-
log("activated_timeout", "No command received")
|
|
431
|
-
if DEBUG:
|
|
432
|
-
print("\n Activation timed out", file=sys.stderr)
|
|
433
|
-
self._reset_listening()
|
|
434
|
-
return True
|
|
435
|
-
|
|
436
|
-
def run(self) -> None:
|
|
437
|
-
signal.signal(signal.SIGTERM, self._handle_signal)
|
|
438
|
-
signal.signal(signal.SIGINT, self._handle_signal)
|
|
439
|
-
|
|
440
|
-
log("daemon_start", "Starting audio capture...")
|
|
441
|
-
self._mic.start()
|
|
442
|
-
|
|
443
|
-
if DEBUG:
|
|
444
|
-
print("Listening... (Ctrl+C to stop)", file=sys.stderr)
|
|
445
|
-
|
|
446
|
-
sample_count = 0
|
|
447
|
-
trailing_silence = 0
|
|
448
|
-
|
|
449
|
-
try:
|
|
450
|
-
while self._running:
|
|
451
|
-
chunk = self._mic.read(timeout=0.5)
|
|
452
|
-
if chunk is None:
|
|
453
|
-
self._check_activated_timeout()
|
|
454
|
-
continue
|
|
455
|
-
|
|
456
|
-
prob = self._vad.process(chunk)
|
|
457
|
-
|
|
458
|
-
if DEBUG:
|
|
459
|
-
_print_state(self._state)
|
|
460
|
-
|
|
461
|
-
if self._state == IDLE:
|
|
462
|
-
if prob > self._vad.threshold:
|
|
463
|
-
log("speech_start")
|
|
464
|
-
self._state = LISTENING
|
|
465
|
-
self._audio_buffer.append(chunk)
|
|
466
|
-
sample_count = len(chunk)
|
|
467
|
-
trailing_silence = 0
|
|
468
|
-
self._last_partial_at = 0
|
|
469
|
-
|
|
470
|
-
elif self._state == ACTIVATED:
|
|
471
|
-
if self._check_activated_timeout():
|
|
472
|
-
continue
|
|
473
|
-
|
|
474
|
-
if prob > self._vad.threshold and not self._audio_buffer:
|
|
475
|
-
log("speech_start", "command after activation")
|
|
476
|
-
|
|
477
|
-
if prob > self._vad.threshold or self._audio_buffer:
|
|
478
|
-
sample_count, trailing_silence = self._process_audio_chunk(
|
|
479
|
-
chunk, prob, sample_count, trailing_silence
|
|
480
|
-
)
|
|
481
|
-
|
|
482
|
-
elif self._state == LISTENING:
|
|
483
|
-
sample_count, trailing_silence = self._process_audio_chunk(
|
|
484
|
-
chunk, prob, sample_count, trailing_silence
|
|
485
|
-
)
|
|
486
|
-
|
|
487
|
-
finally:
|
|
488
|
-
if DEBUG:
|
|
489
|
-
print(file=sys.stderr)
|
|
490
|
-
self._mic.stop()
|
|
491
|
-
log("daemon_stop", "Voice daemon stopped")
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
def _setup_console() -> None:
|
|
495
|
-
"""On Windows, reopen stdout/stderr to the process's own console (CONOUT$).
|
|
496
|
-
|
|
497
|
-
When launched as a detached background process, Node.js redirects stdio to
|
|
498
|
-
NUL. Windows still allocates a console window for the process, but nothing
|
|
499
|
-
appears in it. Opening CONOUT$ gives us a handle to that console so all
|
|
500
|
-
debug output shows up there.
|
|
501
|
-
"""
|
|
502
|
-
if sys.platform != "win32":
|
|
503
|
-
return
|
|
504
|
-
try:
|
|
505
|
-
con = open("CONOUT$", "w", encoding="utf-8")
|
|
506
|
-
sys.stdout = con
|
|
507
|
-
sys.stderr = con
|
|
508
|
-
except OSError:
|
|
509
|
-
pass
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
def main() -> None:
|
|
513
|
-
_setup_console()
|
|
514
|
-
log("daemon_launch", f"PID={os.getpid()}")
|
|
515
|
-
try:
|
|
516
|
-
daemon = VoiceDaemon()
|
|
517
|
-
daemon.run()
|
|
518
|
-
except Exception as e:
|
|
519
|
-
log("daemon_crash", str(e), level="error")
|
|
520
|
-
if DEBUG:
|
|
521
|
-
import traceback
|
|
522
|
-
|
|
523
|
-
traceback.print_exc(file=sys.stderr)
|
|
524
|
-
sys.exit(1)
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
if __name__ == "__main__":
|
|
528
|
-
main()
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
"""Keyword detection in transcribed text."""
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
|
|
5
|
-
from logger import log
|
|
6
|
-
|
|
7
|
-
DEFAULT_WAKE_WORDS = ["computer"]
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def get_wake_words() -> list[str]:
|
|
11
|
-
env = os.environ.get("VOICE_WAKE_WORDS", "")
|
|
12
|
-
if env:
|
|
13
|
-
return [w.strip().lower() for w in env.split(",") if w.strip()]
|
|
14
|
-
return DEFAULT_WAKE_WORDS
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def check_wake_word(text: str) -> tuple[bool, str]:
|
|
18
|
-
"""Check if text contains a wake word. Returns (found, remaining_text)."""
|
|
19
|
-
lower = text.lower()
|
|
20
|
-
for word in get_wake_words():
|
|
21
|
-
idx = lower.find(word)
|
|
22
|
-
if idx != -1:
|
|
23
|
-
remaining = text[idx + len(word) :].strip().lstrip(",").strip()
|
|
24
|
-
log("wake_word_detected", word, remaining=remaining)
|
|
25
|
-
return True, remaining
|
|
26
|
-
return False, text
|