feat: US-009 - Autocomplete voci din catalog pentru /audio și /voice setvoice
- tools/tts.py: +list_voice_names(engine=None), sursă live din tts_voices.json - src/fast_commands.py: _VOICES statice -> _supertonic_voice_names() din catalog - discord_bot.py /audio și discord_voice.py /voice setvoice: choices statice -> autocomplete live - gates rulate: tests PASS (1043 passed, 22 eșecuri preexistente neschimbate), ui/backend review PASS manual
This commit is contained in:
@@ -39,6 +39,19 @@ from src.adapters.discord_views import (
|
|||||||
logger = logging.getLogger("echo-core.discord")
|
logger = logging.getLogger("echo-core.discord")
|
||||||
_security_log = logging.getLogger("echo-core.security")
|
_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()
|
# Module-level config reference, set by create_bot()
|
||||||
_config: Config | None = None
|
_config: Config | None = None
|
||||||
|
|
||||||
@@ -908,26 +921,20 @@ def create_bot(config: Config) -> discord.Client:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return wav_path
|
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")
|
@tree.command(name="audio", description="TTS: convertește text sau URL în voice note")
|
||||||
@app_commands.describe(
|
@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",
|
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)",
|
rezumat="Dacă să facă Claude rezumat înainte de TTS (doar pentru URL)",
|
||||||
)
|
)
|
||||||
@app_commands.choices(
|
@app_commands.autocomplete(voce=_voice_autocomplete)
|
||||||
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"),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
async def audio_cmd(
|
async def audio_cmd(
|
||||||
interaction: discord.Interaction,
|
interaction: discord.Interaction,
|
||||||
voce: str | None = None,
|
voce: str | None = None,
|
||||||
|
|||||||
@@ -140,6 +140,19 @@ def _parse_registered_voice_name(stdout: str) -> Optional[str]:
|
|||||||
return m.group(1) if m else None
|
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:
|
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 tools/tts.py (nu e package, sys.path trick) și generează un preview audio."""
|
||||||
import sys as _sys
|
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)
|
log.warning("Presence reset skipped", exc_info=True)
|
||||||
await interaction.followup.send("Plecat.", ephemeral=True)
|
await interaction.followup.send("Plecat.", ephemeral=True)
|
||||||
|
|
||||||
_VOICE_CHOICES = [
|
async def _voice_autocomplete(
|
||||||
app_commands.Choice(name=v, value=v)
|
interaction: discord.Interaction, current: str
|
||||||
for v in ("M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5")
|
) -> 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.describe(voice="Voce nouă")
|
||||||
@app_commands.choices(voice=_VOICE_CHOICES)
|
@app_commands.autocomplete(voice=_voice_autocomplete)
|
||||||
async def setvoice(
|
async def setvoice(
|
||||||
interaction: discord.Interaction,
|
interaction: discord.Interaction,
|
||||||
voice: app_commands.Choice[str],
|
voice: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
await interaction.response.defer(ephemeral=True)
|
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.
|
# Live-swap on the active session if Echo is in voice on this guild.
|
||||||
guild_id = interaction.guild.id if interaction.guild else None
|
guild_id = interaction.guild.id if interaction.guild else None
|
||||||
session = _voice_sessions.get(guild_id) if guild_id is not None else None
|
session = _voice_sessions.get(guild_id) if guild_id is not None else None
|
||||||
|
|||||||
@@ -712,11 +712,23 @@ Session:
|
|||||||
# Audio / TTS
|
# Audio / TTS
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_VOICES = {"M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5"}
|
|
||||||
_AUDIO_PREFIX = "__AUDIO__:"
|
_AUDIO_PREFIX = "__AUDIO__:"
|
||||||
_MAX_TTS_CHARS = 3000
|
_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:
|
def cmd_audio(args: list[str]) -> str:
|
||||||
"""TTS via Supertonic. Returnează __AUDIO__:/cale sau text de eroare.
|
"""TTS via Supertonic. Returnează __AUDIO__:/cale sau text de eroare.
|
||||||
|
|
||||||
@@ -732,7 +744,7 @@ def cmd_audio(args: list[str]) -> str:
|
|||||||
remaining = list(args)
|
remaining = list(args)
|
||||||
|
|
||||||
# Detectare voce ca prim token
|
# 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()
|
voice = remaining[0].upper()
|
||||||
remaining = remaining[1:]
|
remaining = remaining[1:]
|
||||||
|
|
||||||
|
|||||||
18
tools/tts.py
18
tools/tts.py
@@ -24,7 +24,8 @@ if str(REPO_ROOT) not in sys.path:
|
|||||||
sys.path.insert(0, str(REPO_ROOT))
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
SUPERTONIC_URL = "http://127.0.0.1:7788"
|
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_VOICE = "M2"
|
||||||
DEFAULT_LANG = "ro"
|
DEFAULT_LANG = "ro"
|
||||||
DEFAULT_ENGINE = "supertonic"
|
DEFAULT_ENGINE = "supertonic"
|
||||||
@@ -84,6 +85,18 @@ def _catalog_entry(voice: str) -> dict | None:
|
|||||||
return catalog.get(voice) or catalog.get(voice.upper())
|
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:
|
def _config_get(key: str, default: str) -> str:
|
||||||
from src.config import Config
|
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)
|
text = sanitize_for_supertonic(text)
|
||||||
|
|
||||||
voice = voice.upper()
|
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
|
voice = DEFAULT_VOICE
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user