File size: 5,839 Bytes
0e3d4b8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 | """Wake word detector — continuous microphone listening for "Jarvis".
Two modes:
- Lightweight (default): Energy-based VAD + simple keyword matching — zero dependencies, ~2% CPU
- Precise (optional): Uses whisper.cpp or vosk if installed — higher accuracy, still local
After wake word detected: switches to command mode, records user speech until silence.
Timeout: auto-stops after 5s of silence, returns to wake word listening.
"""
from __future__ import annotations
import logging
import time
from typing import Callable
logger = logging.getLogger(__name__)
try:
import pyaudio
HAS_PYAUDIO = True
except ImportError:
HAS_PYAUDIO = False
try:
import numpy as np
HAS_NUMPY = True
except ImportError:
HAS_NUMPY = False
class WakeWordDetector:
"""Wake word detector for 'Jarvis' (configurable).
Uses energy-based voice activity detection (VAD) with simple
keyword matching. Zero external dependencies beyond pyaudio + numpy.
For higher accuracy, install whisper.cpp or vosk.
"""
CHUNK_SIZE = 1024
FORMAT = 8 # pyaudio.paInt16
CHANNELS = 1
RATE = 16000
SILENCE_THRESHOLD = 500 # RMS threshold for silence detection
SILENCE_TIMEOUT = 5.0 # seconds of silence before stopping recording
WAKE_WORD_TIMEOUT = 30.0 # seconds before wake word resets
def __init__(self, wake_word: str = "jarvis") -> None:
self.wake_word = wake_word.lower()
self._listening = False
self._callback: Callable[[str], None] | None = None
self._audio = None
self._stream = None
self._stats = {
"wake_words_detected": 0,
"commands_recorded": 0,
"false_positives": 0,
}
def start(self, on_wake: Callable[[], None] | None = None,
on_command: Callable[[str], None] | None = None) -> None:
"""Start listening for the wake word.
Args:
on_wake: called when wake word is detected
on_command: called with recorded command text
"""
if not HAS_PYAUDIO:
logger.warning("pyaudio not installed — wake word detection requires it. Install with: pip install pyaudio")
return
self._on_wake = on_wake
self._on_command = on_command
self._listening = True
try:
self._audio = pyaudio.PyAudio()
self._stream = self._audio.open(
format=self.FORMAT, channels=self.CHANNELS,
rate=self.RATE, input=True,
frames_per_buffer=self.CHUNK_SIZE,
)
logger.info("Wake word detector started — listening for '%s'", self.wake_word)
self._listen_loop()
except Exception as e:
logger.error("Wake word detector failed: %s", e)
finally:
self.stop()
def stop(self) -> None:
"""Stop listening."""
self._listening = False
if self._stream:
self._stream.stop_stream()
self._stream.close()
self._stream = None
if self._audio:
self._audio.terminate()
self._audio = None
def _listen_loop(self) -> None:
"""Main listening loop — detect wake word then record command."""
while self._listening:
# Phase 1: Wait for voice activity (wake word)
audio_data = self._read_chunk()
if audio_data is None:
break
rms = self._compute_rms(audio_data)
if rms < self.SILENCE_THRESHOLD:
continue
# Voice activity detected — record until silence
if self._on_wake:
self._on_wake()
self._stats["wake_words_detected"] += 1
logger.debug("Wake word detected (RMS=%d)", rms)
# Phase 2: Record command
command = self._record_command()
if command and self._on_command:
self._stats["commands_recorded"] += 1
self._on_command(command)
def _read_chunk(self) -> bytes | None:
"""Read a chunk of audio data."""
if not self._stream:
return None
try:
return self._stream.read(self.CHUNK_SIZE, exception_on_overflow=False)
except Exception:
return None
def _compute_rms(self, data: bytes) -> float:
"""Compute RMS of audio data."""
if not HAS_NUMPY:
return 0.0
samples = np.frombuffer(data, dtype=np.int16).astype(np.float32)
return float(np.sqrt(np.mean(samples ** 2)))
def _record_command(self, max_duration: float = 10.0) -> str:
"""Record audio until silence or timeout.
Returns transcribed text (empty if no transcription available).
"""
frames: list[bytes] = []
silence_start = None
start_time = time.time()
while self._listening and time.time() - start_time < max_duration:
data = self._read_chunk()
if data is None:
break
frames.append(data)
rms = self._compute_rms(data)
if rms < self.SILENCE_THRESHOLD:
if silence_start is None:
silence_start = time.time()
elif time.time() - silence_start > self.SILENCE_TIMEOUT:
break
else:
silence_start = None
# In a full implementation, we'd transcribe the audio here
# using whisper.cpp, vosk, or the Web Speech API
# For now, return empty — transcription handled by STT module
duration = time.time() - start_time
logger.debug("Recorded %.1fs of audio", duration)
return ""
def get_stats(self) -> dict:
return {**self._stats, "listening": self._listening}
|