From f2f81e93b2ba4d076c2ffc4bd42e5fb978fe1bc2 Mon Sep 17 00:00:00 2001 From: JT Date: Fri, 20 Jun 2025 17:41:59 +0100 Subject: [PATCH 1/5] chore: update elevlenlabs for feature --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 720aea5..184bab5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,5 +17,5 @@ spacy==3.8.7 torch==2.7.0 transformers==4.52.4 ffmpeg-python==0.2.0 -elevenlabs==1.57.0 +elevenlabs==2.32.4 yt-dlp==2025.5.22 From c353b8b5cbed6d7810350819abe949b1e079bb36 Mon Sep 17 00:00:00 2001 From: JT Date: Fri, 20 Jun 2025 20:19:05 +0100 Subject: [PATCH 2/5] Modified to remove hardcoded voice list, api key requested first --- utils/.config.template.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/.config.template.toml b/utils/.config.template.toml index 9185a29..bba534e 100644 --- a/utils/.config.template.toml +++ b/utils/.config.template.toml @@ -46,8 +46,8 @@ background_thumbnail_font_color = { optional = true, default = "255,255,255", ex [settings.tts] voice_choice = { optional = false, default = "tiktok", options = ["elevenlabs", "streamlabspolly", "tiktok", "googletranslate", "awspolly", "pyttsx", "OpenAI"], example = "tiktok", explanation = "The voice platform used for TTS generation. " } random_voice = { optional = false, type = "bool", default = true, example = true, options = [true, false,], explanation = "Randomizes the voice used for each comment" } -elevenlabs_voice_name = { optional = false, default = "Bella", example = "Bella", explanation = "The voice used for elevenlabs", options = ["Adam", "Antoni", "Arnold", "Bella", "Domi", "Elli", "Josh", "Rachel", "Sam", ] } elevenlabs_api_key = { optional = true, example = "21f13f91f54d741e2ae27d2ab1b99d59", explanation = "Elevenlabs API key" } +elevenlabs_voice_name = { optional = true, default = "", example = "Bella", explanation = "The voice used for ElevenLabs. Will be configured after entering your API key. Leave blank to use a random voice." } aws_polly_voice = { optional = false, default = "Matthew", example = "Matthew", explanation = "The voice used for AWS Polly" } streamlabs_polly_voice = { optional = false, default = "Matthew", example = "Matthew", explanation = "The voice used for Streamlabs Polly" } tiktok_voice = { optional = true, default = "en_us_001", example = "en_us_006", explanation = "The voice used for TikTok TTS" } From 6f8c52efe8216f7bf634b8076a4f4291d4f6becb Mon Sep 17 00:00:00 2001 From: JT Date: Fri, 20 Jun 2025 20:19:37 +0100 Subject: [PATCH 3/5] dynamic elevenlabs voice fetching in setup after api key provided --- utils/settings.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/utils/settings.py b/utils/settings.py index 6b8242b..62f0ea1 100755 --- a/utils/settings.py +++ b/utils/settings.py @@ -6,6 +6,7 @@ import toml from rich.console import Console from utils.console import handle_input +from TTS.elevenlabs import elevenlabs console = Console() config = dict # autocomplete @@ -25,6 +26,21 @@ def check(value, checks, name): def get_check_value(key, default_result): return checks[key] if key in checks else default_result + # Dynamically fetch ElevenLabs voices if the API key is present + if name == "elevenlabs_voice_name": + # This relies on elevenlabs_api_key being processed first in the .toml file + api_key = config.get("settings", {}).get("tts", {}).get("elevenlabs_api_key") + if api_key: + console.print("\n[blue]Attempting to fetch your ElevenLabs voices...[/blue]") + # Use a static method to get voices without initializing the full class + available_voices = elevenlabs.get_available_voices(api_key) + if available_voices: + console.print("[green]Successfully fetched voices![/green]") + checks["options"] = available_voices + checks["explanation"] = "Select a voice from your ElevenLabs account. Leave blank for random." + else: + console.print("[yellow]Could not fetch voices. Check your API key. You can enter a voice name manually.[/yellow]") + incorrect = False if value == {}: incorrect = True From 84eea1fd73e7c1953ddd7d63e9db5991b7128fb5 Mon Sep 17 00:00:00 2001 From: JT Date: Fri, 20 Jun 2025 20:20:14 +0100 Subject: [PATCH 4/5] Updated runtime validation and voice fetching logic, v2 models used, new api methods --- TTS/elevenlabs.py | 63 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 55 insertions(+), 8 deletions(-) diff --git a/TTS/elevenlabs.py b/TTS/elevenlabs.py index c1f478e..99634ea 100644 --- a/TTS/elevenlabs.py +++ b/TTS/elevenlabs.py @@ -1,6 +1,6 @@ import random -from elevenlabs import save +from elevenlabs import save, Voice from elevenlabs.client import ElevenLabs from utils import settings @@ -10,29 +10,76 @@ class elevenlabs: def __init__(self): self.max_chars = 2500 self.client: ElevenLabs = None + self.available_voices: list[Voice] = [] # To store fetched voices + + @staticmethod + def get_available_voices(api_key: str) -> list[str]: + """ + Fetches available voice names from ElevenLabs using a given API key. + Returns a list of voice names, or an empty list on failure. + """ + if not api_key: + return [] + try: + client = ElevenLabs(api_key=api_key) + voices = client.voices.get_all().voices + return sorted([voice.name for voice in voices]) + except Exception: + # Fail silently and return an empty list. The caller will handle the message. + return [] def run(self, text, filepath, random_voice: bool = False): if self.client is None: self.initialize() + + voice_id_to_use = None if random_voice: - voice = self.randomvoice() + voice_id_to_use = self.randomvoice() else: - voice = str(settings.config["settings"]["tts"]["elevenlabs_voice_name"]).capitalize() + configured_voice_name = str(settings.config["settings"]["tts"]["elevenlabs_voice_name"]).strip() + if not configured_voice_name: # If name is blank, use random + voice_id_to_use = self.randomvoice() + else: + # Look up the configured voice name in the cached available voices + for voice_obj in self.available_voices: + if voice_obj.name.lower() == configured_voice_name.lower(): + voice_id_to_use = voice_obj.voice_id + break - audio = self.client.generate(text=text, voice=voice, model="eleven_multilingual_v1") + if voice_id_to_use is None: + # Provide a helpful error message if the configured voice is not found + available_voice_names = [v.name for v in self.available_voices] + raise ValueError( + f"Configured ElevenLabs voice '{configured_voice_name}' not found. " + f"Available voices for your account are: {', '.join(available_voice_names)}. " + "Please update 'elevenlabs_voice_name' in your config.toml or GUI." + ) + + audio = self.client.text_to_speech.convert( + text=text, voice_id=voice_id_to_use, model_id="eleven_multilingual_v2" + ) save(audio=audio, filename=filepath) def initialize(self): - if settings.config["settings"]["tts"]["elevenlabs_api_key"]: - api_key = settings.config["settings"]["tts"]["elevenlabs_api_key"] - else: + api_key = settings.config["settings"]["tts"].get("elevenlabs_api_key") + if not api_key: raise ValueError( "You didn't set an Elevenlabs API key! Please set the config variable ELEVENLABS_API_KEY to a valid API key." ) self.client = ElevenLabs(api_key=api_key) + # Fetch and store available voices during initialization + try: + self.available_voices = self.client.voices.get_all().voices + if not self.available_voices: + raise RuntimeError("No voices found for your ElevenLabs account. Please check your API key and account status.") + except Exception as e: + raise RuntimeError(f"Failed to fetch ElevenLabs voices: {e}. Please check your API key and internet connection.") def randomvoice(self): if self.client is None: self.initialize() - return random.choice(self.client.voices.get_all().voices).name + if not self.available_voices: + raise RuntimeError("No voices available from ElevenLabs account to choose from.") + # Return the voice_id of a randomly selected voice object + return random.choice(self.available_voices).voice_id From 35dbd59c7fab295d555cb190c5500b17dacf1bb3 Mon Sep 17 00:00:00 2001 From: JT Date: Sat, 21 Jun 2025 00:20:47 +0100 Subject: [PATCH 5/5] integration and bugfixes after testing elevenlabs API calls --- .gitignore | 3 +++ TTS/elevenlabs.py | 50 +++++++++++++++++++++++++++-------------------- utils/settings.py | 41 +++++++++++++++++++++++++++----------- 3 files changed, 62 insertions(+), 32 deletions(-) diff --git a/.gitignore b/.gitignore index cc6bd18..b9036fb 100644 --- a/.gitignore +++ b/.gitignore @@ -246,3 +246,6 @@ video_creation/data/envvars.txt config.toml *.exe + +# /feat/elevenlabs_v2_support testing script +test_elevenlabs.py \ No newline at end of file diff --git a/TTS/elevenlabs.py b/TTS/elevenlabs.py index 99634ea..3deaeef 100644 --- a/TTS/elevenlabs.py +++ b/TTS/elevenlabs.py @@ -1,6 +1,6 @@ import random -from elevenlabs import save, Voice +from elevenlabs import Voice from elevenlabs.client import ElevenLabs from utils import settings @@ -10,23 +10,31 @@ class elevenlabs: def __init__(self): self.max_chars = 2500 self.client: ElevenLabs = None - self.available_voices: list[Voice] = [] # To store fetched voices + self.available_voices: list[Voice] = [] # To store fetched Voice objects + self.voice_name_to_id_map: dict[str, str] = {} # To store name -> id mapping for quick lookup @staticmethod - def get_available_voices(api_key: str) -> list[str]: + def get_available_voices(api_key: str) -> dict[str, str]: """ - Fetches available voice names from ElevenLabs using a given API key. - Returns a list of voice names, or an empty list on failure. + Fetches available voice names and their IDs from ElevenLabs using a given API key. + Returns a dictionary mapping voice names (lowercase) to voice IDs, or an empty dict on failure. """ if not api_key: - return [] + return {} try: client = ElevenLabs(api_key=api_key) - voices = client.voices.get_all().voices - return sorted([voice.name for voice in voices]) - except Exception: - # Fail silently and return an empty list. The caller will handle the message. - return [] + print("\n[1/4] Fetching available ElevenLabs voices...") + # Keep client.voices.search as per original code and potential test dependency. + # The search method returns GetVoicesV2Response which has a 'voices' attribute. + response = client.voices.search(include_total_count=True, page_size=100) #page-size specified as endpoint returns paginated data + if not response.voices: + raise RuntimeError("No voices found for your ElevenLabs account. Please check your API key.") + voice_mapping = {voice.name.lower(): voice.voice_id for voice in response.voices} + print(f"✅ Success! Found {response.total_count} voices for your account:") + return voice_mapping + except Exception as e: + print(f"❌ Failed to fetch ElevenLabs voices: {e}") + return {} def run(self, text, filepath, random_voice: bool = False): if self.client is None: @@ -40,25 +48,24 @@ class elevenlabs: if not configured_voice_name: # If name is blank, use random voice_id_to_use = self.randomvoice() else: - # Look up the configured voice name in the cached available voices - for voice_obj in self.available_voices: - if voice_obj.name.lower() == configured_voice_name.lower(): - voice_id_to_use = voice_obj.voice_id - break + # Use the pre-built map for efficient lookup + voice_id_to_use = self.voice_name_to_id_map.get(configured_voice_name.lower()) if voice_id_to_use is None: # Provide a helpful error message if the configured voice is not found - available_voice_names = [v.name for v in self.available_voices] + available_voice_names = list(self.voice_name_to_id_map.keys()) # Get names from the map raise ValueError( f"Configured ElevenLabs voice '{configured_voice_name}' not found. " f"Available voices for your account are: {', '.join(available_voice_names)}. " "Please update 'elevenlabs_voice_name' in your config.toml or GUI." ) - audio = self.client.text_to_speech.convert( + audio_stream = self.client.text_to_speech.convert( text=text, voice_id=voice_id_to_use, model_id="eleven_multilingual_v2" ) - save(audio=audio, filename=filepath) + with open(filepath, "wb") as f: + for chunk in audio_stream: + f.write(chunk) def initialize(self): api_key = settings.config["settings"]["tts"].get("elevenlabs_api_key") @@ -70,7 +77,8 @@ class elevenlabs: self.client = ElevenLabs(api_key=api_key) # Fetch and store available voices during initialization try: - self.available_voices = self.client.voices.get_all().voices + self.available_voices = self.client.voices.get_all().voices # Store Voice objects + self.voice_name_to_id_map = {voice.name.lower(): voice.voice_id for voice in self.available_voices} # Build the map if not self.available_voices: raise RuntimeError("No voices found for your ElevenLabs account. Please check your API key and account status.") except Exception as e: @@ -79,7 +87,7 @@ class elevenlabs: def randomvoice(self): if self.client is None: self.initialize() - if not self.available_voices: + if not self.available_voices: # Use the list of Voice objects raise RuntimeError("No voices available from ElevenLabs account to choose from.") # Return the voice_id of a randomly selected voice object return random.choice(self.available_voices).voice_id diff --git a/utils/settings.py b/utils/settings.py index 62f0ea1..9a62795 100755 --- a/utils/settings.py +++ b/utils/settings.py @@ -6,7 +6,7 @@ import toml from rich.console import Console from utils.console import handle_input -from TTS.elevenlabs import elevenlabs +import elevenlabs console = Console() config = dict # autocomplete @@ -32,14 +32,22 @@ def check(value, checks, name): api_key = config.get("settings", {}).get("tts", {}).get("elevenlabs_api_key") if api_key: console.print("\n[blue]Attempting to fetch your ElevenLabs voices...[/blue]") - # Use a static method to get voices without initializing the full class - available_voices = elevenlabs.get_available_voices(api_key) - if available_voices: - console.print("[green]Successfully fetched voices![/green]") - checks["options"] = available_voices - checks["explanation"] = "Select a voice from your ElevenLabs account. Leave blank for random." - else: - console.print("[yellow]Could not fetch voices. Check your API key. You can enter a voice name manually.[/yellow]") + try: + # This logic is ported from TTS/elevenlabs.py to avoid import issues + client = elevenlabs.ElevenLabs(api_key=api_key) + response = client.voices.search(include_total_count=True, page_size=100) + if not response.voices: + console.print("[yellow]No voices found for your ElevenLabs account. Check your API key.[/yellow]") + else: + available_voice_names = [voice.name.lower() for voice in response.voices] + console.print(f"✅ [green]Success! Found {response.total_count} voices for your account.[/green]") + checks["options"] = available_voice_names + checks["explanation"] = "Select a voice from your ElevenLabs account. Leave blank for random." + except Exception as e: + console.print(f"❌ [red]Failed to fetch ElevenLabs voices: {e}[/red]") + console.print("[yellow]You can enter a voice name manually or leave blank for random.[/yellow]") + # This setting is always optional (blank means random voice) + checks["optional"] = True incorrect = False if value == {}: @@ -50,9 +58,20 @@ def check(value, checks, name): except: incorrect = True + # Prepare value for checks; especially for case-insensitive options like voice names + check_value = value + if name == "elevenlabs_voice_name": + check_value = str(value).lower().strip() + + # A blank value is acceptable for optional fields + is_optional_and_blank = "optional" in checks and checks["optional"] and str(value).strip() == "" + if ( - not incorrect and "options" in checks and value not in checks["options"] - ): # FAILSTATE Value is not one of the options + not incorrect + and not is_optional_and_blank + and "options" in checks + and check_value not in checks["options"] + ): incorrect = True if ( not incorrect