blitztext-app-linux/linux/blitztext/wakeword.py
mARTin-B78 5aafb572ed feat: dedicated wakeword models for cancel and send actions (v2.03.30)
WakewordActionListener opens a second Wyoming connection during active
wakeword recording, listening for the configured cancel/send models. When
either fires it immediately calls cancel_dictation() or finish_dictation()
without any silence timer or Whisper pass. Settings UI adds Cancel model
and Send model pickers to the wakeword config card, populated from the
same server model list as the trigger model.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 12:07:03 +02:00

299 lines
12 KiB
Python

"""Wakeword detection using Wyoming protocol (openwakeword).
Runs a background thread that captures audio and streams it to a Wyoming
server (e.g. rhasspy/wyoming-openwakeword). When a detection event occurs,
it triggers the main daemon.
Respects /tmp/wake_muted to allow easy desktop integration via scripts.
"""
from __future__ import annotations
import json
import os
import socket
import subprocess
import threading
import time
from urllib.parse import urlparse
from . import logbuffer
MUTE_FILE = "/tmp/wake_muted"
def is_muted() -> bool:
"""True if wakeword detections are currently paused via the mute flag."""
return os.path.exists(MUTE_FILE)
def set_muted(muted: bool) -> None:
"""Pause (create flag) or resume (remove flag) wakeword detection."""
try:
if muted:
open(MUTE_FILE, "a").close()
elif os.path.exists(MUTE_FILE):
os.remove(MUTE_FILE)
except OSError as e: # noqa: BLE001 - mute is best-effort, never crash
logbuffer.log(f"[wakeword] Could not update mute flag: {e}", level="WARNING")
class WakewordListener:
def __init__(self, uri: str, model: str, mic: str, on_detect):
self.uri = uri
self.model = model
self.mic = mic
self.on_detect = on_detect
self._stop_event = threading.Event()
self._thread = None
self._cooldown_until = 0.0
def start(self):
if self._thread is not None:
return
self._stop_event.clear()
self._thread = threading.Thread(target=self._run, daemon=True, name="WakewordListener")
self._thread.start()
def stop(self):
self._stop_event.set()
if self._thread:
self._thread.join(timeout=2.0)
self._thread = None
def _run(self):
while not self._stop_event.is_set():
try:
self._stream()
except Exception as e:
logbuffer.log(f"[wakeword] Connection error: {e}", level="WARNING")
time.sleep(3) # Retry backoff
def _stream(self):
parsed = urlparse(self.uri)
host = parsed.hostname or "127.0.0.1"
port = parsed.port or 10400
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(5.0)
sock.connect((host, port))
logbuffer.log(f"[wakeword] Connected to {self.uri}")
# Request detection for the specific model
detect_msg = {"type": "detect", "data": {"names": [self.model]}}
sock.sendall((json.dumps(detect_msg) + "\n").encode("utf-8"))
audio_start = {"type": "audio-start", "data": {"rate": 16000, "width": 2, "channels": 1}}
sock.sendall((json.dumps(audio_start) + "\n").encode("utf-8"))
# Start reading thread
read_active = True
def read_loop():
try:
sock.settimeout(1.0)
while read_active and not self._stop_event.is_set():
try:
# Read line
line = b""
while not line.endswith(b"\n"):
byte = sock.recv(1)
if not byte:
break
line += byte
if not line:
break
msg = json.loads(line.decode("utf-8"))
if msg.get("type") == "detection":
self._handle_detection()
payload_len = msg.get("payload_length", 0)
if payload_len > 0:
# Consume payload
remaining = payload_len
while remaining > 0:
received = sock.recv(min(remaining, 4096))
if not received:
break
remaining -= len(received)
except socket.timeout:
pass
except Exception:
pass
reader_thread = threading.Thread(target=read_loop, daemon=True)
reader_thread.start()
# Start recording subprocess (16kHz, 16-bit, mono)
cmd = ["pw-record", "--rate=16000", "--channels=1", "--format=s16", "-"]
if self.mic:
cmd.extend(["--target", self.mic])
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
try:
while not self._stop_event.is_set() and proc.poll() is None:
# Read chunk
chunk = proc.stdout.read(3200) # 100ms of 16kHz 16-bit mono
if not chunk:
break
# Send chunk
header = {"type": "audio-chunk", "data": {"rate": 16000, "width": 2, "channels": 1}, "payload_length": len(chunk)}
sock.sendall((json.dumps(header) + "\n").encode("utf-8"))
sock.sendall(chunk)
finally:
read_active = False
proc.terminate()
try:
proc.wait(timeout=1.0)
except subprocess.TimeoutExpired:
proc.kill()
reader_thread.join(timeout=1.0)
def _handle_detection(self):
if time.time() < self._cooldown_until:
return
if is_muted():
logbuffer.log("[wakeword] Detected, but paused (resume via tray)")
return
logbuffer.log(f"[wakeword] Detected '{self.model}'!")
self._cooldown_until = time.time() + 3.0 # 3s cooldown
self.on_detect()
class WakewordActionListener:
"""Listens for multiple wakeword models simultaneously and calls per-model callbacks.
Used during active wakeword recording so that dedicated "cancel" and "send"
wakeword phrases trigger :meth:`~blitztext.daemon.Daemon.cancel_dictation` or
:meth:`~blitztext.daemon.Daemon.finish_dictation` immediately — much faster
than waiting for Whisper to transcribe the whole clip.
``model_callbacks`` is a ``{model_name: callable}`` dict; only the models
present in the dict are requested from the server. No cooldown is applied
because the listener is torn down immediately after the first action fires.
"""
def __init__(self, uri: str, model_callbacks: dict, mic: str):
self.uri = uri
self.model_callbacks = dict(model_callbacks) # {name: callable}
self.mic = mic
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
def start(self) -> None:
if not self.model_callbacks:
return
self._stop_event.clear()
self._thread = threading.Thread(
target=self._run, daemon=True, name="WakewordActionListener")
self._thread.start()
def stop(self) -> None:
self._stop_event.set()
if self._thread:
self._thread.join(timeout=2.0)
self._thread = None
def _run(self) -> None:
while not self._stop_event.is_set():
try:
self._stream()
except Exception as e:
logbuffer.log(f"[wakeword-action] Connection error: {e}", level="WARNING")
time.sleep(2)
def _stream(self) -> None:
parsed = urlparse(self.uri)
host = parsed.hostname or "127.0.0.1"
port = parsed.port or 10400
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(5.0)
sock.connect((host, port))
logbuffer.log(f"[wakeword-action] Connected — listening for {list(self.model_callbacks)}")
detect_msg = {"type": "detect", "data": {"names": list(self.model_callbacks)}}
sock.sendall((json.dumps(detect_msg) + "\n").encode("utf-8"))
audio_start = {"type": "audio-start",
"data": {"rate": 16000, "width": 2, "channels": 1}}
sock.sendall((json.dumps(audio_start) + "\n").encode("utf-8"))
read_active = True
def read_loop() -> None:
try:
sock.settimeout(1.0)
while read_active and not self._stop_event.is_set():
try:
line = b""
while not line.endswith(b"\n"):
byte = sock.recv(1)
if not byte:
return
line += byte
if not line:
return
msg = json.loads(line.decode("utf-8"))
if msg.get("type") == "detection":
name = msg.get("data", {}).get("name", "")
cb = self.model_callbacks.get(name)
if cb is None:
# Try partial match — some servers omit the lang suffix
for k, v in self.model_callbacks.items():
if name.startswith(k) or k.startswith(name):
cb = v
break
if cb:
logbuffer.log(
f"[wakeword-action] '{name}' detected — firing action")
self._stop_event.set() # one-shot: stop after first fire
cb()
payload_len = msg.get("payload_length", 0)
if payload_len > 0:
remaining = payload_len
while remaining > 0:
chunk = sock.recv(min(remaining, 4096))
if not chunk:
break
remaining -= len(chunk)
except socket.timeout:
pass
except Exception:
pass
reader = threading.Thread(target=read_loop, daemon=True)
reader.start()
cmd = ["pw-record", "--rate=16000", "--channels=1", "--format=s16", "-"]
if self.mic:
cmd.extend(["--target", self.mic])
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
try:
while not self._stop_event.is_set() and proc.poll() is None:
chunk = proc.stdout.read(3200)
if not chunk:
break
header = {"type": "audio-chunk",
"data": {"rate": 16000, "width": 2, "channels": 1},
"payload_length": len(chunk)}
sock.sendall((json.dumps(header) + "\n").encode("utf-8"))
sock.sendall(chunk)
finally:
read_active = False
proc.terminate()
try:
proc.wait(timeout=1.0)
except subprocess.TimeoutExpired:
proc.kill()
reader.join(timeout=1.0)