blitztext-app-linux/linux/blitztext/wakeword.py
mARTin-B78 5484bc6a1b Add Wayland, Tests, CI, and OpenWakeword integration
- Wayland support: text delivery using wtype or ydotool (paste.py)
- Testing: added Pytest coverage for routing, quality, and config logic
- CI/CD: added test-linux job to .github/workflows/ci.yml
- Feature: Hands-free dictation using an external wyoming-openwakeword
  server, respecting the /tmp/wake_muted toggle.
2026-06-05 17:16:05 +02:00

142 lines
5.1 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"
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}")
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 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:
sock.settimeout(1.0)
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)
# Check for responses (detections)
try:
while True:
# 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 # No messages received, continue streaming
finally:
proc.terminate()
try:
proc.wait(timeout=1.0)
except subprocess.TimeoutExpired:
proc.kill()
def _handle_detection(self):
if time.time() < self._cooldown_until:
return
if os.path.exists(_MUTE_FILE):
logbuffer.log("[wakeword] Detected, but muted via /tmp/wake_muted")
return
logbuffer.log(f"[wakeword] Detected '{self.model}'!")
self._cooldown_until = time.time() + 3.0 # 3s cooldown
self.on_detect()