From 4c78f80be82d0fcd29d1a8c773c431fb7b54df25 Mon Sep 17 00:00:00 2001 From: will wade Date: Tue, 18 Mar 2025 20:50:39 +0000 Subject: [PATCH 01/37] refactor to use py3-tts-wrapper --- README.md | 96 ++++++++++++--- speech/config.py | 10 +- speech/provider_base.py | 65 +++++++++++ speech/provider_elevenlabs_data.py | 78 ++++--------- speech/provider_espeak_data.py | 27 +++++ speech/provider_msazure_data.py | 60 ++++------ speech/provider_msazure_playing.py | 81 +++++-------- speech/provider_piper_data.py | 39 ++++--- speech/provider_platform_data.py | 84 ++++++++++++++ speech/provider_template_data.py | 40 ++++--- speech/provider_template_playing.py | 54 ++++----- speech/provider_test_data.py | 27 +++++ speech/speechManager.py | 173 ++++++++++++++-------------- speech/start.py | 53 +++++---- speech/test.py | 56 +++++++++ speech/util.py | 43 +++---- 16 files changed, 627 insertions(+), 359 deletions(-) create mode 100644 speech/provider_base.py create mode 100644 speech/provider_espeak_data.py create mode 100644 speech/provider_platform_data.py create mode 100644 speech/provider_test_data.py create mode 100644 speech/test.py diff --git a/README.md b/README.md index 5f0c1a3..5562b99 100644 --- a/README.md +++ b/README.md @@ -5,17 +5,70 @@ Helper tools to enable [AsTeRICS Grid](https://github.com/asterics/AsTeRICS-Grid Normally AsTeRICS Grid uses the [Web Speech API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API) and therefore voices that are installed on the operating system (e.g. SAPI voices on Windows, or voices that are coming from a TTS module on Android). Sometimes it's interesting to use voices, which aren't available as system voices. This section describes how to use an external custom speech service using Python. ### Terms -* **Speech provider**: a Python module that implements access to a speech generating service like [MS Azure](https://azure.microsoft.com/en-us/products/ai-services/text-to-speech), [Amazon Polly](https://aws.amazon.com/polly/), [Piper](https://github.com/rhasspy/piper), [MycroftAI mimic3](https://github.com/MycroftAI/mimic3) or any others. Speech providers can have two types: +* **Speech provider**: a Python module that implements access to a speech generating service using the [tts_wrapper](https://github.com/willwade/tts-wrapper) library. This library provides unified access to multiple TTS engines: + * **Online Services**: + * Microsoft Azure TTS + * Amazon Polly + * Google Cloud TTS + * IBM Watson + * ElevenLabs + * Wit.Ai + * Play.HT + * **Offline Services**: + * eSpeak-NG + * AVSynth (macOS only) + * SAPI (Windows only) + * Sherpa-ONNX (supports Piper and other ONNX models) + * **Experimental**: + * PicoTTS + * UWP (WinRT) Speech system (Windows 10+) + + Speech providers can have two types: * **type "playing"**: a speech provider where playing the audio file is done internally. Using a speech provider of this type only makes sense, if it's used on the same machine as AsTeRICS Grid. * **type "data"**: a speech provider that generates the speech audio data, which then is used by AsTeRICS Grid and played within the browser. This type is preferable, because it makes it possible to run the speech service on any device or server and also allows caching of the data. ### Installation and Usage #### Speech Service These steps are necessary to start the speech service that can be used by AsTeRICS Grid: -* `pip install flask flask_cors` - for installing Flask, which is needed for providing the REST API -* `pip install pyttsx3` - only if you want to try the speech provider `provider_pytts_playing.py` which is configured by default in `config.py`, otherwise install any other dependencies needed by the used speech providers, see [predefined speech providers](#speech-providers). -* adapt [config.py](https://github.com/asterics/AsTeRICS-Grid-Helper/blob/main/speech/config.py) for using the desired speech providers by importing them and adding them to the list `speechProviderList`. -* `python start.py` - to start the REST API + +1. Install Python dependencies: + ```bash + pip install flask flask_cors + ``` + +2. Install tts_wrapper with the required engines: + ```bash + # For all platforms (includes platform-specific engines) + pip install "py3-tts-wrapper[espeak,avsynth,sapi]" + + # For specific platforms: + # Linux: pip install "py3-tts-wrapper[espeak]" + # macOS: pip install "py3-tts-wrapper[avsynth]" + # Windows: pip install "py3-tts-wrapper[sapi]" + ``` + +3. Install system dependencies if needed: + * Linux: + ```bash + sudo apt-get install portaudio19-dev + sudo apt install espeak-ng # For eSpeak support + ``` + * macOS: + ```bash + brew install portaudio + brew install espeak-ng # Optional, for eSpeak support + ``` + * Windows: No additional dependencies needed for SAPI + +4. Start the speech service: + ```bash + python speech/start.py + ``` + +The service will automatically select the appropriate TTS engine based on your platform: +- Linux: eSpeak-NG +- macOS: AVSynth +- Windows: SAPI #### AsTeRICS Grid In AsTeRICS Grid do the following steps to use the external speech provider: @@ -23,7 +76,7 @@ In AsTeRICS Grid do the following steps to use the external speech provider: * Configure the `External speech service URL` with the IP/host where the API is running, port `5555`. If the speech service is running on the same computer, use `http://localhost:5555`. * Reload AsTeRICS Grid (`F5`) * Go to `Settings -> User settings -> Voice` and enable `Show all voices` -* Verify that the additional voices are selectable and working. For the default `provider_pytts_playing` speech provider some voices like `, pytts_playing` should be listed. +* Verify that the additional voices are selectable and working. #### Caching For speech providers with type "data", all generated speech data is automatically cached to the folder `speech/temp`. If you want to cache speech data for a whole AsTeRICS Grid configuration follow these steps: @@ -35,19 +88,36 @@ These are the important files within the folder `speech` of this repository: * `config.py` configuration file where it's possible to define which speech providers should be used * `provider__playing.py` implementation of a speech provider which generates speech and plays audio on its own * `provider__data.py` implementation of a speech provider which generates speech audio data and returns the binary data, which then is played by AsTeRICS Grid within the browser +* `provider_platform_data.py` platform-specific provider that automatically selects the appropriate TTS engine * `start.py` main script providing a REST API which can be used by AsTeRICS Grid * `speechManager.py` script which manages different speech providers and is used to access them by the API defined in `start.py` ### Speech providers This is a list of predefined speech providers with installation hints: -* **mimic3_data**: see [Mimic 3 installation steps](https://mycroft-ai.gitbook.io/docs/mycroft-technologies/mimic-tts/mimic-3), install in any way which provides `mimic3` as CLI-tool, which is used by the speech provider. The current implementation only uses the voice `en_UK/apope_low`, for further voices the file `provider_mimic3_data.py` must be adapted. + +#### Platform-specific provider +The `platform_data` provider automatically selects the appropriate TTS engine based on your operating system: +* Linux: Uses eSpeak-NG +* macOS: Uses AVSynth +* Windows: Uses SAPI + +No additional configuration is needed - it works out of the box on all supported platforms. + +#### Other providers * **msazure_data, msazure_playing**: - * run `pip install azure-cognitiveservices-speech`, for further information see [MS Azure TTS quickstart](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/get-started-text-to-speech?tabs=windows%2Cterminal&pivots=programming-language-python) - * to get API credentials, you have to [sign-up at MS Azure](https://azure.microsoft.com/de-de/get-started/azure-portal) and create a `SpeechServices` resource. - * Create a file `speech/credentials.py` including two lines `AZURE_KEY_1 = ""` and `AZURE_REGION = ""` -* **piper_data**: run `pip install piper-tts`, for more information see [Running Piper in Python](https://github.com/rhasspy/piper?tab=readme-ov-file#running-in-python). -* **pytts_playing**: run `pip install pyttsx3` -* **elevenlabs_data** run `pip install requests` and create a file `speech/credentials.py` with `ELEVENLABS_KEY = ""`. Read [here how to get the API key](https://elevenlabs.io/docs/api-reference/text-to-speech#authentication). + * Requires Azure credentials in `speech/credentials.py`: + ```python + AZURE_KEY_1 = "" + AZURE_REGION = "" + ``` + * Get API credentials by [signing up at MS Azure](https://azure.microsoft.com/de-de/get-started/azure-portal) and creating a `SpeechServices` resource. +* **piper_data**: Uses Sherpa-ONNX engine from tts_wrapper to run Piper models. No additional setup required as it uses default model paths. +* **elevenlabs_data**: + * Requires ElevenLabs API key in `speech/credentials.py`: + ```python + ELEVENLABS_KEY = "" + ``` + * Get API key from [ElevenLabs](https://elevenlabs.io/docs/api-reference/text-to-speech#authentication) #### Configuration See [config.py](https://github.com/asterics/AsTeRICS-Grid-Helper/blob/main/speech/config.py), where the speech providers to use can be imported and added to the list `speechProviderList`. diff --git a/speech/config.py b/speech/config.py index 03db22a..4f43745 100644 --- a/speech/config.py +++ b/speech/config.py @@ -1,8 +1,6 @@ -import provider_pytts_playing -# import provider_msazure_playing -# import provider_msazure_data -# import provider_piper_data -# import provider_mimic3_data +import provider_test_data -speechProviderList = [provider_pytts_playing] +speechProviderList = [ + provider_test_data, +] cacheData = True diff --git a/speech/provider_base.py b/speech/provider_base.py new file mode 100644 index 0000000..6eb3385 --- /dev/null +++ b/speech/provider_base.py @@ -0,0 +1,65 @@ +from abc import ABC, abstractmethod +from typing import Optional, List, Dict, Any + + +class BaseProvider(ABC): + def __init__(self, provider_id: str, voice_type: str, tts_instance: Any): + self.provider_id = provider_id + self.voice_type = voice_type + self.tts = tts_instance + self.speaking = False + self.is_local = False + + def getProviderId(self) -> str: + return self.provider_id + + def getVoiceType(self) -> str: + return self.voice_type + + def getVoices(self) -> List[Dict[str, Any]]: + voices = self.tts.get_voices() + voice_list: List[Dict[str, Any]] = [] + for voice in voices: + # Handle different voice formats + if isinstance(voice, dict): + # For providers that return a dict with language_codes + if "language_codes" in voice and isinstance( + voice["language_codes"], dict + ): + lang_code = list(voice["language_codes"].keys())[0] + else: + lang_code = voice.get("language", "en") + voice_id = voice.get("id", "") + voice_name = voice.get("name", "") + else: + # For providers that return a simpler format (like eSpeak) + lang_code = "en" # Default to English + voice_id = str(voice) + voice_name = str(voice) + + voice_list.append( + { + "id": voice_id, + "name": voice_name, + "lang": lang_code, + "local": self.is_local, + } + ) + return voice_list + + @abstractmethod + def getSpeakData(self, text: str, voiceId: Optional[str] = None) -> bytes: + pass + + def speak(self, text: str, voiceId: Optional[str] = None) -> None: + if voiceId: + self.tts.set_voice(voiceId) + self.speaking = True + self.tts.speak(text) + self.speaking = False + + def isSpeaking(self) -> bool: + return self.speaking + + def stop(self) -> None: + self.tts.stop_audio() diff --git a/speech/provider_elevenlabs_data.py b/speech/provider_elevenlabs_data.py index 5a736ea..104ea17 100644 --- a/speech/provider_elevenlabs_data.py +++ b/speech/provider_elevenlabs_data.py @@ -1,65 +1,27 @@ -import requests # Used for making HTTP requests -import json # Used for working with JSON data -import constants import credentials -import util - -# Define constants for the script -CHUNK_SIZE = 1024 # Size of chunks to read/write at a time -XI_API_KEY = credentials.ELEVENLABS_KEY # Your API key for authentication +import constants +from typing import Optional +from tts_wrapper import ElevenLabsTTS, ElevenLabsClient +from provider_base import BaseProvider -providerId = "elevenlabs_data" -def getProviderId(): - return providerId +class ElevenLabsDataProvider(BaseProvider): + def __init__(self): + client = ElevenLabsClient(credentials=(credentials.ELEVENLABS_KEY,)) + tts = ElevenLabsTTS(client) + super().__init__("elevenlabs_data", constants.VOICE_TYPE_EXTERNAL_DATA, tts) -def getVoiceType(): - return constants.VOICE_TYPE_EXTERNAL_DATA + def getSpeakData(self, text: str, voiceId: Optional[str] = None) -> bytes: + if voiceId: + self.tts.set_voice(voiceId) + return self.tts.synth_to_bytes(text) -def getVoices(): - headers = { - "Accept": "application/json", - "xi-api-key": XI_API_KEY, - "Content-Type": "application/json" - } - response = requests.get("https://api.elevenlabs.io/v1/voices", headers=headers) - data = response.json() - list = [] - for voice in data['voices']: - list.append({"id": voice['voice_id'], "name": voice['name'], "lang": "de", "local": False}) # optional boolean property "local" to determine of online/offline voice - list.append({"id": voice['voice_id'], "name": voice['name'], "lang": "en", "local": False}) # optional boolean property "local" to determine of online/offline voice - return list +# Create a singleton instance +provider = ElevenLabsDataProvider() -def getSpeakData(text, voiceId=None): - # Construct the URL for the Text-to-Speech API request - tts_url = f"https://api.elevenlabs.io/v1/text-to-speech/{voiceId}/stream" - headers = { - "Accept": "application/json", - "xi-api-key": XI_API_KEY - } - data = { - "text": text, - "model_id": "eleven_multilingual_v2", - "voice_settings": { - "stability": 0.5, - "similarity_boost": 0.8, - "style": 0.0, - "use_speaker_boost": True - } - } - response = requests.post(tts_url, headers=headers, json=data, stream=True) - path = util.getTempFileFullPath(providerId) - if response.ok: - # Open the output file in write-binary mode - with open(path, "wb") as f: - # Read the response in chunks and write to the file - for chunk in response.iter_content(chunk_size=CHUNK_SIZE): - f.write(chunk) - # Inform the user of success - print("Audio stream saved successfully.") - return util.getTempFileData(providerId) - else: - # Print the error message if the request was not successful - print(response.text) - return None +# Export the interface functions +getProviderId = provider.getProviderId +getVoiceType = provider.getVoiceType +getVoices = provider.getVoices +getSpeakData = provider.getSpeakData diff --git a/speech/provider_espeak_data.py b/speech/provider_espeak_data.py new file mode 100644 index 0000000..212da12 --- /dev/null +++ b/speech/provider_espeak_data.py @@ -0,0 +1,27 @@ +import constants +from typing import Optional +from tts_wrapper import eSpeakTTS, eSpeakClient +from provider_base import BaseProvider + + +class eSpeakDataProvider(BaseProvider): + def __init__(self): + client = eSpeakClient() + tts = eSpeakTTS(client) + super().__init__("espeak_data", constants.VOICE_TYPE_EXTERNAL_DATA, tts) + self.is_local = True # eSpeak is always local + + def getSpeakData(self, text: str, voiceId: Optional[str] = None) -> bytes: + if voiceId: + self.tts.set_voice(voiceId) + return self.tts.synth_to_bytes(text) + + +# Create a singleton instance +provider = eSpeakDataProvider() + +# Export the interface functions +getProviderId = provider.getProviderId +getVoiceType = provider.getVoiceType +getVoices = provider.getVoices +getSpeakData = provider.getSpeakData diff --git a/speech/provider_msazure_data.py b/speech/provider_msazure_data.py index 3b01087..2624331 100644 --- a/speech/provider_msazure_data.py +++ b/speech/provider_msazure_data.py @@ -1,47 +1,29 @@ -import azure.cognitiveservices.speech as speechsdk import credentials import constants +from typing import Optional +from tts_wrapper import MicrosoftTTS, MicrosoftClient +from provider_base import BaseProvider -providerId = "azure_data" -speech_config = speechsdk.SpeechConfig(credentials.AZURE_KEY_1, credentials.AZURE_REGION) -speech_config.speech_synthesis_voice_name='en-US-JennyNeural' +class AzureDataProvider(BaseProvider): + def __init__(self): + client = MicrosoftClient( + credentials=(credentials.AZURE_KEY_1, credentials.AZURE_REGION) + ) + tts = MicrosoftTTS(client) + super().__init__("azure_data", constants.VOICE_TYPE_EXTERNAL_DATA, tts) -speech_synthesizer = speechsdk.SpeechSynthesizer(speech_config=speech_config, audio_config=None) -speaking = False + def getSpeakData(self, text: str, voiceId: Optional[str] = None) -> bytes: + if voiceId: + self.tts.set_voice(voiceId) + return self.tts.synth_to_bytes(text) -def getProviderId(): - return providerId -def getVoiceType(): - return constants.VOICE_TYPE_EXTERNAL_DATA +# Create a singleton instance +provider = AzureDataProvider() -def getVoices(): - list = [] - result = speech_synthesizer.get_voices_async().get() - voices = result.voices - - for voice in voices: - list.append({"id": voice.short_name, "name": voice.name, "lang": voice.locale}) - - return list - -def getSpeakData(text, voiceId=None): - global speech_synthesizer - if voiceId: - speech_config.speech_synthesis_voice_name = voiceId - - speech_synthesizer = speechsdk.SpeechSynthesizer(speech_config=speech_config, audio_config=None) - speech_synthesis_result = speech_synthesizer.speak_text_async(text).get() - errorHandling(speech_synthesis_result) - - return speech_synthesis_result.audio_data - -def errorHandling(result): - if result.reason == speechsdk.ResultReason.Canceled: - cancellation_details = result.cancellation_details - print("Speech synthesis canceled: {}".format(cancellation_details.reason)) - if cancellation_details.reason == speechsdk.CancellationReason.Error: - if cancellation_details.error_details: - print("Error details: {}".format(cancellation_details.error_details)) - print("Did you set the speech resource key and region values?") \ No newline at end of file +# Export the interface functions +getProviderId = provider.getProviderId +getVoiceType = provider.getVoiceType +getVoices = provider.getVoices +getSpeakData = provider.getSpeakData diff --git a/speech/provider_msazure_playing.py b/speech/provider_msazure_playing.py index 9c809ca..9ec976a 100644 --- a/speech/provider_msazure_playing.py +++ b/speech/provider_msazure_playing.py @@ -1,55 +1,30 @@ -import azure.cognitiveservices.speech as speechsdk import credentials import constants - -providerId = "azure_playing" - -speech_config = speechsdk.SpeechConfig(credentials.AZURE_KEY_1, credentials.AZURE_REGION) -speech_config.speech_synthesis_voice_name='en-US-JennyNeural' -audio_config = speechsdk.audio.AudioOutputConfig(use_default_speaker=True) - -speech_synthesizer = speechsdk.SpeechSynthesizer(speech_config=speech_config, audio_config=audio_config) -speaking = False - -def getProviderId(): - return providerId - -def getVoiceType(): - return constants.VOICE_TYPE_EXTERNAL_PLAYING - -def getVoices(): - list = [] - result = speech_synthesizer.get_voices_async().get() - voices = result.voices - - for voice in voices: - list.append({"id": voice.name, "name": voice.name, "lang": voice.locale}) - - return list - -def speak(text, voiceId=None): - global speaking, speech_synthesizer - if voiceId: - speech_config.speech_synthesis_voice_name = voiceId - - speech_synthesizer = speechsdk.SpeechSynthesizer(speech_config=speech_config, audio_config=audio_config) - speaking = True - speech_synthesis_result = speech_synthesizer.speak_text_async(text).get() - speaking = False - errorHandling(speech_synthesis_result) - -def isSpeaking(): - return speaking - -def stop(): - if speech_synthesizer: - speech_synthesizer.stop_speaking() - -def errorHandling(result): - if result.reason == speechsdk.ResultReason.Canceled: - cancellation_details = result.cancellation_details - print("Speech synthesis canceled: {}".format(cancellation_details.reason)) - if cancellation_details.reason == speechsdk.CancellationReason.Error: - if cancellation_details.error_details: - print("Error details: {}".format(cancellation_details.error_details)) - print("Did you set the speech resource key and region values?") \ No newline at end of file +from typing import Optional +from tts_wrapper import MicrosoftTTS, MicrosoftClient +from provider_base import BaseProvider + + +class AzurePlayingProvider(BaseProvider): + def __init__(self): + client = MicrosoftClient( + credentials=(credentials.AZURE_KEY_1, credentials.AZURE_REGION) + ) + tts = MicrosoftTTS(client) + super().__init__("azure_playing", constants.VOICE_TYPE_EXTERNAL_PLAYING, tts) + + def getSpeakData(self, text: str, voiceId: Optional[str] = None) -> bytes: + # This provider doesn't need to implement getSpeakData + raise NotImplementedError("This provider only supports playing") + + +# Create a singleton instance +provider = AzurePlayingProvider() + +# Export the interface functions +getProviderId = provider.getProviderId +getVoiceType = provider.getVoiceType +getVoices = provider.getVoices +speak = provider.speak +isSpeaking = provider.isSpeaking +stop = provider.stop diff --git a/speech/provider_piper_data.py b/speech/provider_piper_data.py index c167db9..f8ee9c7 100644 --- a/speech/provider_piper_data.py +++ b/speech/provider_piper_data.py @@ -1,23 +1,28 @@ -import os import constants -import util +from typing import Optional +from tts_wrapper import SherpaOnnxTTS, SherpaOnnxClient +from provider_base import BaseProvider -providerId = "piper_data" -def getProviderId(): - return providerId +class PiperDataProvider(BaseProvider): + def __init__(self): + client = SherpaOnnxClient( + model_path=None, tokens_path=None + ) # Will use default paths + tts = SherpaOnnxTTS(client) + super().__init__("piper_data", constants.VOICE_TYPE_EXTERNAL_DATA, tts) -def getVoiceType(): - return constants.VOICE_TYPE_EXTERNAL_DATA + def getSpeakData(self, text: str, voiceId: Optional[str] = None) -> bytes: + if voiceId: + self.tts.set_voice(voiceId) + return self.tts.synth_to_bytes(text) -def getVoices(): - list = [] - # add supported voices - list.append({"id": "my-voice", "name": "My voice", "lang": "en"}) - return list -def getSpeakData(text, voiceId=None): - # return byte array of data containing speech - path = util.getTempFileFullPath(providerId) - os.system("echo '{}' | piper --model en_US-lessac-medium --output_file {}".format(text, path)) - return util.getTempFileData(providerId) \ No newline at end of file +# Create a singleton instance +provider = PiperDataProvider() + +# Export the interface functions +getProviderId = provider.getProviderId +getVoiceType = provider.getVoiceType +getVoices = provider.getVoices +getSpeakData = provider.getSpeakData diff --git a/speech/provider_platform_data.py b/speech/provider_platform_data.py new file mode 100644 index 0000000..7cbd7f7 --- /dev/null +++ b/speech/provider_platform_data.py @@ -0,0 +1,84 @@ +import platform +import constants +from typing import Optional, Tuple, Any +from provider_base import BaseProvider +import struct + + +def add_wav_header(audio_data: bytes) -> bytes: + """Add WAV header to raw audio data.""" + # WAV header parameters + num_channels = 1 # mono + sample_width = 2 # 16-bit + sample_rate = 22050 # standard rate for most TTS engines + block_align = num_channels * sample_width + byte_rate = sample_rate * block_align + + # Create WAV header + header = struct.pack( + "<4sI4s4sIHHIIHH4sI", + b"RIFF", # ChunkID + 36 + len(audio_data), # ChunkSize + b"WAVE", # Format + b"fmt ", # Subchunk1ID + 16, # Subchunk1Size + 1, # AudioFormat (1 = PCM) + num_channels, # NumChannels + sample_rate, # SampleRate + byte_rate, # ByteRate + block_align, # BlockAlign + sample_width * 8, # BitsPerSample + b"data", # Subchunk2ID + len(audio_data), # Subchunk2Size + ) + + return header + audio_data + + +def get_platform_tts() -> Tuple[str, Any, Any]: + """Get the appropriate TTS client and engine for the current platform.""" + system = platform.system().lower() + + if system == "linux": + from tts_wrapper import eSpeakTTS, eSpeakClient + + client = eSpeakClient() + tts = eSpeakTTS(client) + return "platform_data", client, tts + elif system == "darwin": # macOS + from tts_wrapper import AVSynthTTS, AVSynthClient + + client = AVSynthClient() + tts = AVSynthTTS(client) + return "platform_data", client, tts + elif system == "windows": + from tts_wrapper import SAPITTS, SAPIClient + + client = SAPIClient() + tts = SAPITTS(client) + return "platform_data", client, tts + else: + raise NotImplementedError(f"Unsupported platform: {system}") + + +class PlatformDataProvider(BaseProvider): + def __init__(self): + provider_id, client, tts = get_platform_tts() + super().__init__(provider_id, constants.VOICE_TYPE_EXTERNAL_DATA, tts) + self.is_local = True # All platform-specific providers are local + + def getSpeakData(self, text: str, voiceId: Optional[str] = None) -> bytes: + if voiceId: + self.tts.set_voice(voiceId) + raw_data = self.tts.synth_to_bytes(text) + return add_wav_header(raw_data) + + +# Create a singleton instance +provider = PlatformDataProvider() + +# Export the interface functions +getProviderId = provider.getProviderId +getVoiceType = provider.getVoiceType +getVoices = provider.getVoices +getSpeakData = provider.getSpeakData diff --git a/speech/provider_template_data.py b/speech/provider_template_data.py index b8f2aa9..825ead5 100644 --- a/speech/provider_template_data.py +++ b/speech/provider_template_data.py @@ -1,26 +1,30 @@ # template for a speech provider returning binary data import constants +from typing import Optional, List, Dict, Any +from provider_base import BaseProvider -providerId = "fill_provider_id" -def getProviderId(): - return providerId +class TemplateDataProvider(BaseProvider): + def __init__(self): + # Initialize your TTS client and instance here + # Example: + # client = YourTTSClient() + # tts = YourTTSEngine(client) + # super().__init__("template_data", constants.VOICE_TYPE_EXTERNAL_DATA, tts) + raise NotImplementedError("Template provider - implement your TTS client") -def getVoiceType(): - return constants.VOICE_TYPE_EXTERNAL_DATA + def getSpeakData(self, text: str, voiceId: Optional[str] = None) -> bytes: + if voiceId: + self.tts.set_voice(voiceId) + return self.tts.synth_to_bytes(text) -def getVoices(): - list = [] - # add supported voices - list.append({"id": "my-voice", "name": "My voice", "lang": "en"}) # optional boolean property "local" to determine of online/offline voice - return list -def getSpeakData(text, voiceId=None): - # return byte array of data containing speech - # if your speech provider stores the speech data to file, you can use something like this: - # import util at the top - # path = util.getTempFileFullPath(providerId) - # os.system("shell command including {} and {}".format(text, path)) - # return util.getTempFileData(providerId) - return None \ No newline at end of file +# Create a singleton instance +provider = TemplateDataProvider() + +# Export the interface functions +getProviderId = provider.getProviderId +getVoiceType = provider.getVoiceType +getVoices = provider.getVoices +getSpeakData = provider.getSpeakData diff --git a/speech/provider_template_playing.py b/speech/provider_template_playing.py index dee66df..eb71b2f 100644 --- a/speech/provider_template_playing.py +++ b/speech/provider_template_playing.py @@ -1,29 +1,31 @@ # template for a speech provider that directly plays speech import constants - -providerId = "fill_provider_id" - -def getProviderId(): - return providerId - -def getVoiceType(): - return constants.VOICE_TYPE_EXTERNAL_PLAYING - -def getVoices(): - list = [] - # add supported voices - list.append({"id": "my-voice", "name": "My voice", "lang": "en"}) # optional boolean property "local" to determine of online/offline voice - return list - -def speak(text, voiceId=None): - # directly speak the text with the given voiceId - return - -def isSpeaking(): - # return True if currently speaking - return False - -def stop(): - # stop speaking - return \ No newline at end of file +from typing import Optional +from provider_base import BaseProvider + + +class TemplatePlayingProvider(BaseProvider): + def __init__(self): + # Initialize your TTS client and instance here + # Example: + # client = YourTTSClient() + # tts = YourTTSEngine(client) + # super().__init__("template_playing", constants.VOICE_TYPE_EXTERNAL_PLAYING, tts) + raise NotImplementedError("Template provider - implement your TTS client") + + def getSpeakData(self, text: str, voiceId: Optional[str] = None) -> bytes: + # This provider doesn't need to implement getSpeakData + raise NotImplementedError("This provider only supports playing") + + +# Create a singleton instance +provider = TemplatePlayingProvider() + +# Export the interface functions +getProviderId = provider.getProviderId +getVoiceType = provider.getVoiceType +getVoices = provider.getVoices +speak = provider.speak +isSpeaking = provider.isSpeaking +stop = provider.stop diff --git a/speech/provider_test_data.py b/speech/provider_test_data.py new file mode 100644 index 0000000..9df160b --- /dev/null +++ b/speech/provider_test_data.py @@ -0,0 +1,27 @@ +import constants +from typing import Optional +from tts_wrapper import eSpeakTTS, eSpeakClient +from provider_base import BaseProvider + + +class TestDataProvider(BaseProvider): + def __init__(self): + client = eSpeakClient() + tts = eSpeakTTS(client) + super().__init__("test_data", constants.VOICE_TYPE_EXTERNAL_DATA, tts) + self.is_local = True # eSpeak is always local + + def getSpeakData(self, text: str, voiceId: Optional[str] = None) -> bytes: + if voiceId: + self.tts.set_voice(voiceId) + return self.tts.synth_to_bytes(text) + + +# Create a singleton instance +provider = TestDataProvider() + +# Export the interface functions +getProviderId = provider.getProviderId +getVoiceType = provider.getVoiceType +getVoices = provider.getVoices +getSpeakData = provider.getSpeakData diff --git a/speech/speechManager.py b/speech/speechManager.py index 6a7bb94..be13c5f 100644 --- a/speech/speechManager.py +++ b/speech/speechManager.py @@ -1,89 +1,84 @@ -import config -import constants -import util - -requiredFnsAll = ["getProviderId", "getVoiceType", "getVoices"] -requiredFnsPlaying = ["speak", "isSpeaking", "stop"] -requiredFnsData = ["getSpeakData"] -requiredVoiceKeys = ["id", "name"] - -speechProviders = {} - -def speak(text, providerId, voiceId=None): - provider = speechProviders[providerId] if providerId in speechProviders else config.speechProviderList[0] - if not hasattr(provider, "speak"): - return print("ERROR: speech provider '{}' doesn't implement function 'speak'!".format(providerId)) - provider.speak(text, voiceId) - -def getSpeakData(text, providerId, voiceId=None): - if config.cacheData: - cachedData = util.getCacheData(text, providerId, voiceId) - if cachedData: - return cachedData - provider = speechProviders[providerId] if providerId in speechProviders else config.speechProviderList[0] - if not hasattr(provider, "getSpeakData"): - return print("ERROR: speech provider '{}' doesn't implement function 'getSpeakData'!".format(providerId)) - data = provider.getSpeakData(text, voiceId) - if config.cacheData and data and len(data) > 0: - util.saveCacheData(text, providerId, voiceId, data) - return data - -def isSpeaking(): - for provider in speechProviders.values(): - if hasattr(provider, "isSpeaking") and provider.isSpeaking(): - return True - return False - -def stop(): - for provider in speechProviders.values(): - if hasattr(provider, "stop"): - provider.stop() - -def getVoices(): - allVoices = [] - for provider in config.speechProviderList: - voices = provider.getVoices() - for voice in voices: - voice["providerId"] = provider.getProviderId() - voice["type"] = provider.getVoiceType() - voice["name"] = voice["name"] + ", " + provider.getProviderId() - allVoices.append(voice) - - return allVoices - -def initProviders(): - for provider in config.speechProviderList: - id = provider.getProviderId() if hasattr(provider, "getProviderId") else "fill_provider_id" - if id == "fill_provider_id": - raise Exception("ERROR: '{}' is an invalid provider ID!".format(id)) - - if id in speechProviders: - raise Exception("ERROR: duplicated speech provider with ID '{}'!".format(id)) - - for fnName in requiredFnsAll: - if not hasattr(provider, fnName): - raise Exception("ERROR: speech provider '{}' doesn't implement function '{}'!".format(id, fnName)) - - voiceType = provider.getVoiceType() - additionalRequiredFns = None - if voiceType == constants.VOICE_TYPE_EXTERNAL_PLAYING: - additionalRequiredFns = requiredFnsPlaying - elif voiceType == constants.VOICE_TYPE_EXTERNAL_DATA: - additionalRequiredFns = requiredFnsData - else: - raise Exception("ERROR: voice type of provider '{}' invalid (must be '{}' or '{}')!".format(id, constants.VOICE_TYPE_EXTERNAL_PLAYING, constants.VOICE_TYPE_EXTERNAL_DATA)) - - for fnName in additionalRequiredFns: - if not hasattr(provider, fnName): - raise Exception("ERROR: speech provider '{}' doesn't implement function '{}'!".format(id, fnName)) - - voices = provider.getVoices() - for voice in voices: - name = voice["id"] or voice["name"] or "noVoiceName" - for key in requiredVoiceKeys: - if key not in voice: - raise Exception("ERROR: voice '{}' of provider '{}' doesn't have required key '{}'!".format(name, id, key)) - - speechProviders[id] = provider # store in map - -initProviders() +from typing import Optional, List, Dict, Any +from provider_platform_data import provider as platform_provider +from provider_test_data import provider as test_provider + +# List of all available providers +providers = [ + platform_provider, # Platform-specific provider (eSpeak/AVSynth/SAPI) + test_provider, +] + +# Try to load optional providers +try: + from provider_elevenlabs_data import provider as elevenlabs_provider + + providers.append(elevenlabs_provider) +except ImportError: + print("Note: ElevenLabs provider not available (credentials missing)") + +try: + from provider_espeak_data import provider as espeak_provider + + providers.append(espeak_provider) +except Exception as e: + print("Note: eSpeak provider not available:", str(e)) + +# Default provider is the platform-specific one +defaultProvider = platform_provider + + +def getVoices() -> List[Dict[str, Any]]: + """Get all available voices from all providers.""" + voices = [] + for provider in providers: + try: + providerVoices = provider.getVoices() + for voice in providerVoices: + voice["providerId"] = provider.getProviderId() + voice["type"] = provider.getVoiceType() + voices.append(voice) + except Exception as e: + provider_id = provider.getProviderId() + print(f"Error getting voices from provider {provider_id}: {str(e)}") + return voices + + +def getSpeakData( + text: str, voiceId: str, providerId: Optional[str] = None +) -> Optional[bytes]: + """Get speech data for the given text and voice ID.""" + if providerId is None: + providerId = defaultProvider.getProviderId() + + for provider in providers: + if provider.getProviderId() == providerId: + return provider.getSpeakData(text, voiceId) + return None + + +def speak(text: str, providerId: str, voiceId: Optional[str] = None) -> None: + """Speak the given text using the specified provider and voice.""" + if providerId not in [p.getProviderId() for p in providers]: + print(f"ERROR: Unknown speech provider '{providerId}'!") + return + + for provider in providers: + if provider.getProviderId() == providerId: + if voiceId: + provider.tts.set_voice(voiceId) + provider.tts.speak(text) + return + + +def initProviders() -> None: + """Initialize all speech providers.""" + for provider in providers: + provider_id = provider.getProviderId() + if not all( + hasattr(provider, fn) + for fn in ["getProviderId", "getVoiceType", "getVoices"] + ): + print( + f"ERROR: speech provider '{provider_id}' is missing required functions!" + ) + continue diff --git a/speech/start.py b/speech/start.py index 1e88591..6695c89 100644 --- a/speech/start.py +++ b/speech/start.py @@ -1,45 +1,56 @@ #!/usr/bin/env python -from flask import Flask, jsonify, request, make_response +import os +import sys +from flask import Flask, jsonify, request, make_response, send_file from flask_cors import CORS from urllib.parse import unquote +from io import BytesIO import speechManager import config +# Add the parent directory to the Python path +current_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if current_dir not in sys.path: + sys.path.insert(0, current_dir) + app = Flask(__name__) app.url_map.strict_slashes = False CORS(app) -@app.route('/voices/', methods=['GET']) + +@app.route("/voices/", methods=["GET"]) def voices(): voices = speechManager.getVoices() return jsonify(voices) -@app.route('/speak//', methods=['POST', 'GET']) -@app.route('/speak///', methods=['POST', 'GET']) -@app.route('/speak///', methods=['POST', 'GET']) -def speak(text, providerId="", voiceId=""): +@app.route("/speak//", methods=["POST", "GET"]) +@app.route("/speak///", methods=["POST", "GET"]) +@app.route("/speak///", methods=["POST", "GET"]) +def speak_text(text: str, providerId: str = "", voiceId: str = ""): text = unquote(text).lower() providerId = unquote(providerId) voiceId = unquote(voiceId) speechManager.speak(text, providerId, voiceId) return jsonify(True) -@app.route('/speakdata//', methods=['POST', 'GET']) -@app.route('/speakdata///', methods=['POST', 'GET']) -@app.route('/speakdata///', methods=['POST', 'GET']) -def speakData(text, providerId="", voiceId=""): + +@app.route("/speakdata//", methods=["POST", "GET"]) +@app.route("/speakdata///", methods=["POST", "GET"]) +@app.route("/speakdata///", methods=["POST", "GET"]) +def speakData(text: str, providerId: str = "", voiceId: str = ""): text = unquote(text).lower() providerId = unquote(providerId) voiceId = unquote(voiceId) - data = speechManager.getSpeakData(text, providerId, voiceId) - response = make_response(data) - response.headers.set('Content-Type', 'application/octet-stream') - return response + data = speechManager.getSpeakData(text, voiceId, providerId) + if data: + return send_file(BytesIO(data), mimetype="audio/wav") + return "Error generating speech", 400 -@app.route('/cache///', methods=['POST', 'GET']) -def cacheData(text, providerId="", voiceId=""): + +@app.route("/cache///", methods=["POST", "GET"]) +def cacheData(text: str, providerId: str = "", voiceId: str = ""): if not config.cacheData: return jsonify(False) text = unquote(text).lower() @@ -48,16 +59,18 @@ def cacheData(text, providerId="", voiceId=""): speechManager.getSpeakData(text, providerId, voiceId) return jsonify(True) -@app.route('/speaking/', methods=['GET']) + +@app.route("/speaking/", methods=["GET"]) def speaking(): speaking = speechManager.isSpeaking() return jsonify(speaking) -@app.route('/stop/', methods=['GET', 'POST']) +@app.route("/stop/", methods=["GET", "POST"]) def stop(): speechManager.stop() return jsonify(True) -if __name__ == '__main__': - app.run(host='0.0.0.0', port=5555, threaded=True) + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5555, threaded=True) diff --git a/speech/test.py b/speech/test.py new file mode 100644 index 0000000..cf81567 --- /dev/null +++ b/speech/test.py @@ -0,0 +1,56 @@ +import os +import sys + +# Add the current directory to the Python path +current_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if current_dir not in sys.path: + sys.path.insert(0, current_dir) + +# Now we can import our modules +from speech.provider_platform_data import provider + + +def test_voices(): + print("\nTesting getVoices()...") + voices = provider.getVoices() + print(f"Found {len(voices)} voices:") + for voice in voices: + print(f"- {voice['name']} ({voice['lang']})") + return True + + +def test_speak_data(): + print("\nTesting getSpeakData()...") + # Get the first available voice + voices = provider.getVoices() + if not voices: + print("No voices available!") + return False + + voice_id = voices[0]["id"] + print(f"Using voice: {voices[0]['name']}") + + # Get speech data for "hello" + data = provider.getSpeakData("hello", voice_id) + print(f"Got {len(data)} bytes of audio data") + return True + + +def main(): + print("Starting speech system tests...") + + # Test voice listing + if not test_voices(): + print("❌ Voice listing test failed!") + return + + # Test speech data generation + if not test_speak_data(): + print("❌ Speech data test failed!") + return + + print("\n✅ All tests passed!") + + +if __name__ == "__main__": + main() diff --git a/speech/util.py b/speech/util.py index 77e170c..3582318 100644 --- a/speech/util.py +++ b/speech/util.py @@ -1,11 +1,14 @@ import os from urllib.parse import quote +from typing import Optional -def getTempFilename(providerId): + +def getTempFilename(providerId: str) -> str: providerId = getSafeString(providerId) return "temp_{}.wav".format(providerId) -def getTempFileFullPath(providerId): + +def getTempFileFullPath(providerId: str) -> str: providerId = getSafeString(providerId) currentDir = os.path.dirname(__file__) dirname = os.path.join(currentDir, "temp") @@ -13,7 +16,8 @@ def getTempFileFullPath(providerId): os.mkdir(dirname) return os.path.join(dirname, getTempFilename(providerId)) -def getCacheFileFullPath(text, providerId, voiceId): + +def getCacheFileFullPath(text: str, providerId: str, voiceId: str) -> str: providerId = getSafeString(providerId) text = quote(text) voiceId = getSafeString(str(voiceId)) @@ -23,28 +27,27 @@ def getCacheFileFullPath(text, providerId, voiceId): os.makedirs(dirname) return os.path.join(dirname, "{}.wav".format(text)) -def getTempFileData(providerId): + +def getTempFileData(providerId: str) -> bytes: path = getTempFileFullPath(providerId) - in_file = open(path, "rb") - data = in_file.read() - in_file.close() - return data + with open(path, "rb") as in_file: + return in_file.read() -def saveCacheData(text, providerId, voiceId, data): + +def saveCacheData(text: str, providerId: str, voiceId: str, data: bytes) -> None: path = getCacheFileFullPath(text, providerId, voiceId) - out_file = open(path, "wb") - out_file.write(data) - out_file.close() + with open(path, "wb") as out_file: + out_file.write(data) + -def getCacheData(text, providerId, voiceId): - data = None +def getCacheData(text: str, providerId: str, voiceId: str) -> Optional[bytes]: path = getCacheFileFullPath(text, providerId, voiceId) if os.path.isfile(path): - in_file = open(path, "rb") - data = in_file.read() - in_file.close() - return data + with open(path, "rb") as in_file: + return in_file.read() + return None + -def getSafeString(string): - keepcharacters = (' ','.','_', '-') +def getSafeString(string: str) -> str: + keepcharacters = (" ", ".", "_", "-") return "".join(c for c in string if c.isalnum() or c in keepcharacters).rstrip() From f25ab07eb2d869d1d7082344a07739198195a868 Mon Sep 17 00:00:00 2001 From: will wade Date: Tue, 18 Mar 2025 21:09:04 +0000 Subject: [PATCH 02/37] adding build script --- .gitignore | 6 +- README.md | 69 +++++++++---- asterics-grid-speech-mac.spec | 44 +++++++++ build.py | 177 ++++++++++++++++++++++++++++++++++ 4 files changed, 278 insertions(+), 18 deletions(-) create mode 100644 asterics-grid-speech-mac.spec create mode 100644 build.py diff --git a/.gitignore b/.gitignore index 306cc0d..d310c64 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +.DS_Store +build/ +dist/ + *.iml bin/ node_modules/ @@ -7,4 +11,4 @@ credentials.py *.mp3 *.pyc *.json -*.onnx \ No newline at end of file +*.onnx diff --git a/README.md b/README.md index 5562b99..7968f90 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,55 @@ # AsTeRICS-Grid-Helper Helper tools to enable [AsTeRICS Grid](https://github.com/asterics/AsTeRICS-Grid) to do actions on the operating system or integrations with external services, which aren't possible within the browser. Currently limited to provide speech from external sources. +## Quick Start with Pre-built Binaries +The easiest way to get started is to use our pre-built binaries. These are standalone executables that don't require Python installation: + +1. Download the appropriate binary for your platform from the [releases page](https://github.com/asterics/AsTeRICS-Grid-Helper/releases): + - Windows: `asterics-grid-speech.exe` + - macOS: `asterics-grid-speech-mac.app` or `asterics-grid-speech-mac` + - Linux: `asterics-grid-speech` + +2. Run the executable + - Windows: Double-click `asterics-grid-speech.exe` + - macOS: Double-click `asterics-grid-speech-mac.app` or run `./asterics-grid-speech-mac` in Terminal + - Linux: Run `./asterics-grid-speech` in Terminal + +3. The service will start automatically on port 5555 + +4. In AsTeRICS Grid: + - Go to `Settings -> General Settings -> Advanced general settings` + - Set the `External speech service URL` to: `http://localhost:5555` + - Reload AsTeRICS Grid (`F5`) + - Go to `Settings -> User settings -> Voice` and enable `Show all voices` + +The service will automatically select the appropriate TTS engine for your platform: +- Windows: SAPI (Windows Speech API) +- macOS: AVSynth (macOS Text-to-Speech) +- Linux: eSpeak-NG + +Note: The first run may take a few seconds as it initializes the speech engine. + +## Building from Source +If you want to build the binaries yourself or modify the code: + +1. Clone the repository: + ```bash + git clone https://github.com/asterics/AsTeRICS-Grid-Helper.git + cd AsTeRICS-Grid-Helper + ``` + +2. Install Python 3.8 or later + +3. Run the build script: + ```bash + python build.py + ``` + +4. The executable will be created in the `dist` directory: + - Windows: `dist/asterics-grid-speech.exe` + - macOS: `dist/asterics-grid-speech-mac.app` and `dist/asterics-grid-speech-mac` + - Linux: `dist/asterics-grid-speech` + ## Speech Normally AsTeRICS Grid uses the [Web Speech API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API) and therefore voices that are installed on the operating system (e.g. SAPI voices on Windows, or voices that are coming from a TTS module on Android). Sometimes it's interesting to use voices, which aren't available as system voices. This section describes how to use an external custom speech service using Python. @@ -27,9 +76,8 @@ Normally AsTeRICS Grid uses the [Web Speech API](https://developer.mozilla.org/e * **type "playing"**: a speech provider where playing the audio file is done internally. Using a speech provider of this type only makes sense, if it's used on the same machine as AsTeRICS Grid. * **type "data"**: a speech provider that generates the speech audio data, which then is used by AsTeRICS Grid and played within the browser. This type is preferable, because it makes it possible to run the speech service on any device or server and also allows caching of the data. -### Installation and Usage -#### Speech Service -These steps are necessary to start the speech service that can be used by AsTeRICS Grid: +### Manual Installation and Usage +If you prefer to run the service directly without using pre-built binaries: 1. Install Python dependencies: ```bash @@ -65,20 +113,7 @@ These steps are necessary to start the speech service that can be used by AsTeRI python speech/start.py ``` -The service will automatically select the appropriate TTS engine based on your platform: -- Linux: eSpeak-NG -- macOS: AVSynth -- Windows: SAPI - -#### AsTeRICS Grid -In AsTeRICS Grid do the following steps to use the external speech provider: -* Go to `Settings -> General Settings -> Advanced general settings` -* Configure the `External speech service URL` with the IP/host where the API is running, port `5555`. If the speech service is running on the same computer, use `http://localhost:5555`. -* Reload AsTeRICS Grid (`F5`) -* Go to `Settings -> User settings -> Voice` and enable `Show all voices` -* Verify that the additional voices are selectable and working. - -#### Caching +### Caching For speech providers with type "data", all generated speech data is automatically cached to the folder `speech/temp`. If you want to cache speech data for a whole AsTeRICS Grid configuration follow these steps: * configure AsTeRICS Grid to use your desired speech provider / voice (see steps above) * go to `Settings -> User settings -> Voice -> Advanced voice settings` and click the button `Cache all texts of current configuration using external voice`. This operation may take some time for big AsTeRICS Grid configurations. diff --git a/asterics-grid-speech-mac.spec b/asterics-grid-speech-mac.spec new file mode 100644 index 0000000..c8fa31b --- /dev/null +++ b/asterics-grid-speech-mac.spec @@ -0,0 +1,44 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['speech/start.py'], + pathex=[], + binaries=[], + datas=[('speech/temp', 'temp')], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=['azure', 'google', 'boto3', 'botocore', 'wit', 'elevenlabs', 'sherpa_onnx'], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='asterics-grid-speech-mac', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) +app = BUNDLE( + exe, + name='asterics-grid-speech-mac.app', + icon=None, + bundle_identifier=None, +) diff --git a/build.py b/build.py new file mode 100644 index 0000000..a36cb97 --- /dev/null +++ b/build.py @@ -0,0 +1,177 @@ +import os +import platform +import subprocess +import sys +from pathlib import Path +import site + + +def find_package_path(package_name): + """Find the installation path of a package""" + for path in site.getsitepackages(): + package_path = os.path.join(path, package_name) + if os.path.exists(package_path): + return package_path + return None + + +def install_requirements(): + """Install required packages for building""" + subprocess.check_call([sys.executable, "-m", "pip", "install", "pyinstaller"]) + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "flask", "flask_cors"] + ) + + # Install only the platform-specific TTS engine + current_platform = platform.system().lower() + if current_platform == "darwin": + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "py3-tts-wrapper[avsynth]"] + ) + elif current_platform == "windows": + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "py3-tts-wrapper[sapi]"] + ) + else: # linux + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "py3-tts-wrapper[espeak]"] + ) + + +def build_executable(): + """Build the executable using PyInstaller""" + # Get the current platform + current_platform = platform.system().lower() + + # Define platform-specific options + platform_options = { + "darwin": { + "name": "asterics-grid-speech-mac", + "icon": ( + "speech/assets/icon.icns" + if os.path.exists("speech/assets/icon.icns") + else None + ), + "engine_path": "tts_wrapper/engines/avsynth", + }, + "windows": { + "name": "asterics-grid-speech.exe", + "icon": ( + "speech/assets/icon.ico" + if os.path.exists("speech/assets/icon.ico") + else None + ), + "engine_path": "tts_wrapper/engines/sapi", + }, + "linux": { + "name": "asterics-grid-speech", + "icon": ( + "speech/assets/icon.png" + if os.path.exists("speech/assets/icon.png") + else None + ), + "engine_path": "tts_wrapper/engines/espeak", + }, + } + + options = platform_options.get( + current_platform, + {"name": "asterics-grid-speech", "icon": None, "engine_path": None}, + ) + + # Find the tts_wrapper package path + tts_wrapper_path = find_package_path("tts_wrapper") + if tts_wrapper_path and options["engine_path"]: + engine_path = os.path.join(tts_wrapper_path, options["engine_path"]) + data_option = f"{engine_path}{os.pathsep}{options['engine_path']}" + else: + data_option = None + + # Base PyInstaller command + cmd = [ + "pyinstaller", + "--name", + options["name"], + "--onefile", + "--noconsole", # Hide console window + "--clean", # Clean PyInstaller cache + "--add-data", + "speech/temp:temp", # Include temp directory + ] + + # Add engine files if found + if data_option: + cmd.extend(["--add-data", data_option]) + + # Add platform-specific options + if options["icon"]: + cmd.extend(["--icon", options["icon"]]) + + # Add the main script and exclusions + cmd.extend( + [ + # Exclude unnecessary modules and their dependencies + "--exclude-module", + "azure", + "--exclude-module", + "google", + "--exclude-module", + "boto3", + "--exclude-module", + "botocore", + "--exclude-module", + "wit", + "--exclude-module", + "elevenlabs", + "--exclude-module", + "sherpa_onnx", + "speech/start.py", + ] + ) + + # Run PyInstaller + subprocess.check_call(cmd) + + # Create a simple README in the dist directory + dist_dir = Path("dist") + readme_path = dist_dir / "README.txt" + + with open(readme_path, "w") as f: + f.write( + """AsTeRICS Grid Speech Service +=========================== + +This is a standalone speech service for AsTeRICS Grid. + +Usage: +1. Run the executable +2. The service will start automatically on port 5555 +3. In AsTeRICS Grid, go to Settings -> General Settings -> Advanced general settings +4. Set the External speech service URL to: http://localhost:5555 +5. Reload AsTeRICS Grid (F5) +6. Go to Settings -> User settings -> Voice and enable "Show all voices" + +The service will automatically select the appropriate TTS engine for your platform: +- Windows: SAPI (Windows Speech API) +- macOS: AVSynth (macOS Text-to-Speech) +- Linux: eSpeak-NG + +Note: The first run may take a few seconds as it initializes the speech engine. +""" + ) + + +def main(): + """Main build process""" + print("Installing requirements...") + install_requirements() + + print("Building executable...") + build_executable() + + print("\nBuild complete! The executable can be found in the 'dist' directory.") + print("Please test the executable to ensure it works correctly.") + + +if __name__ == "__main__": + main() From b5a1614dd459a571185051b5cbb37d1c7695c0fd Mon Sep 17 00:00:00 2001 From: will wade Date: Tue, 18 Mar 2025 21:16:12 +0000 Subject: [PATCH 03/37] attempts to include library... --- build.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/build.py b/build.py index a36cb97..184c0cf 100644 --- a/build.py +++ b/build.py @@ -52,7 +52,7 @@ def build_executable(): if os.path.exists("speech/assets/icon.icns") else None ), - "engine_path": "tts_wrapper/engines/avsynth", + "engine": "avsynth", }, "windows": { "name": "asterics-grid-speech.exe", @@ -61,7 +61,7 @@ def build_executable(): if os.path.exists("speech/assets/icon.ico") else None ), - "engine_path": "tts_wrapper/engines/sapi", + "engine": "sapi", }, "linux": { "name": "asterics-grid-speech", @@ -70,22 +70,30 @@ def build_executable(): if os.path.exists("speech/assets/icon.png") else None ), - "engine_path": "tts_wrapper/engines/espeak", + "engine": "espeak", }, } options = platform_options.get( current_platform, - {"name": "asterics-grid-speech", "icon": None, "engine_path": None}, + {"name": "asterics-grid-speech", "icon": None, "engine": None}, ) # Find the tts_wrapper package path tts_wrapper_path = find_package_path("tts_wrapper") - if tts_wrapper_path and options["engine_path"]: - engine_path = os.path.join(tts_wrapper_path, options["engine_path"]) - data_option = f"{engine_path}{os.pathsep}{options['engine_path']}" + if tts_wrapper_path: + # Include the entire tts_wrapper package + data_option = f"{tts_wrapper_path}{os.pathsep}tts_wrapper" + + # Include the specific engine files + engine_path = os.path.join(tts_wrapper_path, "engines", options["engine"]) + if os.path.exists(engine_path): + engine_option = ( + f"{engine_path}{os.pathsep}tts_wrapper/engines/{options['engine']}" + ) else: data_option = None + engine_option = None # Base PyInstaller command cmd = [ @@ -99,10 +107,14 @@ def build_executable(): "speech/temp:temp", # Include temp directory ] - # Add engine files if found + # Add tts_wrapper package if found if data_option: cmd.extend(["--add-data", data_option]) + # Add engine files if found + if engine_option: + cmd.extend(["--add-data", engine_option]) + # Add platform-specific options if options["icon"]: cmd.extend(["--icon", options["icon"]]) From d20952d039c83305e471db562188be0139bafbab Mon Sep 17 00:00:00 2001 From: will wade Date: Wed, 19 Mar 2025 06:20:05 +0000 Subject: [PATCH 04/37] ruff & black lint fixes --- speech/constants.py | 2 +- speech/provider_mimic3_data.py | 4 ++++ speech/provider_pytts_playing.py | 10 ++++++++-- speech/provider_template_data.py | 3 +-- speech/provider_template_playing.py | 1 - speech/start.py | 2 +- speech/tests/coqui_test.py | 13 +++++++++---- 7 files changed, 24 insertions(+), 11 deletions(-) diff --git a/speech/constants.py b/speech/constants.py index 264004b..2d14b6a 100644 --- a/speech/constants.py +++ b/speech/constants.py @@ -1,2 +1,2 @@ VOICE_TYPE_EXTERNAL_PLAYING = "VOICE_TYPE_EXTERNAL_PLAYING" -VOICE_TYPE_EXTERNAL_DATA = "VOICE_TYPE_EXTERNAL_DATA" \ No newline at end of file +VOICE_TYPE_EXTERNAL_DATA = "VOICE_TYPE_EXTERNAL_DATA" diff --git a/speech/provider_mimic3_data.py b/speech/provider_mimic3_data.py index 9cd7db6..8d7a083 100644 --- a/speech/provider_mimic3_data.py +++ b/speech/provider_mimic3_data.py @@ -4,18 +4,22 @@ providerId = "mimic3_data" + def getProviderId(): return providerId + def getVoiceType(): return constants.VOICE_TYPE_EXTERNAL_DATA + def getVoices(): list = [] # add supported voices list.append({"id": "mimic3-voice", "name": "Mimic3 voice", "lang": "en"}) return list + def getSpeakData(text, voiceId=None): # return byte array of data containing speech path = util.getTempFileFullPath(providerId) diff --git a/speech/provider_pytts_playing.py b/speech/provider_pytts_playing.py index 16ef016..3b34551 100644 --- a/speech/provider_pytts_playing.py +++ b/speech/provider_pytts_playing.py @@ -4,12 +4,15 @@ engine = pyttsx3.init() speaking = False + def getProviderId(): return "pytts_playing" + def getVoiceType(): return constants.VOICE_TYPE_EXTERNAL_PLAYING + def speak(text, voiceId=None): global speaking stop() @@ -25,17 +28,20 @@ def speak(text, voiceId=None): engine.endLoop() speaking = False + def isSpeaking(): # return engine.isBusy() # always returns True on Windows?! return speaking + def stop(): engine.stop() + def getVoices(): list = [] - voices = engine.getProperty('voices') + voices = engine.getProperty("voices") for voice in voices: list.append({"id": voice.id, "name": voice.name}) - return list \ No newline at end of file + return list diff --git a/speech/provider_template_data.py b/speech/provider_template_data.py index 825ead5..0501d08 100644 --- a/speech/provider_template_data.py +++ b/speech/provider_template_data.py @@ -1,7 +1,6 @@ # template for a speech provider returning binary data -import constants -from typing import Optional, List, Dict, Any +from typing import Optional from provider_base import BaseProvider diff --git a/speech/provider_template_playing.py b/speech/provider_template_playing.py index eb71b2f..7a1d50b 100644 --- a/speech/provider_template_playing.py +++ b/speech/provider_template_playing.py @@ -1,6 +1,5 @@ # template for a speech provider that directly plays speech -import constants from typing import Optional from provider_base import BaseProvider diff --git a/speech/start.py b/speech/start.py index 6695c89..d25ca24 100644 --- a/speech/start.py +++ b/speech/start.py @@ -2,7 +2,7 @@ import os import sys -from flask import Flask, jsonify, request, make_response, send_file +from flask import Flask, jsonify, send_file from flask_cors import CORS from urllib.parse import unquote from io import BytesIO diff --git a/speech/tests/coqui_test.py b/speech/tests/coqui_test.py index 3b898c9..a373da5 100644 --- a/speech/tests/coqui_test.py +++ b/speech/tests/coqui_test.py @@ -3,8 +3,8 @@ import os dirname = os.path.dirname(__file__) -filename = os.path.join(dirname, 'trump.wav') -output = os.path.join(dirname, 'output_trump.wav') +filename = os.path.join(dirname, "trump.wav") +output = os.path.join(dirname, "output_trump.wav") # Get device device = "cuda" if torch.cuda.is_available() else "cpu" @@ -16,11 +16,16 @@ tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(device) # tts = TTS(model_name="tts_models/de/thorsten/tacotron2-DDC", progress_bar=False) # Run TTS -#tts.tts_to_file(text="Hallo mein Name ist Max. Ich teste nun die Sprachausgabe.", file_path=output) +# tts.tts_to_file(text="Hallo mein Name ist Max. Ich teste nun die Sprachausgabe.", file_path=output) # Run TTS # ❗ Since this model is multi-lingual voice cloning model, we must set the target speaker_wav and language # Text to speech list of amplitude values as output # wav = tts.tts(text="Hello world!", speaker_wav=filename, language="de") # Text to speech to a file -tts.tts_to_file(text="Hello, and good morning. I am Donald Trump, president of the United States. I want to note that your work at the University of Applied Sciences Technikum Wien is really amazing. Keep on going!", speaker_wav=filename, language="en", file_path=output) \ No newline at end of file +tts.tts_to_file( + text="Hello, and good morning. I am Donald Trump, president of the United States. I want to note that your work at the University of Applied Sciences Technikum Wien is really amazing. Keep on going!", + speaker_wav=filename, + language="en", + file_path=output, +) From f6ce13a13999f5a7038d36ef19e80fcd5d362405 Mon Sep 17 00:00:00 2001 From: will wade Date: Wed, 19 Mar 2025 14:22:06 +0000 Subject: [PATCH 05/37] migration step 2. - Refactor Pt2! more use of tts-wrapper - Test working using pytest - Switch to UV throughout (there isnt much to depdenencies but its far nicer to work with) - Update docs. Added swagger interface for docs - And tons more --- .coverage | Bin 0 -> 53248 bytes README.md | 345 ++++---- build.py | 110 +-- pyproject.toml | 47 ++ speech/__init__.py | 1 + speech/config.py | 86 +- speech/constants.py | 2 - speech/provider_base.py | 65 -- speech/provider_elevenlabs_data.py | 27 - speech/provider_espeak_data.py | 27 - speech/provider_mimic3_data.py | 27 - speech/provider_msazure_data.py | 29 - speech/provider_msazure_playing.py | 30 - speech/provider_piper_data.py | 28 - speech/provider_platform_data.py | 84 -- speech/provider_pytts_playing.py | 47 -- speech/provider_template_data.py | 29 - speech/provider_template_playing.py | 30 - speech/provider_test_data.py | 27 - speech/speechManager.py | 84 -- speech/speech_manager.py | 235 ++++++ speech/start.py | 249 ++++-- speech/test.py | 56 -- speech/test_endpoints.py | 223 ++++++ speech/tests/coqui_test.py | 31 - speech/util.py | 53 -- uv.lock | 1157 +++++++++++++++++++++++++++ 27 files changed, 2160 insertions(+), 969 deletions(-) create mode 100644 .coverage create mode 100644 pyproject.toml create mode 100644 speech/__init__.py delete mode 100644 speech/constants.py delete mode 100644 speech/provider_base.py delete mode 100644 speech/provider_elevenlabs_data.py delete mode 100644 speech/provider_espeak_data.py delete mode 100644 speech/provider_mimic3_data.py delete mode 100644 speech/provider_msazure_data.py delete mode 100644 speech/provider_msazure_playing.py delete mode 100644 speech/provider_piper_data.py delete mode 100644 speech/provider_platform_data.py delete mode 100644 speech/provider_pytts_playing.py delete mode 100644 speech/provider_template_data.py delete mode 100644 speech/provider_template_playing.py delete mode 100644 speech/provider_test_data.py delete mode 100644 speech/speechManager.py create mode 100644 speech/speech_manager.py delete mode 100644 speech/test.py create mode 100644 speech/test_endpoints.py delete mode 100644 speech/tests/coqui_test.py delete mode 100644 speech/util.py create mode 100644 uv.lock diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..8708815e455898c452945ca92ee254358dc4f785 GIT binary patch literal 53248 zcmeI)No*5W7zgm#Y~yT`3Pn~Fp**^Uh%7`LP)j9}KtxbM6bO|nqz+^IC7#xvF*D;V z5-pfiLaN$JRrORAF6F|3LoZb~F0Fd3xNxad)E)?`T2)=>A}I8Gv&3;01Zkxx^goKn zj>Fs4h%yp(%nY(P>Hy-L^hZQY5D5mXMzUA1APxD;G=lNv|*dx&5 zh42=ZhSEAN3UoY-8J%l^PA&2>F{9v7hv8@voK41AgtQNB;D2DCyFi{9<0Z%k0 zGf!=lC2Ey?+B;FC_gjWp@txqcL6vU|(5mURM>eD~ot^U86G1}}JVw^?z4?uFU89-b zy+YFw$5(DQqLA_BytwP~Aar>fX;eo@ddtDyA*(>3A>eDwv)a zg}g47b(|tM?Qm|O82darr{%FA4@+mIU9~j48Ii`sWLJ=DD^9;?yQq`}DNYsm zlH|hKXpiZ0-zsq?f&`ff8rGEQv7F24AkC%SUFyA>fv7EGK&-`8@&@r%uG%T5GF!LG z)k@SE!*Ry&Xi_hkakitEN?jvabg2?{*fp6JfW2L%a;nAO^Rv2D70;94dOR z)6gw0@MZm$M1NQy009U<00Izz00bZa0SG_<0uXrk1XNj(Q{wtx(Z7}SpJ;^z0uX=z z1Rwwb2tWV=5P$##AOL~&DbS>-oAmf!Jhthw+L4L=31COxj=pUTR4PTkA?Y{to9nZH z$Tb8Y009U<00Izz00bZa0SG_<0fB*y_009U<00Izz00bbgHUhg6np9Q$k9u@Nfc`0~Se&BY_xJC${LxCbf5;o- zhxhG1(!1BS^1Y+HNVfs%_sX2-3jLmMx<1_%U{s?7+E$Z5kd8q&EuhsR?i@omTPe>B%al@j6?riX%eH*Opftqy|B03hlK!ba z&@$I@LHE|Cig8v5KmY;|fB*y_009U<00IzzfFuoSRaIWag>HQPpV+5W+g6pp;_H8H zNUJ7RlhXY4zq;DIEV=$yhP7(?Uta&?{(tCS5P$##AOHafKmY;|fB*y_0D*^EK$TUg zS$zL5>$fHP!vX;aKmY;|fB*y_009U<00Izzz(XjYs?F)(_y77yN&ic~s(-GZqfIOj zfB*y_009U<00Izz00bZa0SNqu0-F<>oZgf8)0Vc+?Y;Be&Xdo4);fFbt5bLN+kb4A zF3(;&*!q}8K}NNAs*-f|=*6UR-g)csRLh`r`q=)$z^|tZznp*ja6_v~fgV+dKbrjF zjVoVL2DQzKNLl&tgR6Hho%;L2=VFB;$6RK7lUVDRH}2NJTDPPZt*@Bj5{ zl7377P5)89PMcUD009U<00Izz00bZa0SG_<0uWeV0!jKDKu#orCrt~UR5f@~6pcOr z2)_T9TGv;hkXr~q00Izz00bZa0SG_<0uX=z1RhL4eE*N@{|7TeXb3<60uX=z1Rwwb z2tWV=5P-mX69|9*KPUeG|9Aao{Re$+y_Ep@g#ZK~009U<00Izz00bZa0SG`K5>OL# RuYXk&D^;u%u@d+H{|EYjTwwqJ literal 0 HcmV?d00001 diff --git a/README.md b/README.md index 7968f90..f29e6d2 100644 --- a/README.md +++ b/README.md @@ -1,169 +1,188 @@ -# AsTeRICS-Grid-Helper -Helper tools to enable [AsTeRICS Grid](https://github.com/asterics/AsTeRICS-Grid) to do actions on the operating system or integrations with external services, which aren't possible within the browser. Currently limited to provide speech from external sources. +# AsTeRICS Grid Helper -## Quick Start with Pre-built Binaries -The easiest way to get started is to use our pre-built binaries. These are standalone executables that don't require Python installation: +A text-to-speech service that provides a simple HTTP API for speech synthesis. This service is designed to work with AsTeRICS Grid and supports multiple TTS providers, with Sherpa-ONNX as the default provider. -1. Download the appropriate binary for your platform from the [releases page](https://github.com/asterics/AsTeRICS-Grid-Helper/releases): - - Windows: `asterics-grid-speech.exe` - - macOS: `asterics-grid-speech-mac.app` or `asterics-grid-speech-mac` - - Linux: `asterics-grid-speech` +## Features -2. Run the executable - - Windows: Double-click `asterics-grid-speech.exe` - - macOS: Double-click `asterics-grid-speech-mac.app` or run `./asterics-grid-speech-mac` in Terminal - - Linux: Run `./asterics-grid-speech` in Terminal +- Simple HTTP API for text-to-speech synthesis +- Support for multiple TTS providers: + - Sherpa-ONNX (default, offline) + - Amazon Polly + - Google Cloud TTS + - Microsoft Azure TTS + - IBM Watson + - ElevenLabs + - Wit.AI +- Automatic model downloading and caching +- Cross-platform support (Windows, macOS, Linux) +- CORS enabled for web applications +- Voice selection and management +- Audio data streaming -3. The service will start automatically on port 5555 - -4. In AsTeRICS Grid: - - Go to `Settings -> General Settings -> Advanced general settings` - - Set the `External speech service URL` to: `http://localhost:5555` - - Reload AsTeRICS Grid (`F5`) - - Go to `Settings -> User settings -> Voice` and enable `Show all voices` - -The service will automatically select the appropriate TTS engine for your platform: -- Windows: SAPI (Windows Speech API) -- macOS: AVSynth (macOS Text-to-Speech) -- Linux: eSpeak-NG - -Note: The first run may take a few seconds as it initializes the speech engine. - -## Building from Source -If you want to build the binaries yourself or modify the code: +## Installation 1. Clone the repository: - ```bash - git clone https://github.com/asterics/AsTeRICS-Grid-Helper.git - cd AsTeRICS-Grid-Helper - ``` - -2. Install Python 3.8 or later - -3. Run the build script: - ```bash - python build.py - ``` - -4. The executable will be created in the `dist` directory: - - Windows: `dist/asterics-grid-speech.exe` - - macOS: `dist/asterics-grid-speech-mac.app` and `dist/asterics-grid-speech-mac` - - Linux: `dist/asterics-grid-speech` - -## Speech -Normally AsTeRICS Grid uses the [Web Speech API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API) and therefore voices that are installed on the operating system (e.g. SAPI voices on Windows, or voices that are coming from a TTS module on Android). Sometimes it's interesting to use voices, which aren't available as system voices. This section describes how to use an external custom speech service using Python. - -### Terms -* **Speech provider**: a Python module that implements access to a speech generating service using the [tts_wrapper](https://github.com/willwade/tts-wrapper) library. This library provides unified access to multiple TTS engines: - * **Online Services**: - * Microsoft Azure TTS - * Amazon Polly - * Google Cloud TTS - * IBM Watson - * ElevenLabs - * Wit.Ai - * Play.HT - * **Offline Services**: - * eSpeak-NG - * AVSynth (macOS only) - * SAPI (Windows only) - * Sherpa-ONNX (supports Piper and other ONNX models) - * **Experimental**: - * PicoTTS - * UWP (WinRT) Speech system (Windows 10+) - - Speech providers can have two types: - * **type "playing"**: a speech provider where playing the audio file is done internally. Using a speech provider of this type only makes sense, if it's used on the same machine as AsTeRICS Grid. - * **type "data"**: a speech provider that generates the speech audio data, which then is used by AsTeRICS Grid and played within the browser. This type is preferable, because it makes it possible to run the speech service on any device or server and also allows caching of the data. - -### Manual Installation and Usage -If you prefer to run the service directly without using pre-built binaries: - -1. Install Python dependencies: - ```bash - pip install flask flask_cors - ``` - -2. Install tts_wrapper with the required engines: - ```bash - # For all platforms (includes platform-specific engines) - pip install "py3-tts-wrapper[espeak,avsynth,sapi]" - - # For specific platforms: - # Linux: pip install "py3-tts-wrapper[espeak]" - # macOS: pip install "py3-tts-wrapper[avsynth]" - # Windows: pip install "py3-tts-wrapper[sapi]" - ``` - -3. Install system dependencies if needed: - * Linux: - ```bash - sudo apt-get install portaudio19-dev - sudo apt install espeak-ng # For eSpeak support - ``` - * macOS: - ```bash - brew install portaudio - brew install espeak-ng # Optional, for eSpeak support - ``` - * Windows: No additional dependencies needed for SAPI - -4. Start the speech service: - ```bash - python speech/start.py - ``` - -### Caching -For speech providers with type "data", all generated speech data is automatically cached to the folder `speech/temp`. If you want to cache speech data for a whole AsTeRICS Grid configuration follow these steps: -* configure AsTeRICS Grid to use your desired speech provider / voice (see steps above) -* go to `Settings -> User settings -> Voice -> Advanced voice settings` and click the button `Cache all texts of current configuration using external voice`. This operation may take some time for big AsTeRICS Grid configurations. - -### Files -These are the important files within the folder `speech` of this repository: -* `config.py` configuration file where it's possible to define which speech providers should be used -* `provider__playing.py` implementation of a speech provider which generates speech and plays audio on its own -* `provider__data.py` implementation of a speech provider which generates speech audio data and returns the binary data, which then is played by AsTeRICS Grid within the browser -* `provider_platform_data.py` platform-specific provider that automatically selects the appropriate TTS engine -* `start.py` main script providing a REST API which can be used by AsTeRICS Grid -* `speechManager.py` script which manages different speech providers and is used to access them by the API defined in `start.py` - -### Speech providers -This is a list of predefined speech providers with installation hints: - -#### Platform-specific provider -The `platform_data` provider automatically selects the appropriate TTS engine based on your operating system: -* Linux: Uses eSpeak-NG -* macOS: Uses AVSynth -* Windows: Uses SAPI - -No additional configuration is needed - it works out of the box on all supported platforms. - -#### Other providers -* **msazure_data, msazure_playing**: - * Requires Azure credentials in `speech/credentials.py`: - ```python - AZURE_KEY_1 = "" - AZURE_REGION = "" - ``` - * Get API credentials by [signing up at MS Azure](https://azure.microsoft.com/de-de/get-started/azure-portal) and creating a `SpeechServices` resource. -* **piper_data**: Uses Sherpa-ONNX engine from tts_wrapper to run Piper models. No additional setup required as it uses default model paths. -* **elevenlabs_data**: - * Requires ElevenLabs API key in `speech/credentials.py`: - ```python - ELEVENLABS_KEY = "" - ``` - * Get API key from [ElevenLabs](https://elevenlabs.io/docs/api-reference/text-to-speech#authentication) - -#### Configuration -See [config.py](https://github.com/asterics/AsTeRICS-Grid-Helper/blob/main/speech/config.py), where the speech providers to use can be imported and added to the list `speechProviderList`. - -#### Adding new speech providers -Use the templates [provider_template_data.py](https://github.com/asterics/AsTeRICS-Grid-Helper/blob/main/speech/provider_template_data.py) or [provider_template_playing.py](https://github.com/asterics/AsTeRICS-Grid-Helper/blob/main/speech/provider_template_playing.py) depending on which type of speech provider you want to add and implement the predefined methods. - -### REST API -The file `speech/start.py` starts the REST API with the following endpoints: -* `/voices` returns a list of voices that are existing within the current configuration. -* `/speak///` speaks the given text using the given provider and voice. -* `/speakdata///` returns the binary audio data for the text using the given provider and voice. -* `/cache///` caches the audio data for the given parameters to a file in `speech/temp` in order to be able to use it faster or without internet connection afterwards. -* `/speaking` returns `true` if the system is currently speaking (only applicable for voice type "speaking") +```bash +git clone https://github.com/yourusername/AsTeRICS-Grid-Helper.git +cd AsTeRICS-Grid-Helper +``` + +2. Install dependencies using `uv`: +```bash +uv pip install -r requirements.txt +``` + +## Usage + +### Starting the Service + +Run the service using: +```bash +uv run python -m speech.start +``` + +The service will start on `http://localhost:5555` by default. + +### Building Executables + +To build platform-specific executables: + +```bash +uv run python build.py +``` + +This will create executables in the `dist` directory: +- macOS: `asterics-grid-speech-mac` +- Windows: `asterics-grid-speech.exe` +- Linux: `asterics-grid-speech` + +### API Endpoints + +#### List Available Voices +```bash +curl http://localhost:5555/voices +``` + +#### Generate Speech Data +```bash +curl "http://localhost:5555/speakdata/Hello%20World/en-us-amy-medium" --output output.wav +``` + +#### Speak Text +```bash +curl "http://localhost:5555/speak/Hello%20World/en-us-amy-medium" +``` + +### Configuration + +The service can be configured by modifying `speech/config.py`. Here's an example configuration: + +```python +# TTS Configuration +TTS_CONFIG = { + "tts_provider": "sherpa-onnx", # Default provider + "cache_enabled": True, + "cache_dir": "cache", + "cache_ttl": 3600, # Cache TTL in seconds +} + +# Provider-specific credentials +CREDENTIALS = { + "polly": { + "aws_access_key_id": "your-access-key", + "aws_secret_access_key": "your-secret-key", + "region_name": "us-east-1" + }, + "google": { + "credentials_file": "path/to/credentials.json" + }, + "azure": { + "subscription_key": "your-key", + "region": "your-region" + }, + "watson": { + "apikey": "your-api-key", + "url": "your-service-url" + }, + "elevenlabs": { + "api_key": "your-api-key" + }, + "wit": { + "api_key": "your-api-key" + } +} + +# Voice configuration +VOICE_CONFIG = { + "default_voice": "en-us-amy-medium", + "fallback_voice": "en-us-ryan-medium" +} +``` + +### Available Voices + +The service provides several pre-configured voices using the Piper model: + +- `en-us-amy-medium`: English (US) - Amy (Medium) +- `en-us-amy-low`: English (US) - Amy (Low) +- `en-us-amy-high`: English (US) - Amy (High) +- `en-us-ryan-medium`: English (US) - Ryan (Medium) +- `en-us-ryan-low`: English (US) - Ryan (Low) +- `en-us-ryan-high`: English (US) - Ryan (High) + +### Using Different TTS Providers + +To use a different TTS provider: + +1. Update the `tts_provider` in `TTS_CONFIG` +2. Add the required credentials in the `CREDENTIALS` section +3. Restart the service + +Example for using Amazon Polly: +```python +TTS_CONFIG = { + "tts_provider": "polly", + "cache_enabled": True, + "cache_dir": "cache", + "cache_ttl": 3600, +} + +CREDENTIALS = { + "polly": { + "aws_access_key_id": "your-access-key", + "aws_secret_access_key": "your-secret-key", + "region_name": "us-east-1" + } +} +``` + +## Development + +### Running Tests + +Run the test suite: +```bash +uv run pytest +``` + +### Project Structure + +``` +speech/ +├── config.py # Configuration settings +├── speechManager.py # Core TTS functionality +├── start.py # Flask server implementation +└── test_endpoints.py # API endpoint tests +``` + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Commit your changes +4. Push to the branch +5. Create a Pull Request + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. diff --git a/build.py b/build.py index 184c0cf..c4157f2 100644 --- a/build.py +++ b/build.py @@ -1,41 +1,7 @@ import os import platform import subprocess -import sys from pathlib import Path -import site - - -def find_package_path(package_name): - """Find the installation path of a package""" - for path in site.getsitepackages(): - package_path = os.path.join(path, package_name) - if os.path.exists(package_path): - return package_path - return None - - -def install_requirements(): - """Install required packages for building""" - subprocess.check_call([sys.executable, "-m", "pip", "install", "pyinstaller"]) - subprocess.check_call( - [sys.executable, "-m", "pip", "install", "flask", "flask_cors"] - ) - - # Install only the platform-specific TTS engine - current_platform = platform.system().lower() - if current_platform == "darwin": - subprocess.check_call( - [sys.executable, "-m", "pip", "install", "py3-tts-wrapper[avsynth]"] - ) - elif current_platform == "windows": - subprocess.check_call( - [sys.executable, "-m", "pip", "install", "py3-tts-wrapper[sapi]"] - ) - else: # linux - subprocess.check_call( - [sys.executable, "-m", "pip", "install", "py3-tts-wrapper[espeak]"] - ) def build_executable(): @@ -52,7 +18,6 @@ def build_executable(): if os.path.exists("speech/assets/icon.icns") else None ), - "engine": "avsynth", }, "windows": { "name": "asterics-grid-speech.exe", @@ -61,7 +26,6 @@ def build_executable(): if os.path.exists("speech/assets/icon.ico") else None ), - "engine": "sapi", }, "linux": { "name": "asterics-grid-speech", @@ -70,31 +34,14 @@ def build_executable(): if os.path.exists("speech/assets/icon.png") else None ), - "engine": "espeak", }, } options = platform_options.get( current_platform, - {"name": "asterics-grid-speech", "icon": None, "engine": None}, + {"name": "asterics-grid-speech", "icon": None}, ) - # Find the tts_wrapper package path - tts_wrapper_path = find_package_path("tts_wrapper") - if tts_wrapper_path: - # Include the entire tts_wrapper package - data_option = f"{tts_wrapper_path}{os.pathsep}tts_wrapper" - - # Include the specific engine files - engine_path = os.path.join(tts_wrapper_path, "engines", options["engine"]) - if os.path.exists(engine_path): - engine_option = ( - f"{engine_path}{os.pathsep}tts_wrapper/engines/{options['engine']}" - ) - else: - data_option = None - engine_option = None - # Base PyInstaller command cmd = [ "pyinstaller", @@ -105,16 +52,10 @@ def build_executable(): "--clean", # Clean PyInstaller cache "--add-data", "speech/temp:temp", # Include temp directory + "--add-data", + "speech/cache:cache", # Include cache directory ] - # Add tts_wrapper package if found - if data_option: - cmd.extend(["--add-data", data_option]) - - # Add engine files if found - if engine_option: - cmd.extend(["--add-data", engine_option]) - # Add platform-specific options if options["icon"]: cmd.extend(["--icon", options["icon"]]) @@ -135,8 +76,6 @@ def build_executable(): "wit", "--exclude-module", "elevenlabs", - "--exclude-module", - "sherpa_onnx", "speech/start.py", ] ) @@ -153,7 +92,7 @@ def build_executable(): """AsTeRICS Grid Speech Service =========================== -This is a standalone speech service for AsTeRICS Grid. +This is a standalone speech service for AsTeRICS Grid using Sherpa-ONNX for offline text-to-speech. Usage: 1. Run the executable @@ -163,21 +102,46 @@ def build_executable(): 5. Reload AsTeRICS Grid (F5) 6. Go to Settings -> User settings -> Voice and enable "Show all voices" -The service will automatically select the appropriate TTS engine for your platform: -- Windows: SAPI (Windows Speech API) -- macOS: AVSynth (macOS Text-to-Speech) -- Linux: eSpeak-NG +Available Voices: +- en-us-amy-medium: English (US) - Amy (Medium) +- en-us-amy-low: English (US) - Amy (Low) +- en-us-amy-high: English (US) - Amy (High) +- en-us-ryan-medium: English (US) - Ryan (Medium) +- en-us-ryan-low: English (US) - Ryan (Low) +- en-us-ryan-high: English (US) - Ryan (High) -Note: The first run may take a few seconds as it initializes the speech engine. +Note: The first run may take a few seconds as it downloads and initializes the Sherpa-ONNX model. +""" + ) + + # Create a simple config file in the dist directory + config_path = dist_dir / "config.py" + with open(config_path, "w") as f: + f.write( + """# Default configuration for AsTeRICS Grid Speech Service + +# TTS Configuration +TTS_CONFIG = { + "tts_provider": "sherpa-onnx", # Default provider + "cache_enabled": True, + "cache_dir": "cache", + "cache_ttl": 3600, # Cache TTL in seconds +} + +# Voice configuration +VOICE_CONFIG = { + "default_voice": "en-us-amy-medium", + "fallback_voice": "en-us-ryan-medium" +} + +# No credentials needed for Sherpa-ONNX +CREDENTIALS = {} """ ) def main(): """Main build process""" - print("Installing requirements...") - install_requirements() - print("Building executable...") build_executable() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a3aa3a3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,47 @@ +[project] +name = "asterics-grid-helper" +version = "0.1.0" +description = "Helper services for AsTeRICS Grid, providing text-to-speech and Signal messaging automation" +authors = [ + { name = "Will Wade", email = "willwade@gmail.com" } +] +dependencies = [ + "flask>=3.0.0", + "flask-cors>=4.0.0", + "flask-restx>=1.3.0", + "py3-tts-wrapper[avsynth]; sys_platform == 'darwin'", + "py3-tts-wrapper[sapi]; sys_platform == 'win32'", + "py3-tts-wrapper[espeak]; sys_platform == 'linux'", + "py3-tts-wrapper[sherpaonnx,espeak,elevenlabs,playht,microsoft,polly,watson,googletrans,witai,controlaudio]", + "omegaconf>=2.3.0", + "hydra-core>=1.3.0", +] +requires-python = ">=3.11" +readme = "README.md" +license = { file = "LICENSE" } + +[tool.setuptools] +packages = ["speech"] + +[project.optional-dependencies] +test = [ + "requests", + "pytest", + "pytest-cov", + "pyinstaller", + "hydra-core", + "omegaconf", + "pytest-mock>=3.12.0", +] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "B", "UP", "PL", "RUF"] + +[tool.pytest.ini_options] +testpaths = ["speech"] +python_files = ["test_*.py"] +addopts = "-v --cov=speech --cov-report=term-missing" \ No newline at end of file diff --git a/speech/__init__.py b/speech/__init__.py new file mode 100644 index 0000000..8cef44a --- /dev/null +++ b/speech/__init__.py @@ -0,0 +1 @@ +"""Speech service package for AsTeRICS Grid Helper.""" diff --git a/speech/config.py b/speech/config.py index 4f43745..4f7307c 100644 --- a/speech/config.py +++ b/speech/config.py @@ -1,6 +1,82 @@ -import provider_test_data +""" +Configuration for the speech service. +""" -speechProviderList = [ - provider_test_data, -] -cacheData = True +# Default TTS engine to use +TTS_ENGINE = "SherpaOnnx" + +# Credentials for cloud services +CREDENTIALS = { + # AWS Polly + "AWS_REGION": "us-east-1", # e.g., us-east-1 + "AWS_KEY_ID": "", # Your AWS access key ID + "AWS_SECRET_ACCESS_KEY": "", # Your AWS secret access key + # Google Cloud + "GOOGLE_SA_PATH": "", # Path to service account JSON file or dict of credentials + # Microsoft Azure + "AZURE_KEY": "", # Your Azure subscription key + "AZURE_REGION": "", # e.g., eastus + # IBM Watson + "WATSON_API_KEY": "", # Your Watson API key + "WATSON_REGION": "", # e.g., us-east + "WATSON_INSTANCE_ID": "", # Your Watson instance ID + # ElevenLabs + "ELEVENLABS_KEY": "", # Your ElevenLabs API key + # Wit.AI + "WITAI_TOKEN": "", # Your Wit.AI token +} + +# Caching settings +CACHE_ENABLED = True +CACHE_DIR = "temp" + +# SherpaOnnx specific configuration +SHERPA_ONNX_CONFIG = { + "model_dir": "models/sherpa-onnx", +} + +# Watson specific configuration +WATSON_CONFIG = { + "disableSSLVerification": False, # Set to True if you have SSL certificate issues +} + + +def get_tts_config(): + """Get the TTS configuration based on the selected engine.""" + config = { + "engine": TTS_ENGINE, + "credentials": CREDENTIALS, + "cache_enabled": CACHE_ENABLED, + "cache_dir": CACHE_DIR, + } + + # Add engine-specific configurations + if TTS_ENGINE == "SherpaOnnx": + config["config"] = SHERPA_ONNX_CONFIG + elif TTS_ENGINE == "watson": + config["config"] = WATSON_CONFIG + elif TTS_ENGINE == "polly": + config["config"] = { + "region": CREDENTIALS.get("AWS_REGION"), + "key_id": CREDENTIALS.get("AWS_KEY_ID"), + "secret_key": CREDENTIALS.get("AWS_SECRET_ACCESS_KEY"), + } + elif TTS_ENGINE == "google": + config["config"] = { + "credentials": CREDENTIALS.get("GOOGLE_SA_PATH"), + } + elif TTS_ENGINE == "microsoft": + config["config"] = { + "key": CREDENTIALS.get("AZURE_KEY"), + "region": CREDENTIALS.get("AZURE_REGION"), + } + elif TTS_ENGINE == "elevenlabs": + config["config"] = { + "api_key": CREDENTIALS.get("ELEVENLABS_KEY"), + } + elif TTS_ENGINE == "witai": + config["config"] = { + "token": CREDENTIALS.get("WITAI_TOKEN"), + } + + return config diff --git a/speech/constants.py b/speech/constants.py deleted file mode 100644 index 2d14b6a..0000000 --- a/speech/constants.py +++ /dev/null @@ -1,2 +0,0 @@ -VOICE_TYPE_EXTERNAL_PLAYING = "VOICE_TYPE_EXTERNAL_PLAYING" -VOICE_TYPE_EXTERNAL_DATA = "VOICE_TYPE_EXTERNAL_DATA" diff --git a/speech/provider_base.py b/speech/provider_base.py deleted file mode 100644 index 6eb3385..0000000 --- a/speech/provider_base.py +++ /dev/null @@ -1,65 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Optional, List, Dict, Any - - -class BaseProvider(ABC): - def __init__(self, provider_id: str, voice_type: str, tts_instance: Any): - self.provider_id = provider_id - self.voice_type = voice_type - self.tts = tts_instance - self.speaking = False - self.is_local = False - - def getProviderId(self) -> str: - return self.provider_id - - def getVoiceType(self) -> str: - return self.voice_type - - def getVoices(self) -> List[Dict[str, Any]]: - voices = self.tts.get_voices() - voice_list: List[Dict[str, Any]] = [] - for voice in voices: - # Handle different voice formats - if isinstance(voice, dict): - # For providers that return a dict with language_codes - if "language_codes" in voice and isinstance( - voice["language_codes"], dict - ): - lang_code = list(voice["language_codes"].keys())[0] - else: - lang_code = voice.get("language", "en") - voice_id = voice.get("id", "") - voice_name = voice.get("name", "") - else: - # For providers that return a simpler format (like eSpeak) - lang_code = "en" # Default to English - voice_id = str(voice) - voice_name = str(voice) - - voice_list.append( - { - "id": voice_id, - "name": voice_name, - "lang": lang_code, - "local": self.is_local, - } - ) - return voice_list - - @abstractmethod - def getSpeakData(self, text: str, voiceId: Optional[str] = None) -> bytes: - pass - - def speak(self, text: str, voiceId: Optional[str] = None) -> None: - if voiceId: - self.tts.set_voice(voiceId) - self.speaking = True - self.tts.speak(text) - self.speaking = False - - def isSpeaking(self) -> bool: - return self.speaking - - def stop(self) -> None: - self.tts.stop_audio() diff --git a/speech/provider_elevenlabs_data.py b/speech/provider_elevenlabs_data.py deleted file mode 100644 index 104ea17..0000000 --- a/speech/provider_elevenlabs_data.py +++ /dev/null @@ -1,27 +0,0 @@ -import credentials -import constants -from typing import Optional -from tts_wrapper import ElevenLabsTTS, ElevenLabsClient -from provider_base import BaseProvider - - -class ElevenLabsDataProvider(BaseProvider): - def __init__(self): - client = ElevenLabsClient(credentials=(credentials.ELEVENLABS_KEY,)) - tts = ElevenLabsTTS(client) - super().__init__("elevenlabs_data", constants.VOICE_TYPE_EXTERNAL_DATA, tts) - - def getSpeakData(self, text: str, voiceId: Optional[str] = None) -> bytes: - if voiceId: - self.tts.set_voice(voiceId) - return self.tts.synth_to_bytes(text) - - -# Create a singleton instance -provider = ElevenLabsDataProvider() - -# Export the interface functions -getProviderId = provider.getProviderId -getVoiceType = provider.getVoiceType -getVoices = provider.getVoices -getSpeakData = provider.getSpeakData diff --git a/speech/provider_espeak_data.py b/speech/provider_espeak_data.py deleted file mode 100644 index 212da12..0000000 --- a/speech/provider_espeak_data.py +++ /dev/null @@ -1,27 +0,0 @@ -import constants -from typing import Optional -from tts_wrapper import eSpeakTTS, eSpeakClient -from provider_base import BaseProvider - - -class eSpeakDataProvider(BaseProvider): - def __init__(self): - client = eSpeakClient() - tts = eSpeakTTS(client) - super().__init__("espeak_data", constants.VOICE_TYPE_EXTERNAL_DATA, tts) - self.is_local = True # eSpeak is always local - - def getSpeakData(self, text: str, voiceId: Optional[str] = None) -> bytes: - if voiceId: - self.tts.set_voice(voiceId) - return self.tts.synth_to_bytes(text) - - -# Create a singleton instance -provider = eSpeakDataProvider() - -# Export the interface functions -getProviderId = provider.getProviderId -getVoiceType = provider.getVoiceType -getVoices = provider.getVoices -getSpeakData = provider.getSpeakData diff --git a/speech/provider_mimic3_data.py b/speech/provider_mimic3_data.py deleted file mode 100644 index 8d7a083..0000000 --- a/speech/provider_mimic3_data.py +++ /dev/null @@ -1,27 +0,0 @@ -import os -import constants -import util - -providerId = "mimic3_data" - - -def getProviderId(): - return providerId - - -def getVoiceType(): - return constants.VOICE_TYPE_EXTERNAL_DATA - - -def getVoices(): - list = [] - # add supported voices - list.append({"id": "mimic3-voice", "name": "Mimic3 voice", "lang": "en"}) - return list - - -def getSpeakData(text, voiceId=None): - # return byte array of data containing speech - path = util.getTempFileFullPath(providerId) - os.system('mimic3 --voice en_UK/apope_low "{}" > {}'.format(text, path)) - return util.getTempFileData(providerId) diff --git a/speech/provider_msazure_data.py b/speech/provider_msazure_data.py deleted file mode 100644 index 2624331..0000000 --- a/speech/provider_msazure_data.py +++ /dev/null @@ -1,29 +0,0 @@ -import credentials -import constants -from typing import Optional -from tts_wrapper import MicrosoftTTS, MicrosoftClient -from provider_base import BaseProvider - - -class AzureDataProvider(BaseProvider): - def __init__(self): - client = MicrosoftClient( - credentials=(credentials.AZURE_KEY_1, credentials.AZURE_REGION) - ) - tts = MicrosoftTTS(client) - super().__init__("azure_data", constants.VOICE_TYPE_EXTERNAL_DATA, tts) - - def getSpeakData(self, text: str, voiceId: Optional[str] = None) -> bytes: - if voiceId: - self.tts.set_voice(voiceId) - return self.tts.synth_to_bytes(text) - - -# Create a singleton instance -provider = AzureDataProvider() - -# Export the interface functions -getProviderId = provider.getProviderId -getVoiceType = provider.getVoiceType -getVoices = provider.getVoices -getSpeakData = provider.getSpeakData diff --git a/speech/provider_msazure_playing.py b/speech/provider_msazure_playing.py deleted file mode 100644 index 9ec976a..0000000 --- a/speech/provider_msazure_playing.py +++ /dev/null @@ -1,30 +0,0 @@ -import credentials -import constants -from typing import Optional -from tts_wrapper import MicrosoftTTS, MicrosoftClient -from provider_base import BaseProvider - - -class AzurePlayingProvider(BaseProvider): - def __init__(self): - client = MicrosoftClient( - credentials=(credentials.AZURE_KEY_1, credentials.AZURE_REGION) - ) - tts = MicrosoftTTS(client) - super().__init__("azure_playing", constants.VOICE_TYPE_EXTERNAL_PLAYING, tts) - - def getSpeakData(self, text: str, voiceId: Optional[str] = None) -> bytes: - # This provider doesn't need to implement getSpeakData - raise NotImplementedError("This provider only supports playing") - - -# Create a singleton instance -provider = AzurePlayingProvider() - -# Export the interface functions -getProviderId = provider.getProviderId -getVoiceType = provider.getVoiceType -getVoices = provider.getVoices -speak = provider.speak -isSpeaking = provider.isSpeaking -stop = provider.stop diff --git a/speech/provider_piper_data.py b/speech/provider_piper_data.py deleted file mode 100644 index f8ee9c7..0000000 --- a/speech/provider_piper_data.py +++ /dev/null @@ -1,28 +0,0 @@ -import constants -from typing import Optional -from tts_wrapper import SherpaOnnxTTS, SherpaOnnxClient -from provider_base import BaseProvider - - -class PiperDataProvider(BaseProvider): - def __init__(self): - client = SherpaOnnxClient( - model_path=None, tokens_path=None - ) # Will use default paths - tts = SherpaOnnxTTS(client) - super().__init__("piper_data", constants.VOICE_TYPE_EXTERNAL_DATA, tts) - - def getSpeakData(self, text: str, voiceId: Optional[str] = None) -> bytes: - if voiceId: - self.tts.set_voice(voiceId) - return self.tts.synth_to_bytes(text) - - -# Create a singleton instance -provider = PiperDataProvider() - -# Export the interface functions -getProviderId = provider.getProviderId -getVoiceType = provider.getVoiceType -getVoices = provider.getVoices -getSpeakData = provider.getSpeakData diff --git a/speech/provider_platform_data.py b/speech/provider_platform_data.py deleted file mode 100644 index 7cbd7f7..0000000 --- a/speech/provider_platform_data.py +++ /dev/null @@ -1,84 +0,0 @@ -import platform -import constants -from typing import Optional, Tuple, Any -from provider_base import BaseProvider -import struct - - -def add_wav_header(audio_data: bytes) -> bytes: - """Add WAV header to raw audio data.""" - # WAV header parameters - num_channels = 1 # mono - sample_width = 2 # 16-bit - sample_rate = 22050 # standard rate for most TTS engines - block_align = num_channels * sample_width - byte_rate = sample_rate * block_align - - # Create WAV header - header = struct.pack( - "<4sI4s4sIHHIIHH4sI", - b"RIFF", # ChunkID - 36 + len(audio_data), # ChunkSize - b"WAVE", # Format - b"fmt ", # Subchunk1ID - 16, # Subchunk1Size - 1, # AudioFormat (1 = PCM) - num_channels, # NumChannels - sample_rate, # SampleRate - byte_rate, # ByteRate - block_align, # BlockAlign - sample_width * 8, # BitsPerSample - b"data", # Subchunk2ID - len(audio_data), # Subchunk2Size - ) - - return header + audio_data - - -def get_platform_tts() -> Tuple[str, Any, Any]: - """Get the appropriate TTS client and engine for the current platform.""" - system = platform.system().lower() - - if system == "linux": - from tts_wrapper import eSpeakTTS, eSpeakClient - - client = eSpeakClient() - tts = eSpeakTTS(client) - return "platform_data", client, tts - elif system == "darwin": # macOS - from tts_wrapper import AVSynthTTS, AVSynthClient - - client = AVSynthClient() - tts = AVSynthTTS(client) - return "platform_data", client, tts - elif system == "windows": - from tts_wrapper import SAPITTS, SAPIClient - - client = SAPIClient() - tts = SAPITTS(client) - return "platform_data", client, tts - else: - raise NotImplementedError(f"Unsupported platform: {system}") - - -class PlatformDataProvider(BaseProvider): - def __init__(self): - provider_id, client, tts = get_platform_tts() - super().__init__(provider_id, constants.VOICE_TYPE_EXTERNAL_DATA, tts) - self.is_local = True # All platform-specific providers are local - - def getSpeakData(self, text: str, voiceId: Optional[str] = None) -> bytes: - if voiceId: - self.tts.set_voice(voiceId) - raw_data = self.tts.synth_to_bytes(text) - return add_wav_header(raw_data) - - -# Create a singleton instance -provider = PlatformDataProvider() - -# Export the interface functions -getProviderId = provider.getProviderId -getVoiceType = provider.getVoiceType -getVoices = provider.getVoices -getSpeakData = provider.getSpeakData diff --git a/speech/provider_pytts_playing.py b/speech/provider_pytts_playing.py deleted file mode 100644 index 3b34551..0000000 --- a/speech/provider_pytts_playing.py +++ /dev/null @@ -1,47 +0,0 @@ -import pyttsx3 -import constants - -engine = pyttsx3.init() -speaking = False - - -def getProviderId(): - return "pytts_playing" - - -def getVoiceType(): - return constants.VOICE_TYPE_EXTERNAL_PLAYING - - -def speak(text, voiceId=None): - global speaking - stop() - if speaking: - return - if voiceId: - engine.setProperty("voice", voiceId) - engine.say(text) - speaking = True - # return engine.runAndWait() # hanging in combination with Flask on Windows?! - engine.startLoop(False) - engine.iterate() - engine.endLoop() - speaking = False - - -def isSpeaking(): - # return engine.isBusy() # always returns True on Windows?! - return speaking - - -def stop(): - engine.stop() - - -def getVoices(): - list = [] - voices = engine.getProperty("voices") - for voice in voices: - list.append({"id": voice.id, "name": voice.name}) - - return list diff --git a/speech/provider_template_data.py b/speech/provider_template_data.py deleted file mode 100644 index 0501d08..0000000 --- a/speech/provider_template_data.py +++ /dev/null @@ -1,29 +0,0 @@ -# template for a speech provider returning binary data - -from typing import Optional -from provider_base import BaseProvider - - -class TemplateDataProvider(BaseProvider): - def __init__(self): - # Initialize your TTS client and instance here - # Example: - # client = YourTTSClient() - # tts = YourTTSEngine(client) - # super().__init__("template_data", constants.VOICE_TYPE_EXTERNAL_DATA, tts) - raise NotImplementedError("Template provider - implement your TTS client") - - def getSpeakData(self, text: str, voiceId: Optional[str] = None) -> bytes: - if voiceId: - self.tts.set_voice(voiceId) - return self.tts.synth_to_bytes(text) - - -# Create a singleton instance -provider = TemplateDataProvider() - -# Export the interface functions -getProviderId = provider.getProviderId -getVoiceType = provider.getVoiceType -getVoices = provider.getVoices -getSpeakData = provider.getSpeakData diff --git a/speech/provider_template_playing.py b/speech/provider_template_playing.py deleted file mode 100644 index 7a1d50b..0000000 --- a/speech/provider_template_playing.py +++ /dev/null @@ -1,30 +0,0 @@ -# template for a speech provider that directly plays speech - -from typing import Optional -from provider_base import BaseProvider - - -class TemplatePlayingProvider(BaseProvider): - def __init__(self): - # Initialize your TTS client and instance here - # Example: - # client = YourTTSClient() - # tts = YourTTSEngine(client) - # super().__init__("template_playing", constants.VOICE_TYPE_EXTERNAL_PLAYING, tts) - raise NotImplementedError("Template provider - implement your TTS client") - - def getSpeakData(self, text: str, voiceId: Optional[str] = None) -> bytes: - # This provider doesn't need to implement getSpeakData - raise NotImplementedError("This provider only supports playing") - - -# Create a singleton instance -provider = TemplatePlayingProvider() - -# Export the interface functions -getProviderId = provider.getProviderId -getVoiceType = provider.getVoiceType -getVoices = provider.getVoices -speak = provider.speak -isSpeaking = provider.isSpeaking -stop = provider.stop diff --git a/speech/provider_test_data.py b/speech/provider_test_data.py deleted file mode 100644 index 9df160b..0000000 --- a/speech/provider_test_data.py +++ /dev/null @@ -1,27 +0,0 @@ -import constants -from typing import Optional -from tts_wrapper import eSpeakTTS, eSpeakClient -from provider_base import BaseProvider - - -class TestDataProvider(BaseProvider): - def __init__(self): - client = eSpeakClient() - tts = eSpeakTTS(client) - super().__init__("test_data", constants.VOICE_TYPE_EXTERNAL_DATA, tts) - self.is_local = True # eSpeak is always local - - def getSpeakData(self, text: str, voiceId: Optional[str] = None) -> bytes: - if voiceId: - self.tts.set_voice(voiceId) - return self.tts.synth_to_bytes(text) - - -# Create a singleton instance -provider = TestDataProvider() - -# Export the interface functions -getProviderId = provider.getProviderId -getVoiceType = provider.getVoiceType -getVoices = provider.getVoices -getSpeakData = provider.getSpeakData diff --git a/speech/speechManager.py b/speech/speechManager.py deleted file mode 100644 index be13c5f..0000000 --- a/speech/speechManager.py +++ /dev/null @@ -1,84 +0,0 @@ -from typing import Optional, List, Dict, Any -from provider_platform_data import provider as platform_provider -from provider_test_data import provider as test_provider - -# List of all available providers -providers = [ - platform_provider, # Platform-specific provider (eSpeak/AVSynth/SAPI) - test_provider, -] - -# Try to load optional providers -try: - from provider_elevenlabs_data import provider as elevenlabs_provider - - providers.append(elevenlabs_provider) -except ImportError: - print("Note: ElevenLabs provider not available (credentials missing)") - -try: - from provider_espeak_data import provider as espeak_provider - - providers.append(espeak_provider) -except Exception as e: - print("Note: eSpeak provider not available:", str(e)) - -# Default provider is the platform-specific one -defaultProvider = platform_provider - - -def getVoices() -> List[Dict[str, Any]]: - """Get all available voices from all providers.""" - voices = [] - for provider in providers: - try: - providerVoices = provider.getVoices() - for voice in providerVoices: - voice["providerId"] = provider.getProviderId() - voice["type"] = provider.getVoiceType() - voices.append(voice) - except Exception as e: - provider_id = provider.getProviderId() - print(f"Error getting voices from provider {provider_id}: {str(e)}") - return voices - - -def getSpeakData( - text: str, voiceId: str, providerId: Optional[str] = None -) -> Optional[bytes]: - """Get speech data for the given text and voice ID.""" - if providerId is None: - providerId = defaultProvider.getProviderId() - - for provider in providers: - if provider.getProviderId() == providerId: - return provider.getSpeakData(text, voiceId) - return None - - -def speak(text: str, providerId: str, voiceId: Optional[str] = None) -> None: - """Speak the given text using the specified provider and voice.""" - if providerId not in [p.getProviderId() for p in providers]: - print(f"ERROR: Unknown speech provider '{providerId}'!") - return - - for provider in providers: - if provider.getProviderId() == providerId: - if voiceId: - provider.tts.set_voice(voiceId) - provider.tts.speak(text) - return - - -def initProviders() -> None: - """Initialize all speech providers.""" - for provider in providers: - provider_id = provider.getProviderId() - if not all( - hasattr(provider, fn) - for fn in ["getProviderId", "getVoiceType", "getVoices"] - ): - print( - f"ERROR: speech provider '{provider_id}' is missing required functions!" - ) - continue diff --git a/speech/speech_manager.py b/speech/speech_manager.py new file mode 100644 index 0000000..1e92040 --- /dev/null +++ b/speech/speech_manager.py @@ -0,0 +1,235 @@ +import platform +from typing import Any + +from .config import get_tts_config + +# Platform-specific imports +if platform.system().lower() == "darwin": + from tts_wrapper import AVSynthClient, AVSynthTTS +elif platform.system().lower() == "win32": + from tts_wrapper import SAPITTS, SAPIClient +else: + from tts_wrapper import eSpeakClient, eSpeakTTS + +# Common imports for all platforms +from tts_wrapper import ( + ElevenLabsClient, + ElevenLabsTTS, + GoogleClient, + GoogleTTS, + MicrosoftClient, + MicrosoftTTS, + PollyClient, + PollyTTS, + SherpaOnnxClient, + SherpaOnnxTTS, + WatsonClient, + WatsonTTS, + WitAiClient, + WitAiTTS, +) + + +class SpeechManager: + """Manages speech synthesis and playback.""" + + def __init__(self): + """Initialize the speech manager.""" + self.tts_instance = None + self._speaking = False + self._audio_loaded = False + + def on_speech_start(self): + """Callback for when speech starts.""" + self._speaking = True + + def on_speech_end(self): + """Callback for when speech ends.""" + self._speaking = False + self._audio_loaded = False + + def is_speaking(self) -> bool: + """Check if text is being spoken.""" + return self._speaking + + def stop_speaking(self) -> None: + """Stop the current speech playback.""" + try: + if self.tts_instance and self._audio_loaded: + self.tts_instance.stop_audio() + self._speaking = False + self._audio_loaded = False + except Exception as e: + print(f"Error stopping speech: {e}") + + def get_platform_tts(self): + """Get the appropriate TTS instance for the current platform.""" + if platform.system().lower() == "darwin": + client = AVSynthClient() + return AVSynthTTS(client) + elif platform.system().lower() == "win32": + client = SAPIClient() + return SAPITTS(client) + else: + client = eSpeakClient() + return eSpeakTTS(client) + + def init_providers(self): + """Initialize TTS providers based on configuration.""" + config = get_tts_config() + engine = config.get( + "engine", "tts" + ).lower() # Convert to lowercase for comparison + print(f"Initializing TTS provider: {engine}") + + try: + if engine == "google": + client = GoogleClient() + self.tts_instance = GoogleTTS(client) + elif engine == "microsoft": + client = MicrosoftClient() + self.tts_instance = MicrosoftTTS(client) + elif engine == "polly": + client = PollyClient() + self.tts_instance = PollyTTS(client) + elif engine == "watson": + client = WatsonClient() + self.tts_instance = WatsonTTS(client) + elif engine == "elevenlabs": + client = ElevenLabsClient() + self.tts_instance = ElevenLabsTTS(client) + elif engine == "witai": + client = WitAiClient() + self.tts_instance = WitAiTTS(client) + elif engine == "sherpaonnx": + client = SherpaOnnxClient() + self.tts_instance = SherpaOnnxTTS(client) + else: + print( + f"Engine '{engine}' not found, falling back to platform-specific TTS" + ) + self.tts_instance = self.get_platform_tts() + + if self.tts_instance: + print( + f"Successfully initialized {self.tts_instance.__class__.__name__}" + ) + else: + print("Failed to initialize TTS instance") + except Exception as e: + print(f"Error initializing TTS provider: {e}") + import traceback + + traceback.print_exc() + self.tts_instance = None + + def get_voices(self) -> list[dict[str, Any]]: + """Get available voices.""" + try: + if not self.tts_instance: + print("Initializing TTS providers...") + self.init_providers() + if not self.tts_instance: + print("No TTS instance available after initialization") + return [] + print(f"Getting voices from {self.tts_instance.__class__.__name__}") + voices = self.tts_instance.get_voices() + print(f"Found {len(voices)} voices") + return voices + except Exception as e: + print(f"Error getting voices: {e!s}") + return [] + + def get_speak_data( + self, text: str, voice_id: str, provider_id: str + ) -> bytes | None: + """Get speech data for the given text.""" + try: + if not self.tts_instance: + self.init_providers() + if not self.tts_instance: + return None + + # Set voice if specified + if voice_id: + self.tts_instance.set_voice(voice_id) + + # Generate audio data + return self.tts_instance.synth_to_bytes(text) + except Exception as e: + print(f"Error generating speech data: {e}") + return None + + def speak(self, text: str, provider_id: str, voice_id: str | None = None) -> None: + """Speak the given text using the specified voice.""" + try: + if not self.tts_instance: + self.init_providers() + if not self.tts_instance: + return + + # Set up callbacks for speech state tracking + self.tts_instance.connect("onStart", self.on_speech_start) + self.tts_instance.connect("onEnd", self.on_speech_end) + + # Set voice if specified + if voice_id: + self.tts_instance.set_voice(voice_id) + + # Generate and play audio + audio_bytes = self.tts_instance.synth_to_bytes(text) + self.tts_instance.load_audio(audio_bytes) + self._audio_loaded = True + self.tts_instance.play() + except Exception as e: + print(f"Error speaking text: {e}") + self._speaking = False + self._audio_loaded = False + + +# Create a singleton instance +speech_manager = SpeechManager() + + +# Export functions that use the singleton +def get_voices() -> list[dict[str, Any]]: + """Get available voices.""" + try: + if not speech_manager.tts_instance: + print("Initializing TTS providers...") + speech_manager.init_providers() + if not speech_manager.tts_instance: + print("No TTS instance available after initialization") + return [] + print(f"Getting voices from {speech_manager.tts_instance.__class__.__name__}") + voices = speech_manager.tts_instance.get_voices() + print(f"Found {len(voices)} voices") + return voices + except Exception as e: + print(f"Error getting voices: {e!s}") + return [] + + +def get_speak_data(text: str, voice_id: str, provider_id: str) -> bytes | None: + """Get speech data for the given text.""" + return speech_manager.get_speak_data(text, voice_id, provider_id) + + +def speak(text: str, provider_id: str, voice_id: str | None = None) -> None: + """Speak the given text using the specified voice.""" + speech_manager.speak(text, provider_id, voice_id) + + +def init_providers(): + """Initialize TTS providers.""" + speech_manager.init_providers() + + +def is_speaking() -> bool: + """Check if text is being spoken.""" + return speech_manager.is_speaking() + + +def stop_speaking() -> None: + """Stop the current speech playback.""" + speech_manager.stop_speaking() diff --git a/speech/start.py b/speech/start.py index d25ca24..d9c4333 100644 --- a/speech/start.py +++ b/speech/start.py @@ -1,13 +1,28 @@ #!/usr/bin/env python +import io +import logging import os import sys +from urllib.parse import unquote + from flask import Flask, jsonify, send_file from flask_cors import CORS -from urllib.parse import unquote -from io import BytesIO -import speechManager -import config +from flask_restx import Api, Resource, fields + +from .config import CACHE_ENABLED +from .speech_manager import ( + get_speak_data, + get_voices, + init_providers, + is_speaking, + speak, + stop_speaking, +) + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) # Add the parent directory to the Python path current_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -18,59 +33,189 @@ app.url_map.strict_slashes = False CORS(app) - -@app.route("/voices/", methods=["GET"]) -def voices(): - voices = speechManager.getVoices() - return jsonify(voices) - - -@app.route("/speak//", methods=["POST", "GET"]) -@app.route("/speak///", methods=["POST", "GET"]) -@app.route("/speak///", methods=["POST", "GET"]) -def speak_text(text: str, providerId: str = "", voiceId: str = ""): - text = unquote(text).lower() - providerId = unquote(providerId) - voiceId = unquote(voiceId) - speechManager.speak(text, providerId, voiceId) - return jsonify(True) - - -@app.route("/speakdata//", methods=["POST", "GET"]) -@app.route("/speakdata///", methods=["POST", "GET"]) -@app.route("/speakdata///", methods=["POST", "GET"]) -def speakData(text: str, providerId: str = "", voiceId: str = ""): - text = unquote(text).lower() - providerId = unquote(providerId) - voiceId = unquote(voiceId) - data = speechManager.getSpeakData(text, voiceId, providerId) - if data: - return send_file(BytesIO(data), mimetype="audio/wav") - return "Error generating speech", 400 - - -@app.route("/cache///", methods=["POST", "GET"]) -def cacheData(text: str, providerId: str = "", voiceId: str = ""): - if not config.cacheData: +# Initialize Flask-RESTX +api = Api( + app, + version="1.0", + title="AsTeRICS Grid Speech API", + description="API for text-to-speech functionality in AsTeRICS Grid", + doc="/docs", +) + +# Define namespaces +ns = api.namespace("", description="Speech synthesis operations") + +# Define models +voice_model = api.model( + "Voice", + { + "id": fields.String(description="Unique identifier for the voice"), + "name": fields.String(description="Display name of the voice"), + "language_codes": fields.List( + fields.String, description="Supported language codes" + ), + "gender": fields.String(description="Voice gender (M/F/N)"), + }, +) + +voices_response = api.model( + "VoicesResponse", + { + "voices": fields.List( + fields.Nested(voice_model), description="List of available voices" + ), + "status": fields.String(description="Response status (success/error)"), + "error": fields.String( + description="Error message if status is error", required=False + ), + }, +) + +error_response = api.model( + "ErrorResponse", + { + "error": fields.String(description="Error message"), + "status": fields.String(description="Response status (error)"), + }, +) + +success_response = api.model( + "SuccessResponse", + { + "status": fields.String(description="Response status (success)"), + }, +) + +speaking_response = api.model( + "SpeakingResponse", + { + "speaking": fields.Boolean( + description="Whether text is currently being spoken" + ), + "status": fields.String(description="Response status (success)"), + }, +) + +# Initialize speech provider +init_providers() + + +# Error handler for all exceptions +@app.errorhandler(Exception) +def handle_error(error): + """Handle all exceptions and return them as JSON responses.""" + logger.error(f"Error: {error!s}", exc_info=True) + return jsonify({"error": str(error), "status": "error"}), 200 + + +@ns.route("/voices") +class Voices(Resource): + @ns.doc("get_voices") + @ns.response(200, "Success", voices_response) + @ns.response(500, "Error", error_response) + def get(self): + """Get available voices.""" + try: + voices = get_voices() + return {"voices": voices, "status": "success"} + except Exception as e: + logger.error(f"Error in /voices endpoint: {e!s}", exc_info=True) + return {"error": str(e), "status": "error", "voices": []}, 200 + + +@ns.route("/speakdata///") +class SpeakData(Resource): + @ns.doc("get_speak_data") + @ns.param("text", "Text to convert to speech") + @ns.param("provider_id", "TTS provider ID") + @ns.param("voice_id", "Voice ID to use") + @ns.response(200, "Success") + @ns.response(500, "Error", error_response) + def get(self, text: str, provider_id: str, voice_id: str): + """Get speech data for text.""" + try: + data = get_speak_data(text, voice_id, provider_id) + if data is None: + return { + "error": "Failed to generate speech data", + "status": "error", + }, 200 + return send_file( + io.BytesIO(data), + mimetype="audio/wav", + as_attachment=True, + download_name="speech.wav", + ) + except Exception as e: + logger.error(f"Error in /speakdata endpoint: {e!s}", exc_info=True) + return {"error": str(e), "status": "error"}, 200 + + +@ns.route("/speak///") +class Speak(Resource): + @ns.doc("speak_text") + @ns.param("text", "Text to speak") + @ns.param("provider_id", "TTS provider ID") + @ns.param("voice_id", "Voice ID to use") + @ns.response(200, "Success", success_response) + @ns.response(500, "Error", error_response) + def get(self, text: str, provider_id: str, voice_id: str): + """Speak text using specified voice.""" + try: + speak(text, provider_id, voice_id) + return {"status": "success"} + except Exception as e: + logger.error(f"Error in /speak endpoint: {e!s}", exc_info=True) + return {"error": str(e), "status": "error"}, 200 + + +@app.route("/cache///", methods=["POST", "GET"]) +def cache_data(text: str, provider_id: str = "", voice_id: str = ""): + """Cache speech data for the given text.""" + if not CACHE_ENABLED: return jsonify(False) text = unquote(text).lower() - providerId = unquote(providerId) - voiceId = unquote(voiceId) - speechManager.getSpeakData(text, providerId, voiceId) + provider_id = unquote(provider_id) + voice_id = unquote(voice_id) + get_speak_data(text, voice_id, provider_id) return jsonify(True) -@app.route("/speaking/", methods=["GET"]) -def speaking(): - speaking = speechManager.isSpeaking() - return jsonify(speaking) - - -@app.route("/stop/", methods=["GET", "POST"]) -def stop(): - speechManager.stop() - return jsonify(True) +@ns.route("/speaking") +class Speaking(Resource): + @ns.doc("is_speaking") + @ns.response(200, "Success", speaking_response) + @ns.response(500, "Error", error_response) + def get(self): + """Check if text is being spoken.""" + try: + speaking = is_speaking() + return {"speaking": speaking, "status": "success"} + except Exception as e: + logger.error(f"Error in /speaking endpoint: {e!s}", exc_info=True) + return {"error": str(e), "status": "error", "speaking": False}, 200 + + +@ns.route("/stop") +class Stop(Resource): + @ns.doc("stop_speaking") + @ns.response(200, "Success", success_response) + @ns.response(500, "Error", error_response) + def get(self): + """Stop speaking.""" + try: + stop_speaking() + return {"status": "success"} + except Exception as e: + logger.error(f"Error in /stop endpoint: {e!s}", exc_info=True) + return {"error": str(e), "status": "error"}, 200 + + +def start_server(host: str = "127.0.0.1", port: int = 5555) -> None: + """Start the Flask server.""" + init_providers() + app.run(host=host, port=port) if __name__ == "__main__": - app.run(host="0.0.0.0", port=5555, threaded=True) + start_server() diff --git a/speech/test.py b/speech/test.py deleted file mode 100644 index cf81567..0000000 --- a/speech/test.py +++ /dev/null @@ -1,56 +0,0 @@ -import os -import sys - -# Add the current directory to the Python path -current_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -if current_dir not in sys.path: - sys.path.insert(0, current_dir) - -# Now we can import our modules -from speech.provider_platform_data import provider - - -def test_voices(): - print("\nTesting getVoices()...") - voices = provider.getVoices() - print(f"Found {len(voices)} voices:") - for voice in voices: - print(f"- {voice['name']} ({voice['lang']})") - return True - - -def test_speak_data(): - print("\nTesting getSpeakData()...") - # Get the first available voice - voices = provider.getVoices() - if not voices: - print("No voices available!") - return False - - voice_id = voices[0]["id"] - print(f"Using voice: {voices[0]['name']}") - - # Get speech data for "hello" - data = provider.getSpeakData("hello", voice_id) - print(f"Got {len(data)} bytes of audio data") - return True - - -def main(): - print("Starting speech system tests...") - - # Test voice listing - if not test_voices(): - print("❌ Voice listing test failed!") - return - - # Test speech data generation - if not test_speak_data(): - print("❌ Speech data test failed!") - return - - print("\n✅ All tests passed!") - - -if __name__ == "__main__": - main() diff --git a/speech/test_endpoints.py b/speech/test_endpoints.py new file mode 100644 index 0000000..ff8d73c --- /dev/null +++ b/speech/test_endpoints.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python + +import time +import unittest +from unittest.mock import patch + +from speech.config import get_tts_config +from speech.speech_manager import get_speak_data, get_voices +from speech.start import app + + +class TestSpeechService(unittest.TestCase): + """Test cases for the speech service.""" + + def setUp(self): + """Set up test environment.""" + self.app = app.test_client() + self.app.testing = True + + def test_voices_endpoint(self): + """Test the /voices endpoint.""" + response = self.app.get("/voices") + self.assertEqual(response.status_code, 200) + data = response.get_json() + self.assertIn("voices", data) + self.assertIn("status", data) + self.assertEqual(data["status"], "success") + voices = data["voices"] + self.assertIsInstance(voices, list) + if voices: # If any voices are available + voice = voices[0] + self.assertIn("id", voice) + self.assertIn("name", voice) + self.assertIn("language_codes", voice) + self.assertIn("gender", voice) + + def test_voices_endpoint_error(self): + """Test the /voices endpoint with error handling.""" + with patch("speech.start.get_voices") as mock_get_voices: + mock_get_voices.side_effect = Exception("Test error") + response = self.app.get("/voices") + self.assertEqual(response.status_code, 200) + data = response.get_json() + self.assertEqual(data["error"], "Test error") + self.assertEqual(data["status"], "error") + self.assertEqual(data["voices"], []) + + def test_speakdata_endpoint(self): + """Test the /speakdata endpoint.""" + text = "This is a test sentence to verify speech synthesis." + provider_id = "tts" # Default provider + voice_id = "en-US" # Default voice + response = self.app.get(f"/speakdata/{text}/{provider_id}/{voice_id}") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.mimetype, "audio/wav") + # Verify we got some audio data + self.assertGreater( + len(response.data), 1000 + ) # Should be at least 1KB of audio data + + def test_speakdata_endpoint_error(self): + """Test the /speakdata endpoint with error handling.""" + with patch("speech.start.get_speak_data") as mock_get_speak_data: + mock_get_speak_data.return_value = None + response = self.app.get("/speakdata/test/tts/en-US") + self.assertEqual(response.status_code, 200) + data = response.get_json() + self.assertEqual(data["error"], "Failed to generate speech data") + self.assertEqual(data["status"], "error") + + def test_speak_endpoint(self): + """Test the /speak endpoint.""" + text = "This is a test sentence to verify speech synthesis." + provider_id = "tts" # Default provider + voice_id = "en-US" # Default voice + response = self.app.get(f"/speak/{text}/{provider_id}/{voice_id}") + self.assertEqual(response.status_code, 200) + data = response.get_json() + self.assertEqual(data["status"], "success") + # Wait a bit to hear the speech + time.sleep(2) + + def test_speak_endpoint_error(self): + """Test the /speak endpoint with error handling.""" + with patch("speech.start.speak") as mock_speak: + mock_speak.side_effect = Exception("Test error") + response = self.app.get("/speak/test/tts/en-US") + self.assertEqual(response.status_code, 200) + data = response.get_json() + self.assertEqual(data["error"], "Test error") + self.assertEqual(data["status"], "error") + + def test_speaking_endpoint(self): + """Test the /speaking endpoint.""" + response = self.app.get("/speaking/") + self.assertEqual(response.status_code, 200) + data = response.get_json() + self.assertIn("speaking", data) + self.assertIn("status", data) + self.assertEqual(data["status"], "success") + + def test_stop_endpoint(self): + """Test the /stop endpoint.""" + response = self.app.get("/stop/") + self.assertEqual(response.status_code, 200) + data = response.get_json() + self.assertEqual(data["status"], "success") + + def test_stop_endpoint_error(self): + """Test the /stop endpoint with error handling.""" + with patch("speech.start.stop_speaking") as mock_stop: + mock_stop.side_effect = Exception("Test error") + response = self.app.get("/stop/") + self.assertEqual(response.status_code, 200) + data = response.get_json() + self.assertEqual(data["error"], "Test error") + self.assertEqual(data["status"], "error") + + def test_caching(self): + """Test speech data caching.""" + text = "test" + provider_id = "tts" # Default provider + voice_id = "en-US" # Default voice + # First request should generate and cache + response1 = self.app.get(f"/speakdata/{text}/{provider_id}/{voice_id}") + self.assertEqual(response1.status_code, 200) + # Second request should use cache + response2 = self.app.get(f"/speakdata/{text}/{provider_id}/{voice_id}") + self.assertEqual(response2.status_code, 200) + # Verify both responses are identical + self.assertEqual(response1.data, response2.data) + + def test_config(self): + """Test TTS configuration.""" + config = get_tts_config() + self.assertIsInstance(config, dict) + self.assertIn("engine", config) + self.assertIn("credentials", config) + self.assertIn("cache_enabled", config) + self.assertIn("cache_dir", config) + + def test_core_functionality(self): + """Test core speech functionality without HTTP.""" + # Test getting voices + voices = get_voices() + self.assertIsInstance(voices, list) + if voices: # If any voices are available + voice_id = voices[0]["id"] + # Test speech data generation + data = get_speak_data("test", voice_id, "tts") + self.assertIsInstance(data, bytes) + self.assertGreater(len(data), 0) + + def test_core_functionality_error(self): + """Test core speech functionality error handling.""" + with patch("speech.speech_manager.get_voices") as mock_get_voices: + mock_get_voices.side_effect = Exception("Test error") + voices = get_voices() + self.assertEqual(voices, []) + + def test_different_providers(self): + """Test different TTS providers. + + Note: Most providers require API keys to work. This test only verifies + that the provider initialization works, not that it can actually generate speech. + """ + # Test providers that don't require API keys + local_providers = ["sherpaonnx", "tts"] + for provider in local_providers: + with ( + patch("speech.config.get_tts_config") as mock_config, + patch("speech.speech_manager.speech_manager") as mock_manager, + ): + mock_config.return_value = {"engine": provider} + mock_manager.tts_instance.get_voices.return_value = [ + { + "id": f"{provider}_test_voice", + "name": f"Test Voice ({provider})", + "language_codes": ["en-US"], + "gender": "N", + } + ] + voices = get_voices() + self.assertIsInstance(voices, list) + # Verify we got at least one voice with the expected ID + voice_ids = [v["id"] for v in voices] + self.assertIn(f"{provider}_test_voice", voice_ids) + + # Test providers that require API keys (mocked) + api_providers = [ + "google", + "microsoft", + "polly", + "watson", + "elevenlabs", + "witai", + ] + for provider in api_providers: + with ( + patch("speech.config.get_tts_config") as mock_config, + patch("speech.speech_manager.speech_manager") as mock_manager, + ): + mock_config.return_value = { + "engine": provider, + "credentials": {"api_key": "test_key", "region": "test_region"}, + } + mock_manager.tts_instance.get_voices.return_value = [ + { + "id": f"{provider}_test_voice", + "name": f"Test Voice ({provider})", + "language_codes": ["en-US"], + "gender": "N", + } + ] + voices = get_voices() + self.assertIsInstance(voices, list) + # Verify we got at least one voice with the expected ID + voice_ids = [v["id"] for v in voices] + self.assertIn(f"{provider}_test_voice", voice_ids) + + +if __name__ == "__main__": + unittest.main() diff --git a/speech/tests/coqui_test.py b/speech/tests/coqui_test.py deleted file mode 100644 index a373da5..0000000 --- a/speech/tests/coqui_test.py +++ /dev/null @@ -1,31 +0,0 @@ -import torch -from TTS.api import TTS -import os - -dirname = os.path.dirname(__file__) -filename = os.path.join(dirname, "trump.wav") -output = os.path.join(dirname, "output_trump.wav") - -# Get device -device = "cuda" if torch.cuda.is_available() else "cpu" - -# List available 🐸TTS models -print(TTS().list_models()) - -# Init TTS -tts = TTS("tts_models/multilingual/multi-dataset/xtts_v2").to(device) -# tts = TTS(model_name="tts_models/de/thorsten/tacotron2-DDC", progress_bar=False) -# Run TTS -# tts.tts_to_file(text="Hallo mein Name ist Max. Ich teste nun die Sprachausgabe.", file_path=output) - -# Run TTS -# ❗ Since this model is multi-lingual voice cloning model, we must set the target speaker_wav and language -# Text to speech list of amplitude values as output -# wav = tts.tts(text="Hello world!", speaker_wav=filename, language="de") -# Text to speech to a file -tts.tts_to_file( - text="Hello, and good morning. I am Donald Trump, president of the United States. I want to note that your work at the University of Applied Sciences Technikum Wien is really amazing. Keep on going!", - speaker_wav=filename, - language="en", - file_path=output, -) diff --git a/speech/util.py b/speech/util.py deleted file mode 100644 index 3582318..0000000 --- a/speech/util.py +++ /dev/null @@ -1,53 +0,0 @@ -import os -from urllib.parse import quote -from typing import Optional - - -def getTempFilename(providerId: str) -> str: - providerId = getSafeString(providerId) - return "temp_{}.wav".format(providerId) - - -def getTempFileFullPath(providerId: str) -> str: - providerId = getSafeString(providerId) - currentDir = os.path.dirname(__file__) - dirname = os.path.join(currentDir, "temp") - if not os.path.exists(dirname): - os.mkdir(dirname) - return os.path.join(dirname, getTempFilename(providerId)) - - -def getCacheFileFullPath(text: str, providerId: str, voiceId: str) -> str: - providerId = getSafeString(providerId) - text = quote(text) - voiceId = getSafeString(str(voiceId)) - currentDir = os.path.dirname(__file__) - dirname = os.path.join(currentDir, "temp", providerId, voiceId) - if not os.path.exists(dirname): - os.makedirs(dirname) - return os.path.join(dirname, "{}.wav".format(text)) - - -def getTempFileData(providerId: str) -> bytes: - path = getTempFileFullPath(providerId) - with open(path, "rb") as in_file: - return in_file.read() - - -def saveCacheData(text: str, providerId: str, voiceId: str, data: bytes) -> None: - path = getCacheFileFullPath(text, providerId, voiceId) - with open(path, "wb") as out_file: - out_file.write(data) - - -def getCacheData(text: str, providerId: str, voiceId: str) -> Optional[bytes]: - path = getCacheFileFullPath(text, providerId, voiceId) - if os.path.isfile(path): - with open(path, "rb") as in_file: - return in_file.read() - return None - - -def getSafeString(string: str) -> str: - keepcharacters = (" ", ".", "_", "-") - return "".join(c for c in string if c.isalnum() or c in keepcharacters).rstrip() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..c198898 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1157 @@ +version = 1 +requires-python = ">=3.11" +resolution-markers = [ + "sys_platform == 'darwin'", + "sys_platform == 'win32'", + "sys_platform == 'linux'", + "sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", +] + +[[package]] +name = "altgraph" +version = "0.17.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/a8/7145824cf0b9e3c28046520480f207df47e927df83aa9555fb47f8505922/altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406", size = 48418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/3f/3bc3f1d83f6e4a7fcb834d3720544ca597590425be5ba9db032b2bf322a2/altgraph-0.17.4-py2.py3-none-any.whl", hash = "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff", size = 21212 }, +] + +[[package]] +name = "aniso8601" +version = "10.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/3f/dc8a28fa6dc72c13d8c158b01f8975f240e9e72c336cc1ae00f424e2d7ce/aniso8601-10.0.0.tar.gz", hash = "sha256:ff1d0fc2346688c62c0151547136ac30e322896ed8af316ef7602c47da9426cf", size = 47008 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/bf/d5cde2cb7cdc2cb1770d974418d169a79c3187bd962cb752b9fd617848ca/aniso8601-10.0.0-py2.py3-none-any.whl", hash = "sha256:3c943422efaa0229ebd2b0d7d223effb5e7c89e24d2267ebe76c61a2d8e290cb", size = 52767 }, +] + +[[package]] +name = "antlr4-python3-runtime" +version = "4.9.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/38/7859ff46355f76f8d19459005ca000b6e7012f2f1ca597746cbcd1fbfe5e/antlr4-python3-runtime-4.9.3.tar.gz", hash = "sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b", size = 117034 } + +[[package]] +name = "asterics-grid-helper" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "flask" }, + { name = "flask-cors" }, + { name = "flask-restx" }, + { name = "hydra-core" }, + { name = "omegaconf" }, + { name = "py3-tts-wrapper", marker = "sys_platform == 'darwin'" }, + { name = "py3-tts-wrapper", extra = ["controlaudio", "googletrans", "microsoft", "polly", "sherpaonnx", "watson"] }, + { name = "py3-tts-wrapper", extra = ["sapi"], marker = "sys_platform == 'win32'" }, +] + +[package.optional-dependencies] +test = [ + { name = "hydra-core" }, + { name = "omegaconf" }, + { name = "pyinstaller" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, + { name = "requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "flask", specifier = ">=3.0.0" }, + { name = "flask-cors", specifier = ">=4.0.0" }, + { name = "flask-restx", specifier = ">=1.3.0" }, + { name = "hydra-core", specifier = ">=1.3.0" }, + { name = "hydra-core", marker = "extra == 'test'" }, + { name = "omegaconf", specifier = ">=2.3.0" }, + { name = "omegaconf", marker = "extra == 'test'" }, + { name = "py3-tts-wrapper", extras = ["avsynth"], marker = "sys_platform == 'darwin'" }, + { name = "py3-tts-wrapper", extras = ["espeak"], marker = "sys_platform == 'linux'" }, + { name = "py3-tts-wrapper", extras = ["sapi"], marker = "sys_platform == 'win32'" }, + { name = "py3-tts-wrapper", extras = ["sherpaonnx", "espeak", "elevenlabs", "playht", "microsoft", "polly", "watson", "googletrans", "witai", "controlaudio"] }, + { name = "pyinstaller", marker = "extra == 'test'" }, + { name = "pytest", marker = "extra == 'test'" }, + { name = "pytest-cov", marker = "extra == 'test'" }, + { name = "pytest-mock", marker = "extra == 'test'", specifier = ">=3.12.0" }, + { name = "requests", marker = "extra == 'test'" }, +] + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815 }, +] + +[[package]] +name = "azure-cognitiveservices-speech" +version = "1.43.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9f/7eb0d57fa4b9184b190cde12eda20f89ba7078c2d8f88a2cc9b8231af846/azure_cognitiveservices_speech-1.43.0-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:af81ef91103f9174095f4dcdc173fbce180c9d4d7956f03d79859c9a10e8b320", size = 7479901 }, + { url = "https://files.pythonhosted.org/packages/46/a5/0568509dfbd22b15a28a260f4b4c4f2d1a7bc212cdd09de7cff425b7c86b/azure_cognitiveservices_speech-1.43.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ac09cc81ab01d0db4e2d9a79a3894c6a8d09e1359d0eeb5070aa9194a0c84576", size = 7331196 }, + { url = "https://files.pythonhosted.org/packages/a8/b3/1db6c96520a3ec70f14328a373991f8e6ce36e9112b3e73f2a956d67f477/azure_cognitiveservices_speech-1.43.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:e12527746fc5bff040c66e20172544e9708e10b29d9f3acc365576d44ccb7c5c", size = 40935541 }, + { url = "https://files.pythonhosted.org/packages/ee/02/c73a10f5d8ecf83e54f02e6e24813ce7661ad50f532944da5c0ecaeaea54/azure_cognitiveservices_speech-1.43.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:07bdedba8494edfb24306279d3b0500ece016fc811ec0b3366707a75d118a245", size = 40710733 }, + { url = "https://files.pythonhosted.org/packages/83/c5/b593f08f70b73b8a997b87673235f83ec42d9c9bf0fae7f348e889dfc00c/azure_cognitiveservices_speech-1.43.0-py3-none-win32.whl", hash = "sha256:36570806a6b8fe12696a0372193ecc623bc629e355fa1edc67c03ac71731066b", size = 2152884 }, + { url = "https://files.pythonhosted.org/packages/f5/b8/b1e7894cb4bcd721356eb1687e6f17112c2c659f4365827b8e7daac07c7d/azure_cognitiveservices_speech-1.43.0-py3-none-win_amd64.whl", hash = "sha256:50a50aabc69434d1311c09eaa640622c1d47d270e6cbcf5d192a04325cb7de4c", size = 2410492 }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458 }, +] + +[[package]] +name = "boto3" +version = "1.37.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/5b/bd745290810e38fdbfeb6d183b529c8991291412eb00a2d9546eab4cd122/boto3-1.37.15.tar.gz", hash = "sha256:586332456fff19328d57a88214a2ac2eda1bafab743556a836eda46a4ce613c6", size = 111335 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/59/45509aa45eacac81bf280d9b2dd937cd20a702d03cd922d9e52fcf675371/boto3-1.37.15-py3-none-any.whl", hash = "sha256:78cc1b483cc637e1df8e81498d66f89550d4ee92175ccab5be1a2226672fe6b9", size = 139561 }, +] + +[[package]] +name = "botocore" +version = "1.37.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/37/ce3ea9c8354186996ed572afe55560469f41cd761f845395d3f4a7f976fd/botocore-1.37.15.tar.gz", hash = "sha256:72e6f1db6ebc4112d6ba719c97ad71ac7cf4a2f3729ae74fa225641e3ddcba92", size = 13655143 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/39/b8b2a84f0d5b6d3a7db542979cf8d4e28f4251b549d785548a1df8e95093/botocore-1.37.15-py3-none-any.whl", hash = "sha256:996b8d6a342ad7735eb07d8b4a81dad86e60ce0889ccb3edec0cd66eece85393", size = 13419803 }, +] + +[[package]] +name = "certifi" +version = "2025.1.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264 }, + { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651 }, + { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 }, + { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200 }, + { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235 }, + { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721 }, + { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242 }, + { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999 }, + { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242 }, + { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604 }, + { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 }, + { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 }, + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/80/41ef5d5a7935d2d3a773e3eaebf0a9350542f2cab4eac59a7a4741fbbbbe/charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", size = 194995 }, + { url = "https://files.pythonhosted.org/packages/7a/28/0b9fefa7b8b080ec492110af6d88aa3dea91c464b17d53474b6e9ba5d2c5/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", size = 139471 }, + { url = "https://files.pythonhosted.org/packages/71/64/d24ab1a997efb06402e3fc07317e94da358e2585165930d9d59ad45fcae2/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", size = 149831 }, + { url = "https://files.pythonhosted.org/packages/37/ed/be39e5258e198655240db5e19e0b11379163ad7070962d6b0c87ed2c4d39/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", size = 142335 }, + { url = "https://files.pythonhosted.org/packages/88/83/489e9504711fa05d8dde1574996408026bdbdbd938f23be67deebb5eca92/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", size = 143862 }, + { url = "https://files.pythonhosted.org/packages/c6/c7/32da20821cf387b759ad24627a9aca289d2822de929b8a41b6241767b461/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", size = 145673 }, + { url = "https://files.pythonhosted.org/packages/68/85/f4288e96039abdd5aeb5c546fa20a37b50da71b5cf01e75e87f16cd43304/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", size = 140211 }, + { url = "https://files.pythonhosted.org/packages/28/a3/a42e70d03cbdabc18997baf4f0227c73591a08041c149e710045c281f97b/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", size = 148039 }, + { url = "https://files.pythonhosted.org/packages/85/e4/65699e8ab3014ecbe6f5c71d1a55d810fb716bbfd74f6283d5c2aa87febf/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", size = 151939 }, + { url = "https://files.pythonhosted.org/packages/b1/82/8e9fe624cc5374193de6860aba3ea8070f584c8565ee77c168ec13274bd2/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", size = 149075 }, + { url = "https://files.pythonhosted.org/packages/3d/7b/82865ba54c765560c8433f65e8acb9217cb839a9e32b42af4aa8e945870f/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", size = 144340 }, + { url = "https://files.pythonhosted.org/packages/b5/b6/9674a4b7d4d99a0d2df9b215da766ee682718f88055751e1e5e753c82db0/charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", size = 95205 }, + { url = "https://files.pythonhosted.org/packages/1e/ab/45b180e175de4402dcf7547e4fb617283bae54ce35c27930a6f35b6bef15/charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", size = 102441 }, + { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 }, + { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 }, + { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 }, + { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 }, + { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 }, + { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 }, + { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 }, + { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 }, + { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 }, + { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 }, + { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 }, + { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 }, + { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 }, + { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, + { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, + { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, + { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, + { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, + { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, + { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, + { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, + { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, + { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, + { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, + { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, + { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, + { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "platform_system == 'Windows' and sys_platform != 'darwin' and sys_platform != 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "comtypes" +version = "1.4.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/7e/34f4941ab5ec7d1d4c496282b1944a5119fc52641c5770a78e6fa0ca32ec/comtypes-1.4.10.zip", hash = "sha256:b92372e76299836177b41aeda784225e18c5071c6bacdab88a7433224a4dc912", size = 267293 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/44/72009bb0a0d8286f6408c9cb70552350e21e9c280bfa1ef30784b30dfc0f/comtypes-1.4.10-py3-none-any.whl", hash = "sha256:e078555721ee7ab40648a3363697d420b845b323e5944b55846e96aff97d2534", size = 241481 }, +] + +[[package]] +name = "coverage" +version = "7.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/36/465f5492443265e1278f9a82ffe6aeed3f1db779da0d6e7d4611a5cfb6af/coverage-7.7.0.tar.gz", hash = "sha256:cd879d4646055a573775a1cec863d00c9ff8c55860f8b17f6d8eee9140c06166", size = 809969 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/ec/9e0c9358a3bd56b1ddbf266b889ea9d51ee29e58fb72712d5600663fa806/coverage-7.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a0a207c87a9f743c8072d059b4711f8d13c456eb42dac778a7d2e5d4f3c253a7", size = 210722 }, + { url = "https://files.pythonhosted.org/packages/be/bd/7b47a4302423a13960ee30682900d7ca20cee15c978b1d9ea9594d59d352/coverage-7.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2d673e3add00048215c2cc507f1228a7523fd8bf34f279ac98334c9b07bd2656", size = 211154 }, + { url = "https://files.pythonhosted.org/packages/c6/7c/ae54d9022440196bf9f3fad535388678a3db186980ff58a4956ddeb849a2/coverage-7.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f81fe93dc1b8e5673f33443c0786c14b77e36f1025973b85e07c70353e46882b", size = 243787 }, + { url = "https://files.pythonhosted.org/packages/2d/21/913a2a2d89a2221f4410fbea4ff84e64ddf4367a4b9eb2c328bd01a1a401/coverage-7.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8c7524779003d59948c51b4fcbf1ca4e27c26a7d75984f63488f3625c328b9b", size = 241473 }, + { url = "https://files.pythonhosted.org/packages/40/f1/5ae36fffd542fb86ab3b2d5e012af0840265f3dd001ad0ffabe9e4dbdcf6/coverage-7.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c124025430249118d018dcedc8b7426f39373527c845093132196f2a483b6dd", size = 243259 }, + { url = "https://files.pythonhosted.org/packages/47/1b/abc87bad7f606a4df321bd8300413fe13700099a163e7d63453c7c70c1b2/coverage-7.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e7f559c36d5cdc448ee13e7e56ed7b6b5d44a40a511d584d388a0f5d940977ba", size = 242904 }, + { url = "https://files.pythonhosted.org/packages/e0/b3/ff0cf15f5709996727dda2fa00af6f4da92ea3e16168400346f2f742341a/coverage-7.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:37cbc7b0d93dfd133e33c7ec01123fbb90401dce174c3b6661d8d36fb1e30608", size = 241079 }, + { url = "https://files.pythonhosted.org/packages/05/c9/fcad82aad05b1eb8040e6c25ae7a1303716cc05718d4dd326e0fab31aa14/coverage-7.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7d2a65876274acf544703e943c010b60bd79404e3623a1e5d52b64a6e2728de5", size = 241617 }, + { url = "https://files.pythonhosted.org/packages/59/9f/d1efe149afa5c3a459c08bf04f7e6917ef4ee8e3440df5c3e87d6b972870/coverage-7.7.0-cp311-cp311-win32.whl", hash = "sha256:f5a2f71d6a91238e7628f23538c26aa464d390cbdedf12ee2a7a0fb92a24482a", size = 213372 }, + { url = "https://files.pythonhosted.org/packages/88/d2/4b58f03e399185b01fb3168d4b870882de9c7a10e273f99c8f25ec690302/coverage-7.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:ae8006772c6b0fa53c33747913473e064985dac4d65f77fd2fdc6474e7cd54e4", size = 214285 }, + { url = "https://files.pythonhosted.org/packages/b7/47/f7b870caa26082ff8033be074ac61dc175a6b0c965adf7b910f92a6d7cfe/coverage-7.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:056d3017ed67e7ddf266e6f57378ece543755a4c9231e997789ab3bd11392c94", size = 210907 }, + { url = "https://files.pythonhosted.org/packages/ea/eb/40b39bdc6c1da403257f0fcb2c1b2fd81ff9f66c13abbe3862f42780e1c1/coverage-7.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:33c1394d8407e2771547583b66a85d07ed441ff8fae5a4adb4237ad39ece60db", size = 211162 }, + { url = "https://files.pythonhosted.org/packages/53/08/42a2db41b4646d6261122773e222dd7105e2306526f2d7846de6fee808ec/coverage-7.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fbb7a0c3c21908520149d7751cf5b74eb9b38b54d62997b1e9b3ac19a8ee2fe", size = 245223 }, + { url = "https://files.pythonhosted.org/packages/78/2a/0ceb328a7e67e8639d5c7800b8161d4b5f489073ac8d5ac33b11eadee218/coverage-7.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bb356e7ae7c2da13f404bf8f75be90f743c6df8d4607022e759f5d7d89fe83f8", size = 242114 }, + { url = "https://files.pythonhosted.org/packages/ba/68/42b13b849d40af1581830ff06c60f4ec84649764f4a58d5c6e20ae11cbd4/coverage-7.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bce730d484038e97f27ea2dbe5d392ec5c2261f28c319a3bb266f6b213650135", size = 244371 }, + { url = "https://files.pythonhosted.org/packages/68/66/ab7c3b9fdbeb8bdd322f5b67b1886463834dba2014a534caba60fb0075ea/coverage-7.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa4dff57fc21a575672176d5ab0ef15a927199e775c5e8a3d75162ab2b0c7705", size = 244134 }, + { url = "https://files.pythonhosted.org/packages/01/74/b833d299a479681957d6b238e16a0725586e1d56ec1e43658f3184550bb0/coverage-7.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b667b91f4f714b17af2a18e220015c941d1cf8b07c17f2160033dbe1e64149f0", size = 242353 }, + { url = "https://files.pythonhosted.org/packages/f9/c5/0ed656d65da39bbab8e8fc367dc3d465a7501fea0f2b1caccfb4f6361c9f/coverage-7.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:693d921621a0c8043bfdc61f7d4df5ea6d22165fe8b807cac21eb80dd94e4bbd", size = 243543 }, + { url = "https://files.pythonhosted.org/packages/87/b5/142bcff3828e4cce5d4c9ddc9222de1664464263acca09638e4eb0dbda7c/coverage-7.7.0-cp312-cp312-win32.whl", hash = "sha256:52fc89602cde411a4196c8c6894afb384f2125f34c031774f82a4f2608c59d7d", size = 213543 }, + { url = "https://files.pythonhosted.org/packages/29/74/99d226985def03284bad6a9aff27a1079a8881ec7523b5980b00a5260527/coverage-7.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ce8cf59e09d31a4915ff4c3b94c6514af4c84b22c4cc8ad7c3c546a86150a92", size = 214344 }, + { url = "https://files.pythonhosted.org/packages/45/2f/df6235ec963b9eb6b6b2f3c24f70448f1ffa13b9a481c155a6caff176395/coverage-7.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4545485fef7a8a2d8f30e6f79ce719eb154aab7e44217eb444c1d38239af2072", size = 210934 }, + { url = "https://files.pythonhosted.org/packages/f3/85/ff19510bf642e334845318ddb73a550d2b17082831fa9ae053ce72288be7/coverage-7.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1393e5aa9441dafb0162c36c8506c648b89aea9565b31f6bfa351e66c11bcd82", size = 211212 }, + { url = "https://files.pythonhosted.org/packages/2d/6a/af6582a419550d35eacc3e1bf9f4a936dda0ae559632a0bc4e3aef694ac8/coverage-7.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:316f29cc3392fa3912493ee4c83afa4a0e2db04ff69600711f8c03997c39baaa", size = 244727 }, + { url = "https://files.pythonhosted.org/packages/55/62/7c49526111c91f3d7d27e111c22c8d08722f5b661c3f031b625b4d7bc4d9/coverage-7.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1ffde1d6bc2a92f9c9207d1ad808550873748ac2d4d923c815b866baa343b3f", size = 241768 }, + { url = "https://files.pythonhosted.org/packages/62/4b/2dc27700782be9795cbbbe98394dd19ef74815d78d5027ed894972cd1b4a/coverage-7.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:416e2a8845eaff288f97eaf76ab40367deafb9073ffc47bf2a583f26b05e5265", size = 243790 }, + { url = "https://files.pythonhosted.org/packages/d3/11/9cc1ae56d3015edca69437f3121c2b44de309f6828980b29e4cc9b13246d/coverage-7.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5efdeff5f353ed3352c04e6b318ab05c6ce9249c25ed3c2090c6e9cadda1e3b2", size = 243861 }, + { url = "https://files.pythonhosted.org/packages/db/e4/2398ed93edcf42ff43002d91c37be11514d825cec382606654fd44f4b8fa/coverage-7.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:57f3bd0d29bf2bd9325c0ff9cc532a175110c4bf8f412c05b2405fd35745266d", size = 241942 }, + { url = "https://files.pythonhosted.org/packages/ec/fe/b6bd35b17a2b8d26bdb21d5ea4351a837ec01edf552655e833629af05b90/coverage-7.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3ab7090f04b12dc6469882ce81244572779d3a4b67eea1c96fb9ecc8c607ef39", size = 243228 }, + { url = "https://files.pythonhosted.org/packages/6d/06/d8701bae1e5d865edeb00a6c2a71bd7659ca6af349789271c6fd16a57909/coverage-7.7.0-cp313-cp313-win32.whl", hash = "sha256:180e3fc68ee4dc5af8b33b6ca4e3bb8aa1abe25eedcb958ba5cff7123071af68", size = 213572 }, + { url = "https://files.pythonhosted.org/packages/d7/c1/7e67780bfcaed6bed20100c9e1b2645e3414577b4bdad169578325249045/coverage-7.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:55143aa13c49491f5606f05b49ed88663446dce3a4d3c5d77baa4e36a16d3573", size = 214372 }, + { url = "https://files.pythonhosted.org/packages/ed/25/50b0447442a415ad3da33093c589d9ef945dd6933225f1ce0ac97476397e/coverage-7.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:cc41374d2f27d81d6558f8a24e5c114580ffefc197fd43eabd7058182f743322", size = 211774 }, + { url = "https://files.pythonhosted.org/packages/13/cc/3daddc707e934d3c0aafaa4a9b217f53fcf4133d4e40cc6ae63aa51243b8/coverage-7.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:89078312f06237417adda7c021c33f80f7a6d2db8572a5f6c330d89b080061ce", size = 211995 }, + { url = "https://files.pythonhosted.org/packages/98/99/c92f43355d3d67f6bf8c946a350f2174e18f9ea7c8a1e36c9eb84ab7d20b/coverage-7.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b2f144444879363ea8834cd7b6869d79ac796cb8f864b0cfdde50296cd95816", size = 256226 }, + { url = "https://files.pythonhosted.org/packages/25/62/65f0f33c08e0a1632f1e487b9c2d252e8bad6a77a942836043972b0ba6d2/coverage-7.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:60e6347d1ed882b1159ffea172cb8466ee46c665af4ca397edbf10ff53e9ffaf", size = 251937 }, + { url = "https://files.pythonhosted.org/packages/b2/10/99a9565aaeb159aade178c6509c8324a9c9e825b01f02242a37c2a8869f8/coverage-7.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb203c0afffaf1a8f5b9659a013f8f16a1b2cad3a80a8733ceedc968c0cf4c57", size = 254276 }, + { url = "https://files.pythonhosted.org/packages/a7/12/206196edbf0b82250b11bf5c252fe25ebaa2b7c8d66edb0c194e7b3403fe/coverage-7.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ad0edaa97cb983d9f2ff48cadddc3e1fb09f24aa558abeb4dc9a0dbacd12cbb4", size = 255366 }, + { url = "https://files.pythonhosted.org/packages/a5/82/a2abb8d4cdd99c6a443ab6682c0eee5797490a2113a45ffaa8b6b31c5dcc/coverage-7.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:c5f8a5364fc37b2f172c26a038bc7ec4885f429de4a05fc10fdcb53fb5834c5c", size = 253536 }, + { url = "https://files.pythonhosted.org/packages/4d/7d/3747e000e60ad5dd8157bd978f99979967d56cb35c55235980c85305db86/coverage-7.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4e09534037933bf6eb31d804e72c52ec23219b32c1730f9152feabbd7499463", size = 254344 }, + { url = "https://files.pythonhosted.org/packages/45/56/7c33f8a6de1b3b079374d2ae490ccf76fb7c094a23f72d10f071989fc3ef/coverage-7.7.0-cp313-cp313t-win32.whl", hash = "sha256:1b336d06af14f8da5b1f391e8dec03634daf54dfcb4d1c4fb6d04c09d83cef90", size = 214284 }, + { url = "https://files.pythonhosted.org/packages/95/ab/657bfa6171800a67bd1c005402f06d6b78610820ef1364ea4f85b04bbb5b/coverage-7.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b54a1ee4c6f1905a436cbaa04b26626d27925a41cbc3a337e2d3ff7038187f07", size = 215445 }, + { url = "https://files.pythonhosted.org/packages/cb/69/6a5eac32d2e8721274ef75df1b9fd6a8f7e8231e41ff7bc5501f19835f25/coverage-7.7.0-pp39.pp310.pp311-none-any.whl", hash = "sha256:3b0e6e54591ae0d7427def8a4d40fca99df6b899d10354bab73cd5609807261c", size = 202813 }, + { url = "https://files.pythonhosted.org/packages/2a/ac/60f409a448e5b0e9b8539716f683568aa5848c1be903cdbbc805a552cdf8/coverage-7.7.0-py3-none-any.whl", hash = "sha256:708f0a1105ef2b11c79ed54ed31f17e6325ac936501fc373f24be3e6a578146a", size = 202803 }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "flask" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/50/dff6380f1c7f84135484e176e0cac8690af72fa90e932ad2a0a60e28c69b/flask-3.1.0.tar.gz", hash = "sha256:5f873c5184c897c8d9d1b05df1e3d01b14910ce69607a117bd3277098a5836ac", size = 680824 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/47/93213ee66ef8fae3b93b3e29206f6b251e65c97bd91d8e1c5596ef15af0a/flask-3.1.0-py3-none-any.whl", hash = "sha256:d667207822eb83f1c4b50949b1623c8fc8d51f2341d65f72e1a1815397551136", size = 102979 }, +] + +[[package]] +name = "flask-cors" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/d8/667bd90d1ee41c96e938bafe81052494e70b7abd9498c4a0215c103b9667/flask_cors-5.0.1.tar.gz", hash = "sha256:6ccb38d16d6b72bbc156c1c3f192bc435bfcc3c2bc864b2df1eb9b2d97b2403c", size = 11643 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/61/4aea5fb55be1b6f95e604627dc6c50c47d693e39cab2ac086ee0155a0abd/flask_cors-5.0.1-py3-none-any.whl", hash = "sha256:fa5cb364ead54bbf401a26dbf03030c6b18fb2fcaf70408096a572b409586b0c", size = 11296 }, +] + +[[package]] +name = "flask-restx" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aniso8601" }, + { name = "flask" }, + { name = "importlib-resources" }, + { name = "jsonschema" }, + { name = "pytz" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/4c/2e7d84e2b406b47cf3bf730f521efe474977b404ee170d8ea68dc37e6733/flask-restx-1.3.0.tar.gz", hash = "sha256:4f3d3fa7b6191fcc715b18c201a12cd875176f92ba4acc61626ccfd571ee1728", size = 2814072 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/bf/1907369f2a7ee614dde5152ff8f811159d357e77962aa3f8c2e937f63731/flask_restx-1.3.0-py2.py3-none-any.whl", hash = "sha256:636c56c3fb3f2c1df979e748019f084a938c4da2035a3e535a4673e4fc177691", size = 2798683 }, +] + +[[package]] +name = "gtts" +version = "2.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/79/5ddb1dfcd663581d0d3fca34ccb1d8d841b47c22a24dc8dce416e3d87dfa/gtts-2.5.4.tar.gz", hash = "sha256:f5737b585f6442f677dbe8773424fd50697c75bdf3e36443585e30a8d48c1884", size = 24018 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/6c/8b8b1fdcaee7e268536f1bb00183a5894627726b54a9ddc6fc9909888447/gTTS-2.5.4-py3-none-any.whl", hash = "sha256:5dd579377f9f5546893bc26315ab1f846933dc27a054764b168f141065ca8436", size = 29184 }, +] + +[[package]] +name = "hydra-core" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "antlr4-python3-runtime" }, + { name = "omegaconf" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/8e/07e42bc434a847154083b315779b0a81d567154504624e181caf2c71cd98/hydra-core-1.3.2.tar.gz", hash = "sha256:8a878ed67216997c3e9d88a8e72e7b4767e81af37afb4ea3334b269a4390a824", size = 3263494 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/50/e0edd38dcd63fb26a8547f13d28f7a008bc4a3fd4eb4ff030673f22ad41a/hydra_core-1.3.2-py3-none-any.whl", hash = "sha256:fa0238a9e31df3373b35b0bfb672c34cc92718d21f81311d8996a16de1141d8b", size = 154547 }, +] + +[[package]] +name = "ibm-cloud-sdk-core" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyjwt" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/99/b126b89c3905dd85d0f35f20252c058999fd452a162566b05641f219e9ea/ibm_cloud_sdk_core-3.23.0.tar.gz", hash = "sha256:393cb7c8e747d2706f495bc29ae54871979ab2f2e7cb907cd4d4d4f011a92d93", size = 70763 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/7f/14ae0adca4b6ab18d4eecb932aca5277f2d3c43b97679407dd13eea8243e/ibm_cloud_sdk_core-3.23.0-py3-none-any.whl", hash = "sha256:9cbdd27de2306abd0916f6905b1d8aaa73b1bae510a4dc2a49fa2a62d40a6de5", size = 69509 }, +] + +[[package]] +name = "ibm-watson" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ibm-cloud-sdk-core" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/b4/ac938018701185c467f6703fffd5d33f5d1f7edb9f4e7d003a19da2ff532/ibm_watson-9.0.0.tar.gz", hash = "sha256:af9266e24b3e391310c26ad0dfaeca0ed9fecd39a2f1db30f18a36e561df6e9a", size = 342769 } + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "importlib-resources" +version = "6.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461 }, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234 }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, +] + +[[package]] +name = "jmespath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256 }, +] + +[[package]] +name = "jsonschema" +version = "4.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462 }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2024.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/db/58f950c996c793472e336ff3655b13fbcf1e3b359dcf52dcf3ed3b52c352/jsonschema_specifications-2024.10.1.tar.gz", hash = "sha256:0f38b83639958ce1152d02a7f062902c41c8fd20d558b0c34344292d417ae272", size = 15561 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/0f/8910b19ac0670a0f80ce1008e5e751c4a57e14d2c4c13a482aa6079fa9d6/jsonschema_specifications-2024.10.1-py3-none-any.whl", hash = "sha256:a09a0680616357d9a0ecf05c12ad234479f549239d0f5b55f3deea67475da9bf", size = 18459 }, +] + +[[package]] +name = "macholib" +version = "1.16.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph", marker = "sys_platform == 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/ee/af1a3842bdd5902ce133bd246eb7ffd4375c38642aeb5dc0ae3a0329dfa2/macholib-1.16.3.tar.gz", hash = "sha256:07ae9e15e8e4cd9a788013d81f5908b3609aa76f9b1421bae9c4d7606ec86a30", size = 59309 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/5d/c059c180c84f7962db0aeae7c3b9303ed1d73d76f2bfbc32bc231c8be314/macholib-1.16.3-py2.py3-none-any.whl", hash = "sha256:0e315d7583d38b8c77e815b1ecbdbf504a8258d8b3e17b61165c6feb60d18f2c", size = 38094 }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353 }, + { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392 }, + { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984 }, + { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120 }, + { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032 }, + { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057 }, + { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359 }, + { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306 }, + { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094 }, + { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521 }, + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, +] + +[[package]] +name = "numpy" +version = "2.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/78/31103410a57bc2c2b93a3597340a8119588571f6a4539067546cb9a0bfac/numpy-2.2.4.tar.gz", hash = "sha256:9ba03692a45d3eef66559efe1d1096c4b9b75c0986b5dff5530c378fb8331d4f", size = 20270701 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/fb/09e778ee3a8ea0d4dc8329cca0a9c9e65fed847d08e37eba74cb7ed4b252/numpy-2.2.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e9e0a277bb2eb5d8a7407e14688b85fd8ad628ee4e0c7930415687b6564207a4", size = 21254989 }, + { url = "https://files.pythonhosted.org/packages/a2/0a/1212befdbecab5d80eca3cde47d304cad986ad4eec7d85a42e0b6d2cc2ef/numpy-2.2.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9eeea959168ea555e556b8188da5fa7831e21d91ce031e95ce23747b7609f8a4", size = 14425910 }, + { url = "https://files.pythonhosted.org/packages/2b/3e/e7247c1d4f15086bb106c8d43c925b0b2ea20270224f5186fa48d4fb5cbd/numpy-2.2.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:bd3ad3b0a40e713fc68f99ecfd07124195333f1e689387c180813f0e94309d6f", size = 5426490 }, + { url = "https://files.pythonhosted.org/packages/5d/fa/aa7cd6be51419b894c5787a8a93c3302a1ed4f82d35beb0613ec15bdd0e2/numpy-2.2.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cf28633d64294969c019c6df4ff37f5698e8326db68cc2b66576a51fad634880", size = 6967754 }, + { url = "https://files.pythonhosted.org/packages/d5/ee/96457c943265de9fadeb3d2ffdbab003f7fba13d971084a9876affcda095/numpy-2.2.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fa8fa7697ad1646b5c93de1719965844e004fcad23c91228aca1cf0800044a1", size = 14373079 }, + { url = "https://files.pythonhosted.org/packages/c5/5c/ceefca458559f0ccc7a982319f37ed07b0d7b526964ae6cc61f8ad1b6119/numpy-2.2.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4162988a360a29af158aeb4a2f4f09ffed6a969c9776f8f3bdee9b06a8ab7e5", size = 16428819 }, + { url = "https://files.pythonhosted.org/packages/22/31/9b2ac8eee99e001eb6add9fa27514ef5e9faf176169057a12860af52704c/numpy-2.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:892c10d6a73e0f14935c31229e03325a7b3093fafd6ce0af704be7f894d95687", size = 15881470 }, + { url = "https://files.pythonhosted.org/packages/f0/dc/8569b5f25ff30484b555ad8a3f537e0225d091abec386c9420cf5f7a2976/numpy-2.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db1f1c22173ac1c58db249ae48aa7ead29f534b9a948bc56828337aa84a32ed6", size = 18218144 }, + { url = "https://files.pythonhosted.org/packages/5e/05/463c023a39bdeb9bb43a99e7dee2c664cb68d5bb87d14f92482b9f6011cc/numpy-2.2.4-cp311-cp311-win32.whl", hash = "sha256:ea2bb7e2ae9e37d96835b3576a4fa4b3a97592fbea8ef7c3587078b0068b8f09", size = 6606368 }, + { url = "https://files.pythonhosted.org/packages/8b/72/10c1d2d82101c468a28adc35de6c77b308f288cfd0b88e1070f15b98e00c/numpy-2.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:f7de08cbe5551911886d1ab60de58448c6df0f67d9feb7d1fb21e9875ef95e91", size = 12947526 }, + { url = "https://files.pythonhosted.org/packages/a2/30/182db21d4f2a95904cec1a6f779479ea1ac07c0647f064dea454ec650c42/numpy-2.2.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a7b9084668aa0f64e64bd00d27ba5146ef1c3a8835f3bd912e7a9e01326804c4", size = 20947156 }, + { url = "https://files.pythonhosted.org/packages/24/6d/9483566acfbda6c62c6bc74b6e981c777229d2af93c8eb2469b26ac1b7bc/numpy-2.2.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dbe512c511956b893d2dacd007d955a3f03d555ae05cfa3ff1c1ff6df8851854", size = 14133092 }, + { url = "https://files.pythonhosted.org/packages/27/f6/dba8a258acbf9d2bed2525cdcbb9493ef9bae5199d7a9cb92ee7e9b2aea6/numpy-2.2.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:bb649f8b207ab07caebba230d851b579a3c8711a851d29efe15008e31bb4de24", size = 5163515 }, + { url = "https://files.pythonhosted.org/packages/62/30/82116199d1c249446723c68f2c9da40d7f062551036f50b8c4caa42ae252/numpy-2.2.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:f34dc300df798742b3d06515aa2a0aee20941c13579d7a2f2e10af01ae4901ee", size = 6696558 }, + { url = "https://files.pythonhosted.org/packages/0e/b2/54122b3c6df5df3e87582b2e9430f1bdb63af4023c739ba300164c9ae503/numpy-2.2.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3f7ac96b16955634e223b579a3e5798df59007ca43e8d451a0e6a50f6bfdfba", size = 14084742 }, + { url = "https://files.pythonhosted.org/packages/02/e2/e2cbb8d634151aab9528ef7b8bab52ee4ab10e076509285602c2a3a686e0/numpy-2.2.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f92084defa704deadd4e0a5ab1dc52d8ac9e8a8ef617f3fbb853e79b0ea3592", size = 16134051 }, + { url = "https://files.pythonhosted.org/packages/8e/21/efd47800e4affc993e8be50c1b768de038363dd88865920439ef7b422c60/numpy-2.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4e84a6283b36632e2a5b56e121961f6542ab886bc9e12f8f9818b3c266bfbb", size = 15578972 }, + { url = "https://files.pythonhosted.org/packages/04/1e/f8bb88f6157045dd5d9b27ccf433d016981032690969aa5c19e332b138c0/numpy-2.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:11c43995255eb4127115956495f43e9343736edb7fcdb0d973defd9de14cd84f", size = 17898106 }, + { url = "https://files.pythonhosted.org/packages/2b/93/df59a5a3897c1f036ae8ff845e45f4081bb06943039ae28a3c1c7c780f22/numpy-2.2.4-cp312-cp312-win32.whl", hash = "sha256:65ef3468b53269eb5fdb3a5c09508c032b793da03251d5f8722b1194f1790c00", size = 6311190 }, + { url = "https://files.pythonhosted.org/packages/46/69/8c4f928741c2a8efa255fdc7e9097527c6dc4e4df147e3cadc5d9357ce85/numpy-2.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:2aad3c17ed2ff455b8eaafe06bcdae0062a1db77cb99f4b9cbb5f4ecb13c5146", size = 12644305 }, + { url = "https://files.pythonhosted.org/packages/2a/d0/bd5ad792e78017f5decfb2ecc947422a3669a34f775679a76317af671ffc/numpy-2.2.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cf4e5c6a278d620dee9ddeb487dc6a860f9b199eadeecc567f777daace1e9e7", size = 20933623 }, + { url = "https://files.pythonhosted.org/packages/c3/bc/2b3545766337b95409868f8e62053135bdc7fa2ce630aba983a2aa60b559/numpy-2.2.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1974afec0b479e50438fc3648974268f972e2d908ddb6d7fb634598cdb8260a0", size = 14148681 }, + { url = "https://files.pythonhosted.org/packages/6a/70/67b24d68a56551d43a6ec9fe8c5f91b526d4c1a46a6387b956bf2d64744e/numpy-2.2.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:79bd5f0a02aa16808fcbc79a9a376a147cc1045f7dfe44c6e7d53fa8b8a79392", size = 5148759 }, + { url = "https://files.pythonhosted.org/packages/1c/8b/e2fc8a75fcb7be12d90b31477c9356c0cbb44abce7ffb36be39a0017afad/numpy-2.2.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:3387dd7232804b341165cedcb90694565a6015433ee076c6754775e85d86f1fc", size = 6683092 }, + { url = "https://files.pythonhosted.org/packages/13/73/41b7b27f169ecf368b52533edb72e56a133f9e86256e809e169362553b49/numpy-2.2.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f527d8fdb0286fd2fd97a2a96c6be17ba4232da346931d967a0630050dfd298", size = 14081422 }, + { url = "https://files.pythonhosted.org/packages/4b/04/e208ff3ae3ddfbafc05910f89546382f15a3f10186b1f56bd99f159689c2/numpy-2.2.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bce43e386c16898b91e162e5baaad90c4b06f9dcbe36282490032cec98dc8ae7", size = 16132202 }, + { url = "https://files.pythonhosted.org/packages/fe/bc/2218160574d862d5e55f803d88ddcad88beff94791f9c5f86d67bd8fbf1c/numpy-2.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:31504f970f563d99f71a3512d0c01a645b692b12a63630d6aafa0939e52361e6", size = 15573131 }, + { url = "https://files.pythonhosted.org/packages/a5/78/97c775bc4f05abc8a8426436b7cb1be806a02a2994b195945600855e3a25/numpy-2.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:81413336ef121a6ba746892fad881a83351ee3e1e4011f52e97fba79233611fd", size = 17894270 }, + { url = "https://files.pythonhosted.org/packages/b9/eb/38c06217a5f6de27dcb41524ca95a44e395e6a1decdc0c99fec0832ce6ae/numpy-2.2.4-cp313-cp313-win32.whl", hash = "sha256:f486038e44caa08dbd97275a9a35a283a8f1d2f0ee60ac260a1790e76660833c", size = 6308141 }, + { url = "https://files.pythonhosted.org/packages/52/17/d0dd10ab6d125c6d11ffb6dfa3423c3571befab8358d4f85cd4471964fcd/numpy-2.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:207a2b8441cc8b6a2a78c9ddc64d00d20c303d79fba08c577752f080c4007ee3", size = 12636885 }, + { url = "https://files.pythonhosted.org/packages/fa/e2/793288ede17a0fdc921172916efb40f3cbc2aa97e76c5c84aba6dc7e8747/numpy-2.2.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8120575cb4882318c791f839a4fd66161a6fa46f3f0a5e613071aae35b5dd8f8", size = 20961829 }, + { url = "https://files.pythonhosted.org/packages/3a/75/bb4573f6c462afd1ea5cbedcc362fe3e9bdbcc57aefd37c681be1155fbaa/numpy-2.2.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a761ba0fa886a7bb33c6c8f6f20213735cb19642c580a931c625ee377ee8bd39", size = 14161419 }, + { url = "https://files.pythonhosted.org/packages/03/68/07b4cd01090ca46c7a336958b413cdbe75002286295f2addea767b7f16c9/numpy-2.2.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:ac0280f1ba4a4bfff363a99a6aceed4f8e123f8a9b234c89140f5e894e452ecd", size = 5196414 }, + { url = "https://files.pythonhosted.org/packages/a5/fd/d4a29478d622fedff5c4b4b4cedfc37a00691079623c0575978d2446db9e/numpy-2.2.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:879cf3a9a2b53a4672a168c21375166171bc3932b7e21f622201811c43cdd3b0", size = 6709379 }, + { url = "https://files.pythonhosted.org/packages/41/78/96dddb75bb9be730b87c72f30ffdd62611aba234e4e460576a068c98eff6/numpy-2.2.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f05d4198c1bacc9124018109c5fba2f3201dbe7ab6e92ff100494f236209c960", size = 14051725 }, + { url = "https://files.pythonhosted.org/packages/00/06/5306b8199bffac2a29d9119c11f457f6c7d41115a335b78d3f86fad4dbe8/numpy-2.2.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2f085ce2e813a50dfd0e01fbfc0c12bbe5d2063d99f8b29da30e544fb6483b8", size = 16101638 }, + { url = "https://files.pythonhosted.org/packages/fa/03/74c5b631ee1ded596945c12027649e6344614144369fd3ec1aaced782882/numpy-2.2.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:92bda934a791c01d6d9d8e038363c50918ef7c40601552a58ac84c9613a665bc", size = 15571717 }, + { url = "https://files.pythonhosted.org/packages/cb/dc/4fc7c0283abe0981e3b89f9b332a134e237dd476b0c018e1e21083310c31/numpy-2.2.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ee4d528022f4c5ff67332469e10efe06a267e32f4067dc76bb7e2cddf3cd25ff", size = 17879998 }, + { url = "https://files.pythonhosted.org/packages/e5/2b/878576190c5cfa29ed896b518cc516aecc7c98a919e20706c12480465f43/numpy-2.2.4-cp313-cp313t-win32.whl", hash = "sha256:05c076d531e9998e7e694c36e8b349969c56eadd2cdcd07242958489d79a7286", size = 6366896 }, + { url = "https://files.pythonhosted.org/packages/3e/05/eb7eec66b95cf697f08c754ef26c3549d03ebd682819f794cb039574a0a6/numpy-2.2.4-cp313-cp313t-win_amd64.whl", hash = "sha256:188dcbca89834cc2e14eb2f106c96d6d46f200fe0200310fc29089657379c58d", size = 12739119 }, +] + +[[package]] +name = "omegaconf" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "antlr4-python3-runtime" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/48/6388f1bb9da707110532cb70ec4d2822858ddfb44f1cdf1233c20a80ea4b/omegaconf-2.3.0.tar.gz", hash = "sha256:d5d4b6d29955cc50ad50c46dc269bcd92c6e00f5f90d23ab5fee7bfca4ba4cc7", size = 3298120 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/94/1843518e420fa3ed6919835845df698c7e27e183cb997394e4a670973a65/omegaconf-2.3.0-py3-none-any.whl", hash = "sha256:7b4df175cdb08ba400f45cae3bdcae7ba8365db4d165fc65fd04b050ab63b46b", size = 79500 }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + +[[package]] +name = "pefile" +version = "2023.2.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/c5/3b3c62223f72e2360737fd2a57c30e5b2adecd85e70276879609a7403334/pefile-2023.2.7.tar.gz", hash = "sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc", size = 74854 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/26/d0ad8b448476d0a1e8d3ea5622dc77b916db84c6aa3cb1e1c0965af948fc/pefile-2023.2.7-py3-none-any.whl", hash = "sha256:da185cd2af68c08a6cd4481f7325ed600a88f6a813bad9dea07ab3ef73d8d8d6", size = 71791 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "py3-tts-wrapper" +version = "0.9.27" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pymp3" }, + { name = "requests" }, + { name = "sounddevice" }, + { name = "soundfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/1f/7461a9d26434ee3a05786ee261f17c0cc96b434f1599e614e7219af3522a/py3_tts_wrapper-0.9.27.tar.gz", hash = "sha256:aa4fddc8e4311ac7627cfbeda380cc7aab94ff474919c2aeeb2d90d8c66ec8d1", size = 6922903 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/b2/6f397fdcc59794dc9e1d5518f2427ee167e6c1fa1f263943c7c948402736/py3_tts_wrapper-0.9.27-py3-none-any.whl", hash = "sha256:6004bdc809c18a046495ff25a54ebcce57d98f26a877172b7fbaa5b73638fdda", size = 847476 }, +] + +[package.optional-dependencies] +controlaudio = [ + { name = "pyaudio" }, +] +googletrans = [ + { name = "gtts" }, +] +microsoft = [ + { name = "azure-cognitiveservices-speech" }, +] +polly = [ + { name = "boto3" }, +] +sapi = [ + { name = "comtypes", marker = "sys_platform == 'win32'" }, +] +sherpaonnx = [ + { name = "sherpa-onnx" }, +] +watson = [ + { name = "ibm-watson" }, + { name = "websocket-client" }, +] + +[[package]] +name = "pyaudio" +version = "0.2.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/1d/8878c7752febb0f6716a7e1a52cb92ac98871c5aa522cba181878091607c/PyAudio-0.2.14.tar.gz", hash = "sha256:78dfff3879b4994d1f4fc6485646a57755c6ee3c19647a491f790a0895bd2f87", size = 47066 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/f0/b0eab89eafa70a86b7b566a4df2f94c7880a2d483aa8de1c77d335335b5b/PyAudio-0.2.14-cp311-cp311-win32.whl", hash = "sha256:506b32a595f8693811682ab4b127602d404df7dfc453b499c91a80d0f7bad289", size = 144624 }, + { url = "https://files.pythonhosted.org/packages/82/d8/f043c854aad450a76e476b0cf9cda1956419e1dacf1062eb9df3c0055abe/PyAudio-0.2.14-cp311-cp311-win_amd64.whl", hash = "sha256:bbeb01d36a2f472ae5ee5e1451cacc42112986abe622f735bb870a5db77cf903", size = 164070 }, + { url = "https://files.pythonhosted.org/packages/8d/45/8d2b76e8f6db783f9326c1305f3f816d4a12c8eda5edc6a2e1d03c097c3b/PyAudio-0.2.14-cp312-cp312-win32.whl", hash = "sha256:5fce4bcdd2e0e8c063d835dbe2860dac46437506af509353c7f8114d4bacbd5b", size = 144750 }, + { url = "https://files.pythonhosted.org/packages/b0/6a/d25812e5f79f06285767ec607b39149d02aa3b31d50c2269768f48768930/PyAudio-0.2.14-cp312-cp312-win_amd64.whl", hash = "sha256:12f2f1ba04e06ff95d80700a78967897a489c05e093e3bffa05a84ed9c0a7fa3", size = 164126 }, + { url = "https://files.pythonhosted.org/packages/3a/77/66cd37111a87c1589b63524f3d3c848011d21ca97828422c7fde7665ff0d/PyAudio-0.2.14-cp313-cp313-win32.whl", hash = "sha256:95328285b4dab57ea8c52a4a996cb52be6d629353315be5bfda403d15932a497", size = 150982 }, + { url = "https://files.pythonhosted.org/packages/a5/8b/7f9a061c1cc2b230f9ac02a6003fcd14c85ce1828013aecbaf45aa988d20/PyAudio-0.2.14-cp313-cp313-win_amd64.whl", hash = "sha256:692d8c1446f52ed2662120bcd9ddcb5aa2b71f38bda31e58b19fb4672fffba69", size = 173655 }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + +[[package]] +name = "pyinstaller" +version = "6.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, + { name = "macholib", marker = "sys_platform == 'darwin'" }, + { name = "packaging" }, + { name = "pefile", marker = "sys_platform == 'win32'" }, + { name = "pyinstaller-hooks-contrib" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/c0/001e86a13f9f6104613f198721c72d377fa1fc2a09550cfe1ac9a1d12406/pyinstaller-6.12.0.tar.gz", hash = "sha256:1834797be48ce1b26015af68bdeb3c61a6c7500136f04e0fc65e468115dec777", size = 4267132 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/73/b897a3fda99a14130111abdb978d63da14cbc9932497b5e5064c5fe28187/pyinstaller-6.12.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:68f1e4cecf88a6272063977fa2a2c69ad37cf568e5901769d7206d0314c74f47", size = 997954 }, + { url = "https://files.pythonhosted.org/packages/4a/bc/0929ed6aca3c5ff3f20f8cfd4f2f7e90f18c9465440e0d151d56d8170851/pyinstaller-6.12.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:fea76fc9b55ffa730fcf90beb897cce4399938460b0b6f40507fbebfc752c753", size = 714097 }, + { url = "https://files.pythonhosted.org/packages/e1/9a/422d5eb04132e4a4735ca9099a53511324ff7d387b80231fe8dbd67bf322/pyinstaller-6.12.0-py3-none-manylinux2014_i686.whl", hash = "sha256:dac8a27988dbc33cdc34f2046803258bc3f6829de24de52745a5daa22bdba0f1", size = 724471 }, + { url = "https://files.pythonhosted.org/packages/64/1c/5028ba2e09f5b57f6792e9d88e888725224f8f016a07666e48664f6a9fcf/pyinstaller-6.12.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:83c7f3bde9871b4a6aa71c66a96e8ba5c21668ce711ed97f510b9382d10aac6c", size = 722753 }, + { url = "https://files.pythonhosted.org/packages/6f/d9/e7742caf4c4dc07d13e355ad2c14c7844c9bb2e66dea4f3386b4644bd106/pyinstaller-6.12.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:a69818815c6e0711c727edc30680cb1f81c691b59de35db81a2d9e0ae26a9ef1", size = 720906 }, + { url = "https://files.pythonhosted.org/packages/80/2b/14404f2dc95d1ec94d08879c62a76d5f26a176fab99fb023c2c70d2ff500/pyinstaller-6.12.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a2abf5fde31a8b38b6df7939bcef8ac1d0c51e97e25317ce3555cd675259750f", size = 716959 }, + { url = "https://files.pythonhosted.org/packages/11/a6/5c3a233cf19aa6d4caacf62f7ee1c728486cc20b73f5817be17485d7b7ff/pyinstaller-6.12.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:8e92e9873a616547bbabbb5a3a9843d5f2ab40c3d8b26810acdf0fe257bee4cf", size = 719414 }, + { url = "https://files.pythonhosted.org/packages/24/57/069d35236806b281a3331ef00ff94e43f3b91e4b36350de8b40b4baf9fd3/pyinstaller-6.12.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:aefe502d55c9cf6aeaed7feba80b5f8491ce43f8f2b5fe2d9aadca3ee5a05bc4", size = 715903 }, + { url = "https://files.pythonhosted.org/packages/4d/5f/857de8798836f9d16a620bd0a7c8899bba05b5fda7b3b4432762f148a86d/pyinstaller-6.12.0-py3-none-win32.whl", hash = "sha256:138856a5a503bb69c066377e0a22671b0db063e9cc14d5cf5c798a53561200d3", size = 1290980 }, + { url = "https://files.pythonhosted.org/packages/99/6e/d7d76d4d15f6351f1f942256633b795eec3d6c691d985869df1bf319cd9d/pyinstaller-6.12.0-py3-none-win_amd64.whl", hash = "sha256:0e62d3906309248409f215b386f33afec845214e69cc0f296b93222b26a88f43", size = 1348786 }, + { url = "https://files.pythonhosted.org/packages/47/c2/298ad6a3aa2cacb55cbc1f845068dc1e4a6c966082ffa0e19c69084cbc42/pyinstaller-6.12.0-py3-none-win_arm64.whl", hash = "sha256:0c271896a3a168f4f91827145702543db9c5427f4c7372a6df8c75925a3ac18a", size = 1289617 }, +] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2025.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/1b/dc256d42f4217db99b50d6d32dbbf841a41b9615506cde77d2345d94f4a5/pyinstaller_hooks_contrib-2025.1.tar.gz", hash = "sha256:130818f9e9a0a7f2261f1fd66054966a3a50c99d000981c5d1db11d3ad0c6ab2", size = 147043 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/48/833d67a585275e395f351e5787b4b7a8d462d87bca22a8c038f6ffdc2b3c/pyinstaller_hooks_contrib-2025.1-py3-none-any.whl", hash = "sha256:d3c799470cbc0bda60dcc8e6b4ab976777532b77621337f2037f558905e3a8e9", size = 346409 }, +] + +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 }, +] + +[[package]] +name = "pymp3" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/10/5afef9e7e5b6fbd8092487b77af56b945e883dd68619e59baa39008b71d1/pymp3-0.2.0.tar.gz", hash = "sha256:26c95917600cff593e53955059772859739b5f365dbc377c82d256a7f41c983d", size = 25447 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/a2/67e1aaced4efba181643b685c87769d01a871e6cc0401f5d0e8f87b22715/pymp3-0.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a2264eb36eaced7919a9bad93dc630b6b002aa1a1b6eb7122e03b8b055e86b48", size = 208223 }, + { url = "https://files.pythonhosted.org/packages/17/f9/6f8cf11798f016837695a937331868b9b6466e2af3df468cbc22d54eeeec/pymp3-0.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6a3c98bb20e43f411b216cf0217a6edb9948d476065492d1fa22100840c7471", size = 231808 }, + { url = "https://files.pythonhosted.org/packages/6c/d0/5f3f5bb9f5a5a20f4f42c7048984e8b2a36363006d0eb2059ad7f4bc3ec0/pymp3-0.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba8d0b397295952ee2cd52f307eb17f51e26ff002fa3af9c55c13528cf1f7752", size = 236531 }, + { url = "https://files.pythonhosted.org/packages/d3/56/b8746c1092bd1c02a17d090c6f20d3102287446ea9c565482e10e52af05f/pymp3-0.2.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:69ffba77a1eb179ae99a6305dd5434fd82dddb4fbc086383343f58ad76e393a7", size = 216640 }, + { url = "https://files.pythonhosted.org/packages/87/00/79fe9f9eafcc9a7afc7090e4f54db29f9b4148bcc4b3f8a7a266369bb1e7/pymp3-0.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:06e6eda4a5f2942ed1a8b3ff6677ffd58120494731939dcc79096c9c17596bcb", size = 234979 }, + { url = "https://files.pythonhosted.org/packages/d0/14/98c857aed82480de2b47d5aa30c002c19e1cd1fcdc231e2c5bf43c7e130a/pymp3-0.2.0-cp311-cp311-win32.whl", hash = "sha256:97d70bbcfb50b06b883786868e42ebe6bae3b4c3df9ab4cacdcb3a0de305516a", size = 149335 }, + { url = "https://files.pythonhosted.org/packages/b4/8b/49b7d9923859d04d2602c20754b3d0c36c47cd82e3cca08266279e26f471/pymp3-0.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:5bddfb389e567e201521ae666c245ac61a1fca2387444a0ae7594cb2cb52dfe6", size = 171928 }, + { url = "https://files.pythonhosted.org/packages/44/32/1ef924a9d55f525a04d9bfb436c59f3f883253e0558b65da84c76311aa5e/pymp3-0.2.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:dc28cc97239966360e2e1b84fd04bb471a631f66043cf1bcb358fdde4eb89962", size = 208276 }, + { url = "https://files.pythonhosted.org/packages/00/0e/bd56010520307975e52636e10d648e02bccb27b7efbe0344884d57fedad5/pymp3-0.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eeffcca0315538116fbf54deb0f8e91e8dd1db7710c4d007e9b97f6847d37593", size = 231821 }, + { url = "https://files.pythonhosted.org/packages/3b/7c/d94926d3507d5927ff5c217e421b37cdddd91d9e6cc9002bf336f317c34c/pymp3-0.2.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e89a78e1d9942a7290dc317d9100b9e252ac303aaf9a9748c95003cc69eb06c", size = 236556 }, + { url = "https://files.pythonhosted.org/packages/bc/0b/cf07153354fd8424fdac7d942c32042449865913ab11b0bccf2f1eddd0ff/pymp3-0.2.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a5a509336431d7cc8b47559c8c0d5471397acb8dcf57a8732dca9c3062570d2a", size = 216670 }, + { url = "https://files.pythonhosted.org/packages/0d/99/c344eaf308ae2160c57ff6e6cc29bf695e8797e91620a7b40601ea18e58a/pymp3-0.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:29a508c59bc2b6a00698675e39d768f58b8cf1ab5212b964b943499b1abb9300", size = 234996 }, + { url = "https://files.pythonhosted.org/packages/e8/96/24d2955790ead97b012da8f1a2bdae5cc1563b06d90c4e23cd7e6e4e20f3/pymp3-0.2.0-cp312-cp312-win32.whl", hash = "sha256:2a3b6f31c2549aea91c99679407f042ce97e90c829ebee1fc38640643c6ffa26", size = 149440 }, + { url = "https://files.pythonhosted.org/packages/a8/b9/222b91c199d5160441af26544f761e3f406f22aacf581d53fb518382c63c/pymp3-0.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b813f2eda08112cc2a03494ea6c77c8da3958ba2b649c99c56b5b78e835bd211", size = 171953 }, +] + +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, +] + +[[package]] +name = "pytest-cov" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949 }, +] + +[[package]] +name = "pytest-mock" +version = "3.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/90/a955c3ab35ccd41ad4de556596fa86685bf4fc5ffcc62d22d856cfd4e29a/pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0", size = 32814 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/3b/b26f90f74e2986a82df6e7ac7e319b8ea7ccece1caec9f8ab6104dc70603/pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", size = 9863 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + +[[package]] +name = "pytz" +version = "2025.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/57/df1c9157c8d5a05117e455d66fd7cf6dbc46974f832b1058ed4856785d8a/pytz-2025.1.tar.gz", hash = "sha256:c2db42be2a2518b28e65f9207c4d05e6ff547d1efa4086469ef855e4ab70178e", size = 319617 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/38/ac33370d784287baa1c3d538978b5e2ea064d4c1b93ffbd12826c190dd10/pytz-2025.1-py2.py3-none-any.whl", hash = "sha256:89dd22dca55b46eac6eda23b2d72721bf1bdfef212645d81513ef5d03038de57", size = 507930 }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167 }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952 }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301 }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638 }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850 }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980 }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, +] + +[[package]] +name = "referencing" +version = "0.36.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775 }, +] + +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, +] + +[[package]] +name = "rpds-py" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/79/2ce611b18c4fd83d9e3aecb5cba93e1917c050f556db39842889fa69b79f/rpds_py-0.23.1.tar.gz", hash = "sha256:7f3240dcfa14d198dba24b8b9cb3b108c06b68d45b7babd9eefc1038fdf7e707", size = 26806 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/67/6e5d4234bb9dee062ffca2a5f3c7cd38716317d6760ec235b175eed4de2c/rpds_py-0.23.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:b79f5ced71efd70414a9a80bbbfaa7160da307723166f09b69773153bf17c590", size = 372264 }, + { url = "https://files.pythonhosted.org/packages/a7/0a/3dedb2daee8e783622427f5064e2d112751d8276ee73aa5409f000a132f4/rpds_py-0.23.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c9e799dac1ffbe7b10c1fd42fe4cd51371a549c6e108249bde9cd1200e8f59b4", size = 356883 }, + { url = "https://files.pythonhosted.org/packages/ed/fc/e1acef44f9c24b05fe5434b235f165a63a52959ac655e3f7a55726cee1a4/rpds_py-0.23.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721f9c4011b443b6e84505fc00cc7aadc9d1743f1c988e4c89353e19c4a968ee", size = 385624 }, + { url = "https://files.pythonhosted.org/packages/97/0a/a05951f6465d01622720c03ef6ef31adfbe865653e05ed7c45837492f25e/rpds_py-0.23.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f88626e3f5e57432e6191cd0c5d6d6b319b635e70b40be2ffba713053e5147dd", size = 391500 }, + { url = "https://files.pythonhosted.org/packages/ea/2e/cca0583ec0690ea441dceae23c0673b99755710ea22f40bccf1e78f41481/rpds_py-0.23.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:285019078537949cecd0190f3690a0b0125ff743d6a53dfeb7a4e6787af154f5", size = 444869 }, + { url = "https://files.pythonhosted.org/packages/cc/e6/95cda68b33a6d814d1e96b0e406d231ed16629101460d1740e92f03365e6/rpds_py-0.23.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b92f5654157de1379c509b15acec9d12ecf6e3bc1996571b6cb82a4302060447", size = 444930 }, + { url = "https://files.pythonhosted.org/packages/5f/a7/e94cdb73411ae9c11414d3c7c9a6ad75d22ad4a8d094fb45a345ba9e3018/rpds_py-0.23.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e768267cbe051dd8d1c5305ba690bb153204a09bf2e3de3ae530de955f5b5580", size = 386254 }, + { url = "https://files.pythonhosted.org/packages/dd/c5/a4a943d90a39e85efd1e04b1ad5129936786f9a9aa27bb7be8fc5d9d50c9/rpds_py-0.23.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c5334a71f7dc1160382d45997e29f2637c02f8a26af41073189d79b95d3321f1", size = 417090 }, + { url = "https://files.pythonhosted.org/packages/0c/a0/80d0013b12428d1fce0ab4e71829400b0a32caec12733c79e6109f843342/rpds_py-0.23.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6adb81564af0cd428910f83fa7da46ce9ad47c56c0b22b50872bc4515d91966", size = 557639 }, + { url = "https://files.pythonhosted.org/packages/a6/92/ec2e6980afb964a2cd7a99cbdef1f6c01116abe94b42cbe336ac93dd11c2/rpds_py-0.23.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:cafa48f2133d4daa028473ede7d81cd1b9f9e6925e9e4003ebdf77010ee02f35", size = 584572 }, + { url = "https://files.pythonhosted.org/packages/3d/ce/75b6054db34a390789a82523790717b27c1bd735e453abb429a87c4f0f26/rpds_py-0.23.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0fced9fd4a07a1ded1bac7e961ddd9753dd5d8b755ba8e05acba54a21f5f1522", size = 553028 }, + { url = "https://files.pythonhosted.org/packages/cc/24/f45abe0418c06a5cba0f846e967aa27bac765acd927aabd857c21319b8cc/rpds_py-0.23.1-cp311-cp311-win32.whl", hash = "sha256:243241c95174b5fb7204c04595852fe3943cc41f47aa14c3828bc18cd9d3b2d6", size = 220862 }, + { url = "https://files.pythonhosted.org/packages/2d/a6/3c0880e8bbfc36451ef30dc416266f6d2934705e468db5d21c8ba0ab6400/rpds_py-0.23.1-cp311-cp311-win_amd64.whl", hash = "sha256:11dd60b2ffddba85715d8a66bb39b95ddbe389ad2cfcf42c833f1bcde0878eaf", size = 232953 }, + { url = "https://files.pythonhosted.org/packages/f3/8c/d17efccb9f5b9137ddea706664aebae694384ae1d5997c0202093e37185a/rpds_py-0.23.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3902df19540e9af4cc0c3ae75974c65d2c156b9257e91f5101a51f99136d834c", size = 364369 }, + { url = "https://files.pythonhosted.org/packages/6e/c0/ab030f696b5c573107115a88d8d73d80f03309e60952b64c584c70c659af/rpds_py-0.23.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66f8d2a17e5838dd6fb9be6baaba8e75ae2f5fa6b6b755d597184bfcd3cb0eba", size = 349965 }, + { url = "https://files.pythonhosted.org/packages/b3/55/b40170f5a079c4fb0b6a82b299689e66e744edca3c3375a8b160fb797660/rpds_py-0.23.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:112b8774b0b4ee22368fec42749b94366bd9b536f8f74c3d4175d4395f5cbd31", size = 389064 }, + { url = "https://files.pythonhosted.org/packages/ab/1c/b03a912c59ec7c1e16b26e587b9dfa8ddff3b07851e781e8c46e908a365a/rpds_py-0.23.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e0df046f2266e8586cf09d00588302a32923eb6386ced0ca5c9deade6af9a149", size = 397741 }, + { url = "https://files.pythonhosted.org/packages/52/6f/151b90792b62fb6f87099bcc9044c626881fdd54e31bf98541f830b15cea/rpds_py-0.23.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0f3288930b947cbebe767f84cf618d2cbe0b13be476e749da0e6a009f986248c", size = 448784 }, + { url = "https://files.pythonhosted.org/packages/71/2a/6de67c0c97ec7857e0e9e5cd7c52405af931b303eb1e5b9eff6c50fd9a2e/rpds_py-0.23.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce473a2351c018b06dd8d30d5da8ab5a0831056cc53b2006e2a8028172c37ce5", size = 440203 }, + { url = "https://files.pythonhosted.org/packages/db/5e/e759cd1c276d98a4b1f464b17a9bf66c65d29f8f85754e27e1467feaa7c3/rpds_py-0.23.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d550d7e9e7d8676b183b37d65b5cd8de13676a738973d330b59dc8312df9c5dc", size = 391611 }, + { url = "https://files.pythonhosted.org/packages/1c/1e/2900358efcc0d9408c7289769cba4c0974d9db314aa884028ed7f7364f61/rpds_py-0.23.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e14f86b871ea74c3fddc9a40e947d6a5d09def5adc2076ee61fb910a9014fb35", size = 423306 }, + { url = "https://files.pythonhosted.org/packages/23/07/6c177e6d059f5d39689352d6c69a926ee4805ffdb6f06203570234d3d8f7/rpds_py-0.23.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1bf5be5ba34e19be579ae873da515a2836a2166d8d7ee43be6ff909eda42b72b", size = 562323 }, + { url = "https://files.pythonhosted.org/packages/70/e4/f9097fd1c02b516fff9850792161eb9fc20a2fd54762f3c69eae0bdb67cb/rpds_py-0.23.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7031d493c4465dbc8d40bd6cafefef4bd472b17db0ab94c53e7909ee781b9ef", size = 588351 }, + { url = "https://files.pythonhosted.org/packages/87/39/5db3c6f326bfbe4576ae2af6435bd7555867d20ae690c786ff33659f293b/rpds_py-0.23.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:55ff4151cfd4bc635e51cfb1c59ac9f7196b256b12e3a57deb9e5742e65941ad", size = 557252 }, + { url = "https://files.pythonhosted.org/packages/fd/14/2d5ad292f144fa79bafb78d2eb5b8a3a91c358b6065443cb9c49b5d1fedf/rpds_py-0.23.1-cp312-cp312-win32.whl", hash = "sha256:a9d3b728f5a5873d84cba997b9d617c6090ca5721caaa691f3b1a78c60adc057", size = 222181 }, + { url = "https://files.pythonhosted.org/packages/a3/4f/0fce63e0f5cdd658e71e21abd17ac1bc9312741ebb8b3f74eeed2ebdf771/rpds_py-0.23.1-cp312-cp312-win_amd64.whl", hash = "sha256:b03a8d50b137ee758e4c73638b10747b7c39988eb8e6cd11abb7084266455165", size = 237426 }, + { url = "https://files.pythonhosted.org/packages/13/9d/b8b2c0edffb0bed15be17b6d5ab06216f2f47f9ee49259c7e96a3ad4ca42/rpds_py-0.23.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:4caafd1a22e5eaa3732acb7672a497123354bef79a9d7ceed43387d25025e935", size = 363672 }, + { url = "https://files.pythonhosted.org/packages/bd/c2/5056fa29e6894144d7ba4c938b9b0445f75836b87d2dd00ed4999dc45a8c/rpds_py-0.23.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:178f8a60fc24511c0eb756af741c476b87b610dba83270fce1e5a430204566a4", size = 349602 }, + { url = "https://files.pythonhosted.org/packages/b0/bc/33779a1bb0ee32d8d706b173825aab75c628521d23ce72a7c1e6a6852f86/rpds_py-0.23.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c632419c3870507ca20a37c8f8f5352317aca097639e524ad129f58c125c61c6", size = 388746 }, + { url = "https://files.pythonhosted.org/packages/62/0b/71db3e36b7780a619698ec82a9c87ab44ad7ca7f5480913e8a59ff76f050/rpds_py-0.23.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:698a79d295626ee292d1730bc2ef6e70a3ab135b1d79ada8fde3ed0047b65a10", size = 397076 }, + { url = "https://files.pythonhosted.org/packages/bb/2e/494398f613edf77ba10a916b1ddea2acce42ab0e3b62e2c70ffc0757ce00/rpds_py-0.23.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:271fa2184cf28bdded86bb6217c8e08d3a169fe0bbe9be5e8d96e8476b707122", size = 448399 }, + { url = "https://files.pythonhosted.org/packages/dd/53/4bd7f5779b1f463243ee5fdc83da04dd58a08f86e639dbffa7a35f969a84/rpds_py-0.23.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b91cceb5add79ee563bd1f70b30896bd63bc5f78a11c1f00a1e931729ca4f1f4", size = 439764 }, + { url = "https://files.pythonhosted.org/packages/f6/55/b3c18c04a460d951bf8e91f2abf46ce5b6426fb69784166a6a25827cb90a/rpds_py-0.23.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3a6cb95074777f1ecda2ca4fa7717caa9ee6e534f42b7575a8f0d4cb0c24013", size = 390662 }, + { url = "https://files.pythonhosted.org/packages/2a/65/cc463044a3cbd616029b2aa87a651cdee8288d2fdd7780b2244845e934c1/rpds_py-0.23.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:50fb62f8d8364978478b12d5f03bf028c6bc2af04082479299139dc26edf4c64", size = 422680 }, + { url = "https://files.pythonhosted.org/packages/fa/8e/1fa52990c7836d72e8d70cd7753f2362c72fbb0a49c1462e8c60e7176d0b/rpds_py-0.23.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c8f7e90b948dc9dcfff8003f1ea3af08b29c062f681c05fd798e36daa3f7e3e8", size = 561792 }, + { url = "https://files.pythonhosted.org/packages/57/b8/fe3b612979b1a29d0c77f8585903d8b3a292604b26d4b300e228b8ac6360/rpds_py-0.23.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5b98b6c953e5c2bda51ab4d5b4f172617d462eebc7f4bfdc7c7e6b423f6da957", size = 588127 }, + { url = "https://files.pythonhosted.org/packages/44/2d/fde474de516bbc4b9b230f43c98e7f8acc5da7fc50ceed8e7af27553d346/rpds_py-0.23.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2893d778d4671ee627bac4037a075168b2673c57186fb1a57e993465dbd79a93", size = 556981 }, + { url = "https://files.pythonhosted.org/packages/18/57/767deeb27b81370bbab8f74ef6e68d26c4ea99018f3c71a570e506fede85/rpds_py-0.23.1-cp313-cp313-win32.whl", hash = "sha256:2cfa07c346a7ad07019c33fb9a63cf3acb1f5363c33bc73014e20d9fe8b01cdd", size = 221936 }, + { url = "https://files.pythonhosted.org/packages/7d/6c/3474cfdd3cafe243f97ab8474ea8949236eb2a1a341ca55e75ce00cd03da/rpds_py-0.23.1-cp313-cp313-win_amd64.whl", hash = "sha256:3aaf141d39f45322e44fc2c742e4b8b4098ead5317e5f884770c8df0c332da70", size = 237145 }, + { url = "https://files.pythonhosted.org/packages/ec/77/e985064c624230f61efa0423759bb066da56ebe40c654f8b5ba225bd5d63/rpds_py-0.23.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:759462b2d0aa5a04be5b3e37fb8183615f47014ae6b116e17036b131985cb731", size = 359623 }, + { url = "https://files.pythonhosted.org/packages/62/d9/a33dcbf62b29e40559e012d525bae7d516757cf042cc9234bd34ca4b6aeb/rpds_py-0.23.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3e9212f52074fc9d72cf242a84063787ab8e21e0950d4d6709886fb62bcb91d5", size = 345900 }, + { url = "https://files.pythonhosted.org/packages/92/eb/f81a4be6397861adb2cb868bb6a28a33292c2dcac567d1dc575226055e55/rpds_py-0.23.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e9f3a3ac919406bc0414bbbd76c6af99253c507150191ea79fab42fdb35982a", size = 386426 }, + { url = "https://files.pythonhosted.org/packages/09/47/1f810c9b5e83be005341201b5389f1d240dfa440346ea7189f9b3fd6961d/rpds_py-0.23.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c04ca91dda8a61584165825907f5c967ca09e9c65fe8966ee753a3f2b019fe1e", size = 392314 }, + { url = "https://files.pythonhosted.org/packages/83/bd/bc95831432fd6c46ed8001f01af26de0763a059d6d7e6d69e3c5bf02917a/rpds_py-0.23.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4ab923167cfd945abb9b51a407407cf19f5bee35001221f2911dc85ffd35ff4f", size = 447706 }, + { url = "https://files.pythonhosted.org/packages/19/3e/567c04c226b1802dc6dc82cad3d53e1fa0a773258571c74ac5d8fbde97ed/rpds_py-0.23.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed6f011bedca8585787e5082cce081bac3d30f54520097b2411351b3574e1219", size = 437060 }, + { url = "https://files.pythonhosted.org/packages/fe/77/a77d2c6afe27ae7d0d55fc32f6841502648070dc8d549fcc1e6d47ff8975/rpds_py-0.23.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6959bb9928c5c999aba4a3f5a6799d571ddc2c59ff49917ecf55be2bbb4e3722", size = 389347 }, + { url = "https://files.pythonhosted.org/packages/3f/47/6b256ff20a74cfebeac790ab05586e0ac91f88e331125d4740a6c86fc26f/rpds_py-0.23.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1ed7de3c86721b4e83ac440751329ec6a1102229aa18163f84c75b06b525ad7e", size = 415554 }, + { url = "https://files.pythonhosted.org/packages/fc/29/d4572469a245bc9fc81e35166dca19fc5298d5c43e1a6dd64bf145045193/rpds_py-0.23.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5fb89edee2fa237584e532fbf78f0ddd1e49a47c7c8cfa153ab4849dc72a35e6", size = 557418 }, + { url = "https://files.pythonhosted.org/packages/9c/0a/68cf7228895b1a3f6f39f51b15830e62456795e61193d2c8b87fd48c60db/rpds_py-0.23.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7e5413d2e2d86025e73f05510ad23dad5950ab8417b7fc6beaad99be8077138b", size = 583033 }, + { url = "https://files.pythonhosted.org/packages/14/18/017ab41dcd6649ad5db7d00155b4c212b31ab05bd857d5ba73a1617984eb/rpds_py-0.23.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d31ed4987d72aabdf521eddfb6a72988703c091cfc0064330b9e5f8d6a042ff5", size = 554880 }, + { url = "https://files.pythonhosted.org/packages/2e/dd/17de89431268da8819d8d51ce67beac28d9b22fccf437bc5d6d2bcd1acdb/rpds_py-0.23.1-cp313-cp313t-win32.whl", hash = "sha256:f3429fb8e15b20961efca8c8b21432623d85db2228cc73fe22756c6637aa39e7", size = 219743 }, + { url = "https://files.pythonhosted.org/packages/68/15/6d22d07e063ce5e9bfbd96db9ec2fbb4693591b4503e3a76996639474d02/rpds_py-0.23.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d6f6512a90bd5cd9030a6237f5346f046c6f0e40af98657568fa45695d4de59d", size = 235415 }, +] + +[[package]] +name = "s3transfer" +version = "0.11.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/ec/aa1a215e5c126fe5decbee2e107468f51d9ce190b9763cb649f76bb45938/s3transfer-0.11.4.tar.gz", hash = "sha256:559f161658e1cf0a911f45940552c696735f5c74e64362e515f333ebed87d679", size = 148419 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/62/8d3fc3ec6640161a5649b2cddbbf2b9fa39c92541225b33f117c37c5a2eb/s3transfer-0.11.4-py3-none-any.whl", hash = "sha256:ac265fa68318763a03bf2dc4f39d5cbd6a9e178d81cc9483ad27da33637e320d", size = 84412 }, +] + +[[package]] +name = "setuptools" +version = "76.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/2b/287ade3a580869e6178cb37d045f54272b1f006f2c0ff6fad08db258d027/setuptools-76.1.0.tar.gz", hash = "sha256:4959b9ad482ada2ba2320c8f1a8d8481d4d8d668908a7a1b84d987375cd7f5bd", size = 1350273 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/fb/47dc84839f2743553075c80d08543b3d0f498f42329141b6717504abcdfd/setuptools-76.1.0-py3-none-any.whl", hash = "sha256:34750dcb17d046929f545dec9b8349fe42bf4ba13ddffee78428aec422dbfb73", size = 1236933 }, +] + +[[package]] +name = "sherpa-onnx" +version = "1.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/74/2d68dca3b363ade69ef982bdc1d36f7115ddbd9c9a8dafa19016a6cdc63d/sherpa-onnx-1.11.1.tar.gz", hash = "sha256:17fac98868d2144bc3b6d4b925874dd2115143db075e85efc96322d0cf8d4470", size = 513441 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/aa/986bffe2670d3b0a789912de59caf32a5d99b577815f37a9bcfd188bebfc/sherpa_onnx-1.11.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:821e10c0a2ffb68771ab3bcfe80acda4cbd6b51bcc51917895e426571cc5dda6", size = 15705945 }, + { url = "https://files.pythonhosted.org/packages/57/72/9db1615245b98d3fe7e4cd07403992d1f832d7c020f1d17921c2ad99a49c/sherpa_onnx-1.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb62aadfc429b39bc86b2045cd1af37f51d58e0d965805eb2000c8bc5bcb7b85", size = 17734705 }, + { url = "https://files.pythonhosted.org/packages/95/c4/2ec7ac8ae60b379fd97cf149ab4c734541ab90c16879a353e250cbc352d8/sherpa_onnx-1.11.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8eac5b5e2fbd34d052390832dae49639bd3323a43fb211a8fe2a34134838c665", size = 36932085 }, + { url = "https://files.pythonhosted.org/packages/78/5c/be888fb6a64e8b2db8d7d7122dabfa0ab7012ee34876945ea70132133775/sherpa_onnx-1.11.1-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:06e9312704eb2db0b1f1f2aec8922c6738b7b66069ebe4ce348552bf16394c02", size = 20020691 }, + { url = "https://files.pythonhosted.org/packages/70/f4/6247f7a250c0b1dde70595e733bff4ceadef90b898766c7b37c0562a6813/sherpa_onnx-1.11.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b6125311bc18b31b40a9992019cf2fcb2ffe5d59c7b0e0a60c695575679e0485", size = 21446971 }, + { url = "https://files.pythonhosted.org/packages/c2/52/772651e7e1cd00dea5d6c01627ebc0be25404caf5ea9d9b816aff7172e2c/sherpa_onnx-1.11.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1750d262d7717303a70213e2dcb4a0acaec730c1cc929fd844141abe1d13b4a5", size = 23237907 }, + { url = "https://files.pythonhosted.org/packages/c0/13/96f6926bb11f3b3e05c91e805bcc1f61317ea19f33c312d9885447e6e276/sherpa_onnx-1.11.1-cp311-cp311-win32.whl", hash = "sha256:7d935bb5379df1ca54eb2a39a951557356c884d31b5a75dd22531c97b399361d", size = 20135439 }, + { url = "https://files.pythonhosted.org/packages/2e/88/019fdbd5ac25af249f0c5dd2a334aa32ba2f39e09993fe9757bf23482bd5/sherpa_onnx-1.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:6e8d2dda5fa06fc5eda63ffc82eec5751797026c51e5389049d2f817e8a46bcd", size = 22869398 }, + { url = "https://files.pythonhosted.org/packages/d2/36/dbdaca7ddd53cc5849838153dd5d4b003f46d92bd925a892fcee8c4b165c/sherpa_onnx-1.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c242d9b988330fe08461abb40d59afe64bf5aecc3b8c77c5108052fc5e41a354", size = 17740214 }, + { url = "https://files.pythonhosted.org/packages/35/11/32d80dbe03335fe41407c93e737c80f1fe86513a0cc60bee9640d4b6873f/sherpa_onnx-1.11.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3c7d1fde4363d7f1712ed4935cf82ec02b3b37c0369f981941c3455dd8b0ff39", size = 36948363 }, + { url = "https://files.pythonhosted.org/packages/7a/ec/412b75d7ddc8cfb0addc32f729d310fd357a36f82570e75c80b4e5482daf/sherpa_onnx-1.11.1-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:537859be7564d46ef5a9289cf586f4af1fa0ebc0957ddd35d4a76e74baf04b9f", size = 20025961 }, + { url = "https://files.pythonhosted.org/packages/ce/33/6f42ae9eca0877940d08ba60d406d98e460566ce1b805c8a0e7e8f0d86f0/sherpa_onnx-1.11.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0063c2755f650542e544256956b26f5b5797f26a2cab5a0e9f2b807f88f0d930", size = 21443602 }, + { url = "https://files.pythonhosted.org/packages/e0/07/b3d78baa2eec20a7c942d26dcfb25796f90c28b28355ede1dd325e015a8c/sherpa_onnx-1.11.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72937ffd81e4827483342ae0609db3a9f753f6b534d5d5d58036f68ca990e7e8", size = 23236974 }, + { url = "https://files.pythonhosted.org/packages/b2/a4/236b4e853fb48ed39ac2282e26a12cecd29649e8f9483d62d2f137c778a2/sherpa_onnx-1.11.1-cp312-cp312-win32.whl", hash = "sha256:ddfa40b878981615588462e81948c0d617ac8f37f08624787f54e33907b0c454", size = 20134000 }, + { url = "https://files.pythonhosted.org/packages/ee/7b/8f5a7bc308958fa163e593fb685141241a0ba1517d58b186722a7745e321/sherpa_onnx-1.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c0a92d5b432c50404ede5e070d7dee9bbae0aeb8cf31f7183e5e781caa28ee6", size = 22870820 }, + { url = "https://files.pythonhosted.org/packages/1a/64/41d7963482f430b35f3fc4c42f557143aa649330e349134b8f8e3da8a5e3/sherpa_onnx-1.11.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11801854aeb7a9314255cd5c3bf781ee3afcd7567d70230fc29885185b27036f", size = 17740176 }, + { url = "https://files.pythonhosted.org/packages/f8/35/7f116d39c88a23c8a03372839757f3e40c6e3ec397b69f58c92da5e8481d/sherpa_onnx-1.11.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:58518fd46cbb7bed97c3f1e89b563d42603be1d00d8de52d965475848096d2bc", size = 36948693 }, + { url = "https://files.pythonhosted.org/packages/df/46/59e605417637a8f2f8e1f402aed12cd0c18feca8a94c33bec1526142de67/sherpa_onnx-1.11.1-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:9b50eb6eb70b08d3e27f045c6780ce7c5d3f45aa938680aecd8eadcdc76e2e96", size = 20026002 }, + { url = "https://files.pythonhosted.org/packages/96/62/6b8f86f514606bc3ebd2ae7e710a4f31444db333c5b4eb2ccf796fe1d8cd/sherpa_onnx-1.11.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2796bbbf8ec3811b007ad7723098f45f02bf2bfd45ee94ed8aae661ece9d2ac9", size = 21443720 }, + { url = "https://files.pythonhosted.org/packages/0b/29/180e207b123ab8f60e58b4b344d3c105b482755f8cc145adb48b9528e84c/sherpa_onnx-1.11.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c05a17f0826e0cc22534a3f51a06efc3a2d2d0502e505113d5d34b6480631435", size = 23237020 }, + { url = "https://files.pythonhosted.org/packages/a4/e7/be84690717a000771895e34fd5f9983951f3f1c4355b74acb3933d6ba2a8/sherpa_onnx-1.11.1-cp313-cp313-win32.whl", hash = "sha256:2632802ac574a9e8523ae1ce2a2eb094cef86b117cd3dbf0ead6fd0c53aeeaa5", size = 20134213 }, + { url = "https://files.pythonhosted.org/packages/05/b0/22c2bfa43944b5af14dabd406de0b3d371565dae305462da1510a7674bc6/sherpa_onnx-1.11.1-cp313-cp313-win_amd64.whl", hash = "sha256:705c21ee30400cc440966f17ef9b18460da2f65b73bb0744a42f4477333fc2ab", size = 22870853 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "sounddevice" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/2d/b04ae180312b81dbb694504bee170eada5372242e186f6298139fd3a0513/sounddevice-0.5.1.tar.gz", hash = "sha256:09ca991daeda8ce4be9ac91e15a9a81c8f81efa6b695a348c9171ea0c16cb041", size = 52896 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/d1/464b5fca3decdd0cfec8c47f7b4161a0b12972453201c1bf03811f367c5e/sounddevice-0.5.1-py3-none-any.whl", hash = "sha256:e2017f182888c3f3c280d9fbac92e5dbddac024a7e3442f6e6116bd79dab8a9c", size = 32276 }, + { url = "https://files.pythonhosted.org/packages/6f/f6/6703fe7cf3d7b7279040c792aeec6334e7305956aba4a80f23e62c8fdc44/sounddevice-0.5.1-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl", hash = "sha256:d16cb23d92322526a86a9490c427bf8d49e273d9ccc0bd096feecd229cde6031", size = 107916 }, + { url = "https://files.pythonhosted.org/packages/57/a5/78a5e71f5ec0faedc54f4053775d61407bfbd7d0c18228c7f3d4252fd276/sounddevice-0.5.1-py3-none-win32.whl", hash = "sha256:d84cc6231526e7a08e89beff229c37f762baefe5e0cc2747cbe8e3a565470055", size = 312494 }, + { url = "https://files.pythonhosted.org/packages/af/9b/15217b04f3b36d30de55fef542389d722de63f1ad81f9c72d8afc98cb6ab/sounddevice-0.5.1-py3-none-win_amd64.whl", hash = "sha256:4313b63f2076552b23ac3e0abd3bcfc0c1c6a696fc356759a13bd113c9df90f1", size = 363634 }, +] + +[[package]] +name = "soundfile" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/41/9b873a8c055582859b239be17902a85339bec6a30ad162f98c9b0288a2cc/soundfile-0.13.1.tar.gz", hash = "sha256:b2c68dab1e30297317080a5b43df57e302584c49e2942defdde0acccc53f0e5b", size = 46156 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/28/e2a36573ccbcf3d57c00626a21fe51989380636e821b341d36ccca0c1c3a/soundfile-0.13.1-py2.py3-none-any.whl", hash = "sha256:a23c717560da2cf4c7b5ae1142514e0fd82d6bbd9dfc93a50423447142f2c445", size = 25751 }, + { url = "https://files.pythonhosted.org/packages/ea/ab/73e97a5b3cc46bba7ff8650a1504348fa1863a6f9d57d7001c6b67c5f20e/soundfile-0.13.1-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:82dc664d19831933fe59adad199bf3945ad06d84bc111a5b4c0d3089a5b9ec33", size = 1142250 }, + { url = "https://files.pythonhosted.org/packages/a0/e5/58fd1a8d7b26fc113af244f966ee3aecf03cb9293cb935daaddc1e455e18/soundfile-0.13.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:743f12c12c4054921e15736c6be09ac26b3b3d603aef6fd69f9dde68748f2593", size = 1101406 }, + { url = "https://files.pythonhosted.org/packages/58/ae/c0e4a53d77cf6e9a04179535766b3321b0b9ced5f70522e4caf9329f0046/soundfile-0.13.1-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9c9e855f5a4d06ce4213f31918653ab7de0c5a8d8107cd2427e44b42df547deb", size = 1235729 }, + { url = "https://files.pythonhosted.org/packages/57/5e/70bdd9579b35003a489fc850b5047beeda26328053ebadc1fb60f320f7db/soundfile-0.13.1-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:03267c4e493315294834a0870f31dbb3b28a95561b80b134f0bd3cf2d5f0e618", size = 1313646 }, + { url = "https://files.pythonhosted.org/packages/fe/df/8c11dc4dfceda14e3003bb81a0d0edcaaf0796dd7b4f826ea3e532146bba/soundfile-0.13.1-py2.py3-none-win32.whl", hash = "sha256:c734564fab7c5ddf8e9be5bf70bab68042cd17e9c214c06e365e20d64f9a69d5", size = 899881 }, + { url = "https://files.pythonhosted.org/packages/14/e9/6b761de83277f2f02ded7e7ea6f07828ec78e4b229b80e4ca55dd205b9dc/soundfile-0.13.1-py2.py3-none-win_amd64.whl", hash = "sha256:1e70a05a0626524a69e9f0f4dd2ec174b4e9567f4d8b6c11d38b5c289be36ee9", size = 1019162 }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] + +[[package]] +name = "urllib3" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, +] + +[[package]] +name = "websocket-client" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/30/fba0d96b4b5fbf5948ed3f4681f7da2f9f64512e1d303f94b4cc174c24a5/websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da", size = 54648 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", size = 58826 }, +] + +[[package]] +name = "werkzeug" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498 }, +] From bc88023bfda1577acaa0ee7ae4457f3d29e2d761 Mon Sep 17 00:00:00 2001 From: will wade Date: Wed, 19 Mar 2025 14:46:12 +0000 Subject: [PATCH 06/37] fixing root to match original --- speech/start.py | 73 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 66 insertions(+), 7 deletions(-) diff --git a/speech/start.py b/speech/start.py index d9c4333..3721454 100644 --- a/speech/start.py +++ b/speech/start.py @@ -6,7 +6,7 @@ import sys from urllib.parse import unquote -from flask import Flask, jsonify, send_file +from flask import Flask, jsonify, request, send_file from flask_cors import CORS from flask_restx import Api, Resource, fields @@ -45,6 +45,40 @@ # Define namespaces ns = api.namespace("", description="Speech synthesis operations") +# Define models +root_response = api.model( + "RootResponse", + { + "name": fields.String(description="API name"), + "version": fields.String(description="API version"), + "description": fields.String(description="API description"), + "documentation": fields.String(description="Link to API documentation"), + "endpoints": fields.Raw(description="Available API endpoints"), + }, +) + + +@ns.route("/") +class Root(Resource): + @ns.doc("get_root") + @ns.response(200, "Success", root_response) + def get(self): + """Return basic API information and documentation link.""" + return { + "name": "AsTeRICS Grid Speech API", + "version": "1.0", + "description": "API for text-to-speech functionality in AsTeRICS Grid", + "documentation": "/docs", + "endpoints": { + "voices": "/voices", + "speak": "/speak///", + "speakdata": "/speakdata///", + "speaking": "/speaking", + "stop": "/stop", + }, + } + + # Define models voice_model = api.model( "Voice", @@ -104,6 +138,9 @@ @app.errorhandler(Exception) def handle_error(error): """Handle all exceptions and return them as JSON responses.""" + # Let Flask-RESTX handle its own routes + if hasattr(error, "code") and error.code == 404 and request.path.startswith("/"): + return error logger.error(f"Error: {error!s}", exc_info=True) return jsonify({"error": str(error), "status": "error"}), 200 @@ -123,17 +160,22 @@ def get(self): return {"error": str(e), "status": "error", "voices": []}, 200 +@ns.route("/speakdata/") +@ns.route("/speakdata//") @ns.route("/speakdata///") class SpeakData(Resource): @ns.doc("get_speak_data") @ns.param("text", "Text to convert to speech") - @ns.param("provider_id", "TTS provider ID") - @ns.param("voice_id", "Voice ID to use") + @ns.param("provider_id", "TTS provider ID", required=False) + @ns.param("voice_id", "Voice ID to use", required=False) @ns.response(200, "Success") @ns.response(500, "Error", error_response) - def get(self, text: str, provider_id: str, voice_id: str): + def get(self, text: str, provider_id: str = "", voice_id: str = ""): """Get speech data for text.""" try: + text = unquote(text).lower() + provider_id = unquote(provider_id) + voice_id = unquote(voice_id) data = get_speak_data(text, voice_id, provider_id) if data is None: return { @@ -150,24 +192,37 @@ def get(self, text: str, provider_id: str, voice_id: str): logger.error(f"Error in /speakdata endpoint: {e!s}", exc_info=True) return {"error": str(e), "status": "error"}, 200 + def post(self, text: str, provider_id: str = "", voice_id: str = ""): + """POST method for speakdata endpoint.""" + return self.get(text, provider_id, voice_id) + +@ns.route("/speak/") +@ns.route("/speak//") @ns.route("/speak///") class Speak(Resource): @ns.doc("speak_text") @ns.param("text", "Text to speak") - @ns.param("provider_id", "TTS provider ID") - @ns.param("voice_id", "Voice ID to use") + @ns.param("provider_id", "TTS provider ID", required=False) + @ns.param("voice_id", "Voice ID to use", required=False) @ns.response(200, "Success", success_response) @ns.response(500, "Error", error_response) - def get(self, text: str, provider_id: str, voice_id: str): + def get(self, text: str, provider_id: str = "", voice_id: str = ""): """Speak text using specified voice.""" try: + text = unquote(text).lower() + provider_id = unquote(provider_id) + voice_id = unquote(voice_id) speak(text, provider_id, voice_id) return {"status": "success"} except Exception as e: logger.error(f"Error in /speak endpoint: {e!s}", exc_info=True) return {"error": str(e), "status": "error"}, 200 + def post(self, text: str, provider_id: str = "", voice_id: str = ""): + """POST method for speak endpoint.""" + return self.get(text, provider_id, voice_id) + @app.route("/cache///", methods=["POST", "GET"]) def cache_data(text: str, provider_id: str = "", voice_id: str = ""): @@ -210,6 +265,10 @@ def get(self): logger.error(f"Error in /stop endpoint: {e!s}", exc_info=True) return {"error": str(e), "status": "error"}, 200 + def post(self): + """POST method for stop endpoint.""" + return self.get() + def start_server(host: str = "127.0.0.1", port: int = 5555) -> None: """Start the Flask server.""" From e0cdae4130a137e57bd18221791423baf836214c Mon Sep 17 00:00:00 2001 From: will wade Date: Thu, 20 Mar 2025 06:12:39 +0000 Subject: [PATCH 07/37] fix for multiple engines - make voices list more like original --- .coverage | Bin 53248 -> 53248 bytes speech/config.py | 104 ++++++++--- speech/speech_manager.py | 394 +++++++++++++++++++++------------------ speech/start.py | 57 +++--- speech/test_endpoints.py | 318 ++++++++++++++++++++++++++----- 5 files changed, 592 insertions(+), 281 deletions(-) diff --git a/.coverage b/.coverage index 8708815e455898c452945ca92ee254358dc4f785..00179b2743ed047a0ae02405b05e0041dfcd1cf0 100644 GIT binary patch delta 205 zcmZozz}&Eac>`Mmj~@g7PyU_!i}{olji_CLDc zfB5*LiSfbr&-MT7#s9tEzrk*Q^L>351_3n|pjtl`F$RVcPA(Daf3s9GK!L*@hyU%R zyUi|W?*F8{RFoO0PKP;3Ep~U(u@n3Du!B`e*f9Zx>zFE>%})P0%wPP!dDDLzoxdq{ zmnQrDTB1JpOXT(c>+An6+yC#y`uO$x^H%SddBM8o??uahUJjf8Kb+6ZwE1U0y8{5h C-&kq@ delta 160 zcmV;R0AK%rpaX!Q1F!~w4153&`483)#1Eeje6tY{Y7c)Q1q1;JL list[dict[str, Any]]: + """Get available voices.""" + raise NotImplementedError - def on_speech_end(self): - """Callback for when speech ends.""" - self._speaking = False - self._audio_loaded = False + def get_speak_data(self, text: str, voice_id: str) -> bytes: + """Get speech data for text.""" + raise NotImplementedError - def is_speaking(self) -> bool: - """Check if text is being spoken.""" - return self._speaking + def speak(self, text: str, voice_id: str) -> None: + """Speak text using the specified voice.""" + raise NotImplementedError - def stop_speaking(self) -> None: - """Stop the current speech playback.""" - try: - if self.tts_instance and self._audio_loaded: - self.tts_instance.stop_audio() - self._speaking = False - self._audio_loaded = False - except Exception as e: - print(f"Error stopping speech: {e}") - - def get_platform_tts(self): - """Get the appropriate TTS instance for the current platform.""" - if platform.system().lower() == "darwin": - client = AVSynthClient() - return AVSynthTTS(client) - elif platform.system().lower() == "win32": - client = SAPIClient() - return SAPITTS(client) - else: - client = eSpeakClient() - return eSpeakTTS(client) + +class SpeechManager: + """Manages TTS providers and speech operations.""" + + def __init__(self): + """Initialize the speech manager.""" + self.logger = logging.getLogger(__name__) + self.providers = {} + self.current_provider = None + self.is_speaking = False def init_providers(self): """Initialize TTS providers based on configuration.""" config = get_tts_config() - engine = config.get( - "engine", "tts" - ).lower() # Convert to lowercase for comparison - print(f"Initializing TTS provider: {engine}") + engines = config.get("engines", ["espeak"]) + self.logger.info(f"SpeechManager: Got engines from config: {engines}") try: - if engine == "google": - client = GoogleClient() - self.tts_instance = GoogleTTS(client) - elif engine == "microsoft": - client = MicrosoftClient() - self.tts_instance = MicrosoftTTS(client) - elif engine == "polly": - client = PollyClient() - self.tts_instance = PollyTTS(client) - elif engine == "watson": - client = WatsonClient() - self.tts_instance = WatsonTTS(client) - elif engine == "elevenlabs": - client = ElevenLabsClient() - self.tts_instance = ElevenLabsTTS(client) - elif engine == "witai": - client = WitAiClient() - self.tts_instance = WitAiTTS(client) - elif engine == "sherpaonnx": - client = SherpaOnnxClient() - self.tts_instance = SherpaOnnxTTS(client) - else: - print( - f"Engine '{engine}' not found, falling back to platform-specific TTS" + for engine in engines: + engine_name = engine.lower() + self.logger.info( + f"SpeechManager: Attempting to initialize {engine_name} provider..." ) - self.tts_instance = self.get_platform_tts() - if self.tts_instance: - print( - f"Successfully initialized {self.tts_instance.__class__.__name__}" - ) - else: - print("Failed to initialize TTS instance") - except Exception as e: - print(f"Error initializing TTS provider: {e}") - import traceback + if engine_name == "espeak": + client = eSpeakClient() + self.providers["espeak"] = eSpeakTTS(client) + self.logger.info( + "SpeechManager: eSpeak provider initialized successfully" + ) + elif engine_name == "sherpaonnx" and "sherpaonnx" in config.get( + "engine_configs", {} + ): + client = SherpaOnnxClient() + self.providers["sherpaonnx"] = SherpaOnnxTTS(client) + self.logger.info( + "SpeechManager: SherpaOnnx provider initialized successfully" + ) + elif engine_name == "google" and "google" in config.get( + "engine_configs", {} + ): + client = GoogleClient(credentials=config.get("credentials", {})) + self.providers["google"] = GoogleTTS(client) + self.logger.info( + "SpeechManager: Google provider initialized successfully" + ) + elif engine_name == "microsoft" and "microsoft" in config.get( + "engine_configs", {} + ): + client = MicrosoftClient(credentials=config.get("credentials", {})) + self.providers["microsoft"] = MicrosoftTTS(client) + self.logger.info( + "SpeechManager: Microsoft provider initialized successfully" + ) + elif engine_name == "polly" and "polly" in config.get( + "engine_configs", {} + ): + client = PollyClient(credentials=config.get("credentials", {})) + self.providers["polly"] = PollyTTS(client) + self.logger.info( + "SpeechManager: Polly provider initialized successfully" + ) + elif engine_name == "watson" and "watson" in config.get( + "engine_configs", {} + ): + client = WatsonClient(credentials=config.get("credentials", {})) + self.providers["watson"] = WatsonTTS(client) + self.logger.info( + "SpeechManager: Watson provider initialized successfully" + ) + elif engine_name == "elevenlabs" and "elevenlabs" in config.get( + "engine_configs", {} + ): + client = ElevenLabsClient(credentials=config.get("credentials", {})) + self.providers["elevenlabs"] = ElevenLabsTTS(client) + self.logger.info( + "SpeechManager: ElevenLabs provider initialized successfully" + ) + elif engine_name == "witai" and "witai" in config.get( + "engine_configs", {} + ): + client = WitAiClient(credentials=config.get("credentials", {})) + self.providers["witai"] = WitAiTTS(client) + self.logger.info( + "SpeechManager: Wit.AI provider initialized successfully" + ) + # elif engine_name == "avsynth" and "avsynth" in config.get( + # "engine_configs", {} + # ): + # client = AVSynthClient() + # self.providers["avsynth"] = AVSynthTTS(client) + # self.logger.info( + # "SpeechManager: AVSynth provider initialized successfully" + # ) + # elif engine_name == "sapi" and "sapi" in config.get( + # "engine_configs", {} + # ): + # client = SAPIClient() + # self.providers["sapi"] = SAPITTS(client) + # self.logger.info( + # "SpeechManager: SAPI provider initialized successfully" + # ) + else: + self.logger.warning( + f"SpeechManager: Unsupported TTS engine: {engine_name}" + ) + continue + + if not self.providers: + raise ValueError("No valid TTS providers were initialized") + + # Set the first provider as the current provider + self.current_provider = next(iter(self.providers.values())) + self.logger.info( + f"SpeechManager: Successfully initialized providers: {list(self.providers.keys())}" + ) + self.logger.info( + f"SpeechManager: Current provider: {self.current_provider.__class__.__name__}" + ) - traceback.print_exc() - self.tts_instance = None + except Exception as e: + self.logger.error(f"Failed to initialize providers: {e}") + raise def get_voices(self) -> list[dict[str, Any]]: - """Get available voices.""" - try: - if not self.tts_instance: - print("Initializing TTS providers...") - self.init_providers() - if not self.tts_instance: - print("No TTS instance available after initialization") - return [] - print(f"Getting voices from {self.tts_instance.__class__.__name__}") - voices = self.tts_instance.get_voices() - print(f"Found {len(voices)} voices") - return voices - except Exception as e: - print(f"Error getting voices: {e!s}") - return [] + """Get available voices from all providers.""" + all_voices = [] + for provider_id, provider in self.providers.items(): + try: + self.logger.info(f"Getting voices from provider: {provider_id}") + voices = provider.get_voices() + self.logger.info(f"Found {len(voices)} voices from {provider_id}") + for voice in voices: + voice["providerId"] = provider_id + voice["type"] = "external_data" + voice["name"] = f"{voice['name']}, {provider_id}" + all_voices.extend(voices) + except Exception as e: + self.logger.error(f"Error getting voices from {provider_id}: {e}") + continue + + self.logger.info( + f"Found {len(all_voices)} voices across {len(self.providers)} providers" + ) + return all_voices def get_speak_data( - self, text: str, voice_id: str, provider_id: str - ) -> bytes | None: - """Get speech data for the given text.""" - try: - if not self.tts_instance: - self.init_providers() - if not self.tts_instance: - return None - - # Set voice if specified - if voice_id: - self.tts_instance.set_voice(voice_id) - - # Generate audio data - return self.tts_instance.synth_to_bytes(text) - except Exception as e: - print(f"Error generating speech data: {e}") - return None - - def speak(self, text: str, provider_id: str, voice_id: str | None = None) -> None: - """Speak the given text using the specified voice.""" + self, text: str, voice_id: str, provider_id: str | None = None + ) -> bytes: + """Get speech data for text using the specified provider.""" + provider = ( + self.providers.get(provider_id) if provider_id else self.current_provider + ) + if not provider: + raise ValueError(f"Provider {provider_id} not found") + return provider.get_speak_data(text, voice_id) + + def speak(self, text: str, voice_id: str, provider_id: str | None = None) -> None: + """Speak text using the specified provider.""" + provider = ( + self.providers.get(provider_id) if provider_id else self.current_provider + ) + if not provider: + raise ValueError(f"Provider {provider_id} not found") + self.is_speaking = True try: - if not self.tts_instance: - self.init_providers() - if not self.tts_instance: - return - - # Set up callbacks for speech state tracking - self.tts_instance.connect("onStart", self.on_speech_start) - self.tts_instance.connect("onEnd", self.on_speech_end) - - # Set voice if specified - if voice_id: - self.tts_instance.set_voice(voice_id) - - # Generate and play audio - audio_bytes = self.tts_instance.synth_to_bytes(text) - self.tts_instance.load_audio(audio_bytes) - self._audio_loaded = True - self.tts_instance.play() - except Exception as e: - print(f"Error speaking text: {e}") - self._speaking = False - self._audio_loaded = False + provider.speak(text, voice_id) + finally: + self.is_speaking = False - -# Create a singleton instance -speech_manager = SpeechManager() + def stop_speaking(self) -> None: + """Stop the current speech playback.""" + if self.current_provider: + self.current_provider.stop_speaking() + self.is_speaking = False -# Export functions that use the singleton -def get_voices() -> list[dict[str, Any]]: +def get_voices(speech_manager: SpeechManager) -> list[dict[str, Any]]: """Get available voices.""" - try: - if not speech_manager.tts_instance: - print("Initializing TTS providers...") - speech_manager.init_providers() - if not speech_manager.tts_instance: - print("No TTS instance available after initialization") - return [] - print(f"Getting voices from {speech_manager.tts_instance.__class__.__name__}") - voices = speech_manager.tts_instance.get_voices() - print(f"Found {len(voices)} voices") - return voices - except Exception as e: - print(f"Error getting voices: {e!s}") - return [] - - -def get_speak_data(text: str, voice_id: str, provider_id: str) -> bytes | None: - """Get speech data for the given text.""" + return speech_manager.get_voices() + + +def get_speak_data( + text: str, + voice_id: str, + provider_id: str | None = None, + speech_manager: SpeechManager | None = None, +) -> bytes: + """Get speech data for text.""" + if speech_manager is None: + raise ValueError("speech_manager is required") return speech_manager.get_speak_data(text, voice_id, provider_id) -def speak(text: str, provider_id: str, voice_id: str | None = None) -> None: - """Speak the given text using the specified voice.""" - speech_manager.speak(text, provider_id, voice_id) +def speak( + text: str, + voice_id: str, + provider_id: str | None = None, + speech_manager: SpeechManager | None = None, +) -> None: + """Speak text using the specified voice.""" + if speech_manager is None: + raise ValueError("speech_manager is required") + speech_manager.speak(text, voice_id, provider_id) -def init_providers(): - """Initialize TTS providers.""" - speech_manager.init_providers() +def stop_speaking(speech_manager: SpeechManager) -> None: + """Stop the current speech playback.""" + speech_manager.stop_speaking() -def is_speaking() -> bool: +def is_speaking(speech_manager: SpeechManager) -> bool: """Check if text is being spoken.""" - return speech_manager.is_speaking() - - -def stop_speaking() -> None: - """Stop the current speech playback.""" - speech_manager.stop_speaking() + return speech_manager.is_speaking diff --git a/speech/start.py b/speech/start.py index 3721454..f0610f7 100644 --- a/speech/start.py +++ b/speech/start.py @@ -2,8 +2,6 @@ import io import logging -import os -import sys from urllib.parse import unquote from flask import Flask, jsonify, request, send_file @@ -12,27 +10,28 @@ from .config import CACHE_ENABLED from .speech_manager import ( + SpeechManager, get_speak_data, get_voices, - init_providers, is_speaking, speak, stop_speaking, ) # Configure logging -logging.basicConfig(level=logging.INFO) +logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger(__name__) -# Add the parent directory to the Python path -current_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -if current_dir not in sys.path: - sys.path.insert(0, current_dir) +# HTTP status codes +HTTP_NOT_FOUND = 404 app = Flask(__name__) app.url_map.strict_slashes = False CORS(app) +# Create speech manager instance +speech_manager = SpeechManager() + # Initialize Flask-RESTX api = Api( app, @@ -40,6 +39,7 @@ title="AsTeRICS Grid Speech API", description="API for text-to-speech functionality in AsTeRICS Grid", doc="/docs", + prefix="/api", # Add a prefix to avoid conflicts with Flask routes ) # Define namespaces @@ -130,16 +130,17 @@ def get(self): }, ) -# Initialize speech provider -init_providers() - # Error handler for all exceptions @app.errorhandler(Exception) def handle_error(error): """Handle all exceptions and return them as JSON responses.""" # Let Flask-RESTX handle its own routes - if hasattr(error, "code") and error.code == 404 and request.path.startswith("/"): + if ( + hasattr(error, "code") + and error.code == HTTP_NOT_FOUND + and request.path.startswith("/") + ): return error logger.error(f"Error: {error!s}", exc_info=True) return jsonify({"error": str(error), "status": "error"}), 200 @@ -151,9 +152,9 @@ class Voices(Resource): @ns.response(200, "Success", voices_response) @ns.response(500, "Error", error_response) def get(self): - """Get available voices.""" + """Get available voices from all providers.""" try: - voices = get_voices() + voices = get_voices(speech_manager) return {"voices": voices, "status": "success"} except Exception as e: logger.error(f"Error in /voices endpoint: {e!s}", exc_info=True) @@ -176,7 +177,7 @@ def get(self, text: str, provider_id: str = "", voice_id: str = ""): text = unquote(text).lower() provider_id = unquote(provider_id) voice_id = unquote(voice_id) - data = get_speak_data(text, voice_id, provider_id) + data = get_speak_data(text, voice_id, provider_id, speech_manager) if data is None: return { "error": "Failed to generate speech data", @@ -213,7 +214,7 @@ def get(self, text: str, provider_id: str = "", voice_id: str = ""): text = unquote(text).lower() provider_id = unquote(provider_id) voice_id = unquote(voice_id) - speak(text, provider_id, voice_id) + speak(text, provider_id, voice_id, speech_manager) return {"status": "success"} except Exception as e: logger.error(f"Error in /speak endpoint: {e!s}", exc_info=True) @@ -232,7 +233,7 @@ def cache_data(text: str, provider_id: str = "", voice_id: str = ""): text = unquote(text).lower() provider_id = unquote(provider_id) voice_id = unquote(voice_id) - get_speak_data(text, voice_id, provider_id) + get_speak_data(text, voice_id, provider_id, speech_manager) return jsonify(True) @@ -244,7 +245,7 @@ class Speaking(Resource): def get(self): """Check if text is being spoken.""" try: - speaking = is_speaking() + speaking = is_speaking(speech_manager) return {"speaking": speaking, "status": "success"} except Exception as e: logger.error(f"Error in /speaking endpoint: {e!s}", exc_info=True) @@ -259,7 +260,7 @@ class Stop(Resource): def get(self): """Stop speaking.""" try: - stop_speaking() + stop_speaking(speech_manager) return {"status": "success"} except Exception as e: logger.error(f"Error in /stop endpoint: {e!s}", exc_info=True) @@ -270,10 +271,22 @@ def post(self): return self.get() -def start_server(host: str = "127.0.0.1", port: int = 5555) -> None: +def start_server(): """Start the Flask server.""" - init_providers() - app.run(host=host, port=port) + try: + # Initialize speech providers + speech_manager.init_providers() + + # Start Flask server + app.run( + host="127.0.0.1", + port=5555, + debug=True, # Keep debug mode for error reporting + use_reloader=False, # Disable the reloader to prevent double initialization + ) + except Exception as e: + logger.error(f"Failed to start server: {e}") + raise if __name__ == "__main__": diff --git a/speech/test_endpoints.py b/speech/test_endpoints.py index ff8d73c..c2ffb96 100644 --- a/speech/test_endpoints.py +++ b/speech/test_endpoints.py @@ -4,11 +4,195 @@ import unittest from unittest.mock import patch +import pytest + from speech.config import get_tts_config -from speech.speech_manager import get_speak_data, get_voices +from speech.speech_manager import ( + SpeechManager, + get_speak_data, + get_voices, +) from speech.start import app +@pytest.fixture +def test_app(): + """Create a test Flask app instance.""" + app.config["TESTING"] = True + return app + + +@pytest.fixture +def test_client(test_app): + """Create a test client for the Flask app.""" + return test_app.test_client() + + +@pytest.fixture(autouse=True) +def setup_speech_manager(mocker): + """Set up the speech manager for all tests.""" + # Mock the get_tts_config function + mocker.patch( + "speech.config.get_tts_config", + return_value={ + "engines": ["sherpaonnx"], + "credentials": {}, + "cache_enabled": True, + "cache_dir": "cache", + "engine_configs": {"sherpaonnx": {}}, + }, + ) + + # Mock the environment variables + mocker.patch.dict( + "os.environ", + { + "TTS_ENGINE": "sherpaonnx", + "CACHE_ENABLED": "true", + "CACHE_DIR": "cache", + }, + ) + + # Create a real speech manager instance + speech_manager = SpeechManager() + speech_manager.init_providers() + + # Mock the provider methods that are missing + if speech_manager.current_provider: + speech_manager.current_provider.get_speak_data = mocker.Mock( + return_value=b"test_audio_data" + ) + speech_manager.current_provider.stop_speaking = mocker.Mock() + speech_manager.current_provider.get_voices = mocker.Mock( + return_value=[ + { + "id": "en", + "name": "English", + "language_codes": ["en"], + "gender": "N", + "providerId": "sherpaonnx", + "type": "external_data", + } + ] + ) + + # Mock the speech_manager in start.py + mocker.patch("speech.start.speech_manager", speech_manager) + + return speech_manager + + +@pytest.fixture +def mock_speech_manager(mocker): + """Create a mock speech manager.""" + mock_manager = mocker.Mock(spec=SpeechManager) + mock_manager.get_voices.return_value = [ + { + "id": "en", + "name": "English", + "language_codes": ["en"], + "gender": "N", + "providerId": "sherpaonnx", + "type": "external_data", + } + ] + mock_manager.is_speaking = False + mock_manager.stop_speaking = mocker.Mock() + mock_manager.get_speak_data = mocker.Mock(return_value=b"test_audio_data") + return mock_manager + + +def test_root_endpoint(test_client): + """Test the root endpoint.""" + response = test_client.get("/api/") + assert response.status_code == 200 + data = response.get_json() + assert data["name"] == "AsTeRICS Grid Speech API" + assert data["version"] == "1.0" + assert "endpoints" in data + + +def test_voices_endpoint(test_client, mock_speech_manager, mocker): + """Test the voices endpoint.""" + # Mock the get_voices function to use our mock manager + mocker.patch( + "speech.start.get_voices", return_value=mock_speech_manager.get_voices() + ) + + response = test_client.get("/api/voices") + assert response.status_code == 200 + data = response.get_json() + assert data["status"] == "success" + assert len(data["voices"]) == 1 + assert data["voices"][0]["id"] == "en" + + +def test_speak_endpoint(test_client, mock_speech_manager, mocker): + """Test the speak endpoint.""" + # Mock the speak function to use our mock manager + mocker.patch("speech.start.speak") + + response = test_client.get("/api/speak/test_text/sherpaonnx/en") + assert response.status_code == 200 + data = response.get_json() + assert data["status"] == "success" + + +def test_speakdata_endpoint(test_client, mock_speech_manager, mocker): + """Test the speakdata endpoint.""" + # Mock the get_speak_data function to return test audio data + mocker.patch("speech.start.get_speak_data", return_value=b"test_audio_data") + + response = test_client.get("/api/speakdata/test_text/sherpaonnx/en") + assert response.status_code == 200 + assert response.mimetype == "audio/wav" + + +def test_speaking_endpoint(test_client, mock_speech_manager, mocker): + """Test the speaking endpoint.""" + # Mock the is_speaking function to use our mock manager + mocker.patch("speech.start.is_speaking", return_value=False) + + response = test_client.get("/api/speaking") + assert response.status_code == 200 + data = response.get_json() + assert data["status"] == "success" + assert data["speaking"] is False + + +def test_stop_endpoint(test_client, mock_speech_manager, mocker): + """Test the stop endpoint.""" + # Mock the stop_speaking function to use our mock manager + mocker.patch("speech.start.stop_speaking") + + response = test_client.get("/api/stop") + assert response.status_code == 200 + data = response.get_json() + assert data["status"] == "success" + + +def test_cache_endpoint(test_client, mock_speech_manager, mocker): + """Test the cache endpoint.""" + # Mock the get_speak_data function to return test audio data + mocker.patch("speech.start.get_speak_data", return_value=b"test_audio_data") + + response = test_client.get("/cache/test_text/espeak/test_voice") + assert response.status_code == 200 + assert response.get_json() is True + + +def test_error_handling(test_client, mock_speech_manager, mocker): + """Test error handling in endpoints.""" + # Mock the get_voices function to raise an exception + mocker.patch("speech.start.get_voices", side_effect=Exception("Test error")) + + response = test_client.get("/api/voices") + assert response.status_code == 200 + data = response.get_json() + assert data["status"] == "error" + assert "Test error" in data["error"] + + class TestSpeechService(unittest.TestCase): """Test cases for the speech service.""" @@ -16,10 +200,11 @@ def setUp(self): """Set up test environment.""" self.app = app.test_client() self.app.testing = True + self.speech_manager = SpeechManager() def test_voices_endpoint(self): """Test the /voices endpoint.""" - response = self.app.get("/voices") + response = self.app.get("/api/voices") self.assertEqual(response.status_code, 200) data = response.get_json() self.assertIn("voices", data) @@ -38,7 +223,7 @@ def test_voices_endpoint_error(self): """Test the /voices endpoint with error handling.""" with patch("speech.start.get_voices") as mock_get_voices: mock_get_voices.side_effect = Exception("Test error") - response = self.app.get("/voices") + response = self.app.get("/api/voices") self.assertEqual(response.status_code, 200) data = response.get_json() self.assertEqual(data["error"], "Test error") @@ -48,9 +233,9 @@ def test_voices_endpoint_error(self): def test_speakdata_endpoint(self): """Test the /speakdata endpoint.""" text = "This is a test sentence to verify speech synthesis." - provider_id = "tts" # Default provider - voice_id = "en-US" # Default voice - response = self.app.get(f"/speakdata/{text}/{provider_id}/{voice_id}") + provider_id = "sherpaonnx" # Use sherpaonnx provider + voice_id = "en" # Use English voice + response = self.app.get(f"/api/speakdata/{text}/{provider_id}/{voice_id}") self.assertEqual(response.status_code, 200) self.assertEqual(response.mimetype, "audio/wav") # Verify we got some audio data @@ -62,7 +247,7 @@ def test_speakdata_endpoint_error(self): """Test the /speakdata endpoint with error handling.""" with patch("speech.start.get_speak_data") as mock_get_speak_data: mock_get_speak_data.return_value = None - response = self.app.get("/speakdata/test/tts/en-US") + response = self.app.get("/api/speakdata/test/tts/en-US") self.assertEqual(response.status_code, 200) data = response.get_json() self.assertEqual(data["error"], "Failed to generate speech data") @@ -71,9 +256,9 @@ def test_speakdata_endpoint_error(self): def test_speak_endpoint(self): """Test the /speak endpoint.""" text = "This is a test sentence to verify speech synthesis." - provider_id = "tts" # Default provider - voice_id = "en-US" # Default voice - response = self.app.get(f"/speak/{text}/{provider_id}/{voice_id}") + provider_id = "sherpaonnx" # Use sherpaonnx provider + voice_id = "en" # Use English voice + response = self.app.get(f"/api/speak/{text}/{provider_id}/{voice_id}") self.assertEqual(response.status_code, 200) data = response.get_json() self.assertEqual(data["status"], "success") @@ -84,7 +269,7 @@ def test_speak_endpoint_error(self): """Test the /speak endpoint with error handling.""" with patch("speech.start.speak") as mock_speak: mock_speak.side_effect = Exception("Test error") - response = self.app.get("/speak/test/tts/en-US") + response = self.app.get("/api/speak/test/tts/en-US") self.assertEqual(response.status_code, 200) data = response.get_json() self.assertEqual(data["error"], "Test error") @@ -92,7 +277,7 @@ def test_speak_endpoint_error(self): def test_speaking_endpoint(self): """Test the /speaking endpoint.""" - response = self.app.get("/speaking/") + response = self.app.get("/api/speaking") self.assertEqual(response.status_code, 200) data = response.get_json() self.assertIn("speaking", data) @@ -101,7 +286,7 @@ def test_speaking_endpoint(self): def test_stop_endpoint(self): """Test the /stop endpoint.""" - response = self.app.get("/stop/") + response = self.app.get("/api/stop") self.assertEqual(response.status_code, 200) data = response.get_json() self.assertEqual(data["status"], "success") @@ -110,7 +295,7 @@ def test_stop_endpoint_error(self): """Test the /stop endpoint with error handling.""" with patch("speech.start.stop_speaking") as mock_stop: mock_stop.side_effect = Exception("Test error") - response = self.app.get("/stop/") + response = self.app.get("/api/stop") self.assertEqual(response.status_code, 200) data = response.get_json() self.assertEqual(data["error"], "Test error") @@ -122,10 +307,10 @@ def test_caching(self): provider_id = "tts" # Default provider voice_id = "en-US" # Default voice # First request should generate and cache - response1 = self.app.get(f"/speakdata/{text}/{provider_id}/{voice_id}") + response1 = self.app.get(f"/api/speakdata/{text}/{provider_id}/{voice_id}") self.assertEqual(response1.status_code, 200) # Second request should use cache - response2 = self.app.get(f"/speakdata/{text}/{provider_id}/{voice_id}") + response2 = self.app.get(f"/api/speakdata/{text}/{provider_id}/{voice_id}") self.assertEqual(response2.status_code, 200) # Verify both responses are identical self.assertEqual(response1.data, response2.data) @@ -134,20 +319,21 @@ def test_config(self): """Test TTS configuration.""" config = get_tts_config() self.assertIsInstance(config, dict) - self.assertIn("engine", config) + self.assertIn("engines", config) self.assertIn("credentials", config) self.assertIn("cache_enabled", config) self.assertIn("cache_dir", config) + self.assertIn("engine_configs", config) def test_core_functionality(self): """Test core speech functionality without HTTP.""" # Test getting voices - voices = get_voices() + voices = get_voices(self.speech_manager) self.assertIsInstance(voices, list) if voices: # If any voices are available voice_id = voices[0]["id"] # Test speech data generation - data = get_speak_data("test", voice_id, "tts") + data = get_speak_data("test", voice_id, "espeak", self.speech_manager) self.assertIsInstance(data, bytes) self.assertGreater(len(data), 0) @@ -155,7 +341,7 @@ def test_core_functionality_error(self): """Test core speech functionality error handling.""" with patch("speech.speech_manager.get_voices") as mock_get_voices: mock_get_voices.side_effect = Exception("Test error") - voices = get_voices() + voices = get_voices(self.speech_manager) self.assertEqual(voices, []) def test_different_providers(self): @@ -164,27 +350,50 @@ def test_different_providers(self): Note: Most providers require API keys to work. This test only verifies that the provider initialization works, not that it can actually generate speech. """ + # Test providers that don't require API keys - local_providers = ["sherpaonnx", "tts"] + local_providers = [ + "sherpaonnx" + ] # Only test sherpaonnx since it's the only one configured for provider in local_providers: - with ( - patch("speech.config.get_tts_config") as mock_config, - patch("speech.speech_manager.speech_manager") as mock_manager, - ): - mock_config.return_value = {"engine": provider} - mock_manager.tts_instance.get_voices.return_value = [ - { - "id": f"{provider}_test_voice", - "name": f"Test Voice ({provider})", - "language_codes": ["en-US"], - "gender": "N", - } - ] - voices = get_voices() + # Reset the speech manager before each test + self.speech_manager.providers = {} + self.speech_manager.current_provider = None + + with patch("speech.config.get_tts_config") as mock_config: + mock_config.return_value = { + "engines": [provider], + "credentials": {}, + "cache_enabled": True, + "cache_dir": "cache", + "engine_configs": {provider: {}}, + } + + # Force reinitialization of providers + self.speech_manager.init_providers() + + voices = get_voices(self.speech_manager) self.assertIsInstance(voices, list) - # Verify we got at least one voice with the expected ID - voice_ids = [v["id"] for v in voices] - self.assertIn(f"{provider}_test_voice", voice_ids) + self.assertGreater(len(voices), 0) + + # Get voices from the current provider only + provider_voices = [v for v in voices if v["providerId"] == provider] + self.assertGreater( + len(provider_voices), 0, f"No voices found for provider {provider}" + ) + + # Verify each voice has the required fields + for voice in provider_voices: + self.assertIn("id", voice) + self.assertIn("name", voice) + self.assertIn("language_codes", voice) + self.assertIn("gender", voice) + self.assertIn("providerId", voice) + self.assertIn("type", voice) + # Verify the provider ID is set correctly + self.assertEqual(voice["providerId"], provider) + # Verify the voice name includes the provider + self.assertIn(provider, voice["name"]) # Test providers that require API keys (mocked) api_providers = [ @@ -196,27 +405,46 @@ def test_different_providers(self): "witai", ] for provider in api_providers: + # Reset the speech manager before each test + self.speech_manager.providers = {} + self.speech_manager.current_provider = None + with ( patch("speech.config.get_tts_config") as mock_config, - patch("speech.speech_manager.speech_manager") as mock_manager, + patch("speech.speech_manager.get_voices") as mock_get_voices, ): mock_config.return_value = { - "engine": provider, + "engines": [provider], "credentials": {"api_key": "test_key", "region": "test_region"}, + "cache_enabled": True, + "cache_dir": "cache", + "engine_configs": { + provider: { + "credentials": { + "api_key": "test_key", + "region": "test_region", + } + } + }, } - mock_manager.tts_instance.get_voices.return_value = [ + mock_get_voices.return_value = [ { "id": f"{provider}_test_voice", "name": f"Test Voice ({provider})", "language_codes": ["en-US"], "gender": "N", + "providerId": provider, + "type": "external_data", } ] - voices = get_voices() + voices = get_voices(self.speech_manager) self.assertIsInstance(voices, list) - # Verify we got at least one voice with the expected ID - voice_ids = [v["id"] for v in voices] - self.assertIn(f"{provider}_test_voice", voice_ids) + self.assertEqual(len(voices), 1) + self.assertEqual(voices[0]["providerId"], provider) + self.assertEqual(voices[0]["type"], "external_data") + self.assertEqual( + voices[0]["name"], f"Test Voice ({provider}), {provider}" + ) if __name__ == "__main__": From e5d679183634d6b2e48f998d0835d74297e4a81e Mon Sep 17 00:00:00 2001 From: will wade Date: Thu, 20 Mar 2025 09:59:34 +0000 Subject: [PATCH 08/37] fix for build script --- .coverage | Bin 53248 -> 53248 bytes asterics-grid-speech-mac.spec | 44 ------------ asterics-grid-speech.spec | 78 +++++++++++++++++++++ build.py | 123 +++++----------------------------- hooks/hook-flask.py | 9 +++ speech/start.py | 49 +++++++++++--- 6 files changed, 143 insertions(+), 160 deletions(-) delete mode 100644 asterics-grid-speech-mac.spec create mode 100644 asterics-grid-speech.spec create mode 100644 hooks/hook-flask.py diff --git a/.coverage b/.coverage index 00179b2743ed047a0ae02405b05e0041dfcd1cf0..fc6379e17b645be531e7732d279fc22596938bbd 100644 GIT binary patch delta 142 zcmV;90CE3-paX!Q1F!~w3`+nH`471dq7QlxOS2IWIS(%_1q1;JTm|@ZLLC4ArjtMN z58wa6Uwns;#}^C$zn}m6|GV&i|AqDW9sGVF0DzNvk48DThHRGkhacwt2W9_x`pNvH wC!O?3Pde$duK!>E`b~fR(O(^b delta 139 zcmV;60CfL=paX!Q1F!~w3{C(K`471dq7QlxPO}jZJP#}?1q1;JRt2~lz;yCQ{^9#S z_>1rG@%VxP;P>-?|9=<$@4v7 Date: Fri, 21 Mar 2025 09:39:57 +0000 Subject: [PATCH 09/37] getting there - now have a config screen and test interface --- pyproject.toml | 8 +- speech/config_manager.py | 392 +++++++++++++++++++++++++++++++++++ speech/speech_manager.py | 146 +++++++------ speech/start.py | 160 ++++++++++++-- speech/templates/config.html | 293 ++++++++++++++++++++++++++ speech/templates/test.html | 386 ++++++++++++++++++++++++++++++++++ speech/test_endpoints.py | 36 ++-- uv.lock | 15 +- 8 files changed, 1316 insertions(+), 120 deletions(-) create mode 100644 speech/config_manager.py create mode 100644 speech/templates/config.html create mode 100644 speech/templates/test.html diff --git a/pyproject.toml b/pyproject.toml index a3aa3a3..1c94f67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,10 +9,10 @@ dependencies = [ "flask>=3.0.0", "flask-cors>=4.0.0", "flask-restx>=1.3.0", - "py3-tts-wrapper[avsynth]; sys_platform == 'darwin'", - "py3-tts-wrapper[sapi]; sys_platform == 'win32'", - "py3-tts-wrapper[espeak]; sys_platform == 'linux'", - "py3-tts-wrapper[sherpaonnx,espeak,elevenlabs,playht,microsoft,polly,watson,googletrans,witai,controlaudio]", + "py3-tts-wrapper[avsynth]==0.9.31; sys_platform == 'darwin'", + "py3-tts-wrapper[sapi]==0.9.31; sys_platform == 'win32'", + "py3-tts-wrapper[espeak]==0.9.31; sys_platform == 'linux'", + "py3-tts-wrapper[sherpaonnx,espeak,elevenlabs,playht,microsoft,polly,watson,googletrans,playht,witai,controlaudio]==0.9.28", "omegaconf>=2.3.0", "hydra-core>=1.3.0", ] diff --git a/speech/config_manager.py b/speech/config_manager.py new file mode 100644 index 0000000..d2fdb48 --- /dev/null +++ b/speech/config_manager.py @@ -0,0 +1,392 @@ +"""Configuration manager for the speech service.""" + +import configparser +import json +import logging +import os +from dataclasses import dataclass +from typing import Dict, List, Optional + +logger = logging.getLogger(__name__) + +# Default configuration file path +DEFAULT_CONFIG_PATH = "speech.ini" + + +@dataclass +class EngineInfo: + """Information about a TTS engine.""" + + name: str + display_name: str + description: str + is_offline: bool + required_fields: List[str] + help_text: Dict[str, str] + + +# Define available engines and their properties +AVAILABLE_ENGINES = { + "sherpaonnx": EngineInfo( + name="sherpaonnx", + display_name="Sherpa-ONNX", + description="Open-source offline TTS engine using ONNX models (works with default configuration)", + is_offline=True, + required_fields=[], + help_text={ + "model_path": "Optional: Path to custom ONNX model file", + "tokens_path": "Optional: Path to custom tokens file", + }, + ), + "microsoft": EngineInfo( + name="microsoft", + display_name="Microsoft Azure", + description="Microsoft Azure TTS with neural voices", + is_offline=False, + required_fields=["subscription_key", "subscription_region"], + help_text={ + "subscription_key": "Your Azure subscription key", + "subscription_region": "Azure region (e.g., eastus)", + }, + ), + "google": EngineInfo( + name="google", + display_name="Google Cloud TTS", + description="Google Cloud Text-to-Speech", + is_offline=False, + required_fields=["credentials_json"], + help_text={ + "credentials_json": "Your Google Cloud service account JSON credentials", + }, + ), + "googletrans": EngineInfo( + name="googletrans", + display_name="Google Translate TTS", + description="Free Google Translate Text-to-Speech (defaults to en-us if no voice specified)", + is_offline=False, + required_fields=[], + help_text={ + "voice_id": "Optional: Voice ID (e.g., en-us, en-uk, fr-fr). Defaults to en-us", + }, + ), + "elevenlabs": EngineInfo( + name="elevenlabs", + display_name="ElevenLabs", + description="High-quality AI voices with emotion control", + is_offline=False, + required_fields=["api_key"], + help_text={ + "api_key": "Your ElevenLabs API key", + }, + ), + "polly": EngineInfo( + name="polly", + display_name="Amazon Polly", + description="Amazon's Text-to-Speech service", + is_offline=False, + required_fields=["aws_region", "aws_key_id", "aws_secret_access_key"], + help_text={ + "aws_region": "AWS region (e.g., us-east-1)", + "aws_key_id": "Your AWS access key ID", + "aws_secret_access_key": "Your AWS secret access key", + }, + ), + "watson": EngineInfo( + name="watson", + display_name="IBM Watson", + description="IBM Watson Text-to-Speech", + is_offline=False, + required_fields=["api_key", "region", "instance_id", "disable_ssl"], + help_text={ + "api_key": "Your IBM Watson API key", + "region": "Service region (e.g., us-east)", + "instance_id": "Your Watson instance ID", + "disable_ssl": "Set to true if you have SSL certificate issues", + }, + ), + "witai": EngineInfo( + name="witai", + display_name="Wit.ai", + description="Facebook's Wit.ai TTS service", + is_offline=False, + required_fields=["token"], + help_text={ + "token": "Your Wit.ai access token", + }, + ), + "playht": EngineInfo( + name="playht", + display_name="Play.HT", + description="Play.HT Text-to-Speech with high-quality voices", + is_offline=False, + required_fields=["api_key", "user_id"], + help_text={ + "api_key": "Your Play.HT API key", + "user_id": "Your Play.HT user ID", + }, + ), + "espeak": EngineInfo( + name="espeak", + display_name="eSpeak", + description="Open-source speech synthesizer", + is_offline=True, + required_fields=[], + help_text={}, + ), +} + + +class ConfigManager: + """Manages the speech service configuration.""" + + def __init__(self, config_path: str = DEFAULT_CONFIG_PATH): + """Initialize the configuration manager. + + Args: + config_path: Path to the configuration file + """ + self.config_path = config_path + self.config = configparser.ConfigParser() + self.load_config() + + def load_config(self) -> None: + """Load the configuration from file.""" + if os.path.exists(self.config_path): + self.config.read(self.config_path) + else: + self._create_default_config() + + def _create_default_config(self) -> None: + """Create a default configuration.""" + self.config["General"] = { + "engines": "sherpaonnx", + "cache_enabled": "true", + "cache_dir": "temp", + } + + self.config["sherpaonnx"] = {"model_path": "", "tokens_path": ""} + + self.save_config() + + def save_config(self) -> None: + """Save the configuration to file.""" + with open(self.config_path, "w") as f: + self.config.write(f) + + def get_enabled_engines(self) -> List[str]: + """Get the list of enabled engines.""" + engines_str = self.config.get("General", "engines", fallback="sherpaonnx") + return [e.strip() for e in engines_str.split(",") if e.strip()] + + def set_enabled_engines(self, engines: List[str]) -> None: + """Set the list of enabled engines.""" + self.config["General"]["engines"] = ",".join(engines) + self.save_config() + + def get_engine_config(self, engine: str) -> Dict[str, str]: + """Get the configuration for a specific engine.""" + if not self.config.has_section(engine): + return {} + + config = dict(self.config[engine]) + # Unescape % characters in Google credentials JSON + if engine == "google" and "credentials_json" in config: + config["credentials_json"] = config["credentials_json"].replace("%%", "%") + + return config + + def set_engine_config(self, engine: str, config: Dict[str, str]) -> None: + """Set the configuration for a specific engine.""" + if not self.config.has_section(engine): + self.config.add_section(engine) + + for key, value in config.items(): + # Special handling for Google credentials JSON to avoid interpolation issues + if engine == "google" and key == "credentials_json": + # Escape % characters in the JSON string + if value: + value = value.replace("%", "%%") + + self.config[engine][key] = value + self.save_config() + + def validate_engine_config(self, engine: str) -> List[str]: + """Validate the configuration for a specific engine. + + Returns: + List of error messages, empty if configuration is valid + """ + errors = [] + if engine not in AVAILABLE_ENGINES: + return [f"Unknown engine: {engine}"] + + engine_info = AVAILABLE_ENGINES[engine] + engine_config = self.get_engine_config(engine) + + for field in engine_info.required_fields: + if not engine_config.get(field): + errors.append(f"Missing required field for {engine}: {field}") + + return errors + + def get_tts_config(self) -> Dict: + """Get the complete TTS configuration.""" + enabled_engines = self.get_enabled_engines() + + config = { + "engines": enabled_engines, + "cache_enabled": self.config.getboolean( + "General", "cache_enabled", fallback=True + ), + "cache_dir": self.config.get("General", "cache_dir", fallback="temp"), + "engine_configs": {}, + } + + for engine in enabled_engines: + if engine in AVAILABLE_ENGINES: + engine_config = self.get_engine_config(engine) + + # Format credentials based on engine type + if engine == "microsoft": + config["engine_configs"][engine] = { + "credentials": ( + engine_config.get("subscription_key", ""), + engine_config.get("subscription_region", ""), + ) + } + elif engine == "google": + # Handle Google credentials as JSON + creds_json = engine_config.get("credentials_json", "{}") + try: + # Try to parse as JSON first + credentials = json.loads(creds_json) + except json.JSONDecodeError: + # If not JSON, treat as file path + if os.path.exists(creds_json): + with open(creds_json, "r") as f: + credentials = json.load(f) + else: + credentials = {} + config["engine_configs"][engine] = {"credentials": credentials} + elif engine == "googletrans": + config["engine_configs"][engine] = { + "voice_id": engine_config.get("voice_id", "en-us") + } + elif engine == "polly": + config["engine_configs"][engine] = { + "credentials": ( + engine_config.get("aws_region", ""), + engine_config.get("aws_key_id", ""), + engine_config.get("aws_secret_access_key", ""), + ) + } + elif engine == "watson": + config["engine_configs"][engine] = { + "credentials": ( + engine_config.get("api_key", ""), + engine_config.get("region", ""), + engine_config.get("instance_id", ""), + ), + "disableSSLVerification": engine_config.get( + "disable_ssl", "" + ).lower() + == "true", + } + elif engine == "elevenlabs": + config["engine_configs"][engine] = { + "credentials": (engine_config.get("api_key", ""),) + } + elif engine == "witai": + config["engine_configs"][engine] = { + "credentials": (engine_config.get("token", ""),) + } + elif engine == "playht": + config["engine_configs"][engine] = { + "credentials": ( + engine_config.get("api_key", ""), + engine_config.get("user_id", ""), + ) + } + else: + config["engine_configs"][engine] = engine_config + + return config + + def get_available_engines(self) -> List[EngineInfo]: + """Get information about all available engines.""" + return list(AVAILABLE_ENGINES.values()) + + def get_engine_info(self, engine: str) -> Optional[EngineInfo]: + """Get information about a specific engine.""" + return AVAILABLE_ENGINES.get(engine) + + def add_engine(self, engine_name: str, engine_config: dict) -> None: + """Add a new TTS engine to the configuration. + + Args: + engine_name: The unique identifier for the engine + engine_config: Dictionary containing engine configuration: + - display_name: Name to display in UI + - description: Engine description + - is_offline: Whether the engine works offline + - required_fields: List of required configuration fields + """ + if engine_name in self.config.sections(): + raise ValueError(f"Engine '{engine_name}' already exists") + + # Add the engine section + self.config[engine_name] = { + "display_name": engine_config["display_name"], + "description": engine_config["description"], + "is_offline": str(engine_config["is_offline"]), + "required_fields": ",".join(engine_config["required_fields"]), + "enabled": "false", # New engines are disabled by default + } + + # Save the updated configuration + self.save_config() + + def remove_engine(self, engine_name: str) -> None: + """Remove a TTS engine from the configuration. + + Args: + engine_name: The unique identifier for the engine to remove + """ + if engine_name not in self.config.sections(): + raise ValueError(f"Engine '{engine_name}' does not exist") + + if engine_name == "General": + raise ValueError("Cannot remove the General section") + + # Remove the engine section + self.config.remove_section(engine_name) + + # Save the updated configuration + self.save_config() + + def _get_help_text(self, engine_name: str) -> dict: + """Get help text for engine configuration fields. + + Args: + engine_name: The engine identifier + + Returns: + Dictionary mapping field names to help text + """ + help_texts = { + "api_key": "API key for authentication", + "region": "Service region (e.g., westus, eastus)", + "voice": "Default voice ID to use", + "model": "Model name or path", + "language": "Default language code", + "rate": "Speech rate (words per minute)", + "pitch": "Voice pitch (0.0 to 2.0)", + "volume": "Voice volume (0.0 to 1.0)", + } + + engine_fields = self.config[engine_name].get("required_fields", "").split(",") + return { + field.strip(): help_texts.get(field.strip(), "") + for field in engine_fields + if field.strip() + } diff --git a/speech/speech_manager.py b/speech/speech_manager.py index 7621a55..ebcf539 100644 --- a/speech/speech_manager.py +++ b/speech/speech_manager.py @@ -6,8 +6,12 @@ ElevenLabsTTS, GoogleClient, GoogleTTS, + GoogleTransClient, + GoogleTransTTS, MicrosoftClient, MicrosoftTTS, + PlayHTClient, + PlayHTTTS, PollyClient, PollyTTS, SherpaOnnxClient, @@ -40,12 +44,29 @@ def get_voices(self) -> list[dict[str, Any]]: raise NotImplementedError def get_speak_data(self, text: str, voice_id: str) -> bytes: - """Get speech data for text.""" - raise NotImplementedError + """Get speech data for text using synth_to_bytes.""" + if not hasattr(self.tts, "synth_to_bytes"): + raise NotImplementedError("Provider does not support audio streaming") + return self.tts.synth_to_bytes(text, voice_id=voice_id) def speak(self, text: str, voice_id: str) -> None: """Speak text using the specified voice.""" - raise NotImplementedError + if not hasattr(self.tts, "speak"): + raise NotImplementedError("Provider does not support direct speech") + self.tts.speak(text, voice_id=voice_id) + + +class MicrosoftTTSProvider(TTSProvider): + """Microsoft TTS provider wrapper.""" + + def __init__(self, client: MicrosoftClient): + """Initialize the Microsoft TTS provider.""" + super().__init__() + self.tts = MicrosoftTTS(client) + + def get_voices(self) -> list[dict[str, Any]]: + """Get available voices.""" + return self.tts.get_voices() class SpeechManager: @@ -58,9 +79,13 @@ def __init__(self): self.current_provider = None self.is_speaking = False - def init_providers(self): + def init_providers(self, config=None): """Initialize TTS providers based on configuration.""" - config = get_tts_config() + if config is None: + from .config import get_tts_config as get_config + + config = get_config() + engines = config.get("engines", ["espeak"]) self.logger.info(f"SpeechManager: Got engines from config: {engines}") @@ -71,84 +96,38 @@ def init_providers(self): f"SpeechManager: Attempting to initialize {engine_name} provider..." ) + engine_config = config.get("engine_configs", {}).get(engine_name, {}) + if engine_name == "espeak": client = eSpeakClient() self.providers["espeak"] = eSpeakTTS(client) - self.logger.info( - "SpeechManager: eSpeak provider initialized successfully" - ) - elif engine_name == "sherpaonnx" and "sherpaonnx" in config.get( - "engine_configs", {} - ): - client = SherpaOnnxClient() + elif engine_name == "sherpaonnx": + client = SherpaOnnxClient(**engine_config) self.providers["sherpaonnx"] = SherpaOnnxTTS(client) - self.logger.info( - "SpeechManager: SherpaOnnx provider initialized successfully" - ) - elif engine_name == "google" and "google" in config.get( - "engine_configs", {} - ): - client = GoogleClient(credentials=config.get("credentials", {})) + elif engine_name == "google": + client = GoogleClient(**engine_config) self.providers["google"] = GoogleTTS(client) - self.logger.info( - "SpeechManager: Google provider initialized successfully" - ) - elif engine_name == "microsoft" and "microsoft" in config.get( - "engine_configs", {} - ): - client = MicrosoftClient(credentials=config.get("credentials", {})) + elif engine_name == "googletrans": + client = GoogleTransClient(engine_config.get("voice_id", "en-us")) + self.providers["googletrans"] = GoogleTransTTS(client) + elif engine_name == "microsoft": + client = MicrosoftClient(**engine_config) self.providers["microsoft"] = MicrosoftTTS(client) - self.logger.info( - "SpeechManager: Microsoft provider initialized successfully" - ) - elif engine_name == "polly" and "polly" in config.get( - "engine_configs", {} - ): - client = PollyClient(credentials=config.get("credentials", {})) + elif engine_name == "polly": + client = PollyClient(**engine_config) self.providers["polly"] = PollyTTS(client) - self.logger.info( - "SpeechManager: Polly provider initialized successfully" - ) - elif engine_name == "watson" and "watson" in config.get( - "engine_configs", {} - ): - client = WatsonClient(credentials=config.get("credentials", {})) + elif engine_name == "watson": + client = WatsonClient(**engine_config) self.providers["watson"] = WatsonTTS(client) - self.logger.info( - "SpeechManager: Watson provider initialized successfully" - ) - elif engine_name == "elevenlabs" and "elevenlabs" in config.get( - "engine_configs", {} - ): - client = ElevenLabsClient(credentials=config.get("credentials", {})) + elif engine_name == "elevenlabs": + client = ElevenLabsClient(**engine_config) self.providers["elevenlabs"] = ElevenLabsTTS(client) - self.logger.info( - "SpeechManager: ElevenLabs provider initialized successfully" - ) - elif engine_name == "witai" and "witai" in config.get( - "engine_configs", {} - ): - client = WitAiClient(credentials=config.get("credentials", {})) + elif engine_name == "witai": + client = WitAiClient(**engine_config) self.providers["witai"] = WitAiTTS(client) - self.logger.info( - "SpeechManager: Wit.AI provider initialized successfully" - ) - # elif engine_name == "avsynth" and "avsynth" in config.get( - # "engine_configs", {} - # ): - # client = AVSynthClient() - # self.providers["avsynth"] = AVSynthTTS(client) - # self.logger.info( - # "SpeechManager: AVSynth provider initialized successfully" - # ) - # elif engine_name == "sapi" and "sapi" in config.get( - # "engine_configs", {} - # ): - # client = SAPIClient() - # self.providers["sapi"] = SAPITTS(client) - # self.logger.info( - # "SpeechManager: SAPI provider initialized successfully" - # ) + elif engine_name == "playht": + client = PlayHTClient(**engine_config) + self.providers["playht"] = PlayHTTTS(client) else: self.logger.warning( f"SpeechManager: Unsupported TTS engine: {engine_name}" @@ -202,7 +181,24 @@ def get_speak_data( ) if not provider: raise ValueError(f"Provider {provider_id} not found") - return provider.get_speak_data(text, voice_id) + import tempfile + import os + + # Create a temporary file + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as temp_file: + # Generate the audio file + provider.synth_to_file( + text, temp_file.name, output_format="wav", voice_id=voice_id + ) + + # Read the file contents + with open(temp_file.name, "rb") as f: + data = f.read() + + # Clean up the temp file + os.unlink(temp_file.name) + + return data def speak(self, text: str, voice_id: str, provider_id: str | None = None) -> None: """Speak text using the specified provider.""" @@ -213,7 +209,7 @@ def speak(self, text: str, voice_id: str, provider_id: str | None = None) -> Non raise ValueError(f"Provider {provider_id} not found") self.is_speaking = True try: - provider.speak(text, voice_id) + provider.speak(text=text, voice_id=voice_id) finally: self.is_speaking = False diff --git a/speech/start.py b/speech/start.py index 9f60ad4..d55db59 100644 --- a/speech/start.py +++ b/speech/start.py @@ -5,11 +5,7 @@ import os import sys from urllib.parse import unquote -import tempfile -import json - -# Flask imports -from flask import Flask, jsonify, request, send_file +from flask import Flask, jsonify, request, send_file, render_template, redirect, url_for from flask_cors import CORS from flask_restx import Api, Resource, fields @@ -24,7 +20,7 @@ sys.path.append(os.path.dirname(bundle_dir)) try: - from speech.config import CACHE_ENABLED + from speech.config_manager import ConfigManager from speech.speech_manager import ( SpeechManager, get_speak_data, @@ -35,7 +31,7 @@ ) except ImportError: # Fallback for when running as module - from config import CACHE_ENABLED + from config_manager import ConfigManager from speech_manager import ( SpeechManager, get_speak_data, @@ -56,6 +52,9 @@ app.url_map.strict_slashes = False CORS(app) +# Create configuration manager instance +config_manager = ConfigManager() + # Create speech manager instance speech_manager = SpeechManager() @@ -66,7 +65,7 @@ title="AsTeRICS Grid Speech API", description="API for text-to-speech functionality in AsTeRICS Grid", doc="/docs", - prefix="/api", # Add a prefix to avoid conflicts with Flask routes + prefix="/api", ) # Define namespaces @@ -85,6 +84,97 @@ ) +@app.route("/") +def index(): + """Main configuration page for the speech service.""" + return config() + + +@app.route("/config", methods=["GET", "POST"]) +def config(): + """Configuration page for the speech service.""" + validation_errors = {} + success_message = None + error_message = None + + if request.method == "POST": + try: + # Update general settings + config_manager.config["General"]["cache_enabled"] = str( + bool(request.form.get("cache_enabled")) + ) + config_manager.config["General"]["cache_dir"] = request.form.get( + "cache_dir", "temp" + ) + + # Get enabled engines + enabled_engines = request.form.getlist("enabled_engines") + config_manager.set_enabled_engines(enabled_engines) + + # Update engine configurations + for engine in config_manager.get_available_engines(): + if engine.name in enabled_engines: + engine_config = {} + + # Special handling for Google Cloud credentials + if engine.name == "google": + # Check for file upload first + if "google_credentials_file" in request.files: + file = request.files["google_credentials_file"] + if file and file.filename.endswith(".json"): + try: + credentials_json = file.read().decode("utf-8") + engine_config["credentials_json"] = credentials_json + except Exception as e: + validation_errors["google"] = [ + f"Error reading JSON file: {str(e)}" + ] + # If no file uploaded, check for pasted JSON + if not engine_config and request.form.get( + "google_credentials_json" + ): + engine_config["credentials_json"] = request.form.get( + "google_credentials_json" + ) + else: + # Standard field handling for other engines + for field in engine.required_fields: + field_name = f"{engine.name}_{field}" + engine_config[field] = request.form.get(field_name, "") + + config_manager.set_engine_config(engine.name, engine_config) + + # Validate configurations + all_valid = True + for engine in enabled_engines: + errors = config_manager.validate_engine_config(engine) + if errors: + validation_errors[engine] = errors + all_valid = False + + if all_valid: + config_manager.save_config() + # Reinitialize speech manager with new configuration + speech_manager.init_providers(config_manager.get_tts_config()) + success_message = "Configuration saved successfully" + else: + error_message = "Please fix the configuration errors" + + except Exception as e: + logger.error(f"Error saving configuration: {e}", exc_info=True) + error_message = f"Error saving configuration: {str(e)}" + + return render_template( + "config.html", + config=config_manager.config, + available_engines=config_manager.get_available_engines(), + enabled_engines=config_manager.get_enabled_engines(), + validation_errors=validation_errors, + success_message=success_message, + error_message=error_message, + ) + + @ns.route("/") class Root(Resource): @ns.doc("get_root") @@ -188,9 +278,38 @@ def get(self): return {"error": str(e), "status": "error", "voices": []}, 200 +def create_wav_header(pcm_data: bytes) -> bytes: + """Create a WAV header for the PCM data.""" + # WAV header parameters + sample_rate = 16000 # Standard sample rate for speech + bits_per_sample = 16 # 16-bit audio + channels = 1 # Mono audio + data_size = len(pcm_data) + + # WAV header (44 bytes) + header = bytearray() + header.extend(b"RIFF") # ChunkID + header.extend((36 + data_size).to_bytes(4, "little")) # ChunkSize + header.extend(b"WAVE") # Format + header.extend(b"fmt ") # Subchunk1ID + header.extend((16).to_bytes(4, "little")) # Subchunk1Size + header.extend((1).to_bytes(2, "little")) # AudioFormat (1 = PCM) + header.extend(channels.to_bytes(2, "little")) # NumChannels + header.extend(sample_rate.to_bytes(4, "little")) # SampleRate + header.extend( + (sample_rate * channels * bits_per_sample // 8).to_bytes(4, "little") + ) # ByteRate + header.extend((channels * bits_per_sample // 8).to_bytes(2, "little")) # BlockAlign + header.extend(bits_per_sample.to_bytes(2, "little")) # BitsPerSample + header.extend(b"data") # Subchunk2ID + header.extend(data_size.to_bytes(4, "little")) # Subchunk2Size + + return bytes(header) + + @ns.route("/speakdata/") @ns.route("/speakdata//") -@ns.route("/speakdata///") +@ns.route("/speakdata///") class SpeakData(Resource): @ns.doc("get_speak_data") @ns.param("text", "Text to convert to speech") @@ -210,10 +329,13 @@ def get(self, text: str, provider_id: str = "", voice_id: str = ""): "error": "Failed to generate speech data", "status": "error", }, 200 + + # Add WAV header to the PCM data + wav_data = create_wav_header(data) + data return send_file( - io.BytesIO(data), + io.BytesIO(wav_data), mimetype="audio/wav", - as_attachment=True, + as_attachment=False, download_name="speech.wav", ) except Exception as e: @@ -227,7 +349,7 @@ def post(self, text: str, provider_id: str = "", voice_id: str = ""): @ns.route("/speak/") @ns.route("/speak//") -@ns.route("/speak///") +@ns.route("/speak///") class Speak(Resource): @ns.doc("speak_text") @ns.param("text", "Text to speak") @@ -241,7 +363,7 @@ def get(self, text: str, provider_id: str = "", voice_id: str = ""): text = unquote(text).lower() provider_id = unquote(provider_id) voice_id = unquote(voice_id) - speak(text, provider_id, voice_id, speech_manager) + speak(text, voice_id, provider_id, speech_manager) return {"status": "success"} except Exception as e: logger.error(f"Error in /speak endpoint: {e!s}", exc_info=True) @@ -255,7 +377,7 @@ def post(self, text: str, provider_id: str = "", voice_id: str = ""): @app.route("/cache///", methods=["POST", "GET"]) def cache_data(text: str, provider_id: str = "", voice_id: str = ""): """Cache speech data for the given text.""" - if not CACHE_ENABLED: + if not config_manager.config["General"]["cache_enabled"]: return jsonify(False) text = unquote(text).lower() provider_id = unquote(provider_id) @@ -298,11 +420,17 @@ def post(self): return self.get() +@app.route("/test") +def test(): + """Test page for trying out TTS endpoints.""" + return render_template("test.html") + + def start_server(): """Start the Flask server.""" try: - # Initialize speech providers - speech_manager.init_providers() + # Initialize speech providers with configuration + speech_manager.init_providers(config_manager.get_tts_config()) # Start Flask server app.run( diff --git a/speech/templates/config.html b/speech/templates/config.html new file mode 100644 index 0000000..641262e --- /dev/null +++ b/speech/templates/config.html @@ -0,0 +1,293 @@ + + + + + + AsTeRICS Grid Speech Configuration + + + + + + + + +
+ {% if success_message %} + + {% endif %} + + {% if error_message %} + + {% endif %} + +
+ + +
+
+
+
+
General Settings
+
+
+
+ + +
Cache generated speech to improve performance for repeated phrases.
+
+
+ + +
Directory where cached speech files will be stored.
+
+
+
+
+ +
+
+
About TTS Engines
+

This application supports multiple Text-to-Speech engines, both online and offline. Enable the engines you want to use and configure their required settings below.

+
+ + + + + {% for engine in available_engines %} +
+
+
+
+ + +
+
+
+
+

{{ engine.description }}

+ {% if engine.name in validation_errors %} +
+ {% for error in validation_errors[engine.name] %} +

{{ error }}

+ {% endfor %} +
+ {% endif %} + + {% if engine.name == "google" %} + +
+ +
+
+
+ + +
Upload your Google Cloud service account JSON credentials file.
+
+
+
+
+ + +
Paste your Google Cloud service account JSON credentials.
+
+
+
+
+ {% else %} + + {% if engine.required_fields or engine.help_text %} +
+ Configuration: +
    + {% for field in engine.help_text %} +
  • • {{ field }}: {{ engine.help_text[field] }}
  • + {% endfor %} +
+
+ {% for field in engine.help_text %} +
+ + +
{{ engine.help_text[field] }}
+
+ {% endfor %} + {% else %} +
+ No configuration needed +
+ {% endif %} + {% endif %} +
+
+ {% endfor %} +
+
+ +
+ +
+
+
+ + + + + \ No newline at end of file diff --git a/speech/templates/test.html b/speech/templates/test.html new file mode 100644 index 0000000..6e38ff9 --- /dev/null +++ b/speech/templates/test.html @@ -0,0 +1,386 @@ + + + + + + AsTeRICS Grid Speech - Test TTS + + + + + + + +
+
+ +
+
+
+
Available Voices
+
+ +
+
+
+
+
+ Loading voices... +
+
+
+
+
+ + +
+
+
+
Test Text-to-Speech
+
+
+
+
+ +
+ + + + + +
+
+ /speak: Directly plays audio through system speakers +
+
+ +
+ + +
+ +
+ +
+ +
+ + +
+ +
+ Speaking... +
+ + +
+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/speech/test_endpoints.py b/speech/test_endpoints.py index c2ffb96..70cb652 100644 --- a/speech/test_endpoints.py +++ b/speech/test_endpoints.py @@ -104,7 +104,7 @@ def mock_speech_manager(mocker): def test_root_endpoint(test_client): """Test the root endpoint.""" - response = test_client.get("/api/") + response = test_client.get("/") assert response.status_code == 200 data = response.get_json() assert data["name"] == "AsTeRICS Grid Speech API" @@ -119,7 +119,7 @@ def test_voices_endpoint(test_client, mock_speech_manager, mocker): "speech.start.get_voices", return_value=mock_speech_manager.get_voices() ) - response = test_client.get("/api/voices") + response = test_client.get("/voices") assert response.status_code == 200 data = response.get_json() assert data["status"] == "success" @@ -132,7 +132,7 @@ def test_speak_endpoint(test_client, mock_speech_manager, mocker): # Mock the speak function to use our mock manager mocker.patch("speech.start.speak") - response = test_client.get("/api/speak/test_text/sherpaonnx/en") + response = test_client.get("/speak/test_text/sherpaonnx/en") assert response.status_code == 200 data = response.get_json() assert data["status"] == "success" @@ -143,7 +143,7 @@ def test_speakdata_endpoint(test_client, mock_speech_manager, mocker): # Mock the get_speak_data function to return test audio data mocker.patch("speech.start.get_speak_data", return_value=b"test_audio_data") - response = test_client.get("/api/speakdata/test_text/sherpaonnx/en") + response = test_client.get("/speakdata/test_text/sherpaonnx/en") assert response.status_code == 200 assert response.mimetype == "audio/wav" @@ -153,7 +153,7 @@ def test_speaking_endpoint(test_client, mock_speech_manager, mocker): # Mock the is_speaking function to use our mock manager mocker.patch("speech.start.is_speaking", return_value=False) - response = test_client.get("/api/speaking") + response = test_client.get("/speaking") assert response.status_code == 200 data = response.get_json() assert data["status"] == "success" @@ -165,7 +165,7 @@ def test_stop_endpoint(test_client, mock_speech_manager, mocker): # Mock the stop_speaking function to use our mock manager mocker.patch("speech.start.stop_speaking") - response = test_client.get("/api/stop") + response = test_client.get("/stop") assert response.status_code == 200 data = response.get_json() assert data["status"] == "success" @@ -186,7 +186,7 @@ def test_error_handling(test_client, mock_speech_manager, mocker): # Mock the get_voices function to raise an exception mocker.patch("speech.start.get_voices", side_effect=Exception("Test error")) - response = test_client.get("/api/voices") + response = test_client.get("/voices") assert response.status_code == 200 data = response.get_json() assert data["status"] == "error" @@ -204,7 +204,7 @@ def setUp(self): def test_voices_endpoint(self): """Test the /voices endpoint.""" - response = self.app.get("/api/voices") + response = self.app.get("/voices") self.assertEqual(response.status_code, 200) data = response.get_json() self.assertIn("voices", data) @@ -223,7 +223,7 @@ def test_voices_endpoint_error(self): """Test the /voices endpoint with error handling.""" with patch("speech.start.get_voices") as mock_get_voices: mock_get_voices.side_effect = Exception("Test error") - response = self.app.get("/api/voices") + response = self.app.get("/voices") self.assertEqual(response.status_code, 200) data = response.get_json() self.assertEqual(data["error"], "Test error") @@ -235,7 +235,7 @@ def test_speakdata_endpoint(self): text = "This is a test sentence to verify speech synthesis." provider_id = "sherpaonnx" # Use sherpaonnx provider voice_id = "en" # Use English voice - response = self.app.get(f"/api/speakdata/{text}/{provider_id}/{voice_id}") + response = self.app.get(f"/speakdata/{text}/{provider_id}/{voice_id}") self.assertEqual(response.status_code, 200) self.assertEqual(response.mimetype, "audio/wav") # Verify we got some audio data @@ -247,7 +247,7 @@ def test_speakdata_endpoint_error(self): """Test the /speakdata endpoint with error handling.""" with patch("speech.start.get_speak_data") as mock_get_speak_data: mock_get_speak_data.return_value = None - response = self.app.get("/api/speakdata/test/tts/en-US") + response = self.app.get("/speakdata/test/tts/en-US") self.assertEqual(response.status_code, 200) data = response.get_json() self.assertEqual(data["error"], "Failed to generate speech data") @@ -258,7 +258,7 @@ def test_speak_endpoint(self): text = "This is a test sentence to verify speech synthesis." provider_id = "sherpaonnx" # Use sherpaonnx provider voice_id = "en" # Use English voice - response = self.app.get(f"/api/speak/{text}/{provider_id}/{voice_id}") + response = self.app.get(f"/speak/{text}/{provider_id}/{voice_id}") self.assertEqual(response.status_code, 200) data = response.get_json() self.assertEqual(data["status"], "success") @@ -269,7 +269,7 @@ def test_speak_endpoint_error(self): """Test the /speak endpoint with error handling.""" with patch("speech.start.speak") as mock_speak: mock_speak.side_effect = Exception("Test error") - response = self.app.get("/api/speak/test/tts/en-US") + response = self.app.get("/speak/test/tts/en-US") self.assertEqual(response.status_code, 200) data = response.get_json() self.assertEqual(data["error"], "Test error") @@ -277,7 +277,7 @@ def test_speak_endpoint_error(self): def test_speaking_endpoint(self): """Test the /speaking endpoint.""" - response = self.app.get("/api/speaking") + response = self.app.get("/speaking") self.assertEqual(response.status_code, 200) data = response.get_json() self.assertIn("speaking", data) @@ -286,7 +286,7 @@ def test_speaking_endpoint(self): def test_stop_endpoint(self): """Test the /stop endpoint.""" - response = self.app.get("/api/stop") + response = self.app.get("/stop") self.assertEqual(response.status_code, 200) data = response.get_json() self.assertEqual(data["status"], "success") @@ -295,7 +295,7 @@ def test_stop_endpoint_error(self): """Test the /stop endpoint with error handling.""" with patch("speech.start.stop_speaking") as mock_stop: mock_stop.side_effect = Exception("Test error") - response = self.app.get("/api/stop") + response = self.app.get("/stop") self.assertEqual(response.status_code, 200) data = response.get_json() self.assertEqual(data["error"], "Test error") @@ -307,10 +307,10 @@ def test_caching(self): provider_id = "tts" # Default provider voice_id = "en-US" # Default voice # First request should generate and cache - response1 = self.app.get(f"/api/speakdata/{text}/{provider_id}/{voice_id}") + response1 = self.app.get(f"/speakdata/{text}/{provider_id}/{voice_id}") self.assertEqual(response1.status_code, 200) # Second request should use cache - response2 = self.app.get(f"/api/speakdata/{text}/{provider_id}/{voice_id}") + response2 = self.app.get(f"/speakdata/{text}/{provider_id}/{voice_id}") self.assertEqual(response2.status_code, 200) # Verify both responses are identical self.assertEqual(response1.data, response2.data) diff --git a/uv.lock b/uv.lock index c198898..e0023ca 100644 --- a/uv.lock +++ b/uv.lock @@ -66,10 +66,10 @@ requires-dist = [ { name = "hydra-core", marker = "extra == 'test'" }, { name = "omegaconf", specifier = ">=2.3.0" }, { name = "omegaconf", marker = "extra == 'test'" }, - { name = "py3-tts-wrapper", extras = ["avsynth"], marker = "sys_platform == 'darwin'" }, - { name = "py3-tts-wrapper", extras = ["espeak"], marker = "sys_platform == 'linux'" }, - { name = "py3-tts-wrapper", extras = ["sapi"], marker = "sys_platform == 'win32'" }, - { name = "py3-tts-wrapper", extras = ["sherpaonnx", "espeak", "elevenlabs", "playht", "microsoft", "polly", "watson", "googletrans", "witai", "controlaudio"] }, + { name = "py3-tts-wrapper", extras = ["avsynth"], marker = "sys_platform == 'darwin'", specifier = "==0.9.28" }, + { name = "py3-tts-wrapper", extras = ["espeak"], marker = "sys_platform == 'linux'", specifier = "==0.9.28" }, + { name = "py3-tts-wrapper", extras = ["sapi"], marker = "sys_platform == 'win32'", specifier = "==0.9.28" }, + { name = "py3-tts-wrapper", extras = ["sherpaonnx", "espeak", "elevenlabs", "playht", "microsoft", "polly", "watson", "googletrans", "playht", "witai", "controlaudio"], specifier = "==0.9.28" }, { name = "pyinstaller", marker = "extra == 'test'" }, { name = "pytest", marker = "extra == 'test'" }, { name = "pytest-cov", marker = "extra == 'test'" }, @@ -97,6 +97,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/02/c73a10f5d8ecf83e54f02e6e24813ce7661ad50f532944da5c0ecaeaea54/azure_cognitiveservices_speech-1.43.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:07bdedba8494edfb24306279d3b0500ece016fc811ec0b3366707a75d118a245", size = 40710733 }, { url = "https://files.pythonhosted.org/packages/83/c5/b593f08f70b73b8a997b87673235f83ec42d9c9bf0fae7f348e889dfc00c/azure_cognitiveservices_speech-1.43.0-py3-none-win32.whl", hash = "sha256:36570806a6b8fe12696a0372193ecc623bc629e355fa1edc67c03ac71731066b", size = 2152884 }, { url = "https://files.pythonhosted.org/packages/f5/b8/b1e7894cb4bcd721356eb1687e6f17112c2c659f4365827b8e7daac07c7d/azure_cognitiveservices_speech-1.43.0-py3-none-win_amd64.whl", hash = "sha256:50a50aabc69434d1311c09eaa640622c1d47d270e6cbcf5d192a04325cb7de4c", size = 2410492 }, + { url = "https://files.pythonhosted.org/packages/5e/79/8d16e2cdeb01459726818558f1b484106b89e8b54ad85a847e471a5c2659/azure_cognitiveservices_speech-1.43.0-py3-none-win_arm64.whl", hash = "sha256:29dab439a3789196c38b169a74fb4eefa4ede59e79f062541c08cc39a2d786a5", size = 2205218 }, ] [[package]] @@ -657,7 +658,7 @@ wheels = [ [[package]] name = "py3-tts-wrapper" -version = "0.9.27" +version = "0.9.28" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, @@ -666,9 +667,9 @@ dependencies = [ { name = "sounddevice" }, { name = "soundfile" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/1f/7461a9d26434ee3a05786ee261f17c0cc96b434f1599e614e7219af3522a/py3_tts_wrapper-0.9.27.tar.gz", hash = "sha256:aa4fddc8e4311ac7627cfbeda380cc7aab94ff474919c2aeeb2d90d8c66ec8d1", size = 6922903 } +sdist = { url = "https://files.pythonhosted.org/packages/3e/81/51c7e35ce093d2668de59b876976c6669284ee406d78a9bd77870d501368/py3_tts_wrapper-0.9.28.tar.gz", hash = "sha256:d0e4b70b9f8be4d49636545a88d352dac68c930436fc0a7387895aac14e02540", size = 6925926 } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/b2/6f397fdcc59794dc9e1d5518f2427ee167e6c1fa1f263943c7c948402736/py3_tts_wrapper-0.9.27-py3-none-any.whl", hash = "sha256:6004bdc809c18a046495ff25a54ebcce57d98f26a877172b7fbaa5b73638fdda", size = 847476 }, + { url = "https://files.pythonhosted.org/packages/68/ad/cdbd924b62e4d2f197a4361b9d9a3a11234c72c0e37d03ab45f2a0f70d38/py3_tts_wrapper-0.9.28-py3-none-any.whl", hash = "sha256:c6f6a13d5160a1f310d18289d648371c0032b17bb9a011e88cdd016cc55f2716", size = 849872 }, ] [package.optional-dependencies] From 7d906385f801bf7c1e1fb3a2c317bf7e84ada0fd Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 21 Mar 2025 09:54:34 +0000 Subject: [PATCH 10/37] rework around wrapper 0.9.31 --- build.py | 4 ++-- pyproject.toml | 2 +- speech/config_manager.py | 23 +++++++++++----------- speech/speech_manager.py | 16 ++++++---------- speech/start.py | 41 ++++++---------------------------------- uv.lock | 14 +++++++------- 6 files changed, 33 insertions(+), 67 deletions(-) diff --git a/build.py b/build.py index 72bee06..dd2f8a8 100644 --- a/build.py +++ b/build.py @@ -22,7 +22,7 @@ def build_executable(): """AsTeRICS Grid Speech Service =========================== -This is a standalone speech service for AsTeRICS Grid using Sherpa-ONNX for +This is a standalone speech service for AsTeRICS Grid using Sherpa-ONNX for offline text-to-speech. Usage: @@ -41,7 +41,7 @@ def build_executable(): - en-us-ryan-low: English (US) - Ryan (Low) - en-us-ryan-high: English (US) - Ryan (High) -Note: The first run may take a few seconds as it downloads and initializes +Note: The first run may take a few seconds as it downloads and initializes the Sherpa-ONNX model. Configuration: diff --git a/pyproject.toml b/pyproject.toml index 1c94f67..8e775ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ dependencies = [ "py3-tts-wrapper[avsynth]==0.9.31; sys_platform == 'darwin'", "py3-tts-wrapper[sapi]==0.9.31; sys_platform == 'win32'", "py3-tts-wrapper[espeak]==0.9.31; sys_platform == 'linux'", - "py3-tts-wrapper[sherpaonnx,espeak,elevenlabs,playht,microsoft,polly,watson,googletrans,playht,witai,controlaudio]==0.9.28", + "py3-tts-wrapper[sherpaonnx,espeak,elevenlabs,playht,microsoft,polly,watson,googletrans,playht,witai,controlaudio]==0.9.31", "omegaconf>=2.3.0", "hydra-core>=1.3.0", ] diff --git a/speech/config_manager.py b/speech/config_manager.py index d2fdb48..7961cba 100644 --- a/speech/config_manager.py +++ b/speech/config_manager.py @@ -5,7 +5,6 @@ import logging import os from dataclasses import dataclass -from typing import Dict, List, Optional logger = logging.getLogger(__name__) @@ -21,8 +20,8 @@ class EngineInfo: display_name: str description: str is_offline: bool - required_fields: List[str] - help_text: Dict[str, str] + required_fields: list[str] + help_text: dict[str, str] # Define available engines and their properties @@ -173,17 +172,17 @@ def save_config(self) -> None: with open(self.config_path, "w") as f: self.config.write(f) - def get_enabled_engines(self) -> List[str]: + def get_enabled_engines(self) -> list[str]: """Get the list of enabled engines.""" engines_str = self.config.get("General", "engines", fallback="sherpaonnx") return [e.strip() for e in engines_str.split(",") if e.strip()] - def set_enabled_engines(self, engines: List[str]) -> None: + def set_enabled_engines(self, engines: list[str]) -> None: """Set the list of enabled engines.""" self.config["General"]["engines"] = ",".join(engines) self.save_config() - def get_engine_config(self, engine: str) -> Dict[str, str]: + def get_engine_config(self, engine: str) -> dict[str, str]: """Get the configuration for a specific engine.""" if not self.config.has_section(engine): return {} @@ -195,7 +194,7 @@ def get_engine_config(self, engine: str) -> Dict[str, str]: return config - def set_engine_config(self, engine: str, config: Dict[str, str]) -> None: + def set_engine_config(self, engine: str, config: dict[str, str]) -> None: """Set the configuration for a specific engine.""" if not self.config.has_section(engine): self.config.add_section(engine) @@ -210,7 +209,7 @@ def set_engine_config(self, engine: str, config: Dict[str, str]) -> None: self.config[engine][key] = value self.save_config() - def validate_engine_config(self, engine: str) -> List[str]: + def validate_engine_config(self, engine: str) -> list[str]: """Validate the configuration for a specific engine. Returns: @@ -229,7 +228,7 @@ def validate_engine_config(self, engine: str) -> List[str]: return errors - def get_tts_config(self) -> Dict: + def get_tts_config(self) -> dict: """Get the complete TTS configuration.""" enabled_engines = self.get_enabled_engines() @@ -263,7 +262,7 @@ def get_tts_config(self) -> Dict: except json.JSONDecodeError: # If not JSON, treat as file path if os.path.exists(creds_json): - with open(creds_json, "r") as f: + with open(creds_json) as f: credentials = json.load(f) else: credentials = {} @@ -312,11 +311,11 @@ def get_tts_config(self) -> Dict: return config - def get_available_engines(self) -> List[EngineInfo]: + def get_available_engines(self) -> list[EngineInfo]: """Get information about all available engines.""" return list(AVAILABLE_ENGINES.values()) - def get_engine_info(self, engine: str) -> Optional[EngineInfo]: + def get_engine_info(self, engine: str) -> EngineInfo | None: """Get information about a specific engine.""" return AVAILABLE_ENGINES.get(engine) diff --git a/speech/speech_manager.py b/speech/speech_manager.py index ebcf539..51bebb9 100644 --- a/speech/speech_manager.py +++ b/speech/speech_manager.py @@ -5,9 +5,9 @@ ElevenLabsClient, ElevenLabsTTS, GoogleClient, - GoogleTTS, GoogleTransClient, GoogleTransTTS, + GoogleTTS, MicrosoftClient, MicrosoftTTS, PlayHTClient, @@ -22,14 +22,8 @@ WitAiTTS, eSpeakClient, eSpeakTTS, - # AVSynthClient, - # AVSynthTTS, - # SAPIClient, - # SAPITTS, ) -from .config import get_tts_config - class TTSProvider: """Base class for TTS providers.""" @@ -140,10 +134,12 @@ def init_providers(self, config=None): # Set the first provider as the current provider self.current_provider = next(iter(self.providers.values())) self.logger.info( - f"SpeechManager: Successfully initialized providers: {list(self.providers.keys())}" + "SpeechManager: Successfully initialized providers: " + f"{list(self.providers.keys())}" ) self.logger.info( - f"SpeechManager: Current provider: {self.current_provider.__class__.__name__}" + "SpeechManager: Current provider: " + f"{self.current_provider.__class__.__name__}" ) except Exception as e: @@ -181,8 +177,8 @@ def get_speak_data( ) if not provider: raise ValueError(f"Provider {provider_id} not found") - import tempfile import os + import tempfile # Create a temporary file with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as temp_file: diff --git a/speech/start.py b/speech/start.py index d55db59..51b76db 100644 --- a/speech/start.py +++ b/speech/start.py @@ -5,7 +5,8 @@ import os import sys from urllib.parse import unquote -from flask import Flask, jsonify, request, send_file, render_template, redirect, url_for + +from flask import Flask, jsonify, render_template, request, send_file from flask_cors import CORS from flask_restx import Api, Resource, fields @@ -127,7 +128,7 @@ def config(): engine_config["credentials_json"] = credentials_json except Exception as e: validation_errors["google"] = [ - f"Error reading JSON file: {str(e)}" + f"Error reading JSON file: {e!s}" ] # If no file uploaded, check for pasted JSON if not engine_config and request.form.get( @@ -162,7 +163,7 @@ def config(): except Exception as e: logger.error(f"Error saving configuration: {e}", exc_info=True) - error_message = f"Error saving configuration: {str(e)}" + error_message = f"Error saving configuration: {e!s}" return render_template( "config.html", @@ -278,35 +279,6 @@ def get(self): return {"error": str(e), "status": "error", "voices": []}, 200 -def create_wav_header(pcm_data: bytes) -> bytes: - """Create a WAV header for the PCM data.""" - # WAV header parameters - sample_rate = 16000 # Standard sample rate for speech - bits_per_sample = 16 # 16-bit audio - channels = 1 # Mono audio - data_size = len(pcm_data) - - # WAV header (44 bytes) - header = bytearray() - header.extend(b"RIFF") # ChunkID - header.extend((36 + data_size).to_bytes(4, "little")) # ChunkSize - header.extend(b"WAVE") # Format - header.extend(b"fmt ") # Subchunk1ID - header.extend((16).to_bytes(4, "little")) # Subchunk1Size - header.extend((1).to_bytes(2, "little")) # AudioFormat (1 = PCM) - header.extend(channels.to_bytes(2, "little")) # NumChannels - header.extend(sample_rate.to_bytes(4, "little")) # SampleRate - header.extend( - (sample_rate * channels * bits_per_sample // 8).to_bytes(4, "little") - ) # ByteRate - header.extend((channels * bits_per_sample // 8).to_bytes(2, "little")) # BlockAlign - header.extend(bits_per_sample.to_bytes(2, "little")) # BitsPerSample - header.extend(b"data") # Subchunk2ID - header.extend(data_size.to_bytes(4, "little")) # Subchunk2Size - - return bytes(header) - - @ns.route("/speakdata/") @ns.route("/speakdata//") @ns.route("/speakdata///") @@ -330,10 +302,9 @@ def get(self, text: str, provider_id: str = "", voice_id: str = ""): "status": "error", }, 200 - # Add WAV header to the PCM data - wav_data = create_wav_header(data) + data + # The data is already a complete WAV file from synth_to_file return send_file( - io.BytesIO(wav_data), + io.BytesIO(data), mimetype="audio/wav", as_attachment=False, download_name="speech.wav", diff --git a/uv.lock b/uv.lock index e0023ca..7b9a27c 100644 --- a/uv.lock +++ b/uv.lock @@ -66,10 +66,10 @@ requires-dist = [ { name = "hydra-core", marker = "extra == 'test'" }, { name = "omegaconf", specifier = ">=2.3.0" }, { name = "omegaconf", marker = "extra == 'test'" }, - { name = "py3-tts-wrapper", extras = ["avsynth"], marker = "sys_platform == 'darwin'", specifier = "==0.9.28" }, - { name = "py3-tts-wrapper", extras = ["espeak"], marker = "sys_platform == 'linux'", specifier = "==0.9.28" }, - { name = "py3-tts-wrapper", extras = ["sapi"], marker = "sys_platform == 'win32'", specifier = "==0.9.28" }, - { name = "py3-tts-wrapper", extras = ["sherpaonnx", "espeak", "elevenlabs", "playht", "microsoft", "polly", "watson", "googletrans", "playht", "witai", "controlaudio"], specifier = "==0.9.28" }, + { name = "py3-tts-wrapper", extras = ["avsynth"], marker = "sys_platform == 'darwin'", specifier = "==0.9.31" }, + { name = "py3-tts-wrapper", extras = ["espeak"], marker = "sys_platform == 'linux'", specifier = "==0.9.31" }, + { name = "py3-tts-wrapper", extras = ["sapi"], marker = "sys_platform == 'win32'", specifier = "==0.9.31" }, + { name = "py3-tts-wrapper", extras = ["sherpaonnx", "espeak", "elevenlabs", "playht", "microsoft", "polly", "watson", "googletrans", "playht", "witai", "controlaudio"], specifier = "==0.9.31" }, { name = "pyinstaller", marker = "extra == 'test'" }, { name = "pytest", marker = "extra == 'test'" }, { name = "pytest-cov", marker = "extra == 'test'" }, @@ -658,7 +658,7 @@ wheels = [ [[package]] name = "py3-tts-wrapper" -version = "0.9.28" +version = "0.9.31" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, @@ -667,9 +667,9 @@ dependencies = [ { name = "sounddevice" }, { name = "soundfile" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3e/81/51c7e35ce093d2668de59b876976c6669284ee406d78a9bd77870d501368/py3_tts_wrapper-0.9.28.tar.gz", hash = "sha256:d0e4b70b9f8be4d49636545a88d352dac68c930436fc0a7387895aac14e02540", size = 6925926 } +sdist = { url = "https://files.pythonhosted.org/packages/4b/5e/b8c0fae8b1ad8f78f1c19a5b8dd2402f0a06e59445d8c12096529181a7f0/py3_tts_wrapper-0.9.31.tar.gz", hash = "sha256:460f49e67cdd550b0c76a16564c7a9085dea78d5f5c16a7903ab764f3e20548b", size = 6926961 } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/ad/cdbd924b62e4d2f197a4361b9d9a3a11234c72c0e37d03ab45f2a0f70d38/py3_tts_wrapper-0.9.28-py3-none-any.whl", hash = "sha256:c6f6a13d5160a1f310d18289d648371c0032b17bb9a011e88cdd016cc55f2716", size = 849872 }, + { url = "https://files.pythonhosted.org/packages/b7/80/0aad4175c4c3c4ec4358b5663e939113ce1eb4bbd77949b52c9cd5868d97/py3_tts_wrapper-0.9.31-py3-none-any.whl", hash = "sha256:2d5983f665eb2c67ae317df06003f1719590a35f59f9100ad42b6de151d5bd88", size = 849938 }, ] [package.optional-dependencies] From 8af946d93acbc202f3c7117d1ec4e7a48208548a Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 21 Mar 2025 17:24:12 +0000 Subject: [PATCH 11/37] adding custom providers like openai --- README.md | 141 ++++++++++++++- pyproject.toml | 11 +- speech/base_provider.py | 28 +++ speech/config_manager.py | 17 +- speech/custom_providers.py | 102 +++++++++++ speech/speech_manager.py | 351 +++++++++++++++++++++++-------------- uv.lock | 307 ++++++++++++++++++++++++++------ 7 files changed, 762 insertions(+), 195 deletions(-) create mode 100644 speech/base_provider.py create mode 100644 speech/custom_providers.py diff --git a/README.md b/README.md index f29e6d2..d6e391e 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,6 @@ git clone https://github.com/yourusername/AsTeRICS-Grid-Helper.git cd AsTeRICS-Grid-Helper ``` -2. Install dependencies using `uv`: -```bash -uv pip install -r requirements.txt -``` ## Usage @@ -156,6 +152,143 @@ CREDENTIALS = { } ``` +## Creating Custom TTS Providers + +The system supports custom TTS providers through a simple interface. This allows you to integrate any TTS engine that can be controlled via command line or API. + +### Provider Interface + +To create a custom provider, create a new class that inherits from `CustomTTSProvider` in `speech/custom_providers.py`: + +```python +from speech.speech_manager import CustomTTSProvider + +class MyCustomProvider(CustomTTSProvider): + def __init__(self, config: dict[str, Any] | None = None): + super().__init__() + self.config = config or {} + # Initialize your TTS engine here + + def get_voices(self) -> list[dict[str, Any]]: + """Return list of available voices.""" + # Return list of dicts with keys: id, name, language_codes, gender + return [] + + def speak(self, text: str, voice_id: str) -> None: + """Speak text using specified voice.""" + # Implement direct speech output + pass + + def get_speak_data(self, text: str, voice_id: str) -> bytes: + """Get WAV audio data for text.""" + # Return WAV format audio data + return b"" + + def stop_speaking(self) -> None: + """Stop current speech playback.""" + # Implement stop functionality + pass +``` + +### Registering Your Provider + +Add an initialization method to `SpeechManager` in `speech/speech_manager.py`: + +```python +def init_myprovider_provider(self, config: dict[str, Any]) -> CustomTTSProvider | None: + """Initialize your custom provider.""" + try: + from .custom_providers import MyCustomProvider + return MyCustomProvider(config) + except Exception as e: + self.logger.error(f"Failed to initialize MyProvider: {e}") + return None +``` + +### Configuration + +Add your provider to `speech.ini`: + +```ini +[engines] +engines = myprovider,espeak + +[engine_configs] +myprovider_path = /path/to/myprovider +myprovider_data_dir = /path/to/data +``` + +### Example Implementations + +#### OpenAI TTS Provider + +The OpenAI TTS provider demonstrates integration with OpenAI's text-to-speech API: + +```ini +[engines] +engines = openai + +[engine_configs] +openai_api_key = your-api-key +openai_model = gpt-4o-mini-tts +openai_output_format = wav +``` + +Features: +- Uses OpenAI's GPT-4o mini TTS model +- Supports 11 built-in voices (alloy, ash, ballad, coral, echo, fable, onyx, nova, sage, shimmer) +- Optimized for English but supports multiple languages +- High-quality, natural-sounding speech +- Streaming support for real-time playback + +To use the OpenAI provider: + +1. Get an API key from [OpenAI](https://platform.openai.com) +2. Set the `OPENAI_API_KEY` environment variable or add it to your config +3. Select a voice from the available options +4. Use the provider as normal + +Example usage: +```python +from speech.speech_manager import SpeechManager +from speech.config import get_tts_config + +# Initialize with OpenAI +config = get_tts_config() +config["engines"] = ["openai"] +config["engine_configs"] = { + "openai": { + "api_key": "your-api-key", + "model": "gpt-4o-mini-tts", + "output_format": "wav" + } +} + +speech_manager = SpeechManager() +speech_manager.init_providers(config) + +# Get available voices +voices = speech_manager.get_voices() +for voice in voices: + print(f"- {voice['name']} ({voice['language_codes'][0]})") + +# Speak text +speech_manager.speak("Hello, this is a test.", "alloy") +``` + +Note: The OpenAI TTS service requires an API key and may incur costs based on usage. See [OpenAI's pricing](https://openai.com/pricing) for details. + +#### Template Provider + +The `TemplateProvider` class in `speech/custom_providers.py` provides a base template for implementing new TTS providers. It includes: + +1. Basic provider structure +2. Required method signatures +3. Type hints and documentation +4. Error handling patterns + +Use this template as a starting point for implementing new providers. + ## Development ### Running Tests diff --git a/pyproject.toml b/pyproject.toml index 8e775ae..052ebae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,12 +9,15 @@ dependencies = [ "flask>=3.0.0", "flask-cors>=4.0.0", "flask-restx>=1.3.0", - "py3-tts-wrapper[avsynth]==0.9.31; sys_platform == 'darwin'", - "py3-tts-wrapper[sapi]==0.9.31; sys_platform == 'win32'", - "py3-tts-wrapper[espeak]==0.9.31; sys_platform == 'linux'", - "py3-tts-wrapper[sherpaonnx,espeak,elevenlabs,playht,microsoft,polly,watson,googletrans,playht,witai,controlaudio]==0.9.31", + "py3-tts-wrapper[avsynth]==0.9.32; sys_platform == 'darwin'", + "py3-tts-wrapper[sapi]==0.9.32; sys_platform == 'win32'", + "py3-tts-wrapper[espeak]==0.9.32; sys_platform == 'linux'", + "py3-tts-wrapper[sherpaonnx,espeak,elevenlabs,playht,microsoft,polly,watson,googletrans,playht,witai,controlaudio]==0.9.32", "omegaconf>=2.3.0", "hydra-core>=1.3.0", + "openai>=1.12.0", + "sounddevice>=0.4.6", + "soundfile>=0.12.1" ] requires-python = ">=3.11" readme = "README.md" diff --git a/speech/base_provider.py b/speech/base_provider.py new file mode 100644 index 0000000..23a80d6 --- /dev/null +++ b/speech/base_provider.py @@ -0,0 +1,28 @@ +"""Base class for TTS providers.""" + +import logging +from typing import Any + + +class CustomTTSProvider: + """Base class for custom TTS providers.""" + + def __init__(self): + """Initialize the provider.""" + self.logger = logging.getLogger(__name__) + + def get_voices(self) -> list[dict[str, Any]]: + """Get available voices.""" + return [] + + def speak(self, text: str, voice_id: str) -> None: + """Speak text using specified voice.""" + pass + + def get_speak_data(self, text: str, voice_id: str) -> bytes: + """Get WAV audio data for text.""" + return b"" + + def stop_speaking(self) -> None: + """Stop current speech playback.""" + pass diff --git a/speech/config_manager.py b/speech/config_manager.py index 7961cba..ae04930 100644 --- a/speech/config_manager.py +++ b/speech/config_manager.py @@ -29,7 +29,10 @@ class EngineInfo: "sherpaonnx": EngineInfo( name="sherpaonnx", display_name="Sherpa-ONNX", - description="Open-source offline TTS engine using ONNX models (works with default configuration)", + description=( + "Open-source offline TTS engine using ONNX models " + "(works with default configuration)" + ), is_offline=True, required_fields=[], help_text={ @@ -37,6 +40,18 @@ class EngineInfo: "tokens_path": "Optional: Path to custom tokens file", }, ), + "openai": EngineInfo( + name="openai", + display_name="OpenAI TTS", + description="OpenAI's GPT-4o mini TTS with high-quality voices", + is_offline=False, + required_fields=["api_key"], + help_text={ + "api_key": "Your OpenAI API key", + "model": ("Model to use (default: gpt-4o-mini-tts)"), + "output_format": ("Output format (default: wav)"), + }, + ), "microsoft": EngineInfo( name="microsoft", display_name="Microsoft Azure", diff --git a/speech/custom_providers.py b/speech/custom_providers.py new file mode 100644 index 0000000..1e5c3f7 --- /dev/null +++ b/speech/custom_providers.py @@ -0,0 +1,102 @@ +"""Custom TTS provider implementations.""" + +from typing import Any + +from .base_provider import CustomTTSProvider + + +class OpenAITTSProvider(CustomTTSProvider): + """OpenAI TTS provider implementation.""" + + def __init__(self, config: dict[str, Any] | None = None): + """Initialize the OpenAI provider. + + Args: + config: Optional configuration dictionary with keys: + - api_key: OpenAI API key + - model: Model to use (default: "gpt-4o-mini-tts") + - output_format: Output format (default: "wav") + """ + super().__init__() + self.config = config or {} + self.api_key = self.config.get("api_key") + if not self.api_key: + raise ValueError("OpenAI API key is required") + + self.model = self.config.get("model", "gpt-4o-mini-tts") + self.output_format = self.config.get("output_format", "wav") + + # Initialize OpenAI client + from openai import OpenAI + + self.client = OpenAI(api_key=self.api_key) + + def get_voices(self) -> list[dict[str, Any]]: + """Get available OpenAI voices.""" + return [ + { + "id": voice, + "name": voice.capitalize(), + "language_codes": ["en"], # OpenAI voices are optimized for English + "gender": "Unknown", + } + for voice in [ + "alloy", + "ash", + "ballad", + "coral", + "echo", + "fable", + "onyx", + "nova", + "sage", + "shimmer", + ] + ] + + def speak(self, text: str, voice_id: str) -> None: + """Speak text using OpenAI TTS.""" + try: + # Generate audio data + audio_data = self.get_speak_data(text, voice_id) + if not audio_data: + raise RuntimeError("Failed to generate audio data") + + # Play audio using system's audio player + import io + + import sounddevice as sd + import soundfile as sf + + # Read WAV data into numpy array + audio_stream = io.BytesIO(audio_data) + data, samplerate = sf.read(audio_stream) + + # Play audio + sd.play(data, samplerate) + sd.wait() + except Exception as e: + self.logger.error(f"Error speaking text: {e}") + + def get_speak_data(self, text: str, voice_id: str) -> bytes: + """Get WAV audio data for text using OpenAI TTS.""" + try: + # Generate speech + response = self.client.audio.speech.create( + model=self.model, + voice=voice_id, + input=text, + response_format=self.output_format, + ) + + # Get audio data + return response.content + except Exception as e: + self.logger.error(f"Error getting speech data: {e}") + return b"" + + def stop_speaking(self) -> None: + """Stop current speech playback.""" + import sounddevice as sd + + sd.stop() diff --git a/speech/speech_manager.py b/speech/speech_manager.py index 51bebb9..2dbd5cb 100644 --- a/speech/speech_manager.py +++ b/speech/speech_manager.py @@ -1,3 +1,5 @@ +"""Speech manager for handling TTS providers.""" + import logging from typing import Any @@ -24,43 +26,58 @@ eSpeakTTS, ) +from .base_provider import CustomTTSProvider +from .custom_providers import OpenAITTSProvider + -class TTSProvider: - """Base class for TTS providers.""" +class TTSProvider(CustomTTSProvider): + """Base class for TTS wrapper providers.""" def __init__(self): """Initialize the provider.""" - self.logger = logging.getLogger(__name__) + super().__init__() self.tts = None + self.timings = [] def get_voices(self) -> list[dict[str, Any]]: """Get available voices.""" - raise NotImplementedError - - def get_speak_data(self, text: str, voice_id: str) -> bytes: - """Get speech data for text using synth_to_bytes.""" - if not hasattr(self.tts, "synth_to_bytes"): - raise NotImplementedError("Provider does not support audio streaming") - return self.tts.synth_to_bytes(text, voice_id=voice_id) + return self.tts.get_voices() def speak(self, text: str, voice_id: str) -> None: - """Speak text using the specified voice.""" - if not hasattr(self.tts, "speak"): - raise NotImplementedError("Provider does not support direct speech") - self.tts.speak(text, voice_id=voice_id) + """Speak text using specified voice.""" + try: + # First try direct speak + self.tts.speak(text, voice_id=voice_id) + except Exception as e: + self.logger.error(f"Error speaking text: {e}") + def get_speak_data(self, text: str, voice_id: str) -> bytes: + """Get WAV audio data for text.""" + try: + # Get raw PCM audio data as bytes + audio_data = self.tts.synth_to_bytes(text, voice_id=voice_id) -class MicrosoftTTSProvider(TTSProvider): - """Microsoft TTS provider wrapper.""" + # Try to get word timings if available + try: + if hasattr(self.tts, "get_word_timings"): + self.timings = self.tts.get_word_timings() + elif hasattr(self.tts, "word_timings"): + self.timings = self.tts.word_timings + except Exception as e: + self.logger.debug(f"Could not get word timings: {e}") + self.timings = [] - def __init__(self, client: MicrosoftClient): - """Initialize the Microsoft TTS provider.""" - super().__init__() - self.tts = MicrosoftTTS(client) + return audio_data + except Exception as e: + self.logger.error(f"Error getting speech data: {e}") + return b"" - def get_voices(self) -> list[dict[str, Any]]: - """Get available voices.""" - return self.tts.get_voices() + def stop_speaking(self) -> None: + """Stop current speech playback.""" + try: + self.tts.stop_speaking() + except Exception as e: + self.logger.error(f"Error stopping speech: {e}") class SpeechManager: @@ -69,82 +86,141 @@ class SpeechManager: def __init__(self): """Initialize the speech manager.""" self.logger = logging.getLogger(__name__) - self.providers = {} - self.current_provider = None + self.providers: dict[str, CustomTTSProvider] = {} + self.current_provider: CustomTTSProvider | None = None self.is_speaking = False - def init_providers(self, config=None): - """Initialize TTS providers based on configuration.""" - if config is None: - from .config import get_tts_config as get_config - - config = get_config() + def init_providers(self, config: dict[str, Any]) -> None: + """Initialize TTS providers from config.""" + self.providers = {} + self.current_provider = None - engines = config.get("engines", ["espeak"]) - self.logger.info(f"SpeechManager: Got engines from config: {engines}") + # Get list of engines from config + engines = config.get("engines", []) + if not engines: + self.logger.warning("No TTS engines specified in config") + return - try: - for engine in engines: - engine_name = engine.lower() - self.logger.info( - f"SpeechManager: Attempting to initialize {engine_name} provider..." - ) - - engine_config = config.get("engine_configs", {}).get(engine_name, {}) + # Initialize each provider + for engine in engines: + provider = None + try: + if engine == "sherpaonnx": + # Initialize Sherpa-ONNX provider + engine_config = config.get("engine_configs", {}).get( + "sherpaonnx", {} + ) + client = SherpaOnnxClient( + model_path=engine_config.get("model_path"), + tokens_path=engine_config.get("tokens_path"), + ) + provider = TTSProvider() + provider.tts = SherpaOnnxTTS(client) - if engine_name == "espeak": - client = eSpeakClient() - self.providers["espeak"] = eSpeakTTS(client) - elif engine_name == "sherpaonnx": - client = SherpaOnnxClient(**engine_config) - self.providers["sherpaonnx"] = SherpaOnnxTTS(client) - elif engine_name == "google": - client = GoogleClient(**engine_config) - self.providers["google"] = GoogleTTS(client) - elif engine_name == "googletrans": - client = GoogleTransClient(engine_config.get("voice_id", "en-us")) - self.providers["googletrans"] = GoogleTransTTS(client) - elif engine_name == "microsoft": - client = MicrosoftClient(**engine_config) - self.providers["microsoft"] = MicrosoftTTS(client) - elif engine_name == "polly": - client = PollyClient(**engine_config) - self.providers["polly"] = PollyTTS(client) - elif engine_name == "watson": - client = WatsonClient(**engine_config) - self.providers["watson"] = WatsonTTS(client) - elif engine_name == "elevenlabs": - client = ElevenLabsClient(**engine_config) - self.providers["elevenlabs"] = ElevenLabsTTS(client) - elif engine_name == "witai": - client = WitAiClient(**engine_config) - self.providers["witai"] = WitAiTTS(client) - elif engine_name == "playht": - client = PlayHTClient(**engine_config) - self.providers["playht"] = PlayHTTTS(client) - else: - self.logger.warning( - f"SpeechManager: Unsupported TTS engine: {engine_name}" + elif engine == "microsoft": + # Initialize Microsoft Azure provider + engine_config = config.get("engine_configs", {}).get( + "microsoft", {} + ) + credentials = engine_config.get("credentials", ("", "")) + client = MicrosoftClient(credentials=credentials) + provider = TTSProvider() + provider.tts = MicrosoftTTS(client) + + elif engine == "google": + # Initialize Google Cloud provider + engine_config = config.get("engine_configs", {}).get("google", {}) + credentials = engine_config.get("credentials", {}) + if not credentials: + self.logger.warning("Google Cloud credentials not provided") + continue + client = GoogleClient(credentials=credentials) + provider = TTSProvider() + provider.tts = GoogleTTS(client) + + elif engine == "googletrans": + # Initialize Google Translate provider + engine_config = config.get("engine_configs", {}).get( + "googletrans", {} ) - continue - - if not self.providers: - raise ValueError("No valid TTS providers were initialized") - - # Set the first provider as the current provider - self.current_provider = next(iter(self.providers.values())) - self.logger.info( - "SpeechManager: Successfully initialized providers: " - f"{list(self.providers.keys())}" - ) - self.logger.info( - "SpeechManager: Current provider: " - f"{self.current_provider.__class__.__name__}" - ) + client = GoogleTransClient() + provider = TTSProvider() + provider.tts = GoogleTransTTS(client) + + elif engine == "elevenlabs": + # Initialize ElevenLabs provider + engine_config = config.get("engine_configs", {}).get( + "elevenlabs", {} + ) + credentials = engine_config.get("credentials", ("",)) + client = ElevenLabsClient(credentials=credentials) + provider = TTSProvider() + provider.tts = ElevenLabsTTS(client) + + elif engine == "polly": + # Initialize Amazon Polly provider + engine_config = config.get("engine_configs", {}).get("polly", {}) + credentials = engine_config.get("credentials", ("", "", "")) + client = PollyClient(credentials=credentials) + provider = TTSProvider() + provider.tts = PollyTTS(client) + + elif engine == "watson": + # Initialize IBM Watson provider + engine_config = config.get("engine_configs", {}).get("watson", {}) + credentials = engine_config.get("credentials", ("", "", "")) + client = WatsonClient(credentials=credentials) + provider = TTSProvider() + provider.tts = WatsonTTS(client) + + elif engine == "witai": + # Initialize Wit.ai provider + engine_config = config.get("engine_configs", {}).get("witai", {}) + credentials = engine_config.get("credentials", ("",)) + client = WitAiClient(credentials=credentials) + provider = TTSProvider() + provider.tts = WitAiTTS(client) + + elif engine == "playht": + # Initialize Play.HT provider + engine_config = config.get("engine_configs", {}).get("playht", {}) + credentials = engine_config.get("credentials", ("", "")) + client = PlayHTClient(credentials=credentials) + provider = TTSProvider() + provider.tts = PlayHTTTS(client) + + elif engine == "espeak": + # Initialize eSpeak provider + engine_config = config.get("engine_configs", {}).get("espeak", {}) + client = eSpeakClient() + provider = TTSProvider() + provider.tts = eSpeakTTS(client) + + elif engine == "openai": + # Initialize OpenAI provider + engine_config = config.get("engine_configs", {}).get("openai", {}) + provider = OpenAITTSProvider(engine_config) # type: ignore + if provider: + self.providers[engine] = provider + if not self.current_provider: + self.current_provider = provider + self.logger.info( + f"Current provider: {provider.__class__.__name__}" + ) + + if provider: + self.providers[engine] = provider + if not self.current_provider: + self.current_provider = provider + self.logger.info( + f"Current provider: {provider.__class__.__name__}" + ) - except Exception as e: - self.logger.error(f"Failed to initialize providers: {e}") - raise + except Exception as e: + self.logger.error(f"Failed to initialize {engine} provider: {e}") + + if not self.providers: + self.logger.warning("No TTS providers were successfully initialized") def get_voices(self) -> list[dict[str, Any]]: """Get available voices from all providers.""" @@ -152,68 +228,73 @@ def get_voices(self) -> list[dict[str, Any]]: for provider_id, provider in self.providers.items(): try: self.logger.info(f"Getting voices from provider: {provider_id}") - voices = provider.get_voices() - self.logger.info(f"Found {len(voices)} voices from {provider_id}") - for voice in voices: + provider_voices = provider.get_voices() + self.logger.info( + f"Found {len(provider_voices)} voices from {provider_id}" + ) + + # Add provider ID to each voice + for voice in provider_voices: voice["providerId"] = provider_id - voice["type"] = "external_data" - voice["name"] = f"{voice['name']}, {provider_id}" - all_voices.extend(voices) + all_voices.extend(provider_voices) except Exception as e: self.logger.error(f"Error getting voices from {provider_id}: {e}") - continue self.logger.info( f"Found {len(all_voices)} voices across {len(self.providers)} providers" ) return all_voices - def get_speak_data( - self, text: str, voice_id: str, provider_id: str | None = None - ) -> bytes: - """Get speech data for text using the specified provider.""" - provider = ( - self.providers.get(provider_id) if provider_id else self.current_provider - ) - if not provider: - raise ValueError(f"Provider {provider_id} not found") - import os - import tempfile + def speak(self, text: str, voice_id: str, provider_id: str | None = None) -> None: + """Speak text using specified voice.""" + if not text: + return - # Create a temporary file - with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as temp_file: - # Generate the audio file - provider.synth_to_file( - text, temp_file.name, output_format="wav", voice_id=voice_id - ) + provider: CustomTTSProvider | None = None + if provider_id and provider_id in self.providers: + provider = self.providers[provider_id] + else: + provider = self.current_provider - # Read the file contents - with open(temp_file.name, "rb") as f: - data = f.read() + if not provider: + self.logger.error("No TTS provider available") + return - # Clean up the temp file - os.unlink(temp_file.name) + try: + provider.speak(text, voice_id) + except Exception as e: + self.logger.error(f"Error speaking text: {e}") - return data + def get_speak_data( + self, text: str, voice_id: str, provider_id: str | None = None + ) -> bytes: + """Get WAV audio data for text.""" + if not text: + return b"" + + provider: CustomTTSProvider | None = None + if provider_id and provider_id in self.providers: + provider = self.providers[provider_id] + else: + provider = self.current_provider - def speak(self, text: str, voice_id: str, provider_id: str | None = None) -> None: - """Speak text using the specified provider.""" - provider = ( - self.providers.get(provider_id) if provider_id else self.current_provider - ) if not provider: - raise ValueError(f"Provider {provider_id} not found") - self.is_speaking = True + self.logger.error("No TTS provider available") + return b"" + try: - provider.speak(text=text, voice_id=voice_id) - finally: - self.is_speaking = False + return provider.get_speak_data(text, voice_id) + except Exception as e: + self.logger.error(f"Error getting speech data: {e}") + return b"" def stop_speaking(self) -> None: - """Stop the current speech playback.""" + """Stop current speech playback.""" if self.current_provider: - self.current_provider.stop_speaking() - self.is_speaking = False + try: + self.current_provider.stop_speaking() + except Exception as e: + self.logger.error(f"Error stopping speech: {e}") def get_voices(speech_manager: SpeechManager) -> list[dict[str, Any]]: diff --git a/uv.lock b/uv.lock index 7b9a27c..e53b0c6 100644 --- a/uv.lock +++ b/uv.lock @@ -25,12 +25,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/72/bf/d5cde2cb7cdc2cb1770d974418d169a79c3187bd962cb752b9fd617848ca/aniso8601-10.0.0-py2.py3-none-any.whl", hash = "sha256:3c943422efaa0229ebd2b0d7d223effb5e7c89e24d2267ebe76c61a2d8e290cb", size = 52767 }, ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + [[package]] name = "antlr4-python3-runtime" version = "4.9.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/3e/38/7859ff46355f76f8d19459005ca000b6e7012f2f1ca597746cbcd1fbfe5e/antlr4-python3-runtime-4.9.3.tar.gz", hash = "sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b", size = 117034 } +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, +] + [[package]] name = "asterics-grid-helper" version = "0.1.0" @@ -41,9 +64,12 @@ dependencies = [ { name = "flask-restx" }, { name = "hydra-core" }, { name = "omegaconf" }, + { name = "openai" }, { name = "py3-tts-wrapper", marker = "sys_platform == 'darwin'" }, { name = "py3-tts-wrapper", extra = ["controlaudio", "googletrans", "microsoft", "polly", "sherpaonnx", "watson"] }, { name = "py3-tts-wrapper", extra = ["sapi"], marker = "sys_platform == 'win32'" }, + { name = "sounddevice" }, + { name = "soundfile" }, ] [package.optional-dependencies] @@ -66,15 +92,18 @@ requires-dist = [ { name = "hydra-core", marker = "extra == 'test'" }, { name = "omegaconf", specifier = ">=2.3.0" }, { name = "omegaconf", marker = "extra == 'test'" }, - { name = "py3-tts-wrapper", extras = ["avsynth"], marker = "sys_platform == 'darwin'", specifier = "==0.9.31" }, - { name = "py3-tts-wrapper", extras = ["espeak"], marker = "sys_platform == 'linux'", specifier = "==0.9.31" }, - { name = "py3-tts-wrapper", extras = ["sapi"], marker = "sys_platform == 'win32'", specifier = "==0.9.31" }, - { name = "py3-tts-wrapper", extras = ["sherpaonnx", "espeak", "elevenlabs", "playht", "microsoft", "polly", "watson", "googletrans", "playht", "witai", "controlaudio"], specifier = "==0.9.31" }, + { name = "openai", specifier = ">=1.12.0" }, + { name = "py3-tts-wrapper", extras = ["avsynth"], marker = "sys_platform == 'darwin'", specifier = "==0.9.32" }, + { name = "py3-tts-wrapper", extras = ["espeak"], marker = "sys_platform == 'linux'", specifier = "==0.9.32" }, + { name = "py3-tts-wrapper", extras = ["sapi"], marker = "sys_platform == 'win32'", specifier = "==0.9.32" }, + { name = "py3-tts-wrapper", extras = ["sherpaonnx", "espeak", "elevenlabs", "playht", "microsoft", "polly", "watson", "googletrans", "playht", "witai", "controlaudio"], specifier = "==0.9.32" }, { name = "pyinstaller", marker = "extra == 'test'" }, { name = "pytest", marker = "extra == 'test'" }, { name = "pytest-cov", marker = "extra == 'test'" }, { name = "pytest-mock", marker = "extra == 'test'", specifier = ">=3.12.0" }, { name = "requests", marker = "extra == 'test'" }, + { name = "sounddevice", specifier = ">=0.4.6" }, + { name = "soundfile", specifier = ">=0.12.1" }, ] [[package]] @@ -324,6 +353,15 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 }, +] + [[package]] name = "flask" version = "3.1.0" @@ -383,6 +421,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/6c/8b8b1fdcaee7e268536f1bb00183a5894627726b54a9ddc6fc9909888447/gTTS-2.5.4-py3-none-any.whl", hash = "sha256:5dd579377f9f5546893bc26315ab1f846933dc27a054764b168f141065ca8436", size = 29184 }, ] +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, +] + +[[package]] +name = "httpcore" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + [[package]] name = "hydra-core" version = "1.3.2" @@ -472,6 +547,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, ] +[[package]] +name = "jiter" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/c2/e4562507f52f0af7036da125bb699602ead37a2332af0788f8e0a3417f36/jiter-0.9.0.tar.gz", hash = "sha256:aadba0964deb424daa24492abc3d229c60c4a31bfee205aedbf1acc7639d7893", size = 162604 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/44/e241a043f114299254e44d7e777ead311da400517f179665e59611ab0ee4/jiter-0.9.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6c4d99c71508912a7e556d631768dcdef43648a93660670986916b297f1c54af", size = 314654 }, + { url = "https://files.pythonhosted.org/packages/fb/1b/a7e5e42db9fa262baaa9489d8d14ca93f8663e7f164ed5e9acc9f467fc00/jiter-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8f60fb8ce7df529812bf6c625635a19d27f30806885139e367af93f6e734ef58", size = 320909 }, + { url = "https://files.pythonhosted.org/packages/60/bf/8ebdfce77bc04b81abf2ea316e9c03b4a866a7d739cf355eae4d6fd9f6fe/jiter-0.9.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51c4e1a4f8ea84d98b7b98912aa4290ac3d1eabfde8e3c34541fae30e9d1f08b", size = 341733 }, + { url = "https://files.pythonhosted.org/packages/a8/4e/754ebce77cff9ab34d1d0fa0fe98f5d42590fd33622509a3ba6ec37ff466/jiter-0.9.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f4c677c424dc76684fea3e7285a7a2a7493424bea89ac441045e6a1fb1d7b3b", size = 365097 }, + { url = "https://files.pythonhosted.org/packages/32/2c/6019587e6f5844c612ae18ca892f4cd7b3d8bbf49461ed29e384a0f13d98/jiter-0.9.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2221176dfec87f3470b21e6abca056e6b04ce9bff72315cb0b243ca9e835a4b5", size = 406603 }, + { url = "https://files.pythonhosted.org/packages/da/e9/c9e6546c817ab75a1a7dab6dcc698e62e375e1017113e8e983fccbd56115/jiter-0.9.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3c7adb66f899ffa25e3c92bfcb593391ee1947dbdd6a9a970e0d7e713237d572", size = 396625 }, + { url = "https://files.pythonhosted.org/packages/be/bd/976b458add04271ebb5a255e992bd008546ea04bb4dcadc042a16279b4b4/jiter-0.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c98d27330fdfb77913c1097a7aab07f38ff2259048949f499c9901700789ac15", size = 351832 }, + { url = "https://files.pythonhosted.org/packages/07/51/fe59e307aaebec9265dbad44d9d4381d030947e47b0f23531579b9a7c2df/jiter-0.9.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eda3f8cc74df66892b1d06b5d41a71670c22d95a1ca2cbab73654745ce9d0419", size = 384590 }, + { url = "https://files.pythonhosted.org/packages/db/55/5dcd2693794d8e6f4889389ff66ef3be557a77f8aeeca8973a97a7c00557/jiter-0.9.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dd5ab5ddc11418dce28343123644a100f487eaccf1de27a459ab36d6cca31043", size = 520690 }, + { url = "https://files.pythonhosted.org/packages/54/d5/9f51dc90985e9eb251fbbb747ab2b13b26601f16c595a7b8baba964043bd/jiter-0.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:42f8a68a69f047b310319ef8e2f52fdb2e7976fb3313ef27df495cf77bcad965", size = 512649 }, + { url = "https://files.pythonhosted.org/packages/a6/e5/4e385945179bcf128fa10ad8dca9053d717cbe09e258110e39045c881fe5/jiter-0.9.0-cp311-cp311-win32.whl", hash = "sha256:a25519efb78a42254d59326ee417d6f5161b06f5da827d94cf521fed961b1ff2", size = 206920 }, + { url = "https://files.pythonhosted.org/packages/4c/47/5e0b94c603d8e54dd1faab439b40b832c277d3b90743e7835879ab663757/jiter-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:923b54afdd697dfd00d368b7ccad008cccfeb1efb4e621f32860c75e9f25edbd", size = 210119 }, + { url = "https://files.pythonhosted.org/packages/af/d7/c55086103d6f29b694ec79156242304adf521577530d9031317ce5338c59/jiter-0.9.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7b46249cfd6c48da28f89eb0be3f52d6fdb40ab88e2c66804f546674e539ec11", size = 309203 }, + { url = "https://files.pythonhosted.org/packages/b0/01/f775dfee50beb420adfd6baf58d1c4d437de41c9b666ddf127c065e5a488/jiter-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:609cf3c78852f1189894383cf0b0b977665f54cb38788e3e6b941fa6d982c00e", size = 319678 }, + { url = "https://files.pythonhosted.org/packages/ab/b8/09b73a793714726893e5d46d5c534a63709261af3d24444ad07885ce87cb/jiter-0.9.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d726a3890a54561e55a9c5faea1f7655eda7f105bd165067575ace6e65f80bb2", size = 341816 }, + { url = "https://files.pythonhosted.org/packages/35/6f/b8f89ec5398b2b0d344257138182cc090302854ed63ed9c9051e9c673441/jiter-0.9.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2e89dc075c1fef8fa9be219e249f14040270dbc507df4215c324a1839522ea75", size = 364152 }, + { url = "https://files.pythonhosted.org/packages/9b/ca/978cc3183113b8e4484cc7e210a9ad3c6614396e7abd5407ea8aa1458eef/jiter-0.9.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04e8ffa3c353b1bc4134f96f167a2082494351e42888dfcf06e944f2729cbe1d", size = 406991 }, + { url = "https://files.pythonhosted.org/packages/13/3a/72861883e11a36d6aa314b4922125f6ae90bdccc225cd96d24cc78a66385/jiter-0.9.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:203f28a72a05ae0e129b3ed1f75f56bc419d5f91dfacd057519a8bd137b00c42", size = 395824 }, + { url = "https://files.pythonhosted.org/packages/87/67/22728a86ef53589c3720225778f7c5fdb617080e3deaed58b04789418212/jiter-0.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fca1a02ad60ec30bb230f65bc01f611c8608b02d269f998bc29cca8619a919dc", size = 351318 }, + { url = "https://files.pythonhosted.org/packages/69/b9/f39728e2e2007276806d7a6609cda7fac44ffa28ca0d02c49a4f397cc0d9/jiter-0.9.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:237e5cee4d5d2659aaf91bbf8ec45052cc217d9446070699441a91b386ae27dc", size = 384591 }, + { url = "https://files.pythonhosted.org/packages/eb/8f/8a708bc7fd87b8a5d861f1c118a995eccbe6d672fe10c9753e67362d0dd0/jiter-0.9.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:528b6b71745e7326eed73c53d4aa57e2a522242320b6f7d65b9c5af83cf49b6e", size = 520746 }, + { url = "https://files.pythonhosted.org/packages/95/1e/65680c7488bd2365dbd2980adaf63c562d3d41d3faac192ebc7ef5b4ae25/jiter-0.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9f48e86b57bc711eb5acdfd12b6cb580a59cc9a993f6e7dcb6d8b50522dcd50d", size = 512754 }, + { url = "https://files.pythonhosted.org/packages/78/f3/fdc43547a9ee6e93c837685da704fb6da7dba311fc022e2766d5277dfde5/jiter-0.9.0-cp312-cp312-win32.whl", hash = "sha256:699edfde481e191d81f9cf6d2211debbfe4bd92f06410e7637dffb8dd5dfde06", size = 207075 }, + { url = "https://files.pythonhosted.org/packages/cd/9d/742b289016d155f49028fe1bfbeb935c9bf0ffeefdf77daf4a63a42bb72b/jiter-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:099500d07b43f61d8bd780466d429c45a7b25411b334c60ca875fa775f68ccb0", size = 207999 }, + { url = "https://files.pythonhosted.org/packages/e7/1b/4cd165c362e8f2f520fdb43245e2b414f42a255921248b4f8b9c8d871ff1/jiter-0.9.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2764891d3f3e8b18dce2cff24949153ee30c9239da7c00f032511091ba688ff7", size = 308197 }, + { url = "https://files.pythonhosted.org/packages/13/aa/7a890dfe29c84c9a82064a9fe36079c7c0309c91b70c380dc138f9bea44a/jiter-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:387b22fbfd7a62418d5212b4638026d01723761c75c1c8232a8b8c37c2f1003b", size = 318160 }, + { url = "https://files.pythonhosted.org/packages/6a/38/5888b43fc01102f733f085673c4f0be5a298f69808ec63de55051754e390/jiter-0.9.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d8da8629ccae3606c61d9184970423655fb4e33d03330bcdfe52d234d32f69", size = 341259 }, + { url = "https://files.pythonhosted.org/packages/3d/5e/bbdbb63305bcc01006de683b6228cd061458b9b7bb9b8d9bc348a58e5dc2/jiter-0.9.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1be73d8982bdc278b7b9377426a4b44ceb5c7952073dd7488e4ae96b88e1103", size = 363730 }, + { url = "https://files.pythonhosted.org/packages/75/85/53a3edc616992fe4af6814c25f91ee3b1e22f7678e979b6ea82d3bc0667e/jiter-0.9.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2228eaaaa111ec54b9e89f7481bffb3972e9059301a878d085b2b449fbbde635", size = 405126 }, + { url = "https://files.pythonhosted.org/packages/ae/b3/1ee26b12b2693bd3f0b71d3188e4e5d817b12e3c630a09e099e0a89e28fa/jiter-0.9.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:11509bfecbc319459647d4ac3fd391d26fdf530dad00c13c4dadabf5b81f01a4", size = 393668 }, + { url = "https://files.pythonhosted.org/packages/11/87/e084ce261950c1861773ab534d49127d1517b629478304d328493f980791/jiter-0.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f22238da568be8bbd8e0650e12feeb2cfea15eda4f9fc271d3b362a4fa0604d", size = 352350 }, + { url = "https://files.pythonhosted.org/packages/f0/06/7dca84b04987e9df563610aa0bc154ea176e50358af532ab40ffb87434df/jiter-0.9.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:17f5d55eb856597607562257c8e36c42bc87f16bef52ef7129b7da11afc779f3", size = 384204 }, + { url = "https://files.pythonhosted.org/packages/16/2f/82e1c6020db72f397dd070eec0c85ebc4df7c88967bc86d3ce9864148f28/jiter-0.9.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6a99bed9fbb02f5bed416d137944419a69aa4c423e44189bc49718859ea83bc5", size = 520322 }, + { url = "https://files.pythonhosted.org/packages/36/fd/4f0cd3abe83ce208991ca61e7e5df915aa35b67f1c0633eb7cf2f2e88ec7/jiter-0.9.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e057adb0cd1bd39606100be0eafe742de2de88c79df632955b9ab53a086b3c8d", size = 512184 }, + { url = "https://files.pythonhosted.org/packages/a0/3c/8a56f6d547731a0b4410a2d9d16bf39c861046f91f57c98f7cab3d2aa9ce/jiter-0.9.0-cp313-cp313-win32.whl", hash = "sha256:f7e6850991f3940f62d387ccfa54d1a92bd4bb9f89690b53aea36b4364bcab53", size = 206504 }, + { url = "https://files.pythonhosted.org/packages/f4/1c/0c996fd90639acda75ed7fa698ee5fd7d80243057185dc2f63d4c1c9f6b9/jiter-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:c8ae3bf27cd1ac5e6e8b7a27487bf3ab5f82318211ec2e1346a5b058756361f7", size = 204943 }, + { url = "https://files.pythonhosted.org/packages/78/0f/77a63ca7aa5fed9a1b9135af57e190d905bcd3702b36aca46a01090d39ad/jiter-0.9.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f0b2827fb88dda2cbecbbc3e596ef08d69bda06c6f57930aec8e79505dc17001", size = 317281 }, + { url = "https://files.pythonhosted.org/packages/f9/39/a3a1571712c2bf6ec4c657f0d66da114a63a2e32b7e4eb8e0b83295ee034/jiter-0.9.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:062b756ceb1d40b0b28f326cba26cfd575a4918415b036464a52f08632731e5a", size = 350273 }, + { url = "https://files.pythonhosted.org/packages/ee/47/3729f00f35a696e68da15d64eb9283c330e776f3b5789bac7f2c0c4df209/jiter-0.9.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6f7838bc467ab7e8ef9f387bd6de195c43bad82a569c1699cb822f6609dd4cdf", size = 206867 }, +] + [[package]] name = "jmespath" version = "1.0.1" @@ -570,50 +692,26 @@ wheels = [ [[package]] name = "numpy" -version = "2.2.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e1/78/31103410a57bc2c2b93a3597340a8119588571f6a4539067546cb9a0bfac/numpy-2.2.4.tar.gz", hash = "sha256:9ba03692a45d3eef66559efe1d1096c4b9b75c0986b5dff5530c378fb8331d4f", size = 20270701 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/16/fb/09e778ee3a8ea0d4dc8329cca0a9c9e65fed847d08e37eba74cb7ed4b252/numpy-2.2.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e9e0a277bb2eb5d8a7407e14688b85fd8ad628ee4e0c7930415687b6564207a4", size = 21254989 }, - { url = "https://files.pythonhosted.org/packages/a2/0a/1212befdbecab5d80eca3cde47d304cad986ad4eec7d85a42e0b6d2cc2ef/numpy-2.2.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9eeea959168ea555e556b8188da5fa7831e21d91ce031e95ce23747b7609f8a4", size = 14425910 }, - { url = "https://files.pythonhosted.org/packages/2b/3e/e7247c1d4f15086bb106c8d43c925b0b2ea20270224f5186fa48d4fb5cbd/numpy-2.2.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:bd3ad3b0a40e713fc68f99ecfd07124195333f1e689387c180813f0e94309d6f", size = 5426490 }, - { url = "https://files.pythonhosted.org/packages/5d/fa/aa7cd6be51419b894c5787a8a93c3302a1ed4f82d35beb0613ec15bdd0e2/numpy-2.2.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cf28633d64294969c019c6df4ff37f5698e8326db68cc2b66576a51fad634880", size = 6967754 }, - { url = "https://files.pythonhosted.org/packages/d5/ee/96457c943265de9fadeb3d2ffdbab003f7fba13d971084a9876affcda095/numpy-2.2.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fa8fa7697ad1646b5c93de1719965844e004fcad23c91228aca1cf0800044a1", size = 14373079 }, - { url = "https://files.pythonhosted.org/packages/c5/5c/ceefca458559f0ccc7a982319f37ed07b0d7b526964ae6cc61f8ad1b6119/numpy-2.2.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4162988a360a29af158aeb4a2f4f09ffed6a969c9776f8f3bdee9b06a8ab7e5", size = 16428819 }, - { url = "https://files.pythonhosted.org/packages/22/31/9b2ac8eee99e001eb6add9fa27514ef5e9faf176169057a12860af52704c/numpy-2.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:892c10d6a73e0f14935c31229e03325a7b3093fafd6ce0af704be7f894d95687", size = 15881470 }, - { url = "https://files.pythonhosted.org/packages/f0/dc/8569b5f25ff30484b555ad8a3f537e0225d091abec386c9420cf5f7a2976/numpy-2.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db1f1c22173ac1c58db249ae48aa7ead29f534b9a948bc56828337aa84a32ed6", size = 18218144 }, - { url = "https://files.pythonhosted.org/packages/5e/05/463c023a39bdeb9bb43a99e7dee2c664cb68d5bb87d14f92482b9f6011cc/numpy-2.2.4-cp311-cp311-win32.whl", hash = "sha256:ea2bb7e2ae9e37d96835b3576a4fa4b3a97592fbea8ef7c3587078b0068b8f09", size = 6606368 }, - { url = "https://files.pythonhosted.org/packages/8b/72/10c1d2d82101c468a28adc35de6c77b308f288cfd0b88e1070f15b98e00c/numpy-2.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:f7de08cbe5551911886d1ab60de58448c6df0f67d9feb7d1fb21e9875ef95e91", size = 12947526 }, - { url = "https://files.pythonhosted.org/packages/a2/30/182db21d4f2a95904cec1a6f779479ea1ac07c0647f064dea454ec650c42/numpy-2.2.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a7b9084668aa0f64e64bd00d27ba5146ef1c3a8835f3bd912e7a9e01326804c4", size = 20947156 }, - { url = "https://files.pythonhosted.org/packages/24/6d/9483566acfbda6c62c6bc74b6e981c777229d2af93c8eb2469b26ac1b7bc/numpy-2.2.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dbe512c511956b893d2dacd007d955a3f03d555ae05cfa3ff1c1ff6df8851854", size = 14133092 }, - { url = "https://files.pythonhosted.org/packages/27/f6/dba8a258acbf9d2bed2525cdcbb9493ef9bae5199d7a9cb92ee7e9b2aea6/numpy-2.2.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:bb649f8b207ab07caebba230d851b579a3c8711a851d29efe15008e31bb4de24", size = 5163515 }, - { url = "https://files.pythonhosted.org/packages/62/30/82116199d1c249446723c68f2c9da40d7f062551036f50b8c4caa42ae252/numpy-2.2.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:f34dc300df798742b3d06515aa2a0aee20941c13579d7a2f2e10af01ae4901ee", size = 6696558 }, - { url = "https://files.pythonhosted.org/packages/0e/b2/54122b3c6df5df3e87582b2e9430f1bdb63af4023c739ba300164c9ae503/numpy-2.2.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3f7ac96b16955634e223b579a3e5798df59007ca43e8d451a0e6a50f6bfdfba", size = 14084742 }, - { url = "https://files.pythonhosted.org/packages/02/e2/e2cbb8d634151aab9528ef7b8bab52ee4ab10e076509285602c2a3a686e0/numpy-2.2.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f92084defa704deadd4e0a5ab1dc52d8ac9e8a8ef617f3fbb853e79b0ea3592", size = 16134051 }, - { url = "https://files.pythonhosted.org/packages/8e/21/efd47800e4affc993e8be50c1b768de038363dd88865920439ef7b422c60/numpy-2.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4e84a6283b36632e2a5b56e121961f6542ab886bc9e12f8f9818b3c266bfbb", size = 15578972 }, - { url = "https://files.pythonhosted.org/packages/04/1e/f8bb88f6157045dd5d9b27ccf433d016981032690969aa5c19e332b138c0/numpy-2.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:11c43995255eb4127115956495f43e9343736edb7fcdb0d973defd9de14cd84f", size = 17898106 }, - { url = "https://files.pythonhosted.org/packages/2b/93/df59a5a3897c1f036ae8ff845e45f4081bb06943039ae28a3c1c7c780f22/numpy-2.2.4-cp312-cp312-win32.whl", hash = "sha256:65ef3468b53269eb5fdb3a5c09508c032b793da03251d5f8722b1194f1790c00", size = 6311190 }, - { url = "https://files.pythonhosted.org/packages/46/69/8c4f928741c2a8efa255fdc7e9097527c6dc4e4df147e3cadc5d9357ce85/numpy-2.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:2aad3c17ed2ff455b8eaafe06bcdae0062a1db77cb99f4b9cbb5f4ecb13c5146", size = 12644305 }, - { url = "https://files.pythonhosted.org/packages/2a/d0/bd5ad792e78017f5decfb2ecc947422a3669a34f775679a76317af671ffc/numpy-2.2.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cf4e5c6a278d620dee9ddeb487dc6a860f9b199eadeecc567f777daace1e9e7", size = 20933623 }, - { url = "https://files.pythonhosted.org/packages/c3/bc/2b3545766337b95409868f8e62053135bdc7fa2ce630aba983a2aa60b559/numpy-2.2.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1974afec0b479e50438fc3648974268f972e2d908ddb6d7fb634598cdb8260a0", size = 14148681 }, - { url = "https://files.pythonhosted.org/packages/6a/70/67b24d68a56551d43a6ec9fe8c5f91b526d4c1a46a6387b956bf2d64744e/numpy-2.2.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:79bd5f0a02aa16808fcbc79a9a376a147cc1045f7dfe44c6e7d53fa8b8a79392", size = 5148759 }, - { url = "https://files.pythonhosted.org/packages/1c/8b/e2fc8a75fcb7be12d90b31477c9356c0cbb44abce7ffb36be39a0017afad/numpy-2.2.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:3387dd7232804b341165cedcb90694565a6015433ee076c6754775e85d86f1fc", size = 6683092 }, - { url = "https://files.pythonhosted.org/packages/13/73/41b7b27f169ecf368b52533edb72e56a133f9e86256e809e169362553b49/numpy-2.2.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f527d8fdb0286fd2fd97a2a96c6be17ba4232da346931d967a0630050dfd298", size = 14081422 }, - { url = "https://files.pythonhosted.org/packages/4b/04/e208ff3ae3ddfbafc05910f89546382f15a3f10186b1f56bd99f159689c2/numpy-2.2.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bce43e386c16898b91e162e5baaad90c4b06f9dcbe36282490032cec98dc8ae7", size = 16132202 }, - { url = "https://files.pythonhosted.org/packages/fe/bc/2218160574d862d5e55f803d88ddcad88beff94791f9c5f86d67bd8fbf1c/numpy-2.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:31504f970f563d99f71a3512d0c01a645b692b12a63630d6aafa0939e52361e6", size = 15573131 }, - { url = "https://files.pythonhosted.org/packages/a5/78/97c775bc4f05abc8a8426436b7cb1be806a02a2994b195945600855e3a25/numpy-2.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:81413336ef121a6ba746892fad881a83351ee3e1e4011f52e97fba79233611fd", size = 17894270 }, - { url = "https://files.pythonhosted.org/packages/b9/eb/38c06217a5f6de27dcb41524ca95a44e395e6a1decdc0c99fec0832ce6ae/numpy-2.2.4-cp313-cp313-win32.whl", hash = "sha256:f486038e44caa08dbd97275a9a35a283a8f1d2f0ee60ac260a1790e76660833c", size = 6308141 }, - { url = "https://files.pythonhosted.org/packages/52/17/d0dd10ab6d125c6d11ffb6dfa3423c3571befab8358d4f85cd4471964fcd/numpy-2.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:207a2b8441cc8b6a2a78c9ddc64d00d20c303d79fba08c577752f080c4007ee3", size = 12636885 }, - { url = "https://files.pythonhosted.org/packages/fa/e2/793288ede17a0fdc921172916efb40f3cbc2aa97e76c5c84aba6dc7e8747/numpy-2.2.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8120575cb4882318c791f839a4fd66161a6fa46f3f0a5e613071aae35b5dd8f8", size = 20961829 }, - { url = "https://files.pythonhosted.org/packages/3a/75/bb4573f6c462afd1ea5cbedcc362fe3e9bdbcc57aefd37c681be1155fbaa/numpy-2.2.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a761ba0fa886a7bb33c6c8f6f20213735cb19642c580a931c625ee377ee8bd39", size = 14161419 }, - { url = "https://files.pythonhosted.org/packages/03/68/07b4cd01090ca46c7a336958b413cdbe75002286295f2addea767b7f16c9/numpy-2.2.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:ac0280f1ba4a4bfff363a99a6aceed4f8e123f8a9b234c89140f5e894e452ecd", size = 5196414 }, - { url = "https://files.pythonhosted.org/packages/a5/fd/d4a29478d622fedff5c4b4b4cedfc37a00691079623c0575978d2446db9e/numpy-2.2.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:879cf3a9a2b53a4672a168c21375166171bc3932b7e21f622201811c43cdd3b0", size = 6709379 }, - { url = "https://files.pythonhosted.org/packages/41/78/96dddb75bb9be730b87c72f30ffdd62611aba234e4e460576a068c98eff6/numpy-2.2.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f05d4198c1bacc9124018109c5fba2f3201dbe7ab6e92ff100494f236209c960", size = 14051725 }, - { url = "https://files.pythonhosted.org/packages/00/06/5306b8199bffac2a29d9119c11f457f6c7d41115a335b78d3f86fad4dbe8/numpy-2.2.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2f085ce2e813a50dfd0e01fbfc0c12bbe5d2063d99f8b29da30e544fb6483b8", size = 16101638 }, - { url = "https://files.pythonhosted.org/packages/fa/03/74c5b631ee1ded596945c12027649e6344614144369fd3ec1aaced782882/numpy-2.2.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:92bda934a791c01d6d9d8e038363c50918ef7c40601552a58ac84c9613a665bc", size = 15571717 }, - { url = "https://files.pythonhosted.org/packages/cb/dc/4fc7c0283abe0981e3b89f9b332a134e237dd476b0c018e1e21083310c31/numpy-2.2.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ee4d528022f4c5ff67332469e10efe06a267e32f4067dc76bb7e2cddf3cd25ff", size = 17879998 }, - { url = "https://files.pythonhosted.org/packages/e5/2b/878576190c5cfa29ed896b518cc516aecc7c98a919e20706c12480465f43/numpy-2.2.4-cp313-cp313t-win32.whl", hash = "sha256:05c076d531e9998e7e694c36e8b349969c56eadd2cdcd07242958489d79a7286", size = 6366896 }, - { url = "https://files.pythonhosted.org/packages/3e/05/eb7eec66b95cf697f08c754ef26c3549d03ebd682819f794cb039574a0a6/numpy-2.2.4-cp313-cp313t-win_amd64.whl", hash = "sha256:188dcbca89834cc2e14eb2f106c96d6d46f200fe0200310fc29089657379c58d", size = 12739119 }, +version = "1.26.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/57/baae43d14fe163fa0e4c47f307b6b2511ab8d7d30177c491960504252053/numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71", size = 20630554 }, + { url = "https://files.pythonhosted.org/packages/1a/2e/151484f49fd03944c4a3ad9c418ed193cfd02724e138ac8a9505d056c582/numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef", size = 13997127 }, + { url = "https://files.pythonhosted.org/packages/79/ae/7e5b85136806f9dadf4878bf73cf223fe5c2636818ba3ab1c585d0403164/numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e", size = 14222994 }, + { url = "https://files.pythonhosted.org/packages/3a/d0/edc009c27b406c4f9cbc79274d6e46d634d139075492ad055e3d68445925/numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5", size = 18252005 }, + { url = "https://files.pythonhosted.org/packages/09/bf/2b1aaf8f525f2923ff6cfcf134ae5e750e279ac65ebf386c75a0cf6da06a/numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a", size = 13885297 }, + { url = "https://files.pythonhosted.org/packages/df/a0/4e0f14d847cfc2a633a1c8621d00724f3206cfeddeb66d35698c4e2cf3d2/numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a", size = 18093567 }, + { url = "https://files.pythonhosted.org/packages/d2/b7/a734c733286e10a7f1a8ad1ae8c90f2d33bf604a96548e0a4a3a6739b468/numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20", size = 5968812 }, + { url = "https://files.pythonhosted.org/packages/3f/6b/5610004206cf7f8e7ad91c5a85a8c71b2f2f8051a0c0c4d5916b76d6cbb2/numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2", size = 15811913 }, + { url = "https://files.pythonhosted.org/packages/95/12/8f2020a8e8b8383ac0177dc9570aad031a3beb12e38847f7129bacd96228/numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218", size = 20335901 }, + { url = "https://files.pythonhosted.org/packages/75/5b/ca6c8bd14007e5ca171c7c03102d17b4f4e0ceb53957e8c44343a9546dcc/numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b", size = 13685868 }, + { url = "https://files.pythonhosted.org/packages/79/f8/97f10e6755e2a7d027ca783f63044d5b1bc1ae7acb12afe6a9b4286eac17/numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b", size = 13925109 }, + { url = "https://files.pythonhosted.org/packages/0f/50/de23fde84e45f5c4fda2488c759b69990fd4512387a8632860f3ac9cd225/numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed", size = 17950613 }, + { url = "https://files.pythonhosted.org/packages/4c/0c/9c603826b6465e82591e05ca230dfc13376da512b25ccd0894709b054ed0/numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a", size = 13572172 }, + { url = "https://files.pythonhosted.org/packages/76/8c/2ba3902e1a0fc1c74962ea9bb33a534bb05984ad7ff9515bf8d07527cadd/numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0", size = 17786643 }, + { url = "https://files.pythonhosted.org/packages/28/4a/46d9e65106879492374999e76eb85f87b15328e06bd1550668f79f7b18c6/numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110", size = 5677803 }, + { url = "https://files.pythonhosted.org/packages/16/2e/86f24451c2d530c88daf997cb8d6ac622c1d40d19f5a031ed68a4b73a374/numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", size = 15517754 }, ] [[package]] @@ -629,6 +727,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/94/1843518e420fa3ed6919835845df698c7e27e183cb997394e4a670973a65/omegaconf-2.3.0-py3-none-any.whl", hash = "sha256:7b4df175cdb08ba400f45cae3bdcae7ba8365db4d165fc65fd04b050ab63b46b", size = 79500 }, ] +[[package]] +name = "openai" +version = "1.68.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/6b/6b002d5d38794645437ae3ddb42083059d556558493408d39a0fcea608bc/openai-1.68.2.tar.gz", hash = "sha256:b720f0a95a1dbe1429c0d9bb62096a0d98057bcda82516f6e8af10284bdd5b19", size = 413429 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/34/cebce15f64eb4a3d609a83ac3568d43005cc9a1cba9d7fde5590fd415423/openai-1.68.2-py3-none-any.whl", hash = "sha256:24484cb5c9a33b58576fdc5acf0e5f92603024a4e39d0b99793dfa1eb14c2b36", size = 606073 }, +] + [[package]] name = "packaging" version = "24.2" @@ -658,7 +775,7 @@ wheels = [ [[package]] name = "py3-tts-wrapper" -version = "0.9.31" +version = "0.9.32" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, @@ -667,9 +784,9 @@ dependencies = [ { name = "sounddevice" }, { name = "soundfile" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4b/5e/b8c0fae8b1ad8f78f1c19a5b8dd2402f0a06e59445d8c12096529181a7f0/py3_tts_wrapper-0.9.31.tar.gz", hash = "sha256:460f49e67cdd550b0c76a16564c7a9085dea78d5f5c16a7903ab764f3e20548b", size = 6926961 } +sdist = { url = "https://files.pythonhosted.org/packages/02/df/2636a819a01b760e570c6d369e377aefa8396789c1b0f671079464970a1e/py3_tts_wrapper-0.9.32.tar.gz", hash = "sha256:3269e681e968cd2c1b7095850117efcca72e74e22fa78481615d7cf27c6c633b", size = 6929268 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/80/0aad4175c4c3c4ec4358b5663e939113ce1eb4bbd77949b52c9cd5868d97/py3_tts_wrapper-0.9.31-py3-none-any.whl", hash = "sha256:2d5983f665eb2c67ae317df06003f1719590a35f59f9100ad42b6de151d5bd88", size = 849938 }, + { url = "https://files.pythonhosted.org/packages/7d/22/bc37f982149adc8ebd58e9c0e58886182c10841ce5f5324ab7c0bc4edfad/py3_tts_wrapper-0.9.32-py3-none-any.whl", hash = "sha256:3bfb7c840fe1fcea1ff227fd39db0fe763261ee586a97694685adb27a1182998", size = 852082 }, ] [package.optional-dependencies] @@ -719,6 +836,73 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, ] +[[package]] +name = "pydantic" +version = "2.10.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696 }, +] + +[[package]] +name = "pydantic-core" +version = "2.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/89/f3450af9d09d44eea1f2c369f49e8f181d742f28220f88cc4dfaae91ea6e/pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", size = 1893421 }, + { url = "https://files.pythonhosted.org/packages/9e/e3/71fe85af2021f3f386da42d291412e5baf6ce7716bd7101ea49c810eda90/pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", size = 1814998 }, + { url = "https://files.pythonhosted.org/packages/a6/3c/724039e0d848fd69dbf5806894e26479577316c6f0f112bacaf67aa889ac/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", size = 1826167 }, + { url = "https://files.pythonhosted.org/packages/2b/5b/1b29e8c1fb5f3199a9a57c1452004ff39f494bbe9bdbe9a81e18172e40d3/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306", size = 1865071 }, + { url = "https://files.pythonhosted.org/packages/89/6c/3985203863d76bb7d7266e36970d7e3b6385148c18a68cc8915fd8c84d57/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99", size = 2036244 }, + { url = "https://files.pythonhosted.org/packages/0e/41/f15316858a246b5d723f7d7f599f79e37493b2e84bfc789e58d88c209f8a/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459", size = 2737470 }, + { url = "https://files.pythonhosted.org/packages/a8/7c/b860618c25678bbd6d1d99dbdfdf0510ccb50790099b963ff78a124b754f/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048", size = 1992291 }, + { url = "https://files.pythonhosted.org/packages/bf/73/42c3742a391eccbeab39f15213ecda3104ae8682ba3c0c28069fbcb8c10d/pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d", size = 1994613 }, + { url = "https://files.pythonhosted.org/packages/94/7a/941e89096d1175d56f59340f3a8ebaf20762fef222c298ea96d36a6328c5/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b", size = 2002355 }, + { url = "https://files.pythonhosted.org/packages/6e/95/2359937a73d49e336a5a19848713555605d4d8d6940c3ec6c6c0ca4dcf25/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474", size = 2126661 }, + { url = "https://files.pythonhosted.org/packages/2b/4c/ca02b7bdb6012a1adef21a50625b14f43ed4d11f1fc237f9d7490aa5078c/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6", size = 2153261 }, + { url = "https://files.pythonhosted.org/packages/72/9d/a241db83f973049a1092a079272ffe2e3e82e98561ef6214ab53fe53b1c7/pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", size = 1812361 }, + { url = "https://files.pythonhosted.org/packages/e8/ef/013f07248041b74abd48a385e2110aa3a9bbfef0fbd97d4e6d07d2f5b89a/pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", size = 1982484 }, + { url = "https://files.pythonhosted.org/packages/10/1c/16b3a3e3398fd29dca77cea0a1d998d6bde3902fa2706985191e2313cc76/pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", size = 1867102 }, + { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 }, + { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 }, + { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 }, + { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 }, + { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 }, + { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 }, + { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 }, + { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 }, + { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 }, + { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 }, + { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 }, + { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 }, + { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 }, + { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 }, + { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 }, + { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 }, + { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 }, + { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 }, + { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 }, + { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 }, + { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 }, + { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 }, + { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 }, + { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 }, + { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 }, + { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, + { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, + { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, +] + [[package]] name = "pyinstaller" version = "6.12.0" @@ -1045,6 +1229,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + [[package]] name = "sounddevice" version = "0.5.1" @@ -1118,6 +1311,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, ] +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "platform_system == 'Windows' and sys_platform != 'darwin' and sys_platform != 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540 }, +] + [[package]] name = "typing-extensions" version = "4.12.2" From cc9dcca54b5e0e183f634d65aaa9051e9767d734 Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 21 Mar 2025 17:35:30 +0000 Subject: [PATCH 12/37] fixed /speakdata endpoint - now all working. finally. --- speech/speech_manager.py | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/speech/speech_manager.py b/speech/speech_manager.py index 2dbd5cb..948a7f1 100644 --- a/speech/speech_manager.py +++ b/speech/speech_manager.py @@ -54,20 +54,29 @@ def speak(self, text: str, voice_id: str) -> None: def get_speak_data(self, text: str, voice_id: str) -> bytes: """Get WAV audio data for text.""" try: - # Get raw PCM audio data as bytes - audio_data = self.tts.synth_to_bytes(text, voice_id=voice_id) + import tempfile + import os + + # Create a temporary WAV file + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as temp_file: + temp_path = temp_file.name - # Try to get word timings if available try: - if hasattr(self.tts, "get_word_timings"): - self.timings = self.tts.get_word_timings() - elif hasattr(self.tts, "word_timings"): - self.timings = self.tts.word_timings - except Exception as e: - self.logger.debug(f"Could not get word timings: {e}") - self.timings = [] + # Synthesize to WAV file + self.tts.synth_to_file(text, temp_path, voice_id=voice_id) + + # Read the WAV file + with open(temp_path, "rb") as f: + wav_data = f.read() + + return wav_data + finally: + # Clean up temp file + try: + os.unlink(temp_path) + except Exception as e: + self.logger.debug(f"Error cleaning up temp file: {e}") - return audio_data except Exception as e: self.logger.error(f"Error getting speech data: {e}") return b"" From 173db94108dca52bcaa654c41e6573ad95e0e11e Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 21 Mar 2025 17:36:52 +0000 Subject: [PATCH 13/37] false speech.ini --- speech.ini | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 speech.ini diff --git a/speech.ini b/speech.ini new file mode 100644 index 0000000..2ba028b --- /dev/null +++ b/speech.ini @@ -0,0 +1,38 @@ +[General] +engines = sherpaonnx +cache_enabled = True +cache_dir = temp + +[microsoft] +subscription_key = +subscription_region = + +[elevenlabs] +api_key = + +[polly] +aws_key_id = +aws_secret_access_key = +aws_region = + +[witai] +token = + +[sherpaonnx] + +[googletrans] +voice_id = + +[google] + +[espeak] + +[playht] +api_key = +user_id = + +[openai] +api_key = +model = gpt-4o-mini-tts +output_format = wav + From b790c7c5b935e6520e0fae2bf3d78af7589ad2e4 Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 21 Mar 2025 17:39:36 +0000 Subject: [PATCH 14/37] add workflow --- .github/workflows/build.yml | 93 +++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..7d05b34 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,93 @@ +name: Build and Release + +on: + push: + tags: + - 'v*' # Trigger on version tags + +jobs: + build: + name: Build on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ['3.10'] + include: + - os: ubuntu-latest + asset_name: asterics-grid-speech-linux + - os: windows-latest + asset_name: asterics-grid-speech-windows + - os: macos-latest + asset_name: asterics-grid-speech-macos + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install uv + run: | + python -m pip install uv + + - name: Install dependencies + run: | + uv pip install -e ".[dev]" + + - name: Install system dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y portaudio19-dev espeak-ng + + - name: Install system dependencies (macOS) + if: runner.os == 'macOS' + run: | + brew install portaudio espeak-ng + + - name: Build executable + run: | + uv run python build.py + + - name: Compress artifacts (Unix) + if: runner.os != 'Windows' + run: | + cd dist + tar -czf ${{ matrix.asset_name }}.tar.gz asterics-grid-speech/ + + - name: Compress artifacts (Windows) + if: runner.os == 'Windows' + run: | + cd dist + 7z a ${{ matrix.asset_name }}.zip asterics-grid-speech/ + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.asset_name }} + path: | + dist/${{ matrix.asset_name }}.tar.gz + dist/${{ matrix.asset_name }}.zip + if-no-files-found: error + + release: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: | + asterics-grid-speech-linux/asterics-grid-speech-linux.tar.gz + asterics-grid-speech-windows/asterics-grid-speech-windows.zip + asterics-grid-speech-macos/asterics-grid-speech-macos.tar.gz + draft: false + prerelease: false + generate_release_notes: true \ No newline at end of file From d930523678405e606c7baab5afea9ae157565865 Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 21 Mar 2025 22:10:27 +0000 Subject: [PATCH 15/37] fix build script --- asterics-grid-speech.spec | 1 + speech/start.py | 23 ++++++++++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/asterics-grid-speech.spec b/asterics-grid-speech.spec index 5ed3d7d..3f4ecd3 100644 --- a/asterics-grid-speech.spec +++ b/asterics-grid-speech.spec @@ -21,6 +21,7 @@ a = Analysis( ('speech/config.py', 'speech'), ('speech/speech_manager.py', 'speech'), ('speech/__init__.py', 'speech'), + ('speech/templates', 'speech/templates'), # Add templates directory (os.path.join(site_packages, 'tts_wrapper'), 'tts_wrapper'), # Include tts_wrapper from site-packages ], hiddenimports=[ diff --git a/speech/start.py b/speech/start.py index 51b76db..c9ce6c1 100644 --- a/speech/start.py +++ b/speech/start.py @@ -14,9 +14,17 @@ if getattr(sys, "frozen", False): # we are running in a bundle bundle_dir = sys._MEIPASS + # Set template folder for frozen app + app = Flask( + __name__, template_folder=os.path.join(bundle_dir, "speech", "templates") + ) else: # we are running in a normal Python environment bundle_dir = os.path.dirname(os.path.abspath(__file__)) + app = Flask(__name__) + +app.url_map.strict_slashes = False +CORS(app) sys.path.append(os.path.dirname(bundle_dir)) @@ -49,10 +57,6 @@ # HTTP status codes HTTP_NOT_FOUND = 404 -app = Flask(__name__) -app.url_map.strict_slashes = False -CORS(app) - # Create configuration manager instance config_manager = ConfigManager() @@ -85,10 +89,19 @@ ) +# Root route for web interface @app.route("/") def index(): """Main configuration page for the speech service.""" - return config() + return render_template( + "config.html", + config=config_manager.config, + available_engines=config_manager.get_available_engines(), + enabled_engines=config_manager.get_enabled_engines(), + validation_errors={}, + success_message=None, + error_message=None, + ) @app.route("/config", methods=["GET", "POST"]) From cdff22ad848b704e662d47b504407c259f67c4ca Mon Sep 17 00:00:00 2001 From: will wade Date: Sat, 22 Mar 2025 00:49:54 +0000 Subject: [PATCH 16/37] fixes for voice endpoint and our test page --- speech/speech_manager.py | 60 +++++++++---- speech/start.py | 23 ++++- speech/templates/test.html | 171 ++++++++++++++++++++----------------- 3 files changed, 159 insertions(+), 95 deletions(-) diff --git a/speech/speech_manager.py b/speech/speech_manager.py index 948a7f1..310549e 100644 --- a/speech/speech_manager.py +++ b/speech/speech_manager.py @@ -41,7 +41,21 @@ def __init__(self): def get_voices(self) -> list[dict[str, Any]]: """Get available voices.""" - return self.tts.get_voices() + voices = self.tts.get_voices() + cleaned_voices = [] + for voice in voices: + language_codes = voice.get("language_codes", [""]) + language = language_codes[0] if language_codes else "" + cleaned_voices.append( + { + "id": voice.get("id", ""), + "name": voice.get("name", ""), + "language": language, + "language_codes": voice.get("language_codes", []), + "gender": voice.get("gender", "Unknown"), + } + ) + return cleaned_voices def speak(self, text: str, voice_id: str) -> None: """Speak text using specified voice.""" @@ -54,8 +68,8 @@ def speak(self, text: str, voice_id: str) -> None: def get_speak_data(self, text: str, voice_id: str) -> bytes: """Get WAV audio data for text.""" try: - import tempfile import os + import tempfile # Create a temporary WAV file with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as temp_file: @@ -114,11 +128,13 @@ def init_providers(self, config: dict[str, Any]) -> None: for engine in engines: provider = None try: + self.logger.debug(f"Initializing provider for engine: {engine}") if engine == "sherpaonnx": # Initialize Sherpa-ONNX provider engine_config = config.get("engine_configs", {}).get( "sherpaonnx", {} ) + self.logger.debug(f"Sherpa config: {engine_config}") client = SherpaOnnxClient( model_path=engine_config.get("model_path"), tokens_path=engine_config.get("tokens_path"), @@ -131,6 +147,7 @@ def init_providers(self, config: dict[str, Any]) -> None: engine_config = config.get("engine_configs", {}).get( "microsoft", {} ) + self.logger.debug(f"Microsoft config: {engine_config}") credentials = engine_config.get("credentials", ("", "")) client = MicrosoftClient(credentials=credentials) provider = TTSProvider() @@ -152,6 +169,7 @@ def init_providers(self, config: dict[str, Any]) -> None: engine_config = config.get("engine_configs", {}).get( "googletrans", {} ) + self.logger.debug(f"Google Translate config: {engine_config}") client = GoogleTransClient() provider = TTSProvider() provider.tts = GoogleTransTTS(client) @@ -209,27 +227,26 @@ def init_providers(self, config: dict[str, Any]) -> None: # Initialize OpenAI provider engine_config = config.get("engine_configs", {}).get("openai", {}) provider = OpenAITTSProvider(engine_config) # type: ignore - if provider: - self.providers[engine] = provider - if not self.current_provider: - self.current_provider = provider - self.logger.info( - f"Current provider: {provider.__class__.__name__}" - ) + # Store provider if successfully initialized if provider: + self.logger.debug(f"Successfully initialized provider for {engine}") self.providers[engine] = provider if not self.current_provider: self.current_provider = provider self.logger.info( f"Current provider: {provider.__class__.__name__}" ) + else: + self.logger.warning(f"Failed to initialize provider for {engine}") except Exception as e: self.logger.error(f"Failed to initialize {engine} provider: {e}") if not self.providers: self.logger.warning("No TTS providers were successfully initialized") + else: + self.logger.debug(f"Initialized providers: {list(self.providers.keys())}") def get_voices(self) -> list[dict[str, Any]]: """Get available voices from all providers.""" @@ -238,20 +255,33 @@ def get_voices(self) -> list[dict[str, Any]]: try: self.logger.info(f"Getting voices from provider: {provider_id}") provider_voices = provider.get_voices() - self.logger.info( - f"Found {len(provider_voices)} voices from {provider_id}" + self.logger.debug( + f"Voices from provider {provider_id}: {provider_voices}" ) - # Add provider ID to each voice + # Add provider ID and type to each voice for voice in provider_voices: + # Format name as "Name (Language) - provider" + base_name = voice["name"] + language = voice.get("language", "") + formatted_name = ( + f"{base_name} ({language}) - {provider_id}" + if language + else f"{base_name} - {provider_id}" + ) + self.logger.debug( + f"Formatting voice name: base={base_name}, lang={language}, result={formatted_name}" + ) + + voice["name"] = formatted_name voice["providerId"] = provider_id + voice["type"] = "external_playing" + self.logger.debug(f"Final voice entry: {voice}") all_voices.extend(provider_voices) except Exception as e: self.logger.error(f"Error getting voices from {provider_id}: {e}") + continue - self.logger.info( - f"Found {len(all_voices)} voices across {len(self.providers)} providers" - ) return all_voices def speak(self, text: str, voice_id: str, provider_id: str | None = None) -> None: diff --git a/speech/start.py b/speech/start.py index c9ce6c1..d062801 100644 --- a/speech/start.py +++ b/speech/start.py @@ -21,7 +21,7 @@ else: # we are running in a normal Python environment bundle_dir = os.path.dirname(os.path.abspath(__file__)) - app = Flask(__name__) +app = Flask(__name__) app.url_map.strict_slashes = False CORS(app) @@ -286,7 +286,26 @@ def get(self): """Get available voices from all providers.""" try: voices = get_voices(speech_manager) - return {"voices": voices, "status": "success"} + # Transform to match original structure while preserving all fields + transformed_voices = [] + for voice in voices: + transformed_voice = { + "id": voice["id"], + "name": voice[ + "name" + ], # Name is already formatted correctly by SpeechManager + "language": voice.get("language", ""), # Preserve language field + "language_codes": voice.get( + "language_codes", [] + ), # Preserve language codes + "gender": voice.get("gender", "Unknown"), # Preserve gender + "providerId": voice[ + "providerId" + ], # Provider ID is set by SpeechManager + "type": voice.get("type", "external_playing"), + } + transformed_voices.append(transformed_voice) + return transformed_voices except Exception as e: logger.error(f"Error in /voices endpoint: {e!s}", exc_info=True) return {"error": str(e), "status": "error", "voices": []}, 200 diff --git a/speech/templates/test.html b/speech/templates/test.html index 6e38ff9..093ca77 100644 --- a/speech/templates/test.html +++ b/speech/templates/test.html @@ -54,6 +54,21 @@ color: #6c757d; margin-top: 0.5rem; } + .voice-details { + color: #666; + margin-top: 8px; + } + .voice-card { + cursor: pointer; + transition: all 0.2s ease; + } + .voice-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0,0,0,0.1); + } + .voice-card.border-primary { + background-color: #f8f9ff; + } @@ -87,7 +102,16 @@
Available Voices
- +
+ + +
@@ -165,80 +189,12 @@
Test Text-to-Speech
let speakingCheckInterval = null; let allVoices = []; // Store all voices for filtering - // Filter voices based on search input - function filterVoices(searchTerm) { - const voiceList = document.getElementById('voiceList'); - const normalizedSearch = searchTerm.toLowerCase().trim(); - - if (!normalizedSearch) { - // Show all voices if search is empty - displayVoices(allVoices); - return; - } - - const filteredVoices = allVoices.filter(voice => { - return voice.name.toLowerCase().includes(normalizedSearch) || - (voice.language_codes && voice.language_codes.some(lang => lang.toLowerCase().includes(normalizedSearch))) || - voice.providerId.toLowerCase().includes(normalizedSearch) || - voice.gender.toLowerCase().includes(normalizedSearch); - }); - - displayVoices(filteredVoices); - } - - // Display voices in the list - function displayVoices(voices) { - const voiceList = document.getElementById('voiceList'); - - if (voices.length === 0) { - voiceList.innerHTML = ` -
- No voices match your search. Try different keywords. -
- `; - return; - } - - voiceList.innerHTML = ''; - voices.forEach(voice => { - const card = document.createElement('div'); - card.className = 'card voice-card'; - card.innerHTML = ` -
-
- - -
-
- `; - voiceList.appendChild(card); - - // Add click handler - card.querySelector('input[type="radio"]').addEventListener('change', (e) => { - selectedVoiceId = e.target.value; - document.getElementById('selectedVoice').textContent = `${voice.name} (${voice.providerId})`; - }); - }); - } - // Load available voices async function loadVoices() { try { const response = await fetch('/api/voices'); - const data = await response.json(); - - if (data.status === 'error') { - throw new Error(data.error || 'Failed to load voices'); - } + const voices = await response.json(); - const voices = data.voices; if (!voices || !Array.isArray(voices)) { throw new Error('Invalid voice data received'); } @@ -267,14 +223,53 @@
Test Text-to-Speech
} } - // Add search input handler with debounce - let searchTimeout; - document.getElementById('voiceSearch').addEventListener('input', (e) => { - clearTimeout(searchTimeout); - searchTimeout = setTimeout(() => { - filterVoices(e.target.value); - }, 300); // Wait 300ms after user stops typing - }); + // Display voices in the list + function displayVoices(voices) { + const voiceList = document.getElementById('voiceList'); + voiceList.innerHTML = voices.map(voice => ` +
+
+
${voice.name}
+
+
Provider: ${voice.providerId}
+
Language: ${voice.language || 'N/A'}
+
Gender: ${voice.gender || 'N/A'}
+ ${voice.language_codes && voice.language_codes.length > 0 ? + `
Language Codes: ${voice.language_codes.join(', ')}
` : ''} +
+
+
+ `).join(''); + } + + // Filter voices based on search input + function filterVoices() { + const searchText = document.getElementById('voiceSearch').value.toLowerCase(); + const searchField = document.getElementById('searchField').value; + + const filteredVoices = allVoices.filter(voice => { + if (searchField === 'all') { + return ( + voice.name.toLowerCase().includes(searchText) || + (voice.language && voice.language.toLowerCase().includes(searchText)) || + voice.providerId.toLowerCase().includes(searchText) || + (voice.gender && voice.gender.toLowerCase().includes(searchText)) || + (voice.language_codes && voice.language_codes.some(code => code.toLowerCase().includes(searchText))) + ); + } + + if (searchField === 'language' && voice.language_codes) { + return voice.language_codes.some(code => code.toLowerCase().includes(searchText)) || + (voice.language && voice.language.toLowerCase().includes(searchText)); + } + + return voice[searchField] && voice[searchField].toLowerCase().includes(searchText); + }); + + displayVoices(filteredVoices); + } // Update endpoint info when selection changes document.querySelectorAll('input[name="endpoint"]').forEach(radio => { @@ -381,6 +376,26 @@
Test Text-to-Speech
// Initialize loadVoices(); + + // Update the search functionality + document.getElementById('voiceSearch').addEventListener('input', filterVoices); + document.getElementById('searchField').addEventListener('change', filterVoices); + + // Handle voice selection + function selectVoice(voiceId) { + selectedVoiceId = voiceId; + const selectedVoice = allVoices.find(v => v.id === voiceId); + document.getElementById('selectedVoice').textContent = selectedVoice.name; + + // Update visual selection state + document.querySelectorAll('.voice-card').forEach(card => { + if (card.dataset.voiceId === voiceId) { + card.classList.add('border-primary'); + } else { + card.classList.remove('border-primary'); + } + }); + } \ No newline at end of file From 8c4cbe1a78ed5933364cd267a97b99dca2b18ab4 Mon Sep 17 00:00:00 2001 From: will wade Date: Sat, 22 Mar 2025 00:59:15 +0000 Subject: [PATCH 17/37] tweaks for gh action build --- .github/workflows/build.yml | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7d05b34..37394ec 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,6 +2,8 @@ name: Build and Release on: push: + branches: + - '*' # Run on all branches tags: - 'v*' # Trigger on version tags @@ -24,29 +26,15 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Install uv - run: | - python -m pip install uv - + uses: astral-sh/setup-uv@v5 + + - name: Set up Python + run: uv python install + - name: Install dependencies run: | - uv pip install -e ".[dev]" - - - name: Install system dependencies (Linux) - if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install -y portaudio19-dev espeak-ng - - - name: Install system dependencies (macOS) - if: runner.os == 'macOS' - run: | - brew install portaudio espeak-ng + uv sync - name: Build executable run: | From f9ea730d6166fc7a4a90b48f0c7eaebdb9c2cb9b Mon Sep 17 00:00:00 2001 From: will wade Date: Sat, 22 Mar 2025 01:01:27 +0000 Subject: [PATCH 18/37] add back in portaudio --- .github/workflows/build.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 37394ec..0ca7aa2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,7 +25,18 @@ jobs: steps: - uses: actions/checkout@v4 - + + - name: Install system dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y portaudio19-dev espeak-ng + + - name: Install system dependencies (macOS) + if: runner.os == 'macOS' + run: | + brew install portaudio espeak-ng + - name: Install uv uses: astral-sh/setup-uv@v5 From 35bd0b8cc23c827bd75b89729a666b48b17ab880 Mon Sep 17 00:00:00 2001 From: will wade Date: Sat, 22 Mar 2025 01:04:00 +0000 Subject: [PATCH 19/37] cheat. add pyinstaller as its not in pyproject should really be adding this in under dev but im lazy --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0ca7aa2..e1f5410 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -46,6 +46,7 @@ jobs: - name: Install dependencies run: | uv sync + uv pip install pyinstaller - name: Build executable run: | From 0041dad93ad9d3dd59e4868ef357230fe021b896 Mon Sep 17 00:00:00 2001 From: will wade Date: Sat, 22 Mar 2025 01:13:05 +0000 Subject: [PATCH 20/37] numpy build probs on win --- .github/workflows/build.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e1f5410..4dfbb4d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -46,6 +46,9 @@ jobs: - name: Install dependencies run: | uv sync + if [ "$RUNNER_OS" == "Windows" ]; then + uv pip install numpy==1.24.3 + fi uv pip install pyinstaller - name: Build executable From abdbc7c7eeff39f3db0b60e776015cb4075753c5 Mon Sep 17 00:00:00 2001 From: will wade Date: Sat, 22 Mar 2025 01:15:19 +0000 Subject: [PATCH 21/37] diif if syntax --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4dfbb4d..e162c8e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -46,9 +46,9 @@ jobs: - name: Install dependencies run: | uv sync - if [ "$RUNNER_OS" == "Windows" ]; then + if ($env:RUNNER_OS -eq "Windows") { uv pip install numpy==1.24.3 - fi + } uv pip install pyinstaller - name: Build executable From 114652eb562dd7fa00e9ea394ec1295005a36885 Mon Sep 17 00:00:00 2001 From: will wade Date: Sat, 22 Mar 2025 01:17:44 +0000 Subject: [PATCH 22/37] oops. idiot. --- .github/workflows/build.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e162c8e..f0ab667 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -46,10 +46,12 @@ jobs: - name: Install dependencies run: | uv sync - if ($env:RUNNER_OS -eq "Windows") { - uv pip install numpy==1.24.3 - } uv pip install pyinstaller + + - name: Install Windows-specific dependencies + if: runner.os == 'Windows' + run: | + uv pip install numpy==1.24.3 - name: Build executable run: | From d31290ce750b504bba92c5dbf72ac2eabb52d735 Mon Sep 17 00:00:00 2001 From: will wade Date: Sat, 22 Mar 2025 01:28:19 +0000 Subject: [PATCH 23/37] upping numpy on win --- .github/workflows/build.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f0ab667..6714606 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -41,7 +41,9 @@ jobs: uses: astral-sh/setup-uv@v5 - name: Set up Python - run: uv python install + run: | + uv python install + uv venv - name: Install dependencies run: | @@ -51,7 +53,7 @@ jobs: - name: Install Windows-specific dependencies if: runner.os == 'Windows' run: | - uv pip install numpy==1.24.3 + uv pip install numpy==1.26.4 - name: Build executable run: | From d66ef9fbd6115b2420e2290cfa9539bd941d015b Mon Sep 17 00:00:00 2001 From: will wade Date: Sat, 22 Mar 2025 01:34:20 +0000 Subject: [PATCH 24/37] see if this fixes winodws --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6714606..87b6d58 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,7 +42,7 @@ jobs: - name: Set up Python run: | - uv python install + uv python install --python-version 3.10 uv venv - name: Install dependencies From 99c51944fddd7527b6cc4a4c3f386c3efa0ed58b Mon Sep 17 00:00:00 2001 From: will wade Date: Sat, 22 Mar 2025 01:37:19 +0000 Subject: [PATCH 25/37] maybe better syntax --- .github/workflows/build.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 87b6d58..8b9ed39 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -39,11 +39,8 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v5 - - - name: Set up Python - run: | - uv python install --python-version 3.10 - uv venv + with: + python-version: 3.10 - name: Install dependencies run: | From d296fa4a5fa422f9ffcf932a2d859fbbcd708da3 Mon Sep 17 00:00:00 2001 From: will wade Date: Sat, 22 Mar 2025 01:39:22 +0000 Subject: [PATCH 26/37] quotes? --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8b9ed39..88b78e8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -40,7 +40,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v5 with: - python-version: 3.10 + python-version: "3.10" - name: Install dependencies run: | From 14405a94c36709dbc1ca68f0a4dae7c904546815 Mon Sep 17 00:00:00 2001 From: will wade Date: Sat, 22 Mar 2025 01:41:25 +0000 Subject: [PATCH 27/37] fix version in pyproject --- pyproject.toml | 2 +- uv.lock | 156 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 156 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 052ebae..228f09a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ "sounddevice>=0.4.6", "soundfile>=0.12.1" ] -requires-python = ">=3.11" +requires-python = ">=3.10" readme = "README.md" license = { file = "LICENSE" } diff --git a/uv.lock b/uv.lock index e53b0c6..c11bb84 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -requires-python = ">=3.11" +requires-python = ">=3.10" resolution-markers = [ "sys_platform == 'darwin'", "sys_platform == 'win32'", @@ -45,6 +45,7 @@ name = "anyio" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, @@ -184,6 +185,18 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } wheels = [ + { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191 }, + { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592 }, + { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024 }, + { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188 }, + { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571 }, + { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687 }, + { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211 }, + { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325 }, + { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784 }, + { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564 }, + { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804 }, + { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299 }, { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264 }, { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651 }, { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 }, @@ -226,6 +239,19 @@ version = "3.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/58/5580c1716040bc89206c77d8f74418caf82ce519aae06450393ca73475d1/charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de", size = 198013 }, + { url = "https://files.pythonhosted.org/packages/d0/11/00341177ae71c6f5159a08168bcb98c6e6d196d372c94511f9f6c9afe0c6/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176", size = 141285 }, + { url = "https://files.pythonhosted.org/packages/01/09/11d684ea5819e5a8f5100fb0b38cf8d02b514746607934134d31233e02c8/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037", size = 151449 }, + { url = "https://files.pythonhosted.org/packages/08/06/9f5a12939db324d905dc1f70591ae7d7898d030d7662f0d426e2286f68c9/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f", size = 143892 }, + { url = "https://files.pythonhosted.org/packages/93/62/5e89cdfe04584cb7f4d36003ffa2936681b03ecc0754f8e969c2becb7e24/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a", size = 146123 }, + { url = "https://files.pythonhosted.org/packages/a9/ac/ab729a15c516da2ab70a05f8722ecfccc3f04ed7a18e45c75bbbaa347d61/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a", size = 147943 }, + { url = "https://files.pythonhosted.org/packages/03/d2/3f392f23f042615689456e9a274640c1d2e5dd1d52de36ab8f7955f8f050/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247", size = 142063 }, + { url = "https://files.pythonhosted.org/packages/f2/e3/e20aae5e1039a2cd9b08d9205f52142329f887f8cf70da3650326670bddf/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408", size = 150578 }, + { url = "https://files.pythonhosted.org/packages/8d/af/779ad72a4da0aed925e1139d458adc486e61076d7ecdcc09e610ea8678db/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb", size = 153629 }, + { url = "https://files.pythonhosted.org/packages/c2/b6/7aa450b278e7aa92cf7732140bfd8be21f5f29d5bf334ae987c945276639/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d", size = 150778 }, + { url = "https://files.pythonhosted.org/packages/39/f4/d9f4f712d0951dcbfd42920d3db81b00dd23b6ab520419626f4023334056/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807", size = 146453 }, + { url = "https://files.pythonhosted.org/packages/49/2b/999d0314e4ee0cff3cb83e6bc9aeddd397eeed693edb4facb901eb8fbb69/charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f", size = 95479 }, + { url = "https://files.pythonhosted.org/packages/2d/ce/3cbed41cff67e455a386fb5e5dd8906cdda2ed92fbc6297921f2e4419309/charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f", size = 102790 }, { url = "https://files.pythonhosted.org/packages/72/80/41ef5d5a7935d2d3a773e3eaebf0a9350542f2cab4eac59a7a4741fbbbbe/charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", size = 194995 }, { url = "https://files.pythonhosted.org/packages/7a/28/0b9fefa7b8b080ec492110af6d88aa3dea91c464b17d53474b6e9ba5d2c5/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", size = 139471 }, { url = "https://files.pythonhosted.org/packages/71/64/d24ab1a997efb06402e3fc07317e94da358e2585165930d9d59ad45fcae2/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", size = 149831 }, @@ -304,6 +330,16 @@ version = "7.7.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/02/36/465f5492443265e1278f9a82ffe6aeed3f1db779da0d6e7d4611a5cfb6af/coverage-7.7.0.tar.gz", hash = "sha256:cd879d4646055a573775a1cec863d00c9ff8c55860f8b17f6d8eee9140c06166", size = 809969 } wheels = [ + { url = "https://files.pythonhosted.org/packages/10/f5/2b801fe88f199707cf9ec66dcee036e7073b5a208a4a161b64371b3f1e35/coverage-7.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a538a23119d1e2e2ce077e902d02ea3d8e0641786ef6e0faf11ce82324743944", size = 210608 }, + { url = "https://files.pythonhosted.org/packages/07/44/bcc030cf977d1069a28742c0a67284c6e831ef172f914055b3d45da83f89/coverage-7.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1586ad158523f4133499a4f322b230e2cfef9cc724820dbd58595a5a236186f4", size = 211042 }, + { url = "https://files.pythonhosted.org/packages/2c/3f/b427f17e1bcf3e1f5ac42fc0b6cb623616f2aedcfc7fde17a058afb62568/coverage-7.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b6c96d69928a3a6767fab8dc1ce8a02cf0156836ccb1e820c7f45a423570d98", size = 240168 }, + { url = "https://files.pythonhosted.org/packages/58/92/6e8d71c5e651f152ffc518ec4cd7add87035533e88af29e233635c0f0dfb/coverage-7.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f18d47641282664276977c604b5a261e51fefc2980f5271d547d706b06a837f", size = 238079 }, + { url = "https://files.pythonhosted.org/packages/40/33/1c25ae35c16972dc379c24cd7dde20359d076dee00687825c92a53e43b02/coverage-7.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2a1e18a85bd066c7c556d85277a7adf4651f259b2579113844835ba1a74aafd", size = 239216 }, + { url = "https://files.pythonhosted.org/packages/4d/3d/adf40bdd07a49e1880632c1bc6b31f42d32cf0bfe6b4d294a8f706d70078/coverage-7.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:70f0925c4e2bfc965369f417e7cc72538fd1ba91639cf1e4ef4b1a6b50439b3b", size = 239126 }, + { url = "https://files.pythonhosted.org/packages/72/a5/51e39811cd0ec0569a25fe8e6bac0a00efa222a3e49d51d64f5ba0dce24a/coverage-7.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b0fac2088ec4aaeb5468b814bd3ff5e5978364bfbce5e567c44c9e2854469f6c", size = 237842 }, + { url = "https://files.pythonhosted.org/packages/ab/b7/c5796906cd9eed6d258138f1fddc8d6af01b6d07b3c183bac4a9731ac383/coverage-7.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3e212a894d8ae07fde2ca8b43d666a6d49bbbddb10da0f6a74ca7bd31f20054", size = 238136 }, + { url = "https://files.pythonhosted.org/packages/d7/8a/bd34ea3c602b3ef323a001d375f9b1d663e901079bb26b5f9b8f96fae32b/coverage-7.7.0-cp310-cp310-win32.whl", hash = "sha256:f32b165bf6dfea0846a9c9c38b7e1d68f313956d60a15cde5d1709fddcaf3bee", size = 213320 }, + { url = "https://files.pythonhosted.org/packages/94/60/6e7efe849e305a233623a80aaeba7ebb02809fa63ab8a1e49c4323b8083b/coverage-7.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:a2454b12a3f12cc4698f3508912e6225ec63682e2ca5a96f80a2b93cef9e63f3", size = 214219 }, { url = "https://files.pythonhosted.org/packages/e8/ec/9e0c9358a3bd56b1ddbf266b889ea9d51ee29e58fb72712d5600663fa806/coverage-7.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a0a207c87a9f743c8072d059b4711f8d13c456eb42dac778a7d2e5d4f3c253a7", size = 210722 }, { url = "https://files.pythonhosted.org/packages/be/bd/7b47a4302423a13960ee30682900d7ca20cee15c978b1d9ea9594d59d352/coverage-7.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2d673e3add00048215c2cc507f1228a7523fd8bf34f279ac98334c9b07bd2656", size = 211154 }, { url = "https://files.pythonhosted.org/packages/c6/7c/ae54d9022440196bf9f3fad535388678a3db186980ff58a4956ddeb849a2/coverage-7.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f81fe93dc1b8e5673f33443c0786c14b77e36f1025973b85e07c70353e46882b", size = 243787 }, @@ -362,6 +398,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 }, ] +[[package]] +name = "exceptiongroup" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, +] + [[package]] name = "flask" version = "3.1.0" @@ -553,6 +598,18 @@ version = "0.9.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1e/c2/e4562507f52f0af7036da125bb699602ead37a2332af0788f8e0a3417f36/jiter-0.9.0.tar.gz", hash = "sha256:aadba0964deb424daa24492abc3d229c60c4a31bfee205aedbf1acc7639d7893", size = 162604 } wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/82/39f7c9e67b3b0121f02a0b90d433626caa95a565c3d2449fea6bcfa3f5f5/jiter-0.9.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:816ec9b60fdfd1fec87da1d7ed46c66c44ffec37ab2ef7de5b147b2fce3fd5ad", size = 314540 }, + { url = "https://files.pythonhosted.org/packages/01/07/7bf6022c5a152fca767cf5c086bb41f7c28f70cf33ad259d023b53c0b858/jiter-0.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9b1d3086f8a3ee0194ecf2008cf81286a5c3e540d977fa038ff23576c023c0ea", size = 321065 }, + { url = "https://files.pythonhosted.org/packages/6c/b2/de3f3446ecba7c48f317568e111cc112613da36c7b29a6de45a1df365556/jiter-0.9.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1339f839b91ae30b37c409bf16ccd3dc453e8b8c3ed4bd1d6a567193651a4a51", size = 341664 }, + { url = "https://files.pythonhosted.org/packages/13/cf/6485a4012af5d407689c91296105fcdb080a3538e0658d2abf679619c72f/jiter-0.9.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ffba79584b3b670fefae66ceb3a28822365d25b7bf811e030609a3d5b876f538", size = 364635 }, + { url = "https://files.pythonhosted.org/packages/0d/f7/4a491c568f005553240b486f8e05c82547340572d5018ef79414b4449327/jiter-0.9.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cfc7d0a8e899089d11f065e289cb5b2daf3d82fbe028f49b20d7b809193958d", size = 406288 }, + { url = "https://files.pythonhosted.org/packages/d3/ca/f4263ecbce7f5e6bded8f52a9f1a66540b270c300b5c9f5353d163f9ac61/jiter-0.9.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e00a1a2bbfaaf237e13c3d1592356eab3e9015d7efd59359ac8b51eb56390a12", size = 397499 }, + { url = "https://files.pythonhosted.org/packages/ac/a2/522039e522a10bac2f2194f50e183a49a360d5f63ebf46f6d890ef8aa3f9/jiter-0.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1d9870561eb26b11448854dce0ff27a9a27cb616b632468cafc938de25e9e51", size = 352926 }, + { url = "https://files.pythonhosted.org/packages/b1/67/306a5c5abc82f2e32bd47333a1c9799499c1c3a415f8dde19dbf876f00cb/jiter-0.9.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9872aeff3f21e437651df378cb75aeb7043e5297261222b6441a620218b58708", size = 384506 }, + { url = "https://files.pythonhosted.org/packages/0f/89/c12fe7b65a4fb74f6c0d7b5119576f1f16c79fc2953641f31b288fad8a04/jiter-0.9.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:1fd19112d1049bdd47f17bfbb44a2c0001061312dcf0e72765bfa8abd4aa30e5", size = 520621 }, + { url = "https://files.pythonhosted.org/packages/c4/2b/d57900c5c06e6273fbaa76a19efa74dbc6e70c7427ab421bf0095dfe5d4a/jiter-0.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6ef5da104664e526836070e4a23b5f68dec1cc673b60bf1edb1bfbe8a55d0678", size = 512613 }, + { url = "https://files.pythonhosted.org/packages/89/05/d8b90bfb21e58097d5a4e0224f2940568366f68488a079ae77d4b2653500/jiter-0.9.0-cp310-cp310-win32.whl", hash = "sha256:cb12e6d65ebbefe5518de819f3eda53b73187b7089040b2d17f5b39001ff31c4", size = 206613 }, + { url = "https://files.pythonhosted.org/packages/2c/1d/5767f23f88e4f885090d74bbd2755518050a63040c0f59aa059947035711/jiter-0.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:c43ca669493626d8672be3b645dbb406ef25af3f4b6384cfd306da7eb2e70322", size = 208371 }, { url = "https://files.pythonhosted.org/packages/23/44/e241a043f114299254e44d7e777ead311da400517f179665e59611ab0ee4/jiter-0.9.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6c4d99c71508912a7e556d631768dcdef43648a93660670986916b297f1c54af", size = 314654 }, { url = "https://files.pythonhosted.org/packages/fb/1b/a7e5e42db9fa262baaa9489d8d14ca93f8663e7f164ed5e9acc9f467fc00/jiter-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8f60fb8ce7df529812bf6c625635a19d27f30806885139e367af93f6e734ef58", size = 320909 }, { url = "https://files.pythonhosted.org/packages/60/bf/8ebdfce77bc04b81abf2ea316e9c03b4a866a7d739cf355eae4d6fd9f6fe/jiter-0.9.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51c4e1a4f8ea84d98b7b98912aa4290ac3d1eabfde8e3c34541fae30e9d1f08b", size = 341733 }, @@ -648,6 +705,16 @@ version = "3.0.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } wheels = [ + { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357 }, + { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393 }, + { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732 }, + { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866 }, + { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964 }, + { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977 }, + { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366 }, + { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091 }, + { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065 }, + { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514 }, { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353 }, { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392 }, { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984 }, @@ -696,6 +763,14 @@ version = "1.26.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129 } wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/94/ace0fdea5241a27d13543ee117cbc65868e82213fb31a8eb7fe9ff23f313/numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0", size = 20631468 }, + { url = "https://files.pythonhosted.org/packages/20/f7/b24208eba89f9d1b58c1668bc6c8c4fd472b20c45573cb767f59d49fb0f6/numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a", size = 13966411 }, + { url = "https://files.pythonhosted.org/packages/fc/a5/4beee6488160798683eed5bdb7eead455892c3b4e1f78d79d8d3f3b084ac/numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4", size = 14219016 }, + { url = "https://files.pythonhosted.org/packages/4b/d7/ecf66c1cd12dc28b4040b15ab4d17b773b87fa9d29ca16125de01adb36cd/numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f", size = 18240889 }, + { url = "https://files.pythonhosted.org/packages/24/03/6f229fe3187546435c4f6f89f6d26c129d4f5bed40552899fcf1f0bf9e50/numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a", size = 13876746 }, + { url = "https://files.pythonhosted.org/packages/39/fe/39ada9b094f01f5a35486577c848fe274e374bbf8d8f472e1423a0bbd26d/numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2", size = 18078620 }, + { url = "https://files.pythonhosted.org/packages/d5/ef/6ad11d51197aad206a9ad2286dc1aac6a378059e06e8cf22cd08ed4f20dc/numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07", size = 5972659 }, + { url = "https://files.pythonhosted.org/packages/19/77/538f202862b9183f54108557bfda67e17603fc560c384559e769321c9d92/numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5", size = 15808905 }, { url = "https://files.pythonhosted.org/packages/11/57/baae43d14fe163fa0e4c47f307b6b2511ab8d7d30177c491960504252053/numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71", size = 20630554 }, { url = "https://files.pythonhosted.org/packages/1a/2e/151484f49fd03944c4a3ad9c418ed193cfd02724e138ac8a9505d056c582/numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef", size = 13997127 }, { url = "https://files.pythonhosted.org/packages/79/ae/7e5b85136806f9dadf4878bf73cf223fe5c2636818ba3ab1c585d0403164/numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e", size = 14222994 }, @@ -819,6 +894,8 @@ version = "0.2.14" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/26/1d/8878c7752febb0f6716a7e1a52cb92ac98871c5aa522cba181878091607c/PyAudio-0.2.14.tar.gz", hash = "sha256:78dfff3879b4994d1f4fc6485646a57755c6ee3c19647a491f790a0895bd2f87", size = 47066 } wheels = [ + { url = "https://files.pythonhosted.org/packages/90/90/1553487277e6aa25c0b7c2c38709cdd2b49e11c66c0b25c6e8b7b6638c72/PyAudio-0.2.14-cp310-cp310-win32.whl", hash = "sha256:126065b5e82a1c03ba16e7c0404d8f54e17368836e7d2d92427358ad44fefe61", size = 144624 }, + { url = "https://files.pythonhosted.org/packages/27/bc/719d140ee63cf4b0725016531d36743a797ffdbab85e8536922902c9349a/PyAudio-0.2.14-cp310-cp310-win_amd64.whl", hash = "sha256:2a166fc88d435a2779810dd2678354adc33499e9d4d7f937f28b20cc55893e83", size = 164069 }, { url = "https://files.pythonhosted.org/packages/7b/f0/b0eab89eafa70a86b7b566a4df2f94c7880a2d483aa8de1c77d335335b5b/PyAudio-0.2.14-cp311-cp311-win32.whl", hash = "sha256:506b32a595f8693811682ab4b127602d404df7dfc453b499c91a80d0f7bad289", size = 144624 }, { url = "https://files.pythonhosted.org/packages/82/d8/f043c854aad450a76e476b0cf9cda1956419e1dacf1062eb9df3c0055abe/PyAudio-0.2.14-cp311-cp311-win_amd64.whl", hash = "sha256:bbeb01d36a2f472ae5ee5e1451cacc42112986abe622f735bb870a5db77cf903", size = 164070 }, { url = "https://files.pythonhosted.org/packages/8d/45/8d2b76e8f6db783f9326c1305f3f816d4a12c8eda5edc6a2e1d03c097c3b/PyAudio-0.2.14-cp312-cp312-win32.whl", hash = "sha256:5fce4bcdd2e0e8c063d835dbe2860dac46437506af509353c7f8114d4bacbd5b", size = 144750 }, @@ -859,6 +936,19 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/bc/fed5f74b5d802cf9a03e83f60f18864e90e3aed7223adaca5ffb7a8d8d64/pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa", size = 1895938 }, + { url = "https://files.pythonhosted.org/packages/71/2a/185aff24ce844e39abb8dd680f4e959f0006944f4a8a0ea372d9f9ae2e53/pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c", size = 1815684 }, + { url = "https://files.pythonhosted.org/packages/c3/43/fafabd3d94d159d4f1ed62e383e264f146a17dd4d48453319fd782e7979e/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a", size = 1829169 }, + { url = "https://files.pythonhosted.org/packages/a2/d1/f2dfe1a2a637ce6800b799aa086d079998959f6f1215eb4497966efd2274/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5", size = 1867227 }, + { url = "https://files.pythonhosted.org/packages/7d/39/e06fcbcc1c785daa3160ccf6c1c38fea31f5754b756e34b65f74e99780b5/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c", size = 2037695 }, + { url = "https://files.pythonhosted.org/packages/7a/67/61291ee98e07f0650eb756d44998214231f50751ba7e13f4f325d95249ab/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7", size = 2741662 }, + { url = "https://files.pythonhosted.org/packages/32/90/3b15e31b88ca39e9e626630b4c4a1f5a0dfd09076366f4219429e6786076/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a", size = 1993370 }, + { url = "https://files.pythonhosted.org/packages/ff/83/c06d333ee3a67e2e13e07794995c1535565132940715931c1c43bfc85b11/pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236", size = 1996813 }, + { url = "https://files.pythonhosted.org/packages/7c/f7/89be1c8deb6e22618a74f0ca0d933fdcb8baa254753b26b25ad3acff8f74/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962", size = 2005287 }, + { url = "https://files.pythonhosted.org/packages/b7/7d/8eb3e23206c00ef7feee17b83a4ffa0a623eb1a9d382e56e4aa46fd15ff2/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9", size = 2128414 }, + { url = "https://files.pythonhosted.org/packages/4e/99/fe80f3ff8dd71a3ea15763878d464476e6cb0a2db95ff1c5c554133b6b83/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af", size = 2155301 }, + { url = "https://files.pythonhosted.org/packages/2b/a3/e50460b9a5789ca1451b70d4f52546fa9e2b420ba3bfa6100105c0559238/pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4", size = 1816685 }, + { url = "https://files.pythonhosted.org/packages/57/4c/a8838731cb0f2c2a39d3535376466de6049034d7b239c0202a64aaa05533/pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31", size = 1982876 }, { url = "https://files.pythonhosted.org/packages/c2/89/f3450af9d09d44eea1f2c369f49e8f181d742f28220f88cc4dfaae91ea6e/pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", size = 1893421 }, { url = "https://files.pythonhosted.org/packages/9e/e3/71fe85af2021f3f386da42d291412e5baf6ce7716bd7101ea49c810eda90/pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", size = 1814998 }, { url = "https://files.pythonhosted.org/packages/a6/3c/724039e0d848fd69dbf5806894e26479577316c6f0f112bacaf67aa889ac/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", size = 1826167 }, @@ -901,6 +991,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, + { url = "https://files.pythonhosted.org/packages/46/72/af70981a341500419e67d5cb45abe552a7c74b66326ac8877588488da1ac/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e", size = 1891159 }, + { url = "https://files.pythonhosted.org/packages/ad/3d/c5913cccdef93e0a6a95c2d057d2c2cba347815c845cda79ddd3c0f5e17d/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8", size = 1768331 }, + { url = "https://files.pythonhosted.org/packages/f6/f0/a3ae8fbee269e4934f14e2e0e00928f9346c5943174f2811193113e58252/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3", size = 1822467 }, + { url = "https://files.pythonhosted.org/packages/d7/7a/7bbf241a04e9f9ea24cd5874354a83526d639b02674648af3f350554276c/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f", size = 1979797 }, + { url = "https://files.pythonhosted.org/packages/4f/5f/4784c6107731f89e0005a92ecb8a2efeafdb55eb992b8e9d0a2be5199335/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133", size = 1987839 }, + { url = "https://files.pythonhosted.org/packages/6d/a7/61246562b651dff00de86a5f01b6e4befb518df314c54dec187a78d81c84/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc", size = 1998861 }, + { url = "https://files.pythonhosted.org/packages/86/aa/837821ecf0c022bbb74ca132e117c358321e72e7f9702d1b6a03758545e2/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50", size = 2116582 }, + { url = "https://files.pythonhosted.org/packages/81/b0/5e74656e95623cbaa0a6278d16cf15e10a51f6002e3ec126541e95c29ea3/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9", size = 2151985 }, + { url = "https://files.pythonhosted.org/packages/63/37/3e32eeb2a451fddaa3898e2163746b0cffbbdbb4740d38372db0490d67f3/pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151", size = 2004715 }, ] [[package]] @@ -959,6 +1058,13 @@ version = "0.2.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/5a/10/5afef9e7e5b6fbd8092487b77af56b945e883dd68619e59baa39008b71d1/pymp3-0.2.0.tar.gz", hash = "sha256:26c95917600cff593e53955059772859739b5f365dbc377c82d256a7f41c983d", size = 25447 } wheels = [ + { url = "https://files.pythonhosted.org/packages/06/60/54fc714e71b639fd0159cb008af98b001af84ae1b2eeb14d3992254c74ee/pymp3-0.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5da6ef7de6e5bfc508c865d6be281ac7a4d7447ec5b9f6c3a80634a82d88ad07", size = 208223 }, + { url = "https://files.pythonhosted.org/packages/3d/aa/554458cee902fb88c2768f8af5a36eaf2a4d6814283ce3759ad89a90172c/pymp3-0.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:950ccc0db29a7845c527a7866b359156252546253b1dee15e5dc882afde83c1b", size = 231805 }, + { url = "https://files.pythonhosted.org/packages/b3/ec/7d46060b67c4295805fed5e45d03bc67c41b8390e20936a57c0224a8ba8b/pymp3-0.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:529cfa107511957235c830d304b99a4cf10c7be707da746440cbadf44690cf3b", size = 236531 }, + { url = "https://files.pythonhosted.org/packages/56/b1/fe3978bb28534c063baffc0e7a18727a1e6b76f3d32d8ab43e71f8546944/pymp3-0.2.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:d4b325750685fab19efdaf4c4818ae05d10cc44e415a0ac1f5c4a508e363c472", size = 216640 }, + { url = "https://files.pythonhosted.org/packages/f0/82/69ba7b688194806fc5c06409f4129b225b9ef7244c981e72f12195c30804/pymp3-0.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c90aecfb71b39eb27d5c6ea4405d178cea73b5f232285f230b953b3c4f52f2fa", size = 234972 }, + { url = "https://files.pythonhosted.org/packages/52/07/40aea3e44c49354b46730b71d167ea38fbb3ea10fa21781bef3896bcb935/pymp3-0.2.0-cp310-cp310-win32.whl", hash = "sha256:af6df1a5348de1568490abc2fd01e82eecd7df4a7e22716ed0c36cd97efa202e", size = 149332 }, + { url = "https://files.pythonhosted.org/packages/89/15/4c89de6e0ab96f532b040b4e5712744c92f3a692271145e4dd038e75bf6d/pymp3-0.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:b35e5825dee96c0e2317448c0a937c3062d855e14ee2b79497dcf646ce34ce52", size = 171928 }, { url = "https://files.pythonhosted.org/packages/18/a2/67e1aaced4efba181643b685c87769d01a871e6cc0401f5d0e8f87b22715/pymp3-0.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a2264eb36eaced7919a9bad93dc630b6b002aa1a1b6eb7122e03b8b055e86b48", size = 208223 }, { url = "https://files.pythonhosted.org/packages/17/f9/6f8cf11798f016837695a937331868b9b6466e2af3df468cbc22d54eeeec/pymp3-0.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6a3c98bb20e43f411b216cf0217a6edb9948d476065492d1fa22100840c7471", size = 231808 }, { url = "https://files.pythonhosted.org/packages/6c/d0/5f3f5bb9f5a5a20f4f42c7048984e8b2a36363006d0eb2059ad7f4bc3ec0/pymp3-0.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba8d0b397295952ee2cd52f307eb17f51e26ff002fa3af9c55c13528cf1f7752", size = 236531 }, @@ -973,6 +1079,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/99/c344eaf308ae2160c57ff6e6cc29bf695e8797e91620a7b40601ea18e58a/pymp3-0.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:29a508c59bc2b6a00698675e39d768f58b8cf1ab5212b964b943499b1abb9300", size = 234996 }, { url = "https://files.pythonhosted.org/packages/e8/96/24d2955790ead97b012da8f1a2bdae5cc1563b06d90c4e23cd7e6e4e20f3/pymp3-0.2.0-cp312-cp312-win32.whl", hash = "sha256:2a3b6f31c2549aea91c99679407f042ce97e90c829ebee1fc38640643c6ffa26", size = 149440 }, { url = "https://files.pythonhosted.org/packages/a8/b9/222b91c199d5160441af26544f761e3f406f22aacf581d53fb518382c63c/pymp3-0.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b813f2eda08112cc2a03494ea6c77c8da3958ba2b649c99c56b5b78e835bd211", size = 171953 }, + { url = "https://files.pythonhosted.org/packages/06/3d/e83df281401a2137d5ce9e463bef7dddfaaeaa74c316e20603a99457177c/pymp3-0.2.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:e8785a27ef84a9810d4f1bcf4cd33ec4b9ac8adefd25a38cfe8d7fb7cc530f8d", size = 208236 }, + { url = "https://files.pythonhosted.org/packages/84/ed/58c0d06635dea354eed406768bb6102b4eae690bf105263f832402ce5847/pymp3-0.2.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87455d5fe7776c3594886717b25d19559276631f5edb102127b5100c6ac92cd7", size = 231891 }, + { url = "https://files.pythonhosted.org/packages/a5/8a/e04691edc57c4107a8927cfaed820b9a68a0dab6f725627b575572bc97e0/pymp3-0.2.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f26f4fb4f31866d98171003eadca06fd9219372252209961014376dea7a23b4", size = 236597 }, + { url = "https://files.pythonhosted.org/packages/0a/a2/b7a9f3b99284950a1cff9306a4c3f21215ff6903400c1a7cf50f63a6793f/pymp3-0.2.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b286ad139b1ef4d269597d362ec1f9c96336fd9fde69294bc0feb0a71853dfaf", size = 172008 }, ] [[package]] @@ -981,9 +1091,11 @@ version = "8.3.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } wheels = [ @@ -1051,6 +1163,15 @@ version = "6.0.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199 }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758 }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463 }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280 }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239 }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802 }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527 }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052 }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774 }, { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 }, { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 }, { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 }, @@ -1115,6 +1236,19 @@ version = "0.23.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/0a/79/2ce611b18c4fd83d9e3aecb5cba93e1917c050f556db39842889fa69b79f/rpds_py-0.23.1.tar.gz", hash = "sha256:7f3240dcfa14d198dba24b8b9cb3b108c06b68d45b7babd9eefc1038fdf7e707", size = 26806 } wheels = [ + { url = "https://files.pythonhosted.org/packages/34/fe/e5326459863bd525122f4e9c80ac8d7c6cfa171b7518d04cc27c12c209b0/rpds_py-0.23.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2a54027554ce9b129fc3d633c92fa33b30de9f08bc61b32c053dc9b537266fed", size = 372123 }, + { url = "https://files.pythonhosted.org/packages/f9/db/f10a3795f7a89fb27594934012d21c61019bbeb516c5bdcfbbe9e9e617a7/rpds_py-0.23.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b5ef909a37e9738d146519657a1aab4584018746a18f71c692f2f22168ece40c", size = 356778 }, + { url = "https://files.pythonhosted.org/packages/21/27/0d3678ad7f432fa86f8fac5f5fc6496a4d2da85682a710d605219be20063/rpds_py-0.23.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ee9d6f0b38efb22ad94c3b68ffebe4c47865cdf4b17f6806d6c674e1feb4246", size = 385775 }, + { url = "https://files.pythonhosted.org/packages/99/a0/1786defa125b2ad228027f22dff26312ce7d1fee3c7c3c2682f403db2062/rpds_py-0.23.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f7356a6da0562190558c4fcc14f0281db191cdf4cb96e7604c06acfcee96df15", size = 391181 }, + { url = "https://files.pythonhosted.org/packages/f1/5c/1240934050a7ffd020a915486d0cc4c7f6e7a2442a77aedf13664db55d36/rpds_py-0.23.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9441af1d25aed96901f97ad83d5c3e35e6cd21a25ca5e4916c82d7dd0490a4fa", size = 444607 }, + { url = "https://files.pythonhosted.org/packages/b7/1b/cee6905b47817fd0a377716dbe4df35295de46df46ee2ff704538cc371b0/rpds_py-0.23.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d8abf7896a91fb97e7977d1aadfcc2c80415d6dc2f1d0fca5b8d0df247248f3", size = 445550 }, + { url = "https://files.pythonhosted.org/packages/54/f7/f0821ca34032892d7a67fcd5042f50074ff2de64e771e10df01085c88d47/rpds_py-0.23.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b08027489ba8fedde72ddd233a5ea411b85a6ed78175f40285bd401bde7466d", size = 386148 }, + { url = "https://files.pythonhosted.org/packages/eb/ef/2afe53bc857c4bcba336acfd2629883a5746e7291023e017ac7fc98d85aa/rpds_py-0.23.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fee513135b5a58f3bb6d89e48326cd5aa308e4bcdf2f7d59f67c861ada482bf8", size = 416780 }, + { url = "https://files.pythonhosted.org/packages/ae/9a/38d2236cf669789b8a3e1a014c9b6a8d7b8925b952c92e7839ae2749f9ac/rpds_py-0.23.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:35d5631ce0af26318dba0ae0ac941c534453e42f569011585cb323b7774502a5", size = 558265 }, + { url = "https://files.pythonhosted.org/packages/e6/0a/f2705530c42578f20ed0b5b90135eecb30eef6e2ba73e7ba69087fad2dba/rpds_py-0.23.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a20cb698c4a59c534c6701b1c24a968ff2768b18ea2991f886bd8985ce17a89f", size = 585270 }, + { url = "https://files.pythonhosted.org/packages/29/4e/3b597dc84ed82c3d757ac9aa620de224a94e06d2e102069795ae7e81c015/rpds_py-0.23.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e9c206a1abc27e0588cf8b7c8246e51f1a16a103734f7750830a1ccb63f557a", size = 553850 }, + { url = "https://files.pythonhosted.org/packages/00/cc/6498b6f79e4375e6737247661e52a2d18f6accf4910e0c8da978674b4241/rpds_py-0.23.1-cp310-cp310-win32.whl", hash = "sha256:d9f75a06ecc68f159d5d7603b734e1ff6daa9497a929150f794013aa9f6e3f12", size = 220660 }, + { url = "https://files.pythonhosted.org/packages/17/2b/08db023d23e8c7032c99d8d2a70d32e450a868ab73d16e3ff5290308a665/rpds_py-0.23.1-cp310-cp310-win_amd64.whl", hash = "sha256:f35eff113ad430b5272bbfc18ba111c66ff525828f24898b4e146eb479a2cdda", size = 232551 }, { url = "https://files.pythonhosted.org/packages/1c/67/6e5d4234bb9dee062ffca2a5f3c7cd38716317d6760ec235b175eed4de2c/rpds_py-0.23.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:b79f5ced71efd70414a9a80bbbfaa7160da307723166f09b69773153bf17c590", size = 372264 }, { url = "https://files.pythonhosted.org/packages/a7/0a/3dedb2daee8e783622427f5064e2d112751d8276ee73aa5409f000a132f4/rpds_py-0.23.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c9e799dac1ffbe7b10c1fd42fe4cd51371a549c6e108249bde9cd1200e8f59b4", size = 356883 }, { url = "https://files.pythonhosted.org/packages/ed/fc/e1acef44f9c24b05fe5434b235f165a63a52959ac655e3f7a55726cee1a4/rpds_py-0.23.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721f9c4011b443b6e84505fc00cc7aadc9d1743f1c988e4c89353e19c4a968ee", size = 385624 }, @@ -1167,6 +1301,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/18/017ab41dcd6649ad5db7d00155b4c212b31ab05bd857d5ba73a1617984eb/rpds_py-0.23.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d31ed4987d72aabdf521eddfb6a72988703c091cfc0064330b9e5f8d6a042ff5", size = 554880 }, { url = "https://files.pythonhosted.org/packages/2e/dd/17de89431268da8819d8d51ce67beac28d9b22fccf437bc5d6d2bcd1acdb/rpds_py-0.23.1-cp313-cp313t-win32.whl", hash = "sha256:f3429fb8e15b20961efca8c8b21432623d85db2228cc73fe22756c6637aa39e7", size = 219743 }, { url = "https://files.pythonhosted.org/packages/68/15/6d22d07e063ce5e9bfbd96db9ec2fbb4693591b4503e3a76996639474d02/rpds_py-0.23.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d6f6512a90bd5cd9030a6237f5346f046c6f0e40af98657568fa45695d4de59d", size = 235415 }, + { url = "https://files.pythonhosted.org/packages/95/a9/6fafd35fc6bac05f59bcbc800b57cef877911ff1c015397c519fec888642/rpds_py-0.23.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c1f8afa346ccd59e4e5630d5abb67aba6a9812fddf764fd7eb11f382a345f8cc", size = 373463 }, + { url = "https://files.pythonhosted.org/packages/5b/ac/44f00029b8fbe0903a19e9a87a9b86063bf8700df2cc58868373d378418c/rpds_py-0.23.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fad784a31869747df4ac968a351e070c06ca377549e4ace94775aaa3ab33ee06", size = 358400 }, + { url = "https://files.pythonhosted.org/packages/5e/9c/3da199346c68d785f10dccab123b74c8c5f73be3f742c9e33d1116e07931/rpds_py-0.23.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5a96fcac2f18e5a0a23a75cd27ce2656c66c11c127b0318e508aab436b77428", size = 386815 }, + { url = "https://files.pythonhosted.org/packages/d3/45/8f6533c33c0d33da8c2c8b2fb8f2ee90b23c05c679b86b0ac6aee4653749/rpds_py-0.23.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3e77febf227a1dc3220159355dba68faa13f8dca9335d97504abf428469fb18b", size = 392974 }, + { url = "https://files.pythonhosted.org/packages/ca/56/6a9ac1bf0455ba07385d8fe98c571c519b4f2000cff6581487bf9fab9272/rpds_py-0.23.1-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26bb3e8de93443d55e2e748e9fd87deb5f8075ca7bc0502cfc8be8687d69a2ec", size = 446019 }, + { url = "https://files.pythonhosted.org/packages/f4/83/5d9a3f9731cdccf49088bcc4ce821a5cf50bd1737cdad83e9959a7b9054d/rpds_py-0.23.1-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:db7707dde9143a67b8812c7e66aeb2d843fe33cc8e374170f4d2c50bd8f2472d", size = 445811 }, + { url = "https://files.pythonhosted.org/packages/44/50/f2e0a98c62fc1fe68b176caca587714dc5c8bb2c3d1dd1eeb2bd4cc787ac/rpds_py-0.23.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1eedaaccc9bb66581d4ae7c50e15856e335e57ef2734dbc5fd8ba3e2a4ab3cb6", size = 388070 }, + { url = "https://files.pythonhosted.org/packages/f2/d0/4981878f8f157e6dbea01d95e0119bf3d6b4c2c884fe64a9e6987f941104/rpds_py-0.23.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28358c54fffadf0ae893f6c1050e8f8853e45df22483b7fff2f6ab6152f5d8bf", size = 419173 }, + { url = "https://files.pythonhosted.org/packages/ce/13/fc971c470da96b270d2f64fedee987351bd935dc3016932a5cdcb1a88a2a/rpds_py-0.23.1-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:633462ef7e61d839171bf206551d5ab42b30b71cac8f10a64a662536e057fdef", size = 559048 }, + { url = "https://files.pythonhosted.org/packages/42/02/be91e1de139ec8b4f9fec4192fd779ba48af281cfc762c0ca4c15b945484/rpds_py-0.23.1-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:a98f510d86f689fcb486dc59e6e363af04151e5260ad1bdddb5625c10f1e95f8", size = 584773 }, + { url = "https://files.pythonhosted.org/packages/27/28/3af8a1956df3edc41d884267d766dc096496dafc83f02f764a475eca0b4a/rpds_py-0.23.1-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:e0397dd0b3955c61ef9b22838144aa4bef6f0796ba5cc8edfc64d468b93798b4", size = 555153 }, + { url = "https://files.pythonhosted.org/packages/5e/bb/e45f51c4e1327dea3c72b846c6de129eebacb7a6cb309af7af35d0578c80/rpds_py-0.23.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:75307599f0d25bf6937248e5ac4e3bde5ea72ae6618623b86146ccc7845ed00b", size = 233827 }, ] [[package]] @@ -1196,6 +1342,14 @@ version = "1.11.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/94/74/2d68dca3b363ade69ef982bdc1d36f7115ddbd9c9a8dafa19016a6cdc63d/sherpa-onnx-1.11.1.tar.gz", hash = "sha256:17fac98868d2144bc3b6d4b925874dd2115143db075e85efc96322d0cf8d4470", size = 513441 } wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/22/5b168334dbf30fc403989eb6ad568d0ca681851da9c9a35aa037cb3249cb/sherpa_onnx-1.11.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:6ec211e651ba496180234d138b54c373f7b7d3ebd4a47b0a79c77e152c0ab88e", size = 15705601 }, + { url = "https://files.pythonhosted.org/packages/54/77/c1efbcae609ba1307d2f86505ba53332f063bc3d27ea46833556b11a7364/sherpa_onnx-1.11.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6a4ef44a52761afc830f94d9803ba1e89b743bb70ff35da5a4009eb3ad24b948", size = 17733845 }, + { url = "https://files.pythonhosted.org/packages/59/67/aa5281dd58e28450ff156cf91b5434cca085f4db2c05dfaf0b91067f7486/sherpa_onnx-1.11.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:63bb5905361ea28a9acd72143d57f133f221f5aea87e9fce50a114f228b74295", size = 36930375 }, + { url = "https://files.pythonhosted.org/packages/6f/3d/b9eb6e42731f04d355530937dc363f7e24a56142fe9db969bc8686f729f7/sherpa_onnx-1.11.1-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:0ba69e9afe0188d7f789715276f48456c97e5391186589b8e33f6433ca98bfa4", size = 20017156 }, + { url = "https://files.pythonhosted.org/packages/82/ff/64b5f6f3e86fbc3719e578ee2cc2b3dad1b5ba4cc7ddb75af0ecceab3b6a/sherpa_onnx-1.11.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:388bb8dbe8434b4f4458ef8734ac5f38ada805019c56e9367dd2650e77aebd64", size = 21446074 }, + { url = "https://files.pythonhosted.org/packages/c2/30/c57bb738d9733a1a939c8a72d8ceb1c2b3784c3f6ff33e458a889d2f4882/sherpa_onnx-1.11.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03fceacf2231641d095bbd988b147fd44bb62180e3f7e6b0dff059419741b5b5", size = 23237683 }, + { url = "https://files.pythonhosted.org/packages/6d/74/3f94aa6ff118814e98609e5b60f2039f2c0dbbd163a59e1184c6e2915d06/sherpa_onnx-1.11.1-cp310-cp310-win32.whl", hash = "sha256:00b3740a67604c7f01741916aca6647013f761b5a7d181a97abbc95081d37aad", size = 20132495 }, + { url = "https://files.pythonhosted.org/packages/7e/8b/2a6ac5f476a11c7b15437b0005a5a5f807358189ed2e1ccbd288537663f2/sherpa_onnx-1.11.1-cp310-cp310-win_amd64.whl", hash = "sha256:10864de590e94ee8b0c11f989136a382473b27090b0a6b9093d426be15456243", size = 22868899 }, { url = "https://files.pythonhosted.org/packages/7d/aa/986bffe2670d3b0a789912de59caf32a5d99b577815f37a9bcfd188bebfc/sherpa_onnx-1.11.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:821e10c0a2ffb68771ab3bcfe80acda4cbd6b51bcc51917895e426571cc5dda6", size = 15705945 }, { url = "https://files.pythonhosted.org/packages/57/72/9db1615245b98d3fe7e4cd07403992d1f832d7c020f1d17921c2ad99a49c/sherpa_onnx-1.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb62aadfc429b39bc86b2045cd1af37f51d58e0d965805eb2000c8bc5bcb7b85", size = 17734705 }, { url = "https://files.pythonhosted.org/packages/95/c4/2ec7ac8ae60b379fd97cf149ab4c734541ab90c16879a353e250cbc352d8/sherpa_onnx-1.11.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8eac5b5e2fbd34d052390832dae49639bd3323a43fb211a8fe2a34134838c665", size = 36932085 }, From e7a981444c7a62bab0218d6cfd5760ffad4a628d Mon Sep 17 00:00:00 2001 From: will wade Date: Sat, 22 Mar 2025 01:44:33 +0000 Subject: [PATCH 28/37] see if fixes imports for pyinstaller step --- asterics-grid-speech.spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/asterics-grid-speech.spec b/asterics-grid-speech.spec index 3f4ecd3..11932dc 100644 --- a/asterics-grid-speech.spec +++ b/asterics-grid-speech.spec @@ -22,7 +22,7 @@ a = Analysis( ('speech/speech_manager.py', 'speech'), ('speech/__init__.py', 'speech'), ('speech/templates', 'speech/templates'), # Add templates directory - (os.path.join(site_packages, 'tts_wrapper'), 'tts_wrapper'), # Include tts_wrapper from site-packages + (os.path.join(site_packages, 'py3_tts_wrapper'), 'tts_wrapper'), # Include tts_wrapper from site-packages ], hiddenimports=[ 'flask', From c5ab2510076f58d075b2c39b64565dc33ab8e0e6 Mon Sep 17 00:00:00 2001 From: will wade Date: Sat, 22 Mar 2025 01:47:07 +0000 Subject: [PATCH 29/37] dynanically find tts-wrapper path --- asterics-grid-speech.spec | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/asterics-grid-speech.spec b/asterics-grid-speech.spec index 11932dc..be1726a 100644 --- a/asterics-grid-speech.spec +++ b/asterics-grid-speech.spec @@ -1,6 +1,7 @@ # -*- mode: python ; coding: utf-8 -*- import os import site +import glob from PyInstaller.utils.hooks import collect_dynamic_libs block_cipher = None @@ -8,6 +9,16 @@ block_cipher = None # Get site-packages directory site_packages = site.getsitepackages()[0] +# Find the actual tts_wrapper package directory +tts_wrapper_path = None +for path in glob.glob(os.path.join(site_packages, 'py3_tts_wrapper*')): + if os.path.isdir(path): + tts_wrapper_path = path + break + +if not tts_wrapper_path: + raise Exception("Could not find tts_wrapper package in site-packages") + # Collect Azure Speech SDK dynamic libraries azure_binaries = collect_dynamic_libs('azure.cognitiveservices.speech') @@ -22,7 +33,7 @@ a = Analysis( ('speech/speech_manager.py', 'speech'), ('speech/__init__.py', 'speech'), ('speech/templates', 'speech/templates'), # Add templates directory - (os.path.join(site_packages, 'py3_tts_wrapper'), 'tts_wrapper'), # Include tts_wrapper from site-packages + (tts_wrapper_path, 'tts_wrapper'), # Include tts_wrapper from site-packages ], hiddenimports=[ 'flask', From cd4f4cc36083b159bd5cdb1730d8fabc18128c56 Mon Sep 17 00:00:00 2001 From: will wade Date: Sat, 22 Mar 2025 01:48:53 +0000 Subject: [PATCH 30/37] debug it --- asterics-grid-speech.spec | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/asterics-grid-speech.spec b/asterics-grid-speech.spec index be1726a..884840f 100644 --- a/asterics-grid-speech.spec +++ b/asterics-grid-speech.spec @@ -8,15 +8,28 @@ block_cipher = None # Get site-packages directory site_packages = site.getsitepackages()[0] +print(f"Site packages directory: {site_packages}") + +# List all directories in site-packages +print("\nAll directories in site-packages:") +for item in os.listdir(site_packages): + if os.path.isdir(os.path.join(site_packages, item)): + print(f" - {item}") # Find the actual tts_wrapper package directory tts_wrapper_path = None +print("\nSearching for tts_wrapper package...") for path in glob.glob(os.path.join(site_packages, 'py3_tts_wrapper*')): + print(f"Checking path: {path}") if os.path.isdir(path): tts_wrapper_path = path + print(f"Found tts_wrapper at: {path}") break if not tts_wrapper_path: + print("\nCould not find tts_wrapper package. Available paths:") + for path in glob.glob(os.path.join(site_packages, '*tts*')): + print(f" - {path}") raise Exception("Could not find tts_wrapper package in site-packages") # Collect Azure Speech SDK dynamic libraries From d81eeb99091ba0a67c613edbc2eade6cb916ebc1 Mon Sep 17 00:00:00 2001 From: will wade Date: Sat, 22 Mar 2025 01:50:54 +0000 Subject: [PATCH 31/37] win packages in lib --- asterics-grid-speech.spec | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/asterics-grid-speech.spec b/asterics-grid-speech.spec index 884840f..e4c0248 100644 --- a/asterics-grid-speech.spec +++ b/asterics-grid-speech.spec @@ -10,6 +10,11 @@ block_cipher = None site_packages = site.getsitepackages()[0] print(f"Site packages directory: {site_packages}") +# On Windows, the actual packages are in Lib/site-packages +if os.name == 'nt': + site_packages = os.path.join(site_packages, 'Lib', 'site-packages') + print(f"Windows site-packages directory: {site_packages}") + # List all directories in site-packages print("\nAll directories in site-packages:") for item in os.listdir(site_packages): From 3b09db5a1abdd22fc734317837a542a8f8e0ec5b Mon Sep 17 00:00:00 2001 From: will wade Date: Sat, 22 Mar 2025 08:36:16 +0000 Subject: [PATCH 32/37] marginally better readme --- README.md | 372 ++++++++++++++++++++++++++++-------------------- speech/start.py | 131 +++++++++++++---- 2 files changed, 323 insertions(+), 180 deletions(-) diff --git a/README.md b/README.md index d6e391e..a7f9bc5 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ A text-to-speech service that provides a simple HTTP API for speech synthesis. T - Sherpa-ONNX (default, offline) - Amazon Polly - Google Cloud TTS + - OpenAI TTS + - Google Translate TTS - Microsoft Azure TTS - IBM Watson - ElevenLabs @@ -27,6 +29,10 @@ git clone https://github.com/yourusername/AsTeRICS-Grid-Helper.git cd AsTeRICS-Grid-Helper ``` +2. Install UV + +Follow the instructions at https://docs.astral.sh/uv/getting-started/installation/#installation-methods + ## Usage @@ -47,156 +53,211 @@ To build platform-specific executables: uv run python build.py ``` -This will create executables in the `dist` directory: -- macOS: `asterics-grid-speech-mac` -- Windows: `asterics-grid-speech.exe` -- Linux: `asterics-grid-speech` +This will create executables in the `dist` directory. ### API Endpoints #### List Available Voices ```bash -curl http://localhost:5555/voices +curl http://localhost:5555/apivoices ``` #### Generate Speech Data ```bash -curl "http://localhost:5555/speakdata/Hello%20World/en-us-amy-medium" --output output.wav +curl "http://localhost:5555/apispeakdata/Hello%20World/en-us-amy-medium" --output output.wav ``` #### Speak Text ```bash -curl "http://localhost:5555/speak/Hello%20World/en-us-amy-medium" +curl "http://localhost:5555/api/speak/Hello%20World/en-us-amy-medium" ``` ### Configuration -The service can be configured by modifying `speech/config.py`. Here's an example configuration: +The service can be configured by modifying `speech.ini`. Here's an example configuration: -```python -# TTS Configuration -TTS_CONFIG = { - "tts_provider": "sherpa-onnx", # Default provider - "cache_enabled": True, - "cache_dir": "cache", - "cache_ttl": 3600, # Cache TTL in seconds +```ini +[General] +engines = sherpaonnx,microsoft,google,googletrans,elevenlabs,polly,witai,playht,espeak,openai +cache_enabled = True +cache_dir = temp + +[microsoft] +subscription_key = your-subscription-key +subscription_region = your-region + +[elevenlabs] +api_key = your-api-key + +[polly] +aws_key_id = your-aws-key-id +aws_secret_access_key = your-aws-secret-key +aws_region = your-aws-region + +[witai] +token = your-witai-token + +[sherpaonnx] +# No additional configuration needed for sherpaonnx + +[googletrans] +voice_id = + +[google] +credentials_json = { + "type": "service_account", + "project_id": "your-project-id", + "private_key_id": "your-private-key-id", + "private_key": "your-private-key", + "client_email": "your-client-email", + "client_id": "your-client-id", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "your-cert-url" } -# Provider-specific credentials -CREDENTIALS = { - "polly": { - "aws_access_key_id": "your-access-key", - "aws_secret_access_key": "your-secret-key", - "region_name": "us-east-1" - }, - "google": { - "credentials_file": "path/to/credentials.json" - }, - "azure": { - "subscription_key": "your-key", - "region": "your-region" - }, - "watson": { - "apikey": "your-api-key", - "url": "your-service-url" - }, - "elevenlabs": { - "api_key": "your-api-key" - }, - "wit": { - "api_key": "your-api-key" - } -} +[espeak] +# No additional configuration needed for espeak -# Voice configuration -VOICE_CONFIG = { - "default_voice": "en-us-amy-medium", - "fallback_voice": "en-us-ryan-medium" -} -``` +[playht] +api_key = your-playht-api-key +user_id = your-playht-user-id -### Available Voices +[openai] +api_key = your-openai-api-key +model = gpt-4o-mini-tts +output_format = wav +``` -The service provides several pre-configured voices using the Piper model: +You can also configure the service through the web interface at `http://localhost:5555` which provides a user-friendly way to: +1. Enable/disable TTS providers +2. Configure provider credentials +3. Manage cache settings +4. Test different voices -- `en-us-amy-medium`: English (US) - Amy (Medium) -- `en-us-amy-low`: English (US) - Amy (Low) -- `en-us-amy-high`: English (US) - Amy (High) -- `en-us-ryan-medium`: English (US) - Ryan (Medium) -- `en-us-ryan-low`: English (US) - Ryan (Low) -- `en-us-ryan-high`: English (US) - Ryan (High) ### Using Different TTS Providers To use a different TTS provider: -1. Update the `tts_provider` in `TTS_CONFIG` -2. Add the required credentials in the `CREDENTIALS` section +1. Add the provider to the `engines` list in the `[General]` section +2. Add the required credentials in the provider's section 3. Restart the service Example for using Amazon Polly: -```python -TTS_CONFIG = { - "tts_provider": "polly", - "cache_enabled": True, - "cache_dir": "cache", - "cache_ttl": 3600, -} +```ini +[General] +engines = polly +cache_enabled = True +cache_dir = temp + +[polly] +aws_key_id = your-aws-key-id +aws_secret_access_key = your-aws-secret-key +aws_region = us-east-1 +``` -CREDENTIALS = { - "polly": { - "aws_access_key_id": "your-access-key", - "aws_secret_access_key": "your-secret-key", - "region_name": "us-east-1" - } -} +You can allow multiple providers to be used at the same time. +eg. +```ini +[General] +engines = polly,sherpaonnx ``` -## Creating Custom TTS Providers +## Development + +### Creating Custom TTS Providers The system supports custom TTS providers through a simple interface. This allows you to integrate any TTS engine that can be controlled via command line or API. ### Provider Interface -To create a custom provider, create a new class that inherits from `CustomTTSProvider` in `speech/custom_providers.py`: +To create a custom provider, create a new class that inherits from `CustomTTSProvider` in `speech/custom_providers.py`. Here's a complete example: ```python -from speech.speech_manager import CustomTTSProvider +from typing import Any +from speech.base_provider import CustomTTSProvider class MyCustomProvider(CustomTTSProvider): def __init__(self, config: dict[str, Any] | None = None): + """Initialize the provider. + + Args: + config: Configuration dictionary with provider-specific settings + """ super().__init__() self.config = config or {} # Initialize your TTS engine here + # Example: self.engine = MyTTSEngine(self.config) def get_voices(self) -> list[dict[str, Any]]: - """Return list of available voices.""" - # Return list of dicts with keys: id, name, language_codes, gender - return [] + """Return list of available voices. + + Returns: + List of dictionaries with the following keys: + - id: Unique identifier for the voice + - name: Display name of the voice + - language_codes: List of supported language codes + - gender: Voice gender (M/F/N) + """ + # Example: + return [ + { + "id": "voice1", + "name": "Voice 1", + "language_codes": ["en-US"], + "gender": "F" + } + ] def speak(self, text: str, voice_id: str) -> None: - """Speak text using specified voice.""" + """Speak text using specified voice. + + Args: + text: Text to speak + voice_id: ID of the voice to use + """ # Implement direct speech output - pass + # Example: self.engine.speak(text, voice_id) def get_speak_data(self, text: str, voice_id: str) -> bytes: - """Get WAV audio data for text.""" - # Return WAV format audio data + """Get WAV audio data for text. + + Args: + text: Text to convert to speech + voice_id: ID of the voice to use + + Returns: + WAV format audio data as bytes + """ + # Example: + # audio_data = self.engine.synthesize(text, voice_id) + # return audio_data return b"" def stop_speaking(self) -> None: """Stop current speech playback.""" - # Implement stop functionality + # Example: self.engine.stop() pass ``` ### Registering Your Provider -Add an initialization method to `SpeechManager` in `speech/speech_manager.py`: +1. Add your provider class to `speech/custom_providers.py` + +2. Add an initialization method to `SpeechManager` in `speech/speech_manager.py`: ```python def init_myprovider_provider(self, config: dict[str, Any]) -> CustomTTSProvider | None: - """Initialize your custom provider.""" + """Initialize your custom provider. + + Args: + config: Configuration dictionary with provider-specific settings + + Returns: + Initialized provider instance or None if initialization fails + """ try: from .custom_providers import MyCustomProvider return MyCustomProvider(config) @@ -205,91 +266,100 @@ def init_myprovider_provider(self, config: dict[str, Any]) -> CustomTTSProvider return None ``` -### Configuration - -Add your provider to `speech.ini`: +3. Add your provider to the `init_providers` method in `SpeechManager`: -```ini -[engines] -engines = myprovider,espeak - -[engine_configs] -myprovider_path = /path/to/myprovider -myprovider_data_dir = /path/to/data +```python +def init_providers(self, config: dict[str, Any]) -> None: + """Initialize TTS providers from config.""" + # ... existing code ... + + for engine in engines: + try: + if engine == "myprovider": + provider = self.init_myprovider_provider(config) + if provider: + self.providers[engine] = provider + # ... other providers ... ``` -### Example Implementations -#### OpenAI TTS Provider +### Example Implementation: OpenAI TTS Provider -The OpenAI TTS provider demonstrates integration with OpenAI's text-to-speech API: +The OpenAI TTS provider demonstrates a complete implementation: -```ini -[engines] -engines = openai - -[engine_configs] -openai_api_key = your-api-key -openai_model = gpt-4o-mini-tts -openai_output_format = wav -``` - -Features: -- Uses OpenAI's GPT-4o mini TTS model -- Supports 11 built-in voices (alloy, ash, ballad, coral, echo, fable, onyx, nova, sage, shimmer) -- Optimized for English but supports multiple languages -- High-quality, natural-sounding speech -- Streaming support for real-time playback - -To use the OpenAI provider: +```python +from typing import Any +from speech.base_provider import CustomTTSProvider +from openai import OpenAI -1. Get an API key from [OpenAI](https://platform.openai.com) -2. Set the `OPENAI_API_KEY` environment variable or add it to your config -3. Select a voice from the available options -4. Use the provider as normal +class OpenAITTSProvider(CustomTTSProvider): + def __init__(self, config: dict[str, Any] | None = None): + super().__init__() + self.config = config or {} + self.api_key = self.config.get("api_key") + if not self.api_key: + raise ValueError("OpenAI API key is required") + + self.model = self.config.get("model", "gpt-4o-mini-tts") + self.output_format = self.config.get("output_format", "wav") + self.client = OpenAI(api_key=self.api_key) -Example usage: -```python -from speech.speech_manager import SpeechManager -from speech.config import get_tts_config - -# Initialize with OpenAI -config = get_tts_config() -config["engines"] = ["openai"] -config["engine_configs"] = { - "openai": { - "api_key": "your-api-key", - "model": "gpt-4o-mini-tts", - "output_format": "wav" - } -} + def get_voices(self) -> list[dict[str, Any]]: + return [ + { + "id": voice, + "name": voice.capitalize(), + "language_codes": ["en"], + "gender": "Unknown" + } + for voice in [ + "alloy", "ash", "ballad", "coral", "echo", + "fable", "onyx", "nova", "sage", "shimmer" + ] + ] -speech_manager = SpeechManager() -speech_manager.init_providers(config) + def speak(self, text: str, voice_id: str) -> None: + audio_data = self.get_speak_data(text, voice_id) + if not audio_data: + raise RuntimeError("Failed to generate audio data") + + import io + import sounddevice as sd + import soundfile as sf + + audio_stream = io.BytesIO(audio_data) + data, samplerate = sf.read(audio_stream) + sd.play(data, samplerate) + sd.wait() -# Get available voices -voices = speech_manager.get_voices() -for voice in voices: - print(f"- {voice['name']} ({voice['language_codes'][0]})") + def get_speak_data(self, text: str, voice_id: str) -> bytes: + response = self.client.audio.speech.create( + model=self.model, + voice=voice_id, + input=text, + response_format=self.output_format + ) + return response.content -# Speak text -speech_manager.speak("Hello, this is a test.", "alloy") + def stop_speaking(self) -> None: + import sounddevice as sd + sd.stop() ``` -Note: The OpenAI TTS service requires an API key and may incur costs based on usage. See [OpenAI's pricing](https://openai.com/pricing) for details. - -#### Template Provider +### Testing Your Provider -The `TemplateProvider` class in `speech/custom_providers.py` provides a base template for implementing new TTS providers. It includes: +1. Install your provider's dependencies: +```bash +uv pip install your-provider-dependencies +``` -1. Basic provider structure -2. Required method signatures -3. Type hints and documentation -4. Error handling patterns +3. Test your provider: +```bash +uv run python -m speech.start +``` -Use this template as a starting point for implementing new providers. +4. Use the web interface at `http://localhost:5555` to test your provider's functionality. -## Development ### Running Tests diff --git a/speech/start.py b/speech/start.py index d062801..afc4fa7 100644 --- a/speech/start.py +++ b/speech/start.py @@ -68,23 +68,41 @@ app, version="1.0", title="AsTeRICS Grid Speech API", - description="API for text-to-speech functionality in AsTeRICS Grid", + description="API for text-to-speech functionality in AsTeRICS Grid. All endpoints are prefixed with /api.", doc="/docs", prefix="/api", ) # Define namespaces -ns = api.namespace("", description="Speech synthesis operations") +ns = api.namespace( + "", description="Speech synthesis operations. All endpoints are prefixed with /api." +) # Define models root_response = api.model( "RootResponse", { - "name": fields.String(description="API name"), - "version": fields.String(description="API version"), - "description": fields.String(description="API description"), - "documentation": fields.String(description="Link to API documentation"), - "endpoints": fields.Raw(description="Available API endpoints"), + "name": fields.String( + description="API name", example="AsTeRICS Grid Speech API" + ), + "version": fields.String(description="API version", example="1.0"), + "description": fields.String( + description="API description", + example="API for text-to-speech functionality in AsTeRICS Grid", + ), + "documentation": fields.String( + description="Link to API documentation", example="/docs" + ), + "endpoints": fields.Raw( + description="Available API endpoints (all prefixed with /api)", + example={ + "voices": "/api/voices", + "speak": "/api/speak///", + "speakdata": "/api/speakdata///", + "speaking": "/api/speaking", + "stop": "/api/stop", + }, + ), }, ) @@ -201,11 +219,11 @@ def get(self): "description": "API for text-to-speech functionality in AsTeRICS Grid", "documentation": "/docs", "endpoints": { - "voices": "/voices", - "speak": "/speak///", - "speakdata": "/speakdata///", - "speaking": "/speaking", - "stop": "/stop", + "voices": "/api/voices", + "speak": "/api/speak///", + "speakdata": "/api/speakdata///", + "speaking": "/api/speaking", + "stop": "/api/stop", }, } @@ -214,12 +232,23 @@ def get(self): voice_model = api.model( "Voice", { - "id": fields.String(description="Unique identifier for the voice"), - "name": fields.String(description="Display name of the voice"), + "id": fields.String( + description="Unique identifier for the voice", example="en-us-amy-medium" + ), + "name": fields.String( + description="Display name of the voice", example="Amy (en-US) - sherpaonnx" + ), + "language": fields.String(description="Primary language code", example="en-US"), "language_codes": fields.List( - fields.String, description="Supported language codes" + fields.String, description="Supported language codes", example=["en-US"] + ), + "gender": fields.String(description="Voice gender (M/F/N)", example="F"), + "providerId": fields.String( + description="ID of the TTS provider", example="sherpaonnx" + ), + "type": fields.String( + description="Type of voice playback", example="external_playing" ), - "gender": fields.String(description="Voice gender (M/F/N)"), }, ) @@ -229,7 +258,9 @@ def get(self): "voices": fields.List( fields.Nested(voice_model), description="List of available voices" ), - "status": fields.String(description="Response status (success/error)"), + "status": fields.String( + description="Response status (success/error)", example="success" + ), "error": fields.String( description="Error message if status is error", required=False ), @@ -239,15 +270,19 @@ def get(self): error_response = api.model( "ErrorResponse", { - "error": fields.String(description="Error message"), - "status": fields.String(description="Response status (error)"), + "error": fields.String( + description="Error message", example="Failed to generate speech data" + ), + "status": fields.String(description="Response status (error)", example="error"), }, ) success_response = api.model( "SuccessResponse", { - "status": fields.String(description="Response status (success)"), + "status": fields.String( + description="Response status (success)", example="success" + ), }, ) @@ -255,9 +290,11 @@ def get(self): "SpeakingResponse", { "speaking": fields.Boolean( - description="Whether text is currently being spoken" + description="Whether text is currently being spoken", example=False + ), + "status": fields.String( + description="Response status (success)", example="success" ), - "status": fields.String(description="Response status (success)"), }, ) @@ -279,7 +316,11 @@ def handle_error(error): @ns.route("/voices") class Voices(Resource): - @ns.doc("get_voices") + @ns.doc( + "get_voices", + description="Get a list of all available voices from all configured TTS providers", + responses={200: "Successfully retrieved voices", 500: "Internal server error"}, + ) @ns.response(200, "Success", voices_response) @ns.response(500, "Error", error_response) def get(self): @@ -315,8 +356,20 @@ def get(self): @ns.route("/speakdata//") @ns.route("/speakdata///") class SpeakData(Resource): - @ns.doc("get_speak_data") - @ns.param("text", "Text to convert to speech") + @ns.doc( + "get_speak_data", + description="Generate WAV audio data for the given text using specified voice", + params={ + "text": "Text to convert to speech (URL encoded)", + "provider_id": "ID of the TTS provider to use (optional)", + "voice_id": "ID of the voice to use (optional)", + }, + responses={ + 200: "Successfully generated speech data", + 500: "Internal server error", + }, + ) + @ns.param("text", "Text to convert to speech", required=True) @ns.param("provider_id", "TTS provider ID", required=False) @ns.param("voice_id", "Voice ID to use", required=False) @ns.response(200, "Success") @@ -354,8 +407,17 @@ def post(self, text: str, provider_id: str = "", voice_id: str = ""): @ns.route("/speak//") @ns.route("/speak///") class Speak(Resource): - @ns.doc("speak_text") - @ns.param("text", "Text to speak") + @ns.doc( + "speak_text", + description="Speak the given text using specified voice", + params={ + "text": "Text to speak (URL encoded)", + "provider_id": "ID of the TTS provider to use (optional)", + "voice_id": "ID of the voice to use (optional)", + }, + responses={200: "Successfully started speaking", 500: "Internal server error"}, + ) + @ns.param("text", "Text to speak", required=True) @ns.param("provider_id", "TTS provider ID", required=False) @ns.param("voice_id", "Voice ID to use", required=False) @ns.response(200, "Success", success_response) @@ -391,7 +453,14 @@ def cache_data(text: str, provider_id: str = "", voice_id: str = ""): @ns.route("/speaking") class Speaking(Resource): - @ns.doc("is_speaking") + @ns.doc( + "is_speaking", + description="Check if text is currently being spoken", + responses={ + 200: "Successfully retrieved speaking status", + 500: "Internal server error", + }, + ) @ns.response(200, "Success", speaking_response) @ns.response(500, "Error", error_response) def get(self): @@ -406,7 +475,11 @@ def get(self): @ns.route("/stop") class Stop(Resource): - @ns.doc("stop_speaking") + @ns.doc( + "stop_speaking", + description="Stop the current speech playback", + responses={200: "Successfully stopped speaking", 500: "Internal server error"}, + ) @ns.response(200, "Success", success_response) @ns.response(500, "Error", error_response) def get(self): From b655a99bbba0ab1f29426ceca74e4425bb8efdcb Mon Sep 17 00:00:00 2001 From: will wade Date: Sun, 23 Mar 2025 00:57:32 +0000 Subject: [PATCH 33/37] fixed - now working in asterics grid --- speech/speech_manager.py | 37 +++++++++++++++++++++++++++++++++---- speech/start.py | 5 +++-- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/speech/speech_manager.py b/speech/speech_manager.py index 310549e..2e31dab 100644 --- a/speech/speech_manager.py +++ b/speech/speech_manager.py @@ -2,6 +2,7 @@ import logging from typing import Any +import time from tts_wrapper import ( ElevenLabsClient, @@ -108,11 +109,21 @@ class SpeechManager: def __init__(self): """Initialize the speech manager.""" - self.logger = logging.getLogger(__name__) self.providers: dict[str, CustomTTSProvider] = {} self.current_provider: CustomTTSProvider | None = None + self.logger = logging.getLogger(__name__) + self._voices_cache = None + self._voices_cache_timestamp = 0 + self._voices_cache_ttl = 300 # Cache TTL in seconds (5 minutes) self.is_speaking = False + def _is_voices_cache_valid(self) -> bool: + """Check if the voices cache is still valid.""" + if self._voices_cache is None: + return False + current_time = time.time() + return (current_time - self._voices_cache_timestamp) < self._voices_cache_ttl + def init_providers(self, config: dict[str, Any]) -> None: """Initialize TTS providers from config.""" self.providers = {} @@ -250,6 +261,11 @@ def init_providers(self, config: dict[str, Any]) -> None: def get_voices(self) -> list[dict[str, Any]]: """Get available voices from all providers.""" + # Check cache first + if self._is_voices_cache_valid(): + self.logger.debug("Returning cached voices") + return self._voices_cache + all_voices = [] for provider_id, provider in self.providers.items(): try: @@ -270,18 +286,22 @@ def get_voices(self) -> list[dict[str, Any]]: else f"{base_name} - {provider_id}" ) self.logger.debug( - f"Formatting voice name: base={base_name}, lang={language}, result={formatted_name}" + "Formatting voice name: " + f"base={base_name}, lang={language}, result={formatted_name}" ) voice["name"] = formatted_name voice["providerId"] = provider_id - voice["type"] = "external_playing" + voice["type"] = "VOICE_TYPE_EXTERNAL_DATA" self.logger.debug(f"Final voice entry: {voice}") all_voices.extend(provider_voices) except Exception as e: self.logger.error(f"Error getting voices from {provider_id}: {e}") continue + # Update cache + self._voices_cache = all_voices + self._voices_cache_timestamp = time.time() return all_voices def speak(self, text: str, voice_id: str, provider_id: str | None = None) -> None: @@ -300,9 +320,12 @@ def speak(self, text: str, voice_id: str, provider_id: str | None = None) -> Non return try: + self.is_speaking = True provider.speak(text, voice_id) + self.is_speaking = False except Exception as e: self.logger.error(f"Error speaking text: {e}") + self.is_speaking = False def get_speak_data( self, text: str, voice_id: str, provider_id: str | None = None @@ -322,9 +345,13 @@ def get_speak_data( return b"" try: - return provider.get_speak_data(text, voice_id) + self.is_speaking = True + data = provider.get_speak_data(text, voice_id) + self.is_speaking = False + return data except Exception as e: self.logger.error(f"Error getting speech data: {e}") + self.is_speaking = False return b"" def stop_speaking(self) -> None: @@ -334,6 +361,8 @@ def stop_speaking(self) -> None: self.current_provider.stop_speaking() except Exception as e: self.logger.error(f"Error stopping speech: {e}") + finally: + self.is_speaking = False def get_voices(speech_manager: SpeechManager) -> list[dict[str, Any]]: diff --git a/speech/start.py b/speech/start.py index afc4fa7..a84700d 100644 --- a/speech/start.py +++ b/speech/start.py @@ -343,13 +343,14 @@ def get(self): "providerId": voice[ "providerId" ], # Provider ID is set by SpeechManager - "type": voice.get("type", "external_playing"), + "type": voice.get("type", "external_data"), + "ref": voice, # Include the entire voice object as ref } transformed_voices.append(transformed_voice) return transformed_voices except Exception as e: logger.error(f"Error in /voices endpoint: {e!s}", exc_info=True) - return {"error": str(e), "status": "error", "voices": []}, 200 + return [] @ns.route("/speakdata/") From 21533d944a2ce17eca708f0b857821e12dc987fc Mon Sep 17 00:00:00 2001 From: will wade Date: Sun, 23 Mar 2025 01:17:57 +0000 Subject: [PATCH 34/37] safe cleanup --- speech/speech_manager.py | 91 ++++++++++++++++++++++++++-------------- 1 file changed, 59 insertions(+), 32 deletions(-) diff --git a/speech/speech_manager.py b/speech/speech_manager.py index 2e31dab..4f2a1ca 100644 --- a/speech/speech_manager.py +++ b/speech/speech_manager.py @@ -267,42 +267,69 @@ def get_voices(self) -> list[dict[str, Any]]: return self._voices_cache all_voices = [] - for provider_id, provider in self.providers.items(): - try: - self.logger.info(f"Getting voices from provider: {provider_id}") - provider_voices = provider.get_voices() - self.logger.debug( - f"Voices from provider {provider_id}: {provider_voices}" - ) - - # Add provider ID and type to each voice - for voice in provider_voices: - # Format name as "Name (Language) - provider" - base_name = voice["name"] - language = voice.get("language", "") - formatted_name = ( - f"{base_name} ({language}) - {provider_id}" - if language - else f"{base_name} - {provider_id}" - ) + try: + for provider_id, provider in self.providers.items(): + try: + self.logger.info(f"Getting voices from provider: {provider_id}") + provider_voices = provider.get_voices() self.logger.debug( - "Formatting voice name: " - f"base={base_name}, lang={language}, result={formatted_name}" + f"Voices from provider {provider_id}: {provider_voices}" ) - voice["name"] = formatted_name - voice["providerId"] = provider_id - voice["type"] = "VOICE_TYPE_EXTERNAL_DATA" - self.logger.debug(f"Final voice entry: {voice}") - all_voices.extend(provider_voices) - except Exception as e: - self.logger.error(f"Error getting voices from {provider_id}: {e}") - continue + # Add provider ID and type to each voice + for voice in provider_voices: + # Format name as "Name (Language) - provider" + base_name = voice["name"] + language = voice.get("language", "") + + # Convert language format if needed (e.g., "English (US)" to "en-US") + if language and "(" in language: + lang_parts = language.split("(") + if len(lang_parts) == 2: + lang_code = lang_parts[1].strip(")") + if lang_code == "US": + language = "en-US" + elif lang_code == "UK": + language = "en-GB" + # Add more mappings as needed + + formatted_name = ( + f"{base_name} ({language}) - {provider_id}" + if language + else f"{base_name} - {provider_id}" + ) + self.logger.debug( + "Formatting voice name: " + f"base={base_name}, lang={language}, result={formatted_name}" + ) - # Update cache - self._voices_cache = all_voices - self._voices_cache_timestamp = time.time() - return all_voices + voice["name"] = formatted_name + voice["providerId"] = provider_id + voice["type"] = "VOICE_TYPE_EXTERNAL_DATA" + # Ensure language_codes is a list with proper format + if "language_codes" in voice: + voice["language_codes"] = [language] if language else [] + else: + voice["language_codes"] = [language] if language else [] + + self.logger.debug(f"Final voice entry: {voice}") + all_voices.extend(provider_voices) + except Exception as e: + self.logger.error(f"Error getting voices from {provider_id}: {e}") + continue + + # Update cache + self._voices_cache = all_voices + self._voices_cache_timestamp = time.time() + return all_voices + finally: + # Ensure any resources are cleaned up + for provider in self.providers.values(): + if hasattr(provider, "cleanup"): + try: + provider.cleanup() + except Exception as e: + self.logger.error(f"Error cleaning up provider: {e}") def speak(self, text: str, voice_id: str, provider_id: str | None = None) -> None: """Speak text using specified voice.""" From 78566a5cd4ef20a50e86903cb482fe4844286416 Mon Sep 17 00:00:00 2001 From: will wade Date: Sun, 23 Mar 2025 01:20:09 +0000 Subject: [PATCH 35/37] Add debug logging to speech endpoints ( found a problem with playht - largely because ive run out of credit but it caused a memory crash) Add detailed console logging for speak and speakdata endpoints - Log URL construction and response status for better debugging - Track speaking status updates in console - Fix variable naming to avoid redeclaration errors - Improve error visibility for speech service connection issues This change helps diagnose connection and voice selection issues by providing more detailed logging in the browser console when speech endpoints are called. --- speech/templates/test.html | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/speech/templates/test.html b/speech/templates/test.html index 093ca77..24f5d3a 100644 --- a/speech/templates/test.html +++ b/speech/templates/test.html @@ -315,9 +315,12 @@
${voice.name}
// Direct speak endpoint const encodedText = encodeURIComponent(text); const selectedVoice = allVoices.find(v => v.id === selectedVoiceId); - await fetch(`/api/speak/${encodedText}/${selectedVoice.providerId}/${selectedVoiceId}`, { + const apiUrl = `/api/speak/${encodedText}/${selectedVoice.providerId}/${selectedVoiceId}`; + console.log('Calling speak endpoint:', apiUrl); + const response = await fetch(apiUrl, { method: 'POST' }); + console.log('Speak response:', response.status, await response.text()); // Start checking speaking status isSpeaking = true; @@ -325,6 +328,7 @@
${voice.name}
speakingCheckInterval = setInterval(async () => { const statusResponse = await fetch('/api/speaking'); const status = await statusResponse.json(); + console.log('Speaking status:', status); if (!status.speaking) { clearInterval(speakingCheckInterval); isSpeaking = false; @@ -338,13 +342,16 @@
${voice.name}
// Audio stream endpoint const encodedText = encodeURIComponent(text); const selectedVoice = allVoices.find(v => v.id === selectedVoiceId); - const response = await fetch(`/api/speakdata/${encodedText}/${selectedVoice.providerId}/${selectedVoiceId}`, { + const apiUrl = `/api/speakdata/${encodedText}/${selectedVoice.providerId}/${selectedVoiceId}`; + console.log('Calling speakdata endpoint:', apiUrl); + const response = await fetch(apiUrl, { method: 'POST' }); + console.log('Speakdata response:', response.status); const blob = await response.blob(); - const url = URL.createObjectURL(blob); - audioPlayer.src = url; + const blobUrl = URL.createObjectURL(blob); + audioPlayer.src = blobUrl; audioPlayer.play(); speakButton.disabled = false; From 278824381edbf1326ef8617f6acb8c77f2fad1ec Mon Sep 17 00:00:00 2001 From: will wade Date: Tue, 25 Mar 2025 10:59:24 +0000 Subject: [PATCH 36/37] refactor to new abstraction method. --- classDiagram.yml | 72 ++++++ speech/audio_manager.py | 415 +++++++++++++++++++++++++++++++++ speech/base_provider.py | 28 --- speech/config.py | 124 ---------- speech/custom_providers.py | 64 +++-- speech/provider_factory.py | 163 +++++++++++++ speech/speech_manager.py | 431 ---------------------------------- speech/start.py | 224 ++++++++++++------ speech/templates/test.html | 165 +++++++++---- speech/test_audio.py | 205 ++++++++++++++++ speech/test_engines.py | 462 +++++++++++++++++++++++++++++++++++++ speech/tts_provider.py | 246 ++++++++++++++++++++ 12 files changed, 1863 insertions(+), 736 deletions(-) create mode 100644 classDiagram.yml create mode 100644 speech/audio_manager.py delete mode 100644 speech/base_provider.py delete mode 100644 speech/config.py create mode 100644 speech/provider_factory.py delete mode 100644 speech/speech_manager.py create mode 100644 speech/test_audio.py create mode 100644 speech/test_engines.py create mode 100644 speech/tts_provider.py diff --git a/classDiagram.yml b/classDiagram.yml new file mode 100644 index 0000000..b91731a --- /dev/null +++ b/classDiagram.yml @@ -0,0 +1,72 @@ +classDiagram + class TTSProviderAbstract { + <> + +AudioManager audio_manager + +Dict _voice_cache + +Callable _on_start + +Callable _on_stop + +Callable _on_complete + +bool _was_stopped + +Dict config + +bool _cache_enabled + +Path _cache_dir + +Path _audio_cache_dir + +speak(text: str, voice_id: str, on_complete: Callable) bool + +get_speak_data(text: str, voice_id: str) bytes + +get_voices() list[dict] + +stop_speaking() void + +set_speech_handlers(on_start: Callable, on_stop: Callable, on_complete: Callable) void + +_generate_speak_data(text: str, voice_id: str) bytes* + +_get_cache_key(text: str, voice_id: str) str + +_get_cache_path(cache_key: str) Path + +_get_metadata_path(cache_key: str) Path + +_cache_audio_data(cache_key: str, audio_data: bytes, metadata: dict) void + +_get_cached_audio(cache_key: str) bytes + +_get_cached_voices() list[dict] + } + + class AudioManager { + -sd.OutputStream _stream + -bool _is_playing + -Callable _on_complete + -Callable _on_start + -threading.RLock _stream_lock + -Exception _callback_error + -float _last_callback_time + -threading.Event _cleanup_event + -queue.Queue _stream_queue + -Path _cache_dir + -int _cache_size_limit + +play_audio(audio_data: bytes, on_complete: Callable, on_start: Callable) bool + +stop() void + +is_playing bool + -_stream_cleanup_worker() void + -_get_cache_key(audio_data: bytes) str + -_get_cache_path(cache_key: str) Path + -_cache_audio(audio_data: bytes) Path + -_clean_cache_if_needed() void + -_load_audio_data(audio_data: bytes) tuple + -_safe_stream_operation(operation: Callable) bool + -_create_stream(samplerate: int, channels: int, callback: Callable, finished_callback: Callable) bool + -_check_stream_timeout() void + } + + class TTSProviderFactory { + +create_wrapper_provider(tts_instance: Any) TTSProviderAbstract + +create_provider(provider_type: str, config: dict) TTSProviderAbstract + } + + class WrappedProvider { + -Any tts + +get_voices() list[dict] + +_generate_speak_data(text: str, voice_id: str) bytes + } + + class OpenAITTSProvider { + +_generate_speak_data(text: str, voice_id: str) bytes + } + + TTSProviderAbstract <|-- WrappedProvider + TTSProviderAbstract <|-- OpenAITTSProvider + TTSProviderFactory --> WrappedProvider + TTSProviderAbstract --> AudioManager \ No newline at end of file diff --git a/speech/audio_manager.py b/speech/audio_manager.py new file mode 100644 index 0000000..6bae2d7 --- /dev/null +++ b/speech/audio_manager.py @@ -0,0 +1,415 @@ +"""Audio playback manager for TTS providers.""" + +import logging +from pathlib import Path +from typing import Optional, Callable, Any +import hashlib +import tempfile +import threading +import time +import queue +import weakref +import subprocess +import os +import sys + +import numpy as np +import sounddevice as sd +import soundfile as sf + +logger = logging.getLogger(__name__) + + +class AudioManager: + """Manages audio playback and caching.""" + + def __init__(self, cache_dir: Optional[str] = None): + """Initialize audio manager. + + Args: + cache_dir: Optional directory for caching audio files. If None, + uses system temp directory. + """ + self._stream: Optional[sd.OutputStream] = None + self._is_playing = False + self._on_complete: Optional[Callable] = None + self._on_start: Optional[Callable] = None + self._stream_lock = threading.RLock() + self._callback_error = None + self._last_callback_time = 0 + self._cleanup_event = threading.Event() + self._stream_queue = queue.Queue() + self.logger = logging.getLogger(__name__) + + # Setup cleanup thread + self._cleanup_thread = threading.Thread( + target=self._stream_cleanup_worker, daemon=True + ) + self._cleanup_thread.start() + + # Setup caching + if cache_dir: + self._cache_dir = Path(cache_dir) + else: + self._cache_dir = Path(tempfile.gettempdir()) / "tts_cache" + self._cache_dir.mkdir(parents=True, exist_ok=True) + + # Cache size limit (100MB) + self._cache_size_limit = 100 * 1024 * 1024 + + def _stream_cleanup_worker(self): + """Worker thread for safe stream cleanup.""" + while True: + try: + stream = self._stream_queue.get(timeout=0.5) + if stream is None: # Shutdown signal + break + try: + stream.stop() + except: + pass + time.sleep(0.05) # Short delay + try: + stream.close() + except: + pass + except queue.Empty: + if self._cleanup_event.is_set(): + break + continue + except Exception as e: + self.logger.debug(f"Cleanup worker error: {str(e)}") + + def _get_cache_key(self, audio_data: bytes) -> str: + """Generate cache key from audio data.""" + return hashlib.md5(audio_data).hexdigest() + + def _get_cache_path(self, cache_key: str) -> Path: + """Get path for cached audio file.""" + return self._cache_dir / f"{cache_key}.wav" + + def _cache_audio(self, audio_data: bytes) -> Optional[Path]: + """Cache audio data to file.""" + try: + # Check cache size and clean if needed + self._clean_cache_if_needed() + + # Generate cache key and path + cache_key = self._get_cache_key(audio_data) + cache_path = self._get_cache_path(cache_key) + + # Skip if already cached + if cache_path.exists(): + return cache_path + + # Write to cache + with open(cache_path, "wb") as f: + f.write(audio_data) + + return cache_path + + except Exception as e: + self.logger.error(f"Error caching audio: {e}") + return None + + def _clean_cache_if_needed(self) -> None: + """Clean oldest cache files if total size exceeds limit.""" + try: + # Get cache files sorted by modification time + cache_files = sorted( + self._cache_dir.glob("*.wav"), key=lambda p: p.stat().st_mtime + ) + + # Calculate total size + total_size = sum(p.stat().st_size for p in cache_files) + + # Remove oldest files until under limit + while total_size > self._cache_size_limit and cache_files: + file_to_remove = cache_files.pop(0) + total_size -= file_to_remove.stat().st_size + file_to_remove.unlink() + + except Exception as e: + self.logger.error(f"Error cleaning cache: {e}") + + def _load_audio_data(self, audio_data: bytes) -> tuple[np.ndarray, int]: + """Load and normalize audio data. + + Args: + audio_data: WAV format audio data + + Returns: + Tuple of (normalized audio array, sample rate) + """ + # Try to get cached file + cache_path = self._cache_audio(audio_data) + if cache_path and cache_path.exists(): + # Load from cache + data, samplerate = sf.read(str(cache_path)) + else: + # Load directly from bytes + with tempfile.NamedTemporaryFile(suffix=".wav") as temp_file: + temp_file.write(audio_data) + temp_file.flush() + data, samplerate = sf.read(temp_file.name) + + # Ensure data is float32 + if data.dtype != np.float32: + data = data.astype(np.float32) + + # Ensure 2D array for both mono and stereo + if len(data.shape) == 1: + data = data.reshape(-1, 1) + + return data, samplerate + + def _safe_stream_operation(self, operation: Callable) -> bool: + """Safely perform a stream operation with proper locking and error handling. + + Args: + operation: Function that performs the stream operation + + Returns: + bool: True if operation succeeded + """ + if not self._stream_lock.acquire( + timeout=0.5 + ): # Add timeout to prevent deadlock + self.logger.warning("Could not acquire stream lock - timeout") + return False + + try: + if self._stream is not None: + try: + operation() + return True + except Exception as e: + self.logger.debug(f"Stream operation failed: {str(e)}") + return False + finally: + try: + self._stream_lock.release() + except: + pass + + def _create_stream( + self, + samplerate: int, + channels: int, + callback: Callable, + finished_callback: Callable, + ) -> bool: + """Create audio output stream with error handling. + + Returns: + bool: True if stream was created successfully + """ + if not self._stream_lock.acquire(timeout=0.5): + self.logger.warning("Could not acquire stream lock for creation - timeout") + return False + + try: + # Ensure old stream is cleaned up + if self._stream is not None: + self._stream_queue.put(self._stream) + self._stream = None + + try: + self._stream = sd.OutputStream( + samplerate=samplerate, + channels=channels, + callback=callback, + finished_callback=finished_callback, + ) + return True + except Exception as e: + self.logger.error(f"Error creating audio stream: {str(e)}") + self._stream = None + return False + finally: + try: + self._stream_lock.release() + except: + pass + + def _check_stream_timeout(self) -> None: + """Check if stream has timed out and clean up if needed.""" + if ( + self._is_playing and time.time() - self._last_callback_time > 2.0 + ): # 2 second timeout + self.logger.warning("Audio stream timeout detected - forcing cleanup") + self.stop() + + def play_audio( + self, + audio_data: bytes, + on_complete: Optional[Callable] = None, + on_error: Optional[Callable] = None, + ) -> bool: + """Play audio data through system speakers. + + Args: + audio_data: Audio data as bytes + on_complete: Optional callback when playback completes + on_error: Optional callback when an error occurs + + Returns: + True if audio started playing, False if error occurred + """ + try: + logger.debug("Starting audio playback in AudioManager") + logger.debug(f"Audio data size: {len(audio_data)} bytes") + logger.debug(f"on_complete callback: {on_complete}") + logger.debug(f"on_error callback: {on_error}") + + # Create a temporary file for the audio data + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as temp_file: + temp_file.write(audio_data) + temp_path = temp_file.name + + logger.debug(f"Created temporary audio file: {temp_path}") + + # Use platform-specific audio playback command + if sys.platform == "darwin": # macOS + cmd = ["afplay", temp_path] + elif sys.platform == "linux": + cmd = ["aplay", temp_path] + elif sys.platform == "win32": # Windows + # Use PowerShell to play audio + cmd = [ + "powershell", + "-c", + f"Add-Type -AssemblyName Media.SoundPlayer; (New-Object Media.SoundPlayer '{temp_path}').PlaySync()", + ] + else: + raise RuntimeError(f"Unsupported platform: {sys.platform}") + + logger.debug(f"Using audio command: {' '.join(cmd)}") + + # Set up the audio process + self.process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + # Start monitoring thread + self._monitor_thread = threading.Thread( + target=self._monitor_playback, + args=(temp_path, on_complete, on_error), + ) + self._monitor_thread.daemon = True + self._monitor_thread.start() + + logger.debug("Started audio playback monitoring thread") + return True + + except Exception as e: + logger.error(f"Error starting audio playback: {e}", exc_info=True) + if on_error: + try: + logger.debug("Calling error callback") + on_error(e) + except Exception as callback_error: + logger.error( + f"Error in error callback: {callback_error}", exc_info=True + ) + return False + + def _monitor_playback( + self, + temp_path: str, + on_complete: Optional[Callable] = None, + on_error: Optional[Callable] = None, + ) -> None: + """Monitor audio playback process. + + Args: + temp_path: Path to temporary audio file + on_complete: Optional callback when playback completes + on_error: Optional callback when an error occurs + """ + try: + logger.debug("Starting playback monitoring") + stdout, stderr = self.process.communicate() + + # Clean up temporary file + try: + os.unlink(temp_path) + logger.debug(f"Cleaned up temporary file: {temp_path}") + except Exception as e: + logger.error(f"Error cleaning up temporary file: {e}") + + if self.process.returncode == 0: + logger.debug("Audio playback completed normally") + if on_complete: + try: + logger.debug("Calling completion callback") + on_complete() + except Exception as e: + logger.error( + f"Error in completion callback: {e}", exc_info=True + ) + else: + error_msg = stderr.decode() if stderr else "Unknown error" + logger.error(f"Audio playback failed: {error_msg}") + if on_error: + try: + logger.debug("Calling error callback") + on_error(error_msg) + except Exception as e: + logger.error(f"Error in error callback: {e}", exc_info=True) + + except Exception as e: + logger.error(f"Error in playback monitoring: {e}", exc_info=True) + if on_error: + try: + logger.debug("Calling error callback") + on_error(e) + except Exception as callback_error: + logger.error( + f"Error in error callback: {callback_error}", exc_info=True + ) + + def stop(self) -> None: + """Stop current audio playback.""" + if not self._stream_lock.acquire(timeout=0.5): + self.logger.warning("Could not acquire stream lock for stop - timeout") + return + + try: + if self._stream is not None: + # Queue the stream for cleanup rather than handling it directly + self._stream_queue.put(self._stream) + self._stream = None + + # Reset state + self._is_playing = False + self._callback_error = None + + finally: + try: + self._stream_lock.release() + except: + pass + + @property + def is_playing(self) -> bool: + """Check if audio is currently playing.""" + with self._stream_lock: + # Check for timeout + self._check_stream_timeout() + # Ensure state consistency + if self._stream is None and self._is_playing: + self._is_playing = False + return self._is_playing + + def __del__(self): + """Cleanup on deletion.""" + self._cleanup_event.set() + if self._stream is not None: + try: + self._stream_queue.put(None) # Signal cleanup thread to stop + self._cleanup_thread.join(timeout=1.0) # Wait for cleanup with timeout + except: + pass diff --git a/speech/base_provider.py b/speech/base_provider.py deleted file mode 100644 index 23a80d6..0000000 --- a/speech/base_provider.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Base class for TTS providers.""" - -import logging -from typing import Any - - -class CustomTTSProvider: - """Base class for custom TTS providers.""" - - def __init__(self): - """Initialize the provider.""" - self.logger = logging.getLogger(__name__) - - def get_voices(self) -> list[dict[str, Any]]: - """Get available voices.""" - return [] - - def speak(self, text: str, voice_id: str) -> None: - """Speak text using specified voice.""" - pass - - def get_speak_data(self, text: str, voice_id: str) -> bytes: - """Get WAV audio data for text.""" - return b"" - - def stop_speaking(self) -> None: - """Stop current speech playback.""" - pass diff --git a/speech/config.py b/speech/config.py deleted file mode 100644 index e8ec4d7..0000000 --- a/speech/config.py +++ /dev/null @@ -1,124 +0,0 @@ -""" -Configuration for the speech service. -""" - -import logging -import os - -# Configure logging -logger = logging.getLogger(__name__) -logger.setLevel(logging.DEBUG) - -# Default TTS engines to use (comma-separated list) -TTS_ENGINE = "sherpaonnx" # Can be single engine or comma-separated list - -# Credentials for cloud services -CREDENTIALS = { - # AWS Polly - "AWS_REGION": "us-east-1", # e.g., us-east-1 - "AWS_KEY_ID": "", # Your AWS access key ID - "AWS_SECRET_ACCESS_KEY": "", # Your AWS secret access key - # Google Cloud - "GOOGLE_SA_PATH": "", # Path to service account JSON file or dict of credentials - # Microsoft Azure - "AZURE_KEY": "", # Your Azure subscription key - "AZURE_REGION": "", # e.g., eastus - # IBM Watson - "WATSON_API_KEY": "", # Your Watson API key - "WATSON_REGION": "", # e.g., us-east - "WATSON_INSTANCE_ID": "", # Your Watson instance ID - # ElevenLabs - "ELEVENLABS_KEY": "", # Your ElevenLabs API key - # Wit.AI - "WITAI_TOKEN": "", # Your Wit.AI token -} - -# Caching settings -CACHE_ENABLED = True -CACHE_DIR = "temp" - -# SherpaOnnx specific configuration -SHERPA_ONNX_CONFIG = { - "model_dir": "models/sherpa-onnx", -} - -# Watson specific configuration -WATSON_CONFIG = { - "disableSSLVerification": False, # Set to True if you have SSL certificate issues -} - - -def get_tts_config(): - """Get the TTS configuration based on the selected engines.""" - # Split the engine string into a list and clean it - engines = [e.strip() for e in TTS_ENGINE.split(",")] - logger.info(f"Config: Parsed engines from TTS_ENGINE: {engines}") - - config = { - "engines": engines, # List of engines to initialize - "credentials": CREDENTIALS, - "cache_enabled": CACHE_ENABLED, - "cache_dir": CACHE_DIR, - } - logger.info(f"Config: Final engines list in config: {config['engines']}") - - # Add engine-specific configurations - engine_configs = {} - for engine in engines: - engine_name = engine.lower() - if engine_name == "espeak": - engine_configs[engine_name] = {} # espeak doesn't need special config - elif engine_name == "sherpaonnx": - engine_configs[engine_name] = { - "model_path": os.getenv("SHERPAONNX_MODEL_PATH"), - "tokens_path": os.getenv("SHERPAONNX_TOKENS_PATH"), - } - elif engine_name == "google": - engine_configs[engine_name] = { - "credentials": { - "api_key": os.getenv("GOOGLE_API_KEY"), - } - } - elif engine_name == "microsoft": - engine_configs[engine_name] = { - "credentials": { - "api_key": os.getenv("MICROSOFT_API_KEY"), - "region": os.getenv("MICROSOFT_REGION"), - } - } - elif engine_name == "polly": - engine_configs[engine_name] = { - "credentials": { - "aws_access_key_id": os.getenv("AWS_ACCESS_KEY_ID"), - "aws_secret_access_key": os.getenv("AWS_SECRET_ACCESS_KEY"), - "region_name": os.getenv("AWS_REGION"), - } - } - elif engine_name == "watson": - engine_configs[engine_name] = { - "credentials": { - "api_key": os.getenv("WATSON_API_KEY"), - "url": os.getenv("WATSON_URL"), - } - } - elif engine_name == "elevenlabs": - engine_configs[engine_name] = { - "credentials": { - "api_key": os.getenv("ELEVENLABS_API_KEY"), - } - } - elif engine_name == "witai": - engine_configs[engine_name] = { - "credentials": { - "api_key": os.getenv("WITAI_API_KEY"), - } - } - elif engine_name == "avsynth": - engine_configs[engine_name] = { - "script_path": os.getenv("AVSYNTH_SCRIPT_PATH"), - } - elif engine_name == "sapi": - engine_configs[engine_name] = {} # SAPI doesn't need special config - - config["engine_configs"] = engine_configs - return config diff --git a/speech/custom_providers.py b/speech/custom_providers.py index 1e5c3f7..989db26 100644 --- a/speech/custom_providers.py +++ b/speech/custom_providers.py @@ -1,11 +1,12 @@ """Custom TTS provider implementations.""" -from typing import Any +import logging +from typing import Any, Optional -from .base_provider import CustomTTSProvider +from .tts_provider import TTSProviderAbstract -class OpenAITTSProvider(CustomTTSProvider): +class OpenAITTSProvider(TTSProviderAbstract): """OpenAI TTS provider implementation.""" def __init__(self, config: dict[str, Any] | None = None): @@ -18,6 +19,7 @@ def __init__(self, config: dict[str, Any] | None = None): - output_format: Output format (default: "wav") """ super().__init__() + self.logger = logging.getLogger(__name__) self.config = config or {} self.api_key = self.config.get("api_key") if not self.api_key: @@ -27,16 +29,26 @@ def __init__(self, config: dict[str, Any] | None = None): self.output_format = self.config.get("output_format", "wav") # Initialize OpenAI client - from openai import OpenAI + try: + import openai + except ImportError: + raise ImportError( + "OpenAI package not installed. Install with: uv pip install openai" + ) - self.client = OpenAI(api_key=self.api_key) + self.client = openai.OpenAI(api_key=self.api_key) def get_voices(self) -> list[dict[str, Any]]: - """Get available OpenAI voices.""" + """Get available OpenAI voices. + + Returns: + List of voice dictionaries in standardized format. + """ return [ { "id": voice, "name": voice.capitalize(), + "language": "en", "language_codes": ["en"], # OpenAI voices are optimized for English "gender": "Unknown", } @@ -54,32 +66,16 @@ def get_voices(self) -> list[dict[str, Any]]: ] ] - def speak(self, text: str, voice_id: str) -> None: - """Speak text using OpenAI TTS.""" - try: - # Generate audio data - audio_data = self.get_speak_data(text, voice_id) - if not audio_data: - raise RuntimeError("Failed to generate audio data") - - # Play audio using system's audio player - import io - - import sounddevice as sd - import soundfile as sf + def get_speak_data(self, text: str, voice_id: str) -> Optional[bytes]: + """Get WAV audio data for text using OpenAI TTS. - # Read WAV data into numpy array - audio_stream = io.BytesIO(audio_data) - data, samplerate = sf.read(audio_stream) - - # Play audio - sd.play(data, samplerate) - sd.wait() - except Exception as e: - self.logger.error(f"Error speaking text: {e}") + Args: + text: Text to convert to speech + voice_id: Voice ID to use - def get_speak_data(self, text: str, voice_id: str) -> bytes: - """Get WAV audio data for text using OpenAI TTS.""" + Returns: + Audio data as bytes, or None if synthesis failed + """ try: # Generate speech response = self.client.audio.speech.create( @@ -93,10 +89,4 @@ def get_speak_data(self, text: str, voice_id: str) -> bytes: return response.content except Exception as e: self.logger.error(f"Error getting speech data: {e}") - return b"" - - def stop_speaking(self) -> None: - """Stop current speech playback.""" - import sounddevice as sd - - sd.stop() + return None diff --git a/speech/provider_factory.py b/speech/provider_factory.py new file mode 100644 index 0000000..4a10495 --- /dev/null +++ b/speech/provider_factory.py @@ -0,0 +1,163 @@ +"""Factory for creating TTS providers.""" + +import logging +import importlib.util +import os +import tempfile +from typing import Optional, Any + +from .tts_provider import TTSProviderAbstract +from .custom_providers import OpenAITTSProvider + +# Import TTS wrapper clients +from tts_wrapper import ( + ElevenLabsClient, + ElevenLabsTTS, + GoogleClient, + GoogleTTS, + GoogleTransClient, + GoogleTransTTS, + MicrosoftClient, + MicrosoftTTS, + PlayHTClient, + PlayHTTTS, + PollyClient, + PollyTTS, + SherpaOnnxClient, + SherpaOnnxTTS, + WatsonClient, + WatsonTTS, + WitAiClient, + WitAiTTS, + eSpeakClient, + eSpeakTTS, +) + +logger = logging.getLogger(__name__) + + +class TTSProviderFactory: + """Factory for creating TTS provider instances.""" + + @staticmethod + def create_wrapper_provider(tts_instance: Any) -> TTSProviderAbstract: + """Create a provider that wraps a TTS wrapper instance.""" + + class WrappedProvider(TTSProviderAbstract): + def __init__(self): + super().__init__() + self.tts = tts_instance + self.logger = logging.getLogger(__name__) + + def get_voices(self) -> list[dict[str, Any]]: + return self.tts.get_voices() if self.tts else [] + + def _generate_speak_data(self, text: str, voice_id: str) -> Optional[bytes]: + try: + # Create a temporary WAV file + with tempfile.NamedTemporaryFile( + suffix=".wav", delete=False + ) as temp_file: + temp_path = temp_file.name + + try: + # Synthesize to WAV file using TTS wrapper + self.tts.synth_to_file(text, temp_path, voice_id=voice_id) + + # Read the WAV file + with open(temp_path, "rb") as f: + wav_data = f.read() + + return wav_data + finally: + # Clean up temp file + try: + os.unlink(temp_path) + except Exception as e: + self.logger.debug(f"Error cleaning up temp file: {e}") + + except Exception as e: + self.logger.error(f"Error getting speech data: {e}") + return None + + return WrappedProvider() + + @staticmethod + def create_provider( + provider_type: str, config: dict[str, Any] + ) -> Optional[TTSProviderAbstract]: + """Create a TTS provider instance. + + Args: + provider_type: Type of provider to create + config: Provider configuration + + Returns: + TTSProvider instance or None if creation fails + """ + try: + + # Get provider-specific config + provider_config = config.get("engine_configs", {}).get(provider_type, {}) + + # Handle custom providers first + if provider_type == "openai": + return OpenAITTSProvider(provider_config) + + # Handle TTS wrapper providers + if provider_type == "microsoft": + credentials = provider_config.get("credentials", ("", "")) + client = MicrosoftClient(credentials=credentials) + return TTSProviderFactory.create_wrapper_provider(MicrosoftTTS(client)) + + elif provider_type == "google": + credentials = provider_config.get("credentials", {}) + client = GoogleClient(credentials=credentials) + return TTSProviderFactory.create_wrapper_provider(GoogleTTS(client)) + + elif provider_type == "googletrans": + client = GoogleTransClient() + return TTSProviderFactory.create_wrapper_provider( + GoogleTransTTS(client) + ) + + elif provider_type == "elevenlabs": + credentials = provider_config.get("credentials", ("",)) + client = ElevenLabsClient(credentials=credentials) + return TTSProviderFactory.create_wrapper_provider(ElevenLabsTTS(client)) + + elif provider_type == "polly": + credentials = provider_config.get("credentials", ("", "", "")) + client = PollyClient(credentials=credentials) + return TTSProviderFactory.create_wrapper_provider(PollyTTS(client)) + + elif provider_type == "watson": + credentials = provider_config.get("credentials", ("", "", "")) + client = WatsonClient(credentials=credentials) + return TTSProviderFactory.create_wrapper_provider(WatsonTTS(client)) + + elif provider_type == "witai": + credentials = provider_config.get("credentials", ("",)) + client = WitAiClient(credentials=credentials) + return TTSProviderFactory.create_wrapper_provider(WitAiTTS(client)) + + elif provider_type == "playht": + credentials = provider_config.get("credentials", ("", "")) + client = PlayHTClient(credentials=credentials) + return TTSProviderFactory.create_wrapper_provider(PlayHTTTS(client)) + + elif provider_type == "sherpaonnx": + client = SherpaOnnxClient() + return TTSProviderFactory.create_wrapper_provider(SherpaOnnxTTS(client)) + + elif provider_type == "espeak": + client = eSpeakClient() + return TTSProviderFactory.create_wrapper_provider(eSpeakTTS(client)) + + else: + logger.error(f"Unknown provider type: {provider_type}") + return None + + except Exception as e: + logger.error(f"Error creating provider {provider_type}: {e}") + return None diff --git a/speech/speech_manager.py b/speech/speech_manager.py deleted file mode 100644 index 4f2a1ca..0000000 --- a/speech/speech_manager.py +++ /dev/null @@ -1,431 +0,0 @@ -"""Speech manager for handling TTS providers.""" - -import logging -from typing import Any -import time - -from tts_wrapper import ( - ElevenLabsClient, - ElevenLabsTTS, - GoogleClient, - GoogleTransClient, - GoogleTransTTS, - GoogleTTS, - MicrosoftClient, - MicrosoftTTS, - PlayHTClient, - PlayHTTTS, - PollyClient, - PollyTTS, - SherpaOnnxClient, - SherpaOnnxTTS, - WatsonClient, - WatsonTTS, - WitAiClient, - WitAiTTS, - eSpeakClient, - eSpeakTTS, -) - -from .base_provider import CustomTTSProvider -from .custom_providers import OpenAITTSProvider - - -class TTSProvider(CustomTTSProvider): - """Base class for TTS wrapper providers.""" - - def __init__(self): - """Initialize the provider.""" - super().__init__() - self.tts = None - self.timings = [] - - def get_voices(self) -> list[dict[str, Any]]: - """Get available voices.""" - voices = self.tts.get_voices() - cleaned_voices = [] - for voice in voices: - language_codes = voice.get("language_codes", [""]) - language = language_codes[0] if language_codes else "" - cleaned_voices.append( - { - "id": voice.get("id", ""), - "name": voice.get("name", ""), - "language": language, - "language_codes": voice.get("language_codes", []), - "gender": voice.get("gender", "Unknown"), - } - ) - return cleaned_voices - - def speak(self, text: str, voice_id: str) -> None: - """Speak text using specified voice.""" - try: - # First try direct speak - self.tts.speak(text, voice_id=voice_id) - except Exception as e: - self.logger.error(f"Error speaking text: {e}") - - def get_speak_data(self, text: str, voice_id: str) -> bytes: - """Get WAV audio data for text.""" - try: - import os - import tempfile - - # Create a temporary WAV file - with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as temp_file: - temp_path = temp_file.name - - try: - # Synthesize to WAV file - self.tts.synth_to_file(text, temp_path, voice_id=voice_id) - - # Read the WAV file - with open(temp_path, "rb") as f: - wav_data = f.read() - - return wav_data - finally: - # Clean up temp file - try: - os.unlink(temp_path) - except Exception as e: - self.logger.debug(f"Error cleaning up temp file: {e}") - - except Exception as e: - self.logger.error(f"Error getting speech data: {e}") - return b"" - - def stop_speaking(self) -> None: - """Stop current speech playback.""" - try: - self.tts.stop_speaking() - except Exception as e: - self.logger.error(f"Error stopping speech: {e}") - - -class SpeechManager: - """Manages TTS providers and speech operations.""" - - def __init__(self): - """Initialize the speech manager.""" - self.providers: dict[str, CustomTTSProvider] = {} - self.current_provider: CustomTTSProvider | None = None - self.logger = logging.getLogger(__name__) - self._voices_cache = None - self._voices_cache_timestamp = 0 - self._voices_cache_ttl = 300 # Cache TTL in seconds (5 minutes) - self.is_speaking = False - - def _is_voices_cache_valid(self) -> bool: - """Check if the voices cache is still valid.""" - if self._voices_cache is None: - return False - current_time = time.time() - return (current_time - self._voices_cache_timestamp) < self._voices_cache_ttl - - def init_providers(self, config: dict[str, Any]) -> None: - """Initialize TTS providers from config.""" - self.providers = {} - self.current_provider = None - - # Get list of engines from config - engines = config.get("engines", []) - if not engines: - self.logger.warning("No TTS engines specified in config") - return - - # Initialize each provider - for engine in engines: - provider = None - try: - self.logger.debug(f"Initializing provider for engine: {engine}") - if engine == "sherpaonnx": - # Initialize Sherpa-ONNX provider - engine_config = config.get("engine_configs", {}).get( - "sherpaonnx", {} - ) - self.logger.debug(f"Sherpa config: {engine_config}") - client = SherpaOnnxClient( - model_path=engine_config.get("model_path"), - tokens_path=engine_config.get("tokens_path"), - ) - provider = TTSProvider() - provider.tts = SherpaOnnxTTS(client) - - elif engine == "microsoft": - # Initialize Microsoft Azure provider - engine_config = config.get("engine_configs", {}).get( - "microsoft", {} - ) - self.logger.debug(f"Microsoft config: {engine_config}") - credentials = engine_config.get("credentials", ("", "")) - client = MicrosoftClient(credentials=credentials) - provider = TTSProvider() - provider.tts = MicrosoftTTS(client) - - elif engine == "google": - # Initialize Google Cloud provider - engine_config = config.get("engine_configs", {}).get("google", {}) - credentials = engine_config.get("credentials", {}) - if not credentials: - self.logger.warning("Google Cloud credentials not provided") - continue - client = GoogleClient(credentials=credentials) - provider = TTSProvider() - provider.tts = GoogleTTS(client) - - elif engine == "googletrans": - # Initialize Google Translate provider - engine_config = config.get("engine_configs", {}).get( - "googletrans", {} - ) - self.logger.debug(f"Google Translate config: {engine_config}") - client = GoogleTransClient() - provider = TTSProvider() - provider.tts = GoogleTransTTS(client) - - elif engine == "elevenlabs": - # Initialize ElevenLabs provider - engine_config = config.get("engine_configs", {}).get( - "elevenlabs", {} - ) - credentials = engine_config.get("credentials", ("",)) - client = ElevenLabsClient(credentials=credentials) - provider = TTSProvider() - provider.tts = ElevenLabsTTS(client) - - elif engine == "polly": - # Initialize Amazon Polly provider - engine_config = config.get("engine_configs", {}).get("polly", {}) - credentials = engine_config.get("credentials", ("", "", "")) - client = PollyClient(credentials=credentials) - provider = TTSProvider() - provider.tts = PollyTTS(client) - - elif engine == "watson": - # Initialize IBM Watson provider - engine_config = config.get("engine_configs", {}).get("watson", {}) - credentials = engine_config.get("credentials", ("", "", "")) - client = WatsonClient(credentials=credentials) - provider = TTSProvider() - provider.tts = WatsonTTS(client) - - elif engine == "witai": - # Initialize Wit.ai provider - engine_config = config.get("engine_configs", {}).get("witai", {}) - credentials = engine_config.get("credentials", ("",)) - client = WitAiClient(credentials=credentials) - provider = TTSProvider() - provider.tts = WitAiTTS(client) - - elif engine == "playht": - # Initialize Play.HT provider - engine_config = config.get("engine_configs", {}).get("playht", {}) - credentials = engine_config.get("credentials", ("", "")) - client = PlayHTClient(credentials=credentials) - provider = TTSProvider() - provider.tts = PlayHTTTS(client) - - elif engine == "espeak": - # Initialize eSpeak provider - engine_config = config.get("engine_configs", {}).get("espeak", {}) - client = eSpeakClient() - provider = TTSProvider() - provider.tts = eSpeakTTS(client) - - elif engine == "openai": - # Initialize OpenAI provider - engine_config = config.get("engine_configs", {}).get("openai", {}) - provider = OpenAITTSProvider(engine_config) # type: ignore - - # Store provider if successfully initialized - if provider: - self.logger.debug(f"Successfully initialized provider for {engine}") - self.providers[engine] = provider - if not self.current_provider: - self.current_provider = provider - self.logger.info( - f"Current provider: {provider.__class__.__name__}" - ) - else: - self.logger.warning(f"Failed to initialize provider for {engine}") - - except Exception as e: - self.logger.error(f"Failed to initialize {engine} provider: {e}") - - if not self.providers: - self.logger.warning("No TTS providers were successfully initialized") - else: - self.logger.debug(f"Initialized providers: {list(self.providers.keys())}") - - def get_voices(self) -> list[dict[str, Any]]: - """Get available voices from all providers.""" - # Check cache first - if self._is_voices_cache_valid(): - self.logger.debug("Returning cached voices") - return self._voices_cache - - all_voices = [] - try: - for provider_id, provider in self.providers.items(): - try: - self.logger.info(f"Getting voices from provider: {provider_id}") - provider_voices = provider.get_voices() - self.logger.debug( - f"Voices from provider {provider_id}: {provider_voices}" - ) - - # Add provider ID and type to each voice - for voice in provider_voices: - # Format name as "Name (Language) - provider" - base_name = voice["name"] - language = voice.get("language", "") - - # Convert language format if needed (e.g., "English (US)" to "en-US") - if language and "(" in language: - lang_parts = language.split("(") - if len(lang_parts) == 2: - lang_code = lang_parts[1].strip(")") - if lang_code == "US": - language = "en-US" - elif lang_code == "UK": - language = "en-GB" - # Add more mappings as needed - - formatted_name = ( - f"{base_name} ({language}) - {provider_id}" - if language - else f"{base_name} - {provider_id}" - ) - self.logger.debug( - "Formatting voice name: " - f"base={base_name}, lang={language}, result={formatted_name}" - ) - - voice["name"] = formatted_name - voice["providerId"] = provider_id - voice["type"] = "VOICE_TYPE_EXTERNAL_DATA" - # Ensure language_codes is a list with proper format - if "language_codes" in voice: - voice["language_codes"] = [language] if language else [] - else: - voice["language_codes"] = [language] if language else [] - - self.logger.debug(f"Final voice entry: {voice}") - all_voices.extend(provider_voices) - except Exception as e: - self.logger.error(f"Error getting voices from {provider_id}: {e}") - continue - - # Update cache - self._voices_cache = all_voices - self._voices_cache_timestamp = time.time() - return all_voices - finally: - # Ensure any resources are cleaned up - for provider in self.providers.values(): - if hasattr(provider, "cleanup"): - try: - provider.cleanup() - except Exception as e: - self.logger.error(f"Error cleaning up provider: {e}") - - def speak(self, text: str, voice_id: str, provider_id: str | None = None) -> None: - """Speak text using specified voice.""" - if not text: - return - - provider: CustomTTSProvider | None = None - if provider_id and provider_id in self.providers: - provider = self.providers[provider_id] - else: - provider = self.current_provider - - if not provider: - self.logger.error("No TTS provider available") - return - - try: - self.is_speaking = True - provider.speak(text, voice_id) - self.is_speaking = False - except Exception as e: - self.logger.error(f"Error speaking text: {e}") - self.is_speaking = False - - def get_speak_data( - self, text: str, voice_id: str, provider_id: str | None = None - ) -> bytes: - """Get WAV audio data for text.""" - if not text: - return b"" - - provider: CustomTTSProvider | None = None - if provider_id and provider_id in self.providers: - provider = self.providers[provider_id] - else: - provider = self.current_provider - - if not provider: - self.logger.error("No TTS provider available") - return b"" - - try: - self.is_speaking = True - data = provider.get_speak_data(text, voice_id) - self.is_speaking = False - return data - except Exception as e: - self.logger.error(f"Error getting speech data: {e}") - self.is_speaking = False - return b"" - - def stop_speaking(self) -> None: - """Stop current speech playback.""" - if self.current_provider: - try: - self.current_provider.stop_speaking() - except Exception as e: - self.logger.error(f"Error stopping speech: {e}") - finally: - self.is_speaking = False - - -def get_voices(speech_manager: SpeechManager) -> list[dict[str, Any]]: - """Get available voices.""" - return speech_manager.get_voices() - - -def get_speak_data( - text: str, - voice_id: str, - provider_id: str | None = None, - speech_manager: SpeechManager | None = None, -) -> bytes: - """Get speech data for text.""" - if speech_manager is None: - raise ValueError("speech_manager is required") - return speech_manager.get_speak_data(text, voice_id, provider_id) - - -def speak( - text: str, - voice_id: str, - provider_id: str | None = None, - speech_manager: SpeechManager | None = None, -) -> None: - """Speak text using the specified voice.""" - if speech_manager is None: - raise ValueError("speech_manager is required") - speech_manager.speak(text, voice_id, provider_id) - - -def stop_speaking(speech_manager: SpeechManager) -> None: - """Stop the current speech playback.""" - speech_manager.stop_speaking() - - -def is_speaking(speech_manager: SpeechManager) -> bool: - """Check if text is being spoken.""" - return speech_manager.is_speaking diff --git a/speech/start.py b/speech/start.py index a84700d..ba7967a 100644 --- a/speech/start.py +++ b/speech/start.py @@ -30,25 +30,13 @@ try: from speech.config_manager import ConfigManager - from speech.speech_manager import ( - SpeechManager, - get_speak_data, - get_voices, - is_speaking, - speak, - stop_speaking, - ) + from speech.provider_factory import TTSProviderFactory + from speech.audio_manager import AudioManager except ImportError: # Fallback for when running as module from config_manager import ConfigManager - from speech_manager import ( - SpeechManager, - get_speak_data, - get_voices, - is_speaking, - speak, - stop_speaking, - ) + from provider_factory import TTSProviderFactory + from audio_manager import AudioManager # Configure logging logging.basicConfig(level=logging.DEBUG) @@ -60,8 +48,20 @@ # Create configuration manager instance config_manager = ConfigManager() -# Create speech manager instance -speech_manager = SpeechManager() +# Create provider factory and audio manager +provider_factory = TTSProviderFactory() +audio_manager = AudioManager() + +# Initialize providers with configuration +config = config_manager.get_tts_config() +providers = {} +for engine_type in config.get("engines", []): + provider = provider_factory.create_provider(engine_type, config) + if provider: + providers[engine_type] = provider + logger.info(f"Successfully initialized {engine_type} provider") + else: + logger.error(f"Failed to initialize {engine_type} provider") # Initialize Flask-RESTX api = Api( @@ -186,8 +186,16 @@ def config(): if all_valid: config_manager.save_config() - # Reinitialize speech manager with new configuration - speech_manager.init_providers(config_manager.get_tts_config()) + # Reinitialize providers with new configuration + config = config_manager.get_tts_config() + providers = {} + for engine_type in config.get("engines", []): + provider = provider_factory.create_provider(engine_type, config) + if provider: + providers[engine_type] = provider + logger.info(f"Successfully initialized {engine_type} provider") + else: + logger.error(f"Failed to initialize {engine_type} provider") success_message = "Configuration saved successfully" else: error_message = "Please fix the configuration errors" @@ -326,31 +334,25 @@ class Voices(Resource): def get(self): """Get available voices from all providers.""" try: - voices = get_voices(speech_manager) - # Transform to match original structure while preserving all fields - transformed_voices = [] - for voice in voices: - transformed_voice = { - "id": voice["id"], - "name": voice[ - "name" - ], # Name is already formatted correctly by SpeechManager - "language": voice.get("language", ""), # Preserve language field - "language_codes": voice.get( - "language_codes", [] - ), # Preserve language codes - "gender": voice.get("gender", "Unknown"), # Preserve gender - "providerId": voice[ - "providerId" - ], # Provider ID is set by SpeechManager - "type": voice.get("type", "external_data"), - "ref": voice, # Include the entire voice object as ref - } - transformed_voices.append(transformed_voice) - return transformed_voices + voices = [] + for provider_id, provider in providers.items(): + try: + provider_voices = provider.get_voices() + for voice in provider_voices: + voice["providerId"] = provider_id + voices.append(voice) + except Exception as e: + logger.error(f"Error getting voices from {provider_id}: {e}") + continue + + if not voices: + logger.warning("No voices available") + return {"status": "error", "error": "No voices available", "voices": []} + + return {"status": "success", "voices": voices} except Exception as e: - logger.error(f"Error in /voices endpoint: {e!s}", exc_info=True) - return [] + logger.error(f"Error in /voices endpoint: {e}", exc_info=True) + return {"status": "error", "error": str(e), "voices": []} @ns.route("/speakdata/") @@ -378,25 +380,39 @@ class SpeakData(Resource): def get(self, text: str, provider_id: str = "", voice_id: str = ""): """Get speech data for text.""" try: + # Decode parameters text = unquote(text).lower() provider_id = unquote(provider_id) voice_id = unquote(voice_id) - data = get_speak_data(text, voice_id, provider_id, speech_manager) - if data is None: + + logger.info(f"Generating speech data for text: {text}, voice: {voice_id}") + + # Get provider and generate audio data + provider = providers.get(provider_id) + if not provider: + logger.error(f"Provider {provider_id} not found") + return { + "error": f"Provider {provider_id} not found", + "status": "error", + }, 200 + + audio_data = provider.get_speak_data(text, voice_id) + if audio_data is None: + logger.error("Failed to generate speech data") return { "error": "Failed to generate speech data", "status": "error", }, 200 - # The data is already a complete WAV file from synth_to_file + logger.info("Successfully generated speech data") return send_file( - io.BytesIO(data), + io.BytesIO(audio_data), mimetype="audio/wav", as_attachment=False, download_name="speech.wav", ) except Exception as e: - logger.error(f"Error in /speakdata endpoint: {e!s}", exc_info=True) + logger.error(f"Error in /speakdata endpoint: {e}", exc_info=True) return {"error": str(e), "status": "error"}, 200 def post(self, text: str, provider_id: str = "", voice_id: str = ""): @@ -426,13 +442,49 @@ class Speak(Resource): def get(self, text: str, provider_id: str = "", voice_id: str = ""): """Speak text using specified voice.""" try: + # Decode parameters text = unquote(text).lower() provider_id = unquote(provider_id) voice_id = unquote(voice_id) - speak(text, voice_id, provider_id, speech_manager) + + logger.info(f"Starting speech for text: {text}, voice: {voice_id}") + + # Get provider and generate audio data + provider = providers.get(provider_id) + if not provider: + logger.error(f"Provider {provider_id} not found") + return { + "error": f"Provider {provider_id} not found", + "status": "error", + }, 200 + + audio_data = provider.get_speak_data(text, voice_id) + if audio_data is None: + logger.error("Failed to generate speech data") + return { + "error": "Failed to generate speech data", + "status": "error", + }, 200 + + # Start speaking with callbacks + def on_complete(): + logger.info("Speech playback completed") + + def on_error(error): + logger.error(f"Speech playback error: {error}") + logger.debug(f"Error callback called with error: {error}") + + logger.debug("Starting audio playback with callbacks") + logger.debug(f"Audio data size: {len(audio_data)} bytes") + success = audio_manager.play_audio(audio_data, on_complete, on_error) + if not success: + logger.error("Failed to start speech") + return {"error": "Failed to start speech", "status": "error"}, 200 + + logger.info("Successfully started speech") return {"status": "success"} except Exception as e: - logger.error(f"Error in /speak endpoint: {e!s}", exc_info=True) + logger.error(f"Error in /speak endpoint: {e}", exc_info=True) return {"error": str(e), "status": "error"}, 200 def post(self, text: str, provider_id: str = "", voice_id: str = ""): @@ -443,13 +495,55 @@ def post(self, text: str, provider_id: str = "", voice_id: str = ""): @app.route("/cache///", methods=["POST", "GET"]) def cache_data(text: str, provider_id: str = "", voice_id: str = ""): """Cache speech data for the given text.""" - if not config_manager.config["General"]["cache_enabled"]: - return jsonify(False) - text = unquote(text).lower() - provider_id = unquote(provider_id) - voice_id = unquote(voice_id) - get_speak_data(text, voice_id, provider_id, speech_manager) - return jsonify(True) + try: + # Check if caching is enabled + if not config_manager.config["General"].get("cache_enabled", False): + logger.info("Caching is disabled in configuration") + return jsonify( + {"status": "error", "message": "Caching is disabled in configuration"} + ) + + # Get cache directory from config + cache_dir = config_manager.config["General"].get("cache_dir", "temp") + logger.info(f"Using cache directory: {cache_dir}") + + # Decode parameters + text = unquote(text).lower() + provider_id = unquote(provider_id) + voice_id = unquote(voice_id) + + # Get provider and generate audio data + provider = providers.get(provider_id) + if not provider: + logger.error(f"Provider {provider_id} not found") + return jsonify( + {"status": "error", "message": f"Provider {provider_id} not found"} + ) + + # Get speech data (this will cache it if caching is enabled) + logger.info( + f"Attempting to cache speech data for text: {text}, voice: {voice_id}" + ) + audio_data = provider.get_speak_data(text, voice_id) + + if audio_data: + logger.info("Successfully cached speech data") + return jsonify( + { + "status": "success", + "message": "Speech data cached successfully", + "cache_dir": cache_dir, + } + ) + else: + logger.error("Failed to generate speech data for caching") + return jsonify( + {"status": "error", "message": "Failed to generate speech data"} + ) + + except Exception as e: + logger.error(f"Error in cache endpoint: {e}", exc_info=True) + return jsonify({"status": "error", "message": str(e)}) @ns.route("/speaking") @@ -467,10 +561,11 @@ class Speaking(Resource): def get(self): """Check if text is being spoken.""" try: - speaking = is_speaking(speech_manager) + speaking = audio_manager.is_playing + logger.debug(f"Speaking status: {speaking}") return {"speaking": speaking, "status": "success"} except Exception as e: - logger.error(f"Error in /speaking endpoint: {e!s}", exc_info=True) + logger.error(f"Error in /speaking endpoint: {e}", exc_info=True) return {"error": str(e), "status": "error", "speaking": False}, 200 @@ -486,10 +581,12 @@ class Stop(Resource): def get(self): """Stop speaking.""" try: - stop_speaking(speech_manager) + logger.info("Stopping speech playback") + audio_manager.stop_speaking() + logger.info("Successfully stopped speech playback") return {"status": "success"} except Exception as e: - logger.error(f"Error in /stop endpoint: {e!s}", exc_info=True) + logger.error(f"Error in /stop endpoint: {e}", exc_info=True) return {"error": str(e), "status": "error"}, 200 def post(self): @@ -506,9 +603,6 @@ def test(): def start_server(): """Start the Flask server.""" try: - # Initialize speech providers with configuration - speech_manager.init_providers(config_manager.get_tts_config()) - # Start Flask server app.run( host="127.0.0.1", diff --git a/speech/templates/test.html b/speech/templates/test.html index 24f5d3a..7ea3140 100644 --- a/speech/templates/test.html +++ b/speech/templates/test.html @@ -193,8 +193,13 @@
Test Text-to-Speech
async function loadVoices() { try { const response = await fetch('/api/voices'); - const voices = await response.json(); + const data = await response.json(); + if (!data || data.status !== 'success') { + throw new Error(data.error || 'Failed to load voices'); + } + + const voices = data.voices; if (!voices || !Array.isArray(voices)) { throw new Error('Invalid voice data received'); } @@ -289,73 +294,119 @@
${voice.name}
// Handle form submission document.getElementById('testForm').addEventListener('submit', async (e) => { e.preventDefault(); + const text = document.getElementById('testText').value.trim(); + const endpoint = document.querySelector('input[name="endpoint"]:checked').value; + const voiceId = selectedVoiceId; - if (!selectedVoiceId) { - alert('Please select a voice first.'); + // Get the voice object to get the correct providerId + const selectedVoice = allVoices.find(v => v.id === voiceId); + if (!selectedVoice) { + alert('Selected voice not found'); return; } + const providerId = selectedVoice.providerId; - const text = document.getElementById('testText').value.trim(); if (!text) { alert('Please enter some text to speak.'); return; } - const endpoint = document.querySelector('input[name="endpoint"]:checked').value; + if (!voiceId) { + alert('Please select a voice first.'); + return; + } + const speakButton = document.getElementById('speakButton'); const stopButton = document.getElementById('stopButton'); const speakingStatus = document.getElementById('speakingStatus'); - const audioPlayer = document.getElementById('audioPlayer'); + // Disable speak button and enable stop button speakButton.disabled = true; stopButton.disabled = false; try { + const encodedText = encodeURIComponent(text); + const apiUrl = `/api/${endpoint}/${encodedText}/${providerId}/${voiceId}`; + console.log('Calling endpoint:', apiUrl); + if (endpoint === 'speak') { // Direct speak endpoint - const encodedText = encodeURIComponent(text); - const selectedVoice = allVoices.find(v => v.id === selectedVoiceId); - const apiUrl = `/api/speak/${encodedText}/${selectedVoice.providerId}/${selectedVoiceId}`; - console.log('Calling speak endpoint:', apiUrl); const response = await fetch(apiUrl, { method: 'POST' }); console.log('Speak response:', response.status, await response.text()); - // Start checking speaking status - isSpeaking = true; - speakingStatus.classList.add('active'); - speakingCheckInterval = setInterval(async () => { - const statusResponse = await fetch('/api/speaking'); - const status = await statusResponse.json(); - console.log('Speaking status:', status); - if (!status.speaking) { + // Start speaking status check + let speakingCheckInterval = setInterval(async () => { + try { + const response = await fetch("/api/speaking"); + const data = await response.json(); + console.log("Speaking status:", data); + + if (!data.speaking) { + // Clear the interval first + clearInterval(speakingCheckInterval); + speakingCheckInterval = null; + + // Reset button states + speakButton.disabled = false; + stopButton.disabled = true; + console.log("Speech finished or timed out, re-enabling speak button"); + } + } catch (error) { + console.error("Error checking speaking status:", error); + // Clear interval on error clearInterval(speakingCheckInterval); - isSpeaking = false; - speakingStatus.classList.remove('active'); + speakingCheckInterval = null; + + // Reset button states speakButton.disabled = false; stopButton.disabled = true; } }, 500); + // Add timeout to ensure speak button is re-enabled + setTimeout(() => { + if (speakingCheckInterval) { + clearInterval(speakingCheckInterval); + speakingCheckInterval = null; + } + speakButton.disabled = false; + stopButton.disabled = true; + console.log("Speech timeout, re-enabling speak button"); + }, 5000); // 5 second timeout + + // Store interval ID for cleanup + speakButton.dataset.speakingCheckInterval = speakingCheckInterval; } else { // Audio stream endpoint - const encodedText = encodeURIComponent(text); - const selectedVoice = allVoices.find(v => v.id === selectedVoiceId); - const apiUrl = `/api/speakdata/${encodedText}/${selectedVoice.providerId}/${selectedVoiceId}`; - console.log('Calling speakdata endpoint:', apiUrl); - const response = await fetch(apiUrl, { - method: 'POST' - }); - console.log('Speakdata response:', response.status); - - const blob = await response.blob(); - const blobUrl = URL.createObjectURL(blob); - audioPlayer.src = blobUrl; - audioPlayer.play(); + const response = await fetch(apiUrl); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } - speakButton.disabled = false; - stopButton.disabled = true; + // Get the audio data as a blob + const audioBlob = await response.blob(); + + // Create an object URL for the blob + const audioUrl = URL.createObjectURL(audioBlob); + + // Set the audio source and show the player + const audioPlayer = document.getElementById('audioPlayer'); + audioPlayer.src = audioUrl; + audioPlayer.style.display = 'block'; + + // Play the audio + await audioPlayer.play(); + + // Reset button states when audio ends + audioPlayer.onended = () => { + speakButton.disabled = false; + stopButton.disabled = true; + audioPlayer.style.display = 'none'; + // Clean up the object URL + URL.revokeObjectURL(audioUrl); + }; } } catch (error) { console.error('Error:', error); @@ -366,28 +417,31 @@
${voice.name}
} }); - // Handle stop button - document.getElementById('stopButton').addEventListener('click', async () => { + // Stop button handler + document.getElementById('stopButton').addEventListener("click", async () => { try { - await fetch('/api/stop', { method: 'POST' }); - if (speakingCheckInterval) { - clearInterval(speakingCheckInterval); + const response = await fetch("/api/stop", { method: "POST" }); + const data = await response.json(); + console.log("Stop response:", data); + + // Clear speaking check interval if it exists + if (speakButton.dataset.speakingCheckInterval) { + clearInterval(parseInt(speakButton.dataset.speakingCheckInterval)); + speakButton.dataset.speakingCheckInterval = ""; } - document.getElementById('speakingStatus').classList.remove('active'); - document.getElementById('speakButton').disabled = false; - document.getElementById('stopButton').disabled = true; + + // Reset button states + speakButton.disabled = false; + stopButton.disabled = true; + console.log("Speech stopped, re-enabling speak button"); } catch (error) { - console.error('Error stopping speech:', error); + console.error("Error stopping speech:", error); + // Reset button states even on error + speakButton.disabled = false; + stopButton.disabled = true; } }); - // Initialize - loadVoices(); - - // Update the search functionality - document.getElementById('voiceSearch').addEventListener('input', filterVoices); - document.getElementById('searchField').addEventListener('change', filterVoices); - // Handle voice selection function selectVoice(voiceId) { selectedVoiceId = voiceId; @@ -403,6 +457,15 @@
${voice.name}
} }); } + + // Update the search functionality + document.getElementById('voiceSearch').addEventListener('input', filterVoices); + document.getElementById('searchField').addEventListener('change', filterVoices); + + // Initialize when the page loads + document.addEventListener('DOMContentLoaded', () => { + loadVoices(); + }); \ No newline at end of file diff --git a/speech/test_audio.py b/speech/test_audio.py new file mode 100644 index 0000000..9e86f4a --- /dev/null +++ b/speech/test_audio.py @@ -0,0 +1,205 @@ +"""Test audio playback and event handling with sherpaonnx.""" + +import asyncio +import logging +from pathlib import Path +from speech.provider_factory import TTSProviderFactory +from speech.audio_manager import AudioManager + +# Setup logging +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + +# Test configuration matching settings.ini structure +config = { + "General": { + "engines": ["sherpaonnx"], # Only test sherpaonnx + "cache_enabled": True, + "cache_dir": "temp", + } +} + + +async def test_audio_playback(): + """Test audio playback with sherpaonnx.""" + # Create provider and audio manager + factory = TTSProviderFactory() + provider = factory.create_provider("sherpaonnx", config) + audio_manager = AudioManager() + + # Test text + long_text = ( + "This is a long test sentence that should take several seconds to " + "speak. We want to make sure we can stop it in the middle." + ) + short_text = "Short test." + + # Event flags + long_text_started = asyncio.Event() + long_text_stopped = asyncio.Event() + short_text_started = asyncio.Event() + short_text_completed = asyncio.Event() + cached_text_started = asyncio.Event() + cached_text_completed = asyncio.Event() + + def on_long_start(): + logger.info("Long text started playing") + long_text_started.set() + + def on_long_stop(): + logger.info("Long text stopped") + long_text_stopped.set() + + def on_short_start(): + logger.info("Short text started playing") + short_text_started.set() + + def on_short_complete(): + logger.info("Short text completed") + short_text_completed.set() + + def on_cached_start(): + logger.info("Cached text started playing") + cached_text_started.set() + + def on_cached_complete(): + logger.info("Cached text completed") + cached_text_completed.set() + + try: + # First test: Long text with stop + logger.info("=== Starting first test: Long text with stop ===") + # Get audio data for long text + logger.info("Getting audio data for long text...") + audio_data = provider.get_speak_data(long_text, "mms_eng") + + # Start playing long text + logger.info("Starting long text playback...") + provider.set_speech_handlers(on_start=on_long_start, on_stop=on_long_stop) + success = audio_manager.play_audio( + audio_data, + on_start=on_long_start, + on_complete=on_long_stop, + ) + + if not success: + logger.error("Failed to start long text playback") + return + + # Wait for playback to start with timeout + try: + await asyncio.wait_for(long_text_started.wait(), timeout=2.0) + logger.info("Long text playback started") + except asyncio.TimeoutError: + logger.error("Timeout waiting for playback to start") + audio_manager.stop() + return + + # Wait a bit then stop + await asyncio.sleep(2.0) + logger.info("Stopping long text playback...") + audio_manager.stop() + long_text_stopped.set() + + # Wait for stop event with timeout + try: + await asyncio.wait_for(long_text_stopped.wait(), timeout=2.0) + logger.info("Long text playback stopped") + except asyncio.TimeoutError: + logger.error("Timeout waiting for playback to stop") + return + + # Short delay to ensure cleanup + await asyncio.sleep(0.5) + + # Second test: Short text with completion + logger.info("=== Starting second test: Short text with completion ===") + # Get audio data for short text + logger.info("Getting audio data for short text...") + audio_data = provider.get_speak_data(short_text, "mms_eng") + + # Start playing short text + logger.info("Starting short text playback...") + provider.set_speech_handlers( + on_start=on_short_start, on_complete=on_short_complete + ) + success = audio_manager.play_audio( + audio_data, on_start=on_short_start, on_complete=on_short_complete + ) + + if not success: + logger.error("Failed to start short text playback") + return + + # Wait for short text to start with timeout + try: + await asyncio.wait_for(short_text_started.wait(), timeout=2.0) + logger.info("Short text playback started") + except asyncio.TimeoutError: + logger.error("Timeout waiting for short text to start") + audio_manager.stop() + return + + # Wait for completion with timeout + try: + await asyncio.wait_for(short_text_completed.wait(), timeout=5.0) + logger.info("Short text playback completed") + except asyncio.TimeoutError: + logger.error("Timeout waiting for short text to complete") + audio_manager.stop() + return + + # Third test: Cached audio playback + logger.info("=== Starting third test: Cached audio playback ===") + # Get the same audio data again - should use cache + logger.info("Getting cached audio data for short text...") + cached_audio_data = provider.get_speak_data(short_text, "mms_eng") + + # Start playing cached audio + logger.info("Starting cached audio playback...") + provider.set_speech_handlers( + on_start=on_cached_start, on_complete=on_cached_complete + ) + success = audio_manager.play_audio( + cached_audio_data, on_start=on_cached_start, on_complete=on_cached_complete + ) + + if not success: + logger.error("Failed to start cached audio playback") + return + + # Wait for cached audio to start with timeout + try: + await asyncio.wait_for(cached_text_started.wait(), timeout=2.0) + logger.info("Cached audio playback started") + except asyncio.TimeoutError: + logger.error("Timeout waiting for cached audio to start") + audio_manager.stop() + return + + # Wait for completion with timeout + try: + await asyncio.wait_for(cached_text_completed.wait(), timeout=5.0) + logger.info("Cached audio playback completed") + except asyncio.TimeoutError: + logger.error("Timeout waiting for cached audio to complete") + audio_manager.stop() + return + + logger.info("All tests completed successfully") + + except Exception as e: + logger.error(f"Test failed with error: {str(e)}", exc_info=True) + audio_manager.stop() + finally: + # Ensure cleanup + audio_manager.stop() + + +if __name__ == "__main__": + try: + asyncio.run(test_audio_playback()) + except KeyboardInterrupt: + logger.info("Test interrupted by user") + except Exception as e: + logger.error(f"Test failed: {str(e)}", exc_info=True) diff --git a/speech/test_engines.py b/speech/test_engines.py new file mode 100644 index 0000000..e0606b5 --- /dev/null +++ b/speech/test_engines.py @@ -0,0 +1,462 @@ +"""Test harness for TTS providers.""" + +import asyncio +import json +import logging +from datetime import datetime +from typing import Any, Dict, List + +from speech.tts_provider import TTSProviderAbstract +from speech.provider_factory import TTSProviderFactory +from speech.config_manager import ConfigManager + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[ + logging.FileHandler("testengines.log"), + logging.StreamHandler(), + ], +) +logger = logging.getLogger(__name__) + +# Set up summary logging +summary_logger = logging.getLogger("summary") +summary_handler = logging.FileHandler("test_summary.log") +summary_handler.setFormatter(logging.Formatter("%(message)s")) +summary_logger.addHandler(summary_handler) +summary_logger.setLevel(logging.INFO) + + +def log_summary(message: str) -> None: + """Log a message to both the summary file and console.""" + print(message) + summary_logger.info(message) + + +class EngineTester: + """Test harness for TTS providers.""" + + def __init__(self) -> None: + """Initialize the engine tester.""" + self.config_manager = ConfigManager() + self.providers: Dict[str, TTSProviderAbstract] = {} + self.test_text = "Hello! This is a test of the text-to-speech system." + self.summary_data: Dict[str, Any] = { + "timestamp": datetime.now().isoformat(), + "providers": {}, + } + + # Initialize providers using factory + self._init_providers() + + def _init_providers(self) -> None: + """Initialize all configured providers using the factory.""" + config = self.config_manager.get_tts_config() + for engine_type in config.get("engines", []): + provider = TTSProviderFactory.create_provider(engine_type, config) + if provider: + self.providers[engine_type] = provider + logger.info(f"Successfully initialized {engine_type} provider") + else: + logger.error(f"Failed to initialize {engine_type} provider") + + def check_credentials(self, provider_id: str) -> bool: + """Check if credentials are valid for a provider. + + Args: + provider_id: The ID of the provider to check. + + Returns: + bool: True if credentials are valid, False otherwise. + """ + try: + if provider_id not in self.providers: + logger.warning(f"Provider {provider_id} not found") + return False + + provider = self.providers[provider_id] + # Try to get voices as a credential check + voices = provider.get_voices() + return bool(voices) + except Exception as e: + logger.error(f"Error checking credentials for {provider_id}: {e}") + return False + + def print_voice_info(self, voice: Dict[str, Any], provider_id: str) -> None: + """Print detailed information about a voice. + + Args: + voice: The voice information dictionary. + provider_id: The ID of the provider. + """ + logger.info(f"\nVoice from {provider_id}:") + logger.info(f" ID: {voice.get('id', 'N/A')}") + logger.info(f" Name: {voice.get('name', 'N/A')}") + logger.info(f" Language: {voice.get('language', 'N/A')}") + logger.info(f" Language Codes: {voice.get('language_codes', [])}") + logger.info(f" Gender: {voice.get('gender', 'N/A')}") + + # Check for potential issues + issues = self._check_voice_issues(voice) + if issues: + logger.warning(" ⚠️ Issues found:") + for issue in issues: + logger.warning(f" - {issue}") + + def _check_voice_issues(self, voice: Dict[str, Any]) -> List[str]: + """Check for potential issues in voice data. + + Args: + voice: The voice information dictionary. + + Returns: + List[str]: List of issues found. + """ + issues = [] + if not voice.get("language"): + issues.append("Missing language field") + if not voice.get("language_codes"): + issues.append("Missing language_codes") + if any("\x02" in code for code in voice.get("language_codes", [])): + issues.append("Contains \\x02 in language codes") + if "\x02" in voice.get("language", ""): + issues.append("Contains \\x02 in language field") + return issues + + def _sanitize_voice_data(self, voice: dict) -> dict: + """Sanitize voice data for JSON serialization. + + Args: + voice: Voice data dictionary + + Returns: + Sanitized voice data dictionary + """ + # Create a new dict with only JSON-serializable values + sanitized: dict[str, Any] = {} + for key, value in voice.items(): + # Handle common types + if isinstance(value, (str, int, float, bool, type(None))): + sanitized[key] = value + # Handle lists/tuples + elif isinstance(value, (list, tuple)): + sanitized[key] = [ + ( + self._sanitize_voice_data(item) + if isinstance(item, dict) + else ( + str(item) + if not isinstance(item, (str, int, float, bool, type(None))) + else item + ) + ) + for item in value + ] + # Handle dicts + elif isinstance(value, dict): + sanitized[key] = self._sanitize_voice_data(value) + # Convert other types to strings + else: + sanitized[key] = str(value) + return sanitized + + async def test_voice_reporting(self) -> None: + """Test voice reporting from all providers.""" + logger.info("\n=== Testing Voice Reporting ===") + + for provider_id, provider in self.providers.items(): + logger.info(f"\nTesting provider: {provider_id}") + + # Check credentials first + if not self.check_credentials(provider_id): + logger.warning(f" ⚠️ Invalid or missing credentials for {provider_id}") + self.summary_data["providers"][provider_id] = { + "status": "error", + "error": "Invalid or missing credentials", + } + continue + + try: + voices = provider.get_voices() + logger.info(f"Found {len(voices)} voices") + + # Add to summary data with sanitized voice data + self.summary_data["providers"][provider_id] = { + "status": "success", + "voice_count": len(voices), + "voices": [self._sanitize_voice_data(voice) for voice in voices], + } + + for voice in voices: + self.print_voice_info(voice, provider_id) + + except Exception as e: + logger.error(f"Error getting voices from {provider_id}: {e}") + self.summary_data["providers"][provider_id] = { + "status": "error", + "error": str(e), + } + + async def test_speech_functionality(self, provider_id: str, voice_id: str) -> None: + """Test speech functionality for a specific provider and voice. + + Args: + provider_id: The ID of the provider to test. + voice_id: The ID of the voice to use. + """ + logger.info(f"\n=== Testing Speech Functionality for {provider_id} ===") + logger.info(f"Using voice: {voice_id}") + + try: + provider = self.providers[provider_id] + + # Test getting speech data + logger.info("Testing get_speak_data...") + try: + audio_data = provider.get_speak_data(self.test_text, voice_id) + if not audio_data: + logger.error("Failed to get speech data - no data returned") + self.summary_data["providers"][provider_id][ + "speech_test" + ] = "failed" + self.summary_data["providers"][provider_id][ + "speech_error" + ] = "No audio data returned" + return + logger.info(f"✓ Successfully got speech data ({len(audio_data)} bytes)") + except Exception as e: + logger.error(f"Failed to get speech data: {str(e)}") + self.summary_data["providers"][provider_id]["speech_test"] = "failed" + self.summary_data["providers"][provider_id][ + "speech_error" + ] = f"Speech data error: {str(e)}" + return + + # Test 1: Basic speech with events + logger.info("Testing speech events...") + start_event = asyncio.Event() + stop_event = asyncio.Event() + complete_event = asyncio.Event() + + def on_start(): + logger.debug("Speech started callback triggered") + start_event.set() + + def on_stop(): + logger.debug("Speech stopped callback triggered") + stop_event.set() + + def on_complete(): + logger.debug("Speech completed callback triggered") + complete_event.set() + + # Set up event handlers + provider.set_speech_handlers( + on_start=on_start, + on_stop=on_stop, + on_complete=on_complete, + ) + + # Start speaking + logger.info("Starting speech test...") + success = provider.speak(self.test_text, voice_id) + if not success: + logger.error("Failed to start speech") + self.summary_data["providers"][provider_id]["speech_test"] = "failed" + self.summary_data["providers"][provider_id][ + "speech_error" + ] = "Failed to start speech" + return + + # Wait for speech to start + try: + await asyncio.wait_for(start_event.wait(), timeout=2) + logger.info("✓ Speech started") + except asyncio.TimeoutError: + logger.error("Speech did not start within timeout") + provider.stop_speaking() # Ensure cleanup + self.summary_data["providers"][provider_id]["speech_test"] = "failed" + self.summary_data["providers"][provider_id][ + "speech_error" + ] = "Start timeout" + return + + # Verify playback is active + if not provider.is_speaking: + logger.error("Playback not active after start event") + provider.stop_speaking() # Ensure cleanup + self.summary_data["providers"][provider_id]["speech_test"] = "failed" + self.summary_data["providers"][provider_id][ + "speech_error" + ] = "Not active after start" + return + logger.info("✓ Playback is active") + + # Test 2: Stop functionality + logger.info("Testing stop_speaking...") + provider.stop_speaking() + + # Wait for stop event with timeout + try: + await asyncio.wait_for(stop_event.wait(), timeout=2) + logger.info("✓ Stop event received") + except asyncio.TimeoutError: + logger.error("Stop event not received within timeout") + self.summary_data["providers"][provider_id]["speech_test"] = "failed" + self.summary_data["providers"][provider_id][ + "speech_error" + ] = "Stop timeout" + return + + # Verify playback stopped + if provider.is_speaking: + logger.error("Playback still active after stop") + provider.stop_speaking() # Try stopping again + self.summary_data["providers"][provider_id]["speech_test"] = "failed" + self.summary_data["providers"][provider_id][ + "speech_error" + ] = "Still active after stop" + return + logger.info("✓ Successfully stopped speaking") + + # Short delay to ensure cleanup + await asyncio.sleep(0.5) + + # Test 3: Full playback completion + logger.info("Testing full playback...") + # Reset all events + start_event.clear() + stop_event.clear() + complete_event.clear() + + success = provider.speak(self.test_text, voice_id) + if not success: + logger.error("Failed to start full playback test") + self.summary_data["providers"][provider_id]["speech_test"] = "failed" + self.summary_data["providers"][provider_id][ + "speech_error" + ] = "Failed to start full playback" + return + + try: + # Wait for start + await asyncio.wait_for(start_event.wait(), timeout=2) + logger.info("✓ Full playback started") + + # Wait for natural completion + await asyncio.wait_for(complete_event.wait(), timeout=10) + logger.info("✓ Full playback completed naturally") + + # Verify complete event was triggered + if not complete_event.is_set(): + logger.error("Complete event not set after natural completion") + provider.stop_speaking() # Ensure cleanup + self.summary_data["providers"][provider_id][ + "speech_test" + ] = "failed" + self.summary_data["providers"][provider_id][ + "speech_error" + ] = "No completion event" + return + + # Verify playback is not active + if provider.is_speaking: + logger.error("Playback still active after completion") + provider.stop_speaking() # Ensure cleanup + self.summary_data["providers"][provider_id][ + "speech_test" + ] = "failed" + self.summary_data["providers"][provider_id][ + "speech_error" + ] = "Still active after completion" + return + + except asyncio.TimeoutError: + logger.error("Full playback did not complete within timeout") + provider.stop_speaking() # Ensure cleanup + self.summary_data["providers"][provider_id]["speech_test"] = "failed" + self.summary_data["providers"][provider_id][ + "speech_error" + ] = "Completion timeout" + return + + # Update summary data + self.summary_data["providers"][provider_id]["speech_test"] = "success" + + except Exception as e: + logger.error(f"Error testing speech functionality: {str(e)}") + logger.exception(f"Detailed error for {provider_id}:") + self.summary_data["providers"][provider_id]["speech_test"] = "error" + self.summary_data["providers"][provider_id]["speech_error"] = str(e) + # Ensure cleanup on error + try: + provider.stop_speaking() + except Exception as cleanup_error: + logger.debug(f"Cleanup error: {str(cleanup_error)}") + pass + + async def run_tests(self) -> None: + """Run all tests.""" + # Print available providers + logger.info("\nAvailable providers:") + for provider_id in self.providers: + logger.info(f" - {provider_id}") + + if not self.providers: + logger.warning("\nNo providers were initialized!") + logger.warning("Check the logs for initialization errors.") + return + + # Test voice reporting + await self.test_voice_reporting() + + # Test speech functionality for each provider + for provider_id, provider in self.providers.items(): + try: + # Get first available voice for the provider + voices = provider.get_voices() + if voices: + voice_id = voices[0]["id"] + await self.test_speech_functionality(provider_id, voice_id) + else: + logger.warning(f"\nNo voices available for {provider_id}") + self.summary_data["providers"][provider_id][ + "speech_test" + ] = "no_voices" + except Exception as e: + logger.error(f"\nError testing {provider_id}: {e}") + logger.exception(f"Detailed error for {provider_id}:") + self.summary_data["providers"][provider_id]["speech_test"] = "error" + self.summary_data["providers"][provider_id]["speech_error"] = str(e) + + # Log summary + log_summary("\n=== TTS Engine Test Summary ===") + log_summary(f"Test completed at: {self.summary_data['timestamp']}\n") + + for provider_id, data in self.summary_data["providers"].items(): + log_summary(f"\n{provider_id}:") + if data["status"] == "success": + log_summary(" ✓ Successfully initialized") + log_summary(f" ✓ Found {data['voice_count']} voices") + if "speech_test" in data: + log_summary(f" ✓ Speech test: {data['speech_test'].title()}") + if data["speech_test"] == "error": + log_summary(f" ✗ Speech error: {data['speech_error']}") + else: + log_summary(f" ✗ Error: {data['error']}") + + # Save detailed summary to JSON + with open("test_summary.json", "w") as f: + json.dump(self.summary_data, f, indent=2) + + +def main() -> None: + """Main entry point.""" + tester = EngineTester() + asyncio.run(tester.run_tests()) + + +if __name__ == "__main__": + main() diff --git a/speech/tts_provider.py b/speech/tts_provider.py new file mode 100644 index 0000000..d0b4ac2 --- /dev/null +++ b/speech/tts_provider.py @@ -0,0 +1,246 @@ +"""Abstract base class for TTS providers.""" + +import logging +from abc import ABC, abstractmethod +from typing import Optional, Callable, Any, Dict +from functools import lru_cache +from .audio_manager import AudioManager +import hashlib +from pathlib import Path +import json +import time + + +class TTSProviderAbstract(ABC): + """Abstract base class for all TTS providers.""" + + def __init__(self, config: Optional[Dict] = None): + """Initialize the TTS provider with audio management.""" + self.audio_manager = AudioManager() + self.logger = logging.getLogger(__name__) + self._voice_cache: Dict[str, list[dict[str, Any]]] = {} + self._on_start: Optional[Callable] = None + self._on_stop: Optional[Callable] = None + self._on_complete: Optional[Callable] = None + self._was_stopped = False + + # Setup caching + self.config = config or {} + self._cache_enabled = self.config.get("General", {}).get("cache_enabled", True) + self._cache_dir = Path(self.config.get("General", {}).get("cache_dir", "temp")) + self._cache_dir.mkdir(parents=True, exist_ok=True) + self._audio_cache_dir = self._cache_dir / "audio" + self._audio_cache_dir.mkdir(parents=True, exist_ok=True) + + def _get_cache_key(self, text: str, voice_id: str) -> str: + """Generate cache key for audio data.""" + # Create a unique key based on text and voice + key_data = f"{text}:{voice_id}".encode("utf-8") + return hashlib.md5(key_data).hexdigest() + + def _get_cache_path(self, cache_key: str) -> Path: + """Get path for cached audio file.""" + return self._audio_cache_dir / f"{cache_key}.wav" + + def _get_metadata_path(self, cache_key: str) -> Path: + """Get path for cached metadata file.""" + return self._audio_cache_dir / f"{cache_key}.json" + + def _cache_audio_data( + self, cache_key: str, audio_data: bytes, metadata: Dict + ) -> None: + """Cache audio data and metadata.""" + if not self._cache_enabled: + return + + try: + # Save audio data + audio_path = self._get_cache_path(cache_key) + with open(audio_path, "wb") as f: + f.write(audio_data) + + # Save metadata + meta_path = self._get_metadata_path(cache_key) + with open(meta_path, "w") as f: + json.dump(metadata, f) + + self.logger.debug(f"Cached audio data for key: {cache_key}") + except Exception as e: + self.logger.error(f"Error caching audio data: {e}") + + def _get_cached_audio(self, cache_key: str) -> Optional[bytes]: + """Get cached audio data if available.""" + if not self._cache_enabled: + return None + + try: + audio_path = self._get_cache_path(cache_key) + meta_path = self._get_metadata_path(cache_key) + + if audio_path.exists() and meta_path.exists(): + # Load metadata to verify cache validity + with open(meta_path, "r") as f: + metadata = json.load(f) + + # TODO: Add cache validation based on metadata + # For now, just return the cached audio + with open(audio_path, "rb") as f: + audio_data = f.read() + self.logger.debug(f"Using cached audio data for key: {cache_key}") + return audio_data + except Exception as e: + self.logger.error(f"Error reading cached audio: {e}") + return None + + def get_speak_data(self, text: str, voice_id: str) -> Optional[bytes]: + """Get audio data for text, using cache if available.""" + # If caching is disabled, just generate new audio + if not self._cache_enabled: + return self._generate_speak_data(text, voice_id) + + # Try to get from cache first + cache_key = self._get_cache_key(text, voice_id) + cached_data = self._get_cached_audio(cache_key) + if cached_data: + return cached_data + + # If not in cache, generate new audio data + audio_data = self._generate_speak_data(text, voice_id) + if audio_data: + # Cache the new audio data + metadata = {"text": text, "voice_id": voice_id, "timestamp": time.time()} + self._cache_audio_data(cache_key, audio_data, metadata) + return audio_data + + @abstractmethod + def _generate_speak_data(self, text: str, voice_id: str) -> Optional[bytes]: + """Generate audio data for text. Must be implemented by concrete providers. + + Args: + text: Text to convert to speech + voice_id: Voice ID to use + + Returns: + Audio data as bytes, or None if synthesis failed + """ + pass + + def speak( + self, text: str, voice_id: str, on_complete: Optional[Callable] = None + ) -> bool: + """Speak text using specified voice. + + Args: + text: Text to speak + voice_id: Voice ID to use + on_complete: Optional callback when playback completes + + Returns: + True if audio started playing, False if error occurred + """ + try: + # Get audio data from concrete implementation + audio_data = self.get_speak_data(text, voice_id) + if not audio_data: + raise RuntimeError("Failed to generate audio data") + + # Set up completion callback that handles both natural completion and stops + def on_audio_complete(): + if self._on_stop: + self._on_stop() + if not self._was_stopped and self._on_complete: + self._on_complete() + if not self._was_stopped and on_complete: + on_complete() + self._was_stopped = False + + # Set up start callback + def on_audio_start(): + if self._on_start: + self._on_start() + + # Reset stop flag + self._was_stopped = False + + # Start playback with callbacks + success = self.audio_manager.play_audio( + audio_data, on_complete=on_audio_complete, on_start=on_audio_start + ) + + return success + + except Exception as e: + self.logger.error(f"Error in speak: {e}") + if on_complete: + on_complete() + return False + + @abstractmethod + def get_voices(self) -> list[dict[str, Any]]: + """Get available voices. Must be implemented by concrete providers. + + Returns: + List of voice dictionaries with standardized format: + { + "id": str, + "name": str, + "language": str, + "language_codes": list[str], + "gender": str + } + """ + pass + + @lru_cache(maxsize=100) + def _get_cached_voices(self) -> list[dict[str, Any]]: + """Get cached list of voices, fetching from provider if not cached.""" + return self.get_voices() + + def get_cached_voices(self, force_refresh: bool = False) -> list[dict[str, Any]]: + """Get list of available voices, using cache if available. + + Args: + force_refresh: If True, ignore cache and fetch fresh data + + Returns: + List of voice dictionaries + """ + if force_refresh: + # Clear the cache and fetch fresh data + self._get_cached_voices.cache_clear() + + return self._get_cached_voices() + + def stop_speaking(self) -> None: + """Stop current speech playback.""" + if self.is_speaking: + self._was_stopped = True + self.audio_manager.stop() + if self._on_stop: + self._on_stop() + + @property + def is_speaking(self) -> bool: + """Check if audio is currently playing. + + Returns: + True if audio is playing, False otherwise + """ + return self.audio_manager.is_playing + + def set_speech_handlers( + self, + on_start: Optional[Callable] = None, + on_stop: Optional[Callable] = None, + on_complete: Optional[Callable] = None, + ) -> None: + """Set handlers for speech events. + + Args: + on_start: Called when speech starts + on_stop: Called when speech is stopped (manually or naturally) + on_complete: Called only when speech completes naturally + """ + self._on_start = on_start + self._on_stop = on_stop + self._on_complete = on_complete From b4b6f818ce0eef2ab6379a585612fd0ce86b4748 Mon Sep 17 00:00:00 2001 From: will wade Date: Mon, 7 Apr 2025 22:31:38 +0100 Subject: [PATCH 37/37] upgrading to new tts-wrapper code. nb: beta tagged release. better lang support --- pyproject.toml | 8 +- speech/custom_providers.py | 103 ++++++--- speech/provider_factory.py | 431 +++++++++++++++++++++++++++---------- speech/start.py | 48 ++++- speech/test_audio.py | 314 ++++++++++++++------------- speech/test_engines.py | 102 +++++++-- speech/tts_provider.py | 96 ++++++--- test_provider.py | 50 +++++ uv.lock | 94 +++++++- 9 files changed, 890 insertions(+), 356 deletions(-) create mode 100644 test_provider.py diff --git a/pyproject.toml b/pyproject.toml index 228f09a..478e61a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,10 +9,10 @@ dependencies = [ "flask>=3.0.0", "flask-cors>=4.0.0", "flask-restx>=1.3.0", - "py3-tts-wrapper[avsynth]==0.9.32; sys_platform == 'darwin'", - "py3-tts-wrapper[sapi]==0.9.32; sys_platform == 'win32'", - "py3-tts-wrapper[espeak]==0.9.32; sys_platform == 'linux'", - "py3-tts-wrapper[sherpaonnx,espeak,elevenlabs,playht,microsoft,polly,watson,googletrans,playht,witai,controlaudio]==0.9.32", + "py3-tts-wrapper[avsynth]==0.10.2b; sys_platform == 'darwin'", + "py3-tts-wrapper[sapi]==0.10.2b; sys_platform == 'win32'", + "py3-tts-wrapper[espeak]==0.10.2b; sys_platform == 'linux'", + "py3-tts-wrapper[sherpaonnx,espeak,elevenlabs,playht,microsoft,polly,watson,googletrans,playht,witai,controlaudio]==0.10.2b", "omegaconf>=2.3.0", "hydra-core>=1.3.0", "openai>=1.12.0", diff --git a/speech/custom_providers.py b/speech/custom_providers.py index 989db26..dd2e67b 100644 --- a/speech/custom_providers.py +++ b/speech/custom_providers.py @@ -1,7 +1,7 @@ """Custom TTS provider implementations.""" import logging -from typing import Any, Optional +from typing import Any from .tts_provider import TTSProviderAbstract @@ -38,36 +38,89 @@ def __init__(self, config: dict[str, Any] | None = None): self.client = openai.OpenAI(api_key=self.api_key) - def get_voices(self) -> list[dict[str, Any]]: - """Get available OpenAI voices. + def get_voices(self, langcodes: str = "bcp47") -> list[dict[str, Any]]: + """Get available OpenAI voices with specified language code format. + + Args: + langcodes: Language code format to return. Options are: + - "bcp47": BCP-47 format (default) + - "iso639_3": ISO 639-3 format + - "display": Human-readable display names + - "all": All formats in a dictionary Returns: List of voice dictionaries in standardized format. """ - return [ - { - "id": voice, - "name": voice.capitalize(), - "language": "en", - "language_codes": ["en"], # OpenAI voices are optimized for English - "gender": "Unknown", - } - for voice in [ - "alloy", - "ash", - "ballad", - "coral", - "echo", - "fable", - "onyx", - "nova", - "sage", - "shimmer", - ] + voices = [ + "alloy", + "ash", + "ballad", + "coral", + "echo", + "fable", + "onyx", + "nova", + "sage", + "shimmer", ] - def get_speak_data(self, text: str, voice_id: str) -> Optional[bytes]: - """Get WAV audio data for text using OpenAI TTS. + # Format language codes based on requested format + if langcodes == "bcp47": + return [ + { + "id": voice, + "name": voice.capitalize(), + "language_codes": ["en"], # OpenAI voices are optimized for English + "gender": "Unknown", + } + for voice in voices + ] + elif langcodes == "iso639_3": + return [ + { + "id": voice, + "name": voice.capitalize(), + "language_codes": ["eng"], # ISO 639-3 code for English + "gender": "Unknown", + } + for voice in voices + ] + elif langcodes == "display": + return [ + { + "id": voice, + "name": voice.capitalize(), + "language_codes": ["English"], # Human-readable name + "gender": "Unknown", + } + for voice in voices + ] + elif langcodes == "all": + return [ + { + "id": voice, + "name": voice.capitalize(), + "language_codes": { + "en": {"bcp47": "en", "iso639_3": "eng", "display": "English"} + }, + "gender": "Unknown", + } + for voice in voices + ] + else: + # Default to BCP-47 format + return [ + { + "id": voice, + "name": voice.capitalize(), + "language_codes": ["en"], + "gender": "Unknown", + } + for voice in voices + ] + + def _generate_speak_data(self, text: str, voice_id: str) -> bytes | None: + """Generate WAV audio data for text using OpenAI TTS. Args: text: Text to convert to speech diff --git a/speech/provider_factory.py b/speech/provider_factory.py index 4a10495..c546036 100644 --- a/speech/provider_factory.py +++ b/speech/provider_factory.py @@ -1,38 +1,27 @@ """Factory for creating TTS providers.""" import logging -import importlib.util -import os -import tempfile -from typing import Optional, Any - -from .tts_provider import TTSProviderAbstract -from .custom_providers import OpenAITTSProvider +from collections.abc import Callable +from typing import Any # Import TTS wrapper clients from tts_wrapper import ( ElevenLabsClient, - ElevenLabsTTS, GoogleClient, - GoogleTTS, GoogleTransClient, - GoogleTransTTS, MicrosoftClient, - MicrosoftTTS, PlayHTClient, - PlayHTTTS, PollyClient, - PollyTTS, SherpaOnnxClient, - SherpaOnnxTTS, WatsonClient, - WatsonTTS, WitAiClient, - WitAiTTS, eSpeakClient, - eSpeakTTS, ) +from .audio_manager import AudioManager +from .custom_providers import OpenAITTSProvider +from .tts_provider import TTSProviderAbstract + logger = logging.getLogger(__name__) @@ -44,120 +33,346 @@ def create_wrapper_provider(tts_instance: Any) -> TTSProviderAbstract: """Create a provider that wraps a TTS wrapper instance.""" class WrappedProvider(TTSProviderAbstract): - def __init__(self): + """Wrapper for TTS providers that use the TTS library.""" + + def __init__(self, tts_instance: Any): + """Initialize the provider.""" super().__init__() self.tts = tts_instance + self._is_playing = False + self._current_playback = None + self._was_stopped = False + self._on_start = None + self._on_stop = None + self._on_complete = None self.logger = logging.getLogger(__name__) + self.logger.debug( + f"Initialized WrappedProvider with TTS instance: {tts_instance}" + ) - def get_voices(self) -> list[dict[str, Any]]: - return self.tts.get_voices() if self.tts else [] + def get_voices(self, langcodes: str = "bcp47") -> list[dict[str, Any]]: + """Get available voices with specified language code format. + + Args: + langcodes: Language code format to return. Options are: + - "bcp47": BCP-47 format (default) + - "iso639_3": ISO 639-3 format + - "display": Human-readable display names + - "all": All formats in a dictionary + + Returns: + List of voice dictionaries + """ + self.logger.debug( + f"Getting voices from TTS instance with langcodes={langcodes}" + ) + voices = self.tts.get_voices(langcodes=langcodes) if self.tts else [] + self.logger.debug(f"Retrieved {len(voices)} voices") + return voices - def _generate_speak_data(self, text: str, voice_id: str) -> Optional[bytes]: + def _generate_speak_data(self, text: str, voice_id: str) -> bytes | None: + """Generate WAV audio data for text using specified voice.""" try: - # Create a temporary WAV file - with tempfile.NamedTemporaryFile( - suffix=".wav", delete=False - ) as temp_file: - temp_path = temp_file.name - - try: - # Synthesize to WAV file using TTS wrapper - self.tts.synth_to_file(text, temp_path, voice_id=voice_id) - - # Read the WAV file - with open(temp_path, "rb") as f: - wav_data = f.read() - - return wav_data - finally: - # Clean up temp file - try: - os.unlink(temp_path) - except Exception as e: - self.logger.debug(f"Error cleaning up temp file: {e}") + self.logger.debug( + f"Generating speech data for text: '{text}' with voice: {voice_id}" + ) - except Exception as e: - self.logger.error(f"Error getting speech data: {e}") - return None + if not self.tts: + self.logger.error("TTS instance not initialized") + return None - return WrappedProvider() + # In v1.0.0, we can use synth_to_bytes directly + if voice_id: + self.tts.set_voice(voice_id) - @staticmethod - def create_provider( - provider_type: str, config: dict[str, Any] - ) -> Optional[TTSProviderAbstract]: - """Create a TTS provider instance. + # Generate speech to bytes + audio_data = self.tts.synth_to_bytes(text) - Args: - provider_type: Type of provider to create - config: Provider configuration + if audio_data is None: + self.logger.error("Failed to generate audio data") + return None - Returns: - TTSProvider instance or None if creation fails - """ - try: + self.logger.debug(f"Generated audio data: {len(audio_data)} bytes") + return audio_data - # Get provider-specific config - provider_config = config.get("engine_configs", {}).get(provider_type, {}) - - # Handle custom providers first - if provider_type == "openai": - return OpenAITTSProvider(provider_config) - - # Handle TTS wrapper providers - if provider_type == "microsoft": - credentials = provider_config.get("credentials", ("", "")) - client = MicrosoftClient(credentials=credentials) - return TTSProviderFactory.create_wrapper_provider(MicrosoftTTS(client)) - - elif provider_type == "google": - credentials = provider_config.get("credentials", {}) - client = GoogleClient(credentials=credentials) - return TTSProviderFactory.create_wrapper_provider(GoogleTTS(client)) + except Exception as e: + self.logger.error( + f"Error generating speech data: {e}", exc_info=True + ) + return None - elif provider_type == "googletrans": - client = GoogleTransClient() - return TTSProviderFactory.create_wrapper_provider( - GoogleTransTTS(client) + def speak_streamed( + self, + text: str, + voice_id: str, + on_start: Callable | None = None, + on_end: Callable | None = None, + on_word: Callable[[str, float, float], None] | None = None, + on_error: Callable[[Exception], None] | None = None, + ) -> bool: + """Start streaming playback with callbacks.""" + self.logger.debug( + f"Starting streaming playback for text: '{text}' with voice: {voice_id}" ) + try: + if not self.tts: + self.logger.error("TTS instance not initialized") + raise RuntimeError("TTS instance not initialized") + + # Connect callbacks if provided + if on_start: + self.logger.debug("Connecting on_start callback") + self.tts.connect("onStart", on_start) + if on_end: + self.logger.debug("Connecting on_end callback") + self.tts.connect("onEnd", on_end) + if on_word: + self.logger.debug("Connecting on_word callback") + self.tts.connect("onWord", on_word) + + # Start streaming playback + self._is_playing = True + self.logger.debug("Setting _is_playing to True") + + # In v1.0.0, all engines use the same interface + self._current_playback = self.tts.speak_streamed( + text, voice_id=voice_id + ) + + self.logger.debug("Streaming playback started successfully") + return True - elif provider_type == "elevenlabs": - credentials = provider_config.get("credentials", ("",)) - client = ElevenLabsClient(credentials=credentials) - return TTSProviderFactory.create_wrapper_provider(ElevenLabsTTS(client)) - - elif provider_type == "polly": - credentials = provider_config.get("credentials", ("", "", "")) - client = PollyClient(credentials=credentials) - return TTSProviderFactory.create_wrapper_provider(PollyTTS(client)) + except Exception as e: + self.logger.error( + f"Error starting streaming playback: {e}", exc_info=True + ) + if on_error: + self.logger.debug("Calling on_error callback") + on_error(e) + return False + + def stop_playback(self) -> bool: + """Stop current streaming playback.""" + self.logger.debug("Attempting to stop playback") + try: + if self._current_playback: + self.logger.debug("Stopping current playback") + self._current_playback.stop() + self._is_playing = False + self._current_playback = None + self.logger.debug("Playback stopped successfully") + return True + self.logger.debug("No current playback to stop") + return False + except Exception as e: + self.logger.error(f"Error stopping playback: {e}", exc_info=True) + return False + + def speak( + self, text: str, voice_id: str, on_complete: Callable | None = None + ) -> bool: + """Speak text using specified voice. + + Args: + text: Text to speak + voice_id: Voice ID to use + on_complete: Optional callback when playback completes + + Returns: + True if audio started playing, False if error occurred + """ + try: + self.logger.debug( + f"Starting speech for text: '{text}' with voice: {voice_id}" + ) + + # Set up callbacks + def on_start_callback(): + self.logger.debug("Speech started") + if self._on_start: + self._on_start() + + def on_end_callback(): + self.logger.debug("Speech ended") + if self._on_stop: + self._on_stop() + if not self._was_stopped and self._on_complete: + self._on_complete() + if not self._was_stopped and on_complete: + on_complete() + self._was_stopped = False + self._is_playing = False + self.logger.debug("Reset playback state after completion") + + def on_error_callback(error): + self.logger.error(f"Speech error: {error}") + if self._on_stop: + self._on_stop() + if on_complete: + on_complete() + self._is_playing = False + self.logger.debug("Reset playback state after error") + + # Reset stop flag and ensure we're not playing + self._was_stopped = False + self._is_playing = False + self.logger.debug("Reset playback state before starting") + + # Get audio data using parent class method + audio_data = self.get_speak_data(text, voice_id) + if not audio_data: + self.logger.error("Failed to generate audio data") + return False + + # Start playback with callbacks + success = self.audio_manager.play_audio( + audio_data, + on_complete=on_end_callback, + on_error=on_error_callback, + ) + + if success: + on_start_callback() + else: + on_error_callback(RuntimeError("Failed to start playback")) + + return success - elif provider_type == "watson": - credentials = provider_config.get("credentials", ("", "", "")) - client = WatsonClient(credentials=credentials) - return TTSProviderFactory.create_wrapper_provider(WatsonTTS(client)) + except Exception as e: + self.logger.error(f"Error in speak: {e}", exc_info=True) + if on_complete: + on_complete() + self._is_playing = False + self.logger.debug("Reset playback state after exception") + return False + + def stop_speaking(self) -> None: + """Stop current speech playback.""" + if self.is_speaking: + self.logger.debug("Stopping speech playback") + self._was_stopped = True + self.stop_playback() + if self._on_stop: + self._on_stop() + self._is_playing = False + self.logger.debug("Reset playback state after stop") + + @property + def is_speaking(self) -> bool: + """Check if audio is currently playing.""" + return self._is_playing + + return WrappedProvider(tts_instance) - elif provider_type == "witai": - credentials = provider_config.get("credentials", ("",)) - client = WitAiClient(credentials=credentials) - return TTSProviderFactory.create_wrapper_provider(WitAiTTS(client)) + @staticmethod + def create_provider( + engine_type: str, config: dict[str, Any] + ) -> TTSProviderAbstract | None: + """Create a TTS provider instance based on engine type.""" + try: + logger.debug(f"Creating provider for engine type: {engine_type}") + logger.debug(f"Config: {config}") - elif provider_type == "playht": - credentials = provider_config.get("credentials", ("", "")) - client = PlayHTClient(credentials=credentials) - return TTSProviderFactory.create_wrapper_provider(PlayHTTTS(client)) + # Get engine configuration + engine_configs = config.get("engine_configs", {}) + logger.debug(f"Engine configs: {engine_configs}") - elif provider_type == "sherpaonnx": - client = SherpaOnnxClient() - return TTSProviderFactory.create_wrapper_provider(SherpaOnnxTTS(client)) + engine_config = engine_configs.get(engine_type, {}) + logger.debug(f"Engine config for {engine_type}: {engine_config}") - elif provider_type == "espeak": - client = eSpeakClient() - return TTSProviderFactory.create_wrapper_provider(eSpeakTTS(client)) + # Only check for empty config for engines that need credentials + if engine_type not in ["sherpaonnx", "espeak"] and not engine_config: + logger.error(f"No configuration found for engine type: {engine_type}") + return None + # Create TTS instance based on engine type + tts_instance = None + if engine_type == "microsoft": + credentials = engine_config.get("credentials", ("", "")) + if not credentials[0] or not credentials[1]: + logger.error("Missing Microsoft credentials") + return None + # Create client first, then set voice separately + voice_id = engine_config.get("voice_id", None) + tts_instance = MicrosoftClient(credentials=credentials) + if voice_id: + tts_instance.set_voice(voice_id) + elif engine_type == "google": + credentials = engine_config.get("credentials", {}) + voice_id = engine_config.get("voice_id", None) + tts_instance = GoogleClient(credentials=credentials) + if voice_id: + tts_instance.set_voice(voice_id) + elif engine_type == "googletrans": + voice_id = engine_config.get("voice_id", "en-us") + tts_instance = GoogleTransClient() + if voice_id: + tts_instance.set_voice(voice_id) + elif engine_type == "elevenlabs": + credentials = engine_config.get("credentials", ("",)) + voice_id = engine_config.get("voice_id", None) + tts_instance = ElevenLabsClient(credentials=credentials) + if voice_id: + tts_instance.set_voice(voice_id) + elif engine_type == "polly": + credentials = engine_config.get("credentials", ("", "", "")) + voice_id = engine_config.get("voice_id", None) + tts_instance = PollyClient(credentials=credentials) + if voice_id: + tts_instance.set_voice(voice_id) + elif engine_type == "watson": + credentials = engine_config.get("credentials", ("", "", "")) + voice_id = engine_config.get("voice_id", None) + tts_instance = WatsonClient(credentials=credentials) + if voice_id: + tts_instance.set_voice(voice_id) + elif engine_type == "witai": + credentials = engine_config.get("credentials", ("",)) + voice_id = engine_config.get("voice_id", None) + tts_instance = WitAiClient(credentials=credentials) + if voice_id: + tts_instance.set_voice(voice_id) + elif engine_type == "playht": + credentials = engine_config.get("credentials", ("", "")) + voice_id = engine_config.get("voice_id", None) + tts_instance = PlayHTClient(credentials=credentials) + if voice_id: + tts_instance.set_voice(voice_id) + elif engine_type == "sherpaonnx": + # Get voice and language from config if available + voice = engine_config.get("voice", None) + lang = engine_config.get("lang", None) + tts_instance = SherpaOnnxClient() + # Set voice and language if provided + if voice: + tts_instance.set_voice(voice) + if lang: + tts_instance.set_lang(lang) + logger.debug( + f"Created SherpaOnnxClient with lang={lang}, voice={voice}" + ) + elif engine_type == "espeak": + voice_id = engine_config.get("voice_id", None) + tts_instance = eSpeakClient() + if voice_id: + tts_instance.set_voice(voice_id) + elif engine_type == "openai": + # Create OpenAI provider directly + openai_provider = OpenAITTSProvider(engine_config) + # Set audio manager for the provider + openai_provider.audio_manager = AudioManager() + return openai_provider else: - logger.error(f"Unknown provider type: {provider_type}") + logger.error(f"Unsupported engine type: {engine_type}") + return None + + if not tts_instance: + logger.error(f"Failed to create TTS instance for {engine_type}") return None + # Create wrapped provider + return TTSProviderFactory.create_wrapper_provider(tts_instance) + except Exception as e: - logger.error(f"Error creating provider {provider_type}: {e}") + logger.error(f"Error creating provider {engine_type}: {e}", exc_info=True) return None diff --git a/speech/start.py b/speech/start.py index ba7967a..52bec2a 100644 --- a/speech/start.py +++ b/speech/start.py @@ -29,14 +29,14 @@ sys.path.append(os.path.dirname(bundle_dir)) try: + from speech.audio_manager import AudioManager from speech.config_manager import ConfigManager from speech.provider_factory import TTSProviderFactory - from speech.audio_manager import AudioManager except ImportError: # Fallback for when running as module + from audio_manager import AudioManager from config_manager import ConfigManager from provider_factory import TTSProviderFactory - from audio_manager import AudioManager # Configure logging logging.basicConfig(level=logging.DEBUG) @@ -322,6 +322,19 @@ def handle_error(error): return jsonify({"error": str(error), "status": "error"}), 200 +langcodes_param = ns.model( + "LangcodesParam", + { + "langcodes": fields.String( + description="Language code format to return", + enum=["bcp47", "iso639_3", "display", "all"], + default="bcp47", + required=False, + ) + }, +) + + @ns.route("/voices") class Voices(Resource): @ns.doc( @@ -331,13 +344,24 @@ class Voices(Resource): ) @ns.response(200, "Success", voices_response) @ns.response(500, "Error", error_response) + @ns.param( + "langcodes", + "Language code format to return (bcp47, iso639_3, display, or all)", + _in="query", + default="bcp47", + ) def get(self): """Get available voices from all providers.""" try: + # Get langcodes parameter from query string + langcodes = request.args.get("langcodes", "bcp47") + if langcodes not in ["bcp47", "iso639_3", "display", "all"]: + langcodes = "bcp47" # Default to BCP-47 format + voices = [] for provider_id, provider in providers.items(): try: - provider_voices = provider.get_voices() + provider_voices = provider.get_voices(langcodes=langcodes) for voice in provider_voices: voice["providerId"] = provider_id voices.append(voice) @@ -600,13 +624,13 @@ def test(): return render_template("test.html") -def start_server(): +def start_server(port=5555): """Start the Flask server.""" try: # Start Flask server app.run( host="127.0.0.1", - port=5555, + port=port, debug=False, # Disable debug mode use_reloader=False, # Disable the reloader ) @@ -616,4 +640,16 @@ def start_server(): if __name__ == "__main__": - start_server() + import argparse + + # Parse command line arguments + parser = argparse.ArgumentParser(description="Start the speech service") + parser.add_argument( + "--port", type=int, default=5555, help="Port to run the server on" + ) + parser.add_argument("--debug", action="store_true", help="Enable debug mode") + + args = parser.parse_args() + + # Start the server + start_server(port=args.port) diff --git a/speech/test_audio.py b/speech/test_audio.py index 9e86f4a..761b00e 100644 --- a/speech/test_audio.py +++ b/speech/test_audio.py @@ -1,205 +1,203 @@ +#!/usr/bin/env python + """Test audio playback and event handling with sherpaonnx.""" import asyncio import logging -from pathlib import Path + from speech.provider_factory import TTSProviderFactory -from speech.audio_manager import AudioManager # Setup logging logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger(__name__) -# Test configuration matching settings.ini structure -config = { - "General": { - "engines": ["sherpaonnx"], # Only test sherpaonnx - "cache_enabled": True, - "cache_dir": "temp", - } -} +# Test texts +LONG_TEXT = ( + "This is a long test sentence that should take several seconds to " + "speak. We want to make sure we can stop it in the middle." +) +SHORT_TEXT = "Short test." +MULTIPLE_TEXTS = [ + "First test sentence.", + "Second test sentence.", + "Third test sentence.", +] async def test_audio_playback(): - """Test audio playback with sherpaonnx.""" - # Create provider and audio manager - factory = TTSProviderFactory() - provider = factory.create_provider("sherpaonnx", config) - audio_manager = AudioManager() - - # Test text - long_text = ( - "This is a long test sentence that should take several seconds to " - "speak. We want to make sure we can stop it in the middle." - ) - short_text = "Short test." - - # Event flags - long_text_started = asyncio.Event() - long_text_stopped = asyncio.Event() - short_text_started = asyncio.Event() - short_text_completed = asyncio.Event() - cached_text_started = asyncio.Event() - cached_text_completed = asyncio.Event() - - def on_long_start(): - logger.info("Long text started playing") - long_text_started.set() - - def on_long_stop(): - logger.info("Long text stopped") - long_text_stopped.set() - - def on_short_start(): - logger.info("Short text started playing") - short_text_started.set() - - def on_short_complete(): - logger.info("Short text completed") - short_text_completed.set() - - def on_cached_start(): - logger.info("Cached text started playing") - cached_text_started.set() - - def on_cached_complete(): - logger.info("Cached text completed") - cached_text_completed.set() - + """Test audio playback and event handling with TTS provider.""" + provider = None try: - # First test: Long text with stop - logger.info("=== Starting first test: Long text with stop ===") - # Get audio data for long text - logger.info("Getting audio data for long text...") - audio_data = provider.get_speak_data(long_text, "mms_eng") - - # Start playing long text - logger.info("Starting long text playback...") - provider.set_speech_handlers(on_start=on_long_start, on_stop=on_long_stop) - success = audio_manager.play_audio( - audio_data, - on_start=on_long_start, - on_complete=on_long_stop, - ) - - if not success: - logger.error("Failed to start long text playback") + # Create config with only sherpaonnx + config = { + "General": { + "engines": ["sherpaonnx"], # Only test sherpaonnx + "cache_enabled": True, + "cache_dir": "temp", + }, + "engine_configs": { + "sherpaonnx": { + "voice": "mms_eng", # Default English voice + "lang": "en", # English language + } + }, + } + logger.debug(f"Using config: {config}") + + # Initialize provider with sherpaonnx + provider = TTSProviderFactory.create_provider("sherpaonnx", config) + if not provider: + logger.error("Failed to create provider") return - # Wait for playback to start with timeout - try: - await asyncio.wait_for(long_text_started.wait(), timeout=2.0) - logger.info("Long text playback started") - except asyncio.TimeoutError: - logger.error("Timeout waiting for playback to start") - audio_manager.stop() + # Get available voices + voices = provider.get_voices() + if not voices: + logger.error("No voices available") return - # Wait a bit then stop - await asyncio.sleep(2.0) - logger.info("Stopping long text playback...") - audio_manager.stop() - long_text_stopped.set() + # Use first available voice + voice_id = voices[0]["id"] + logger.info(f"Using voice: {voice_id}") - # Wait for stop event with timeout - try: - await asyncio.wait_for(long_text_stopped.wait(), timeout=2.0) - logger.info("Long text playback stopped") - except asyncio.TimeoutError: - logger.error("Timeout waiting for playback to stop") - return + # Event flags for state tracking + long_text_started = asyncio.Event() + long_text_stopped = asyncio.Event() + short_text_started = asyncio.Event() + short_text_completed = asyncio.Event() - # Short delay to ensure cleanup - await asyncio.sleep(0.5) + # Test 1: Long text with stop + logger.info("=== Starting first test: Long text with stop ===") + long_text = "This is a long text that will be played and then stopped." - # Second test: Short text with completion - logger.info("=== Starting second test: Short text with completion ===") - # Get audio data for short text - logger.info("Getting audio data for short text...") - audio_data = provider.get_speak_data(short_text, "mms_eng") + def on_start(): + logger.info("Long text started playing") + long_text_started.set() - # Start playing short text - logger.info("Starting short text playback...") - provider.set_speech_handlers( - on_start=on_short_start, on_complete=on_short_complete - ) - success = audio_manager.play_audio( - audio_data, on_start=on_short_start, on_complete=on_short_complete - ) + def on_stop(): + logger.info("Long text stopped") + long_text_stopped.set() + + def on_complete(): + logger.info("Long text playback completed") + def on_error(error): + logger.error(f"Long text playback error: {error}") + + success = provider.speak( + long_text, + voice_id, + on_start=on_start, + on_stop=on_stop, + on_complete=on_complete, + on_error=on_error, + ) if not success: - logger.error("Failed to start short text playback") + logger.error("Failed to start long text playback") return - # Wait for short text to start with timeout + # Wait for start with timeout try: - await asyncio.wait_for(short_text_started.wait(), timeout=2.0) - logger.info("Short text playback started") - except asyncio.TimeoutError: - logger.error("Timeout waiting for short text to start") - audio_manager.stop() + await asyncio.wait_for(long_text_started.wait(), timeout=10.0) + except TimeoutError: + logger.error("Timeout waiting for long text to start") return - # Wait for completion with timeout + # Wait a bit then stop + await asyncio.sleep(2) + logger.info("Stopping playback...") + provider.stop_speaking() + + # Wait for stop with timeout try: - await asyncio.wait_for(short_text_completed.wait(), timeout=5.0) - logger.info("Short text playback completed") - except asyncio.TimeoutError: - logger.error("Timeout waiting for short text to complete") - audio_manager.stop() + await asyncio.wait_for(long_text_stopped.wait(), timeout=10.0) + except TimeoutError: + logger.error("Timeout waiting for long text to stop") return - # Third test: Cached audio playback - logger.info("=== Starting third test: Cached audio playback ===") - # Get the same audio data again - should use cache - logger.info("Getting cached audio data for short text...") - cached_audio_data = provider.get_speak_data(short_text, "mms_eng") + # Test 2: Short text + logger.info("\n=== Starting second test: Short text ===") + short_text = "This is a short test." - # Start playing cached audio - logger.info("Starting cached audio playback...") - provider.set_speech_handlers( - on_start=on_cached_start, on_complete=on_cached_complete - ) - success = audio_manager.play_audio( - cached_audio_data, on_start=on_cached_start, on_complete=on_cached_complete - ) + def on_start(): + logger.info("Short text started playing") + short_text_started.set() - if not success: - logger.error("Failed to start cached audio playback") - return + def on_complete(): + logger.info("Short text completed") + short_text_completed.set() - # Wait for cached audio to start with timeout - try: - await asyncio.wait_for(cached_text_started.wait(), timeout=2.0) - logger.info("Cached audio playback started") - except asyncio.TimeoutError: - logger.error("Timeout waiting for cached audio to start") - audio_manager.stop() + def on_error(error): + logger.error(f"Short text playback error: {error}") + + success = provider.speak( + short_text, + voice_id, + on_start=on_start, + on_complete=on_complete, + on_error=on_error, + ) + if not success: + logger.error("Failed to start short text playback") return # Wait for completion with timeout try: - await asyncio.wait_for(cached_text_completed.wait(), timeout=5.0) - logger.info("Cached audio playback completed") - except asyncio.TimeoutError: - logger.error("Timeout waiting for cached audio to complete") - audio_manager.stop() + await asyncio.wait_for(short_text_completed.wait(), timeout=10.0) + except TimeoutError: + logger.error("Timeout waiting for short text to complete") return - logger.info("All tests completed successfully") + # Test 3: Multiple short texts + logger.info("\n=== Starting third test: Multiple short texts ===") + texts = ["First text.", "Second text.", "Third text."] + + for i, text in enumerate(texts, 1): + logger.info(f"Getting audio data for text {i}...") + + text_started = asyncio.Event() + text_completed = asyncio.Event() + + def on_start(): + logger.info(f"Text {i} started playing") + text_started.set() + + def on_complete(): + logger.info(f"Text {i} completed") + text_completed.set() + + def on_error(error): + logger.error(f"Text {i} playback error: {error}") + + logger.info(f"Starting Text {i} playback...") + success = provider.speak( + text, + voice_id, + on_start=on_start, + on_complete=on_complete, + on_error=on_error, + ) + if not success: + logger.error(f"Failed to start text {i} playback") + continue + + # Wait for completion with timeout + try: + await asyncio.wait_for(text_completed.wait(), timeout=10.0) + except TimeoutError: + logger.error(f"Timeout waiting for text {i} to complete") + provider.stop_speaking() + break + + logger.info("\nAll tests completed successfully!") except Exception as e: - logger.error(f"Test failed with error: {str(e)}", exc_info=True) - audio_manager.stop() + logger.error(f"Test error: {e}", exc_info=True) finally: - # Ensure cleanup - audio_manager.stop() + # Cleanup + if provider and provider.is_speaking: + provider.stop_speaking() if __name__ == "__main__": - try: - asyncio.run(test_audio_playback()) - except KeyboardInterrupt: - logger.info("Test interrupted by user") - except Exception as e: - logger.error(f"Test failed: {str(e)}", exc_info=True) + asyncio.run(test_audio_playback()) diff --git a/speech/test_engines.py b/speech/test_engines.py index e0606b5..c9c9828 100644 --- a/speech/test_engines.py +++ b/speech/test_engines.py @@ -1,14 +1,18 @@ +#!/usr/bin/env python + """Test harness for TTS providers.""" import asyncio import json import logging +import time from datetime import datetime -from typing import Any, Dict, List +from typing import Any -from speech.tts_provider import TTSProviderAbstract -from speech.provider_factory import TTSProviderFactory +from speech.audio_manager import AudioManager from speech.config_manager import ConfigManager +from speech.provider_factory import TTSProviderFactory +from speech.tts_provider import TTSProviderAbstract # Configure logging logging.basicConfig( @@ -28,6 +32,12 @@ summary_logger.addHandler(summary_handler) summary_logger.setLevel(logging.INFO) +# Test text +TEST_TEXT = "This is a test sentence for the TTS engine." + +# Create audio manager instance +audio_manager = AudioManager() + def log_summary(message: str) -> None: """Log a message to both the summary file and console.""" @@ -41,9 +51,9 @@ class EngineTester: def __init__(self) -> None: """Initialize the engine tester.""" self.config_manager = ConfigManager() - self.providers: Dict[str, TTSProviderAbstract] = {} + self.providers: dict[str, TTSProviderAbstract] = {} self.test_text = "Hello! This is a test of the text-to-speech system." - self.summary_data: Dict[str, Any] = { + self.summary_data: dict[str, Any] = { "timestamp": datetime.now().isoformat(), "providers": {}, } @@ -84,7 +94,7 @@ def check_credentials(self, provider_id: str) -> bool: logger.error(f"Error checking credentials for {provider_id}: {e}") return False - def print_voice_info(self, voice: Dict[str, Any], provider_id: str) -> None: + def print_voice_info(self, voice: dict[str, Any], provider_id: str) -> None: """Print detailed information about a voice. Args: @@ -105,7 +115,7 @@ def print_voice_info(self, voice: Dict[str, Any], provider_id: str) -> None: for issue in issues: logger.warning(f" - {issue}") - def _check_voice_issues(self, voice: Dict[str, Any]) -> List[str]: + def _check_voice_issues(self, voice: dict[str, Any]) -> list[str]: """Check for potential issues in voice data. Args: @@ -138,17 +148,17 @@ def _sanitize_voice_data(self, voice: dict) -> dict: sanitized: dict[str, Any] = {} for key, value in voice.items(): # Handle common types - if isinstance(value, (str, int, float, bool, type(None))): + if isinstance(value, str | int | float | bool | type(None)): sanitized[key] = value # Handle lists/tuples - elif isinstance(value, (list, tuple)): + elif isinstance(value, list | tuple): sanitized[key] = [ ( self._sanitize_voice_data(item) if isinstance(item, dict) else ( str(item) - if not isinstance(item, (str, int, float, bool, type(None))) + if not isinstance(item, str | int | float | bool | type(None)) else item ) ) @@ -227,11 +237,11 @@ async def test_speech_functionality(self, provider_id: str, voice_id: str) -> No return logger.info(f"✓ Successfully got speech data ({len(audio_data)} bytes)") except Exception as e: - logger.error(f"Failed to get speech data: {str(e)}") + logger.error(f"Failed to get speech data: {e!s}") self.summary_data["providers"][provider_id]["speech_test"] = "failed" self.summary_data["providers"][provider_id][ "speech_error" - ] = f"Speech data error: {str(e)}" + ] = f"Speech data error: {e!s}" return # Test 1: Basic speech with events @@ -274,7 +284,7 @@ def on_complete(): try: await asyncio.wait_for(start_event.wait(), timeout=2) logger.info("✓ Speech started") - except asyncio.TimeoutError: + except TimeoutError: logger.error("Speech did not start within timeout") provider.stop_speaking() # Ensure cleanup self.summary_data["providers"][provider_id]["speech_test"] = "failed" @@ -302,7 +312,7 @@ def on_complete(): try: await asyncio.wait_for(stop_event.wait(), timeout=2) logger.info("✓ Stop event received") - except asyncio.TimeoutError: + except TimeoutError: logger.error("Stop event not received within timeout") self.summary_data["providers"][provider_id]["speech_test"] = "failed" self.summary_data["providers"][provider_id][ @@ -373,7 +383,7 @@ def on_complete(): ] = "Still active after completion" return - except asyncio.TimeoutError: + except TimeoutError: logger.error("Full playback did not complete within timeout") provider.stop_speaking() # Ensure cleanup self.summary_data["providers"][provider_id]["speech_test"] = "failed" @@ -386,7 +396,7 @@ def on_complete(): self.summary_data["providers"][provider_id]["speech_test"] = "success" except Exception as e: - logger.error(f"Error testing speech functionality: {str(e)}") + logger.error(f"Error testing speech functionality: {e!s}") logger.exception(f"Detailed error for {provider_id}:") self.summary_data["providers"][provider_id]["speech_test"] = "error" self.summary_data["providers"][provider_id]["speech_error"] = str(e) @@ -394,7 +404,7 @@ def on_complete(): try: provider.stop_speaking() except Exception as cleanup_error: - logger.debug(f"Cleanup error: {str(cleanup_error)}") + logger.debug(f"Cleanup error: {cleanup_error!s}") pass async def run_tests(self) -> None: @@ -452,6 +462,64 @@ async def run_tests(self) -> None: json.dump(self.summary_data, f, indent=2) +def test_provider(provider: TTSProviderAbstract, provider_name: str) -> bool: + """Test a TTS provider. + + Args: + provider: Provider instance to test + provider_name: Name of the provider for logging + + Returns: + True if all tests passed + """ + try: + logger.info(f"\n=== Testing {provider_name} ===") + + # Test 1: Get voices + logger.info("Getting available voices...") + voices = provider.get_voices() + if not voices: + logger.error(f"No voices available for {provider_name}") + return False + logger.info(f"Found {len(voices)} voices") + for voice in voices: + logger.info(f"Voice: {voice['name']} ({voice['id']})") + + # Test 2: Generate speech data + logger.info("\nGenerating speech data...") + audio_data = provider.get_speak_data(TEST_TEXT, voices[0]["id"]) + if audio_data is None: + logger.error(f"Failed to generate speech data for {provider_name}") + return False + logger.info(f"Generated {len(audio_data)} bytes of audio data") + + # Test 3: Play audio + logger.info("\nPlaying audio...") + success = audio_manager.play_audio( + audio_data, + on_complete=lambda: logger.info(f"{provider_name} playback completed"), + on_error=lambda e: logger.error(f"{provider_name} playback error: {e}"), + ) + if not success: + logger.error(f"Failed to start playback for {provider_name}") + return False + + # Wait for completion + while audio_manager.is_playing: + time.sleep(0.1) + time.sleep(1) + + logger.info(f"\n{provider_name} tests completed successfully!") + return True + + except Exception as e: + logger.error(f"Error testing {provider_name}: {e}", exc_info=True) + return False + finally: + # Ensure cleanup + audio_manager.stop() + + def main() -> None: """Main entry point.""" tester = EngineTester() diff --git a/speech/tts_provider.py b/speech/tts_provider.py index d0b4ac2..ce75662 100644 --- a/speech/tts_provider.py +++ b/speech/tts_provider.py @@ -1,27 +1,29 @@ """Abstract base class for TTS providers.""" +import hashlib +import json import logging +import time from abc import ABC, abstractmethod -from typing import Optional, Callable, Any, Dict +from collections.abc import Callable from functools import lru_cache -from .audio_manager import AudioManager -import hashlib from pathlib import Path -import json -import time +from typing import Any + +from .audio_manager import AudioManager class TTSProviderAbstract(ABC): """Abstract base class for all TTS providers.""" - def __init__(self, config: Optional[Dict] = None): + def __init__(self, config: dict | None = None): """Initialize the TTS provider with audio management.""" self.audio_manager = AudioManager() self.logger = logging.getLogger(__name__) - self._voice_cache: Dict[str, list[dict[str, Any]]] = {} - self._on_start: Optional[Callable] = None - self._on_stop: Optional[Callable] = None - self._on_complete: Optional[Callable] = None + self._voice_cache: dict[str, list[dict[str, Any]]] = {} + self._on_start: Callable | None = None + self._on_stop: Callable | None = None + self._on_complete: Callable | None = None self._was_stopped = False # Setup caching @@ -35,7 +37,7 @@ def __init__(self, config: Optional[Dict] = None): def _get_cache_key(self, text: str, voice_id: str) -> str: """Generate cache key for audio data.""" # Create a unique key based on text and voice - key_data = f"{text}:{voice_id}".encode("utf-8") + key_data = f"{text}:{voice_id}".encode() return hashlib.md5(key_data).hexdigest() def _get_cache_path(self, cache_key: str) -> Path: @@ -47,7 +49,7 @@ def _get_metadata_path(self, cache_key: str) -> Path: return self._audio_cache_dir / f"{cache_key}.json" def _cache_audio_data( - self, cache_key: str, audio_data: bytes, metadata: Dict + self, cache_key: str, audio_data: bytes, metadata: dict ) -> None: """Cache audio data and metadata.""" if not self._cache_enabled: @@ -68,7 +70,7 @@ def _cache_audio_data( except Exception as e: self.logger.error(f"Error caching audio data: {e}") - def _get_cached_audio(self, cache_key: str) -> Optional[bytes]: + def _get_cached_audio(self, cache_key: str) -> bytes | None: """Get cached audio data if available.""" if not self._cache_enabled: return None @@ -79,8 +81,8 @@ def _get_cached_audio(self, cache_key: str) -> Optional[bytes]: if audio_path.exists() and meta_path.exists(): # Load metadata to verify cache validity - with open(meta_path, "r") as f: - metadata = json.load(f) + with open(meta_path) as f: + json.load(f) # TODO: Add cache validation based on metadata # For now, just return the cached audio @@ -92,7 +94,7 @@ def _get_cached_audio(self, cache_key: str) -> Optional[bytes]: self.logger.error(f"Error reading cached audio: {e}") return None - def get_speak_data(self, text: str, voice_id: str) -> Optional[bytes]: + def get_speak_data(self, text: str, voice_id: str) -> bytes | None: """Get audio data for text, using cache if available.""" # If caching is disabled, just generate new audio if not self._cache_enabled: @@ -113,7 +115,7 @@ def get_speak_data(self, text: str, voice_id: str) -> Optional[bytes]: return audio_data @abstractmethod - def _generate_speak_data(self, text: str, voice_id: str) -> Optional[bytes]: + def _generate_speak_data(self, text: str, voice_id: str) -> bytes | None: """Generate audio data for text. Must be implemented by concrete providers. Args: @@ -126,7 +128,7 @@ def _generate_speak_data(self, text: str, voice_id: str) -> Optional[bytes]: pass def speak( - self, text: str, voice_id: str, on_complete: Optional[Callable] = None + self, text: str, voice_id: str, on_complete: Callable | None = None ) -> bool: """Speak text using specified voice. @@ -176,31 +178,63 @@ def on_audio_start(): return False @abstractmethod - def get_voices(self) -> list[dict[str, Any]]: - """Get available voices. Must be implemented by concrete providers. + def get_voices(self, langcodes: str = "bcp47") -> list[dict[str, Any]]: + """Get available voices with specified language code format. + + Args: + langcodes: Language code format to return. Options are: + - "bcp47": BCP-47 format (default) + - "iso639_3": ISO 639-3 format + - "display": Human-readable display names + - "all": All formats in a dictionary Returns: - List of voice dictionaries with standardized format: + List of voice dictionaries with standardized format. + + For bcp47, iso639_3, and display formats: { "id": str, "name": str, - "language": str, - "language_codes": list[str], + "language_codes": list[str], # Format depends on langcodes parameter + "gender": str + } + + For "all" format: + { + "id": str, + "name": str, + "language_codes": { # Dictionary of language codes + "": { + "bcp47": str, + "iso639_3": str, + "display": str + } + }, "gender": str } """ pass @lru_cache(maxsize=100) - def _get_cached_voices(self) -> list[dict[str, Any]]: - """Get cached list of voices, fetching from provider if not cached.""" - return self.get_voices() + def _get_cached_voices(self, langcodes: str = "bcp47") -> list[dict[str, Any]]: + """Get cached list of voices, fetching from provider if not cached. + + Args: + langcodes: Language code format to return + + Returns: + List of voice dictionaries + """ + return self.get_voices(langcodes=langcodes) - def get_cached_voices(self, force_refresh: bool = False) -> list[dict[str, Any]]: + def get_cached_voices( + self, force_refresh: bool = False, langcodes: str = "bcp47" + ) -> list[dict[str, Any]]: """Get list of available voices, using cache if available. Args: force_refresh: If True, ignore cache and fetch fresh data + langcodes: Language code format to return Returns: List of voice dictionaries @@ -209,7 +243,7 @@ def get_cached_voices(self, force_refresh: bool = False) -> list[dict[str, Any]] # Clear the cache and fetch fresh data self._get_cached_voices.cache_clear() - return self._get_cached_voices() + return self._get_cached_voices(langcodes=langcodes) def stop_speaking(self) -> None: """Stop current speech playback.""" @@ -230,9 +264,9 @@ def is_speaking(self) -> bool: def set_speech_handlers( self, - on_start: Optional[Callable] = None, - on_stop: Optional[Callable] = None, - on_complete: Optional[Callable] = None, + on_start: Callable | None = None, + on_stop: Callable | None = None, + on_complete: Callable | None = None, ) -> None: """Set handlers for speech events. diff --git a/test_provider.py b/test_provider.py new file mode 100644 index 0000000..69699f9 --- /dev/null +++ b/test_provider.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python + +import logging + +from speech.provider_factory import TTSProviderFactory + +# Configure logging +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + +def main(): + """Test the TTS provider factory.""" + # Create provider factory + provider_factory = TTSProviderFactory() + + # Create a simple config with just espeak + config = { + "engines": ["espeak"], + "cache_enabled": True, + "cache_dir": "temp", + "engine_configs": { + "espeak": {} + } + } + + # Create provider + provider = provider_factory.create_provider("espeak", config) + if provider: + logger.info("Successfully created espeak provider") + + # Test getting voices + voices = provider.get_voices() + logger.info(f"Got {len(voices)} voices") + + # Test speaking + text = "Hello, world!" + voice_id = voices[0]["id"] if voices else None + logger.info(f"Speaking with voice: {voice_id}") + + # Test get_speak_data + audio_data = provider.get_speak_data(text, voice_id) + if audio_data: + logger.info(f"Got audio data: {len(audio_data)} bytes") + else: + logger.error("Failed to get audio data") + else: + logger.error("Failed to create espeak provider") + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock index c11bb84..20abdb8 100644 --- a/uv.lock +++ b/uv.lock @@ -94,10 +94,10 @@ requires-dist = [ { name = "omegaconf", specifier = ">=2.3.0" }, { name = "omegaconf", marker = "extra == 'test'" }, { name = "openai", specifier = ">=1.12.0" }, - { name = "py3-tts-wrapper", extras = ["avsynth"], marker = "sys_platform == 'darwin'", specifier = "==0.9.32" }, - { name = "py3-tts-wrapper", extras = ["espeak"], marker = "sys_platform == 'linux'", specifier = "==0.9.32" }, - { name = "py3-tts-wrapper", extras = ["sapi"], marker = "sys_platform == 'win32'", specifier = "==0.9.32" }, - { name = "py3-tts-wrapper", extras = ["sherpaonnx", "espeak", "elevenlabs", "playht", "microsoft", "polly", "watson", "googletrans", "playht", "witai", "controlaudio"], specifier = "==0.9.32" }, + { name = "py3-tts-wrapper", extras = ["avsynth"], marker = "sys_platform == 'darwin'", specifier = "==0.10.2b0" }, + { name = "py3-tts-wrapper", extras = ["espeak"], marker = "sys_platform == 'linux'", specifier = "==0.10.2b0" }, + { name = "py3-tts-wrapper", extras = ["sapi"], marker = "sys_platform == 'win32'", specifier = "==0.10.2b0" }, + { name = "py3-tts-wrapper", extras = ["sherpaonnx", "espeak", "elevenlabs", "playht", "microsoft", "polly", "watson", "googletrans", "playht", "witai", "controlaudio"], specifier = "==0.10.2b0" }, { name = "pyinstaller", marker = "extra == 'test'" }, { name = "pytest", marker = "extra == 'test'" }, { name = "pytest-cov", marker = "extra == 'test'" }, @@ -687,6 +687,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/0f/8910b19ac0670a0f80ce1008e5e751c4a57e14d2c4c13a482aa6079fa9d6/jsonschema_specifications-2024.10.1-py3-none-any.whl", hash = "sha256:a09a0680616357d9a0ecf05c12ad234479f549239d0f5b55f3deea67475da9bf", size = 18459 }, ] +[[package]] +name = "langcodes" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "language-data" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/7a/5a97e327063409a5caa21541e6d08ae4a0f2da328447e9f2c7b39e179226/langcodes-3.5.0.tar.gz", hash = "sha256:1eef8168d07e51e131a2497ffecad4b663f6208e7c3ae3b8dc15c51734a6f801", size = 191030 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6b/068c2ea7a712bf805c62445bd9e9c06d7340358ef2824150eceac027444b/langcodes-3.5.0-py3-none-any.whl", hash = "sha256:853c69d1a35e0e13da2f427bb68fb2fa4a8f4fb899e0c62ad8df8d073dcfed33", size = 182974 }, +] + +[[package]] +name = "language-data" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "marisa-trie" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/ce/3f144716a9f2cbf42aa86ebc8b085a184be25c80aa453eea17c294d239c1/language_data-1.3.0.tar.gz", hash = "sha256:7600ef8aa39555145d06c89f0c324bf7dab834ea0b0a439d8243762e3ebad7ec", size = 5129310 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/e9/5a5ffd9b286db82be70d677d0a91e4d58f7912bb8dd026ddeeb4abe70679/language_data-1.3.0-py3-none-any.whl", hash = "sha256:e2ee943551b5ae5f89cd0e801d1fc3835bb0ef5b7e9c3a4e8e17b2b214548fbf", size = 5385760 }, +] + [[package]] name = "macholib" version = "1.16.3" @@ -699,6 +723,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/5d/c059c180c84f7962db0aeae7c3b9303ed1d73d76f2bfbc32bc231c8be314/macholib-1.16.3-py2.py3-none-any.whl", hash = "sha256:0e315d7583d38b8c77e815b1ecbdbf504a8258d8b3e17b61165c6feb60d18f2c", size = 38094 }, ] +[[package]] +name = "marisa-trie" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/15/9d9743897e4450b2de199ee673b50cb018980c4ced477d41cf91304a85e3/marisa_trie-1.2.1.tar.gz", hash = "sha256:3a27c408e2aefc03e0f1d25b2ff2afb85aac3568f6fa2ae2a53b57a2e87ce29d", size = 416124 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/83/ccf5b33f2123f3110705c608f8e0caa82002626511aafafc58f82e50d322/marisa_trie-1.2.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a2eb41d2f9114d8b7bd66772c237111e00d2bae2260824560eaa0a1e291ce9e8", size = 362200 }, + { url = "https://files.pythonhosted.org/packages/9d/74/f7ce1fc2ee480c7f8ceadd9b992caceaba442a97e5e99d6aea00d3635a0b/marisa_trie-1.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9e956e6a46f604b17d570901e66f5214fb6f658c21e5e7665deace236793cef6", size = 192309 }, + { url = "https://files.pythonhosted.org/packages/e4/52/5dbbc13e57ce54c2ef0d04962d7d8f66edc69ed34310c734a2913199a581/marisa_trie-1.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bd45142501300e7538b2e544905580918b67b1c82abed1275fe4c682c95635fa", size = 174713 }, + { url = "https://files.pythonhosted.org/packages/57/49/2580372f3f980aea95c23d05b2c1d3bbb9ee1ab8cfd441545153e44f1be7/marisa_trie-1.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8443d116c612cfd1961fbf76769faf0561a46d8e317315dd13f9d9639ad500c", size = 1314808 }, + { url = "https://files.pythonhosted.org/packages/5a/ba/e12a4d450f265414cc68df6a116a78beece72b95f774f04d29cd48e08d19/marisa_trie-1.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:875a6248e60fbb48d947b574ffa4170f34981f9e579bde960d0f9a49ea393ecc", size = 1346678 }, + { url = "https://files.pythonhosted.org/packages/b2/81/8e130cb1eea741fd17694d821096f7ec9841f0e3d3c69b740257f5eeafa8/marisa_trie-1.2.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:746a7c60a17fccd3cfcfd4326926f02ea4fcdfc25d513411a0c4fc8e4a1ca51f", size = 1307254 }, + { url = "https://files.pythonhosted.org/packages/d7/d0/3deb5ea2bf7e4d845339875dbb31f3c3f66c8d6568723db1d137fb08a91c/marisa_trie-1.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e70869737cc0e5bd903f620667da6c330d6737048d1f44db792a6af68a1d35be", size = 2194712 }, + { url = "https://files.pythonhosted.org/packages/9c/5f/b38d728dd30954816497b53425cfaddaf7b93ac0912db5911888f191b07a/marisa_trie-1.2.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:06b099dd743676dbcd8abd8465ceac8f6d97d8bfaabe2c83b965495523b4cef2", size = 2355625 }, + { url = "https://files.pythonhosted.org/packages/7e/4f/61c0faa9ae9e53600a1b7a0c367bc9db1a4fdc625402ec232c755a05e094/marisa_trie-1.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d2a82eb21afdaf22b50d9b996472305c05ca67fc4ff5a026a220320c9c961db6", size = 2290290 }, + { url = "https://files.pythonhosted.org/packages/7c/7d/713b970fb3043248881ed776dbf4d54918398aa5dde843a38711d0d62c8f/marisa_trie-1.2.1-cp310-cp310-win32.whl", hash = "sha256:8951e7ce5d3167fbd085703b4cbb3f47948ed66826bef9a2173c379508776cf5", size = 130743 }, + { url = "https://files.pythonhosted.org/packages/cc/94/3d619cc82c30daeacd18a88674f4e6540ebfb7b4b7752ca0552793be80cf/marisa_trie-1.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:5685a14b3099b1422c4f59fa38b0bf4b5342ee6cc38ae57df9666a0b28eeaad3", size = 151891 }, + { url = "https://files.pythonhosted.org/packages/4a/93/ffb01dfa22b6eee918e798e0bc3487427036c608aa4c065725f31aaf4104/marisa_trie-1.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed3fb4ed7f2084597e862bcd56c56c5529e773729a426c083238682dba540e98", size = 362823 }, + { url = "https://files.pythonhosted.org/packages/6d/1d/5c36500ac350c278c9bdfd88e17fa846fa4136d75597c167141ed973cdf2/marisa_trie-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0fe69fb9ffb2767746181f7b3b29bbd3454d1d24717b5958e030494f3d3cddf3", size = 192741 }, + { url = "https://files.pythonhosted.org/packages/e8/04/87dd0840f3f720e511eba56193c02bf64d7d96df1ca9f6d19994f55154be/marisa_trie-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4728ed3ae372d1ea2cdbd5eaa27b8f20a10e415d1f9d153314831e67d963f281", size = 174995 }, + { url = "https://files.pythonhosted.org/packages/c9/51/9e903a7e13b7593e2e675d0ec4c390ca076dc5df1c1a0d5e85a513b886a3/marisa_trie-1.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8cf4f25cf895692b232f49aa5397af6aba78bb679fb917a05fce8d3cb1ee446d", size = 1384728 }, + { url = "https://files.pythonhosted.org/packages/e8/3f/7362a5ac60c2b0aad0f52cd57e7bd0c708f20d2660d8df85360f3d8f1c4b/marisa_trie-1.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7cca7f96236ffdbf49be4b2e42c132e3df05968ac424544034767650913524de", size = 1412620 }, + { url = "https://files.pythonhosted.org/packages/1f/bc/aaa3eaf6875f78a204a8da9692d56e3a36f89997dad2c388628385614576/marisa_trie-1.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d7eb20bf0e8b55a58d2a9b518aabc4c18278787bdba476c551dd1c1ed109e509", size = 1361555 }, + { url = "https://files.pythonhosted.org/packages/18/98/e11b5a6206c5d110f32adab37fa84a85410d684e9c731acdd5c9250e2ce4/marisa_trie-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b1ec93f0d1ee6d7ab680a6d8ea1a08bf264636358e92692072170032dda652ba", size = 2257717 }, + { url = "https://files.pythonhosted.org/packages/d2/9d/6b4a40867875e738a67c5b29f83e2e490a66bd9067ace3dd9a5c497e2b7f/marisa_trie-1.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e2699255d7ac610dee26d4ae7bda5951d05c7d9123a22e1f7c6a6f1964e0a4e4", size = 2417044 }, + { url = "https://files.pythonhosted.org/packages/fe/61/e25613c72f2931757334b8bcf6b501569ef713f5ee9c6c7688ec460bd720/marisa_trie-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c484410911182457a8a1a0249d0c09c01e2071b78a0a8538cd5f7fa45589b13a", size = 2351960 }, + { url = "https://files.pythonhosted.org/packages/19/0a/a90ccaf3eb476d13ec261f80c6c52defaf10ebc7f35eb2bcd7dfb533aef7/marisa_trie-1.2.1-cp311-cp311-win32.whl", hash = "sha256:ad548117744b2bcf0e3d97374608be0a92d18c2af13d98b728d37cd06248e571", size = 130446 }, + { url = "https://files.pythonhosted.org/packages/fc/98/574b4e143e0a2f5f71af8716b6c4a8a46220f75a6e0847ce7d11ee0ba4aa/marisa_trie-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:436f62d27714970b9cdd3b3c41bdad046f260e62ebb0daa38125ef70536fc73b", size = 152037 }, + { url = "https://files.pythonhosted.org/packages/4e/bf/8bd4ac8436b33fd46c9e1ffe3c2a131cd9744cc1649dbbe13308f744ef2b/marisa_trie-1.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:638506eacf20ca503fff72221a7e66a6eadbf28d6a4a6f949fcf5b1701bb05ec", size = 360041 }, + { url = "https://files.pythonhosted.org/packages/ab/dd/4d3151e302e66ae387885f6ec265bd189e096b0c43c1379bfd9a3b9d2543/marisa_trie-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de1665eaafefa48a308e4753786519888021740501a15461c77bdfd57638e6b4", size = 190520 }, + { url = "https://files.pythonhosted.org/packages/00/28/ae5991c74fb90b173167a366a634c83445f948ad044d37287b478d6b457e/marisa_trie-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f713af9b8aa66a34cd3a78c7d150a560a75734713abe818a69021fd269e927fa", size = 174175 }, + { url = "https://files.pythonhosted.org/packages/5a/6a/fbfa89a8680eaabc6847a6c421e65427c43182db0c4bdb60e1516c81c822/marisa_trie-1.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2a7d00f53f4945320b551bccb826b3fb26948bde1a10d50bb9802fabb611b10", size = 1354995 }, + { url = "https://files.pythonhosted.org/packages/9e/4c/2ba0b385e5f64ca4ddb0c10ec52ddf881bc4521f135948786fc339d1d6c8/marisa_trie-1.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98042040d1d6085792e8d0f74004fc0f5f9ca6091c298f593dd81a22a4643854", size = 1390989 }, + { url = "https://files.pythonhosted.org/packages/6b/22/0791ed3045c91d0938345a86be472fc7c188b894f16c5dfad2ef31e7f882/marisa_trie-1.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6532615111eec2c79e711965ece0bc95adac1ff547a7fff5ffca525463116deb", size = 1328810 }, + { url = "https://files.pythonhosted.org/packages/9d/7d/3f566e563abae6efce7fc311c63282a447c611739b3cd66c0e36077c86f8/marisa_trie-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:20948e40ab2038e62b7000ca6b4a913bc16c91a2c2e6da501bd1f917eeb28d51", size = 2230222 }, + { url = "https://files.pythonhosted.org/packages/a5/0b/38fbb4611b5d1030242ddc2aa62e524438c8076e26f87395dbbf222dc62d/marisa_trie-1.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66b23e5b35dd547f85bf98db7c749bc0ffc57916ade2534a6bbc32db9a4abc44", size = 2383620 }, + { url = "https://files.pythonhosted.org/packages/ae/17/4553c63de29904d5d2521a24cad817bc7883cfa90506ab702ec4dae59a7b/marisa_trie-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6704adf0247d2dda42e876b793be40775dff46624309ad99bc7537098bee106d", size = 2329202 }, + { url = "https://files.pythonhosted.org/packages/45/08/6307a630e63cd763fe77ac56516faa67fa9cd342060691e40fabc84be6b0/marisa_trie-1.2.1-cp312-cp312-win32.whl", hash = "sha256:3ad356442c2fea4c2a6f514738ddf213d23930f942299a2b2c05df464a00848a", size = 129652 }, + { url = "https://files.pythonhosted.org/packages/a1/fe/67c357bfd92710d95a16b86e1453c663d565415d7f7838781c79ff7e1a7e/marisa_trie-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:f2806f75817392cedcacb24ac5d80b0350dde8d3861d67d045c1d9b109764114", size = 150845 }, + { url = "https://files.pythonhosted.org/packages/2a/a4/a110cd9952f0e72da7bafea1f0084b18b9e03952110d9083bfda52279f5c/marisa_trie-1.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b5ea16e69bfda0ac028c921b58de1a4aaf83d43934892977368579cd3c0a2554", size = 354439 }, + { url = "https://files.pythonhosted.org/packages/3c/a5/a6099eb1c3fd8d7e93408c45501e1d08536ac57dfef02ec331f78e1ace18/marisa_trie-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9f627f4e41be710b6cb6ed54b0128b229ac9d50e2054d9cde3af0fef277c23cf", size = 188187 }, + { url = "https://files.pythonhosted.org/packages/7c/cc/f637127e2beffa920d21f7fc45b4029575bcd1b28a90c0d90cb2b08c2205/marisa_trie-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5e649f3dc8ab5476732094f2828cc90cac3be7c79bc0c8318b6fda0c1d248db4", size = 171484 }, + { url = "https://files.pythonhosted.org/packages/6d/0f/29f2ad7260b956570f69f25a542efa51ba76eb76ecd53c63ee9d21987c3d/marisa_trie-1.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46e528ee71808c961baf8c3ce1c46a8337ec7a96cc55389d11baafe5b632f8e9", size = 1319770 }, + { url = "https://files.pythonhosted.org/packages/f2/12/0b69ed61fba59551a5f3d569af367afae614db7214ce1da12946ba9a433a/marisa_trie-1.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36aa4401a1180615f74d575571a6550081d84fc6461e9aefc0bb7b2427af098e", size = 1356488 }, + { url = "https://files.pythonhosted.org/packages/33/23/483b110db7ffe8729d6ebea2bf74258aef51f10fef5775f99e4bac7aef69/marisa_trie-1.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce59bcd2cda9bb52b0e90cc7f36413cd86c3d0ce7224143447424aafb9f4aa48", size = 1302334 }, + { url = "https://files.pythonhosted.org/packages/1c/6f/46c2be99ce925985127fdf78900f1673bce8cb72debfebee6dccd11032c6/marisa_trie-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f4cd800704a5fc57e53c39c3a6b0c9b1519ebdbcb644ede3ee67a06eb542697d", size = 2202624 }, + { url = "https://files.pythonhosted.org/packages/fd/b6/ef642327dbd4ec35be55d5682520b8f70fca98a54024f441ef2732f6b305/marisa_trie-1.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2428b495003c189695fb91ceeb499f9fcced3a2dce853e17fa475519433c67ff", size = 2364206 }, + { url = "https://files.pythonhosted.org/packages/69/04/ef8197a79d0ab5043b781cc9b457bd11b81d4204fe78adf7625a67f48c21/marisa_trie-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:735c363d9aaac82eaf516a28f7c6b95084c2e176d8231c87328dc80e112a9afa", size = 2304801 }, + { url = "https://files.pythonhosted.org/packages/03/72/f87564d653daf31d8f33d9bf0121e99ccc21f18f5c485fb404ba06abc10e/marisa_trie-1.2.1-cp313-cp313-win32.whl", hash = "sha256:eba6ca45500ca1a042466a0684aacc9838e7f20fe2605521ee19f2853062798f", size = 128799 }, + { url = "https://files.pythonhosted.org/packages/27/40/5f9eb8b73030cc4b0d6817176e66079a62a2ddd9d5530da54f8011473428/marisa_trie-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:aa7cd17e1c690ce96c538b2f4aae003d9a498e65067dd433c52dd069009951d4", size = 149035 }, +] + [[package]] name = "markupsafe" version = "3.0.2" @@ -850,18 +929,19 @@ wheels = [ [[package]] name = "py3-tts-wrapper" -version = "0.9.32" +version = "0.10.2b0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "langcodes" }, { name = "numpy" }, { name = "pymp3" }, { name = "requests" }, { name = "sounddevice" }, { name = "soundfile" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/02/df/2636a819a01b760e570c6d369e377aefa8396789c1b0f671079464970a1e/py3_tts_wrapper-0.9.32.tar.gz", hash = "sha256:3269e681e968cd2c1b7095850117efcca72e74e22fa78481615d7cf27c6c633b", size = 6929268 } +sdist = { url = "https://files.pythonhosted.org/packages/ee/f5/eb5404257a3e98e19ba8b7b0984c761fd76377accd6b2e2591d60b5a5441/py3_tts_wrapper-0.10.2b0.tar.gz", hash = "sha256:b424e5b2c7959502bab2f9010257371af09fe11a161ef16ed3c22c11222f1e09", size = 6961164 } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/22/bc37f982149adc8ebd58e9c0e58886182c10841ce5f5324ab7c0bc4edfad/py3_tts_wrapper-0.9.32-py3-none-any.whl", hash = "sha256:3bfb7c840fe1fcea1ff227fd39db0fe763261ee586a97694685adb27a1182998", size = 852082 }, + { url = "https://files.pythonhosted.org/packages/07/7b/cdc6c41834c5029c53be9ee3bff679b47072875e7e6cd1c87aa0c18edb05/py3_tts_wrapper-0.10.2b0-py3-none-any.whl", hash = "sha256:f1e33e0a035684ab5e40693daeae07960626ae617612f721f1ab0e578d401a9c", size = 862164 }, ] [package.optional-dependencies]