AIIT-Threshold commited on
Commit
050e596
·
verified ·
1 Parent(s): 2d4e0fd

voice2 1.0.0 — full-duplex interruptible voice engine for local AI

Browse files
.gitignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ venv/
8
+ voice_engine.jsonl
9
+ *.onnx
10
+ *.onnx.json
.pytest_cache/.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Created by pytest automatically.
2
+ *
.pytest_cache/CACHEDIR.TAG ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ Signature: 8a477f597d28d172789f06886806bc55
2
+ # This file is a cache directory tag created by pytest.
3
+ # For information about cache directory tags, see:
4
+ # https://bford.info/cachedir/spec.html
.pytest_cache/README.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # pytest cache directory #
2
+
3
+ This directory contains data from the pytest's cache plugin,
4
+ which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
5
+
6
+ **Do not** commit this to version control.
7
+
8
+ See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.
.pytest_cache/v/cache/nodeids ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ "voice2/tests/test_interrupt_controller.py::test_clear",
3
+ "voice2/tests/test_interrupt_controller.py::test_debounce_allows_after_window",
4
+ "voice2/tests/test_interrupt_controller.py::test_debounce_blocks_second",
5
+ "voice2/tests/test_interrupt_controller.py::test_trigger_sets_event",
6
+ "voice2/tests/test_ring_buffer.py::test_append_and_get",
7
+ "voice2/tests/test_ring_buffer.py::test_capacity_trimming",
8
+ "voice2/tests/test_ring_buffer.py::test_clear",
9
+ "voice2/tests/test_ring_buffer.py::test_preroll_extraction",
10
+ "voice2/tests/test_state_controller.py::test_floor_blocks_agent_speaking",
11
+ "voice2/tests/test_state_controller.py::test_invalid_transition_rejected",
12
+ "voice2/tests/test_state_controller.py::test_snapshot",
13
+ "voice2/tests/test_state_controller.py::test_stopped_blocks_all",
14
+ "voice2/tests/test_state_controller.py::test_turn_counter",
15
+ "voice2/tests/test_state_controller.py::test_valid_transition"
16
+ ]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rhet Wike
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ language:
4
+ - en
5
+ tags:
6
+ - voice
7
+ - speech
8
+ - full-duplex
9
+ - barge-in
10
+ - vad
11
+ - asr
12
+ - tts
13
+ - piper
14
+ - whisper
15
+ - silero
16
+ - local-ai
17
+ - voice-assistant
18
+ ---
19
+
20
+ # voice2
21
+
22
+ A full-duplex, interruptible voice engine for local AI, **built and run daily as the voice of a fully local companion** before being extracted for release. Plain Python threads, CPU-only defaults, no cloud, no API keys. Talk to your model — and talk over it.
23
+
24
+ voice2 turns any `callable(text) -> str` into a hands-free voice conversation: mic → Silero VAD → faster-whisper ASR → your model → Piper TTS. Speak over the reply (or tap spacebar) and playback stops mid-chunk in ~100–200 ms, exactly like interrupting a person.
25
+
26
+ Code is mirrored on GitHub: https://github.com/AIIT-GLITCH/voice2
27
+
28
+ ## Engine details
29
+
30
+ - **What it is:** a turn-taking state machine, not a demo loop. Explicit states (`IDLE → LISTENING → THINKING → SPEAKING → INTERRUPTING`), a validated transition table, and a `FloorOwner` (USER / AGENT / NONE) that arbitrates who may speak. The agent can never talk over you.
31
+ - **Barge-in:** a fast energy-gated VAD watches the mic *only while the engine is speaking*; a debounced central `InterruptController` also accepts spacebar and programmatic triggers.
32
+ - **Stale-turn suppression:** every utterance gets a `turn_id`; replies to an abandoned turn are discarded at every stage (think, TTS, playback).
33
+ - **Invariants, enforced:** a background checker audits rules like *SPEAKING ⇒ floor == AGENT* and *interrupted ⇒ no new TTS*, with forced repair plus a structural gate in the playback hot path.
34
+ - **Observability:** every event is a JSON line with per-turn latency marks (`asr_ms`, `think_ms`, `interrupt_stop_ms`, `total_turn_ms`).
35
+ - **Degrades gracefully:** no mic → text mode; TTS missing → silent replies, still logs; keyboard hook fails → engine keeps running.
36
+
37
+ ## Backends
38
+
39
+ | Stage | Default | Swap point |
40
+ |---|---|---|
41
+ | VAD (quality gate) | Silero VAD via `torch.hub` | `ListenWorker` |
42
+ | ASR | faster-whisper `small.en`, int8, CPU | `backends/asr.py` (Protocol) |
43
+ | LLM | any `callable(text) -> str` | `backends/llm.py` |
44
+ | TTS | Piper CLI, any `.onnx` voice | `backends/tts.py` (Protocol) |
45
+
46
+ ## Limitations — stated honestly
47
+
48
+ - English-first defaults: ASR ships as `small.en`. Other Whisper models load with one config line, but nothing else was tested.
49
+ - The LLM callable is synchronous: TTS starts after the full reply returns (Piper then streams sentence-by-sentence). No token-level streaming yet.
50
+ - Barge-in is energy-based with an absolute RMS floor of 0.06 — tuned on open speakers in a quiet room. Headsets and noisy rooms need recalibration. There is no echo cancellation.
51
+ - Keyboard interrupt uses POSIX `termios` — Linux/macOS terminals only.
52
+ - Unit tests cover the control plane (state transitions, floor rules, interrupt debounce, ring buffer). Audio I/O paths were validated by months of daily use, not by CI.
53
+
54
+ ## How to run
55
+
56
+ ```bash
57
+ git clone https://github.com/AIIT-GLITCH/voice2
58
+ cd voice2
59
+ pip install -r requirements.txt
60
+ # put a Piper voice at ~/.local/share/piper-voices/ (or set VOICE2_PIPER_MODEL)
61
+ python -m voice2.main # echo backend — proves the loop, no LLM needed
62
+ python examples/http_llm.py # wire any local HTTP LLM
63
+ ```
64
+
65
+ ```python
66
+ from voice2 import VoiceEngine, VoiceConfig
67
+
68
+ def ask(text: str) -> str:
69
+ return my_model.reply(text) # any callable(text) -> str
70
+
71
+ engine = VoiceEngine(VoiceConfig(), ask)
72
+ engine.load_models()
73
+ engine.start() # talk naturally; speak over it to interrupt
74
+ ```
75
+
76
+ ## Provenance
77
+
78
+ voice2 was written as the voice front-end for **Buddy**, a fully local AI companion running on a single RTX 3090 in Council Hill, Oklahoma, and carried his daily conversations for months before release. The design bias throughout: the user always wins the floor, and a companion you can't interrupt isn't a companion. Released alongside [Tessera-1B](https://huggingface.co/AIIT-Threshold/Tessera-1B) as part of AIIT-THRESHOLD's open stack.
79
+
80
+ ## License
81
+
82
+ MIT © 2026 Rhet Dillard Wike, AIIT-THRESHOLD, Oklahoma.
83
+
84
+ ## Citation
85
+
86
+ ```bibtex
87
+ @software{wike2026voice2,
88
+ author = {Wike, Rhet Dillard},
89
+ title = {voice2: a full-duplex, interruptible voice engine for local AI},
90
+ year = {2026},
91
+ url = {https://github.com/AIIT-GLITCH/voice2},
92
+ note = {AIIT-THRESHOLD}
93
+ }
94
+ ```
examples/http_llm.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Wire the voice engine to any local LLM behind an HTTP API.
3
+
4
+ Works with anything that takes text and returns text — llama.cpp server,
5
+ Ollama, vLLM, or your own FastAPI wrapper. Edit `ask()` to match your API.
6
+
7
+ Usage:
8
+ LLM_URL=http://localhost:8000/chat python examples/http_llm.py
9
+ """
10
+ import os
11
+ import signal
12
+
13
+ import httpx
14
+
15
+ from voice2 import VoiceEngine, VoiceConfig
16
+
17
+ LLM_URL = os.environ.get("LLM_URL", "http://localhost:11434/api/generate")
18
+ MODEL = os.environ.get("LLM_MODEL", "llama3.2")
19
+
20
+
21
+ def ask(text: str) -> str:
22
+ """Ollama-style example. Adapt the payload/response to your server."""
23
+ try:
24
+ r = httpx.post(
25
+ LLM_URL,
26
+ json={"model": MODEL, "prompt": text, "stream": False},
27
+ timeout=120.0,
28
+ )
29
+ r.raise_for_status()
30
+ return r.json().get("response", "").strip() or "..."
31
+ except Exception as e:
32
+ return f"Backend error: {e}"
33
+
34
+
35
+ def main() -> None:
36
+ engine = VoiceEngine(VoiceConfig(), ask)
37
+ engine.load_models()
38
+ engine.start()
39
+ print("Online. Talk naturally. Space = interrupt, Ctrl+C = quit.")
40
+ try:
41
+ signal.pause()
42
+ except KeyboardInterrupt:
43
+ pass
44
+ engine.stop()
45
+
46
+
47
+ if __name__ == "__main__":
48
+ main()
pyproject.toml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=64"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "voice2"
7
+ version = "1.0.0"
8
+ description = "Full-duplex, interruptible voice engine for local AI - barge-in, floor management, stale-turn suppression"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Rhet Wike" }]
12
+ requires-python = ">=3.10"
13
+ dependencies = [
14
+ "numpy",
15
+ "sounddevice",
16
+ "faster-whisper",
17
+ "torch",
18
+ ]
19
+ keywords = ["voice", "speech", "tts", "asr", "llm", "barge-in", "full-duplex", "piper", "whisper", "local-ai"]
20
+ classifiers = [
21
+ "License :: OSI Approved :: MIT License",
22
+ "Operating System :: POSIX :: Linux",
23
+ "Programming Language :: Python :: 3",
24
+ "Topic :: Multimedia :: Sound/Audio :: Speech",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/AIIT-GLITCH/voice2"
29
+
30
+ [tool.setuptools.packages.find]
31
+ include = ["voice2*"]
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ numpy
2
+ sounddevice
3
+ faster-whisper
4
+ torch # Silero VAD via torch.hub (CPU build is fine)
5
+ httpx # examples/http_llm.py only
voice2/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """voice2 — production-grade stateful interruptible voice engine."""
2
+ from .engine import VoiceEngine
3
+ from .config import VoiceConfig
4
+ from .enums import EngineState, FloorOwner, InterruptSource
5
+
6
+ __all__ = ["VoiceEngine", "VoiceConfig", "EngineState", "FloorOwner", "InterruptSource"]
voice2/audio_broadcaster.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """AudioBroadcaster — fan-out mic frames to N consumers. No frame theft."""
2
+ import queue
3
+ import threading
4
+ from collections import defaultdict
5
+ from typing import Dict
6
+ import numpy as np
7
+ from . import logging_util as log
8
+
9
+
10
+ class AudioBroadcaster:
11
+ def __init__(self, shutdown: threading.Event) -> None:
12
+ self._shutdown = shutdown
13
+ self._lock = threading.Lock()
14
+ self._consumers: Dict[str, queue.Queue] = {}
15
+ self._dropped: Dict[str, int] = defaultdict(int)
16
+
17
+ def subscribe(self, name: str, maxsize: int = 500) -> queue.Queue:
18
+ q: queue.Queue = queue.Queue(maxsize=maxsize)
19
+ with self._lock:
20
+ self._consumers[name] = q
21
+ log.event("broadcaster", "subscribed", meta={"name": name, "maxsize": maxsize})
22
+ return q
23
+
24
+ def unsubscribe(self, name: str) -> None:
25
+ with self._lock:
26
+ self._consumers.pop(name, None)
27
+
28
+ def publish(self, frame: np.ndarray) -> None:
29
+ with self._lock:
30
+ consumers = list(self._consumers.items())
31
+ for name, q in consumers:
32
+ try:
33
+ q.put_nowait(frame)
34
+ except queue.Full:
35
+ # Drop oldest, push newest — never block the publisher
36
+ try:
37
+ q.get_nowait()
38
+ q.put_nowait(frame)
39
+ self._dropped[name] += 1
40
+ except Exception:
41
+ pass
42
+
43
+ def dropped_count(self, name: str) -> int:
44
+ return self._dropped.get(name, 0)
voice2/backends/__init__.py ADDED
File without changes
voice2/backends/asr.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ASR backend wrapper. Swap internals without touching workers."""
2
+ from typing import Protocol
3
+ import numpy as np
4
+
5
+
6
+ class ASRBackend(Protocol):
7
+ def transcribe(self, audio: np.ndarray) -> str: ...
8
+
9
+
10
+ class FasterWhisperASR:
11
+ def __init__(self, model_size: str = "small.en", device: str = "cpu",
12
+ compute_type: str = "int8") -> None:
13
+ from faster_whisper import WhisperModel
14
+ self._model = WhisperModel(model_size, device=device,
15
+ compute_type=compute_type)
16
+
17
+ def transcribe(self, audio: np.ndarray) -> str:
18
+ audio_f32 = audio.astype("float32")
19
+ segments, _ = self._model.transcribe(audio_f32, language="en",
20
+ beam_size=1, vad_filter=False,
21
+ condition_on_previous_text=False)
22
+ return " ".join(s.text.strip() for s in segments).strip()
voice2/backends/llm.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM backend wrapper. Wire any callable(text) -> str here."""
2
+ from typing import Callable, Protocol
3
+
4
+
5
+ class LLMBackend(Protocol):
6
+ def reply(self, text: str) -> str: ...
7
+
8
+
9
+ class CallableLLM:
10
+ """Wraps any callable(text) -> str as an LLM backend."""
11
+ def __init__(self, fn: Callable[[str], str]) -> None:
12
+ self._fn = fn
13
+
14
+ def reply(self, text: str) -> str:
15
+ return self._fn(text)
voice2/backends/tts.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TTS backend — yields PCM chunks. Swap without touching PlaybackWorker."""
2
+ from typing import Iterable, Protocol
3
+ import io
4
+ import os
5
+ import subprocess
6
+ import numpy as np
7
+
8
+
9
+ class TTSBackend(Protocol):
10
+ def synthesize(self, text: str) -> Iterable[np.ndarray]: ...
11
+
12
+
13
+ class PiperTTS:
14
+ """Yields one PCM chunk per sentence. Starts playback before full synthesis."""
15
+
16
+ def __init__(self, model_path: str, sample_rate: int = 22050) -> None:
17
+ self._model = model_path
18
+ self._sr = sample_rate
19
+ self._length_scale = os.environ.get("VOICE2_TTS_LENGTH_SCALE")
20
+ self._noise_scale = os.environ.get("VOICE2_TTS_NOISE_SCALE")
21
+ self._noise_w = os.environ.get("VOICE2_TTS_NOISE_W")
22
+ self._volume = os.environ.get("VOICE2_TTS_VOLUME")
23
+
24
+ def synthesize(self, text: str) -> Iterable[np.ndarray]:
25
+ # Piper reads text from stdin, outputs raw PCM on stdout
26
+ cmd = ["piper", "--model", self._model, "--output-raw"]
27
+ if self._length_scale:
28
+ cmd += ["--length-scale", self._length_scale]
29
+ if self._noise_scale:
30
+ cmd += ["--noise-scale", self._noise_scale]
31
+ if self._noise_w:
32
+ cmd += ["--noise-w-scale", self._noise_w]
33
+ if self._volume:
34
+ cmd += ["--volume", self._volume]
35
+ proc = subprocess.Popen(
36
+ cmd,
37
+ stdin=subprocess.PIPE,
38
+ stdout=subprocess.PIPE,
39
+ stderr=subprocess.DEVNULL,
40
+ )
41
+ assert proc.stdin and proc.stdout
42
+ proc.stdin.write(text.encode())
43
+ proc.stdin.close()
44
+
45
+ chunk_bytes = int(self._sr * 0.020) * 2 # 20ms of int16
46
+ while True:
47
+ raw = proc.stdout.read(chunk_bytes)
48
+ if not raw:
49
+ break
50
+ pcm = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
51
+ yield pcm
52
+
53
+ proc.wait()
voice2/config.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Voice engine configuration. Dataclasses only. No logic here."""
2
+ import os
3
+ from dataclasses import dataclass, field
4
+ from typing import Optional
5
+
6
+
7
+ @dataclass
8
+ class AudioConfig:
9
+ sample_rate: int = 16000
10
+ channels: int = 1
11
+ dtype: str = "float32"
12
+ block_ms: int = 32
13
+ input_device: int | None = None
14
+ output_device: int | None = None
15
+
16
+ @property
17
+ def blocksize(self) -> int:
18
+ return int(self.sample_rate * self.block_ms / 1000)
19
+
20
+
21
+ @dataclass
22
+ class VADConfig:
23
+ """Normal utterance capture — patient."""
24
+ threshold: float = 0.5
25
+ pre_roll_ms: int = 400
26
+ min_utterance_ms: int = 300
27
+ min_voiced_ms: int = 320
28
+ min_voice_prob: float = 0.45
29
+ min_voiced_ratio: float = 0.08
30
+ end_silence_ms: int = 5000 # patient: give the speaker room to think mid-sentence
31
+ max_utterance_sec: float = 90.0
32
+
33
+
34
+ @dataclass
35
+ class InterruptVADConfig:
36
+ """Aggressive barge-in detection during SPEAKING. Fast and cheap."""
37
+ threshold: float = 0.4
38
+ consecutive_frames_required: int = 4 # ~128ms at 32ms blocks
39
+ energy_multiplier: float = 2.0 # must exceed N× playback bleed baseline
40
+ refractory_ms: int = 500 # don't re-trigger within this window
41
+ check_interval_ms: int = 32 # match block_ms
42
+
43
+
44
+ @dataclass
45
+ class ASRConfig:
46
+ model_size: str = "small.en"
47
+ device: str = "cpu"
48
+ compute_type: str = "int8"
49
+ language: str = "en"
50
+ beam_size: int = 1
51
+ vad_filter: bool = False
52
+ condition_on_previous_text: bool = False
53
+
54
+
55
+ @dataclass
56
+ class TTSConfig:
57
+ model_path: str = os.environ.get(
58
+ "VOICE2_PIPER_MODEL",
59
+ os.path.expanduser("~/.local/share/piper-voices/en_US-ryan-high.onnx"),
60
+ )
61
+ sample_rate: int = 22050
62
+ chunk_ms: int = 20 # playback chunk size — 20ms = fast interrupt response
63
+
64
+
65
+ @dataclass
66
+ class PlaybackConfig:
67
+ chunk_ms: int = 20 # must match TTSConfig.chunk_ms
68
+ discard_on_interrupt: bool = True
69
+
70
+
71
+ @dataclass
72
+ class InterruptConfig:
73
+ debounce_ms: int = 200
74
+ keyboard_key: str = " " # spacebar
75
+
76
+
77
+ @dataclass
78
+ class RingBufferConfig:
79
+ seconds: float = 2.0
80
+
81
+
82
+ @dataclass
83
+ class QueueConfig:
84
+ """Sizes for all internal queues. Tune for latency vs. drop behavior."""
85
+ broadcast_listen_maxsize: int = 500
86
+ broadcast_interrupt_maxsize: int = 200
87
+ transcript_maxsize: int = 50
88
+ playback_maxsize: int = 200
89
+
90
+
91
+ @dataclass
92
+ class VoiceConfig:
93
+ audio: AudioConfig = field(default_factory=AudioConfig)
94
+ vad: VADConfig = field(default_factory=VADConfig)
95
+ interrupt_vad: InterruptVADConfig = field(default_factory=InterruptVADConfig)
96
+ asr: ASRConfig = field(default_factory=ASRConfig)
97
+ tts: TTSConfig = field(default_factory=TTSConfig)
98
+ playback: PlaybackConfig = field(default_factory=PlaybackConfig)
99
+ interrupt: InterruptConfig = field(default_factory=InterruptConfig)
100
+ ring: RingBufferConfig = field(default_factory=RingBufferConfig)
101
+ queues: QueueConfig = field(default_factory=QueueConfig)
102
+ log_file: str | None = "voice_engine.jsonl"
voice2/engine.py ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """VoiceEngine — orchestration only. Wires all workers, manages lifecycle."""
2
+ import os
3
+ import queue
4
+ import subprocess
5
+ import sys
6
+ import threading
7
+ from typing import Callable
8
+
9
+ import sounddevice as sd
10
+ import numpy as np
11
+
12
+ # Optional companion visualizer: point VOICE2_EDGE_GLOW at a script that tails
13
+ # the engine's JSONL log (e.g. a screen-edge glow while speaking). Off by default.
14
+ EDGE_GLOW_PATH = os.path.expanduser(os.environ.get('VOICE2_EDGE_GLOW', ''))
15
+
16
+ from .config import VoiceConfig
17
+ from .enums import EngineState, TransitionReason
18
+ from .shared_state import SharedState
19
+ from .state_controller import StateController
20
+ from .floor_manager import FloorManager
21
+ from .interrupt_controller import InterruptController
22
+ from .audio_broadcaster import AudioBroadcaster
23
+ from .ring_buffer import RingAudioBuffer
24
+ from .workers.listen import ListenWorker
25
+ from .workers.interrupt_detector import InterruptDetectorWorker
26
+ from .workers.think import ThinkWorker
27
+ from .workers.playback import PlaybackWorker
28
+ from .workers.keyboard import KeyboardWorker
29
+ from .backends.asr import FasterWhisperASR
30
+ from .backends.tts import PiperTTS
31
+ from .backends.llm import CallableLLM
32
+ from .invariants import InvariantChecker
33
+ from .tones import UICues
34
+ from . import logging_util as log
35
+
36
+
37
+ class VoiceEngine:
38
+ def __init__(self, config: VoiceConfig, ask_fn: Callable[[str], str]) -> None:
39
+ self.cfg = config
40
+ self._ask_fn = ask_fn
41
+ self._started = False
42
+
43
+ # Control plane
44
+ self.shared = SharedState()
45
+ self.ctrl = StateController(self.shared)
46
+ self.floor = FloorManager(self.shared, self.ctrl)
47
+ self.interrupt = InterruptController(
48
+ self.shared, self.ctrl, self.floor,
49
+ debounce_ms=config.interrupt.debounce_ms,
50
+ )
51
+
52
+ # Data plane
53
+ self.broadcaster = AudioBroadcaster(self.shared.shutdown)
54
+ self.ring = RingAudioBuffer(
55
+ config.audio.sample_rate, config.audio.channels,
56
+ config.ring.seconds,
57
+ )
58
+ self._transcript_q: queue.Queue = queue.Queue(maxsize=50)
59
+ self._threads: list[threading.Thread] = []
60
+ self._stream: sd.InputStream | None = None
61
+
62
+ # UI cues
63
+ self.cues = UICues(
64
+ sample_rate=config.tts.sample_rate,
65
+ device=config.audio.output_device,
66
+ )
67
+
68
+ # Backends (lazy loaded)
69
+ self._asr = None
70
+ self._tts = None
71
+ self._llm = None
72
+
73
+ # Workers (built after load_models)
74
+ self._listen_worker = None
75
+ self._interrupt_det = None
76
+ self._think_worker = None
77
+ self._playback_worker = None
78
+ self._keyboard_worker = None
79
+
80
+ # ── Lifecycle ──
81
+
82
+ def load_models(self) -> None:
83
+ log.event("engine", "load_models_start")
84
+ try:
85
+ self._asr = FasterWhisperASR(
86
+ self.cfg.asr.model_size,
87
+ self.cfg.asr.device,
88
+ self.cfg.asr.compute_type,
89
+ )
90
+ except Exception as e:
91
+ log.event("engine", "asr_load_failed", error=str(e))
92
+ self.shared.asr_available = False
93
+
94
+ try:
95
+ self._tts = PiperTTS(self.cfg.tts.model_path, self.cfg.tts.sample_rate)
96
+ except Exception as e:
97
+ log.event("engine", "tts_load_failed", error=str(e))
98
+ self.shared.voice_out_available = False
99
+
100
+ self._llm = CallableLLM(self._ask_fn)
101
+ log.event("engine", "load_models_done")
102
+
103
+ def start(self) -> None:
104
+ if self._started:
105
+ return
106
+
107
+ log.init(self.cfg.log_file)
108
+ log.event("engine", "starting")
109
+
110
+ if self._asr is None:
111
+ self.load_models()
112
+
113
+ # Test audio I/O
114
+ self._test_audio_in()
115
+ self._test_audio_out()
116
+
117
+ # Build workers
118
+ qcfg = self.cfg.queues
119
+ listen_q = self.broadcaster.subscribe("listen", maxsize=qcfg.broadcast_listen_maxsize)
120
+ interrupt_q = self.broadcaster.subscribe("interrupt_det", maxsize=qcfg.broadcast_interrupt_maxsize)
121
+
122
+ self._playback_worker = PlaybackWorker(
123
+ self.shared, self.ctrl, self.floor, self.interrupt,
124
+ sample_rate=self.cfg.tts.sample_rate,
125
+ chunk_ms=self.cfg.playback.chunk_ms,
126
+ output_device=self.cfg.audio.output_device,
127
+ cues=self.cues,
128
+ )
129
+ self._think_worker = ThinkWorker(
130
+ self._transcript_q, self.shared, self.ctrl, self.floor,
131
+ self.interrupt, self._llm, self._playback_worker, self._tts,
132
+ cues=self.cues,
133
+ )
134
+ self._listen_worker = ListenWorker(
135
+ listen_q, self.shared, self.ctrl, self.floor, self.interrupt,
136
+ self.ring, self.cfg.vad, self.cfg.audio.sample_rate,
137
+ self._transcript_q, self._asr, cues=self.cues,
138
+ )
139
+ self._interrupt_det = InterruptDetectorWorker(
140
+ interrupt_q, self.shared, self.ctrl, self.interrupt,
141
+ self.cfg.interrupt_vad,
142
+ )
143
+ self._keyboard_worker = KeyboardWorker(
144
+ self.shared, self.interrupt, self.cfg.interrupt.keyboard_key,
145
+ )
146
+
147
+ # Launch workers (Thread subclasses — call .start() directly)
148
+ for worker in (self._listen_worker, self._interrupt_det,
149
+ self._think_worker, self._playback_worker):
150
+ worker.start()
151
+ self._threads.append(worker)
152
+
153
+ # Keyboard in isolated try — failure here doesn't kill engine
154
+ try:
155
+ self._keyboard_worker.start()
156
+ self._threads.append(self._keyboard_worker)
157
+ except Exception as e:
158
+ log.event("engine", "keyboard_start_failed", error=str(e))
159
+ self.shared.keyboard_interrupt_available = False
160
+
161
+ # Start mic stream
162
+ self._start_mic()
163
+
164
+ # Invariant monitor
165
+ self._invariants = InvariantChecker(self.shared, self.ctrl)
166
+ self._spawn("invariants", lambda: self._invariants.run_loop(interval_sec=2.0))
167
+
168
+ # Engine starts in IDLE — no transition needed, just record it
169
+ self._started = True
170
+ log.event("engine", "online", state=self.ctrl.get_state().name)
171
+
172
+ # Launch optional edge-glow companion (VOICE2_EDGE_GLOW).
173
+ # Runs under system python so it can use GUI bindings not in this venv.
174
+ self._edge_glow = None
175
+ if (self.cfg.log_file and EDGE_GLOW_PATH
176
+ and os.path.exists(EDGE_GLOW_PATH) and os.environ.get('DISPLAY')):
177
+ try:
178
+ self._edge_glow = subprocess.Popen(
179
+ ['/usr/bin/python3', EDGE_GLOW_PATH,
180
+ '--log', self.cfg.log_file,
181
+ '--rate', str(self.cfg.tts.sample_rate)],
182
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
183
+ )
184
+ log.event("engine", "edge_glow_started", pid=self._edge_glow.pid)
185
+ except Exception as e:
186
+ log.event("engine", "edge_glow_start_failed", error=str(e))
187
+
188
+ def stop(self) -> None:
189
+ if not self._started:
190
+ return
191
+ log.event("engine", "stopping")
192
+ self.shared.shutdown.set()
193
+ self.shared.interrupted.set() # unblock any active playback loop immediately
194
+ # Shut down cue stream first (no concurrent sd.play calls after this)
195
+ if self.cues:
196
+ try:
197
+ self.cues.close()
198
+ except Exception:
199
+ pass
200
+ if self._stream:
201
+ try:
202
+ self._stream.abort()
203
+ self._stream.close()
204
+ except Exception:
205
+ pass
206
+ for t in self._threads:
207
+ t.join(timeout=2.0)
208
+ if getattr(self, "_edge_glow", None):
209
+ try:
210
+ self._edge_glow.terminate()
211
+ self._edge_glow.wait(timeout=2.0)
212
+ except Exception:
213
+ pass
214
+ self._started = False
215
+ log.event("engine", "stopped")
216
+
217
+ def submit_text(self, text: str) -> None:
218
+ """Fallback: inject text directly as if ASR produced it."""
219
+ from .logging_util import LatencyTrace
220
+ turn_id = self.ctrl.start_new_turn()
221
+ trace = LatencyTrace(turn_id)
222
+ trace.mark("turn_start")
223
+ self._transcript_q.put_nowait((text, turn_id, trace))
224
+
225
+ def status(self) -> dict:
226
+ return {
227
+ **self.ctrl.snapshot(),
228
+ "started": self._started,
229
+ "interrupted": self.shared.interrupted.is_set(),
230
+ "speaking": self.shared.speaking.is_set(),
231
+ "thinking": self.shared.thinking.is_set(),
232
+ "listening": self.shared.listening.is_set(),
233
+ "capabilities": {
234
+ "voice_in_available": self.shared.voice_in_available,
235
+ "voice_out_available": self.shared.voice_out_available,
236
+ "asr_available": self.shared.asr_available,
237
+ "llm_available": self.shared.llm_available,
238
+ "keyboard_interrupt_available": self.shared.keyboard_interrupt_available,
239
+ "vad_interrupt_available": self.shared.vad_interrupt_available,
240
+ },
241
+ "worker_health": {
242
+ t.name: t.is_alive()
243
+ for t in self._threads
244
+ },
245
+ }
246
+
247
+ def join(self) -> None:
248
+ for t in self._threads:
249
+ t.join()
250
+
251
+ # ── Internal ──
252
+
253
+ def _spawn(self, name: str, target) -> None:
254
+ t = threading.Thread(target=target, name=f"voice-{name}", daemon=True)
255
+ t.start()
256
+ self._threads.append(t)
257
+
258
+ def _start_mic(self) -> None:
259
+ cfg = self.cfg.audio
260
+
261
+ def _callback(indata, frames, time_info, status):
262
+ frame = indata[:, 0].copy()
263
+ self.ring.append(frame)
264
+ self.broadcaster.publish(frame)
265
+
266
+ try:
267
+ self._stream = sd.InputStream(
268
+ samplerate=cfg.sample_rate,
269
+ channels=cfg.channels,
270
+ dtype=cfg.dtype,
271
+ blocksize=cfg.blocksize,
272
+ device=cfg.input_device,
273
+ callback=_callback,
274
+ )
275
+ self._stream.start()
276
+ log.event("engine", "mic_started")
277
+ except Exception as e:
278
+ log.event("engine", "mic_failed", error=str(e))
279
+ self.shared.voice_in_available = False
280
+
281
+ def _test_audio_in(self) -> None:
282
+ try:
283
+ sd.check_input_settings(
284
+ device=self.cfg.audio.input_device,
285
+ samplerate=self.cfg.audio.sample_rate,
286
+ )
287
+ except Exception as e:
288
+ log.event("engine", "audio_in_unavailable", error=str(e))
289
+ self.shared.voice_in_available = False
290
+
291
+ def _test_audio_out(self) -> None:
292
+ try:
293
+ sd.check_output_settings(
294
+ device=self.cfg.audio.output_device,
295
+ samplerate=self.cfg.tts.sample_rate,
296
+ )
297
+ except Exception as e:
298
+ log.event("engine", "audio_out_unavailable", error=str(e))
299
+ self.shared.voice_out_available = False
voice2/enums.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """All enums for the voice engine. Single source of truth."""
2
+ from enum import Enum, auto
3
+
4
+
5
+ class EngineState(Enum):
6
+ IDLE = auto()
7
+ LISTENING = auto()
8
+ THINKING = auto()
9
+ SPEAKING = auto()
10
+ INTERRUPTING = auto()
11
+ FALLBACK_TEXT = auto()
12
+ ERROR = auto()
13
+ STOPPED = auto()
14
+
15
+
16
+ class FloorOwner(Enum):
17
+ NONE = auto()
18
+ USER = auto()
19
+ AGENT = auto()
20
+
21
+
22
+ class TransitionReason(Enum):
23
+ STARTUP = auto()
24
+ SPEECH_DETECTED = auto()
25
+ SPEECH_ENDED = auto()
26
+ ASR_COMPLETE = auto()
27
+ THINK_START = auto()
28
+ THINK_COMPLETE = auto()
29
+ PLAYBACK_START = auto()
30
+ PLAYBACK_COMPLETE = auto()
31
+ INTERRUPT = auto()
32
+ INTERRUPT_RESOLVED = auto()
33
+ ERROR_RECOVERY = auto()
34
+ CAPABILITY_FAILURE = auto()
35
+ SHUTDOWN = auto()
36
+ FALLBACK = auto()
37
+
38
+
39
+ class InterruptSource(Enum):
40
+ KEYBOARD = auto()
41
+ VAD = auto()
42
+ PROGRAMMATIC = auto()
43
+ PARTIAL_ASR = auto() # hook for future use
voice2/floor_manager.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FloorManager — policy object for turn-taking arbitration."""
2
+ import threading
3
+ from .enums import FloorOwner, EngineState, TransitionReason, InterruptSource
4
+ from .shared_state import SharedState
5
+ from .state_controller import StateController
6
+ from . import logging_util as log
7
+
8
+
9
+ class FloorManager:
10
+ def __init__(self, shared: SharedState, ctrl: StateController) -> None:
11
+ self._shared = shared
12
+ self._ctrl = ctrl
13
+ self._lock = threading.RLock() # reentrant — request_agent_floor calls can_agent_speak
14
+
15
+ def can_agent_speak(self, turn_id: int = 0) -> bool:
16
+ """Agent may speak only if floor is NONE or AGENT and state allows.
17
+ If turn_id provided, also verifies the turn is still current."""
18
+ with self._lock:
19
+ state = self._ctrl.get_state()
20
+ floor = self._ctrl.get_floor_owner()
21
+ if state in (EngineState.STOPPED, EngineState.ERROR):
22
+ return False
23
+ if floor == FloorOwner.USER:
24
+ return False
25
+ if turn_id and turn_id != self._shared.current_turn_id:
26
+ return False
27
+ return True
28
+
29
+ def request_user_floor(self, reason: str = "", turn_id: int = 0) -> bool:
30
+ with self._lock:
31
+ self._ctrl.set_floor(FloorOwner.USER, reason=reason, turn_id=turn_id)
32
+ return True
33
+
34
+ def request_agent_floor(self, reason: str = "", turn_id: int = 0) -> bool:
35
+ with self._lock:
36
+ if not self.can_agent_speak():
37
+ log.event("floor", "agent_floor_denied",
38
+ state=self._ctrl.get_state().name,
39
+ floor_owner=self._ctrl.get_floor_owner().name,
40
+ turn_id=turn_id, reason=reason)
41
+ return False
42
+ self._ctrl.set_floor(FloorOwner.AGENT, reason=reason, turn_id=turn_id)
43
+ return True
44
+
45
+ def release_floor(self, reason: str = "", turn_id: int = 0) -> None:
46
+ with self._lock:
47
+ self._ctrl.set_floor(FloorOwner.NONE, reason=reason, turn_id=turn_id)
48
+
49
+ def should_commit_user_audio(self) -> bool:
50
+ """Is ASR result from user currently valid to act on?"""
51
+ floor = self._ctrl.get_floor_owner()
52
+ state = self._ctrl.get_state()
53
+ if state in (EngineState.STOPPED, EngineState.ERROR):
54
+ return False
55
+ # User speech is always commitable if user has or can take floor
56
+ return floor in (FloorOwner.USER, FloorOwner.NONE)
57
+
58
+ def handle_interrupt(self, source: InterruptSource, reason: str = "") -> None:
59
+ """User is interrupting. Floor goes back to USER immediately."""
60
+ with self._lock:
61
+ self._shared.speaking.clear()
62
+ self._ctrl.set_floor(FloorOwner.USER,
63
+ reason=f"interrupt:{source.name}:{reason}")
64
+ log.event("floor", "interrupt_floor_taken",
65
+ state=self._ctrl.get_state().name,
66
+ floor_owner=FloorOwner.USER.name,
67
+ turn_id=self._shared.current_turn_id,
68
+ source=source.name)
voice2/interrupt_controller.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """InterruptController — central interrupt trigger with debounce."""
2
+ import threading
3
+ import time
4
+ from .enums import InterruptSource, EngineState, TransitionReason
5
+ from .shared_state import SharedState
6
+ from .state_controller import StateController
7
+ from .floor_manager import FloorManager
8
+ from .logging_util import LatencyTrace
9
+ from . import logging_util as log
10
+
11
+
12
+ class InterruptController:
13
+ def __init__(
14
+ self,
15
+ shared: SharedState,
16
+ ctrl: StateController,
17
+ floor: FloorManager,
18
+ debounce_ms: int = 200,
19
+ ) -> None:
20
+ self._shared = shared
21
+ self._ctrl = ctrl
22
+ self._floor = floor
23
+ self._debounce_sec = debounce_ms / 1000.0
24
+ self._lock = threading.Lock()
25
+ self._last_trigger_ts: float = 0.0
26
+ self._last_source: InterruptSource | None = None
27
+ self._trace: LatencyTrace | None = None
28
+
29
+ def set_trace(self, trace: LatencyTrace) -> None:
30
+ self._trace = trace
31
+
32
+ def trigger(self, source: InterruptSource, reason: str = "", **meta) -> bool:
33
+ with self._lock:
34
+ now = time.monotonic()
35
+ # Debounce — first trigger wins within the window
36
+ since = now - self._last_trigger_ts
37
+ if since < self._debounce_sec:
38
+ log.event("interrupt", "debounced",
39
+ state=self._ctrl.get_state().name,
40
+ floor_owner=self._ctrl.get_floor_owner().name,
41
+ turn_id=self._shared.current_turn_id,
42
+ source=source.name, since_ms=int(since * 1000))
43
+ return False
44
+
45
+ self._last_trigger_ts = now
46
+ self._last_source = source
47
+
48
+ # Mark timing
49
+ if self._trace:
50
+ self._trace.mark("interrupt_detected")
51
+
52
+ log.event("interrupt", "triggered",
53
+ state=self._ctrl.get_state().name,
54
+ floor_owner=self._ctrl.get_floor_owner().name,
55
+ turn_id=self._shared.current_turn_id,
56
+ source=source.name, reason=reason, **meta)
57
+
58
+ # 1. Set interrupted event — playback loop polls this
59
+ self._shared.interrupted.set()
60
+
61
+ # 2. Floor immediately back to user
62
+ self._floor.handle_interrupt(source=source, reason=reason)
63
+
64
+ # 3. State → INTERRUPTING
65
+ self._ctrl.transition(
66
+ EngineState.INTERRUPTING,
67
+ TransitionReason.INTERRUPT,
68
+ source=source.name,
69
+ reason_text=reason,
70
+ )
71
+
72
+ return True
73
+
74
+ def clear(self) -> None:
75
+ """Called after interrupt is fully resolved and we're back to listening."""
76
+ self._shared.interrupted.clear()
77
+ log.event("interrupt", "cleared",
78
+ state=self._ctrl.get_state().name,
79
+ floor_owner=self._ctrl.get_floor_owner().name,
80
+ turn_id=self._shared.current_turn_id)
81
+
82
+ def is_interrupted(self) -> bool:
83
+ return self._shared.interrupted.is_set()
voice2/invariants.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ System invariants — called periodically or on every transition.
3
+ Violations are logged critical and trigger forced repair.
4
+
5
+ INVARIANT 1: SPEAKING => FloorOwner == AGENT
6
+ INVARIANT 2: interrupted.is_set() => no new TTS begins
7
+ INVARIANT 3: Only ListenWorker advances committed turn_id
8
+ INVARIANT 4: Response plays only if turn_id matches + floor + no interrupt + not shutdown
9
+ INVARIANT 5: No direct state mutation outside StateController
10
+ INVARIANT 6: No subscriber failure may stall mic capture
11
+ """
12
+
13
+ import threading
14
+ from .enums import EngineState, FloorOwner
15
+ from .shared_state import SharedState
16
+ from .state_controller import StateController
17
+ from . import logging_util as log
18
+
19
+
20
+ class InvariantChecker:
21
+ def __init__(self, shared: SharedState, ctrl: StateController) -> None:
22
+ self._shared = shared
23
+ self._ctrl = ctrl
24
+ self._lock = threading.Lock()
25
+
26
+ def check_all(self) -> list[str]:
27
+ """Run all invariant checks. Returns list of violation descriptions."""
28
+ violations = []
29
+ violations += self._check_speaking_floor()
30
+ violations += self._check_interrupt_blocks_playback()
31
+ for v in violations:
32
+ log.event("invariant", "VIOLATION",
33
+ state=self._ctrl.get_state().name,
34
+ floor_owner=self._ctrl.get_floor_owner().name,
35
+ turn_id=self._shared.current_turn_id,
36
+ violation=v)
37
+ return violations
38
+
39
+ def _check_speaking_floor(self) -> list[str]:
40
+ state = self._ctrl.get_state()
41
+ floor = self._ctrl.get_floor_owner()
42
+ if state == EngineState.SPEAKING and floor != FloorOwner.AGENT:
43
+ msg = f"INV1: SPEAKING but floor={floor.name}, expected AGENT"
44
+ # Force repair
45
+ self._ctrl.set_floor(FloorOwner.AGENT, reason="invariant_repair")
46
+ return [msg]
47
+ return []
48
+
49
+ def _check_interrupt_blocks_playback(self) -> list[str]:
50
+ # Invariant 2 is enforced structurally in PlaybackWorker._speak()
51
+ # Here we just audit and log if something slipped through
52
+ if (self._shared.interrupted.is_set()
53
+ and self._ctrl.get_state() == EngineState.SPEAKING):
54
+ return ["INV2: speaking while interrupted flag is set"]
55
+ return []
56
+
57
+ def playback_is_allowed(self, turn_id: int) -> tuple[bool, str]:
58
+ """
59
+ Invariant 4: A response may only play if:
60
+ - turn_id == current_turn_id
61
+ - floor manager grants agent floor
62
+ - interrupt flag is clear
63
+ - not shutting down
64
+ Returns (allowed, reason).
65
+ """
66
+ if self._shared.shutdown.is_set():
67
+ return False, "shutdown"
68
+ if self._shared.interrupted.is_set():
69
+ return False, "interrupted"
70
+ if turn_id != self._shared.current_turn_id:
71
+ return False, f"stale_turn:{turn_id}!={self._shared.current_turn_id}"
72
+ if self._ctrl.get_floor_owner() == FloorOwner.USER:
73
+ return False, "user_owns_floor"
74
+ return True, "ok"
75
+
76
+ def run_loop(self, interval_sec: float = 2.0) -> None:
77
+ """Background invariant monitor. Run in daemon thread."""
78
+ import time
79
+ while not self._shared.shutdown.is_set():
80
+ time.sleep(interval_sec)
81
+ self.check_all()
voice2/logging_util.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Structured JSON-line event logger for the voice engine."""
2
+ import json
3
+ import threading
4
+ import time
5
+ from datetime import datetime, timezone
6
+ from typing import Any
7
+
8
+
9
+ _log_lock = threading.Lock()
10
+ _log_file: str | None = None
11
+
12
+
13
+ def init(path: str | None) -> None:
14
+ global _log_file
15
+ _log_file = path
16
+
17
+
18
+ def event(subsystem: str, ev: str, **meta: Any) -> None:
19
+ """All metadata is keyword-only. No positional args beyond subsystem + ev."""
20
+ turn_id = meta.get("turn_id", 0)
21
+ state = meta.get("state", "")
22
+ record = {
23
+ "ts": time.monotonic(),
24
+ "wall_ts": datetime.now(timezone.utc).isoformat(),
25
+ "subsystem": subsystem,
26
+ "event": ev,
27
+ **meta,
28
+ }
29
+ line = json.dumps(record, default=str)
30
+ with _log_lock:
31
+ print(f"[{subsystem}] {ev}" + (f" | turn={turn_id}" if turn_id else "")
32
+ + (f" | state={state}" if state else ""))
33
+ if _log_file:
34
+ try:
35
+ with open(_log_file, "a") as f:
36
+ f.write(line + "\n")
37
+ except Exception:
38
+ pass
39
+
40
+
41
+ class LatencyTrace:
42
+ """Per-turn monotonic timing. Thread-safe reads, single-writer assumed."""
43
+
44
+ FIELDS = [
45
+ "turn_start", "speech_detect_start", "speech_detect_end",
46
+ "asr_start", "asr_end", "think_start", "first_token", "think_end",
47
+ "tts_start", "tts_first_sample", "playback_start",
48
+ "interrupt_detected", "interrupt_executed",
49
+ "playback_end", "turn_end",
50
+ ]
51
+
52
+ def __init__(self, turn_id: int) -> None:
53
+ self.turn_id = turn_id
54
+ self._marks: dict[str, float] = {}
55
+
56
+ def mark(self, name: str) -> None:
57
+ self._marks[name] = time.monotonic()
58
+
59
+ def duration(self, a: str, b: str) -> float | None:
60
+ if a in self._marks and b in self._marks:
61
+ return round(self._marks[b] - self._marks[a], 4)
62
+ return None
63
+
64
+ def to_dict(self) -> dict:
65
+ out: dict[str, Any] = {"turn_id": self.turn_id, "marks": {}}
66
+ for k, v in self._marks.items():
67
+ out["marks"][k] = round(v, 4)
68
+ # Key durations
69
+ out["durations"] = {
70
+ "asr_ms": int((self.duration("asr_start", "asr_end") or 0) * 1000),
71
+ "think_ms": int((self.duration("think_start", "think_end") or 0) * 1000),
72
+ "interrupt_stop_ms": int((self.duration("interrupt_detected", "interrupt_executed") or 0) * 1000),
73
+ "total_turn_ms": int((self.duration("turn_start", "turn_end") or 0) * 1000),
74
+ }
75
+ return out
voice2/main.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Minimal bootstrap — run the engine with an echo backend, no LLM required.
3
+
4
+ Usage:
5
+ python -m voice2.main
6
+
7
+ Wire your own model by replacing `backend` with any callable(text) -> str.
8
+ See examples/ for an HTTP LLM backend.
9
+ """
10
+ import os
11
+ import signal
12
+
13
+ from voice2 import VoiceEngine, VoiceConfig
14
+ from voice2.config import VADConfig, InterruptVADConfig, InterruptConfig
15
+
16
+
17
+ def backend(text: str) -> str:
18
+ """Echo backend — proves the full loop (mic -> ASR -> reply -> TTS)."""
19
+ return f"You said: {text}"
20
+
21
+
22
+ def main() -> None:
23
+ cfg = VoiceConfig(
24
+ vad=VADConfig(end_silence_ms=5000, max_utterance_sec=90.0),
25
+ interrupt_vad=InterruptVADConfig(
26
+ consecutive_frames_required=4,
27
+ energy_multiplier=2.0,
28
+ ),
29
+ interrupt=InterruptConfig(debounce_ms=200, keyboard_key=" "),
30
+ log_file=os.path.expanduser("~/voice_engine.jsonl"),
31
+ )
32
+
33
+ engine = VoiceEngine(cfg, backend)
34
+
35
+ print("=" * 60)
36
+ print(" VOICE ENGINE v2 — Full-Duplex Interruptible")
37
+ print(" Space = interrupt | Ctrl+C = quit")
38
+ print("=" * 60)
39
+
40
+ print("[boot] Loading models...")
41
+ engine.load_models()
42
+
43
+ print("[boot] Starting engine...")
44
+ engine.start()
45
+
46
+ print(f"[boot] Online. Status: {engine.status()}")
47
+ print("[boot] Listening. Talk naturally.\n")
48
+
49
+ # Text fallback if voice_in failed
50
+ if not engine.shared.voice_in_available:
51
+ print("[fallback] Mic unavailable — TEXT MODE. Type messages, Enter to send.")
52
+ while not engine.shared.shutdown.is_set():
53
+ try:
54
+ text = input("You: ").strip()
55
+ if text:
56
+ engine.submit_text(text)
57
+ except (EOFError, KeyboardInterrupt):
58
+ break
59
+ else:
60
+ try:
61
+ signal.pause()
62
+ except KeyboardInterrupt:
63
+ pass
64
+
65
+ print("\n[shutdown] Stopping engine...")
66
+ engine.stop()
67
+ print("[shutdown] Done.")
68
+
69
+
70
+ if __name__ == "__main__":
71
+ main()
voice2/ring_buffer.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RingAudioBuffer — thread-safe rolling mic history for pre-roll."""
2
+ import threading
3
+ from collections import deque
4
+ import numpy as np
5
+
6
+
7
+ class RingAudioBuffer:
8
+ def __init__(self, sample_rate: int, channels: int, seconds: float,
9
+ dtype: str = "float32") -> None:
10
+ self._sr = sample_rate
11
+ self._ch = channels
12
+ self._dtype = dtype
13
+ self._capacity_samples = int(sample_rate * seconds)
14
+ self._buf: deque[np.ndarray] = deque()
15
+ self._size = 0 # samples stored
16
+ self._lock = threading.Lock()
17
+
18
+ def append(self, frame: np.ndarray) -> None:
19
+ with self._lock:
20
+ self._buf.append(frame)
21
+ self._size += len(frame)
22
+ # Trim oldest frames to stay within capacity
23
+ while self._size > self._capacity_samples and self._buf:
24
+ oldest = self._buf.popleft()
25
+ self._size -= len(oldest)
26
+
27
+ def get_last(self, seconds: float) -> np.ndarray:
28
+ """Return up to `seconds` of most recent audio."""
29
+ n = int(self._sr * seconds)
30
+ with self._lock:
31
+ if not self._buf:
32
+ return np.zeros(0, dtype=self._dtype)
33
+ frames = list(self._buf)
34
+ combined = np.concatenate(frames)
35
+ return combined[-n:] if len(combined) > n else combined
36
+
37
+ def clear(self) -> None:
38
+ with self._lock:
39
+ self._buf.clear()
40
+ self._size = 0
41
+
42
+ def size_seconds(self) -> float:
43
+ with self._lock:
44
+ return self._size / self._sr
voice2/shared_state.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SharedState — synchronized primitives only. No business logic."""
2
+ import threading
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass
7
+ class SharedState:
8
+ # Lifecycle
9
+ shutdown: threading.Event = field(default_factory=threading.Event)
10
+
11
+ # Floor signals
12
+ interrupted: threading.Event = field(default_factory=threading.Event)
13
+ speaking: threading.Event = field(default_factory=threading.Event)
14
+ thinking: threading.Event = field(default_factory=threading.Event)
15
+ listening: threading.Event = field(default_factory=threading.Event)
16
+ mic_gated: threading.Event = field(default_factory=threading.Event)
17
+
18
+ # Capability flags — set once at startup, read-only after that
19
+ voice_in_available: bool = True
20
+ voice_out_available: bool = True
21
+ asr_available: bool = True
22
+ llm_available: bool = True
23
+ keyboard_interrupt_available: bool = True
24
+ vad_interrupt_available: bool = True
25
+
26
+ # Turn tracking — only StateController writes this
27
+ current_turn_id: int = 0
voice2/state_controller.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """StateController — owns all state transitions. Single lock. Atomic only."""
2
+ import threading
3
+ import time
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+ from .enums import EngineState, FloorOwner, TransitionReason
8
+ from .shared_state import SharedState
9
+ from . import logging_util as log
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class StateSnapshot:
14
+ """Immutable atomic snapshot. Callers compare against this, never hold lock."""
15
+ state: EngineState
16
+ floor_owner: FloorOwner
17
+ turn_id: int
18
+ ts: float
19
+
20
+ # Transition validation table: (from_state, to_state) -> allowed
21
+ _VALID = {
22
+ (EngineState.IDLE, EngineState.LISTENING),
23
+ (EngineState.IDLE, EngineState.THINKING), # submit_text() path: no LISTENING step
24
+ (EngineState.IDLE, EngineState.FALLBACK_TEXT),
25
+ (EngineState.IDLE, EngineState.ERROR),
26
+ (EngineState.IDLE, EngineState.STOPPED),
27
+ (EngineState.LISTENING, EngineState.THINKING),
28
+ (EngineState.LISTENING, EngineState.IDLE),
29
+ (EngineState.LISTENING, EngineState.INTERRUPTING),
30
+ (EngineState.LISTENING, EngineState.ERROR),
31
+ (EngineState.LISTENING, EngineState.STOPPED),
32
+ (EngineState.THINKING, EngineState.SPEAKING),
33
+ (EngineState.THINKING, EngineState.IDLE),
34
+ (EngineState.THINKING, EngineState.INTERRUPTING),
35
+ (EngineState.THINKING, EngineState.ERROR),
36
+ (EngineState.THINKING, EngineState.STOPPED),
37
+ (EngineState.SPEAKING, EngineState.IDLE),
38
+ (EngineState.SPEAKING, EngineState.LISTENING),
39
+ (EngineState.SPEAKING, EngineState.INTERRUPTING),
40
+ (EngineState.SPEAKING, EngineState.ERROR),
41
+ (EngineState.SPEAKING, EngineState.STOPPED),
42
+ (EngineState.INTERRUPTING, EngineState.LISTENING),
43
+ (EngineState.INTERRUPTING, EngineState.IDLE),
44
+ (EngineState.INTERRUPTING, EngineState.ERROR),
45
+ (EngineState.INTERRUPTING, EngineState.STOPPED),
46
+ (EngineState.ERROR, EngineState.IDLE),
47
+ (EngineState.ERROR, EngineState.STOPPED),
48
+ (EngineState.FALLBACK_TEXT, EngineState.THINKING),
49
+ (EngineState.FALLBACK_TEXT, EngineState.STOPPED),
50
+ }
51
+
52
+ # Floor ownership rules: cannot enter SPEAKING if floor is USER
53
+ _FLOOR_BLOCKS: dict[EngineState, set[FloorOwner]] = {
54
+ EngineState.SPEAKING: {FloorOwner.USER},
55
+ }
56
+
57
+
58
+ @dataclass
59
+ class TransitionRecord:
60
+ ts: float
61
+ from_state: EngineState
62
+ to_state: EngineState
63
+ reason: TransitionReason
64
+ floor_owner: FloorOwner
65
+ turn_id: int
66
+ meta: dict = field(default_factory=dict)
67
+
68
+
69
+ class StateController:
70
+ def __init__(self, shared: SharedState) -> None:
71
+ self._shared = shared
72
+ self._lock = threading.RLock()
73
+ self._state = EngineState.IDLE
74
+ self._floor = FloorOwner.NONE
75
+ self._history: list[TransitionRecord] = []
76
+
77
+ # ── Reads ──
78
+
79
+ def get_state(self) -> EngineState:
80
+ with self._lock:
81
+ return self._state
82
+
83
+ def get_floor_owner(self) -> FloorOwner:
84
+ with self._lock:
85
+ return self._floor
86
+
87
+ def get_snapshot(self) -> StateSnapshot:
88
+ """Atomic frozen snapshot — callers may read freely without holding lock."""
89
+ with self._lock:
90
+ return StateSnapshot(
91
+ state=self._state,
92
+ floor_owner=self._floor,
93
+ turn_id=self._shared.current_turn_id,
94
+ ts=time.monotonic(),
95
+ )
96
+
97
+ def snapshot(self) -> dict[str, Any]:
98
+ s = self.get_snapshot()
99
+ return {
100
+ "state": s.state.name,
101
+ "floor_owner": s.floor_owner.name,
102
+ "turn_id": s.turn_id,
103
+ "history_len": len(self._history),
104
+ }
105
+
106
+ # ── Turn counter ──
107
+
108
+ def start_new_turn(self) -> int:
109
+ with self._lock:
110
+ self._shared.current_turn_id += 1
111
+ log.event("state", "new_turn",
112
+ state=self._state.name, floor_owner=self._floor.name,
113
+ turn_id=self._shared.current_turn_id)
114
+ return self._shared.current_turn_id
115
+
116
+ def current_turn(self) -> int:
117
+ return self._shared.current_turn_id
118
+
119
+ # ── State transition ──
120
+
121
+ def transition(
122
+ self,
123
+ new_state: EngineState,
124
+ reason: TransitionReason,
125
+ **meta: Any,
126
+ ) -> bool:
127
+ with self._lock:
128
+ if self._state == EngineState.STOPPED and new_state != EngineState.STOPPED:
129
+ log.event("state", "transition_rejected",
130
+ from_state=self._state.name, to_state=new_state.name,
131
+ floor_owner=self._floor.name,
132
+ turn_id=self._shared.current_turn_id,
133
+ reason="STOPPED_no_exit")
134
+ return False
135
+
136
+ pair = (self._state, new_state)
137
+ if pair not in _VALID:
138
+ log.event("state", "transition_rejected",
139
+ from_state=self._state.name, to_state=new_state.name,
140
+ floor_owner=self._floor.name,
141
+ turn_id=self._shared.current_turn_id,
142
+ reason="invalid_transition")
143
+ return False
144
+
145
+ blocked = _FLOOR_BLOCKS.get(new_state, set())
146
+ if self._floor in blocked:
147
+ log.event("state", "transition_rejected",
148
+ from_state=self._state.name, to_state=new_state.name,
149
+ floor_owner=self._floor.name,
150
+ turn_id=self._shared.current_turn_id,
151
+ reason="floor_blocked")
152
+ return False
153
+
154
+ rec = TransitionRecord(
155
+ ts=time.monotonic(),
156
+ from_state=self._state,
157
+ to_state=new_state,
158
+ reason=reason,
159
+ floor_owner=self._floor,
160
+ turn_id=self._shared.current_turn_id,
161
+ meta=meta,
162
+ )
163
+ self._history.append(rec)
164
+ self._state = new_state
165
+
166
+ _RESERVED = {"turn_id", "state", "floor_owner", "reason",
167
+ "from_state", "to_state", "old_state", "new_state"}
168
+ safe_meta = {k: v for k, v in meta.items() if k not in _RESERVED}
169
+ log.event("state", "transition",
170
+ from_state=self._history[-1].from_state.name,
171
+ state=new_state.name, floor_owner=self._floor.name,
172
+ turn_id=self._shared.current_turn_id,
173
+ reason=reason.name, **safe_meta)
174
+ return True
175
+
176
+ # ── Floor control ──
177
+
178
+ def set_floor(self, owner: FloorOwner, reason: str = "", **meta: Any) -> None:
179
+ with self._lock:
180
+ _RESERVED = {"turn_id", "state", "floor_owner", "reason"}
181
+ safe_meta = {k: v for k, v in meta.items() if k not in _RESERVED}
182
+ self._floor = owner
183
+ log.event("floor", "floor_set",
184
+ state=self._state.name, floor_owner=owner.name,
185
+ turn_id=self._shared.current_turn_id,
186
+ reason=reason, **safe_meta)
voice2/tests/__init__.py ADDED
File without changes
voice2/tests/test_interrupt_controller.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests — InterruptController debounce and trigger."""
2
+ import time
3
+ from ..shared_state import SharedState
4
+ from ..state_controller import StateController
5
+ from ..floor_manager import FloorManager
6
+ from ..interrupt_controller import InterruptController
7
+ from ..enums import InterruptSource, EngineState, TransitionReason
8
+
9
+
10
+ def make_stack():
11
+ shared = SharedState()
12
+ ctrl = StateController(shared)
13
+ floor = FloorManager(shared, ctrl)
14
+ interrupt = InterruptController(shared, ctrl, floor, debounce_ms=200)
15
+ return shared, ctrl, floor, interrupt
16
+
17
+
18
+ def test_trigger_sets_event():
19
+ shared, ctrl, floor, interrupt = make_stack()
20
+ ctrl.transition(EngineState.LISTENING, TransitionReason.SPEECH_DETECTED)
21
+ ctrl.transition(EngineState.THINKING, TransitionReason.ASR_COMPLETE)
22
+ # Need SPEAKING state for transition to INTERRUPTING
23
+ # go through full path
24
+ result = interrupt.trigger(InterruptSource.KEYBOARD, "test")
25
+ assert shared.interrupted.is_set()
26
+
27
+
28
+ def test_debounce_blocks_second():
29
+ shared, ctrl, floor, interrupt = make_stack()
30
+ ctrl.transition(EngineState.LISTENING, TransitionReason.SPEECH_DETECTED)
31
+ interrupt.trigger(InterruptSource.KEYBOARD, "first")
32
+ result = interrupt.trigger(InterruptSource.KEYBOARD, "second")
33
+ assert not result # debounced
34
+
35
+
36
+ def test_debounce_allows_after_window():
37
+ shared, ctrl, floor, interrupt = make_stack()
38
+ interrupt._debounce_sec = 0.05
39
+ ctrl.transition(EngineState.LISTENING, TransitionReason.SPEECH_DETECTED)
40
+ interrupt.trigger(InterruptSource.KEYBOARD, "first")
41
+ time.sleep(0.1)
42
+ shared.interrupted.clear() # simulate resolution
43
+ # Now trigger again — should work
44
+ interrupt._last_trigger_ts = 0.0
45
+ result = interrupt.trigger(InterruptSource.KEYBOARD, "second")
46
+ assert result
47
+
48
+
49
+ def test_clear():
50
+ shared, ctrl, floor, interrupt = make_stack()
51
+ shared.interrupted.set()
52
+ interrupt.clear()
53
+ assert not shared.interrupted.is_set()
voice2/tests/test_ring_buffer.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests — RingAudioBuffer."""
2
+ import numpy as np
3
+ from ..ring_buffer import RingAudioBuffer
4
+
5
+
6
+ def test_append_and_get():
7
+ buf = RingAudioBuffer(sample_rate=16000, channels=1, seconds=2.0)
8
+ frame = np.ones(1600, dtype="float32")
9
+ buf.append(frame)
10
+ out = buf.get_last(0.1)
11
+ assert len(out) == 1600
12
+
13
+
14
+ def test_capacity_trimming():
15
+ buf = RingAudioBuffer(sample_rate=16000, channels=1, seconds=1.0)
16
+ for _ in range(20):
17
+ buf.append(np.ones(1600, dtype="float32"))
18
+ # Should not exceed capacity
19
+ assert buf.size_seconds() <= 1.1
20
+
21
+
22
+ def test_preroll_extraction():
23
+ buf = RingAudioBuffer(sample_rate=16000, channels=1, seconds=2.0)
24
+ buf.append(np.zeros(8000, dtype="float32"))
25
+ buf.append(np.ones(8000, dtype="float32"))
26
+ out = buf.get_last(0.5)
27
+ assert len(out) == 8000
28
+ # Last 0.5s should be the ones
29
+ assert np.all(out == 1.0)
30
+
31
+
32
+ def test_clear():
33
+ buf = RingAudioBuffer(sample_rate=16000, channels=1, seconds=2.0)
34
+ buf.append(np.ones(1600, dtype="float32"))
35
+ buf.clear()
36
+ assert buf.size_seconds() == 0.0
voice2/tests/test_state_controller.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests — StateController."""
2
+ import pytest
3
+ from ..shared_state import SharedState
4
+ from ..state_controller import StateController
5
+ from ..enums import EngineState, FloorOwner, TransitionReason
6
+
7
+
8
+ def make_ctrl():
9
+ return StateController(SharedState())
10
+
11
+
12
+ def test_valid_transition():
13
+ ctrl = make_ctrl()
14
+ assert ctrl.transition(EngineState.LISTENING, TransitionReason.SPEECH_DETECTED)
15
+ assert ctrl.get_state() == EngineState.LISTENING
16
+
17
+
18
+ def test_invalid_transition_rejected():
19
+ ctrl = make_ctrl()
20
+ # Can't go IDLE -> SPEAKING directly
21
+ result = ctrl.transition(EngineState.SPEAKING, TransitionReason.PLAYBACK_START)
22
+ assert not result
23
+ assert ctrl.get_state() == EngineState.IDLE
24
+
25
+
26
+ def test_stopped_blocks_all():
27
+ ctrl = make_ctrl()
28
+ ctrl.transition(EngineState.LISTENING, TransitionReason.SPEECH_DETECTED)
29
+ ctrl.transition(EngineState.STOPPED, TransitionReason.SHUTDOWN)
30
+ result = ctrl.transition(EngineState.IDLE, TransitionReason.STARTUP)
31
+ assert not result
32
+
33
+
34
+ def test_floor_blocks_agent_speaking():
35
+ shared = SharedState()
36
+ ctrl = StateController(shared)
37
+ # Set floor to USER then try to go SPEAKING
38
+ ctrl.set_floor(FloorOwner.USER, "test")
39
+ ctrl.transition(EngineState.LISTENING, TransitionReason.SPEECH_DETECTED)
40
+ result = ctrl.transition(EngineState.THINKING, TransitionReason.ASR_COMPLETE)
41
+ # THINKING is allowed regardless of floor
42
+ assert result
43
+ result = ctrl.transition(EngineState.SPEAKING, TransitionReason.PLAYBACK_START)
44
+ assert not result # USER has floor — blocked
45
+
46
+
47
+ def test_turn_counter():
48
+ ctrl = make_ctrl()
49
+ t1 = ctrl.start_new_turn()
50
+ t2 = ctrl.start_new_turn()
51
+ assert t2 == t1 + 1
52
+
53
+
54
+ def test_snapshot():
55
+ ctrl = make_ctrl()
56
+ snap = ctrl.snapshot()
57
+ assert snap["state"] == "IDLE"
58
+ assert snap["floor_owner"] == "NONE"
voice2/tones.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Soft UI tones — dedicated persistent OutputStream, no sd.play() calls."""
2
+ import queue
3
+ import threading
4
+ import numpy as np
5
+ import sounddevice as sd
6
+
7
+
8
+ def _tone(
9
+ freq: float,
10
+ duration_ms: int,
11
+ volume: float,
12
+ sample_rate: int,
13
+ fade_ms: int = 30,
14
+ ) -> np.ndarray:
15
+ """Sine wave with fade-in and fade-out to avoid clicks."""
16
+ n = int(sample_rate * duration_ms / 1000)
17
+ t = np.linspace(0, duration_ms / 1000, n, endpoint=False)
18
+ wave = np.sin(2 * np.pi * freq * t).astype(np.float32) * volume
19
+
20
+ fade = int(sample_rate * fade_ms / 1000)
21
+ fade = min(fade, n // 2)
22
+ ramp = np.linspace(0, 1, fade)
23
+ wave[:fade] *= ramp
24
+ wave[-fade:] *= ramp[::-1]
25
+ return wave
26
+
27
+
28
+ def _chord(freqs, duration_ms, volume, sample_rate, fade_ms=30) -> np.ndarray:
29
+ wave = sum(_tone(f, duration_ms, volume / len(freqs), sample_rate, fade_ms) for f in freqs)
30
+ return wave.astype(np.float32)
31
+
32
+
33
+ def _sequence(tones, gap_ms, sample_rate) -> np.ndarray:
34
+ gap = np.zeros(int(sample_rate * gap_ms / 1000), dtype=np.float32)
35
+ parts = []
36
+ for i, t in enumerate(tones):
37
+ parts.append(t)
38
+ if i < len(tones) - 1:
39
+ parts.append(gap)
40
+ return np.concatenate(parts)
41
+
42
+
43
+ class UICues:
44
+ """
45
+ Play soft tones for voice engine state transitions.
46
+
47
+ Uses a dedicated persistent sd.OutputStream so tone playback never
48
+ conflicts with the TTS OutputStream in PlaybackWorker. All public
49
+ methods are non-blocking: they enqueue the pre-rendered numpy array
50
+ and return immediately.
51
+ """
52
+
53
+ def __init__(self, sample_rate: int = 22050, device=None, volume: float = 0.18):
54
+ self._sr = sample_rate
55
+ self._dev = device
56
+ self._vol = volume
57
+ self._q: queue.Queue = queue.Queue(maxsize=16)
58
+ self._stream: sd.OutputStream | None = None
59
+ self._thread: threading.Thread | None = None
60
+ self._open()
61
+
62
+ # ── Lifecycle ──
63
+
64
+ def _open(self) -> None:
65
+ """Open dedicated cue stream and start drain thread."""
66
+ try:
67
+ self._stream = sd.OutputStream(
68
+ samplerate=self._sr,
69
+ channels=1,
70
+ dtype="float32",
71
+ device=self._dev,
72
+ blocksize=512,
73
+ )
74
+ self._stream.start()
75
+ except Exception as e:
76
+ self._stream = None
77
+ return # cues silently disabled
78
+
79
+ self._thread = threading.Thread(target=self._drain, daemon=True, name="voice-cues")
80
+ self._thread.start()
81
+
82
+ def _drain(self) -> None:
83
+ """Pull tone arrays from queue and write to the open stream."""
84
+ while True:
85
+ try:
86
+ samples = self._q.get(timeout=0.5)
87
+ except queue.Empty:
88
+ if self._stream is None:
89
+ break
90
+ continue
91
+ if samples is None: # sentinel
92
+ break
93
+ if self._stream is None:
94
+ continue
95
+ try:
96
+ self._stream.write(samples)
97
+ except Exception:
98
+ pass
99
+
100
+ def close(self) -> None:
101
+ """Graceful shutdown — call from engine.stop()."""
102
+ self._q.put(None) # sentinel to drain thread (blocking — guaranteed delivery)
103
+ if self._stream:
104
+ try:
105
+ self._stream.stop()
106
+ self._stream.close()
107
+ except Exception:
108
+ pass
109
+ self._stream = None
110
+
111
+ def _play(self, samples: np.ndarray) -> None:
112
+ """Non-blocking enqueue. Drops silently if queue full."""
113
+ if self._stream is None:
114
+ return
115
+ try:
116
+ self._q.put_nowait(samples)
117
+ except queue.Full:
118
+ pass
119
+
120
+ # ── Cues ──
121
+
122
+ def listening(self) -> None:
123
+ """Soft rising two-note: ready to hear you."""
124
+ tones = [
125
+ _tone(660, 80, self._vol, self._sr),
126
+ _tone(880, 100, self._vol, self._sr),
127
+ ]
128
+ self._play(_sequence(tones, 20, self._sr))
129
+
130
+ def thinking(self) -> None:
131
+ """Single soft mid-note ping: processing."""
132
+ self._play(_tone(528, 90, self._vol * 0.7, self._sr))
133
+
134
+ def speaking(self) -> None:
135
+ """Gentle chord: about to speak."""
136
+ self._play(_chord([528, 660], 100, self._vol * 0.8, self._sr))
137
+
138
+ def interrupted(self) -> None:
139
+ """Soft descending note: cut off."""
140
+ tones = [
141
+ _tone(660, 70, self._vol * 0.7, self._sr),
142
+ _tone(440, 90, self._vol * 0.6, self._sr),
143
+ ]
144
+ self._play(_sequence(tones, 15, self._sr))
145
+
146
+ def error(self) -> None:
147
+ """Low soft thud: something wrong."""
148
+ self._play(_tone(220, 150, self._vol * 0.5, self._sr))
voice2/turn_context.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Turn-level types. Per-turn state is separate from global engine state."""
2
+ import time
3
+ from dataclasses import dataclass, field
4
+ from typing import TYPE_CHECKING, Iterable
5
+
6
+ import numpy as np
7
+
8
+ from .enums import InterruptSource
9
+
10
+ if TYPE_CHECKING:
11
+ from .logging_util import LatencyTrace
12
+
13
+
14
+ @dataclass
15
+ class TurnContext:
16
+ turn_id: int
17
+ created_ts: float = field(default_factory=time.monotonic)
18
+ transcript: str | None = None
19
+ audio: np.ndarray | None = None
20
+ generation_id: str | None = None
21
+ cancelled: bool = False
22
+ stale: bool = False
23
+ latency: "LatencyTrace | None" = None
24
+
25
+ def mark_stale(self) -> None:
26
+ self.stale = True
27
+
28
+ def mark_cancelled(self) -> None:
29
+ self.cancelled = True
30
+
31
+ def is_live(self) -> bool:
32
+ return not self.stale and not self.cancelled
33
+
34
+
35
+ @dataclass
36
+ class PlaybackItem:
37
+ turn_id: int
38
+ text: str
39
+ created_ts: float = field(default_factory=time.monotonic)
40
+ # Pre-synthesized audio iterator. If None, PlaybackWorker synthesizes from text.
41
+ audio_iter: "Iterable[np.ndarray] | None" = None
42
+
43
+ def is_stale(self, current_turn_id: int) -> bool:
44
+ return self.turn_id != current_turn_id
45
+
46
+
47
+ @dataclass
48
+ class InterruptRecord:
49
+ source: InterruptSource
50
+ ts: float
51
+ state: str
52
+ floor_owner: str
53
+ turn_id: int
54
+ reason: str
voice2/workers/__init__.py ADDED
File without changes
voice2/workers/interrupt_detector.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """InterruptDetectorWorker — fast VAD/energy barge-in during SPEAKING only."""
2
+ import queue
3
+ import time
4
+ import threading
5
+ import numpy as np
6
+
7
+ from ..enums import EngineState, InterruptSource
8
+ from ..shared_state import SharedState
9
+ from ..state_controller import StateController
10
+ from ..interrupt_controller import InterruptController
11
+ from ..config import InterruptVADConfig
12
+ from .. import logging_util as log
13
+
14
+
15
+ class InterruptDetectorWorker(threading.Thread):
16
+ def __init__(
17
+ self,
18
+ audio_queue: queue.Queue,
19
+ shared: SharedState,
20
+ ctrl: StateController,
21
+ interrupt: InterruptController,
22
+ cfg: InterruptVADConfig,
23
+ ) -> None:
24
+ super().__init__(name="voice-interrupt_det", daemon=True)
25
+ self._q = audio_queue
26
+ self._shared = shared
27
+ self._ctrl = ctrl
28
+ self._interrupt = interrupt
29
+ self._cfg = cfg
30
+
31
+ def run(self) -> None:
32
+ """Runs in daemon thread. Only hot during SPEAKING."""
33
+ baseline_rms = 0.001
34
+ consecutive = 0
35
+ last_refractory = 0.0
36
+
37
+ while not self._shared.shutdown.is_set():
38
+ # Only active during SPEAKING — idle otherwise
39
+ if self._ctrl.get_state() != EngineState.SPEAKING:
40
+ # Drain queue so it doesn't fill up while inactive
41
+ try:
42
+ self._q.get(timeout=0.05)
43
+ except queue.Empty:
44
+ pass
45
+ consecutive = 0
46
+ baseline_rms = 0.001
47
+ continue
48
+
49
+ try:
50
+ frame = self._q.get(timeout=0.05)
51
+ except queue.Empty:
52
+ continue
53
+
54
+ # Compute RMS energy
55
+ rms = float(np.sqrt(np.mean(frame.astype(np.float32) ** 2)))
56
+
57
+ # Slowly update baseline from speaker bleed (slower = more stable floor)
58
+ baseline_rms = 0.995 * baseline_rms + 0.005 * rms
59
+
60
+ # Threshold: multiplier × baseline, but NEVER below 0.06 absolute floor
61
+ # 0.06 blocks ambient noise and most speaker bleed; a normal speaking voice clears it
62
+ threshold = max(baseline_rms * self._cfg.energy_multiplier, 0.06)
63
+
64
+ if rms > threshold:
65
+ consecutive += 1
66
+ else:
67
+ consecutive = 0
68
+
69
+ if consecutive >= self._cfg.consecutive_frames_required:
70
+ now = time.monotonic()
71
+ refractory_sec = self._cfg.refractory_ms / 1000.0
72
+ if (now - last_refractory) > refractory_sec:
73
+ last_refractory = now
74
+ consecutive = 0
75
+ log.event("interrupt_detector", "vad_triggered",
76
+ state=self._ctrl.get_state().name,
77
+ floor_owner=self._ctrl.get_floor_owner().name,
78
+ turn_id=self._shared.current_turn_id,
79
+ rms=round(rms, 4),
80
+ baseline=round(baseline_rms, 4))
81
+ self._interrupt.trigger(
82
+ InterruptSource.VAD,
83
+ reason="energy_threshold_exceeded",
84
+ rms=round(rms, 4),
85
+ )
voice2/workers/keyboard.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """KeyboardWorker — spacebar interrupt. Isolated so failure doesn't kill engine."""
2
+ import threading
3
+ import sys
4
+ import termios
5
+ import tty
6
+
7
+ from ..enums import InterruptSource
8
+ from ..shared_state import SharedState
9
+ from ..interrupt_controller import InterruptController
10
+ from .. import logging_util as log
11
+
12
+
13
+ def _read_char() -> str:
14
+ fd = sys.stdin.fileno()
15
+ old = termios.tcgetattr(fd)
16
+ try:
17
+ tty.setraw(fd)
18
+ return sys.stdin.read(1)
19
+ finally:
20
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
21
+
22
+
23
+ class KeyboardWorker(threading.Thread):
24
+ def __init__(
25
+ self,
26
+ shared: SharedState,
27
+ interrupt: InterruptController,
28
+ key: str = " ",
29
+ ) -> None:
30
+ super().__init__(name="voice-keyboard", daemon=True)
31
+ self._shared = shared
32
+ self._interrupt = interrupt
33
+ self._key = key
34
+
35
+ def run(self) -> None:
36
+ log.event("keyboard", "listener_started", meta={"key": repr(self._key)})
37
+ while not self._shared.shutdown.is_set():
38
+ try:
39
+ ch = _read_char()
40
+ if ch == self._key:
41
+ log.event("keyboard", "spacebar_pressed")
42
+ self._interrupt.trigger(
43
+ InterruptSource.KEYBOARD,
44
+ reason="spacebar_pressed",
45
+ )
46
+ elif ch in ("\x03", "\x04"): # Ctrl+C or Ctrl+D
47
+ self._shared.shutdown.set()
48
+ break
49
+ except Exception as e:
50
+ log.event("keyboard", "listener_error", meta={"error": str(e)})
51
+ # Don't kill the engine over keyboard issues
52
+ break
53
+ log.event("keyboard", "listener_stopped")
voice2/workers/listen.py ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ListenWorker — patient VAD utterance capture with pre-roll."""
2
+ import queue
3
+ import re
4
+ import threading
5
+ import time
6
+ import numpy as np
7
+
8
+ from ..enums import EngineState, FloorOwner, TransitionReason
9
+ from ..shared_state import SharedState
10
+ from ..state_controller import StateController
11
+ from ..floor_manager import FloorManager
12
+ from ..interrupt_controller import InterruptController
13
+ from ..ring_buffer import RingAudioBuffer
14
+ from ..logging_util import LatencyTrace
15
+ from ..config import VADConfig
16
+ from .. import logging_util as log
17
+
18
+
19
+ class ListenWorker(threading.Thread):
20
+ def __init__(
21
+ self,
22
+ audio_queue: queue.Queue,
23
+ shared: SharedState,
24
+ ctrl: StateController,
25
+ floor: FloorManager,
26
+ interrupt: InterruptController,
27
+ ring: RingAudioBuffer,
28
+ vad_cfg: VADConfig,
29
+ sample_rate: int,
30
+ transcript_queue: queue.Queue,
31
+ asr_backend,
32
+ cues=None,
33
+ ) -> None:
34
+ super().__init__(name="voice-listen", daemon=True)
35
+ self._cues = cues
36
+ self._q = audio_queue
37
+ self._shared = shared
38
+ self._ctrl = ctrl
39
+ self._floor = floor
40
+ self._interrupt = interrupt
41
+ self._ring = ring
42
+ self._cfg = vad_cfg
43
+ self._sr = sample_rate
44
+ self._out_q = transcript_queue
45
+ self._asr = asr_backend
46
+ self._silence_threshold = 0.02 # onset: need intentional voice, not ambient noise
47
+ self._collect_silence_threshold = 0.015 # during collection: slightly more lenient
48
+ self._vad_model = None
49
+ self._torch = None
50
+
51
+ def run(self) -> None:
52
+ try:
53
+ import torch
54
+ model, utils = torch.hub.load(
55
+ repo_or_dir="snakers4/silero-vad",
56
+ model="silero_vad",
57
+ force_reload=False,
58
+ trust_repo=True,
59
+ )
60
+ (get_speech_timestamps, _, read_audio, *_) = utils
61
+ self._vad_model = model
62
+ self._torch = torch
63
+ except Exception as e:
64
+ log.event("listen", "vad_load_error", error=str(e))
65
+ self._vad_model = None
66
+ self._torch = None
67
+
68
+ while not self._shared.shutdown.is_set():
69
+ try:
70
+ frame = self._q.get(timeout=0.1)
71
+ except queue.Empty:
72
+ continue
73
+
74
+ # Always feed ring buffer
75
+ self._ring.append(frame)
76
+
77
+ # Only capture if engine is in a state that allows it
78
+ state = self._ctrl.get_state()
79
+ if state not in (EngineState.IDLE, EngineState.LISTENING,
80
+ EngineState.INTERRUPTING):
81
+ continue
82
+
83
+ # Simple energy gate to detect speech onset
84
+ rms = float(np.sqrt(np.mean(frame.astype(np.float32) ** 2)))
85
+ if rms < self._silence_threshold:
86
+ continue
87
+
88
+ # Speech detected — start an utterance
89
+ turn_id = self._ctrl.start_new_turn()
90
+ trace = LatencyTrace(turn_id)
91
+ trace.mark("turn_start")
92
+ trace.mark("speech_detect_start")
93
+
94
+ self._floor.request_user_floor(reason="speech_onset", turn_id=turn_id)
95
+ # Only transition if not already LISTENING
96
+ if self._ctrl.get_state() != EngineState.LISTENING:
97
+ self._ctrl.transition(EngineState.LISTENING, TransitionReason.SPEECH_DETECTED,
98
+ turn_id=turn_id)
99
+ self._interrupt.clear()
100
+
101
+ log.event("listen", "utterance_start",
102
+ state=EngineState.LISTENING.name,
103
+ floor_owner=FloorOwner.USER.name,
104
+ turn_id=turn_id)
105
+ if self._cues:
106
+ self._cues.listening()
107
+
108
+ # Collect utterance until silence
109
+ utterance = self._collect_utterance(turn_id, trace)
110
+
111
+ if utterance is None or len(utterance) == 0:
112
+ self._ctrl.transition(EngineState.IDLE, TransitionReason.SPEECH_ENDED,
113
+ turn_id=turn_id)
114
+ continue
115
+
116
+ quality = self._speech_quality(utterance)
117
+ if not quality["ok"]:
118
+ log.event("listen", "utterance_discarded",
119
+ turn_id=turn_id,
120
+ reason=quality["reason"],
121
+ duration_ms=quality["duration_ms"],
122
+ voiced_ms=quality["voiced_ms"],
123
+ max_prob=round(quality["max_prob"], 3),
124
+ voiced_ratio=round(quality["voiced_ratio"], 3))
125
+ self._floor.release_floor(reason="audio_quality_gate", turn_id=turn_id)
126
+ self._ctrl.transition(EngineState.IDLE, TransitionReason.SPEECH_ENDED,
127
+ turn_id=turn_id)
128
+ continue
129
+
130
+ trace.mark("speech_detect_end")
131
+ trace.mark("asr_start")
132
+ log.event("listen", "asr_start", turn_id=turn_id,
133
+ duration_sec=round(len(utterance) / self._sr, 2))
134
+
135
+ try:
136
+ text = self._clean_transcript(self._asr.transcribe(utterance))
137
+ trace.mark("asr_end")
138
+ log.event("listen", "asr_complete", turn_id=turn_id,
139
+ text_preview=text[:80],
140
+ asr_ms=int((trace.duration("asr_start", "asr_end") or 0) * 1000))
141
+
142
+ if self._is_transcript_noise(text, quality):
143
+ log.event("listen", "asr_discarded", turn_id=turn_id,
144
+ reason="noise_transcript",
145
+ text_preview=text[:80])
146
+ self._floor.release_floor(reason="noise_transcript", turn_id=turn_id)
147
+ self._ctrl.transition(EngineState.IDLE, TransitionReason.SPEECH_ENDED)
148
+ elif text and self._floor.should_commit_user_audio():
149
+ # Release floor so agent can respond
150
+ self._floor.release_floor(reason="asr_committed", turn_id=turn_id)
151
+ self._out_q.put_nowait((text, turn_id, trace))
152
+ else:
153
+ log.event("listen", "asr_discarded", turn_id=turn_id,
154
+ reason="empty_or_floor_denied")
155
+ self._floor.release_floor(reason="asr_discarded", turn_id=turn_id)
156
+ self._ctrl.transition(EngineState.IDLE, TransitionReason.SPEECH_ENDED)
157
+
158
+ except Exception as e:
159
+ log.event("listen", "asr_error", turn_id=turn_id, error=str(e))
160
+ self._floor.release_floor(reason="asr_error", turn_id=turn_id)
161
+ self._ctrl.transition(EngineState.IDLE, TransitionReason.SPEECH_ENDED)
162
+
163
+ def _collect_utterance(self, turn_id: int, trace: LatencyTrace) -> np.ndarray | None:
164
+ """Collect frames until end_silence_ms of quiet. Returns full audio."""
165
+ frames = []
166
+ # Pre-roll from ring buffer
167
+ pre_roll_sec = self._cfg.pre_roll_ms / 1000.0
168
+ pre = self._ring.get_last(pre_roll_sec)
169
+ if len(pre) > 0:
170
+ frames.append(pre)
171
+
172
+ silence_samples = int(self._sr * self._cfg.end_silence_ms / 1000.0)
173
+ max_samples = int(self._sr * self._cfg.max_utterance_sec)
174
+ silent_count = 0
175
+ total_samples = 0
176
+
177
+ deadline = time.monotonic() + self._cfg.max_utterance_sec + 2
178
+
179
+ while not self._shared.shutdown.is_set():
180
+ if time.monotonic() > deadline:
181
+ break
182
+ try:
183
+ frame = self._q.get(timeout=0.1)
184
+ except queue.Empty:
185
+ continue
186
+
187
+ self._ring.append(frame)
188
+ frames.append(frame)
189
+ total_samples += len(frame)
190
+
191
+ rms = float(np.sqrt(np.mean(frame.astype(np.float32) ** 2)))
192
+ if rms < self._collect_silence_threshold:
193
+ silent_count += len(frame)
194
+ if silent_count >= silence_samples:
195
+ break
196
+ else:
197
+ silent_count = 0
198
+
199
+ if total_samples >= max_samples:
200
+ break
201
+
202
+ if not frames:
203
+ return None
204
+
205
+ audio = np.concatenate(frames)
206
+ min_samples = int(self._sr * self._cfg.min_utterance_ms / 1000.0)
207
+ if len(audio) < min_samples:
208
+ return None
209
+ return audio
210
+
211
+ def _speech_quality(self, audio: np.ndarray) -> dict:
212
+ duration_ms = int(len(audio) / self._sr * 1000)
213
+ result = {
214
+ "ok": True,
215
+ "reason": "ok",
216
+ "duration_ms": duration_ms,
217
+ "voiced_ms": 0,
218
+ "max_prob": 0.0,
219
+ "voiced_ratio": 0.0,
220
+ }
221
+ if duration_ms < self._cfg.min_utterance_ms:
222
+ result.update(ok=False, reason="too_short")
223
+ return result
224
+
225
+ if self._vad_model is None or self._torch is None:
226
+ # Better to allow text through the post-ASR noise gate than dead-stop
227
+ # voice if Silero failed to load.
228
+ return result
229
+
230
+ frame_len = 512
231
+ probs = []
232
+ audio_f32 = audio.astype(np.float32)
233
+ for start in range(0, max(len(audio_f32) - frame_len + 1, 0), frame_len):
234
+ frame = audio_f32[start:start + frame_len]
235
+ if frame.shape[0] != frame_len:
236
+ continue
237
+ if float(np.sqrt(np.mean(frame ** 2))) < 0.003:
238
+ probs.append(0.0)
239
+ continue
240
+ try:
241
+ with self._torch.no_grad():
242
+ prob = float(self._vad_model(self._torch.from_numpy(frame), self._sr).item())
243
+ except Exception as e:
244
+ log.event("listen", "quality_vad_error", error=str(e))
245
+ return result
246
+ probs.append(prob)
247
+
248
+ if not probs:
249
+ result.update(ok=False, reason="no_frames")
250
+ return result
251
+
252
+ voiced = [p for p in probs if p >= self._cfg.min_voice_prob]
253
+ voiced_ms = int(len(voiced) * frame_len / self._sr * 1000)
254
+ voiced_ratio = len(voiced) / max(len(probs), 1)
255
+ max_prob = max(probs)
256
+ result.update(
257
+ voiced_ms=voiced_ms,
258
+ max_prob=max_prob,
259
+ voiced_ratio=voiced_ratio,
260
+ )
261
+ if max_prob < self._cfg.min_voice_prob:
262
+ result.update(ok=False, reason="no_speech_probability")
263
+ elif voiced_ms < self._cfg.min_voiced_ms:
264
+ result.update(ok=False, reason="too_little_voiced_audio")
265
+ elif voiced_ratio < self._cfg.min_voiced_ratio:
266
+ result.update(ok=False, reason="voiced_ratio_low")
267
+ return result
268
+
269
+ @staticmethod
270
+ def _clean_transcript(text: str) -> str:
271
+ return re.sub(r"\s+", " ", (text or "").strip())
272
+
273
+ @staticmethod
274
+ def _is_transcript_noise(text: str, quality: dict) -> bool:
275
+ norm = re.sub(r"[^a-z0-9' ]+", "", (text or "").lower()).strip()
276
+ noise = {
277
+ "",
278
+ ".",
279
+ "...",
280
+ "uh",
281
+ "um",
282
+ "umm",
283
+ "hmm",
284
+ "hm",
285
+ "mm",
286
+ "ah",
287
+ "oh",
288
+ "you",
289
+ "thank you",
290
+ "thanks",
291
+ "thanks for watching",
292
+ "bye",
293
+ "bye bye",
294
+ "subtitles by the amaraorg community",
295
+ }
296
+ if norm in noise:
297
+ return True
298
+ if quality.get("duration_ms", 0) < 1100:
299
+ words = [w for w in norm.split() if w]
300
+ if len(words) <= 1 and len(norm) <= 5:
301
+ return True
302
+ return len(norm) <= 1
voice2/workers/playback.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PlaybackWorker — chunk-by-chunk TTS playback with hard interrupt guarantee."""
2
+ import queue
3
+ import socket
4
+ import threading
5
+ import time
6
+ import numpy as np
7
+ import sounddevice as sd
8
+
9
+ # Fan out played TTS samples to localhost UDP so companion visualizers
10
+ # (e.g. edge_glow) can derive a sample-accurate envelope without guessing
11
+ # through PulseAudio monitor routing. Fire-and-forget; listener optional.
12
+ _TAP_HOST = "127.0.0.1"
13
+ _TAP_PORT = 47121
14
+
15
+ from ..enums import EngineState, TransitionReason, FloorOwner
16
+ from ..shared_state import SharedState
17
+ from ..state_controller import StateController
18
+ from ..floor_manager import FloorManager
19
+ from ..interrupt_controller import InterruptController
20
+ from ..logging_util import LatencyTrace
21
+ from .. import logging_util as log
22
+
23
+
24
+ class PlaybackWorker(threading.Thread):
25
+ def __init__(
26
+ self,
27
+ shared: SharedState,
28
+ ctrl: StateController,
29
+ floor: FloorManager,
30
+ interrupt: InterruptController,
31
+ sample_rate: int = 22050,
32
+ chunk_ms: int = 20,
33
+ output_device: int | None = None,
34
+ cues=None,
35
+ ) -> None:
36
+ super().__init__(name="voice-playback", daemon=True)
37
+ self._cues = cues
38
+ self._shared = shared
39
+ self._ctrl = ctrl
40
+ self._floor = floor
41
+ self._interrupt = interrupt
42
+ self._sr = sample_rate
43
+ self._chunk_samples = int(sample_rate * chunk_ms / 1000)
44
+ self._device = output_device
45
+ self._queue: queue.Queue = queue.Queue(maxsize=200)
46
+ self._stream: sd.OutputStream | None = None
47
+ self._lock = threading.Lock()
48
+ try:
49
+ self._tap_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
50
+ self._tap_sock.setblocking(False)
51
+ except Exception:
52
+ self._tap_sock = None
53
+
54
+ def submit(self, text: str, turn_id: int, tts_backend, trace: LatencyTrace) -> None:
55
+ """Non-blocking. Puts work on internal queue."""
56
+ self._queue.put_nowait((text, turn_id, tts_backend, trace))
57
+
58
+ def run(self) -> None:
59
+ """Worker loop. Run in its own daemon thread."""
60
+ log.event("playback", "worker_started")
61
+ try:
62
+ while not self._shared.shutdown.is_set():
63
+ try:
64
+ item = self._queue.get(timeout=0.1)
65
+ except queue.Empty:
66
+ continue
67
+ text, turn_id, tts_backend, trace = item
68
+ self._speak(text, turn_id, tts_backend, trace)
69
+ except Exception as e:
70
+ log.event("playback", "worker_crashed", error=str(e))
71
+ raise
72
+
73
+ def _speak(self, text: str, turn_id: int, tts_backend, trace: LatencyTrace) -> None:
74
+ log.event("playback", "speak_entry", turn_id=turn_id,
75
+ current_turn=self._ctrl.current_turn(),
76
+ floor=self._ctrl.get_floor_owner().name,
77
+ state=self._ctrl.get_state().name)
78
+ # Stale generation guard — if turn moved on, suppress
79
+ if turn_id != self._ctrl.current_turn():
80
+ log.event("playback", "stale_suppressed", turn_id=turn_id,
81
+ current_turn=self._ctrl.current_turn())
82
+ return
83
+
84
+ if not self._floor.request_agent_floor(reason="playback_start", turn_id=turn_id):
85
+ log.event("playback", "floor_denied", turn_id=turn_id)
86
+ return
87
+
88
+ ok = self._ctrl.transition(EngineState.SPEAKING, TransitionReason.PLAYBACK_START,
89
+ turn_id=turn_id)
90
+ if not ok:
91
+ self._floor.release_floor(reason="transition_failed", turn_id=turn_id)
92
+ return
93
+
94
+ self._shared.speaking.set()
95
+ if self._cues:
96
+ self._cues.speaking()
97
+ trace.mark("tts_start")
98
+ trace.mark("playback_start")
99
+ log.event("playback", "start",
100
+ state=EngineState.SPEAKING.name,
101
+ floor_owner=FloorOwner.AGENT.name,
102
+ turn_id=turn_id)
103
+
104
+ try:
105
+ stream = sd.OutputStream(
106
+ samplerate=self._sr,
107
+ channels=1,
108
+ dtype="float32",
109
+ device=self._device,
110
+ blocksize=self._chunk_samples,
111
+ )
112
+ stream.start()
113
+ first = True
114
+
115
+ for chunk in tts_backend.synthesize(text):
116
+ # Split into target chunk sizes for tight interrupt polling
117
+ for subchunk in self._split(chunk):
118
+ if self._shared.shutdown.is_set():
119
+ stream.abort()
120
+ return
121
+ if self._interrupt.is_interrupted():
122
+ if self._cues:
123
+ self._cues.interrupted()
124
+ trace.mark("interrupt_executed")
125
+ log.event("playback", "interrupt_executed",
126
+ turn_id=turn_id,
127
+ queued_chunks_discarded=self._queue.qsize())
128
+ stream.abort()
129
+ # Drain pending TTS for this turn
130
+ self._drain(turn_id)
131
+ return
132
+ if first:
133
+ trace.mark("tts_first_sample")
134
+ first = False
135
+ stream.write(subchunk)
136
+ if self._tap_sock is not None:
137
+ try:
138
+ self._tap_sock.sendto(
139
+ subchunk.tobytes(), (_TAP_HOST, _TAP_PORT)
140
+ )
141
+ except Exception:
142
+ pass
143
+
144
+ stream.stop()
145
+ stream.close()
146
+ trace.mark("playback_end")
147
+ log.event("playback", "complete", turn_id=turn_id,
148
+ duration_ms=int((trace.duration("playback_start", "playback_end") or 0) * 1000))
149
+
150
+ except Exception as e:
151
+ log.event("playback", "error", turn_id=turn_id, error=str(e))
152
+ finally:
153
+ self._shared.speaking.clear()
154
+ self._floor.release_floor(reason="playback_finished", turn_id=turn_id)
155
+ state = self._ctrl.get_state()
156
+ if state == EngineState.SPEAKING:
157
+ self._ctrl.transition(EngineState.LISTENING,
158
+ TransitionReason.PLAYBACK_COMPLETE,
159
+ turn_id=turn_id)
160
+
161
+ def _split(self, chunk: np.ndarray):
162
+ """Yield chunk_samples-sized sub-arrays for tight interrupt polling."""
163
+ for i in range(0, len(chunk), self._chunk_samples):
164
+ yield chunk[i:i + self._chunk_samples].astype(np.float32)
165
+
166
+ def _drain(self, turn_id: int) -> None:
167
+ """Discard queued TTS items for this turn only; preserve items for other turns."""
168
+ keep = []
169
+ drained = 0
170
+ while not self._queue.empty():
171
+ try:
172
+ item = self._queue.get_nowait()
173
+ except queue.Empty:
174
+ break
175
+ if len(item) >= 2 and item[1] == turn_id:
176
+ drained += 1
177
+ else:
178
+ keep.append(item)
179
+ for item in keep:
180
+ try:
181
+ self._queue.put_nowait(item)
182
+ except queue.Full:
183
+ pass # queue filled while we were draining — item lost
184
+ if drained:
185
+ log.event("playback", "drained", turn_id=turn_id, count=drained)
voice2/workers/think.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ThinkWorker — consumes ASR transcripts, calls LLM, submits to playback."""
2
+ import queue
3
+ import threading
4
+
5
+ from ..enums import EngineState, TransitionReason
6
+ from ..shared_state import SharedState
7
+ from ..state_controller import StateController
8
+ from ..floor_manager import FloorManager
9
+ from ..interrupt_controller import InterruptController
10
+ from ..logging_util import LatencyTrace
11
+ from .. import logging_util as log
12
+
13
+
14
+ class ThinkWorker(threading.Thread):
15
+ def __init__(
16
+ self,
17
+ transcript_queue: queue.Queue,
18
+ shared: SharedState,
19
+ ctrl: StateController,
20
+ floor: FloorManager,
21
+ interrupt: InterruptController,
22
+ llm_backend,
23
+ playback_worker,
24
+ tts_backend,
25
+ cues=None,
26
+ ) -> None:
27
+ super().__init__(name="voice-think", daemon=True)
28
+ self._cues = cues
29
+ self._in_q = transcript_queue
30
+ self._shared = shared
31
+ self._ctrl = ctrl
32
+ self._floor = floor
33
+ self._interrupt = interrupt
34
+ self._llm = llm_backend
35
+ self._playback = playback_worker
36
+ self._tts = tts_backend
37
+
38
+ def run(self) -> None:
39
+ while not self._shared.shutdown.is_set():
40
+ try:
41
+ text, turn_id, trace = self._in_q.get(timeout=0.1)
42
+ except queue.Empty:
43
+ continue
44
+
45
+ # Stale generation guard — newer turn already started
46
+ if turn_id != self._ctrl.current_turn():
47
+ log.event("think", "stale_discarded", turn_id=turn_id,
48
+ current=self._ctrl.current_turn())
49
+ continue
50
+
51
+ self._ctrl.transition(EngineState.THINKING, TransitionReason.THINK_START,
52
+ turn_id=turn_id)
53
+ self._shared.thinking.set()
54
+ if self._cues:
55
+ self._cues.thinking()
56
+ trace.mark("think_start")
57
+ log.event("think", "start",
58
+ state=EngineState.THINKING.name,
59
+ floor_owner=self._ctrl.get_floor_owner().name,
60
+ turn_id=turn_id, input_preview=text[:80])
61
+
62
+ try:
63
+ reply = self._llm.reply(text)
64
+ trace.mark("first_token")
65
+ trace.mark("think_end")
66
+ self._shared.thinking.clear()
67
+
68
+ log.event("think", "complete", turn_id=turn_id,
69
+ think_ms=int((trace.duration("think_start", "think_end") or 0) * 1000),
70
+ reply_preview=reply[:80])
71
+
72
+ # Stale check again — could have been interrupted while thinking
73
+ if turn_id != self._ctrl.current_turn():
74
+ log.event("think", "post_think_stale_suppressed", turn_id=turn_id)
75
+ continue
76
+
77
+ if not self._floor.can_agent_speak(turn_id):
78
+ log.event("think", "floor_blocked_post_think", turn_id=turn_id)
79
+ self._ctrl.transition(EngineState.IDLE, TransitionReason.THINK_COMPLETE,
80
+ turn_id=turn_id)
81
+ continue
82
+
83
+ if reply:
84
+ # Hand off to PlaybackWorker — it owns the SPEAKING transition
85
+ self._interrupt.set_trace(trace)
86
+ self._playback.submit(reply, turn_id, self._tts, trace)
87
+ log.event("think", "submitted_to_playback", turn_id=turn_id)
88
+ else:
89
+ self._ctrl.transition(EngineState.IDLE, TransitionReason.THINK_COMPLETE,
90
+ turn_id=turn_id)
91
+
92
+ except Exception as e:
93
+ self._shared.thinking.clear()
94
+ log.event("think", "error", turn_id=turn_id, error=str(e))
95
+ self._ctrl.transition(EngineState.IDLE, TransitionReason.ERROR_RECOVERY,
96
+ turn_id=turn_id)