Preserve macOS app permissions via dynamic launcher, .env workspace path, and session/audio tools

This commit is contained in:
Adolfo Reyna
2026-08-11 21:15:37 -04:00
parent 583131221c
commit b5034b4b16
17 changed files with 1888 additions and 68 deletions
+13 -5
View File
@@ -49,23 +49,29 @@ The project operates entirely from the app directory without depending on `~/Wor
- **CLI Helper Scripts**: - **CLI Helper Scripts**:
- `bin/voice_tool.py`: List and switch TTS voices (`python bin/voice_tool.py list`, `python bin/voice_tool.py set <voice>`). - `bin/voice_tool.py`: List and switch TTS voices (`python bin/voice_tool.py list`, `python bin/voice_tool.py set <voice>`).
- `bin/model_tool.py`: List and switch LLM models (`python bin/model_tool.py list`, `python bin/model_tool.py set <model>`). - `bin/model_tool.py`: List and switch LLM models (`python bin/model_tool.py list`, `python bin/model_tool.py set <model>`).
- `bin/session_tool.py`: Inspect or reset active Hermes session (`python bin/session_tool.py get`, `python bin/session_tool.py reset`).
- `bin/profile_tool.py`: List and switch Hermes agent profiles (`python bin/profile_tool.py list`, `python bin/profile_tool.py set <profile>`).
- `bin/web_tool.py`: Inspect files or URLs in the Companion Web UI drawer (`http://localhost:8888`). - `bin/web_tool.py`: Inspect files or URLs in the Companion Web UI drawer (`http://localhost:8888`).
--- ---
## 4. Building & Packaging `/Applications/VoiceAgent.app` ## 4. Building & Packaging `/Applications/VoiceAgent.app`
Whenever python or Swift sources are modified, update the standalone macOS application bundle: `VoiceAgentLauncher` runs Python scripts dynamically from the project workspace (configured in `~/.voiceagent.env` or `VOICEAGENT_DIR`).
- **Python code edits take effect immediately** without recompiling or re-signing the macOS app bundle.
- **Rebuilding binaries** (`bash build_app.sh`) is only required when modifying Swift sources (`swift/*.swift`), helper binaries, or `Info.plist` entitlements.
- `build_app.sh` automatically updates `~/.voiceagent.env` and skips binary re-signing if Swift sources are unchanged, preserving macOS privacy permissions (Microphone, Accessibility, Speech Recognition). Use `bash build_app.sh -f` to force a full rebuild.
```bash ```bash
bash build_app.sh bash build_app.sh
``` ```
**Build Workflow**: **Build Workflow**:
1. Builds Swift binaries (`swift/build.sh` -> `speech-helper`, `llm-helper`, `VoiceAgentLauncher`). 1. Configures `~/.voiceagent.env` with `VOICEAGENT_DIR` and `VOICEAGENT_PYTHON`.
2. Bundles Python source files, `bin/` tools, and assets into `dist/VoiceAgent.app`. 2. Checks if Swift binaries (`speech-helper`, `llm-helper`, `VoiceAgentLauncher`) need recompiling.
3. Signs the app bundle (`codesign -s - --deep --force`). 3. If up to date, copies Python resources without re-signing the app bundle (preserving macOS TCC permissions).
4. Replaces `/Applications/VoiceAgent.app`. 4. If modified, compiles Swift binaries, creates `dist/VoiceAgent.app`, signs, and installs to `/Applications/VoiceAgent.app`.
--- ---
@@ -74,6 +80,8 @@ bash build_app.sh
Before committing changes, execute the test suite: Before committing changes, execute the test suite:
```bash ```bash
.venv/bin/python3 test_profile_tool.py
.venv/bin/python3 test_session_tool.py
.venv/bin/python3 test_model_manager.py .venv/bin/python3 test_model_manager.py
.venv/bin/python3 test_spoken_text.py .venv/bin/python3 test_spoken_text.py
.venv/bin/python3 test_journal.py .venv/bin/python3 test_journal.py
+35
View File
@@ -70,6 +70,41 @@ terminal under System Settings → Privacy & Security → Microphone.
The speech model downloads itself the first time a locale is used. The speech model downloads itself the first time a locale is used.
## macOS AirPods route switching checklist
When no audio device is pinned, VoiceAgent follows macOS's current default
microphone and speaker while a conversation is running. Use this checklist when
validating AirPods or another Bluetooth headset:
1. Connect AirPods before starting `./talk`; confirm that both microphone and
speaker audio use them.
2. Start a conversation on the built-in microphone and speakers, then connect
AirPods. Confirm that each available direction moves independently to the
new macOS default without ending the conversation.
3. Disconnect AirPods during a conversation. Confirm that the available input
and output return to the macOS defaults and that the conversation remains
usable.
4. In Control Center or Sound settings, explicitly switch back to the built-in
microphone and speakers while AirPods remain connected. Confirm both routes
follow those defaults.
5. Repeat connect, disconnect, and manual default changes several times to
catch delayed Bluetooth profile changes or a stale route listener.
Run this validation without `--input-device` or `--output-device`: omitted
values intentionally follow macOS defaults. Supplying either flag pins only that
direction (for example, a pinned USB microphone still allows an unpinned output
to follow AirPods); pinning both directions disables automatic route following.
Use `./talk --list-devices` to identify a device by index or name substring.
This behavior depends on macOS Core Audio notifications and the device profile
currently exposed by Bluetooth. A headset may briefly expose only output, or
reject the negotiated sample rate while switching profiles; VoiceAgent keeps the
previous working route when a replacement cannot open. The terminal/app still
needs macOS Microphone permission, and the global hold-key needs Input
Monitoring. A sandboxed packaged app also needs the appropriate microphone usage
description and audio-input entitlement; those platform permissions cannot be
granted by route switching code.
## Turn taking, and why it's push-to-talk ## Turn taking, and why it's push-to-talk
Detecting the end of a turn by listening for silence is both slow and wrong Detecting the end of a turn by listening for silence is both slow and wrong
+357
View File
@@ -0,0 +1,357 @@
"""macOS default audio-device monitoring.
The monitor deliberately depends on a small backend protocol. Core Audio invokes
listeners on an arbitrary thread, while VoiceAgent consumes snapshots on its
asyncio loop; :class:`AudioDeviceMonitor` is the lifecycle and thread boundary
between those two worlds. The backend can be replaced by a deterministic fake
in unit tests.
"""
from __future__ import annotations
import asyncio
import ctypes
import ctypes.util
import inspect
import sys
from dataclasses import dataclass, field
from enum import Enum
from typing import Callable, Protocol
class DeviceChangeReason(str, Enum):
INITIAL = "initial"
DEFAULT_CHANGED = "default_changed"
DEVICE_ADDED = "device_added"
DEVICE_REMOVED = "device_removed"
DEVICE_RECONFIGURED = "device_reconfigured"
PROFILE_CHANGED = "profile_changed"
@dataclass(frozen=True)
class AudioDevice:
uid: str
name: str
can_input: bool
can_output: bool
transport: str = "unknown"
alive: bool = True
@dataclass(frozen=True)
class AudioDeviceSnapshot:
generation: int
default_input_uid: str | None
default_output_uid: str | None
devices: dict[str, AudioDevice] = field(default_factory=dict)
reason: DeviceChangeReason = DeviceChangeReason.INITIAL
Listener = Callable[[], None]
class AudioDeviceBackend(Protocol):
"""The minimal Core Audio surface required by the monitor."""
def enumerate_devices(self) -> list[AudioDevice]: ...
def default_input_uid(self) -> str | None: ...
def default_output_uid(self) -> str | None: ...
def add_listener(self, callback: Listener) -> object: ...
def remove_listener(self, token: object) -> None: ...
class AudioDeviceMonitor:
"""Coalesces Core Audio notifications and dispatches snapshots on one loop."""
def __init__(self, backend: AudioDeviceBackend, *, debounce_seconds: float = 0.05,
loop: asyncio.AbstractEventLoop | None = None):
self._backend = backend
self._debounce_seconds = debounce_seconds
self._loop = loop
self._callback: Callable[[AudioDeviceSnapshot], object] | None = None
self._snapshot = AudioDeviceSnapshot(0, None, None)
self._generation = 0
self._listener_tokens: list[object] = []
self._debounce_handle: asyncio.TimerHandle | None = None
self._running = False
self._refresh_scheduled = False
self._refresh_task: asyncio.Task[None] | None = None
async def start(self, on_change: Callable[[AudioDeviceSnapshot], object]) -> None:
"""Register listeners once and publish an initial snapshot.
``on_change`` may be synchronous or return an awaitable. It is always
called on the loop used by this monitor, never on a Core Audio thread.
"""
if self._running:
return
self._loop = self._loop or asyncio.get_running_loop()
self._callback = on_change
self._running = True
try:
# Register all listeners before the initial read so a concurrent
# device change cannot be missed.
self._listener_tokens = [self._backend.add_listener(self._on_backend_event) for _ in range(1)]
await self._refresh(DeviceChangeReason.INITIAL)
except Exception:
await self.stop()
raise
async def stop(self) -> None:
"""Stop idempotently and ensure no callback can be scheduled afterward."""
self._running = False
if self._debounce_handle:
self._debounce_handle.cancel()
self._debounce_handle = None
current_task = asyncio.current_task()
if (self._refresh_task and not self._refresh_task.done()
and self._refresh_task is not current_task):
self._refresh_task.cancel()
try:
await self._refresh_task
except asyncio.CancelledError:
pass
self._refresh_task = None
tokens, self._listener_tokens = self._listener_tokens, []
for token in tokens:
self._backend.remove_listener(token)
self._callback = None
self._refresh_scheduled = False
def snapshot(self) -> AudioDeviceSnapshot:
return self._snapshot
def _on_backend_event(self) -> None:
"""Core Audio callback entry point; safe to call from any thread."""
if not self._running or not self._loop:
return
self._loop.call_soon_threadsafe(self._schedule_refresh)
def _schedule_refresh(self) -> None:
if not self._running or self._refresh_scheduled:
return
self._refresh_scheduled = True
if self._debounce_handle:
self._debounce_handle.cancel()
self._debounce_handle = self._loop.call_later(self._debounce_seconds, self._start_refresh)
def _start_refresh(self) -> None:
self._debounce_handle = None
self._refresh_scheduled = False
if self._running:
self._refresh_task = self._loop.create_task(self._refresh(self._infer_reason()))
self._refresh_task.add_done_callback(self._consume_refresh_failure)
@staticmethod
def _consume_refresh_failure(task: asyncio.Task[None]) -> None:
"""Retrieve scheduled refresh failures after cleanup has completed."""
if not task.cancelled():
task.exception()
def _infer_reason(self) -> DeviceChangeReason:
# The backend intentionally keeps the callback payload-free. The
# resulting snapshot is authoritative; callers can inspect UID/device
# differences. A generic device reconfiguration is safest here.
return DeviceChangeReason.DEFAULT_CHANGED
async def _refresh(self, reason: DeviceChangeReason) -> None:
if not self._running:
return
devices = {d.uid: d for d in self._backend.enumerate_devices() if d.uid and d.alive}
new_input = self._backend.default_input_uid()
new_output = self._backend.default_output_uid()
# An unavailable default is represented as None rather than a stale UID.
if new_input not in devices or not devices[new_input].can_input:
new_input = None
if new_output not in devices or not devices[new_output].can_output:
new_output = None
old = self._snapshot
if (old.default_input_uid == new_input and old.default_output_uid == new_output
and old.devices == devices and old.generation != 0):
return
if old.generation and reason != DeviceChangeReason.INITIAL:
old_uids, new_uids = set(old.devices), set(devices)
if old.default_input_uid != new_input or old.default_output_uid != new_output:
reason = DeviceChangeReason.DEFAULT_CHANGED
elif new_uids - old_uids:
reason = DeviceChangeReason.DEVICE_ADDED
elif old_uids - new_uids:
reason = DeviceChangeReason.DEVICE_REMOVED
elif any(old.devices[uid] != device for uid, device in devices.items()
if uid in old.devices):
reason = DeviceChangeReason.PROFILE_CHANGED
self._generation += 1
self._snapshot = AudioDeviceSnapshot(self._generation, new_input, new_output, devices, reason)
callback = self._callback
if callback and self._running:
try:
result = callback(self._snapshot)
if inspect.isawaitable(result):
await result
except Exception:
# A failed consumer must not leave a native listener active with
# an unusable callback. ``stop`` handles the current refresh
# task specially so this cleanup is safe from inside _refresh.
await self.stop()
raise
class MacOSCoreAudioBackend:
"""Core Audio backend hook.
PyObjC's CoreAudio listener ABI differs between macOS releases. Keeping the
native adapter behind this class lets packaging provide the matching adapter
without exposing it to the async monitor or its tests.
"""
def __init__(self, adapter):
self._adapter = adapter
def enumerate_devices(self) -> list[AudioDevice]:
return list(self._adapter.enumerate_devices())
def default_input_uid(self) -> str | None:
return self._adapter.default_input_uid()
def default_output_uid(self) -> str | None:
return self._adapter.default_output_uid()
def add_listener(self, callback: Listener) -> object:
return self._adapter.add_device_listener(callback)
def remove_listener(self, token: object) -> None:
self._adapter.remove_device_listener(token)
class NativeMacOSCoreAudioAdapter:
"""Native Core Audio adapter using stable device UIDs, never PortAudio IDs."""
_SYSTEM_OBJECT = 1
_GLOBAL = int.from_bytes(b"glob", "big")
_INPUT_SCOPE = int.from_bytes(b"inpt", "big")
_OUTPUT_SCOPE = int.from_bytes(b"outp", "big")
_DEFAULT_INPUT = int.from_bytes(b"dIn ", "big")
_DEFAULT_OUTPUT = int.from_bytes(b"dOut", "big")
_DEVICES = int.from_bytes(b"dev#", "big")
_UID = int.from_bytes(b"uid ", "big")
_NAME = int.from_bytes(b"lnam", "big")
_ALIVE = int.from_bytes(b"livn", "big")
_TRANSPORT = int.from_bytes(b"tran", "big")
_STREAMS = int.from_bytes(b"stm#", "big")
class _Address(ctypes.Structure):
_fields_ = [("selector", ctypes.c_uint32), ("scope", ctypes.c_uint32), ("element", ctypes.c_uint32)]
def __init__(self):
if sys.platform != "darwin":
raise RuntimeError("Core Audio is only available on macOS")
core_audio = ctypes.util.find_library("CoreAudio")
core_foundation = ctypes.util.find_library("CoreFoundation")
if not core_audio or not core_foundation:
raise RuntimeError("CoreAudio.framework is unavailable")
self._lib = ctypes.CDLL(core_audio)
self._cf = ctypes.CDLL(core_foundation)
self._listener_type = ctypes.CFUNCTYPE(ctypes.c_int32, ctypes.c_uint32, ctypes.c_uint32,
ctypes.POINTER(self._Address), ctypes.c_void_p)
self._lib.AudioObjectGetPropertyData.argtypes = [ctypes.c_uint32, ctypes.POINTER(self._Address), ctypes.c_uint32, ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint32), ctypes.c_void_p]
self._lib.AudioObjectGetPropertyData.restype = ctypes.c_int32
self._lib.AudioObjectGetPropertyDataSize.argtypes = [ctypes.c_uint32, ctypes.POINTER(self._Address), ctypes.c_uint32, ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint32)]
self._lib.AudioObjectGetPropertyDataSize.restype = ctypes.c_int32
self._lib.AudioObjectAddPropertyListener.argtypes = [ctypes.c_uint32, ctypes.POINTER(self._Address), self._listener_type, ctypes.c_void_p]
self._lib.AudioObjectRemovePropertyListener.argtypes = [ctypes.c_uint32, ctypes.POINTER(self._Address), self._listener_type, ctypes.c_void_p]
self._cf.CFStringGetCString.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_long, ctypes.c_uint32]
self._callbacks = {}
def _address(self, selector, scope=None):
return self._Address(selector, self._GLOBAL if scope is None else scope, 0)
def _value(self, object_id, selector, value_type, scope=None):
value, size = value_type(), ctypes.c_uint32(ctypes.sizeof(value_type))
status = self._lib.AudioObjectGetPropertyData(object_id, ctypes.byref(self._address(selector, scope)), 0, None, ctypes.byref(size), ctypes.byref(value))
if status:
raise OSError(f"AudioObjectGetPropertyData({selector}) failed: {status}")
return value.value
def _string(self, object_id, selector):
ref = self._value(object_id, selector, ctypes.c_void_p)
if not ref:
return ""
buffer = ctypes.create_string_buffer(1024)
if not self._cf.CFStringGetCString(ref, buffer, len(buffer), 0x08000100):
return ""
return buffer.value.decode("utf-8", "replace")
def _device_ids(self):
address, size = self._address(self._DEVICES), ctypes.c_uint32()
status = self._lib.AudioObjectGetPropertyDataSize(self._SYSTEM_OBJECT, ctypes.byref(address), 0, None, ctypes.byref(size))
if status:
raise OSError(f"Audio device enumeration failed: {status}")
devices = (ctypes.c_uint32 * (size.value // ctypes.sizeof(ctypes.c_uint32)))()
status = self._lib.AudioObjectGetPropertyData(self._SYSTEM_OBJECT, ctypes.byref(address), 0, None, ctypes.byref(size), devices)
if status:
raise OSError(f"Audio device enumeration failed: {status}")
return list(devices)
def _has_streams(self, device_id, scope):
address, size = self._address(self._STREAMS, scope), ctypes.c_uint32()
status = self._lib.AudioObjectGetPropertyDataSize(device_id, ctypes.byref(address), 0, None, ctypes.byref(size))
return not status and bool(size.value)
def enumerate_devices(self):
devices = []
for device_id in self._device_ids():
try:
uid = self._string(device_id, self._UID)
if not uid:
continue
devices.append(AudioDevice(uid, self._string(device_id, self._NAME) or uid,
self._has_streams(device_id, self._INPUT_SCOPE), self._has_streams(device_id, self._OUTPUT_SCOPE),
self._fourcc(self._value(device_id, self._TRANSPORT, ctypes.c_uint32)), bool(self._value(device_id, self._ALIVE, ctypes.c_uint32))))
except OSError:
continue
return devices
@staticmethod
def _fourcc(value):
return value.to_bytes(4, "big").decode("ascii", "replace").strip() or "unknown"
def default_input_uid(self):
return self._uid_for_id(self._value(self._SYSTEM_OBJECT, self._DEFAULT_INPUT, ctypes.c_uint32))
def default_output_uid(self):
return self._uid_for_id(self._value(self._SYSTEM_OBJECT, self._DEFAULT_OUTPUT, ctypes.c_uint32))
def _uid_for_id(self, device_id):
return self._string(device_id, self._UID) if device_id else None
def add_device_listener(self, callback):
addresses = [(self._SYSTEM_OBJECT, self._address(selector)) for selector in (self._DEFAULT_INPUT, self._DEFAULT_OUTPUT, self._DEVICES)]
addresses += [(device_id, self._address(selector, scope)) for device_id in self._device_ids() for selector, scope in ((self._ALIVE, None), (self._STREAMS, self._INPUT_SCOPE), (self._STREAMS, self._OUTPUT_SCOPE))]
native_callback = self._listener_type(lambda *_: (callback(), 0)[1])
registered = []
try:
for object_id, address in addresses:
status = self._lib.AudioObjectAddPropertyListener(object_id, ctypes.byref(address), native_callback, None)
if status:
raise OSError(f"AudioObjectAddPropertyListener failed: {status}")
registered.append((object_id, address))
except Exception:
for object_id, address in registered:
self._lib.AudioObjectRemovePropertyListener(object_id, ctypes.byref(address), native_callback, None)
raise
token = (native_callback, registered)
self._callbacks[id(token)] = token
return token
def remove_device_listener(self, token):
native_callback, addresses = token
for object_id, address in addresses:
self._lib.AudioObjectRemovePropertyListener(object_id, ctypes.byref(address), native_callback, None)
self._callbacks.pop(id(token), None)
def create_macos_audio_monitor(*, adapter=None, **kwargs) -> AudioDeviceMonitor:
"""Create a native Core Audio monitor (or a supplied test adapter)."""
if adapter is None:
adapter = NativeMacOSCoreAudioAdapter()
return AudioDeviceMonitor(MacOSCoreAudioBackend(adapter), **kwargs)
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Inspect or switch VoiceAgent's live input/output device through its local UI API."""
import argparse
import json
import sys
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
BASE_URL = "http://127.0.0.1:8888"
def request(path, method="GET", payload=None):
data = json.dumps(payload).encode() if payload is not None else None
req = Request(BASE_URL + path, data=data, method=method)
if data is not None:
req.add_header("Content-Type", "application/json")
try:
with urlopen(req, timeout=5) as response:
return json.load(response)
except HTTPError as exc:
try:
message = json.load(exc)
except Exception:
message = {"error": exc.read().decode("utf-8", "replace")}
raise RuntimeError(message.get("error", str(exc))) from exc
except URLError as exc:
raise RuntimeError(f"VoiceAgent is not reachable at {BASE_URL}: {exc.reason}") from exc
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
commands.add_parser("list", help="List selectable input/output devices.")
select = commands.add_parser("set", help="Switch one live route or follow the macOS default.")
select.add_argument("direction", choices=("input", "output"))
select.add_argument("device", help="Unique device-name substring, PortAudio index, or 'default'.")
args = parser.parse_args()
try:
if args.command == "list":
result = request("/api/audio-devices")
for device in result["devices"]:
kinds = "/".join(kind for kind in ("input" if device["input"] else "", "output" if device["output"] else "") if kind)
print(f"[{device['id']}] {device['name']} ({kinds})")
else:
result = request("/api/audio-device", "POST", {"direction": args.direction, "device": args.device})
mode = "following macOS default" if result["following_default"] else f"pinned to [{result['device']}]"
print(f"{args.direction} changed to {result['name']} ({mode})")
return 0
except (KeyError, RuntimeError) as exc:
print(f"audio-tool: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""CLI helper to list, get, and switch Hermes agent profiles for VoiceAgent."""
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
# Add VoiceAgent1 project root to sys.path
project_root = Path(__file__).resolve().parent.parent
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
from hermes_llm import find_hermes_cli
def _get_hermes_cli() -> str:
cli = find_hermes_cli()
if not cli:
raise RuntimeError("Hermes CLI binary not found in PATH or standard paths")
return cli
def list_profiles() -> str:
cli = _get_hermes_cli()
res = subprocess.run([cli, "profile", "list"], capture_output=True, text=True)
if res.returncode != 0:
return f"Error listing profiles: {res.stderr.strip()}"
return res.stdout.strip()
def get_current_profile() -> str:
cli = _get_hermes_cli()
res = subprocess.run([cli, "profile", "list"], capture_output=True, text=True)
if res.returncode == 0:
for line in res.stdout.splitlines():
line_clean = line.strip()
if line_clean.startswith("◆") or line_clean.startswith("*"):
parts = line_clean.lstrip("◆* ").split()
if parts:
return parts[0]
return "default"
def set_profile(profile_name: str) -> tuple[bool, str]:
target = profile_name.strip().lstrip("◆* ")
if not target:
return False, "No profile name specified."
cli = _get_hermes_cli()
res = subprocess.run([cli, "profile", "use", target], capture_output=True, text=True)
if res.returncode == 0:
output_msg = res.stdout.strip() or f"Switched to Hermes profile '{target}'."
try:
import web_server
web_server.broadcast_event("status_change", {"profile": target})
except Exception:
pass
return True, output_msg
else:
err_msg = res.stderr.strip() or res.stdout.strip() or f"Failed to set profile '{target}'."
return False, err_msg
def main():
if len(sys.argv) < 2 or sys.argv[1] in ("list", "ls", "--list"):
print(list_profiles())
return
action = sys.argv[1].lower()
if action in ("get", "current", "show"):
print(f"Active Hermes Profile: {get_current_profile()}")
return
if action in ("set", "use", "change") and len(sys.argv) >= 3:
target = sys.argv[2]
ok, msg = set_profile(target)
print(msg)
else:
# Treat single argument as target profile
target = sys.argv[1]
ok, msg = set_profile(target)
print(msg)
if __name__ == "__main__":
main()
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""CLI helper to inspect and reset Hermes voice agent sessions."""
import json
import sys
from pathlib import Path
# Add VoiceAgent1 project root to sys.path
project_root = Path(__file__).resolve().parent.parent
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
SESSION_FILE_NAME = ".hermes-voice-session.json"
def get_session_file(app_dir: Path | None = None) -> Path:
base_dir = app_dir or project_root
return base_dir / SESSION_FILE_NAME
def get_active_session_id(app_dir: Path | None = None) -> str | None:
session_file = get_session_file(app_dir)
if session_file.exists():
try:
data = json.loads(session_file.read_text())
sid = data.get("session_id")
if sid and isinstance(sid, str):
return sid.strip()
except Exception:
pass
return None
def reset_session(app_dir: Path | None = None) -> tuple[bool, str]:
session_file = get_session_file(app_dir)
deleted = False
if session_file.exists():
try:
session_file.unlink()
deleted = True
except Exception as e:
return False, f"Could not remove session file: {e}"
msg = "Session reset successfully. A fresh Hermes session will start on the next turn."
if not deleted:
msg = "No active session file found. Next turn will start with a fresh session."
try:
import web_server
web_server.broadcast_event("session_reset", {"message": msg})
except Exception:
pass
return True, msg
def main():
app_dir = project_root
if len(sys.argv) < 2 or sys.argv[1] in ("get", "info", "current", "show"):
sid = get_active_session_id(app_dir)
if sid:
print(f"Active Session ID: {sid}")
else:
print("No active Hermes session (a new session will start on the next turn).")
return
action = sys.argv[1].lower()
if action in ("reset", "new", "clear"):
ok, msg = reset_session(app_dir)
print(msg)
else:
print(f"Unknown action: {sys.argv[1]}. Usage: python bin/session_tool.py [get|reset]")
sys.exit(1)
if __name__ == "__main__":
main()
+22 -3
View File
@@ -82,6 +82,9 @@ say them:
- Use your available tools (listing directories, searching, reading files, shell execution) whenever the user asks about files, commands, CLI tools (such as Paseo), or workspace tasks. - Use your available tools (listing directories, searching, reading files, shell execution) whenever the user asks about files, commands, CLI tools (such as Paseo), or workspace tasks.
- You can change your own voice! If the user asks to list available voices or switch voice, run `python bin/voice_tool.py list` or `python bin/voice_tool.py set <voice_name>` (voices: af_heart, af_bella, am_michael, am_fenrir, am_puck, bf_emma, bm_george, Moira, Daniel). - You can change your own voice! If the user asks to list available voices or switch voice, run `python bin/voice_tool.py list` or `python bin/voice_tool.py set <voice_name>` (voices: af_heart, af_bella, am_michael, am_fenrir, am_puck, bf_emma, bm_george, Moira, Daniel).
- You can change your AI model on the fly! If the user asks to list available models or change model, run `python bin/model_tool.py list` or `python bin/model_tool.py set <model_name>` (models: luna, gemma, deepseek, gpt-oss, sonnet, etc.). - You can change your AI model on the fly! If the user asks to list available models or change model, run `python bin/model_tool.py list` or `python bin/model_tool.py set <model_name>` (models: luna, gemma, deepseek, gpt-oss, sonnet, etc.).
- You can reset or start a fresh conversation session! If the user asks to start a fresh session, reset the conversation, or clear session context, run `python bin/session_tool.py reset`.
- You can switch Hermes agent profiles! If the user asks to list Hermes profiles or switch profile, run `python bin/profile_tool.py list` or `python bin/profile_tool.py set <profile_name>`.
- You can change the running microphone and speakers independently. For requests such as “use AirPods”, “switch to Mac speakers”, or “use the Mac default mic”, run `python bin/audio_tool.py list` then `python bin/audio_tool.py set input|output <device-name-or-index|default>`. Report the command result plainly; do not claim a device changed if the tool says it is unavailable.
- You can open files visually for the user in the Companion Web UI drawer! Run `python bin/web_tool.py show <filepath>`. - You can open files visually for the user in the Companion Web UI drawer! Run `python bin/web_tool.py show <filepath>`.
- You can open links or the Companion Web UI in the default browser! Run `python bin/web_tool.py open <url>`. - You can open links or the Companion Web UI in the default browser! Run `python bin/web_tool.py open <url>`.
- Keep implementation details and tool activity silent in the spoken channel. The user can see technical progress in the logs or Companion Web UI; only speak the useful conversational response. - Keep implementation details and tool activity silent in the spoken channel. The user can see technical progress in the logs or Companion Web UI; only speak the useful conversational response.
@@ -571,6 +574,21 @@ async def main() -> int:
logger.info(f"Created {workspace}") logger.info(f"Created {workspace}")
logger.info(f"Workspace: {workspace}") logger.info(f"Workspace: {workspace}")
async def on_audio_device_event(snapshot) -> None:
"""Keep native route changes observable without touching conversation state."""
logger.info(
"Audio device event "
f"generation={snapshot.generation} reason={snapshot.reason.value} "
f"input={snapshot.default_input_uid!r} output={snapshot.default_output_uid!r}"
)
if not getattr(args, "no_web", False):
web_server.broadcast_event("audio_device", {
"generation": snapshot.generation,
"reason": snapshot.reason.value,
"input_uid": snapshot.default_input_uid,
"output_uid": snapshot.default_output_uid,
})
transport = SoundDeviceTransport( transport = SoundDeviceTransport(
SoundDeviceTransportParams( SoundDeviceTransportParams(
audio_in_enabled=True, audio_in_enabled=True,
@@ -579,7 +597,8 @@ async def main() -> int:
audio_out_sample_rate=TTS_SAMPLE_RATE, audio_out_sample_rate=TTS_SAMPLE_RATE,
input_device=as_device(args.input_device), input_device=as_device(args.input_device),
output_device=as_device(args.output_device), output_device=as_device(args.output_device),
) ),
device_event_sink=on_audio_device_event,
) )
brain = None brain = None
@@ -603,7 +622,7 @@ async def main() -> int:
model_manager = ModelManager(workspace) model_manager = ModelManager(workspace)
if not getattr(args, "no_web", False): if not getattr(args, "no_web", False):
await web_server.start_server(workspace, port=getattr(args, "web_port", 8888)) await web_server.start_server(workspace, port=getattr(args, "web_port", 8888))
web_server.set_managers(workspace, model_manager, voice_manager) web_server.set_managers(workspace, model_manager, voice_manager, audio_controller=transport)
llm = build_llm( llm = build_llm(
args, args,
@@ -674,7 +693,7 @@ async def main() -> int:
await worker.queue_frames(frames) await worker.queue_frames(frames)
if not getattr(args, "no_web", False): if not getattr(args, "no_web", False):
web_server.set_managers(workspace, model_manager, voice_manager, input_callback=on_web_input) web_server.set_managers(workspace, model_manager, voice_manager, input_callback=on_web_input, audio_controller=transport)
if args.greeting: if args.greeting:
await worker.queue_frames([TTSSpeakFrame(args.greeting)]) await worker.queue_frames([TTSSpeakFrame(args.greeting)])
+72 -5
View File
@@ -5,17 +5,84 @@ set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$HERE" cd "$HERE"
echo "=== 1. Building Swift Helper & Launcher Binaries ===" FORCE=0
bash swift/build.sh for arg in "$@"; do
if [ "$arg" = "-f" ] || [ "$arg" = "--force" ]; then
FORCE=1
fi
done
APP_NAME="VoiceAgent" APP_NAME="VoiceAgent"
DIST_DIR="$HERE/dist" DIST_DIR="$HERE/dist"
APP_BUNDLE="$DIST_DIR/$APP_NAME.app" APP_BUNDLE="$DIST_DIR/$APP_NAME.app"
INSTALLED_APP="/Applications/VoiceAgent.app"
CONTENTS_DIR="$APP_BUNDLE/Contents" CONTENTS_DIR="$APP_BUNDLE/Contents"
MACOS_DIR="$CONTENTS_DIR/MacOS" MACOS_DIR="$CONTENTS_DIR/MacOS"
RESOURCES_DIR="$CONTENTS_DIR/Resources" RESOURCES_DIR="$CONTENTS_DIR/Resources"
SRC_DIR="$RESOURCES_DIR/src" SRC_DIR="$RESOURCES_DIR/src"
ENV_FILE="$HOME/.voiceagent.env"
echo "=== 0. Updating Local Environment Config ($ENV_FILE) ==="
mkdir -p "$(dirname "$ENV_FILE")"
touch "$ENV_FILE"
# Helper to update or append key=val in .env file
set_env_var() {
local key="$1"
local val="$2"
if grep -q "^${key}=" "$ENV_FILE" 2>/dev/null; then
# Replace existing line using python helper for clean string replacement
python3 -c "
import sys, re
path, k, v = sys.argv[1], sys.argv[2], sys.argv[3]
with open(path, 'r') as f: content = f.read()
new_content = re.sub(r'^' + re.escape(k) + r'=.*$', f'{k}={v}', content, flags=re.MULTILINE)
with open(path, 'w') as f: f.write(new_content)
" "$ENV_FILE" "$key" "$val"
else
echo "${key}=${val}" >> "$ENV_FILE"
fi
}
set_env_var "VOICEAGENT_DIR" "$HERE"
set_env_var "VOICEAGENT_PYTHON" "$HERE/.venv/bin/python"
echo "Configured VOICEAGENT_DIR=$HERE in $ENV_FILE"
echo "=== 1. Building Swift Helper Binaries ==="
bash swift/build.sh "$@"
INSTALLED_LAUNCHER="$INSTALLED_APP/Contents/MacOS/VoiceAgent"
NEED_REBUILD=0
if [ "$FORCE" -eq 1 ] || [ ! -f "$INSTALLED_LAUNCHER" ]; then
NEED_REBUILD=1
elif [ "$HERE/swift/VoiceAgentLauncher.swift" -nt "$INSTALLED_LAUNCHER" ]; then
NEED_REBUILD=1
elif [ "$HERE/swift/SpeechHelper.swift" -nt "$INSTALLED_LAUNCHER" ]; then
NEED_REBUILD=1
fi
if [ "$NEED_REBUILD" -eq 0 ]; then
echo "=== [SKIPPED BINARY REBUILD & CODESIGN] ==="
echo "Native launcher binary is up to date."
echo "Copying updated python resources without modifying code signature..."
mkdir -p "$SRC_DIR"
cp "$HERE"/*.py "$SRC_DIR/" 2>/dev/null || true
cp -R "$HERE/bin" "$SRC_DIR/" 2>/dev/null || true
if [ -d "$INSTALLED_APP/Contents/Resources/src" ]; then
cp "$HERE"/*.py "$INSTALLED_APP/Contents/Resources/src/" 2>/dev/null || true
cp -R "$HERE/bin" "$INSTALLED_APP/Contents/Resources/src/" 2>/dev/null || true
fi
echo "=========================================================="
echo "Python changes deployed cleanly! App binary signature unchanged."
echo "macOS permissions preserved for: $INSTALLED_APP"
echo "=========================================================="
exit 0
fi
echo "=== 2. Creating macOS App Bundle Structure ===" echo "=== 2. Creating macOS App Bundle Structure ==="
rm -rf "$APP_BUNDLE" rm -rf "$APP_BUNDLE"
mkdir -p "$MACOS_DIR" mkdir -p "$MACOS_DIR"
@@ -77,12 +144,12 @@ echo "=== 6. Code-signing App Bundle ==="
codesign -s - --deep --force "$APP_BUNDLE" codesign -s - --deep --force "$APP_BUNDLE"
echo "=== 7. Installing to /Applications ===" echo "=== 7. Installing to /Applications ==="
rm -rf /Applications/VoiceAgent.app rm -rf "$INSTALLED_APP"
cp -R "$APP_BUNDLE" /Applications/ cp -R "$APP_BUNDLE" /Applications/
codesign -s - --deep --force /Applications/VoiceAgent.app codesign -s - --deep --force "$INSTALLED_APP"
echo "==========================================================" echo "=========================================================="
echo "Successfully built and installed VoiceAgent.app to:" echo "Successfully built and installed VoiceAgent.app to:"
echo "1. /Applications/VoiceAgent.app" echo "1. $INSTALLED_APP"
echo "2. $APP_BUNDLE" echo "2. $APP_BUNDLE"
echo "==========================================================" echo "=========================================================="
+48 -6
View File
@@ -95,16 +95,24 @@ def find_hermes_cli() -> str | None:
return shutil.which("hermes") return shutil.which("hermes")
async def ensure_hermes_server(port: int = 8642) -> tuple[bool, str]: async def check_hermes_server_active(port: int = 8642) -> tuple[bool, str]:
"""Ensure Hermes gateway server daemon is available on port.""" """Check if Hermes gateway server daemon is responding to health requests."""
url = f"http://localhost:{port}/api/health" url = f"http://localhost:{port}/api/health"
try: try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=2.0)) as session: async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=1.5)) as session:
async with session.get(url) as resp: async with session.get(url) as resp:
if resp.status == 200: if resp.status == 200:
return True, f"Hermes server active on http://localhost:{port}" return True, f"Hermes server active on http://localhost:{port}"
except Exception: except Exception:
pass pass
return False, "Hermes server daemon not active"
async def ensure_hermes_server(port: int = 8642) -> tuple[bool, str]:
"""Ensure Hermes gateway server daemon or CLI binary is available."""
active, msg = await check_hermes_server_active(port)
if active:
return True, msg
cli = find_hermes_cli() cli = find_hermes_cli()
if not cli: if not cli:
@@ -146,6 +154,7 @@ class HermesLLM(FrameProcessor):
self._cli_path = find_hermes_cli() or "hermes" self._cli_path = find_hermes_cli() or "hermes"
self._use_server = use_server self._use_server = use_server
self._session_renamed = False self._session_renamed = False
self._http_session: aiohttp.ClientSession | None = None
# Keep the conversation lineage with the workspace. A single global # Keep the conversation lineage with the workspace. A single global
# session file can make two voice-agent workspaces resume each other's # session file can make two voice-agent workspaces resume each other's
@@ -157,6 +166,37 @@ class HermesLLM(FrameProcessor):
if self._session_id: if self._session_id:
logger.info(f"Loaded existing Hermes session ID: {self._session_id}") logger.info(f"Loaded existing Hermes session ID: {self._session_id}")
def reset_session(self):
"""Reset active session so a fresh session starts on the next turn."""
logger.info("Resetting active Hermes session state...")
self._session_id = None
self._session_renamed = False
self._history.clear()
try:
if self._session_state_file.exists():
self._session_state_file.unlink()
except Exception as e:
logger.warning(f"Could not remove session file during reset: {e}")
def _sync_disk_session(self):
"""Sync in-memory session ID with disk file state prior to each turn."""
disk_sid = self._load_session_id()
if disk_sid != self._session_id:
logger.info(f"Hermes session state updated from disk: {self._session_id} -> {disk_sid}")
self._session_id = disk_sid
self._session_renamed = False
self._history.clear()
async def _get_http_session(self) -> aiohttp.ClientSession:
if self._http_session is None or self._http_session.closed:
self._http_session = aiohttp.ClientSession()
return self._http_session
async def _close_http_session(self):
if self._http_session and not self._http_session.closed:
await self._http_session.close()
self._http_session = None
def _load_session_id(self) -> str | None: def _load_session_id(self) -> str | None:
if self._session_state_file.exists(): if self._session_state_file.exists():
try: try:
@@ -219,6 +259,7 @@ class HermesLLM(FrameProcessor):
logger.info(f"Hermes LLM engine initialized: {reason}") logger.info(f"Hermes LLM engine initialized: {reason}")
elif isinstance(frame, (EndFrame, CancelFrame)): elif isinstance(frame, (EndFrame, CancelFrame)):
await self._cancel_turn() await self._cancel_turn()
await self._close_http_session()
await self.push_frame(frame, direction) await self.push_frame(frame, direction)
elif isinstance(frame, InterruptionFrame): elif isinstance(frame, InterruptionFrame):
await self._cancel_turn() await self._cancel_turn()
@@ -289,6 +330,7 @@ class HermesLLM(FrameProcessor):
async def _run_turn(self, utterance: str): async def _run_turn(self, utterance: str):
self._sync_disk_model() self._sync_disk_model()
self._sync_disk_session()
self._history.append({"role": "user", "content": utterance}) self._history.append({"role": "user", "content": utterance})
await self.push_frame(LLMFullResponseStartFrame()) await self.push_frame(LLMFullResponseStartFrame())
@@ -313,7 +355,7 @@ class HermesLLM(FrameProcessor):
async def _run_turn_server(self, utterance: str, chunks: list[str]): async def _run_turn_server(self, utterance: str, chunks: list[str]):
"""Run turn via Hermes Server / Gateway HTTP API if available.""" """Run turn via Hermes Server / Gateway HTTP API if available."""
try: try:
ok, _ = await ensure_hermes_server(self._port) ok, _ = await check_hermes_server_active(self._port)
if not ok: if not ok:
raise RuntimeError("Hermes server daemon unavailable") raise RuntimeError("Hermes server daemon unavailable")
@@ -325,8 +367,8 @@ class HermesLLM(FrameProcessor):
if self._model and self._model.lower() not in ("default", "none", ""): if self._model and self._model.lower() not in ("default", "none", ""):
payload["model"] = self._model payload["model"] = self._model
async with aiohttp.ClientSession() as session: session = await self._get_http_session()
async with session.post(url, json=payload) as resp: async with session.post(url, json=payload) as resp:
if resp.status == 200: if resp.status == 200:
data = await resp.json() data = await resp.json()
text_val = data.get("reply") or data.get("text") or data.get("content", "") text_val = data.get("reply") or data.get("text") or data.get("content", "")
+302 -36
View File
@@ -8,6 +8,8 @@ identical to the upstream transport.
""" """
import asyncio import asyncio
import inspect
import sys
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
import sounddevice as sd import sounddevice as sd
@@ -18,6 +20,7 @@ from pipecat.processors.frame_processor import FrameProcessor
from pipecat.transports.base_input import BaseInputTransport from pipecat.transports.base_input import BaseInputTransport
from pipecat.transports.base_output import BaseOutputTransport from pipecat.transports.base_output import BaseOutputTransport
from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.base_transport import BaseTransport, TransportParams
from audio_device_monitor import AudioDeviceSnapshot, create_macos_audio_monitor
class SoundDeviceTransportParams(TransportParams): class SoundDeviceTransportParams(TransportParams):
@@ -36,11 +39,14 @@ class SoundDeviceInputTransport(BaseInputTransport):
"""Captures microphone audio and pushes it into the pipeline.""" """Captures microphone audio and pushes it into the pipeline."""
_params: SoundDeviceTransportParams _params: SoundDeviceTransportParams
_transport: "SoundDeviceTransport"
def __init__(self, params: SoundDeviceTransportParams): def __init__(self, params: SoundDeviceTransportParams):
super().__init__(params) super().__init__(params)
self._in_stream: sd.RawInputStream | None = None self._in_stream: sd.RawInputStream | None = None
self._sample_rate = 0 self._sample_rate = 0
self._stream_generation = 0
self._stream_lock = asyncio.Lock()
async def start(self, frame: StartFrame): async def start(self, frame: StartFrame):
await super().start(frame) await super().start(frame)
@@ -51,29 +57,74 @@ class SoundDeviceInputTransport(BaseInputTransport):
self._sample_rate = self._params.audio_in_sample_rate or frame.audio_in_sample_rate self._sample_rate = self._params.audio_in_sample_rate or frame.audio_in_sample_rate
blocksize = int(self._sample_rate / 100) * 2 # 20ms blocksize = int(self._sample_rate / 100) * 2 # 20ms
self._in_stream = sd.RawInputStream( await self._open_stream()
samplerate=self._sample_rate,
blocksize=blocksize,
device=self._params.input_device,
channels=self._params.audio_in_channels,
dtype="int16",
callback=self._audio_in_callback,
)
self._in_stream.start()
device_name = sd.query_devices(self._in_stream.device, "input")["name"]
logger.info(f"Microphone: {device_name} @ {self._sample_rate} Hz")
await self.set_transport_ready(frame) await self.set_transport_ready(frame)
if hasattr(self, "_transport"):
await self._transport.start_device_monitor(self)
async def reopen(self, *, device=None):
"""Recreate an unset-device stream so PortAudio resolves the new default."""
if not self._in_stream:
return
async with self._stream_lock:
old_stream = self._in_stream
old_device = old_stream.device
# Fail before disrupting an otherwise healthy conversation whenever
# PortAudio can already tell us that the new default is unavailable.
sd.check_input_settings(
device=self._params.input_device if device is None else device, samplerate=self._sample_rate,
channels=self._params.audio_in_channels, dtype="int16",
)
self._stream_generation += 1 # makes callbacks from the old stream inert
try:
old_stream.stop()
old_stream.close()
self._in_stream = None
await self._open_stream(device=device)
except Exception as exc:
logger.warning(f"Audio input route change failed; restoring prior stream: {type(exc).__name__}")
try:
self._in_stream = None
await self._open_stream(device=old_device)
except Exception as restore_exc:
logger.error(f"Audio input fallback unavailable: {type(restore_exc).__name__}")
raise
async def _open_stream(self, *, device=None):
blocksize = int(self._sample_rate / 100) * 2
self._stream_generation += 1
generation = self._stream_generation
stream = sd.RawInputStream(
samplerate=self._sample_rate, blocksize=blocksize,
device=self._params.input_device if device is None else device,
channels=self._params.audio_in_channels, dtype="int16",
callback=lambda *args: self._audio_in_callback(generation, *args),
)
try:
stream.start()
except Exception:
stream.close()
raise
self._in_stream = stream
device_name = sd.query_devices(self._in_stream.device, "input")["name"]
logger.info(f"Microphone: {device_name} @ {self._sample_rate} Hz")
async def cleanup(self): async def cleanup(self):
await super().cleanup() await super().cleanup()
if self._in_stream: async with self._stream_lock:
self._in_stream.stop() self._stream_generation += 1
self._in_stream.close() if self._in_stream:
self._in_stream = None self._in_stream.stop()
self._in_stream.close()
self._in_stream = None
if hasattr(self, "_transport"):
await self._transport.stop_device_monitor(self)
def _audio_in_callback(self, indata, frame_count, time_info, status): def _audio_in_callback(self, generation, indata, frame_count, time_info, status):
if generation != self._stream_generation:
return
if status: if status:
logger.trace(f"Audio input status: {status}") logger.trace(f"Audio input status: {status}")
@@ -83,7 +134,16 @@ class SoundDeviceInputTransport(BaseInputTransport):
num_channels=self._params.audio_in_channels, num_channels=self._params.audio_in_channels,
) )
asyncio.run_coroutine_threadsafe(self.push_audio_frame(frame), self.get_event_loop()) asyncio.run_coroutine_threadsafe(
self._push_audio_frame_if_current(generation, frame), self.get_event_loop()
)
async def _push_audio_frame_if_current(self, generation, frame):
"""Serialize frame delivery with replacement so a closed route cannot leak audio."""
async with self._stream_lock:
if generation != self._stream_generation:
return
await self.push_audio_frame(frame)
class SoundDeviceOutputTransport(BaseOutputTransport): class SoundDeviceOutputTransport(BaseOutputTransport):
@@ -97,6 +157,7 @@ class SoundDeviceOutputTransport(BaseOutputTransport):
self._sample_rate = 0 self._sample_rate = 0
# Writes are serialized by the pipeline, so one worker is enough. # Writes are serialized by the pipeline, so one worker is enough.
self._executor = ThreadPoolExecutor(max_workers=1) self._executor = ThreadPoolExecutor(max_workers=1)
self._stream_lock = asyncio.Lock()
async def start(self, frame: StartFrame): async def start(self, frame: StartFrame):
await super().start(frame) await super().start(frame)
@@ -105,53 +166,258 @@ class SoundDeviceOutputTransport(BaseOutputTransport):
return return
self._sample_rate = self._params.audio_out_sample_rate or frame.audio_out_sample_rate self._sample_rate = self._params.audio_out_sample_rate or frame.audio_out_sample_rate
await self._open_stream()
self._out_stream = sd.RawOutputStream( await self.set_transport_ready(frame)
if hasattr(self, "_transport"):
await self._transport.start_device_monitor(self)
async def reopen(self, *, device=None):
"""Recreate an unset-device stream so PortAudio resolves the new default."""
if not self._out_stream:
return
async with self._stream_lock:
old_stream = self._out_stream
old_device = old_stream.device
# Keep the current output route intact when the selected default
# cannot satisfy this stream's negotiated format.
sd.check_output_settings(
device=self._params.output_device if device is None else device, samplerate=self._sample_rate,
channels=self._params.audio_out_channels, dtype="int16",
)
try:
old_stream.stop()
old_stream.close()
self._out_stream = None
await self._open_stream(device=device)
except Exception as exc:
logger.warning(f"Audio output route change failed; restoring prior stream: {type(exc).__name__}")
try:
self._out_stream = None
await self._open_stream(device=old_device)
except Exception as restore_exc:
logger.error(f"Audio output fallback unavailable: {type(restore_exc).__name__}")
raise
async def _open_stream(self, *, device=None):
stream = sd.RawOutputStream(
samplerate=self._sample_rate, samplerate=self._sample_rate,
device=self._params.output_device, device=self._params.output_device if device is None else device,
channels=self._params.audio_out_channels, channels=self._params.audio_out_channels, dtype="int16",
dtype="int16",
) )
self._out_stream.start() try:
stream.start()
except Exception:
stream.close()
raise
self._out_stream = stream
device_name = sd.query_devices(self._out_stream.device, "output")["name"] device_name = sd.query_devices(self._out_stream.device, "output")["name"]
logger.info(f"Speaker: {device_name} @ {self._sample_rate} Hz") logger.info(f"Speaker: {device_name} @ {self._sample_rate} Hz")
await self.set_transport_ready(frame)
async def cleanup(self): async def cleanup(self):
await super().cleanup() await super().cleanup()
if self._out_stream: async with self._stream_lock:
self._out_stream.stop() if self._out_stream:
self._out_stream.close() self._out_stream.stop()
self._out_stream = None self._out_stream.close()
self._out_stream = None
if hasattr(self, "_transport"):
await self._transport.stop_device_monitor(self)
async def write_audio_frame(self, frame: OutputAudioRawFrame) -> bool: async def write_audio_frame(self, frame: OutputAudioRawFrame) -> bool:
if not self._out_stream: async with self._stream_lock:
return False if not self._out_stream:
await self.get_event_loop().run_in_executor( return False
self._executor, self._out_stream.write, frame.audio await self.get_event_loop().run_in_executor(
) self._executor, self._out_stream.write, frame.audio
)
return True return True
class SoundDeviceTransport(BaseTransport): class SoundDeviceTransport(BaseTransport):
"""Local microphone + speaker transport.""" """Local microphone + speaker transport."""
def __init__(self, params: SoundDeviceTransportParams): def __init__(self, params: SoundDeviceTransportParams, *, device_monitor=None,
device_event_sink=None):
super().__init__() super().__init__()
self._params = params self._params = params
self._input: SoundDeviceInputTransport | None = None self._input: SoundDeviceInputTransport | None = None
self._output: SoundDeviceOutputTransport | None = None self._output: SoundDeviceOutputTransport | None = None
self._device_monitor = device_monitor
self._device_event_sink = device_event_sink
self._last_snapshot: AudioDeviceSnapshot | None = None
self._restart_lock = asyncio.Lock()
self._monitor_started = False
self._monitor_owners: set[object] = set()
self._monitor_lock = asyncio.Lock()
def _monitor_if_needed(self):
if self._device_monitor is not None:
return self._device_monitor
if sys.platform != "darwin":
return None
if self._params.input_device is None or self._params.output_device is None:
try:
self._device_monitor = create_macos_audio_monitor()
except Exception as exc:
logger.warning(f"Audio default monitoring unavailable: {exc}")
return self._device_monitor
async def start_device_monitor(self, owner=None):
"""Keep the shared monitor running while any transport side is active."""
owner = self if owner is None else owner
async with self._monitor_lock:
self._monitor_owners.add(owner)
if self._monitor_started:
return
monitor = self._monitor_if_needed()
if monitor:
await monitor.start(self._on_device_change)
self._monitor_started = True
async def stop_device_monitor(self, owner=None):
"""Release one transport side; stop only after the final release."""
owner = self if owner is None else owner
async with self._monitor_lock:
self._monitor_owners.discard(owner)
if self._monitor_owners or not self._device_monitor or not self._monitor_started:
return
await self._device_monitor.stop()
self._monitor_started = False
async def _on_device_change(self, snapshot: AudioDeviceSnapshot):
if self._last_snapshot and snapshot.generation <= self._last_snapshot.generation:
logger.debug(f"Ignoring stale audio route event generation={snapshot.generation}")
return
if self._device_event_sink:
try:
result = self._device_event_sink(snapshot)
if inspect.isawaitable(result):
await result
except Exception as exc:
logger.warning(f"Audio device event sink failed: {type(exc).__name__}")
old = self._last_snapshot
self._last_snapshot = snapshot
if old is None:
return
input_changed = old.default_input_uid != snapshot.default_input_uid
output_changed = old.default_output_uid != snapshot.default_output_uid
if not (input_changed or output_changed):
return
async with self._restart_lock:
if input_changed and self._params.input_device is None and self._input:
try:
await self._reopen_default(self._input, snapshot, snapshot.default_input_uid, "input")
except Exception as exc:
logger.warning(f"Audio input route refresh failed: {type(exc).__name__}")
if output_changed and self._params.output_device is None and self._output:
try:
await self._reopen_default(self._output, snapshot, snapshot.default_output_uid, "output")
except Exception as exc:
logger.warning(f"Audio output route refresh failed: {type(exc).__name__}")
async def _reopen_default(self, stream, snapshot: AudioDeviceSnapshot, uid: str | None, direction: str) -> None:
# Empty snapshots are supported for legacy/injected monitors. Native
# snapshots always carry devices and therefore get an explicit index.
if not snapshot.devices:
await stream.reopen()
return
await stream.reopen(device=self._portaudio_device(snapshot, uid, direction))
@staticmethod
def _portaudio_device(snapshot: AudioDeviceSnapshot, uid: str | None, direction: str) -> int:
"""Map Core Audio's current default to an explicit PortAudio index.
``device=None`` in a long-lived sounddevice process retains PortAudio's
startup default. Reopening with the current index is what makes a
default-device event actually move the live stream.
"""
if not uid or uid not in snapshot.devices:
raise RuntimeError(f"No available Core Audio default {direction} device")
native = snapshot.devices[uid]
capability = "max_input_channels" if direction == "input" else "max_output_channels"
matches = [index for index, candidate in enumerate(sd.query_devices())
if candidate["name"] == native.name and candidate[capability] > 0]
if len(matches) != 1:
raise RuntimeError(f"No unique PortAudio {direction} device for {native.name!r}: {matches}")
return matches[0]
@staticmethod
def available_devices() -> list[dict]:
return [
{"id": index, "name": device["name"], "input": bool(device["max_input_channels"]),
"output": bool(device["max_output_channels"])}
for index, device in enumerate(sd.query_devices())
if device["max_input_channels"] or device["max_output_channels"]
]
@classmethod
def _select_device(cls, request: int | str, direction: str) -> int:
capability = "input" if direction == "input" else "output"
devices = cls.available_devices()
if isinstance(request, int) or (isinstance(request, str) and request.isdecimal()):
index = int(request)
if any(device["id"] == index and device[capability] for device in devices):
return index
else:
needle = str(request).casefold().strip()
matches = [device["id"] for device in devices
if device[capability] and needle in device["name"].casefold()]
if len(matches) == 1:
return matches[0]
raise ValueError(f"No unique available {direction} device matches {request!r}")
async def set_runtime_device(self, direction: str, request: int | str | None) -> dict:
"""Pin one route live, or pass ``default``/None to follow macOS again."""
if direction not in {"input", "output"}:
raise ValueError("direction must be input or output")
following_default = request is None or str(request).casefold().strip() in {"default", "mac default", "system default"}
selected = None if following_default else self._select_device(request, direction) # type: ignore[arg-type]
previous = self._params.input_device if direction == "input" else self._params.output_device
async with self._restart_lock:
stream = self._input if direction == "input" else self._output
try:
# Temporarily clear this pin so an all-pinned transport can
# create its native monitor and take a fresh default snapshot.
if following_default:
if direction == "input":
self._params.input_device = None
else:
self._params.output_device = None
await self.start_device_monitor()
if stream:
if following_default:
snapshot = self._last_snapshot
if not snapshot:
raise RuntimeError(f"No macOS default-{direction} snapshot is available yet")
uid = snapshot.default_input_uid if direction == "input" else snapshot.default_output_uid
await self._reopen_default(stream, snapshot, uid, direction)
else:
await stream.reopen(device=selected)
if direction == "input":
self._params.input_device = selected
else:
self._params.output_device = selected
except Exception:
if direction == "input":
self._params.input_device = previous
else:
self._params.output_device = previous
raise
name = "macOS default" if following_default else next(device["name"] for device in self.available_devices() if device["id"] == selected)
logger.info(f"Runtime {direction} device changed to {name}")
return {"direction": direction, "device": selected, "name": name, "following_default": following_default}
def input(self) -> FrameProcessor: def input(self) -> FrameProcessor:
if not self._input: if not self._input:
self._input = SoundDeviceInputTransport(self._params) self._input = SoundDeviceInputTransport(self._params)
self._input._transport = self
return self._input return self._input
def output(self) -> FrameProcessor: def output(self) -> FrameProcessor:
if not self._output: if not self._output:
self._output = SoundDeviceOutputTransport(self._params) self._output = SoundDeviceOutputTransport(self._params)
self._output._transport = self
return self._output return self._output
+42 -4
View File
@@ -4,15 +4,53 @@ import Foundation
struct VoiceAgentLauncher { struct VoiceAgentLauncher {
static func main() { static func main() {
let fileManager = FileManager.default let fileManager = FileManager.default
let projDir = "/Users/adolforeyna/Projects/VoiceAgent1" let homeDir = fileManager.homeDirectoryForCurrentUser.path
// Load configuration from .env files if present
var envConfig = [String: String]()
let envPaths = [
"\(homeDir)/.voiceagent.env",
"\(homeDir)/.config/voiceagent/env",
"\(homeDir)/.env"
]
for envPath in envPaths {
if let content = try? String(contentsOfFile: envPath, encoding: .utf8) {
for line in content.components(separatedBy: .newlines) {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
let parts = trimmed.split(separator: "=", maxSplits: 1).map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
if parts.count == 2 {
let key = parts[0]
var val = parts[1]
if (val.hasPrefix("\"") && val.hasSuffix("\"")) || (val.hasPrefix("'") && val.hasSuffix("'")) {
val = String(val.dropFirst().dropLast())
}
envConfig[key] = val
}
}
}
}
let defaultProjDir = "/Users/adolforeyna/Projects/VoiceAgent1"
let configuredProjDir = ProcessInfo.processInfo.environment["VOICEAGENT_DIR"]
?? envConfig["VOICEAGENT_DIR"]
?? envConfig["WORKSPACE_DIR"]
?? defaultProjDir
let bundleResPath = Bundle.main.resourcePath ?? "" let bundleResPath = Bundle.main.resourcePath ?? ""
let bundledSrcPath = "\(bundleResPath)/src" let bundledSrcPath = "\(bundleResPath)/src"
let workDir = fileManager.fileExists(atPath: projDir) ? projDir : bundledSrcPath let workDir = fileManager.fileExists(atPath: configuredProjDir) ? configuredProjDir : bundledSrcPath
let pythonBin = "\(projDir)/.venv/bin/python"
let defaultPythonBin = "\(workDir)/.venv/bin/python"
let configuredPython = ProcessInfo.processInfo.environment["VOICEAGENT_PYTHON"]
?? envConfig["VOICEAGENT_PYTHON"]
?? envConfig["PYTHON_PATH"]
?? defaultPythonBin
let fallbackPython = "/usr/bin/python3" let fallbackPython = "/usr/bin/python3"
let targetPython = fileManager.fileExists(atPath: pythonBin) ? pythonBin : fallbackPython let targetPython = fileManager.fileExists(atPath: configuredPython) ? configuredPython : fallbackPython
let targetScript = "\(workDir)/app_main.py" let targetScript = "\(workDir)/app_main.py"
setenv("SSL_CERT_FILE", "/etc/ssl/cert.pem", 1) setenv("SSL_CERT_FILE", "/etc/ssl/cert.pem", 1)
+22 -7
View File
@@ -9,8 +9,19 @@ set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$HERE" cd "$HERE"
swiftc -O -parse-as-library SpeechHelper.swift -o speech-helper FORCE=0
echo "built $HERE/speech-helper" for arg in "$@"; do
if [ "$arg" = "-f" ] || [ "$arg" = "--force" ]; then
FORCE=1
fi
done
if [ "$FORCE" -eq 1 ] || [ ! -f speech-helper ] || [ SpeechHelper.swift -nt speech-helper ]; then
swiftc -O -parse-as-library SpeechHelper.swift -o speech-helper
echo "built $HERE/speech-helper"
else
echo "speech-helper is up to date — skipping recompile"
fi
if ./speech-helper --check >/dev/null 2>&1; then if ./speech-helper --check >/dev/null 2>&1; then
echo "speech-helper runs — ./speech-helper --check for details" echo "speech-helper runs — ./speech-helper --check for details"
@@ -24,12 +35,16 @@ else
fi fi
fi fi
if swiftc -O -parse-as-library -target arm64-apple-macosx26.0 LLMHelper.swift -o llm-helper 2>/dev/null; then if [ "$FORCE" -eq 1 ] || [ ! -f llm-helper ] || [ LLMHelper.swift -nt llm-helper ]; then
echo "built $HERE/llm-helper" if swiftc -O -parse-as-library -target arm64-apple-macosx26.0 LLMHelper.swift -o llm-helper 2>/dev/null; then
if ./llm-helper --check >/dev/null 2>&1; then echo "built $HERE/llm-helper"
echo "llm-helper runs — ./llm-helper --check for details" if ./llm-helper --check >/dev/null 2>&1; then
echo "llm-helper runs — ./llm-helper --check for details"
fi
else
echo "could not build llm-helper with FoundationModels; fallback to Python MLX bridge will be available"
fi fi
else else
echo "could not build llm-helper with FoundationModels; fallback to Python MLX bridge will be available" echo "llm-helper is up to date — skipping recompile"
fi fi
+244
View File
@@ -0,0 +1,244 @@
import asyncio
import sys
import threading
import unittest
from audio_device_monitor import AudioDevice, AudioDeviceMonitor, DeviceChangeReason
class FakeBackend:
def __init__(self):
self.devices = {}
self.input = None
self.output = None
self.listeners = []
self.removed = []
def enumerate_devices(self):
return list(self.devices.values())
def default_input_uid(self):
return self.input
def default_output_uid(self):
return self.output
def add_listener(self, callback):
self.listeners.append(callback)
return callback
def remove_listener(self, token):
self.removed.append(token)
self.listeners.remove(token)
def notify(self):
# Emulate Core Audio's arbitrary callback thread.
threads = [threading.Thread(target=callback) for callback in list(self.listeners)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
class AudioDeviceMonitorTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.backend = FakeBackend()
self.mic = AudioDevice("mic", "Microphone", True, False, "built-in")
self.speaker = AudioDevice("speaker", "Speaker", False, True, "built-in")
self.airpods = AudioDevice("airpods", "AirPods", True, True, "bluetooth")
self.backend.devices = {x.uid: x for x in (self.mic, self.speaker)}
self.backend.input, self.backend.output = "mic", "speaker"
async def test_initial_and_independent_default_change(self):
events = []
monitor = AudioDeviceMonitor(self.backend, debounce_seconds=.01)
await monitor.start(events.append)
self.assertEqual(events[-1].default_input_uid, "mic")
self.backend.devices["airpods"] = self.airpods
self.backend.output = "airpods"
self.backend.notify()
await asyncio.sleep(.04)
self.assertEqual(len(events), 2)
self.assertEqual(events[-1].default_output_uid, "airpods")
self.assertEqual(events[-1].default_input_uid, "mic")
self.assertEqual(events[-1].reason, DeviceChangeReason.DEFAULT_CHANGED)
await monitor.stop()
async def test_duplicate_burst_is_coalesced(self):
events = []
monitor = AudioDeviceMonitor(self.backend, debounce_seconds=.03)
await monitor.start(events.append)
for _ in range(10):
self.backend.notify()
await asyncio.sleep(.08)
self.assertEqual(len(events), 1) # no actual state change
await monitor.stop()
async def test_unavailable_default_is_none(self):
events = []
monitor = AudioDeviceMonitor(self.backend, debounce_seconds=.01)
await monitor.start(events.append)
self.backend.input = "gone"
self.backend.notify()
await asyncio.sleep(.04)
self.assertIsNone(events[-1].default_input_uid)
self.assertEqual(events[-1].default_output_uid, "speaker")
await monitor.stop()
async def test_stop_removes_listener_and_blocks_late_callbacks(self):
events = []
monitor = AudioDeviceMonitor(self.backend, debounce_seconds=.01)
await monitor.start(events.append)
await monitor.stop()
self.assertEqual(len(self.backend.removed), 1)
self.backend.input = "gone"
self.backend.notify()
await asyncio.sleep(.04)
self.assertEqual(len(events), 1)
await monitor.stop() # idempotent
@unittest.skipUnless(sys.platform == "darwin", "Core Audio is macOS-only")
def test_native_adapter_uses_coreaudio_uids_not_portaudio_indices(self):
"""Stable Core Audio identity must survive PortAudio index renumbering."""
from audio_device_monitor import NativeMacOSCoreAudioAdapter
adapter = NativeMacOSCoreAudioAdapter()
devices = adapter.enumerate_devices()
self.assertTrue(devices)
self.assertTrue(all(not device.uid.isdecimal() for device in devices))
async def test_connect_disconnect_replacement_and_profile_events_are_classified(self):
events = []
monitor = AudioDeviceMonitor(self.backend, debounce_seconds=.001)
await monitor.start(events.append)
self.backend.devices["airpods"] = self.airpods
self.backend.notify()
await asyncio.sleep(.01)
self.assertEqual(events[-1].reason, DeviceChangeReason.DEVICE_ADDED)
del self.backend.devices["speaker"]
self.backend.output = "airpods"
self.backend.notify()
await asyncio.sleep(.01)
self.assertEqual(events[-1].reason, DeviceChangeReason.DEFAULT_CHANGED)
self.backend.devices["airpods"] = AudioDevice(
"airpods", "AirPods Hands-Free", True, True, "bluetooth"
)
self.backend.notify()
await asyncio.sleep(.01)
self.assertEqual(events[-1].reason, DeviceChangeReason.PROFILE_CHANGED)
del self.backend.devices["airpods"]
self.backend.output = None
self.backend.notify()
await asyncio.sleep(.01)
self.assertEqual(events[-1].reason, DeviceChangeReason.DEFAULT_CHANGED)
self.backend.devices["usb"] = AudioDevice("usb", "USB headset", False, True)
self.backend.notify()
await asyncio.sleep(.01)
del self.backend.devices["usb"]
self.backend.notify()
await asyncio.sleep(.01)
self.assertEqual(events[-1].reason, DeviceChangeReason.DEVICE_REMOVED)
self.assertIsNone(events[-1].default_output_uid)
await monitor.stop()
async def test_backend_callback_is_dispatched_on_monitor_event_loop(self):
callback_threads = []
monitor = AudioDeviceMonitor(self.backend, debounce_seconds=.001)
def record(snapshot):
callback_threads.append((snapshot, threading.get_ident()))
await monitor.start(record)
loop_thread = threading.get_ident()
self.backend.output = "airpods"
self.backend.devices["airpods"] = self.airpods
self.backend.notify()
await asyncio.sleep(.01)
self.assertEqual(callback_threads[-1][1], loop_thread)
await monitor.stop()
async def test_listener_registration_failure_cleans_up_registered_tokens(self):
class FailingBackend(FakeBackend):
def add_listener(self, callback):
raise RuntimeError("listener registration failed")
backend = FailingBackend()
backend.devices = self.backend.devices
backend.input, backend.output = self.backend.input, self.backend.output
monitor = AudioDeviceMonitor(backend)
with self.assertRaises(RuntimeError):
await monitor.start(lambda _snapshot: None)
self.assertFalse(monitor._running)
self.assertEqual(len(backend.listeners), 0)
async def test_repeated_start_is_idempotent_and_does_not_duplicate_listener(self):
first_events = []
second_events = []
monitor = AudioDeviceMonitor(self.backend, debounce_seconds=.001)
await monitor.start(first_events.append)
await monitor.start(second_events.append)
self.assertEqual(len(self.backend.listeners), 1)
self.assertEqual(len(first_events), 1)
self.assertEqual(second_events, [])
await monitor.stop()
async def test_callback_failure_stops_monitor_and_removes_listener(self):
callback_started = asyncio.Event()
async def failing_callback(_snapshot):
callback_started.set()
raise RuntimeError("consumer failed")
monitor = AudioDeviceMonitor(self.backend, debounce_seconds=.001)
with self.assertRaises(RuntimeError):
await monitor.start(failing_callback)
self.assertFalse(monitor._running)
self.assertEqual(len(self.backend.listeners), 0)
self.assertEqual(len(self.backend.removed), 1)
self.assertTrue(callback_started.is_set())
async def test_callback_failure_after_start_cleans_up_listener(self):
callback_started = asyncio.Event()
calls = 0
def failing_after_initial(snapshot):
nonlocal calls
calls += 1
if calls == 2:
callback_started.set()
raise RuntimeError("consumer failed after notification")
monitor = AudioDeviceMonitor(self.backend, debounce_seconds=.001)
await monitor.start(failing_after_initial)
self.backend.output = None
self.backend.notify()
await asyncio.wait_for(callback_started.wait(), timeout=.2)
await asyncio.sleep(.01)
self.assertFalse(monitor._running)
self.assertEqual(len(self.backend.listeners), 0)
self.assertEqual(len(self.backend.removed), 1)
async def test_notifications_after_stop_are_ignored_even_if_callback_was_queued(self):
events = []
monitor = AudioDeviceMonitor(self.backend, debounce_seconds=.05)
await monitor.start(events.append)
self.backend.notify()
await monitor.stop()
await asyncio.sleep(.06)
self.assertEqual(len(events), 1)
self.assertEqual(len(self.backend.listeners), 0)
if __name__ == "__main__":
unittest.main()
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""Tests for bin/profile_tool.py."""
from bin.profile_tool import get_current_profile, list_profiles, set_profile
def test_profile_tool():
# Test profile listing
profiles_text = list_profiles()
assert len(profiles_text) > 0
# Test current profile getter
cur_profile = get_current_profile()
assert isinstance(cur_profile, str)
assert len(cur_profile) > 0
# Test setting valid profile (switch back to current profile to be idempotent)
ok, msg = set_profile(cur_profile)
assert ok
assert cur_profile in msg or "Switched" in msg
print(f"PASS: test_profile_tool verified (active profile: '{cur_profile}')")
if __name__ == "__main__":
test_profile_tool()
print("\nAll profile tool tests passed successfully!")
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Tests for bin/session_tool.py and Hermes session resetting in hermes_llm.py."""
import json
import tempfile
from pathlib import Path
from bin.session_tool import get_active_session_id, reset_session, SESSION_FILE_NAME
from hermes_llm import HermesLLM
def test_session_tool_api():
with tempfile.TemporaryDirectory() as tmpdir:
workspace = Path(tmpdir)
session_file = workspace / SESSION_FILE_NAME
# Initially no session
assert get_active_session_id(workspace) is None
# Write mock session file
session_file.write_text(json.dumps({"session_id": "test_session_123"}) + "\n")
assert get_active_session_id(workspace) == "test_session_123"
# Reset session
ok, msg = reset_session(workspace)
assert ok
assert "reset successfully" in msg.lower() or "no active session" in msg.lower()
assert get_active_session_id(workspace) is None
assert not session_file.exists()
# Double reset safely handles non-existent file
ok, msg = reset_session(workspace)
assert ok
print("PASS: test_session_tool_api verified")
def test_hermes_llm_session_sync():
with tempfile.TemporaryDirectory() as tmpdir:
workspace = Path(tmpdir)
session_file = workspace / SESSION_FILE_NAME
# Write mock session ID
session_file.write_text(json.dumps({"session_id": "test_session_456"}) + "\n")
llm = HermesLLM(cwd=workspace)
assert llm._session_id == "test_session_456"
# External reset via tool
reset_session(workspace)
assert get_active_session_id(workspace) is None
# Sync disk state in HermesLLM
llm._sync_disk_session()
assert llm._session_id is None
# Call reset_session directly on instance
session_file.write_text(json.dumps({"session_id": "test_session_789"}) + "\n")
llm._sync_disk_session()
assert llm._session_id == "test_session_789"
llm.reset_session()
assert llm._session_id is None
assert not session_file.exists()
print("PASS: test_hermes_llm_session_sync verified")
if __name__ == "__main__":
test_session_tool_api()
test_hermes_llm_session_sync()
print("\nAll session tool tests passed successfully!")
+350
View File
@@ -0,0 +1,350 @@
import unittest
from unittest.mock import AsyncMock, patch
from audio_device_monitor import AudioDeviceSnapshot
from pipecat.frames.frames import StartFrame
from sounddevice_transport import (
SoundDeviceInputTransport,
SoundDeviceOutputTransport,
SoundDeviceTransport,
SoundDeviceTransportParams,
)
class FakeMonitor:
def __init__(self):
self.start = AsyncMock()
self.stop = AsyncMock()
class SoundDeviceTransportSwitchTests(unittest.IsolatedAsyncioTestCase):
def _transport(self, *, input_device=None, output_device=None):
transport = SoundDeviceTransport(
SoundDeviceTransportParams(input_device=input_device, output_device=output_device)
)
transport._input = type("Input", (), {"reopen": AsyncMock()})()
transport._output = type("Output", (), {"reopen": AsyncMock()})()
return transport
async def test_default_changes_reopen_only_unset_sides(self):
transport = self._transport(input_device=None, output_device="My Speakers")
await transport._on_device_change(AudioDeviceSnapshot(1, "mic", "speakers"))
await transport._on_device_change(AudioDeviceSnapshot(2, "airpods", "airpods"))
transport._input.reopen.assert_awaited_once_with()
transport._output.reopen.assert_not_awaited()
async def test_default_changes_preserve_each_override_for_all_override_combinations(self):
devices = {
"old-mic": type("Device", (), {"name": "Built-in Mic", "can_input": True, "can_output": False})(),
"old-speaker": type("Device", (), {"name": "Built-in Speaker", "can_input": False, "can_output": True})(),
"new-mic": type("Device", (), {"name": "USB Mic", "can_input": True, "can_output": False})(),
"new-speaker": type("Device", (), {"name": "USB Speaker", "can_input": False, "can_output": True})(),
}
portaudio_devices = [
{"name": "Built-in Mic", "max_input_channels": 1, "max_output_channels": 0},
{"name": "Built-in Speaker", "max_input_channels": 0, "max_output_channels": 2},
{"name": "USB Mic", "max_input_channels": 1, "max_output_channels": 0},
{"name": "USB Speaker", "max_input_channels": 0, "max_output_channels": 2},
]
initial = AudioDeviceSnapshot(1, "old-mic", "old-speaker", devices=devices)
changed = AudioDeviceSnapshot(2, "new-mic", "new-speaker", devices=devices)
with patch("sounddevice_transport.sd.query_devices", return_value=portaudio_devices):
for input_override, output_override in (
(None, None),
("Pinned Mic", None),
(None, "Pinned Speaker"),
("Pinned Mic", "Pinned Speaker"),
):
with self.subTest(input_override=input_override, output_override=output_override):
transport = self._transport(
input_device=input_override,
output_device=output_override,
)
await transport._on_device_change(initial)
await transport._on_device_change(changed)
if input_override is None:
transport._input.reopen.assert_awaited_once_with(device=2)
else:
transport._input.reopen.assert_not_awaited()
if output_override is None:
transport._output.reopen.assert_awaited_once_with(device=3)
else:
transport._output.reopen.assert_not_awaited()
async def test_unavailable_default_does_not_reopen_that_side_but_reopens_other_side(self):
transport = self._transport()
devices = {
"mic": type("Device", (), {"name": "Mic", "can_input": True, "can_output": False})(),
"speaker": type("Device", (), {"name": "Speaker", "can_input": False, "can_output": True})(),
"headphones": type("Device", (), {"name": "Headphones", "can_input": False, "can_output": True})(),
}
initial = AudioDeviceSnapshot(1, "mic", "speaker", devices=devices)
unavailable_input = AudioDeviceSnapshot(2, None, "headphones", devices=devices)
with patch("sounddevice_transport.sd.query_devices", return_value=[
{"name": "Headphones", "max_input_channels": 0, "max_output_channels": 2},
]):
await transport._on_device_change(initial)
await transport._on_device_change(unavailable_input)
transport._input.reopen.assert_not_awaited()
transport._output.reopen.assert_awaited_once_with(device=0)
async def test_input_only_output_only_and_simultaneous_changes_route_independently(self):
transport = self._transport()
await transport._on_device_change(AudioDeviceSnapshot(1, "mic", "speaker"))
await transport._on_device_change(AudioDeviceSnapshot(2, "airpods-mic", "speaker"))
transport._input.reopen.assert_awaited_once_with()
transport._output.reopen.assert_not_awaited()
await transport._on_device_change(AudioDeviceSnapshot(3, "airpods-mic", "airpods-speaker"))
transport._input.reopen.assert_awaited_once_with()
transport._output.reopen.assert_awaited_once_with()
await transport._on_device_change(AudioDeviceSnapshot(4, "mac-mic", "mac-speaker"))
self.assertEqual(transport._input.reopen.await_count, 2)
self.assertEqual(transport._output.reopen.await_count, 2)
async def test_default_change_uses_current_portaudio_device_not_process_startup_default(self):
transport = self._transport()
initial = AudioDeviceSnapshot(1, "built-in-mic", "built-in-speaker")
switched = AudioDeviceSnapshot(2, "airpods-input", "airpods-output", devices={
"airpods-input": type("Device", (), {"name": "AirPods", "can_input": True, "can_output": False})(),
"airpods-output": type("Device", (), {"name": "AirPods", "can_input": False, "can_output": True})(),
})
with patch("sounddevice_transport.sd.query_devices", return_value=[
{"name": "MacBook Air Speakers", "max_input_channels": 0, "max_output_channels": 2},
{"name": "AirPods", "max_input_channels": 1, "max_output_channels": 0},
{"name": "AirPods", "max_input_channels": 0, "max_output_channels": 2},
]):
await transport._on_device_change(initial)
await transport._on_device_change(switched)
transport._input.reopen.assert_awaited_once_with(device=1)
transport._output.reopen.assert_awaited_once_with(device=2)
async def test_input_override_does_not_follow_default_but_output_does(self):
params = SoundDeviceTransportParams(input_device="USB Mic", output_device=None)
transport = SoundDeviceTransport(params)
transport._input = type("Input", (), {"reopen": AsyncMock()})()
transport._output = type("Output", (), {"reopen": AsyncMock()})()
await transport._on_device_change(AudioDeviceSnapshot(1, "mic", "speakers"))
await transport._on_device_change(AudioDeviceSnapshot(2, "airpods", "headphones"))
transport._input.reopen.assert_not_awaited()
transport._output.reopen.assert_awaited_once_with()
async def test_runtime_event_sink_receives_native_device_snapshot(self):
sink = AsyncMock()
transport = SoundDeviceTransport(SoundDeviceTransportParams(), device_event_sink=sink)
snapshot = AudioDeviceSnapshot(1, "mic", "speaker")
await transport._on_device_change(snapshot)
sink.assert_awaited_once_with(snapshot)
async def test_stale_snapshot_cannot_reopen_a_replaced_stream(self):
transport = self._transport()
await transport._on_device_change(AudioDeviceSnapshot(1, "mic", "speaker"))
await transport._on_device_change(AudioDeviceSnapshot(3, "airpods", "airpods"))
await transport._on_device_change(AudioDeviceSnapshot(2, "mic", "speaker"))
transport._input.reopen.assert_awaited_once_with()
transport._output.reopen.assert_awaited_once_with()
async def test_failed_input_reopen_keeps_output_route_change_alive(self):
transport = self._transport()
transport._input.reopen.side_effect = OSError("device unavailable")
await transport._on_device_change(AudioDeviceSnapshot(1, "mic", "speaker"))
await transport._on_device_change(AudioDeviceSnapshot(2, "airpods", "airpods"))
transport._input.reopen.assert_awaited_once_with()
transport._output.reopen.assert_awaited_once_with()
async def test_transport_starts_and_stops_injected_monitor_once(self):
monitor = FakeMonitor()
transport = SoundDeviceTransport(SoundDeviceTransportParams(), device_monitor=monitor)
await transport.start_device_monitor()
await transport.start_device_monitor()
await transport.stop_device_monitor()
await transport.stop_device_monitor()
monitor.start.assert_awaited_once_with(transport._on_device_change)
monitor.stop.assert_awaited_once_with()
async def test_production_transport_installs_macos_monitor_when_defaults_are_unset(self):
monitor = FakeMonitor()
with (
patch("sounddevice_transport.sys.platform", "darwin"),
patch("sounddevice_transport.create_macos_audio_monitor", return_value=monitor) as factory,
):
transport = SoundDeviceTransport(SoundDeviceTransportParams())
await transport.start_device_monitor()
factory.assert_called_once_with()
monitor.start.assert_awaited_once_with(transport._on_device_change)
async def test_production_input_start_installs_and_cleanup_releases_device_monitor(self):
monitor = FakeMonitor()
transport = SoundDeviceTransport(
SoundDeviceTransportParams(), device_monitor=monitor
)
input_transport = transport.input()
class FakeInputStream:
device = 0
def __init__(self, **_kwargs):
pass
def start(self):
pass
def stop(self):
pass
def close(self):
pass
with (
patch("sounddevice_transport.sd.RawInputStream", FakeInputStream),
patch("sounddevice_transport.sd.query_devices", return_value={"name": "Fake Mic"}),
):
await input_transport.start(StartFrame(audio_in_sample_rate=16000))
await input_transport.cleanup()
monitor.start.assert_awaited_once_with(transport._on_device_change)
monitor.stop.assert_awaited_once_with()
async def test_production_input_start_skips_unavailable_monitor_off_macos(self):
transport = SoundDeviceTransport(SoundDeviceTransportParams())
input_transport = transport.input()
class FakeInputStream:
device = 0
def __init__(self, **_kwargs):
pass
def start(self):
pass
def stop(self):
pass
def close(self):
pass
with (
patch("sounddevice_transport.sys.platform", "linux"),
patch("sounddevice_transport.create_macos_audio_monitor") as factory,
patch("sounddevice_transport.sd.RawInputStream", FakeInputStream),
patch("sounddevice_transport.sd.query_devices", return_value={"name": "Fake Mic"}),
):
await input_transport.start(StartFrame(audio_in_sample_rate=16000))
await input_transport.cleanup()
factory.assert_not_called()
async def test_cleaning_one_side_keeps_monitor_until_last_side_stops(self):
monitor = FakeMonitor()
transport = SoundDeviceTransport(SoundDeviceTransportParams(), device_monitor=monitor)
input_transport = transport.input()
output_transport = transport.output()
await transport.start_device_monitor(input_transport)
await transport.start_device_monitor(output_transport)
await input_transport.cleanup()
monitor.stop.assert_not_awaited()
await output_transport.cleanup()
monitor.start.assert_awaited_once_with(transport._on_device_change)
monitor.stop.assert_awaited_once_with()
async def test_failed_runtime_selection_keeps_previous_pin(self):
transport = self._transport(output_device=5)
transport._output.reopen.side_effect = OSError("unavailable")
with patch("sounddevice_transport.sd.query_devices", return_value=[
{"name": "MacBook Air Speakers", "max_input_channels": 0, "max_output_channels": 2},
{"name": "AirPods", "max_input_channels": 0, "max_output_channels": 2},
]):
with self.assertRaises(OSError):
await transport.set_runtime_device("output", "airpods")
self.assertEqual(transport._params.output_device, 5)
async def test_runtime_selection_pins_only_requested_direction(self):
transport = self._transport()
with patch("sounddevice_transport.sd.query_devices", return_value=[
{"name": "MacBook Air Speakers", "max_input_channels": 0, "max_output_channels": 2},
{"name": "AirPods", "max_input_channels": 1, "max_output_channels": 0},
{"name": "AirPods", "max_input_channels": 0, "max_output_channels": 2},
]):
result = await transport.set_runtime_device("output", "airpods")
self.assertEqual(result["device"], 2)
self.assertEqual(transport._params.output_device, 2)
self.assertIsNone(transport._params.input_device)
transport._output.reopen.assert_awaited_once_with(device=2)
transport._input.reopen.assert_not_awaited()
async def test_switching_a_pinned_side_to_default_starts_monitor_for_snapshot(self):
monitor = FakeMonitor()
async def publish_initial_snapshot(callback):
await callback(AudioDeviceSnapshot(1, "mic", "speaker"))
monitor.start.side_effect = publish_initial_snapshot
transport = SoundDeviceTransport(
SoundDeviceTransportParams(input_device="USB Mic", output_device="USB Speakers"),
device_monitor=monitor,
)
transport._input = type("Input", (), {"reopen": AsyncMock()})()
await transport.set_runtime_device("input", None)
monitor.start.assert_awaited_once_with(transport._on_device_change)
transport._input.reopen.assert_awaited_once_with()
async def test_old_input_callback_cannot_deliver_after_replacement(self):
input_transport = SoundDeviceInputTransport(SoundDeviceTransportParams())
input_transport.push_audio_frame = AsyncMock()
input_transport._stream_generation = 2
await input_transport._push_audio_frame_if_current(1, object())
input_transport.push_audio_frame.assert_not_awaited()
async def test_failed_output_stream_start_closes_partial_stream(self):
class FailingStream:
closed = False
def __init__(self, **_kwargs):
pass
def start(self):
raise OSError("unavailable")
def close(self):
self.closed = True
output = SoundDeviceOutputTransport(SoundDeviceTransportParams())
output._sample_rate = 24000
with patch("sounddevice_transport.sd.RawOutputStream", FailingStream):
with self.assertRaises(OSError):
await output._open_stream()
self.assertIsNone(output._out_stream)
if __name__ == "__main__":
unittest.main()
+63 -2
View File
@@ -25,18 +25,21 @@ _sse_clients: Set[asyncio.Queue] = set()
_workspace_dir: Path = Path(__file__).parent.resolve() _workspace_dir: Path = Path(__file__).parent.resolve()
_model_manager = None _model_manager = None
_voice_manager = None _voice_manager = None
_audio_controller = None
_input_callback: Callable = None _input_callback: Callable = None
_recent_events: list = [] _recent_events: list = []
_MAX_HISTORY_EVENTS = 200 _MAX_HISTORY_EVENTS = 200
def set_managers(workspace: Path, model_mgr=None, voice_mgr=None, input_callback=None): def set_managers(workspace: Path, model_mgr=None, voice_mgr=None, input_callback=None, audio_controller=None):
global _workspace_dir, _model_manager, _voice_manager, _input_callback global _workspace_dir, _model_manager, _voice_manager, _input_callback, _audio_controller
_workspace_dir = Path(workspace) _workspace_dir = Path(workspace)
_model_manager = model_mgr _model_manager = model_mgr
_voice_manager = voice_mgr _voice_manager = voice_mgr
if input_callback is not None: if input_callback is not None:
_input_callback = input_callback _input_callback = input_callback
if audio_controller is not None:
_audio_controller = audio_controller
def broadcast_event(event_type: str, payload: dict): def broadcast_event(event_type: str, payload: dict):
@@ -185,6 +188,43 @@ async def handle_set_voice(request):
return web.json_response({"error": str(e)}, status=500) return web.json_response({"error": str(e)}, status=500)
async def handle_reset_session(request):
try:
from bin.session_tool import reset_session
ok, msg = reset_session(_workspace_dir)
if ok:
broadcast_event("session_reset", {"message": msg})
return web.json_response({"success": True, "message": msg})
return web.json_response({"error": msg}, status=500)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
async def handle_get_audio_devices(request):
if not _audio_controller:
return web.json_response({"error": "Audio controller not active"}, status=503)
return web.json_response({"devices": _audio_controller.available_devices()})
async def handle_set_audio_device(request):
try:
if not _audio_controller:
return web.json_response({"error": "Audio controller not active"}, status=503)
body = await request.json()
direction, device = body.get("direction"), body.get("device")
if direction not in {"input", "output"}:
return web.json_response({"error": "direction must be input or output"}, status=400)
result = await _audio_controller.set_runtime_device(direction, device)
broadcast_event("audio_device_selected", result)
return web.json_response({"success": True, **result})
except (ValueError, RuntimeError) as exc:
logger.warning(f"Runtime audio-device selection failed: {exc}")
return web.json_response({"error": str(exc)}, status=400)
except Exception as exc:
logger.exception("Runtime audio-device selection failed")
return web.json_response({"error": str(exc)}, status=500)
async def handle_show_file_api(request): async def handle_show_file_api(request):
try: try:
body = await request.json() body = await request.json()
@@ -249,9 +289,12 @@ async def start_server(workspace: Path, host: str = "127.0.0.1", port: int = 888
app.router.add_get("/api/models", handle_get_models) app.router.add_get("/api/models", handle_get_models)
app.router.add_post("/api/model", handle_set_model) app.router.add_post("/api/model", handle_set_model)
app.router.add_post("/api/voice", handle_set_voice) app.router.add_post("/api/voice", handle_set_voice)
app.router.add_get("/api/audio-devices", handle_get_audio_devices)
app.router.add_post("/api/audio-device", handle_set_audio_device)
app.router.add_post("/api/show_file", handle_show_file_api) app.router.add_post("/api/show_file", handle_show_file_api)
app.router.add_post("/api/open_browser", handle_open_browser_api) app.router.add_post("/api/open_browser", handle_open_browser_api)
app.router.add_post("/api/send", handle_send_message_api) app.router.add_post("/api/send", handle_send_message_api)
app.router.add_post("/api/session/reset", handle_reset_session)
runner = web.AppRunner(app) runner = web.AppRunner(app)
await runner.setup() await runner.setup()
@@ -692,6 +735,9 @@ HTML_INDEX = """<!DOCTYPE html>
<div class="pill" id="voicePill" onclick="openVoiceModal()"> <div class="pill" id="voicePill" onclick="openVoiceModal()">
Voice: <strong id="voiceName">Loading...</strong> Voice: <strong id="voiceName">Loading...</strong>
</div> </div>
<div class="pill" id="resetPill" onclick="resetSession()" title="Start a fresh conversation session">
🔄 <strong>Reset Session</strong>
</div>
</div> </div>
</header> </header>
@@ -843,6 +889,21 @@ HTML_INDEX = """<!DOCTYPE html>
.catch(err => console.error('Failed to send message:', err)); .catch(err => console.error('Failed to send message:', err));
} }
async function resetSession() {
if (!confirm('Start a fresh Hermes conversation session?')) return;
try {
const res = await fetch('/api/session/reset', { method: 'POST' });
const data = await res.json();
if (data.success) {
appendToolStep('System', data.message || 'Session reset.');
} else {
alert('Failed to reset session: ' + (data.error || 'Unknown error'));
}
} catch (err) {
alert('Error resetting session: ' + err.message);
}
}
function handleKeyDown(e) { function handleKeyDown(e) {
if (e.key === 'Enter') { if (e.key === 'Enter') {
sendMessage(); sendMessage();