Add voice management tool bin/voice_tool.py and dynamic voice switching system
This commit is contained in:
Executable
+42
@@ -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()
|
||||
@@ -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.
|
||||
|
||||
@@ -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}."
|
||||
Reference in New Issue
Block a user