Files
VoiceAgent/bot.py
T

690 lines
26 KiB
Python

#!/usr/bin/env python3
"""Local voice conversation with Claude Code.
Everything but Claude itself runs on this machine: Apple's on-device dictation
model for speech-to-text, Kokoro for text-to-speech, Silero for voice activity
detection. Claude runs through the Claude Agent SDK, which drives the same
`claude` CLI — and the same auth — as a terminal session.
"""
import argparse
import asyncio
import re
import shutil
import sys
from datetime import datetime
from pathlib import Path
from claude_agent_sdk import ClaudeAgentOptions, SandboxSettings
from loguru import logger
import env_setup
import web_server
from brain import Brain
from voice_manager import VoiceManager
from model_manager import ModelManager
from claude_llm import ClaudeCodeLLM
from echo_guard import EchoGuardUserMuteStrategy
from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.frames.frames import (
TTSSpeakFrame,
TranscriptionFrame,
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
)
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import (
LLMContextAggregatorPair,
LLMUserAggregatorParams,
)
from pipecat.processors.audio.vad_processor import VADProcessor
from pipecat.services.kokoro.tts import KokoroTTSService
from pipecat.services.whisper.stt import MLXModel, WhisperSTTService, WhisperSTTServiceMLX
from pipecat.transcriptions.language import Language
from pipecat.turns.user_start.external_user_turn_start_strategy import (
ExternalUserTurnStartStrategy,
)
from pipecat.turns.user_stop.external_user_turn_stop_strategy import (
ExternalUserTurnStopStrategy,
)
from pipecat.turns.user_stop.speech_timeout_user_turn_stop_strategy import (
SpeechTimeoutUserTurnStopStrategy,
)
from pipecat.turns.user_turn_strategies import UserTurnStrategies
from pipecat.workers.runner import WorkerRunner
from global_hotkey import HOLD_KEYS
from push_to_talk import PushToTalk
from journal import Journal, recent_prompt
from memory_tools import build_server
from transcript_repair import TranscriptRepair
from vocabulary import Vocabulary
from spoken_text import SpokenTextFilter
from sounddevice_transport import SoundDeviceTransport, SoundDeviceTransportParams, list_devices
# Every recognizer here expects 16 kHz; Kokoro synthesizes at 24 kHz. Matching
# both natively avoids a resample on the hot path in each direction.
STT_SAMPLE_RATE = 16000
TTS_SAMPLE_RATE = 24000
VOICE_STYLE = """
You are talking to the user out loud, over a microphone and speakers. Your
replies are spoken by a text-to-speech voice, so write them the way you would
say them:
- No markdown, bullet points, code blocks, emoji, or URLs. None of it survives
being read aloud.
- Keep answers to a few sentences unless asked to go deeper. This is a
conversation, not a document.
- Spell out things that only make sense visually. Say "line forty-two of
bot dot py" rather than pasting a path.
- 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 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 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>`.
- Speak your intent out loud BEFORE calling tools! Give brief guidance on what you are attempting (e.g. "Switching your voice to af_heart now...", "Checking Paseo CLI agents...", "Inspecting bot dot py..."). Speak a short guiding sentence first, then run your tools.
- Complete multi-step tool calls fully before speaking your final response summary.
- Be strictly truthful about your findings and never invent fake file contents.
- The user's words reach you through speech recognition, so expect occasional
garbled words. Ask rather than guess when it matters.
""".strip()
# Enforcement is by denial, not by allow-list. Measured: with
# permission_mode="bypassPermissions", passing allowed_tools does NOT restrict
# anything — Claude ran Bash while it was absent from that list, with no denial
# recorded. Only disallowed_tools blocks.
#
# The shell is on by default because the point of this assistant is asking about
# work out loud, and most of that lives behind `meta` — experiments, memory,
# tasks, calendar. Without a shell it can only apologise.
#
# Be clear-eyed about what the default denies: a shell can write files perfectly
# well, so withholding Edit and Write is a speed bump against casual edits, not
# a security boundary. --read-only is the real boundary.
FILE_EDIT_TOOLS = ["Write", "Edit", "NotebookEdit"]
SHELL_TOOLS = ["Bash", "BashOutput", "KillShell"]
# Left unset, the CLI picks whatever the interactive session would use, which
# here resolves to a model this account can't reach headlessly ("Access to Fable
# is currently restricted"). Naming one explicitly avoids that, and for a spoken
# conversation time-to-first-token matters more than the extra capability.
DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-6"
# Default directory is now the app folder itself
DEFAULT_APP_DIR = Path(__file__).parent.resolve()
# Personality file path
PERSONALITY_FILE = "AGENTS.md"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--list-devices", action="store_true", help="Print audio devices and exit."
)
parser.add_argument("--input-device", help="Microphone index or name substring.")
parser.add_argument("--output-device", help="Speaker index or name substring.")
parser.add_argument(
"--stt-engine",
choices=["auto", "analyzer", "apple", "mlx", "cpu"],
default="auto",
help=(
"analyzer is macOS 26's SpeechAnalyzer and measured best; apple is the "
"older dictation API; mlx and cpu run Whisper. auto tries them in that "
"order, falling back on whatever is available."
),
)
parser.add_argument(
"--analyzer-module",
choices=["transcriber", "dictation"],
default="transcriber",
help=(
"Which SpeechAnalyzer module: transcriber is macOS 26's new model; "
"dictation is the older one, which honours the vocabulary."
),
)
parser.add_argument(
"--whisper-model",
help="Whisper model, for the mlx and cpu engines. Downloaded on first use.",
)
parser.add_argument(
"--tts",
choices=["kokoro", "apple"],
default="kokoro",
help="kokoro is a local neural voice; apple uses the macOS system voices.",
)
parser.add_argument(
"--voice",
help="Kokoro voice id (default af_heart) or macOS voice name (default Moira).",
)
parser.add_argument(
"--voice-rate", type=int, help="Words per minute, macOS voices only."
)
parser.add_argument(
"--list-voices", action="store_true", help="Print macOS system voices and exit."
)
parser.add_argument(
"--llm-engine",
choices=["hermes", "ollama", "apple", "macos", "claude"],
default="hermes",
help="LLM engine to use: hermes for Hermes models/CLI/Gateway, apple/macos for local MLX, claude for Claude Code.",
)
parser.add_argument(
"--hermes-model",
default="hermes-3",
help="Hermes model (default hermes-3).",
)
parser.add_argument(
"--ollama-host",
help="Ollama API host URL (defaults to OLLAMA_HOST or http://localhost:11434).",
)
parser.add_argument(
"--mlx-model",
default="mlx-community/Qwen2.5-7B-Instruct-4bit",
help="MLX model repo or path for local macOS execution.",
)
parser.add_argument(
"--claude-model",
default=DEFAULT_CLAUDE_MODEL,
help="Claude model. Voice wants fast first tokens more than raw capability.",
)
parser.add_argument(
"--voice-activity",
action="store_true",
help=(
"End turns by detecting silence instead of a keypress. Hands-free, but "
"adds about 0.8s per turn and lets the mic hear the speakers."
),
)
parser.add_argument(
"--hold-key",
default="fn",
choices=[*HOLD_KEYS, "none"],
help=(
"Modifier to hold while speaking, recognised in any app. "
"'none' falls back to a spacebar toggle in this terminal."
),
)
parser.add_argument(
"--ptt-key",
default=" ",
help="Key that toggles the microphone when not using a hold key.",
)
parser.add_argument(
"--cwd",
default=str(DEFAULT_APP_DIR),
help=(
"Working directory for file tools and AGENTS.md."
),
)
parser.add_argument(
"--allow-writes",
action="store_true",
help="Also allow Edit and Write, on top of the shell it already has.",
)
parser.add_argument(
"--read-only",
action="store_true",
help="Deny the shell too, so it can only read and search. Overrides --allow-writes.",
)
parser.add_argument(
"--barge-in",
action="store_true",
help=(
"With --voice-activity, let your voice interrupt Claude mid-sentence. "
"Headphones only: on speakers the mic hears Claude and he interrupts himself."
),
)
parser.add_argument(
"--echo-tail",
type=float,
default=0.4,
help=(
"With --voice-activity, seconds to keep ignoring the mic after Claude "
"stops speaking."
),
)
parser.add_argument(
"--load-settings",
action="store_true",
help=(
"Load your ~/.claude settings, plugins and skills. Off by default because "
"they add seconds to startup and a voice agent needs few of them."
),
)
parser.add_argument(
"--greeting",
default="I'm listening.",
help="Spoken on startup. Pass an empty string to start silent.",
)
parser.add_argument(
"--vocabulary-file",
help="Terms to bias the recognizer towards. Defaults to vocabulary.txt here.",
)
parser.add_argument(
"--no-brain",
action="store_true",
help="Skip the Metamate personal brain: no long-term context, no remembering.",
)
parser.add_argument(
"--no-vocabulary",
action="store_true",
help="Turn off vocabulary biasing and transcript repair.",
)
parser.add_argument(
"--web-port",
type=int,
default=8888,
help="Port for the Companion Web Chat UI server (default 8888).",
)
parser.add_argument(
"--no-web",
action="store_true",
help="Disable the Companion Web Chat UI server.",
)
parser.add_argument("--log-level", default="INFO")
return parser.parse_args()
def as_device(value: str | None) -> int | str | None:
"""Accept either a device index or a name substring."""
if value is None:
return None
return int(value) if value.isdigit() else value
def build_stt(args: argparse.Namespace, vocabulary):
engine = args.stt_engine
# Preferred. Scored the way the published benchmark scores — spelling digits
# out, so an engine is not punished for writing "400" — SpeechTranscriber
# reaches 9.4% with five repair rules and no vocabulary at all, matching the
# older model's best while needing far less hand-tuning, and beating it raw
# (16.5% against 20.0%). It also needs no transcript stitching on long
# speech. Requires the Swift helper built and approved, so fall back quietly.
if engine in ("auto", "analyzer"):
from speech_analyzer_stt import SpeechAnalyzerSTTService, probe
available, reason = probe()
if available:
model = (
"SpeechTranscriber, the new macOS 26 model"
if args.analyzer_module == "transcriber"
else "DictationTranscriber, the older model"
)
logger.info(f"Speech to text: {model} — {reason}")
return SpeechAnalyzerSTTService(
vocabulary=vocabulary, module=args.analyzer_module
)
if engine == "analyzer":
raise SystemExit(f"SpeechAnalyzer is unavailable: {reason}")
logger.info(f"SpeechAnalyzer unavailable ({reason}); using the dictation model.")
engine = "apple"
if engine in ("auto", "apple"):
from apple_stt import AppleSpeechSTTService, probe
available, reason = probe()
if available:
logger.info(f"Speech to text: Apple dictation model ({reason})")
return AppleSpeechSTTService(vocabulary=vocabulary)
if engine == "apple":
raise SystemExit(f"Apple speech recognition is unavailable: {reason}")
logger.warning(f"Apple speech recognition unavailable ({reason}); falling back to Whisper.")
engine = "cpu"
if engine == "mlx":
model = args.whisper_model or MLXModel.LARGE_V3_TURBO_Q4.value
logger.info(f"Speech to text: MLX Whisper ({model})")
return WhisperSTTServiceMLX(
settings=WhisperSTTServiceMLX.Settings(model=model, language=Language.EN)
)
# base.en transcribed the benchmark phrase as accurately as distil-medium.en
# while taking 0.3s instead of 2.2s, which matters a lot in a conversation.
model = args.whisper_model or "base.en"
logger.info(f"Speech to text: faster-whisper on CPU ({model})")
return WhisperSTTService(settings=WhisperSTTService.Settings(model=model, language=Language.EN))
def build_tts(args: argparse.Namespace, voice_manager=None):
if args.tts == "apple":
from apple_tts import AppleTTSService, find_voice
voice = args.voice or "Moira"
found = find_voice(voice)
logger.info(f"Text to speech: macOS voice {voice} ({found[1] if found else '?'})")
return AppleTTSService(
voice=voice, rate_wpm=args.voice_rate, text_filters=[SpokenTextFilter()]
)
if args.voice_rate:
logger.warning("--voice-rate only applies to --tts apple; ignoring it.")
voice = (voice_manager.load_saved_voice() if voice_manager else None) or args.voice or "af_heart"
logger.info(f"Text to speech: Kokoro {voice}")
tts = KokoroTTSService(
settings=KokoroTTSService.Settings(voice=voice, language=Language.EN),
text_filters=[SpokenTextFilter(voice_manager=voice_manager)],
)
if voice_manager:
voice_manager.set_tts_processor(tts)
return tts
def build_turn_taking(args: argparse.Namespace):
"""Decide what opens and closes a turn.
Returns the processor that sits right after the transport, the turn
strategies for the aggregator, and any mute strategies.
"""
if args.voice_activity:
logger.info("Turn taking: voice activity detection")
return (
VADProcessor(vad_analyzer=SileroVADAnalyzer()),
# The default stop strategy loads a separate smart-turn model; plain
# VAD silence detection is enough and keeps startup fast.
UserTurnStrategies(stop=[SpeechTimeoutUserTurnStopStrategy()]),
# Without a keypress gating the mic, the speakers feed straight back
# into it and Claude answers himself.
[] if args.barge_in else [EchoGuardUserMuteStrategy(tail_secs=args.echo_tail)],
)
# Push-to-talk drives both ends of the turn itself, so no VAD, no smart-turn
# model, and no echo guard — audio outside a keypress is never transcribed.
return (
PushToTalk(
hold_key=None if args.hold_key == "none" else args.hold_key,
toggle_key=args.ptt_key,
),
UserTurnStrategies(
start=[ExternalUserTurnStartStrategy()],
stop=[ExternalUserTurnStopStrategy()],
),
[],
)
def build_vocabulary(args: argparse.Namespace, brain=None) -> Vocabulary | None:
if args.no_vocabulary:
logger.info("Vocabulary biasing disabled.")
return None
# Vocabulary and correction files live in the app folder
app_dir = Path(__file__).parent
vocabulary_file = (
Path(args.vocabulary_file) if args.vocabulary_file else app_dir / "vocabulary.txt"
)
corrections_file = app_dir / "corrections.txt"
for target, template in (
(vocabulary_file, app_dir / "vocabulary.example.txt"),
(corrections_file, app_dir / "corrections.example.txt"),
):
if not target.exists() and template.exists():
target.write_text(template.read_text())
logger.info(f"Created {target} from template")
vocabulary = Vocabulary(
project_dir=Path(args.cwd),
vocabulary_file=vocabulary_file,
corrections_file=corrections_file,
)
if brain and brain.projects:
vocabulary.add_terms(brain.projects)
terms = vocabulary.terms()
logger.info(f"Vocabulary: biasing towards {len(terms)} terms, e.g. {', '.join(terms[:6])}")
return vocabulary
def _denied_tools(args: argparse.Namespace) -> list[str]:
if getattr(args, "read_only", False):
return [*SHELL_TOOLS, *FILE_EDIT_TOOLS]
if args.allow_writes:
return []
return FILE_EDIT_TOOLS
def read_personality(cwd: str | None) -> str:
"""Load AGENTS.md from the working directory, if it's there.
HTML comments are stripped, so the file can carry notes to whoever edits it
without those notes reaching Claude as instructions.
"""
if not cwd:
return ""
path = Path(cwd) / PERSONALITY_FILE
if not path.exists():
return ""
text = re.sub(r"<!--.*?-->", "", path.read_text(), flags=re.DOTALL).strip()
if text:
logger.info(f"Personality: {path} ({len(text)} chars)")
return text
def build_claude_options(
args: argparse.Namespace, vocabulary=None, brain=None, journal_context: str = ""
) -> ClaudeAgentOptions:
cli_path = shutil.which("claude")
if cli_path:
logger.info(f"Claude CLI: {cli_path}")
else:
logger.warning("No `claude` on PATH; falling back to the SDK's bundled CLI.")
style = ""
if personality := read_personality(args.cwd):
style += personality + "\n\n"
style += VOICE_STYLE
if brain and (memory := brain.prompt_block()):
style += "\n\n" + memory
if journal_context:
style += "\n\n" + journal_context
if vocabulary and (terms := vocabulary.prompt_block()):
style += (
"\n\nSpeech recognition mangles unusual words. When a transcript is "
"close to one of these, assume that is what was said and carry on "
"without remarking on it:\n" + terms
)
memory_server = build_server(workspace=Path(args.cwd), brain=brain)
return ClaudeAgentOptions(
cli_path=cli_path,
mcp_servers={"memory": memory_server},
system_prompt={"type": "preset", "preset": "claude_code", "append": style},
disallowed_tools=_denied_tools(args),
permission_mode="bypassPermissions",
cwd=args.cwd,
model=args.claude_model,
setting_sources=(
["user", "project", "local"] if args.load_settings else ["project"]
),
skills="all",
include_partial_messages=True,
sandbox=SandboxSettings(enabled=False),
)
def build_llm(
args: argparse.Namespace,
vocabulary=None,
brain=None,
observer=None,
model_manager=None,
journal_context: str = "",
):
model = (model_manager.load_saved_model() if model_manager else None) or getattr(args, "hermes_model", "hermes-3")
if args.llm_engine in ("hermes", "ollama"):
from hermes_llm import HermesLLM, probe_hermes
available, reason = probe_hermes(model)
logger.info(f"LLM: Hermes ({reason})")
# Hermes handles persona, personality, and memory natively.
return HermesLLM(
model=model,
cwd=args.cwd,
session_name="Voice Agent",
observer=observer,
)
if args.llm_engine in ("apple", "macos"):
from apple_llm import MacOSLLM, probe_apple_llm
available, reason = probe_apple_llm()
logger.info(f"LLM: macOS native model ({reason})")
personality = read_personality(args.cwd) or "You are a helpful macOS voice assistant."
system_prompt = personality + "\n\n" + VOICE_STYLE
if brain and (memory := brain.prompt_block()):
system_prompt += "\n\n" + memory
if journal_context:
system_prompt += "\n\n" + journal_context
return MacOSLLM(model=args.mlx_model, system_prompt=system_prompt, observer=observer)
logger.info("LLM: Claude Code")
return ClaudeCodeLLM(
options=build_claude_options(args, vocabulary, brain, journal_context),
observer=observer,
)
async def main() -> int:
args = parse_args()
if args.list_devices:
print(list_devices())
return 0
if args.list_voices:
from apple_tts import available_voices
for name, language in sorted(available_voices(), key=lambda v: (v[1], v[0])):
print(f" {language:<8} {name}")
return 0
logger.remove()
logger.add(sys.stderr, level=args.log_level)
workspace = Path(args.cwd)
if not workspace.exists():
workspace.mkdir(parents=True)
logger.info(f"Created {workspace}")
logger.info(f"Workspace: {workspace}")
transport = SoundDeviceTransport(
SoundDeviceTransportParams(
audio_in_enabled=True,
audio_out_enabled=True,
audio_in_sample_rate=STT_SAMPLE_RATE,
audio_out_sample_rate=TTS_SAMPLE_RATE,
input_device=as_device(args.input_device),
output_device=as_device(args.output_device),
)
)
brain = None
if not args.no_brain:
brain = Brain()
brain.load()
vocabulary = build_vocabulary(args, brain)
stt = build_stt(args, vocabulary)
journal = Journal(workspace / "journal.jsonl")
journal_context = recent_prompt(workspace / "journal.jsonl")
if journal_context:
logger.info("Journal: loaded the 10 most recent entries")
def on_reply(text: str):
journal.record_reply(text)
if vocabulary:
vocabulary.observe(text)
voice_manager = VoiceManager(workspace)
model_manager = ModelManager(workspace)
if not getattr(args, "no_web", False):
await web_server.start_server(workspace, port=getattr(args, "web_port", 8888))
web_server.set_managers(workspace, model_manager, voice_manager)
llm = build_llm(
args,
vocabulary,
brain,
observer=on_reply,
model_manager=model_manager,
journal_context=journal_context,
)
model_manager.set_llm_processor(llm)
tts = build_tts(args, voice_manager=voice_manager)
# Claude keeps its own history; this context exists so Pipecat can decide
# when a turn has ended.
context = LLMContext()
turn_source, turn_strategies, mute_strategies = build_turn_taking(args)
user_aggregator, assistant_aggregator = LLMContextAggregatorPair(
context,
user_params=LLMUserAggregatorParams(
user_turn_strategies=turn_strategies,
user_mute_strategies=mute_strategies,
),
)
pipeline = Pipeline(
[
transport.input(),
turn_source,
stt,
*([TranscriptRepair(vocabulary)] if vocabulary else []),
journal,
user_aggregator,
llm,
tts,
transport.output(),
assistant_aggregator,
]
)
worker = PipelineWorker(
pipeline,
params=PipelineParams(enable_metrics=True, enable_usage_metrics=True),
# Off, because "idle" is meaningless here. The timer only resets on
# speech frames, so it cannot tell an abandoned session from a user
# who hasn't pressed the key for a while, or from Claude spending two
# minutes inside a subagent — and on firing it cancels the worker *and*
# the runner, killing the conversation mid-answer. Waiting quietly is
# this program's normal state.
idle_timeout_secs=None,
)
async def on_web_input(text: str):
logger.info(f"Web typed input: {text}")
web_server.broadcast_event("heard", {"text": text})
if hasattr(llm, "start_turn_direct"):
llm.start_turn_direct(text)
else:
frames = [
UserStartedSpeakingFrame(),
TranscriptionFrame(
text=text,
user_id="user",
timestamp=datetime.now().isoformat(timespec="seconds")
),
UserStoppedSpeakingFrame(),
]
await worker.queue_frames(frames)
if not getattr(args, "no_web", False):
web_server.set_managers(workspace, model_manager, voice_manager, input_callback=on_web_input)
if args.greeting:
await worker.queue_frames([TTSSpeakFrame(args.greeting)])
runner = WorkerRunner()
await runner.add_workers(worker)
await runner.run()
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))