voice-mcp-server 0.1.25 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/config/config.yaml +1 -1
- package/config/vad/ptt_vad.yaml +1 -1
- package/package.json +1 -1
- package/requirements.txt +1 -0
- package/src/__pycache__/logger.cpython-312.pyc +0 -0
- package/src/__pycache__/mcp_server.cpython-312.pyc +0 -0
- package/src/adapters_real/__pycache__/kokoro_speaker.cpython-312.pyc +0 -0
- package/src/adapters_real/__pycache__/live_mic.cpython-312.pyc +0 -0
- package/src/adapters_real/__pycache__/ptt_vad.cpython-312.pyc +0 -0
- package/src/adapters_real/__pycache__/whisper_stt.cpython-312.pyc +0 -0
- package/src/adapters_real/kokoro_speaker.py +7 -6
- package/src/adapters_real/live_mic.py +15 -4
- package/src/adapters_real/ptt_sidecar +0 -0
- package/src/adapters_real/ptt_sidecar.swift +156 -0
- package/src/adapters_real/ptt_vad.py +143 -25
- package/src/adapters_real/whisper_stt.py +5 -4
- package/src/daemon/__pycache__/audio_server.cpython-312.pyc +0 -0
- package/src/daemon/audio_server.py +47 -13
- package/src/logger.py +29 -0
- package/src/mcp_server.py +113 -65
- package/src/simulation/__pycache__/adapters.cpython-312.pyc +0 -0
- package/src/simulation/__pycache__/engine.cpython-312.pyc +0 -0
- package/src/simulation/engine.py +12 -1
- package/src/simulation/tests/__pycache__/__init__.cpython-312.pyc +0 -0
- package/src/simulation/tests/__pycache__/test_ptt_vad.cpython-312-pytest-7.4.2.pyc +0 -0
- package/src/simulation/tests/__pycache__/test_scenarios.cpython-312-pytest-7.4.2.pyc +0 -0
- package/src/simulation/tests/test_abort_daemon.py +109 -0
- package/src/simulation/tests/test_mcp_cancellation.py +83 -0
- package/src/simulation/tests/test_ptt_vad.py +81 -0
|
@@ -4,7 +4,6 @@ import os
|
|
|
4
4
|
import time
|
|
5
5
|
import threading
|
|
6
6
|
import queue
|
|
7
|
-
import logging
|
|
8
7
|
from contextlib import asynccontextmanager
|
|
9
8
|
from fastapi import FastAPI, Request, HTTPException
|
|
10
9
|
from fastapi.responses import StreamingResponse
|
|
@@ -20,6 +19,7 @@ os.environ["TORCH_HOME"] = os.path.join(app_support_dir, "torch")
|
|
|
20
19
|
# Add src to python path for imports
|
|
21
20
|
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
|
22
21
|
|
|
22
|
+
from logger import logger
|
|
23
23
|
from simulation.models import Config
|
|
24
24
|
from simulation.engine import CoreEngine, State
|
|
25
25
|
from adapters_real.queue_llm import QueueLLMBridge
|
|
@@ -75,7 +75,7 @@ def pre_download_models():
|
|
|
75
75
|
daemon_status_message = "Finalizing AI setup..."
|
|
76
76
|
daemon_progress = 90
|
|
77
77
|
except Exception as e:
|
|
78
|
-
|
|
78
|
+
logger.error(f"Model download error: {e}")
|
|
79
79
|
daemon_status_message = f"Error downloading models: {e}"
|
|
80
80
|
|
|
81
81
|
def run_audio_daemon():
|
|
@@ -92,7 +92,7 @@ def run_audio_daemon():
|
|
|
92
92
|
|
|
93
93
|
with initialize(version_base=None, config_path="../../config"):
|
|
94
94
|
cfg = compose(config_name="config")
|
|
95
|
-
|
|
95
|
+
logger.info("Loaded Hydra configuration successfully.")
|
|
96
96
|
|
|
97
97
|
mic = instantiate(cfg.microphone)
|
|
98
98
|
speaker = instantiate(cfg.speaker)
|
|
@@ -114,7 +114,7 @@ def run_audio_daemon():
|
|
|
114
114
|
daemon_status = "READY"
|
|
115
115
|
daemon_status_message = "Audio Engine is online."
|
|
116
116
|
daemon_progress = 100
|
|
117
|
-
|
|
117
|
+
logger.info("Audio Daemon Started. Waiting for commands.")
|
|
118
118
|
|
|
119
119
|
try:
|
|
120
120
|
while True:
|
|
@@ -125,6 +125,8 @@ def run_audio_daemon():
|
|
|
125
125
|
|
|
126
126
|
# We got a command, wake up the hardware!
|
|
127
127
|
mic.start_stream()
|
|
128
|
+
if hasattr(vad, "set_active"):
|
|
129
|
+
vad.set_active(True)
|
|
128
130
|
engine.start_conversation(cmd.get("text", ""), standby_mode=cmd.get("standby_mode", False))
|
|
129
131
|
engine.expect_reply = cmd.get("expect_reply", True)
|
|
130
132
|
|
|
@@ -135,10 +137,12 @@ def run_audio_daemon():
|
|
|
135
137
|
# Once we drop back to EXECUTING, we finished the conversation loop
|
|
136
138
|
if engine.state == State.EXECUTING:
|
|
137
139
|
mic.stop_stream()
|
|
140
|
+
if hasattr(vad, "set_active"):
|
|
141
|
+
vad.set_active(False)
|
|
138
142
|
last_active_timestamp = time.time()
|
|
139
143
|
|
|
140
144
|
except Exception as e:
|
|
141
|
-
|
|
145
|
+
logger.error(f"Daemon exception: {e}")
|
|
142
146
|
finally:
|
|
143
147
|
if mic:
|
|
144
148
|
mic.close()
|
|
@@ -150,17 +154,19 @@ async def watchdog():
|
|
|
150
154
|
await asyncio.sleep(60)
|
|
151
155
|
idle_time = time.time() - last_active_timestamp
|
|
152
156
|
if idle_time > IDLE_TIMEOUT_SECONDS:
|
|
153
|
-
|
|
157
|
+
logger.info(f"Idle timeout reached ({idle_time:.0f}s). Self-destructing to free RAM.")
|
|
154
158
|
if mic:
|
|
155
159
|
mic.close()
|
|
156
160
|
os._exit(0)
|
|
157
161
|
|
|
158
162
|
def parent_pid_polling():
|
|
159
163
|
"""Polls the parent PID. If the parent dies, the daemon instantly self-destructs."""
|
|
164
|
+
original_ppid = os.getppid()
|
|
160
165
|
while True:
|
|
161
166
|
time.sleep(3.0)
|
|
162
|
-
|
|
163
|
-
|
|
167
|
+
current_ppid = os.getppid()
|
|
168
|
+
if current_ppid == 1 or current_ppid != original_ppid:
|
|
169
|
+
logger.warning("Parent process died. Stopping daemon to prevent Zombie microphone lock.")
|
|
164
170
|
os._exit(0)
|
|
165
171
|
|
|
166
172
|
@asynccontextmanager
|
|
@@ -172,10 +178,6 @@ async def lifespan(app: FastAPI):
|
|
|
172
178
|
# Start the watchdog
|
|
173
179
|
asyncio.create_task(watchdog())
|
|
174
180
|
|
|
175
|
-
# Start the Parent PID Poller
|
|
176
|
-
polling_thread = threading.Thread(target=parent_pid_polling, daemon=True)
|
|
177
|
-
polling_thread.start()
|
|
178
|
-
|
|
179
181
|
yield
|
|
180
182
|
# Shutdown logic
|
|
181
183
|
if mic:
|
|
@@ -289,6 +291,36 @@ async def reload_config():
|
|
|
289
291
|
daemon_status_message = f"Failed to reload: {str(e)}"
|
|
290
292
|
return {"status": "error", "message": daemon_status_message}
|
|
291
293
|
|
|
294
|
+
@app.post("/abort")
|
|
295
|
+
async def abort_conversation():
|
|
296
|
+
global engine, mic, speaker, vad, active_session_id
|
|
297
|
+
logger.info("Received /abort command from client. Stopping audio.")
|
|
298
|
+
with mutex_lock:
|
|
299
|
+
if speaker:
|
|
300
|
+
speaker.flush()
|
|
301
|
+
if engine:
|
|
302
|
+
engine.state = State.EXECUTING
|
|
303
|
+
engine.buffer = []
|
|
304
|
+
if hasattr(engine.vad, "set_active"):
|
|
305
|
+
engine.vad.set_active(False)
|
|
306
|
+
if mic:
|
|
307
|
+
mic.stop_stream()
|
|
308
|
+
|
|
309
|
+
while not mcp_command_queue.empty():
|
|
310
|
+
try: mcp_command_queue.get_nowait()
|
|
311
|
+
except queue.Empty: break
|
|
312
|
+
|
|
313
|
+
mcp_result_queue.put({
|
|
314
|
+
"status": "ok",
|
|
315
|
+
"user_transcript": "",
|
|
316
|
+
"was_interrupted": True,
|
|
317
|
+
"message": "User manually aborted the voice loop using the panic button. You MUST NOT try to speak to the user right now. Wait for them to initiate the next interaction."
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
active_session_id = None
|
|
321
|
+
|
|
322
|
+
return {"status": "ok"}
|
|
323
|
+
|
|
292
324
|
@app.post("/converse")
|
|
293
325
|
async def converse(request: Request):
|
|
294
326
|
global active_session_id, last_active_timestamp
|
|
@@ -323,12 +355,14 @@ async def converse(request: Request):
|
|
|
323
355
|
# Wait for human to interact or natural termination, checking for client disconnects
|
|
324
356
|
while True:
|
|
325
357
|
if await request.is_disconnected():
|
|
326
|
-
|
|
358
|
+
logger.warning(f"[{session_id}] Client disconnected! Aborting audio loop.")
|
|
327
359
|
# Client hung up (e.g. reload or ctrl+c). We must reset the engine immediately.
|
|
328
360
|
if speaker:
|
|
329
361
|
speaker.flush()
|
|
330
362
|
if engine:
|
|
331
363
|
engine.state = State.EXECUTING # This will trigger mic.stop_stream() in the loop
|
|
364
|
+
if hasattr(vad, "set_active"):
|
|
365
|
+
vad.set_active(False)
|
|
332
366
|
raise HTTPException(status_code=499, detail="Client Disconnected")
|
|
333
367
|
|
|
334
368
|
try:
|
package/src/logger.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
def setup_logger(name="VoiceMCP", level=logging.INFO):
|
|
6
|
+
logger = logging.getLogger(name)
|
|
7
|
+
if not logger.handlers:
|
|
8
|
+
logger.setLevel(level)
|
|
9
|
+
# Use a professional telemetry format
|
|
10
|
+
formatter = logging.Formatter(
|
|
11
|
+
fmt='%(asctime)s.%(msecs)03d | %(levelname)-7s | %(module)-15s | %(message)s',
|
|
12
|
+
datefmt='%Y-%m-%d %H:%M:%S'
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# Output to stderr to avoid breaking stdio (MCP communication)
|
|
16
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
17
|
+
handler.setFormatter(formatter)
|
|
18
|
+
logger.addHandler(handler)
|
|
19
|
+
|
|
20
|
+
# File logger for persistent telemetry
|
|
21
|
+
log_dir = os.path.expanduser("~/Library/Application Support/VoiceMCP/logs")
|
|
22
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
23
|
+
file_handler = logging.FileHandler(os.path.join(log_dir, "telemetry.log"))
|
|
24
|
+
file_handler.setFormatter(formatter)
|
|
25
|
+
logger.addHandler(file_handler)
|
|
26
|
+
|
|
27
|
+
return logger
|
|
28
|
+
|
|
29
|
+
logger = setup_logger()
|
package/src/mcp_server.py
CHANGED
|
@@ -17,13 +17,11 @@ import json
|
|
|
17
17
|
import socket
|
|
18
18
|
import http.client
|
|
19
19
|
import time
|
|
20
|
-
import logging
|
|
21
20
|
import asyncio
|
|
22
21
|
import random
|
|
23
22
|
|
|
24
23
|
from mcp.server.fastmcp import FastMCP, Context
|
|
25
|
-
|
|
26
|
-
logging.basicConfig(level=logging.INFO, stream=sys.stderr)
|
|
24
|
+
from logger import logger
|
|
27
25
|
|
|
28
26
|
# Inject the advanced conversational instructions into the server
|
|
29
27
|
instructions = """
|
|
@@ -31,47 +29,52 @@ instructions = """
|
|
|
31
29
|
# VOICE-NATIVE PAIR PROGRAMMING PROTOCOL
|
|
32
30
|
You are a senior pair-programming partner collaborating with the user via a bidirectional, real-time voice interface. You are NOT a traditional text-based chatbot; you are an autonomous peer sitting next to the user.
|
|
33
31
|
|
|
34
|
-
## Core
|
|
35
|
-
|
|
36
|
-
When you run non-voice tools (reading files, searching, editing),
|
|
32
|
+
## Core Hardware Constraints & Your Senses
|
|
33
|
+
1. **Push-To-Talk (PTT):** The user communicates with you by pressing and holding the `Right Option (⌥)` key.
|
|
34
|
+
2. **Deaf by Default:** You execute tools strictly sequentially. When you run non-voice tools (reading files, searching, editing), your microphone is physically OFF. The user cannot interrupt you during these times.
|
|
35
|
+
3. **Hardware Watchdog:** To save the user's Unified Memory, your backend audio daemon will self-destruct and sleep if you are completely silent for 15 minutes.
|
|
36
|
+
4. **The Panic Button (Double-Tap):** Due to a known bug in the Gemini CLI, clicking "Stop" in the UI will NOT tell the audio daemon to stop talking or listening. To forcefully stop your voice or close the microphone, the user must DOUBLE-TAP the `Right Option` key.
|
|
37
|
+
|
|
38
|
+
To prevent the user from feeling abandoned, confused, or locked out, you must orchestrate the conversation using the following rules:
|
|
39
|
+
|
|
40
|
+
## 1. First Contact (Onboarding)
|
|
41
|
+
Since voice interfaces lack visual menus, the user might not know the physical controls. On your VERY FIRST conversational turn in a new session, you MUST seamlessly weave a brief explanation of the controls into your greeting.
|
|
42
|
+
*Example:* "Hey, I'm ready to dive in. Just a quick heads up—whenever you want to talk, just press and hold the Right Option key. To force me to stop talking or listening, just double-tap it quickly. If you ever need time to think, just ask me to pause. What are we working on today?"
|
|
43
|
+
CRITICAL: Do not repeat this instruction after the first interaction.
|
|
37
44
|
|
|
38
|
-
##
|
|
45
|
+
## 2. Floor Management (`expect_reply` Heuristics)
|
|
39
46
|
Think of the microphone as a shared conversational token.
|
|
40
47
|
|
|
41
48
|
**Keep the Token (`expect_reply: false`):**
|
|
42
49
|
Use this for micro-updates, acknowledgments, and transitions. You speak, the mic stays OFF, and you immediately execute your next tool.
|
|
43
50
|
- *Acknowledgment:* "Got it, looking into the routing file."
|
|
44
|
-
- *
|
|
45
|
-
- *The "Head Down" Warning:* "I'm going to run a deep codebase search. I'll be deaf for a minute while it runs."
|
|
51
|
+
- *The "Head Down" Warning (CRITICAL):* If you are about to do a heavy search or multi-file edit, warn the user they cannot interrupt you. "I'm going to run a deep codebase search. I'll be deaf for a minute, so the Right Option key won't work until I'm done."
|
|
46
52
|
|
|
47
53
|
**Yield the Token (`expect_reply: true`):**
|
|
48
|
-
Use this ONLY when you genuinely need the user to speak.
|
|
54
|
+
Use this ONLY when you genuinely need the user to speak. This MUST be the final tool call in your current execution sequence.
|
|
49
55
|
- *Clarification:* "I hit a compilation error on the auth module. Do you want me to rewrite the types or mock it out?"
|
|
50
|
-
- *Consent Gates:* "I've drafted the refactor for the database schema. Should I go ahead and apply it?"
|
|
51
|
-
- *Task Completion:* "All done with the UI updates. What should we tackle next?"
|
|
52
|
-
|
|
53
|
-
## 2. Rules of Engagement
|
|
54
|
-
- **Be Conversational & Terse:** Never use AI-isms ("As an AI...", "I will now execute the tool..."). Speak like a human engineer ("Let's check...", "Ah, I see the bug...", "On it.").
|
|
55
|
-
- **Never Dump Code:** Never read raw code blocks, markdown, or complex lists out loud. Summarize conceptually.
|
|
56
|
-
- **Interleave Work:** Do not chain multiple silent tools together for long periods without "muttering" an update to the user (`expect_reply: false`).
|
|
57
|
-
|
|
58
|
-
## 3. Handling Silences / Timeouts
|
|
59
|
-
If you ask a question (`expect_reply: true`) but the user is deep in thought, reviewing code, or steps away, the `voice_converse` tool will return `{"status": "silence_timeout"}`.
|
|
60
|
-
|
|
61
|
-
CRITICAL: Do not treat this as an error, and do not mention microphones, timeouts, or technical constraints. Act like a human colleague voluntarily giving them space.
|
|
62
56
|
|
|
63
|
-
|
|
64
|
-
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
57
|
+
## 3. Handling Hardware Interruptions (`was_interrupted: true`)
|
|
58
|
+
If `voice_converse` returns `was_interrupted: true`, it means the user held the Right Option key and cut you off mid-sentence. Instantly drop your previous train of thought. Do not try to finish your sentence. Acknowledge the interruption naturally and pivot immediately to their new input. (e.g., "Ah, good catch, switching to the backend folder now.")
|
|
59
|
+
|
|
60
|
+
## 4. Handling User Think Time & The 15-Minute Watchdog
|
|
61
|
+
If the user says "give me a minute", "let me think", or similar:
|
|
62
|
+
1. Acknowledge them quickly using `voice_converse(..., expect_reply=False)`.
|
|
63
|
+
2. Gently warn them about the 15-minute hardware watchdog.
|
|
64
|
+
3. Remind them to hold the `Right Option` key when they are ready to return.
|
|
65
|
+
4. IMMEDIATELY call the `wait_for_user()` tool.
|
|
66
|
+
*Example:* "Take your time. Just hold the Right Option key to wake me up when you're ready. As a heads up, my audio engine spins down after 15 minutes to save your Mac's memory, but I'll be right here."
|
|
67
|
+
|
|
68
|
+
## 5. Handling Silences / Timeouts
|
|
69
|
+
If you ask a question (`expect_reply: true`) but the user doesn't press the Right Option key, the tool will return `{"status": "silence_timeout"}`.
|
|
70
|
+
CRITICAL: Do not treat this as an error. Act like a human colleague voluntarily giving them space. Gracefully close the microphone by calling `voice_converse` one last time with `expect_reply: false`.
|
|
71
|
+
- *Example:* "Looks like you're focused. I'll pause my mic and stand by. Just hold the Right Option key when you want to pick it up."
|
|
72
|
+
|
|
73
|
+
## 6. General Rules of Engagement
|
|
74
|
+
- **Be Conversational & Terse:** Never use AI-isms ("As an AI..."). Speak like a human engineer.
|
|
75
|
+
- **Never Dump Code:** Never read raw code blocks out loud. Summarize conceptually.
|
|
76
|
+
- **Interleave Work:** Do not chain multiple silent tools together without muttering an update (`expect_reply: false`).
|
|
77
|
+
- **Handling System Busy:** If you get `"status": "system_busy"`, output a standard text message explaining the audio channel is locked, and continue via text.
|
|
75
78
|
</voice_loop_instructions>
|
|
76
79
|
"""
|
|
77
80
|
|
|
@@ -122,10 +125,10 @@ def ensure_daemon_running():
|
|
|
122
125
|
if check_daemon_health():
|
|
123
126
|
return
|
|
124
127
|
|
|
125
|
-
|
|
128
|
+
logger.info("Daemon is down, attempting to boot detached process...")
|
|
126
129
|
# Boot the daemon detached
|
|
127
130
|
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
128
|
-
python_exec =
|
|
131
|
+
python_exec = sys.executable
|
|
129
132
|
daemon_script = os.path.join(project_root, "src", "daemon", "audio_server.py")
|
|
130
133
|
|
|
131
134
|
subprocess.Popen(
|
|
@@ -200,20 +203,35 @@ async def render_visualizer(ctx: Context):
|
|
|
200
203
|
except asyncio.CancelledError:
|
|
201
204
|
pass
|
|
202
205
|
|
|
206
|
+
import threading
|
|
207
|
+
|
|
208
|
+
def fire_abort():
|
|
209
|
+
logger.info("Firing synchronous abort request to daemon...")
|
|
210
|
+
try:
|
|
211
|
+
make_uds_request("POST", "/abort", None, 5.0)
|
|
212
|
+
logger.info("Abort request sent successfully.")
|
|
213
|
+
except Exception as e:
|
|
214
|
+
logger.error(f"Failed to send abort request: {e}")
|
|
215
|
+
|
|
216
|
+
async def make_cancellable_converse_request(payload: dict, timeout: float) -> tuple[int, dict]:
|
|
217
|
+
try:
|
|
218
|
+
return await asyncio.to_thread(make_uds_request, "POST", "/converse", payload, timeout)
|
|
219
|
+
except asyncio.CancelledError:
|
|
220
|
+
# If the MCP client cancels this tool call, immediately tell the daemon to abort audio
|
|
221
|
+
logger.warning("Tool call was cancelled by MCP client! Triggering abort.")
|
|
222
|
+
threading.Thread(target=fire_abort, daemon=True).start()
|
|
223
|
+
raise
|
|
224
|
+
|
|
203
225
|
@mcp.tool()
|
|
204
226
|
async def voice_converse(text_to_speak: str, expect_reply: bool = True, ctx: Context = None) -> dict:
|
|
205
227
|
"""
|
|
206
|
-
Speak a prompt to the user and listen for a response.
|
|
207
|
-
If expect_reply is False, the tool returns immediately after queuing the speech.
|
|
228
|
+
Speak a prompt to the user and listen for a response. If expect_reply is False, the tool queues the speech and returns immediately. If expect_reply is True, it yields the floor to the user. If the returned JSON contains `was_interrupted: true`, the user used the Right Option key to cut you off mid-speech; you MUST completely abandon your previous thought and address their new input.
|
|
208
229
|
"""
|
|
209
230
|
try:
|
|
210
231
|
ensure_daemon_running()
|
|
211
232
|
|
|
212
233
|
async def _do_converse():
|
|
213
|
-
return await
|
|
214
|
-
make_uds_request,
|
|
215
|
-
"POST",
|
|
216
|
-
"/converse",
|
|
234
|
+
return await make_cancellable_converse_request(
|
|
217
235
|
{"session_id": SESSION_ID, "text_to_speak": text_to_speak, "expect_reply": expect_reply},
|
|
218
236
|
300.0
|
|
219
237
|
)
|
|
@@ -245,6 +263,7 @@ async def voice_converse(text_to_speak: str, expect_reply: bool = True, ctx: Con
|
|
|
245
263
|
await ctx.report_progress(d_progress, 100, message=d_msg)
|
|
246
264
|
|
|
247
265
|
if d_status == "READY":
|
|
266
|
+
logger.info("Model initialized to RAM")
|
|
248
267
|
if ctx:
|
|
249
268
|
await ctx.info("Voice MCP: Setup Complete!")
|
|
250
269
|
|
|
@@ -288,41 +307,70 @@ async def voice_converse(text_to_speak: str, expect_reply: bool = True, ctx: Con
|
|
|
288
307
|
@mcp.tool()
|
|
289
308
|
async def wait_for_user(ctx: Context = None) -> dict:
|
|
290
309
|
"""
|
|
291
|
-
Call this tool
|
|
292
|
-
It suspends the AI indefinitely until the user speaks.
|
|
310
|
+
Call this tool IMMEDIATELY after using voice_converse(expect_reply=False) to acknowledge a user's explicit request for time to think. It suspends the AI indefinitely until the user presses the Right Option key to wake you back up. Note: The underlying audio daemon will self-destruct after 15 minutes of idle time to free Unified Memory, so you must warn the user of this limit before calling.
|
|
293
311
|
"""
|
|
294
312
|
try:
|
|
295
313
|
ensure_daemon_running()
|
|
296
314
|
if ctx:
|
|
297
315
|
await ctx.info("🎙️ Waiting for user to speak... 🎙️")
|
|
298
316
|
|
|
299
|
-
status, response_data = await
|
|
300
|
-
make_uds_request,
|
|
301
|
-
"POST",
|
|
302
|
-
"/converse",
|
|
317
|
+
status, response_data = await make_cancellable_converse_request(
|
|
303
318
|
{"session_id": SESSION_ID, "text_to_speak": "", "expect_reply": True, "standby_mode": True},
|
|
304
319
|
3600.0
|
|
305
320
|
)
|
|
306
321
|
return response_data
|
|
307
322
|
|
|
308
|
-
except (socket.error, ConnectionError, FileNotFoundError, ConnectionRefusedError):
|
|
309
|
-
return {
|
|
310
|
-
"status": "error",
|
|
311
|
-
"user_transcript": "",
|
|
312
|
-
"message": "CRITICAL: The Voice Audio Daemon failed to respond."
|
|
313
|
-
}
|
|
314
|
-
except TimeoutError:
|
|
315
|
-
return {
|
|
316
|
-
"status": "error",
|
|
317
|
-
"user_transcript": "",
|
|
318
|
-
"message": "CRITICAL: The Voice Audio Daemon timed out waiting for speech."
|
|
319
|
-
}
|
|
320
323
|
except Exception as e:
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
324
|
+
# The daemon likely died from the 15-minute watchdog to save RAM.
|
|
325
|
+
# Implement the "Ghost Wake-Up": silently listen for Right Option, then boot the daemon.
|
|
326
|
+
if ctx:
|
|
327
|
+
await ctx.info("💤 Audio Engine sleeping to save RAM. Press Right Option to wake... 💤")
|
|
328
|
+
|
|
329
|
+
import pynput
|
|
330
|
+
loop = asyncio.get_running_loop()
|
|
331
|
+
wake_event = asyncio.Event()
|
|
332
|
+
|
|
333
|
+
def on_press(key):
|
|
334
|
+
if key in (pynput.keyboard.Key.alt_r, pynput.keyboard.Key.ctrl_r):
|
|
335
|
+
loop.call_soon_threadsafe(wake_event.set)
|
|
336
|
+
|
|
337
|
+
listener = pynput.keyboard.Listener(on_press=on_press)
|
|
338
|
+
listener.start()
|
|
339
|
+
|
|
340
|
+
await wake_event.wait()
|
|
341
|
+
listener.stop()
|
|
342
|
+
|
|
343
|
+
if ctx:
|
|
344
|
+
await ctx.info("🚀 Waking up Audio Engine... This might take a few seconds... 🚀")
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
ensure_daemon_running()
|
|
348
|
+
status, response_data = await make_cancellable_converse_request(
|
|
349
|
+
{"session_id": SESSION_ID, "text_to_speak": "", "expect_reply": True, "standby_mode": True},
|
|
350
|
+
3600.0
|
|
351
|
+
)
|
|
352
|
+
return response_data
|
|
353
|
+
except Exception as retry_e:
|
|
354
|
+
return {
|
|
355
|
+
"status": "error",
|
|
356
|
+
"user_transcript": "",
|
|
357
|
+
"message": f"CRITICAL Error waking up audio daemon: {str(retry_e)}"
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
import signal
|
|
361
|
+
|
|
362
|
+
def cleanup_on_exit(signum, frame):
|
|
363
|
+
logger.warning(f"Received termination signal {signum}. Firing abort request to daemon...")
|
|
364
|
+
try:
|
|
365
|
+
# Use a short timeout to prevent hanging the shutdown process
|
|
366
|
+
make_uds_request("POST", "/abort", None, 1.0)
|
|
367
|
+
logger.info("Abort request sent successfully during shutdown.")
|
|
368
|
+
except Exception as e:
|
|
369
|
+
logger.error(f"Failed to send abort request during shutdown: {e}")
|
|
370
|
+
sys.exit(0)
|
|
371
|
+
|
|
372
|
+
signal.signal(signal.SIGINT, cleanup_on_exit)
|
|
373
|
+
signal.signal(signal.SIGTERM, cleanup_on_exit)
|
|
326
374
|
|
|
327
375
|
if __name__ == "__main__":
|
|
328
376
|
# 4. Restore the OS-level stdout just before handing control to the MCP SDK
|
|
Binary file
|
|
Binary file
|
package/src/simulation/engine.py
CHANGED
|
@@ -2,6 +2,7 @@ from enum import Enum
|
|
|
2
2
|
from typing import List
|
|
3
3
|
from .models import Config, VirtualAudioFrame
|
|
4
4
|
from .ports import IMicrophone, ISpeaker, IVAD, ISTT, ILLMBridge
|
|
5
|
+
from logger import logger
|
|
5
6
|
|
|
6
7
|
class State(Enum):
|
|
7
8
|
IDLE = 1
|
|
@@ -49,6 +50,7 @@ class CoreEngine:
|
|
|
49
50
|
# If the VAD is PTT, we can safely close the mic stream to turn off the orange dot.
|
|
50
51
|
if hasattr(self.vad, "is_pressed"):
|
|
51
52
|
if hasattr(self.mic, "stop_stream"):
|
|
53
|
+
logger.debug("Microphone stream stopped")
|
|
52
54
|
self.mic.stop_stream()
|
|
53
55
|
self.state = State.STANDBY
|
|
54
56
|
self._reset_listening_state()
|
|
@@ -122,6 +124,7 @@ class CoreEngine:
|
|
|
122
124
|
else:
|
|
123
125
|
spoken_text = self.speaker.flush()
|
|
124
126
|
self.was_interrupted = True
|
|
127
|
+
logger.info("Barge-in detected! User interrupted the AI.")
|
|
125
128
|
self.state = State.LISTENING
|
|
126
129
|
self.current_silence_duration_ms = 0
|
|
127
130
|
self.total_recording_ms = self.current_speech_duration_ms
|
|
@@ -131,6 +134,7 @@ class CoreEngine:
|
|
|
131
134
|
if self.standby_mode:
|
|
132
135
|
self.state = State.STANDBY
|
|
133
136
|
if hasattr(self.vad, "is_pressed") and hasattr(self.mic, "stop_stream"):
|
|
137
|
+
logger.debug("Microphone stream stopped")
|
|
134
138
|
self.mic.stop_stream()
|
|
135
139
|
self._reset_listening_state()
|
|
136
140
|
else:
|
|
@@ -143,6 +147,7 @@ class CoreEngine:
|
|
|
143
147
|
self.total_listening_ms = 0
|
|
144
148
|
elif self.state == State.EXECUTING:
|
|
145
149
|
if hasattr(self.mic, 'stop_stream'):
|
|
150
|
+
logger.debug("Microphone stream stopped")
|
|
146
151
|
self.mic.stop_stream()
|
|
147
152
|
self.llm.start_request({"status": "notification_delivered"})
|
|
148
153
|
else:
|
|
@@ -158,6 +163,7 @@ class CoreEngine:
|
|
|
158
163
|
if self.standby_mode:
|
|
159
164
|
self.state = State.STANDBY
|
|
160
165
|
if hasattr(self.vad, "is_pressed") and hasattr(self.mic, "stop_stream"):
|
|
166
|
+
logger.debug("Microphone stream stopped")
|
|
161
167
|
self.mic.stop_stream()
|
|
162
168
|
self._reset_listening_state()
|
|
163
169
|
else:
|
|
@@ -167,6 +173,7 @@ class CoreEngine:
|
|
|
167
173
|
self.was_interrupted = False
|
|
168
174
|
elif self.state == State.EXECUTING:
|
|
169
175
|
if hasattr(self.mic, 'stop_stream'):
|
|
176
|
+
logger.debug("Microphone stream stopped")
|
|
170
177
|
self.mic.stop_stream()
|
|
171
178
|
self.llm.start_request({"status": "notification_delivered"})
|
|
172
179
|
|
|
@@ -201,6 +208,7 @@ class CoreEngine:
|
|
|
201
208
|
return
|
|
202
209
|
|
|
203
210
|
if not self.has_started_speaking and self.total_listening_ms >= self.config.listening_timeout_ms:
|
|
211
|
+
logger.info("Silence timeout reached. Prompting LLM.")
|
|
204
212
|
self.llm.start_request({"status": "silence_timeout", "user_transcript": ""})
|
|
205
213
|
self.state = State.PROCESSING
|
|
206
214
|
self.processing_wait_ms = 0
|
|
@@ -218,6 +226,7 @@ class CoreEngine:
|
|
|
218
226
|
self.state = State.LISTENING
|
|
219
227
|
if hasattr(self.vad, "is_pressed") and hasattr(self.mic, "start_stream"):
|
|
220
228
|
# We closed it earlier for PTT, so we need to reopen it.
|
|
229
|
+
logger.debug("Microphone stream started")
|
|
221
230
|
self.mic.start_stream()
|
|
222
231
|
self._reset_listening_state()
|
|
223
232
|
self.buffer.append(frame)
|
|
@@ -232,9 +241,10 @@ class CoreEngine:
|
|
|
232
241
|
|
|
233
242
|
if self.processing_wait_ms >= self.config.llm_timeout_ms:
|
|
234
243
|
import sys
|
|
235
|
-
|
|
244
|
+
logger.error("LLM Timeout reached. Assuming agent abandoned the voice loop. Tearing down hardware.")
|
|
236
245
|
self.state = State.EXECUTING
|
|
237
246
|
if hasattr(self.mic, 'stop_stream'):
|
|
247
|
+
logger.debug("Microphone stream stopped")
|
|
238
248
|
self.mic.stop_stream()
|
|
239
249
|
self.processing_wait_ms = 0
|
|
240
250
|
self.buffer = []
|
|
@@ -246,6 +256,7 @@ class CoreEngine:
|
|
|
246
256
|
|
|
247
257
|
orphan_speech = any(f.has_speech for f in self.buffer)
|
|
248
258
|
if orphan_speech:
|
|
259
|
+
logger.warning("Orphan speech detected. Interrupted previous context.")
|
|
249
260
|
self.was_interrupted = True
|
|
250
261
|
self.state = State.LISTENING
|
|
251
262
|
self.has_started_speaking = True
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import socket
|
|
3
|
+
import http.client
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
SOCKET_PATH = os.path.expanduser("~/Library/Application Support/VoiceMCP/daemon.sock")
|
|
9
|
+
|
|
10
|
+
class UDSHTTPConnection(http.client.HTTPConnection):
|
|
11
|
+
def __init__(self, socket_path, timeout=300.0):
|
|
12
|
+
super().__init__("localhost", timeout=timeout)
|
|
13
|
+
self.socket_path = socket_path
|
|
14
|
+
|
|
15
|
+
def connect(self):
|
|
16
|
+
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
17
|
+
self.sock.settimeout(self.timeout)
|
|
18
|
+
self.sock.connect(self.socket_path)
|
|
19
|
+
|
|
20
|
+
def make_uds_request(method: str, path: str, payload: dict = None, timeout: float = 1.0) -> tuple[int, dict]:
|
|
21
|
+
conn = UDSHTTPConnection(SOCKET_PATH, timeout=timeout)
|
|
22
|
+
try:
|
|
23
|
+
body = json.dumps(payload).encode('utf-8') if payload else None
|
|
24
|
+
headers = {'Content-Type': 'application/json'} if payload else {}
|
|
25
|
+
conn.request(method, path, body=body, headers=headers)
|
|
26
|
+
response = conn.getresponse()
|
|
27
|
+
data = response.read().decode('utf-8')
|
|
28
|
+
return response.status, json.loads(data) if data else {}
|
|
29
|
+
finally:
|
|
30
|
+
conn.close()
|
|
31
|
+
|
|
32
|
+
async def test_abort_during_synthesis():
|
|
33
|
+
print("\n--- Test 1: Abort during TTS Synthesis ---")
|
|
34
|
+
|
|
35
|
+
# Task 1: Start a long conversation
|
|
36
|
+
async def run_converse():
|
|
37
|
+
print("[Converse Task] Sending /converse request (Expect a 5-second TTS delay)...")
|
|
38
|
+
payload = {"session_id": "test_1", "text_to_speak": "This is a very long sentence that will take a moment to synthesize.", "expect_reply": True}
|
|
39
|
+
try:
|
|
40
|
+
status, response = await asyncio.to_thread(make_uds_request, "POST", "/converse", payload, 30.0)
|
|
41
|
+
print(f"[Converse Task] Finished with status: {status}, response: {response}")
|
|
42
|
+
return response
|
|
43
|
+
except Exception as e:
|
|
44
|
+
print(f"[Converse Task] Failed: {e}")
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
# Task 2: Fire the abort after 1 second
|
|
48
|
+
async def run_abort():
|
|
49
|
+
await asyncio.sleep(1.0)
|
|
50
|
+
print("[Abort Task] Firing /abort request NOW!")
|
|
51
|
+
status, response = await asyncio.to_thread(make_uds_request, "POST", "/abort", None, 5.0)
|
|
52
|
+
print(f"[Abort Task] /abort returned status: {status}, response: {response}")
|
|
53
|
+
|
|
54
|
+
converse_task = asyncio.create_task(run_converse())
|
|
55
|
+
abort_task = asyncio.create_task(run_abort())
|
|
56
|
+
|
|
57
|
+
response = await converse_task
|
|
58
|
+
await abort_task
|
|
59
|
+
|
|
60
|
+
if response and "User manually aborted" in response.get("message", ""):
|
|
61
|
+
print("✅ TEST 1 PASSED: Converse loop was successfully interrupted by /abort!")
|
|
62
|
+
else:
|
|
63
|
+
print("❌ TEST 1 FAILED: Converse loop did not return the expected cancellation message.")
|
|
64
|
+
|
|
65
|
+
async def test_abort_during_standby():
|
|
66
|
+
print("\n--- Test 2: Abort during Standby Mode ---")
|
|
67
|
+
|
|
68
|
+
async def run_standby():
|
|
69
|
+
print("[Standby Task] Entering infinite standby mode...")
|
|
70
|
+
payload = {"session_id": "test_2", "text_to_speak": "", "expect_reply": True, "standby_mode": True}
|
|
71
|
+
try:
|
|
72
|
+
status, response = await asyncio.to_thread(make_uds_request, "POST", "/converse", payload, 30.0)
|
|
73
|
+
print(f"[Standby Task] Finished with status: {status}, response: {response}")
|
|
74
|
+
return response
|
|
75
|
+
except Exception as e:
|
|
76
|
+
print(f"[Standby Task] Failed: {e}")
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
async def run_abort():
|
|
80
|
+
await asyncio.sleep(1.5)
|
|
81
|
+
print("[Abort Task] Firing /abort request NOW!")
|
|
82
|
+
status, response = await asyncio.to_thread(make_uds_request, "POST", "/abort", None, 5.0)
|
|
83
|
+
print(f"[Abort Task] /abort returned status: {status}, response: {response}")
|
|
84
|
+
|
|
85
|
+
standby_task = asyncio.create_task(run_standby())
|
|
86
|
+
abort_task = asyncio.create_task(run_abort())
|
|
87
|
+
|
|
88
|
+
response = await standby_task
|
|
89
|
+
await abort_task
|
|
90
|
+
|
|
91
|
+
if response and "User manually aborted" in response.get("message", ""):
|
|
92
|
+
print("✅ TEST 2 PASSED: Standby loop was successfully interrupted by /abort!")
|
|
93
|
+
else:
|
|
94
|
+
print("❌ TEST 2 FAILED: Standby loop did not return the expected cancellation message.")
|
|
95
|
+
|
|
96
|
+
async def main():
|
|
97
|
+
# Ensure daemon is up before testing
|
|
98
|
+
try:
|
|
99
|
+
make_uds_request("GET", "/health")
|
|
100
|
+
except Exception:
|
|
101
|
+
print("CRITICAL: Audio Daemon is not running or socket is missing.")
|
|
102
|
+
sys.exit(1)
|
|
103
|
+
|
|
104
|
+
await test_abort_during_synthesis()
|
|
105
|
+
await test_abort_during_standby()
|
|
106
|
+
print("\nAll tests completed.")
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
asyncio.run(main())
|