659 lines
26 KiB
Python
659 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 pathlib import Path
|
|
|
|
from claude_agent_sdk import ClaudeAgentOptions, SandboxSettings
|
|
from loguru import logger
|
|
|
|
from brain import Brain
|
|
from voice_manager import VoiceManager
|
|
from claude_llm import ClaudeCodeLLM
|
|
from echo_guard import EchoGuardUserMuteStrategy
|
|
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
|
from pipecat.frames.frames import TTSSpeakFrame
|
|
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
|
|
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) whenever the user asks about files 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).
|
|
- Complete multi-step tool calls fully before speaking your response. Do not stop halfway to ask if you should continue.
|
|
- 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"
|
|
|
|
# Where Claude works, and where the personality file lives. Deliberately not
|
|
# this repo: the assistant is for everyday use, not for editing itself.
|
|
DEFAULT_WORKSPACE = Path.home() / "Workspace"
|
|
|
|
# The CLI reads CLAUDE.md from the working directory on its own but ignores
|
|
# AGENTS.md, so that one is loaded here and appended to the system prompt.
|
|
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=["opencode", "ollama", "apple", "macos", "claude"],
|
|
default="opencode",
|
|
help="LLM engine to use: opencode/ollama for OpenCode Cloud models (gemma4:31b), apple/macos for local MLX, claude for Claude Code.",
|
|
)
|
|
parser.add_argument(
|
|
"--opencode-model",
|
|
default="ollama-cloud/gemma4:31b",
|
|
help="OpenCode Cloud model (default ollama-cloud/gemma4:31b).",
|
|
)
|
|
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_WORKSPACE),
|
|
help=(
|
|
"Where Claude's tools point, and where AGENTS.md is read from. "
|
|
"Created if it doesn't exist."
|
|
),
|
|
)
|
|
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("--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
|
|
|
|
# These live in the workspace, not here: they are learned state that grows
|
|
# with use, and keeping them beside AGENTS.md means git tracks how they
|
|
# change. This repo only carries the starting templates.
|
|
here = Path(__file__).parent
|
|
workspace = Path(args.cwd)
|
|
vocabulary_file = (
|
|
Path(args.vocabulary_file) if args.vocabulary_file else workspace / "vocabulary.txt"
|
|
)
|
|
corrections_file = workspace / "corrections.txt"
|
|
|
|
for target, template in (
|
|
(vocabulary_file, here / "vocabulary.example.txt"),
|
|
(corrections_file, here / "corrections.example.txt"),
|
|
(workspace / PERSONALITY_FILE, here / "AGENTS.md.example"),
|
|
):
|
|
if not target.exists() and template.exists():
|
|
target.write_text(template.read_text())
|
|
logger.info(f"Created {target} from the template")
|
|
|
|
# Ensure bin/voice_tool.py is available in the workspace for OpenCode and Claude
|
|
ws_bin = workspace / "bin"
|
|
ws_bin.mkdir(exist_ok=True)
|
|
voice_script = ws_bin / "voice_tool.py"
|
|
source_script = here / "bin" / "voice_tool.py"
|
|
if source_script.exists() and not voice_script.exists():
|
|
try:
|
|
voice_script.symlink_to(source_script.resolve())
|
|
logger.info(f"Created symlink {voice_script} -> {source_script}")
|
|
except Exception:
|
|
try:
|
|
voice_script.write_text(source_script.read_text())
|
|
logger.info(f"Copied {source_script} -> {voice_script}")
|
|
except Exception as e:
|
|
logger.warning(f"Could not install voice_tool.py into {ws_bin}: {e}")
|
|
|
|
vocabulary = Vocabulary(
|
|
project_dir=workspace,
|
|
vocabulary_file=vocabulary_file,
|
|
corrections_file=corrections_file,
|
|
)
|
|
if brain and brain.projects:
|
|
# "CIP-Unified-Cooldown", "pSMSL-RT-Cache" — the words most likely to be
|
|
# spoken and least likely to be recognised.
|
|
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) -> ClaudeAgentOptions:
|
|
# The SDK bundles its own stock Claude Code binary and prefers it over the
|
|
# one on PATH. That build knows nothing about this org's gateway or its
|
|
# managed apiKeyHelper, so every turn fails with "Invalid API key" unless we
|
|
# point it back at the installed CLI.
|
|
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.")
|
|
|
|
# Naming the domain vocabulary lets Claude resolve a garbled transcript
|
|
# while answering it. That recovers most of what a separate correction pass
|
|
# would, at no latency cost, because he already reads every transcript.
|
|
# Personality first, then the voice rules, so the constraints of speaking
|
|
# aloud get the last word over anything the personality file asks for.
|
|
style = ""
|
|
if personality := read_personality(args.cwd):
|
|
style += personality + "\n\n"
|
|
style += VOICE_STYLE
|
|
if brain and (memory := brain.prompt_block()):
|
|
# Only the context goes in the prompt. When to write back is described
|
|
# by the memory skill in the workspace, which Claude picks up on its own.
|
|
style += "\n\n" + memory
|
|
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,
|
|
# "project" makes the workspace's own .claude/skills discoverable;
|
|
# without it a skills folder there is silently ignored.
|
|
setting_sources=(
|
|
["user", "project", "local"] if args.load_settings else ["project"]
|
|
),
|
|
skills="all",
|
|
include_partial_messages=True, # Speak as tokens arrive instead of per message.
|
|
# The CLI's own sandbox uses sandbox-exec, which fails when this process
|
|
# is already running inside one.
|
|
sandbox=SandboxSettings(enabled=False),
|
|
)
|
|
|
|
|
|
def build_llm(args: argparse.Namespace, vocabulary=None, brain=None, observer=None):
|
|
if args.llm_engine in ("opencode", "ollama"):
|
|
from opencode_llm import OpenCodeLLM, probe_opencode
|
|
|
|
available, reason = probe_opencode(args.opencode_model)
|
|
logger.info(f"LLM: OpenCode Cloud ({reason})")
|
|
personality = read_personality(args.cwd) or "You are a helpful voice assistant."
|
|
system_prompt = personality + "\n\n" + VOICE_STYLE
|
|
if brain and (memory := brain.prompt_block()):
|
|
system_prompt += "\n\n" + memory
|
|
return OpenCodeLLM(
|
|
model=args.opencode_model,
|
|
cwd=args.cwd,
|
|
system_prompt=system_prompt,
|
|
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
|
|
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),
|
|
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")
|
|
|
|
def on_reply(text: str):
|
|
journal.record_reply(text)
|
|
if vocabulary:
|
|
vocabulary.observe(text)
|
|
|
|
voice_manager = VoiceManager(workspace)
|
|
llm = build_llm(args, vocabulary, brain, observer=on_reply)
|
|
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,
|
|
)
|
|
|
|
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()))
|