# app.py import os import io import base64 import tempfile from typing import List, Dict, Tuple, Iterable, Union from elevenlabs.core.api_error import ApiError import gradio as gr from elevenlabs import ElevenLabs API_KEY_ENV = "ELEVENLABS_API_KEY" # Voices you want to hide (by NAME, case-Sensitive) HIDDEN_VOICES = { "Ruby Conv", "Scouse Woman", "Scouse Woman 2", "Scouse Woman 3", "Scouse Woman 4", "Scouse Woman 5", "Lara Old", "Valilu Old", "BLANK SLOT", "BLANK SLOT", "BLANK SLOT" } _client = None # ElevenLabs client cache # ---------- helpers ---------- def get_client() -> ElevenLabs: global _client if _client is None: api_key = os.getenv(API_KEY_ENV) if not api_key: raise RuntimeError( f"Missing {API_KEY_ENV}. In Spaces, add it under Settings → Variables as a secret." ) _client = ElevenLabs(api_key=api_key) return _client def _norm(s: str) -> str: return s.strip().lower() def _collect_bytes(chunks_or_bytes: Union[bytes, bytearray, Iterable[bytes]]) -> bytes: """ElevenLabs may return bytes or a generator of bytes; normalize to bytes.""" if isinstance(chunks_or_bytes, (bytes, bytearray)): return bytes(chunks_or_bytes) buf = io.BytesIO() for chunk in chunks_or_bytes: if not chunk: continue if isinstance(chunk, (bytes, bytearray, memoryview)): buf.write(bytes(chunk)) return buf.getvalue() def _write_temp_mp3(data: bytes) -> str: fd, path = tempfile.mkstemp(suffix=".mp3") with os.fdopen(fd, "wb") as f: f.write(data) return path def _list_all_voices(): """ Retrieve all voices robustly: - request up to the API max page_size (100) - follow next_cursor / has_more if present - dedupe by voice_id """ client = get_client() seen_ids = set() items = [] cursor = None for _ in range(50): # safety cap kwargs = {"include_total_count": False, "page_size": 100} # <= max allowed if cursor: kwargs["cursor"] = cursor try: res = client.voices.search(**kwargs) except TypeError: # SDK doesn't support these kwargs → single fetch fallback res = client.voices.search(include_total_count=False) voices = getattr(res, "voices", res) or [] new_count = 0 for v in voices: vid = getattr(v, "voice_id", None) if not vid or vid in seen_ids: continue seen_ids.add(vid) items.append(v) new_count += 1 print(f"[voices] page got={len(voices)} new={new_count} total_unique={len(items)}") # Pagination signals (if API/SDK exposes them) cursor = getattr(res, "next_cursor", None) has_more = bool(getattr(res, "has_more", False)) # Stop when we have no new items, or there's no indication of more pages if new_count == 0: break if not cursor and not has_more: break print(f"[voices] done: total_unique={len(items)}") return items def fetch_voices() -> List[Tuple[str, str]]: """ Return (label, value) = (name, voice_id) tuples for the dropdown. Only show voices that appear on "My Voices": - your generated/cloned voices (owner or category) - community/library voices you've saved (favorited) Excludes plain premades you haven't saved. """ try: voices = _list_all_voices() hidden_norm = {_norm(n) for n in HIDDEN_VOICES} entries: List[Tuple[str, str]] = [] for v in voices: name = getattr(v, "name", "Voice") vid = getattr(v, "voice_id", None) if not vid: continue # Skip hidden names if _norm(name) in hidden_norm: continue category = getattr(v, "category", None) # 'premade' | 'generated' | 'cloned' | None is_owner = bool(getattr(v, "is_owner", False)) # True if you own the voice favorited = getattr(v, "favorited_at_unix", None) is not None # Saved/added to your library # Keep if it's yours OR saved OR clearly user-type (generated/cloned) keep = ( is_owner or favorited or (category in {"generated", "cloned"}) ) # But drop plain premade voices you haven't saved if category == "premade" and not favorited: keep = False if not keep: continue entries.append((name, vid)) # Sort nicely entries.sort(key=lambda x: _norm(x[0])) # Fallback so UI still works if nothing matches if not entries: return [("Rachel", "21m00Tcm4TlvDq8ikWAM")] return entries except Exception: return [("Rachel", "21m00Tcm4TlvDq8ikWAM")] def refresh_voices(): # IMPORTANT: return the same shape as initial choices (tuples) return gr.update(choices=fetch_voices(), value=None) # ---------- TTS ---------- def synthesize(text: str, voice_id: str, voice_id_override: str) -> tuple[str, str]: """ Returns (html_player, mp3_filepath). - html_player: