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}