From 351b87beb8c69ad9bc2574613b2512fe5e46fad7 Mon Sep 17 00:00:00 2001 From: Adolfo Reyna Date: Fri, 7 Aug 2026 20:16:35 -0400 Subject: [PATCH] Add voice management tool bin/voice_tool.py and dynamic voice switching system --- bin/voice_tool.py | 42 +++++++++++++++++ bot.py | 15 ++++-- voice_manager.py | 116 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 5 deletions(-) create mode 100755 bin/voice_tool.py create mode 100644 voice_manager.py diff --git a/bin/voice_tool.py b/bin/voice_tool.py new file mode 100755 index 0000000..0335249 --- /dev/null +++ b/bin/voice_tool.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""CLI helper to list and set voices for OpenCode and Claude tools.""" + +import sys +from pathlib import Path + +# Add project root to sys.path +here = Path(__file__).parent.parent +if str(here) not in sys.path: + sys.path.insert(0, str(here)) + +from voice_manager import KOKORO_VOICES, MACOS_VOICES, VoiceManager + +def main(): + workspace = Path.home() / "Workspace" + vm = VoiceManager(workspace) + + if len(sys.argv) < 2 or sys.argv[1] in ("list", "ls", "--list"): + print("Available Voices:\n") + print("Kokoro Voices (On-Device Neural Speech):") + for k, v in KOKORO_VOICES.items(): + active = " (ACTIVE)" if k == vm._active_voice else "" + print(f" - {k}: {v}{active}") + print("\nmacOS System Voices:") + for k, v in MACOS_VOICES.items(): + active = " (ACTIVE)" if k == vm._active_voice else "" + print(f" - {k}: {v}{active}") + return + + action = sys.argv[1] + if action in ("set", "change") and len(sys.argv) >= 3: + target_voice = sys.argv[2] + ok, msg = vm.apply_voice(target_voice) + print(msg) + else: + # Treat single argument as target voice + target_voice = sys.argv[1] + ok, msg = vm.apply_voice(target_voice) + print(msg) + +if __name__ == "__main__": + main() diff --git a/bot.py b/bot.py index 6c7d82e..2f384ae 100644 --- a/bot.py +++ b/bot.py @@ -18,6 +18,7 @@ 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 @@ -70,6 +71,7 @@ say them: - 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 ` (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 @@ -326,7 +328,7 @@ def build_stt(args: argparse.Namespace, vocabulary): return WhisperSTTService(settings=WhisperSTTService.Settings(model=model, language=Language.EN)) -def build_tts(args: argparse.Namespace): +def build_tts(args: argparse.Namespace, voice_manager=None): if args.tts == "apple": from apple_tts import AppleTTSService, find_voice @@ -339,12 +341,15 @@ def build_tts(args: argparse.Namespace): if args.voice_rate: logger.warning("--voice-rate only applies to --tts apple; ignoring it.") - voice = args.voice or "af_heart" + 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}") - return KokoroTTSService( + tts = KokoroTTSService( settings=KokoroTTSService.Settings(voice=voice, language=Language.EN), text_filters=[SpokenTextFilter()], ) + if voice_manager: + voice_manager.set_tts_processor(tts) + return tts def build_turn_taking(args: argparse.Namespace): @@ -580,9 +585,9 @@ async def main() -> int: if vocabulary: vocabulary.observe(text) + voice_manager = VoiceManager(workspace) llm = build_llm(args, vocabulary, brain, observer=on_reply) - - tts = build_tts(args) + 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. diff --git a/voice_manager.py b/voice_manager.py new file mode 100644 index 0000000..8d05b99 --- /dev/null +++ b/voice_manager.py @@ -0,0 +1,116 @@ +"""Voice Manager for listing and dynamically changing TTS voices at runtime. + +Supports both Kokoro TTS voices and macOS System voices, persisting user voice preferences to disk. +""" + +import json +from pathlib import Path +from loguru import logger + +# Supported Kokoro TTS voices categorized by style +KOKORO_VOICES = { + "af_heart": "American Female - Warm & Natural (Default)", + "af_bella": "American Female - Clear & Expressive", + "af_sarah": "American Female - Soft & Smooth", + "af_nicole": "American Female - Relaxed", + "af_sky": "American Female - Bright", + "am_michael": "American Male - Friendly & Crisp", + "am_adam": "American Male - Natural", + "am_fenrir": "American Male - Deep", + "am_puck": "American Male - Energetic", + "bf_emma": "British Female - Professional", + "bf_isabella": "British Female - Smooth", + "bm_george": "British Male - Warm", + "bm_fable": "British Male - Expressive", +} + +MACOS_VOICES = { + "Moira": "Irish Female", + "Daniel": "UK Male", + "Samantha": "US Female", + "Karen": "Australian Female", + "Alex": "US Male", +} + + +class VoiceManager: + """Manages active voice settings and dynamic voice switching.""" + + def __init__(self, workspace_dir: Path, tts_processor=None): + self._workspace_dir = Path(workspace_dir) + self._tts_processor = tts_processor + self._config_file = self._workspace_dir / "voice_settings.json" + self._active_voice = "af_heart" + self.load_saved_voice() + + def set_tts_processor(self, tts_processor): + self._tts_processor = tts_processor + if self._active_voice: + self.apply_voice(self._active_voice) + + def load_saved_voice(self) -> str: + if self._config_file.exists(): + try: + data = json.loads(self._config_file.read_text()) + if "voice" in data: + self._active_voice = data["voice"] + logger.info(f"Loaded saved voice preference: {self._active_voice}") + except Exception as e: + logger.warning(f"Could not load saved voice settings: {e}") + return self._active_voice + + def save_voice(self, voice_name: str): + try: + self._config_file.write_text(json.dumps({"voice": voice_name}, indent=2)) + except Exception as e: + logger.warning(f"Could not save voice setting: {e}") + + def list_voices(self) -> str: + kokoro_lines = [f"- {v}: {desc}" for v, desc in KOKORO_VOICES.items()] + macos_lines = [f"- {v}: {desc}" for v, desc in MACOS_VOICES.items()] + return ( + "Available Voices:\n\n" + "Kokoro Voices:\n" + "\n".join(kokoro_lines) + "\n\n" + "macOS System Voices:\n" + "\n".join(macos_lines) + ) + + def apply_voice(self, voice_name: str) -> tuple[bool, str]: + voice_name = voice_name.strip() + matched_voice = None + + # Exact match or fuzzy match + for v in {**KOKORO_VOICES, **MACOS_VOICES}: + if voice_name.lower() == v.lower(): + matched_voice = v + break + + if not matched_voice: + # Partial match search + for v in {**KOKORO_VOICES, **MACOS_VOICES}: + if voice_name.lower() in v.lower(): + matched_voice = v + break + + if not matched_voice: + available = ", ".join(list(KOKORO_VOICES.keys()) + list(MACOS_VOICES.keys())) + return False, f"Voice '{voice_name}' not found. Available voices: {available}" + + self._active_voice = matched_voice + self.save_voice(matched_voice) + + if self._tts_processor: + try: + # Update Kokoro TTS setting if active + if hasattr(self._tts_processor, "_settings"): + self._tts_processor._settings.voice = matched_voice + logger.info(f"Dynamic voice updated to: {matched_voice}") + return True, f"Voice changed to {matched_voice}." + elif hasattr(self._tts_processor, "set_voice"): + self._tts_processor.set_voice(matched_voice) + logger.info(f"Dynamic voice updated to: {matched_voice}") + return True, f"Voice changed to {matched_voice}." + except Exception as e: + logger.error(f"Failed to apply voice to TTS processor: {e}") + return False, f"Could not change voice: {e}" + + return True, f"Voice set to {matched_voice}."