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.
Files changed (30) hide show
  1. package/README.md +2 -2
  2. package/config/config.yaml +1 -1
  3. package/config/vad/ptt_vad.yaml +1 -1
  4. package/package.json +1 -1
  5. package/requirements.txt +1 -0
  6. package/src/__pycache__/logger.cpython-312.pyc +0 -0
  7. package/src/__pycache__/mcp_server.cpython-312.pyc +0 -0
  8. package/src/adapters_real/__pycache__/kokoro_speaker.cpython-312.pyc +0 -0
  9. package/src/adapters_real/__pycache__/live_mic.cpython-312.pyc +0 -0
  10. package/src/adapters_real/__pycache__/ptt_vad.cpython-312.pyc +0 -0
  11. package/src/adapters_real/__pycache__/whisper_stt.cpython-312.pyc +0 -0
  12. package/src/adapters_real/kokoro_speaker.py +7 -6
  13. package/src/adapters_real/live_mic.py +15 -4
  14. package/src/adapters_real/ptt_sidecar +0 -0
  15. package/src/adapters_real/ptt_sidecar.swift +156 -0
  16. package/src/adapters_real/ptt_vad.py +143 -25
  17. package/src/adapters_real/whisper_stt.py +5 -4
  18. package/src/daemon/__pycache__/audio_server.cpython-312.pyc +0 -0
  19. package/src/daemon/audio_server.py +47 -13
  20. package/src/logger.py +29 -0
  21. package/src/mcp_server.py +113 -65
  22. package/src/simulation/__pycache__/adapters.cpython-312.pyc +0 -0
  23. package/src/simulation/__pycache__/engine.cpython-312.pyc +0 -0
  24. package/src/simulation/engine.py +12 -1
  25. package/src/simulation/tests/__pycache__/__init__.cpython-312.pyc +0 -0
  26. package/src/simulation/tests/__pycache__/test_ptt_vad.cpython-312-pytest-7.4.2.pyc +0 -0
  27. package/src/simulation/tests/__pycache__/test_scenarios.cpython-312-pytest-7.4.2.pyc +0 -0
  28. package/src/simulation/tests/test_abort_daemon.py +109 -0
  29. package/src/simulation/tests/test_mcp_cancellation.py +83 -0
  30. 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
- print(f"Model download error: {e}", file=sys.stderr)
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
- print("Loaded Hydra configuration successfully.")
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
- print("Audio Daemon Started. Waiting for commands.", file=sys.stderr)
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
- print(f"Daemon exception: {e}", file=sys.stderr)
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
- print(f"Idle timeout reached ({idle_time:.0f}s). Self-destructing to free RAM.", file=sys.stderr)
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
- if os.getppid() == 1:
163
- print("Parent process died. Stopping daemon to prevent Zombie microphone lock.", file=sys.stderr)
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
- print(f"[{session_id}] Client disconnected! Aborting audio loop.", file=sys.stderr)
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 Constraint: Sequential Execution
35
- You execute tools strictly sequentially. Your primary communication tool is `voice_converse(text_to_speak, expect_reply)`.
36
- When you run non-voice tools (reading files, searching, editing), you are "deaf" to the user and your microphone is OFF. To prevent the user from feeling abandoned or locked out, you must proactively orchestrate the conversation using the following rules:
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
- ## 1. Floor Management (`expect_reply` Heuristics)
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
- - *Transitions:* "Auth tests passed, moving on to the user models."
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. Crucially, when you set this to True, it MUST be the final tool call in your current execution sequence, as you are pausing your logic to wait for human input.
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
- You MUST gracefully close the microphone by calling `voice_converse` one last time with `expect_reply: false`. Use brief, casual, supportive phrases such as:
64
- - "Take your time. Just say my name when you're ready to continue."
65
- - "Looks like you're focused. I'll pause my mic and stand by."
66
- - "I'll let you look that over. Ping me when you want to pick it up."
67
- - "No rush, I'll be right here when you need me."
68
-
69
- ## 4. Handling User Think Time
70
- If the user says "give me a minute", "let me think", or similar, you MUST acknowledge them quickly using `voice_converse(..., expect_reply=False)`, and then immediately call the `wait_for_user()` tool. This will suspend your execution indefinitely until they are ready to speak again.
71
-
72
- ## 5. Handling System Busy
73
- If the voice_converse tool returns "status": "system_busy", it means the physical microphone is currently locked by another AI agent in a different window.
74
- DO NOT retry the tool. Output a standard text message explaining the audio channel is busy, and continue the conversation via text.
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
- logging.info("Daemon is down, attempting to boot detached process...")
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 = os.path.join(app_support_dir, "venv", "bin", "python3")
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 asyncio.to_thread(
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 when the user explicitly asks for time to think.
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 asyncio.to_thread(
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
- return {
322
- "status": "error",
323
- "user_transcript": "",
324
- "message": f"CRITICAL Error during standby: {str(e)}"
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
@@ -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
- print("LLM Timeout reached. Assuming agent abandoned the voice loop. Tearing down hardware.", file=sys.stderr)
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
@@ -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())