Add voice management tool bin/voice_tool.py and dynamic voice switching system

This commit is contained in:
Adolfo Reyna
2026-08-07 20:16:35 -04:00
parent 87b54ce8f4
commit 351b87beb8
3 changed files with 168 additions and 5 deletions
+42
View File
@@ -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()
+10 -5
View File
@@ -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 <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
@@ -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.
+116
View File
@@ -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}."