Spaces:
Sleeping
Sleeping
| """ | |
| echo/tools/voice.py | |
| ------------------- | |
| The voice tool gives each echo a spoken line — the same "you" at different ages | |
| and emotional registers. Hearing an alternate self speak is the visceral beat | |
| that text can't reach. | |
| VoiceTool interface: | |
| * MockVoice — writes a tiny placeholder file path, no audio deps (testing). | |
| * PiperVoice — wraps a local TTS (Piper/Coqui) at deploy time, with the | |
| WorldState's voice_hint shaping pace/warmth, and an optional | |
| pitch shift so each branch's voice differs subtly. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| from abc import ABC, abstractmethod | |
| from ..core.world_state import WorldState # module import (not the core package) avoids a cycle | |
| class VoiceTool(ABC): | |
| def speak(self, state: WorldState, out_dir: str) -> str: | |
| """Synthesize state.voice_line; return the audio file path.""" | |
| ... | |
| class MockVoice(VoiceTool): | |
| """Writes a placeholder .txt 'audio' marker so the pipeline runs offline.""" | |
| def speak(self, state: WorldState, out_dir: str) -> str: | |
| os.makedirs(out_dir, exist_ok=True) | |
| path = os.path.join(out_dir, f"voice_{state.node_id}.txt") | |
| with open(path, "w") as f: | |
| f.write(f"[VOICE hint={state.tone.voice_hint!r}] {state.voice_line}") | |
| return path | |
| class PiperVoice(VoiceTool): | |
| """ | |
| Deploy-time TTS. Lazy-imports the synth backend. A small pitch offset keyed | |
| to the branch makes parallel selves sound like the same person tuned | |
| differently (older/wearier/brighter). | |
| """ | |
| def __init__(self, model_path: str, base_pitch: float = 1.0): | |
| self.model_path = model_path | |
| self.base_pitch = base_pitch | |
| def speak(self, state: WorldState, out_dir: str) -> str: | |
| os.makedirs(out_dir, exist_ok=True) | |
| path = os.path.join(out_dir, f"voice_{state.node_id}.wav") | |
| # pitch nudged by emotional valence: down when struggling, up when light | |
| pitch = self.base_pitch + 0.04 * state.tone.valence | |
| self._synthesize(state.voice_line, path, pitch, state.tone.voice_hint) | |
| return path | |
| def _synthesize(self, text: str, path: str, pitch: float, hint: str) -> None: | |
| # Lazy import keeps the package importable without TTS installed. | |
| try: | |
| from piper import PiperVoice as _Piper # type: ignore | |
| except Exception: | |
| # graceful fallback: write a marker instead of crashing the demo | |
| with open(path + ".txt", "w") as f: | |
| f.write(f"[TTS pitch={pitch:.2f} hint={hint!r}] {text}") | |
| return | |
| voice = _Piper.load(self.model_path) | |
| with open(path, "wb") as f: | |
| voice.synthesize(text, f, length_scale=1.0, sentence_silence=0.3) | |