voice2 1.0.0 — full-duplex interruptible voice engine for local AI
Browse files- .gitignore +10 -0
- .pytest_cache/.gitignore +2 -0
- .pytest_cache/CACHEDIR.TAG +4 -0
- .pytest_cache/README.md +8 -0
- .pytest_cache/v/cache/nodeids +16 -0
- LICENSE +21 -0
- README.md +94 -0
- examples/http_llm.py +48 -0
- pyproject.toml +31 -0
- requirements.txt +5 -0
- voice2/__init__.py +6 -0
- voice2/audio_broadcaster.py +44 -0
- voice2/backends/__init__.py +0 -0
- voice2/backends/asr.py +22 -0
- voice2/backends/llm.py +15 -0
- voice2/backends/tts.py +53 -0
- voice2/config.py +102 -0
- voice2/engine.py +299 -0
- voice2/enums.py +43 -0
- voice2/floor_manager.py +68 -0
- voice2/interrupt_controller.py +83 -0
- voice2/invariants.py +81 -0
- voice2/logging_util.py +75 -0
- voice2/main.py +71 -0
- voice2/ring_buffer.py +44 -0
- voice2/shared_state.py +27 -0
- voice2/state_controller.py +186 -0
- voice2/tests/__init__.py +0 -0
- voice2/tests/test_interrupt_controller.py +53 -0
- voice2/tests/test_ring_buffer.py +36 -0
- voice2/tests/test_state_controller.py +58 -0
- voice2/tones.py +148 -0
- voice2/turn_context.py +54 -0
- voice2/workers/__init__.py +0 -0
- voice2/workers/interrupt_detector.py +85 -0
- voice2/workers/keyboard.py +53 -0
- voice2/workers/listen.py +302 -0
- voice2/workers/playback.py +185 -0
- voice2/workers/think.py +96 -0
.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)
|