Squashed branch: voice/dave-recv → master. Closes Pas 12 (DAVE E2E) and lands voice-mode UX polish + verbal voice control on top of the Pas 1-10 scaffolding already on master. ## DAVE E2E receive-side decrypt (e4f3177) Vendored fork: discord-ext-voice-recv 0.5.3a+echo.dave1. Patches the receive pipeline to handle Discord's mandatory DAVE encryption on voice gateway v=8. - `_maybe_dave_decrypt`: uses davey.can_passthrough(user_id) as primary gate, falls through to dave.decrypt for DAVE-epoch peers, drops on decrypt failure without killing the reader thread. - VAD fix: silero-vad v5+ requires exactly 512 samples; our 100ms window (1600 samples) was silently raising ValueError → STT never fired. Now slice into 512-sample chunks. - Whisper: bumped beam_size 1→5 and added RO initial_prompt. - Tests: 11 DAVE unit tests + 2 callback integration tests + contract test with fork-version guard. ## Voice UX polish (d1bc77e) - Killed the 3s "mă gândesc" filler (always collided with Claude p50 4-7s). - Barge-in via `ttsq.clear()` at top of `on_segment_done`. - DTX silence-flush poller (200ms tick) — Discord stops sending RTP packets when silent, so the inline silence-check in sink.write() never fired for trailing audio; background thread handles it. - `EchoStreamingAudioSource.read()` non-blocking — old `get_frame(timeout=0.1)` wrecked Discord's 20ms cadence and the client interpreted bursts as stuttering (Marius heard "4 de minute" instead of full sentence). - RO time expansion: 23:09 → "douăzeci și trei și nouă minute". - Supertonic Unicode sanitize centralized in tools/tts.py. - Whisper local_files_only=True — no HF metadata GET on each startup. - Diagnostic logging through sink → VAD → Claude stream → TTS chain. ## Voice mode iteration (e589e48) - `personality/VOICE_MODE.md` — voice-tailored system prompt (short, no markdown, no abbreviations, time without seconds, distances in "mii"/"milioane"); plumbed via build_system_prompt(voice_mode=True). - Isolated voice session key `voice:<channel_id>` — voice doesn't share context with text adapter on the same channel; auto-applied without /clear ceremony. /clear drops both keys. - Metric units + Romanian thousands (normalize.py): "384.000 km" → "trei sute optzeci și patru de mii de kilometri" with feminine-correct pluralization and "de" particle for ≥20. - `/voice setvoice <M1-F5>` slash command with native autocomplete; swaps live + persists voice.default_voice to config.json. - Verbal voice change (src/voice/voice_commands.py + 29 tests) — "schimbă vocea pe M5", "voce em cinci", with permissive substring fallback for Whisper-mangled forms like "Mâcinci"=M5 and "unul cinci"=M5. Whisper initial_prompt now lists voice vocabulary to bias STT toward clean outputs. - Fast barge-in: VAD ≥2 consecutive windows (~200ms) on Marius's user while Echo has pending TTS frames → cut him off mid-sentence so user doesn't wait the full silence + STT cycle. Acoustic echo bleed-through still requires headphones (no AEC). ## Test suite 130 voice + router tests pass (test_voice_recv_dave, test_voice_session_cleanup, test_voice_adapter_contract, test_voice_normalize, test_voice_commands, test_router). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
117 lines
3.7 KiB
Python
117 lines
3.7 KiB
Python
#!/usr/bin/env python3
|
||
"""Text-to-speech via Supertonic local server.
|
||
|
||
CLI:
|
||
python3 tools/tts.py --text "Salut Marius" [--voice M1] [--lang ro]
|
||
→ stdout: {"ok": true, "path": "/tmp/echo-tts-xxx.wav", "size_bytes": 12345}
|
||
→ stdout: {"ok": false, "error": "..."}
|
||
|
||
Module:
|
||
from tools.tts import synthesize
|
||
result = synthesize("text", voice="M1", lang="ro")
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import sys
|
||
import tempfile
|
||
|
||
import httpx
|
||
|
||
SUPERTONIC_URL = "http://127.0.0.1:7788"
|
||
VOICES = {"M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5"}
|
||
DEFAULT_VOICE = "M2"
|
||
DEFAULT_LANG = "ro"
|
||
|
||
# Punctuation Supertonic synthesis rejects with HTTP 500 (Romanian curly quotes,
|
||
# smart dashes, ellipsis, angle quotes). Mapped to ASCII so a stray „foo" in
|
||
# any caller's text doesn't kill the whole request.
|
||
_TTS_PUNCT_MAP = {
|
||
'„': '"', '“': '"', '”': '"',
|
||
'‘': "'", '’': "'", '‚': "'",
|
||
'«': '"', '»': '"',
|
||
'–': '-', '—': '-',
|
||
'…': '...',
|
||
}
|
||
|
||
|
||
def sanitize_for_supertonic(text: str) -> str:
|
||
"""Replace Unicode punctuation Supertonic rejects with ASCII equivalents."""
|
||
for src, dst in _TTS_PUNCT_MAP.items():
|
||
text = text.replace(src, dst)
|
||
return text
|
||
|
||
|
||
def synthesize(text: str, voice: str = DEFAULT_VOICE, lang: str = DEFAULT_LANG) -> dict:
|
||
"""Call Supertonic server and save audio to a temp WAV file.
|
||
|
||
Returns:
|
||
{"ok": True, "path": "/tmp/echo-tts-xxx.wav", "size_bytes": N}
|
||
{"ok": False, "error": "mesaj eroare"}
|
||
"""
|
||
if not text or not text.strip():
|
||
return {"ok": False, "error": "Text gol."}
|
||
|
||
text = sanitize_for_supertonic(text)
|
||
|
||
voice = voice.upper()
|
||
if voice not in VOICES:
|
||
voice = DEFAULT_VOICE
|
||
|
||
try:
|
||
resp = httpx.post(
|
||
f"{SUPERTONIC_URL}/v1/audio/speech",
|
||
json={
|
||
"model": "supertonic-3",
|
||
"input": text,
|
||
"voice": voice,
|
||
"response_format": "wav",
|
||
"lang": lang,
|
||
},
|
||
timeout=60.0,
|
||
)
|
||
resp.raise_for_status()
|
||
except httpx.ConnectError:
|
||
return {
|
||
"ok": False,
|
||
"error": (
|
||
"Serverul Supertonic nu rulează pe :7788. "
|
||
"Pornește cu: systemctl --user start supertonic-tts"
|
||
),
|
||
}
|
||
except httpx.HTTPStatusError as e:
|
||
body = e.response.text[:300]
|
||
# Fallback: dacă lang=ro eșuează, încearcă na (language-agnostic)
|
||
if lang != "na":
|
||
return synthesize(text, voice=voice, lang="na")
|
||
return {"ok": False, "error": f"HTTP {e.response.status_code}: {body}"}
|
||
except Exception as e:
|
||
return {"ok": False, "error": str(e)}
|
||
|
||
# Salvează în fișier temp
|
||
try:
|
||
fd, path = tempfile.mkstemp(prefix="echo-tts-", suffix=".wav")
|
||
with open(fd, "wb") as f:
|
||
f.write(resp.content)
|
||
return {"ok": True, "path": path, "size_bytes": len(resp.content)}
|
||
except Exception as e:
|
||
return {"ok": False, "error": f"Scriere fișier: {e}"}
|
||
|
||
|
||
if __name__ == "__main__":
|
||
parser = argparse.ArgumentParser(description="Supertonic TTS CLI")
|
||
parser.add_argument("--text", required=True, help="Text de convertit în audio")
|
||
parser.add_argument(
|
||
"--voice", default=DEFAULT_VOICE,
|
||
help="Voce: M1-M5 (masculin) sau F1-F5 (feminin). Default: M1"
|
||
)
|
||
parser.add_argument(
|
||
"--lang", default=DEFAULT_LANG,
|
||
help="Limbă (ro, en, na). Default: ro. Fallback automat la na dacă ro eșuează."
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
result = synthesize(args.text, voice=args.voice, lang=args.lang)
|
||
print(json.dumps(result, ensure_ascii=False))
|
||
sys.exit(0 if result.get("ok") else 1)
|