diff --git a/src/adapters/discord_bot.py b/src/adapters/discord_bot.py index e61444f..c968951 100644 --- a/src/adapters/discord_bot.py +++ b/src/adapters/discord_bot.py @@ -39,6 +39,19 @@ from src.adapters.discord_views import ( logger = logging.getLogger("echo-core.discord") _security_log = logging.getLogger("echo-core.security") + +def _tts_voice_names() -> list[str]: + """Nume de voci din tts_voices.json (import lazy din tools/tts.py, catalog live).""" + import sys as _sys + tools_dir = str(PROJECT_ROOT / "tools") + if tools_dir not in _sys.path: + _sys.path.insert(0, tools_dir) + try: + import tts as _tts_mod + return _tts_mod.list_voice_names() + except Exception: + return [] + # Module-level config reference, set by create_bot() _config: Config | None = None @@ -908,26 +921,20 @@ def create_bot(config: Config) -> discord.Client: except Exception: return wav_path + async def _voice_autocomplete( + interaction: discord.Interaction, current: str + ) -> list[app_commands.Choice[str]]: + current_low = (current or "").lower() + names = [n for n in _tts_voice_names() if current_low in n.lower()] + return [app_commands.Choice(name=n, value=n) for n in names[:25]] + @tree.command(name="audio", description="TTS: convertește text sau URL în voice note") @app_commands.describe( - voce="Voce (M1-M5 masculin, F1-F5 feminin; default M2)", + voce="Voce din catalog (ex: M1-M5, F1-F5, 'Marius 1'; default M2)", text_sau_url="Text direct, URL articol, sau gol pentru ultimul răspuns Echo", rezumat="Dacă să facă Claude rezumat înainte de TTS (doar pentru URL)", ) - @app_commands.choices( - voce=[ - app_commands.Choice(name="M1 — Masculin 1", value="M1"), - app_commands.Choice(name="M2 — Masculin 2 (default)", value="M2"), - app_commands.Choice(name="M3 — Masculin 3", value="M3"), - app_commands.Choice(name="M4 — Masculin 4", value="M4"), - app_commands.Choice(name="M5 — Masculin 5", value="M5"), - app_commands.Choice(name="F1 — Feminin 1", value="F1"), - app_commands.Choice(name="F2 — Feminin 2", value="F2"), - app_commands.Choice(name="F3 — Feminin 3", value="F3"), - app_commands.Choice(name="F4 — Feminin 4", value="F4"), - app_commands.Choice(name="F5 — Feminin 5", value="F5"), - ] - ) + @app_commands.autocomplete(voce=_voice_autocomplete) async def audio_cmd( interaction: discord.Interaction, voce: str | None = None, diff --git a/src/adapters/discord_voice.py b/src/adapters/discord_voice.py index 906811c..022f6f8 100644 --- a/src/adapters/discord_voice.py +++ b/src/adapters/discord_voice.py @@ -140,6 +140,19 @@ def _parse_registered_voice_name(stdout: str) -> Optional[str]: return m.group(1) if m else None +def _tts_voice_names() -> list[str]: + """Nume de voci din tts_voices.json (import lazy din tools/tts.py, catalog live).""" + import sys as _sys + tools_dir = str(PROJECT_ROOT / "tools") + if tools_dir not in _sys.path: + _sys.path.insert(0, tools_dir) + try: + import tts as _tts_mod + return _tts_mod.list_voice_names() + except Exception: + return [] + + def _tts_synthesize_preview(text: str, voice: str) -> dict: """Import tools/tts.py (nu e package, sys.path trick) și generează un preview audio.""" import sys as _sys @@ -301,20 +314,27 @@ def register(tree: app_commands.CommandTree, bot: discord.Client) -> app_command log.warning("Presence reset skipped", exc_info=True) await interaction.followup.send("Plecat.", ephemeral=True) - _VOICE_CHOICES = [ - app_commands.Choice(name=v, value=v) - for v in ("M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5") - ] + async def _voice_autocomplete( + interaction: discord.Interaction, current: str + ) -> list[app_commands.Choice[str]]: + current_low = (current or "").lower() + names = [n for n in _tts_voice_names() if current_low in n.lower()] + return [app_commands.Choice(name=n, value=n) for n in names[:25]] - @voice_group.command(name="setvoice", description="Schimbă vocea Echo (M1-M5 sau F1-F5)") + @voice_group.command(name="setvoice", description="Schimbă vocea Echo (din catalogul tts_voices.json)") @app_commands.describe(voice="Voce nouă") - @app_commands.choices(voice=_VOICE_CHOICES) + @app_commands.autocomplete(voice=_voice_autocomplete) async def setvoice( interaction: discord.Interaction, - voice: app_commands.Choice[str], + voice: str, ) -> None: await interaction.response.defer(ephemeral=True) - new_voice = voice.value + if voice not in _tts_voice_names(): + await interaction.followup.send( + f"Voce necunoscută: {voice!r}. Alege din autocomplete.", ephemeral=True + ) + return + new_voice = voice # Live-swap on the active session if Echo is in voice on this guild. guild_id = interaction.guild.id if interaction.guild else None session = _voice_sessions.get(guild_id) if guild_id is not None else None diff --git a/src/fast_commands.py b/src/fast_commands.py index 35ff7d6..f20d216 100644 --- a/src/fast_commands.py +++ b/src/fast_commands.py @@ -712,11 +712,23 @@ Session: # Audio / TTS # --------------------------------------------------------------------------- -_VOICES = {"M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5"} _AUDIO_PREFIX = "__AUDIO__:" _MAX_TTS_CHARS = 3000 +def _supertonic_voice_names() -> set[str]: + """Nume de voci Supertonic din tts_voices.json (import lazy din tools/tts.py).""" + import sys as _sys + _tools_dir = str(TOOLS_DIR) + if _tools_dir not in _sys.path: + _sys.path.insert(0, _tools_dir) + try: + import tts as _tts_mod + return set(_tts_mod.list_voice_names(engine="supertonic")) + except Exception: + return {"M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5"} + + def cmd_audio(args: list[str]) -> str: """TTS via Supertonic. Returnează __AUDIO__:/cale sau text de eroare. @@ -732,7 +744,7 @@ def cmd_audio(args: list[str]) -> str: remaining = list(args) # Detectare voce ca prim token - if remaining and remaining[0].upper() in _VOICES: + if remaining and remaining[0].upper() in _supertonic_voice_names(): voice = remaining[0].upper() remaining = remaining[1:] diff --git a/tools/tts.py b/tools/tts.py index eeeaee0..bdbd7a4 100644 --- a/tools/tts.py +++ b/tools/tts.py @@ -24,7 +24,8 @@ if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) SUPERTONIC_URL = "http://127.0.0.1:7788" -VOICES = {"M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5"} +# Safety net dacă tts_voices.json lipsește/e gol — normal, catalogul e sursa de adevăr. +_FALLBACK_SUPERTONIC_VOICES = {"M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5"} DEFAULT_VOICE = "M2" DEFAULT_LANG = "ro" DEFAULT_ENGINE = "supertonic" @@ -84,6 +85,18 @@ def _catalog_entry(voice: str) -> dict | None: return catalog.get(voice) or catalog.get(voice.upper()) +def list_voice_names(engine: str | None = None) -> list[str]: + """Nume de voci din tts_voices.json, opțional filtrate pe `engine`. + + Sursă live pentru autocomplete Discord (/audio, /voice setvoice) — o voce + nouă adăugată în catalog apare imediat, fără redeploy. + """ + catalog = _load_voice_catalog() + if engine is not None: + return sorted(name for name, entry in catalog.items() if entry.get("engine") == engine) + return sorted(catalog.keys()) + + def _config_get(key: str, default: str) -> str: from src.config import Config @@ -110,7 +123,8 @@ def _synthesize_supertonic(text: str, voice: str = DEFAULT_VOICE, lang: str = DE text = sanitize_for_supertonic(text) voice = voice.upper() - if voice not in VOICES: + supertonic_voices = set(list_voice_names(engine="supertonic")) or _FALLBACK_SUPERTONIC_VOICES + if voice not in supertonic_voices: voice = DEFAULT_VOICE try: