From 0aecd3a777bd7e7059c7f1860424603211d3bfec Mon Sep 17 00:00:00 2001 From: Muhammad Date: Thu, 14 May 2026 01:59:35 +0700 Subject: [PATCH 1/2] feat(addon): prepare 1.7.0 NVDA 2026.1 support --- .../NativeSpeechGeneration/__init__.py | 2 +- .../core/audio_utils.py | 16 +- .../core/config_store.py | 130 ++--- .../NativeSpeechGeneration/core/constants.py | 5 +- .../core/vendor_loader.py | 4 - .../interface/generation_dialog.py | 174 ++++-- .../interface/settings.py | 55 +- .../NativeSpeechGeneration/lib_updater.py | 542 +++++++++++++++--- .../NativeSpeechGeneration/talkWithAI.py | 89 +-- addon/locale/de/LC_MESSAGES/nvda.po | 2 +- addon/locale/es/LC_MESSAGES/nvda.po | 2 +- addon/locale/id/LC_MESSAGES/nvda.po | 2 +- addon/locale/ru/LC_MESSAGES/nvda.po | 2 +- addon/locale/uk/LC_MESSAGES/nvda.po | 2 +- buildVars.py | 36 +- changelog.md | 11 + pyproject.toml | 2 + readme.md | 19 +- 18 files changed, 772 insertions(+), 323 deletions(-) diff --git a/addon/globalPlugins/NativeSpeechGeneration/__init__.py b/addon/globalPlugins/NativeSpeechGeneration/__init__.py index c5b4d09..b323026 100644 --- a/addon/globalPlugins/NativeSpeechGeneration/__init__.py +++ b/addon/globalPlugins/NativeSpeechGeneration/__init__.py @@ -82,7 +82,7 @@ class GlobalPlugin(globalPluginHandler.GlobalPlugin): def __init__(self) -> None: super().__init__() self.dialog = None - config_store.prepare_config_for_startup(persist=True) + config_store.prepareConfigForStartup(persist=True) if NativeSpeechSettingsPanel not in gui.settingsDialogs.NVDASettingsDialog.categoryClasses: gui.settingsDialogs.NVDASettingsDialog.categoryClasses.append(NativeSpeechSettingsPanel) diff --git a/addon/globalPlugins/NativeSpeechGeneration/core/audio_utils.py b/addon/globalPlugins/NativeSpeechGeneration/core/audio_utils.py index d06339b..8f18213 100644 --- a/addon/globalPlugins/NativeSpeechGeneration/core/audio_utils.py +++ b/addon/globalPlugins/NativeSpeechGeneration/core/audio_utils.py @@ -5,6 +5,16 @@ import contextlib from logHandler import log import wx +import addonHandler +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + + def _(msg: str) -> str: + return msg + + +addonHandler.initTranslation() def parseAudioMimeType(mimeType: str) -> dict[str, int]: @@ -97,7 +107,9 @@ def safeStartFile(path: str) -> None: log.error(f"Failed to open file: {e}", exc_info=True) wx.CallAfter( wx.MessageBox, - f"Audio generated, but failed to play automatically: {e}", - "Info", + # Translators: Message shown when generated audio was saved but could not be opened automatically. + _("Audio generated, but failed to play automatically: {error}").format(error=e), + # Translators: Title of an informational message dialog. + _("Info"), wx.OK | wx.ICON_INFORMATION, ) diff --git a/addon/globalPlugins/NativeSpeechGeneration/core/config_store.py b/addon/globalPlugins/NativeSpeechGeneration/core/config_store.py index 57afa80..42b109a 100644 --- a/addon/globalPlugins/NativeSpeechGeneration/core/config_store.py +++ b/addon/globalPlugins/NativeSpeechGeneration/core/config_store.py @@ -40,67 +40,67 @@ class _DATA_BLOB(ctypes.Structure): ] -def register_config_spec() -> None: +def registerConfigSpec() -> None: config.conf.spec[CONFIG_DOMAIN] = _CONFIG_SPEC.copy() - _get_config_section() + _getConfigSection() -def prepare_config_for_startup(*, persist: bool) -> bool: - register_config_spec() - removedLegacyPlaintext = _remove_legacy_plaintext_if_encrypted_exists() - migratedLegacyPlaintext = _migrate_plaintext_api_key() +def prepareConfigForStartup(*, persist: bool) -> bool: + registerConfigSpec() + removedLegacyPlaintext = _removeLegacyPlaintextIfEncryptedExists() + migratedLegacyPlaintext = _migratePlaintextApiKey() if persist and (removedLegacyPlaintext or migratedLegacyPlaintext): config.save() return removedLegacyPlaintext or migratedLegacyPlaintext -def get_stored_api_key() -> str: - prepare_config_for_startup(persist=False) - encryptedValue = _get_text_setting("apiKeyEncrypted").strip() +def getStoredApiKey() -> str: + prepareConfigForStartup(persist=False) + encryptedValue = _getTextSetting("apiKeyEncrypted").strip() if not encryptedValue: return "" try: - return _decrypt_api_key(encryptedValue) + return _decryptApiKey(encryptedValue) except ApiKeyStorageError as error: log.warning(f"Stored encrypted Gemini API key could not be decrypted: {error}") return "" -def prepare_api_key_for_storage(value: str) -> tuple[str, str]: +def prepareApiKeyForStorage(value: str) -> tuple[str, str]: cleanValue = value.strip() if not cleanValue: return "", "" - return cleanValue, _encrypt_api_key(cleanValue) + return cleanValue, _encryptApiKey(cleanValue) -def write_prepared_api_key(cleanValue: str, encryptedValue: str) -> None: - register_config_spec() +def writePreparedApiKey(cleanValue: str, encryptedValue: str) -> None: + registerConfigSpec() if not cleanValue: - _set_text_setting("apiKeyEncrypted", "") - _set_text_setting("apiKey", "") + _setTextSetting("apiKeyEncrypted", "") + _setTextSetting("apiKey", "") return - _set_text_setting("apiKeyEncrypted", encryptedValue) - _set_text_setting("apiKey", "") + _setTextSetting("apiKeyEncrypted", encryptedValue) + _setTextSetting("apiKey", "") -def set_stored_api_key(value: str) -> None: - cleanValue, encryptedValue = prepare_api_key_for_storage(value) - write_prepared_api_key(cleanValue, encryptedValue) +def setStoredApiKey(value: str) -> None: + cleanValue, encryptedValue = prepareApiKeyForStorage(value) + writePreparedApiKey(cleanValue, encryptedValue) -def resolve_api_key() -> ApiKeyResolution: - migratedLegacyPlaintext = prepare_config_for_startup(persist=False) - encryptedValue = _get_text_setting("apiKeyEncrypted").strip() +def resolveApiKey() -> ApiKeyResolution: + migratedLegacyPlaintext = prepareConfigForStartup(persist=False) + encryptedValue = _getTextSetting("apiKeyEncrypted").strip() if encryptedValue: try: return ApiKeyResolution( - value=_decrypt_api_key(encryptedValue), + value=_decryptApiKey(encryptedValue), source="stored", status="legacyMigrated" if migratedLegacyPlaintext else "stored", ) except ApiKeyStorageError as error: log.warning(f"Stored encrypted Gemini API key could not be decrypted: {error}") - environmentValue = _get_environment_api_key() + environmentValue = _getEnvironmentApiKey() if environmentValue: return ApiKeyResolution( value=environmentValue, @@ -109,68 +109,68 @@ def resolve_api_key() -> ApiKeyResolution: ) return ApiKeyResolution(value="", source="missing", status="undecryptable") - environmentValue = _get_environment_api_key() + environmentValue = _getEnvironmentApiKey() if environmentValue: return ApiKeyResolution(value=environmentValue, source="environment", status="environment") return ApiKeyResolution(value="", source="missing", status="missing") -def _get_environment_api_key() -> str: +def _getEnvironmentApiKey() -> str: return os.environ.get(API_KEY_ENV_VAR, "").strip() -def _get_config_section() -> Any: +def _getConfigSection() -> Any: return config.conf[CONFIG_DOMAIN] -def _get_text_setting(name: str) -> str: - value = _get_config_section().get(name, "") +def _getTextSetting(name: str) -> str: + value = _getConfigSection().get(name, "") return value if isinstance(value, str) else str(value or "") -def _set_text_setting(name: str, value: str) -> None: - _get_config_section()[name] = value +def _setTextSetting(name: str, value: str) -> None: + _getConfigSection()[name] = value -def _remove_legacy_plaintext_if_encrypted_exists() -> bool: - legacyValue = _get_text_setting("apiKey").strip() - encryptedValue = _get_text_setting("apiKeyEncrypted").strip() +def _removeLegacyPlaintextIfEncryptedExists() -> bool: + legacyValue = _getTextSetting("apiKey").strip() + encryptedValue = _getTextSetting("apiKeyEncrypted").strip() if not (legacyValue and encryptedValue): return False - _set_text_setting("apiKey", "") + _setTextSetting("apiKey", "") log.info("Removed legacy plaintext Gemini API key from configuration.") return True -def _migrate_plaintext_api_key() -> bool: - legacyValue = _get_text_setting("apiKey").strip() - encryptedValue = _get_text_setting("apiKeyEncrypted").strip() +def _migratePlaintextApiKey() -> bool: + legacyValue = _getTextSetting("apiKey").strip() + encryptedValue = _getTextSetting("apiKeyEncrypted").strip() if not legacyValue or encryptedValue: return False try: - _set_text_setting("apiKeyEncrypted", _encrypt_api_key(legacyValue)) + _setTextSetting("apiKeyEncrypted", _encryptApiKey(legacyValue)) except ApiKeyStorageError: log.error( "Failed to migrate the legacy plaintext Gemini API key to encrypted storage.", exc_info=True, ) return False - _set_text_setting("apiKey", "") + _setTextSetting("apiKey", "") log.info("Migrated legacy plaintext Gemini API key to DPAPI-protected storage.") return True -def _encrypt_api_key(value: str) -> str: +def _encryptApiKey(value: str) -> str: if not value: return "" try: - protectedValue = _protect_bytes_with_dpapi(value.encode("utf-8")) + protectedValue = _protectBytesWithDpapi(value.encode("utf-8")) except Exception as error: raise ApiKeyStorageError("Failed to encrypt the API key with Windows DPAPI.") from error return base64.b64encode(protectedValue).decode("ascii") -def _decrypt_api_key(value: str) -> str: +def _decryptApiKey(value: str) -> str: if not value: return "" try: @@ -178,7 +178,7 @@ def _decrypt_api_key(value: str) -> str: except Exception as error: raise ApiKeyStorageError("Stored API key data is not valid base64.") from error try: - plainValue = _unprotect_bytes_with_dpapi(protectedValue) + plainValue = _unprotectBytesWithDpapi(protectedValue) except Exception as error: raise ApiKeyStorageError( "Stored API key data could not be decrypted for this Windows user or machine.", @@ -189,11 +189,11 @@ def _decrypt_api_key(value: str) -> str: raise ApiKeyStorageError("Stored API key data is not valid UTF-8 text.") from error -def _protect_bytes_with_dpapi(value: bytes) -> bytes: +def _protectBytesWithDpapi(value: bytes) -> bytes: try: import win32crypt except ImportError: - return _protect_bytes_with_ctypes(value) + return _protectBytesWithCtypes(value) return win32crypt.CryptProtectData( value, _DPAPI_DESCRIPTION, @@ -204,11 +204,11 @@ def _protect_bytes_with_dpapi(value: bytes) -> bytes: ) -def _unprotect_bytes_with_dpapi(value: bytes) -> bytes: +def _unprotectBytesWithDpapi(value: bytes) -> bytes: try: import win32crypt except ImportError: - return _unprotect_bytes_with_ctypes(value) + return _unprotectBytesWithCtypes(value) _description, plainValue = win32crypt.CryptUnprotectData( value, None, @@ -219,11 +219,10 @@ def _unprotect_bytes_with_dpapi(value: bytes) -> bytes: return plainValue -def _protect_bytes_with_ctypes(value: bytes) -> bytes: - dataIn, inputBuffer = _create_data_blob(value) +def _protectBytesWithCtypes(value: bytes) -> bytes: + dataIn, inputBuffer = _createDataBlob(value) dataOut = _DATA_BLOB() - crypt32, _kernel32 = _load_dpapi_libraries() - del inputBuffer + crypt32, _kernel32 = _loadDpapiLibraries() if not crypt32.CryptProtectData( ctypes.byref(dataIn), _DPAPI_DESCRIPTION, @@ -234,14 +233,15 @@ def _protect_bytes_with_ctypes(value: bytes) -> bytes: ctypes.byref(dataOut), ): raise ctypes.WinError(ctypes.get_last_error()) - return _copy_and_free_data_blob(dataOut) + # Keep inputBuffer alive until CryptProtectData returns; dataIn points into it. + del inputBuffer + return _copyAndFreeDataBlob(dataOut) -def _unprotect_bytes_with_ctypes(value: bytes) -> bytes: - dataIn, inputBuffer = _create_data_blob(value) +def _unprotectBytesWithCtypes(value: bytes) -> bytes: + dataIn, inputBuffer = _createDataBlob(value) dataOut = _DATA_BLOB() - crypt32, _kernel32 = _load_dpapi_libraries() - del inputBuffer + crypt32, _kernel32 = _loadDpapiLibraries() if not crypt32.CryptUnprotectData( ctypes.byref(dataIn), None, @@ -252,10 +252,12 @@ def _unprotect_bytes_with_ctypes(value: bytes) -> bytes: ctypes.byref(dataOut), ): raise ctypes.WinError(ctypes.get_last_error()) - return _copy_and_free_data_blob(dataOut) + # Keep inputBuffer alive until CryptUnprotectData returns; dataIn points into it. + del inputBuffer + return _copyAndFreeDataBlob(dataOut) -def _create_data_blob(value: bytes) -> tuple[_DATA_BLOB, ctypes.Array[ctypes.c_char] | None]: +def _createDataBlob(value: bytes) -> tuple[_DATA_BLOB, ctypes.Array[ctypes.c_char] | None]: if not value: return _DATA_BLOB(0, ctypes.POINTER(ctypes.c_ubyte)()), None buffer = ctypes.create_string_buffer(value, len(value)) @@ -265,8 +267,8 @@ def _create_data_blob(value: bytes) -> tuple[_DATA_BLOB, ctypes.Array[ctypes.c_c ), buffer -def _copy_and_free_data_blob(blob: _DATA_BLOB) -> bytes: - _crypt32, kernel32 = _load_dpapi_libraries() +def _copyAndFreeDataBlob(blob: _DATA_BLOB) -> bytes: + _crypt32, kernel32 = _loadDpapiLibraries() try: if not blob.cbData or not blob.pbData: return b"" @@ -276,7 +278,7 @@ def _copy_and_free_data_blob(blob: _DATA_BLOB) -> bytes: kernel32.LocalFree(ctypes.cast(blob.pbData, ctypes.c_void_p)) -def _load_dpapi_libraries() -> tuple[ctypes.WinDLL, ctypes.WinDLL]: +def _loadDpapiLibraries() -> tuple[ctypes.WinDLL, ctypes.WinDLL]: crypt32 = ctypes.WinDLL("crypt32", use_last_error=True) kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) crypt32.CryptProtectData.argtypes = ( diff --git a/addon/globalPlugins/NativeSpeechGeneration/core/constants.py b/addon/globalPlugins/NativeSpeechGeneration/core/constants.py index 9a59a1b..eb43d19 100644 --- a/addon/globalPlugins/NativeSpeechGeneration/core/constants.py +++ b/addon/globalPlugins/NativeSpeechGeneration/core/constants.py @@ -2,8 +2,9 @@ import os CONFIG_DOMAIN = "NativeSpeechGeneration" -DEFAULT_MODEL = "gemini-2.5-flash-preview-tts" -SECOND_MODEL = "gemini-2.5-pro-preview-tts" +DEFAULT_MODEL = "gemini-3.1-flash-tts-preview" +FLASH_25_MODEL = "gemini-2.5-flash-preview-tts" +PRO_25_MODEL = "gemini-2.5-pro-preview-tts" # Compute directories relative to this file _coreDir = os.path.dirname(os.path.abspath(__file__)) diff --git a/addon/globalPlugins/NativeSpeechGeneration/core/vendor_loader.py b/addon/globalPlugins/NativeSpeechGeneration/core/vendor_loader.py index 7d3ec9a..860878d 100644 --- a/addon/globalPlugins/NativeSpeechGeneration/core/vendor_loader.py +++ b/addon/globalPlugins/NativeSpeechGeneration/core/vendor_loader.py @@ -25,8 +25,6 @@ "httpcore", "anyio", "sniffio", - "requests", - "urllib3", "certifi", "charset_normalizer", "idna", @@ -180,8 +178,6 @@ def _create_runtime(libDir: str) -> VendorRuntime: "pydantic_core", "websockets", "httpx", - "requests", - "urllib3", "typing_extensions", "pyaudio", ), diff --git a/addon/globalPlugins/NativeSpeechGeneration/interface/generation_dialog.py b/addon/globalPlugins/NativeSpeechGeneration/interface/generation_dialog.py index bf8335c..6fa64d9 100644 --- a/addon/globalPlugins/NativeSpeechGeneration/interface/generation_dialog.py +++ b/addon/globalPlugins/NativeSpeechGeneration/interface/generation_dialog.py @@ -3,19 +3,22 @@ import threading import os import mimetypes -import requests +import urllib.request import webbrowser import tempfile +import uuid import winsound import gui import ui import addonHandler from logHandler import log +from dataclasses import dataclass from typing import Any, TYPE_CHECKING from ..core.constants import ( DEFAULT_MODEL, - SECOND_MODEL, + FLASH_25_MODEL, + PRO_25_MODEL, VOICE_SAMPLE_BASE, FALLBACK_VOICES, ) @@ -33,10 +36,21 @@ def _(msg: str) -> str: addonHandler.initTranslation() -_guiDir = os.path.dirname(os.path.abspath(__file__)) -_pkgDir = os.path.dirname(_guiDir) -_globalPluginsDir = os.path.dirname(_pkgDir) -ADDON_DIR_VAL = os.path.dirname(_globalPluginsDir) +GENERATED_AUDIO_DIR = os.path.join(tempfile.gettempdir(), "NativeSpeechGeneration") + + +@dataclass(frozen=True) +class GenerationRequest: + apiKey: str + text: str + model: str + temperature: float + styleInstructions: str + modeMulti: bool + voiceName: str + voiceName2: str + speaker1Name: str + speaker2Name: str class NativeSpeechDialog(wx.Dialog): @@ -45,7 +59,8 @@ def __init__(self, parent: wx.Window) -> None: super().__init__(parent, title=_("Native Speech Generation (Gemini TTS)")) self.lastAudioPath: str | None = None - self.model = DEFAULT_MODEL + self.modelOptions = self._getModelOptions() + self.model = self.modelOptions[0][0] self.modeMulti = False self.voices: list[dict[str, Any]] = [] self.selectedVoiceIdx = 0 @@ -59,6 +74,31 @@ def __init__(self, parent: wx.Window) -> None: threading.Thread(target=self.loadVoices, daemon=True).start() self.textCtrl.SetFocus() + def _getModelOptions(self) -> list[tuple[str, str, str]]: + return [ + ( + DEFAULT_MODEL, + # Translators: Model option for the newest Gemini Flash text-to-speech preview. + _("Flash 3.1 Preview"), + # Translators: Description for the Gemini Flash 3.1 Preview model. + _("Powerful, low-latency speech generation, very good for short audio."), + ), + ( + FLASH_25_MODEL, + # Translators: Model option for the older Gemini Flash text-to-speech preview. + _("Flash 2.5 (Standard Quality)"), + # Translators: Description for the Gemini Flash 2.5 model. + _("Standard quality, responsive speech generation."), + ), + ( + PRO_25_MODEL, + # Translators: Model option for the Gemini Pro text-to-speech preview. + _("Pro 2.5 (High Quality)"), + # Translators: Description for the Gemini Pro 2.5 model. + _("Premium speech generation with more realistic voices."), + ), + ] + def _buildUi(self) -> None: mainSizer = wx.BoxSizer(wx.VERTICAL) @@ -74,10 +114,13 @@ def _buildUi(self) -> None: mainSizer.Add(styleLabel, flag=wx.ALL, border=6) mainSizer.Add(self.styleCtrl, flag=wx.EXPAND | wx.LEFT | wx.RIGHT, border=6) + modelOuterSizer = wx.BoxSizer(wx.VERTICAL) modelSizer = wx.BoxSizer(wx.HORIZONTAL) # Translators: Label for selecting the AI model to use for generation. modelLabel = wx.StaticText(self, label=_("Select &Model:")) - self.modelChoice = wx.Choice(self, choices=[_("Flash (Standard Quality)"), _("Pro (High Quality)")]) + self.modelChoice = wx.Choice( + self, choices=[label for _model, label, _description in self.modelOptions] + ) self.modelChoice.SetSelection(0) self.modelChoice.Bind(wx.EVT_CHOICE, self.onModelChange) modelSizer.Add(modelLabel, flag=wx.ALIGN_CENTER_VERTICAL | wx.ALL, border=6) @@ -92,7 +135,11 @@ def _buildUi(self) -> None: self.modeMultiRb.Bind(wx.EVT_RADIOBUTTON, self.onModeChange) modelSizer.Add(self.modeSingleRb, flag=wx.ALL, border=6) modelSizer.Add(self.modeMultiRb, flag=wx.ALL, border=6) - mainSizer.Add(modelSizer, flag=wx.EXPAND) + modelOuterSizer.Add(modelSizer, flag=wx.EXPAND) + self.modelDescriptionLabel = wx.StaticText(self, label=self.modelOptions[0][2]) + self.modelDescriptionLabel.Wrap(520) + modelOuterSizer.Add(self.modelDescriptionLabel, flag=wx.LEFT | wx.RIGHT | wx.BOTTOM, border=6) + mainSizer.Add(modelOuterSizer, flag=wx.EXPAND) # Translators: Checkbox to show advanced settings like Temperature. self.settingsCheckbox = wx.CheckBox(self, label=_("Advanced Settings (&Temperature)")) @@ -274,7 +321,13 @@ def onToggleSettings(self, evt: wx.Event) -> None: def onModelChange(self, evt: wx.Event) -> None: sel = self.modelChoice.GetSelection() - self.model = DEFAULT_MODEL if sel == 0 else SECOND_MODEL + if sel == wx.NOT_FOUND: + sel = 0 + self.model = self.modelOptions[sel][0] + self.modelDescriptionLabel.SetLabel(self.modelOptions[sel][2]) + self.modelDescriptionLabel.Wrap(520) + self.GetSizer().Layout() + self.Fit() def onModeChange(self, evt: wx.Event) -> None: self.modeMulti = self.modeMultiRb.GetValue() @@ -315,7 +368,7 @@ def _getSelectedVoiceName(self, choiceCtrl: wx.Choice, idx: int | None) -> str: return "Zephyr" def _resolveApiKeyForUse(self) -> str | None: - resolution = config_store.resolve_api_key() + resolution = config_store.resolveApiKey() if resolution.value: return resolution.value self._showApiKeyUnavailableMessage(resolution) @@ -390,6 +443,23 @@ def onTalkWithAi(self, evt: wx.Event) -> None: wx.OK | wx.ICON_ERROR, ) + def _buildGenerationRequest(self, text: str, apiKey: str) -> GenerationRequest: + primaryVoiceCtrl = self.voiceChoiceMulti1 if self.modeMulti else self.voiceChoiceSingle + primaryVoiceIdx = primaryVoiceCtrl.GetSelection() + secondaryVoiceIdx = self.voiceChoiceMulti2.GetSelection() + return GenerationRequest( + apiKey=apiKey, + text=text, + model=self.model, + temperature=self.tempSlider.GetValue() / 10.0, + styleInstructions=self.styleCtrl.GetValue().strip(), + modeMulti=self.modeMulti, + voiceName=self._getSelectedVoiceName(primaryVoiceCtrl, primaryVoiceIdx), + voiceName2=self._getSelectedVoiceName(self.voiceChoiceMulti2, secondaryVoiceIdx), + speaker1Name=self.spk1NameCtrl.GetValue().strip() or _("Speaker1"), + speaker2Name=self.spk2NameCtrl.GetValue().strip() or _("Speaker2"), + ) + def onGenerate(self, evt: wx.Event) -> None: if self.isGenerating: return @@ -422,18 +492,19 @@ def onGenerate(self, evt: wx.Event) -> None: ) return + generationRequest = self._buildGenerationRequest(text, apiKey) self.isGenerating = True self.generateBtn.SetLabel(_("Generating...")) self.playBtn.Enable(False) self.saveBtn.Enable(False) self.talkBtn.Enable(False) - threading.Thread(target=self._generateThread, args=(text, apiKey), daemon=True).start() + threading.Thread(target=self._generateThread, args=(generationRequest,), daemon=True).start() - def _generateThread(self, text: str, apiKey: str) -> None: + def _generateThread(self, generationRequest: GenerationRequest) -> None: ui.message(_("Generating speech, please wait...")) try: with getRuntimeScope(): - self.client = genai.Client(api_key=apiKey) + self.client = genai.Client(api_key=generationRequest.apiKey) except Exception as e: log.error(f"Failed init genai client: {e}", exc_info=True) if not self.isClosed: @@ -460,40 +531,39 @@ def handleSuccess(savedPath: str | None) -> None: try: with getRuntimeScope(): - temp = self.tempSlider.GetValue() / 10.0 - styleInstructions = self.styleCtrl.GetValue().strip() - if styleInstructions: - finalText = f"{styleInstructions}\n{text}" + if generationRequest.styleInstructions: + finalText = f"{generationRequest.styleInstructions}\n{generationRequest.text}" else: - finalText = f"Please read the following text aloud:\n{text}" + finalText = f"Please read the following text aloud:\n{generationRequest.text}" contents = [types.Content(role="user", parts=[types.Part.from_text(text=finalText)])] - if not self.modeMulti: - voiceName = self._getSelectedVoiceName(self.voiceChoiceSingle, self.selectedVoiceIdx) + if not generationRequest.modeMulti: speechConfig = types.SpeechConfig( voice_config=types.VoiceConfig( - prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voiceName), + prebuilt_voice_config=types.PrebuiltVoiceConfig( + voice_name=generationRequest.voiceName + ), ), ) else: - speaker1Name = self.spk1NameCtrl.GetValue().strip() or _("Speaker1") - speaker2Name = self.spk2NameCtrl.GetValue().strip() or _("Speaker2") - voice1 = self._getSelectedVoiceName(self.voiceChoiceMulti1, self.selectedVoiceIdx) - voice2 = self._getSelectedVoiceName(self.voiceChoiceMulti2, self.selectedVoiceIdx2) speechConfig = types.SpeechConfig( multi_speaker_voice_config=types.MultiSpeakerVoiceConfig( speaker_voice_configs=[ types.SpeakerVoiceConfig( - speaker=speaker1Name, + speaker=generationRequest.speaker1Name, voice_config=types.VoiceConfig( - prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice1), + prebuilt_voice_config=types.PrebuiltVoiceConfig( + voice_name=generationRequest.voiceName, + ), ), ), types.SpeakerVoiceConfig( - speaker=speaker2Name, + speaker=generationRequest.speaker2Name, voice_config=types.VoiceConfig( - prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice2), + prebuilt_voice_config=types.PrebuiltVoiceConfig( + voice_name=generationRequest.voiceName2, + ), ), ), ], @@ -501,18 +571,18 @@ def handleSuccess(savedPath: str | None) -> None: ) generateConfig = types.GenerateContentConfig( - temperature=temp, + temperature=generationRequest.temperature, response_modalities=["audio"], speech_config=speechConfig, ) - outPathBase = os.path.join(ADDON_DIR_VAL, "last_audio_generated") + outPathBase = self._buildOutputPathBase() if self.isClosed: return savedPath = self._streamAndSaveAudio( self.client, - self.model, + generationRequest.model, contents, generateConfig, outPathBase, @@ -543,6 +613,22 @@ def _restoreGenerateButton(self) -> None: self.talkBtn.Enable(True) self.isGenerating = False + def _buildOutputPathBase(self) -> str: + os.makedirs(GENERATED_AUDIO_DIR, exist_ok=True) + return os.path.join(GENERATED_AUDIO_DIR, f"last_audio_generated_{uuid.uuid4().hex}") + + def _iterResponseParts(self, chunk: Any) -> list[Any]: + parts = getattr(chunk, "parts", None) + if parts: + return list(parts) + collectedParts = [] + for candidate in getattr(chunk, "candidates", []) or []: + content = getattr(candidate, "content", None) + candidateParts = getattr(content, "parts", None) if content else None + if candidateParts: + collectedParts.extend(candidateParts) + return collectedParts + def _streamAndSaveAudio( self, client: Any, @@ -576,14 +662,11 @@ def _streamAndSaveAudio( ) return None - if not getattr(chunk, "candidates", None): - continue - candidate = chunk.candidates[0] - if not candidate.content or not candidate.content.parts: - continue - part = candidate.content.parts[0] - - if part.inline_data and getattr(part.inline_data, "data", None): + for part in self._iterResponseParts(chunk): + if not getattr(part, "inline_data", None) or not getattr( + part.inline_data, "data", None + ): + continue inline = part.inline_data ext = mimetypes.guess_extension(inline.mime_type or "") or "" @@ -674,12 +757,15 @@ def _playSampleForVoice(self, voiceName: str) -> None: def _downloadAndPlaySample(self, url: str) -> None: try: - resp = requests.get(url, timeout=10) - if resp.status_code != 200 or not resp.content: + request = urllib.request.Request(url, headers={"User-Agent": "NativeSpeechGeneration-NVDA-Addon"}) + with urllib.request.urlopen(request, timeout=10) as response: + statusCode = getattr(response, "status", response.getcode()) + content = response.read() + if statusCode != 200 or not content: ui.message(_("Sample not available")) return with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp: - tmp.write(resp.content) + tmp.write(content) tempPath = tmp.name ui.message(_("Playing voice sample")) winsound.PlaySound(tempPath, winsound.SND_FILENAME | winsound.SND_ASYNC) diff --git a/addon/globalPlugins/NativeSpeechGeneration/interface/settings.py b/addon/globalPlugins/NativeSpeechGeneration/interface/settings.py index d57c59e..2719639 100644 --- a/addon/globalPlugins/NativeSpeechGeneration/interface/settings.py +++ b/addon/globalPlugins/NativeSpeechGeneration/interface/settings.py @@ -1,9 +1,6 @@ # -*- coding: utf-8 -*- import wx import webbrowser -import os -import shutil -import time from typing import TYPE_CHECKING import gui import addonHandler @@ -32,7 +29,7 @@ def __init__(self, *args, **kwargs) -> None: def makeSettings(self, settingsSizer: wx.Sizer) -> None: sHelper = gui.guiHelper.BoxSizerHelper(self, sizer=settingsSizer) - apiResolution = config_store.resolve_api_key() + apiResolution = config_store.resolveApiKey() apiSizer = wx.BoxSizer(wx.HORIZONTAL) @@ -40,7 +37,7 @@ def makeSettings(self, settingsSizer: wx.Sizer) -> None: apiLabel = wx.StaticText(self, label=_("&Gemini API Key:")) apiSizer.Add(apiLabel, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5) - apiValue = config_store.get_stored_api_key() + apiValue = config_store.getStoredApiKey() self.apiKeyCtrlHidden = wx.TextCtrl(self, value=apiValue, style=wx.TE_PASSWORD) self.apiKeyCtrlVisible = wx.TextCtrl(self, value=apiValue) @@ -143,10 +140,8 @@ def _showStorageError(self, error: config_store.ApiKeyStorageError) -> None: def isValid(self) -> bool: try: - self._validatedApiKeyValue, self._validatedEncryptedApiKey = ( - config_store.prepare_api_key_for_storage( - self._getCurrentApiKeyFieldValue(), - ) + self._validatedApiKeyValue, self._validatedEncryptedApiKey = config_store.prepareApiKeyForStorage( + self._getCurrentApiKeyFieldValue(), ) except config_store.ApiKeyStorageError as error: self._validatedApiKeyValue = "" @@ -156,50 +151,14 @@ def isValid(self) -> bool: return True def onReinstall(self, evt: wx.Event) -> None: - """Handles the reinstall libraries action.""" - res = wx.MessageBox( - _("This will delete the existing library and restart NVDA to redownload it.\nAre you sure?"), - _("Confirm Reinstall"), - wx.OK | wx.CANCEL | wx.ICON_WARNING, - ) - if res != wx.OK: - return - - try: - try: - targetLib = lib_updater.LIB_DIR - except AttributeError: - guiDir = os.path.dirname(os.path.abspath(__file__)) - pkgDir = os.path.dirname(guiDir) - targetLib = os.path.join(pkgDir, "lib") - - if os.path.exists(targetLib): - tempTrash = targetLib + "_trash_" + str(time.time()) - os.rename(targetLib, tempTrash) - shutil.rmtree(tempTrash, ignore_errors=True) - - wx.MessageBox( - _("Library removed successfully. NVDA will now restart to download the latest version."), - _("Restart Required"), - wx.OK | wx.ICON_INFORMATION, - ) - import core - - core.restart() - - except Exception as e: - log.error(f"Failed to delete lib folder: {e}", exc_info=True) - wx.MessageBox( - _("Failed to remove library: {error}\nPlease check log.").format(error=str(e)), - _("Error"), - wx.OK | wx.ICON_ERROR, - ) + """Start the verified dependency update flow.""" + lib_updater.reinstallDependencies() def onSave(self) -> None: if not self.isValid(): return try: - config_store.write_prepared_api_key( + config_store.writePreparedApiKey( self._validatedApiKeyValue, self._validatedEncryptedApiKey, ) diff --git a/addon/globalPlugins/NativeSpeechGeneration/lib_updater.py b/addon/globalPlugins/NativeSpeechGeneration/lib_updater.py index 5ba0da3..5c1ba16 100644 --- a/addon/globalPlugins/NativeSpeechGeneration/lib_updater.py +++ b/addon/globalPlugins/NativeSpeechGeneration/lib_updater.py @@ -1,120 +1,186 @@ # -*- coding: utf-8 -*- +import glob +import hashlib +import json import os -import wx -import gui -import addonHandler -import core +import re +import shutil +import tempfile +import threading +import time import urllib.request import zipfile -import threading -import shutil -import glob -from logHandler import log from collections.abc import Callable +from dataclasses import dataclass +from typing import Any -addonHandler.initTranslation() +import addonHandler +import core +import gui +import wx +from logHandler import log -LIB_URL = "https://github.com/muhammadGagah/python-library-add-on-Native-Speech-Generation/releases/latest/download/lib.zip" +addonHandler.initTranslation() -# Centralized Path Definitions +LIBRARY_RELEASE_API_URL = "https://api.github.com/repos/muhammadGagah/python-library-add-on-Native-Speech-Generation/releases/latest" +LIBRARY_RELEASE_DOWNLOAD_BASE = ( + "https://github.com/muhammadGagah/python-library-add-on-Native-Speech-Generation/releases/download" +) +APPROVED_LIBRARY_VERSION = "1.70.0" +APPROVED_LIBRARY_SHA256 = { + "lib.zip": "8F09EEFBD099067CAF7A977A9D93B109B641ED3CAB9DC8F58B751EA13DAE9555", + # Fill this after publishing a release that contains lib64.zip. + "lib64.zip": "", +} +NVDA_2026_RUNTIME_VERSION = (2026, 1, 0) +USER_AGENT = "NativeSpeechGeneration-NVDA-Addon" +SHA256_RE = re.compile(r"\b([a-fA-F0-9]{64})\b") + +PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__)) ADDON_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -LIB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib") +LIB_DIR = os.path.join(PACKAGE_DIR, "lib") + + +@dataclass(frozen=True) +class LibraryAsset: + version: str + name: str + url: str + sha256: str + source: str + + +class LibraryUpdateError(RuntimeError): + pass def cleanupTrash() -> None: - """ - Garbage Collection: Delete any 'lib_trash_*' folders left over from previous updates. - Run this at startup when files are likely unlocked. - """ - try: - gpBase = os.path.dirname(os.path.abspath(__file__)) - trashPattern = os.path.join(gpBase, "lib_trash_*") - for trashDir in glob.glob(trashPattern): - if os.path.isdir(trashDir): - try: - shutil.rmtree(trashDir, ignore_errors=True) - log.info(f"lib_updater: Cleaned up trash directory: {trashDir}") - except Exception as e: - log.warning(f"lib_updater: Failed to clean trash {trashDir}: {e}") - except Exception as e: - log.warning(f"lib_updater: Error during trash cleanup: {e}") + """Remove dependency update leftovers from previous sessions.""" + for pattern in ("lib_trash_*", ".lib_staging_*", ".lib_ready_*"): + for trashDir in glob.glob(os.path.join(PACKAGE_DIR, pattern)): + if not os.path.isdir(trashDir): + continue + try: + shutil.rmtree(trashDir, ignore_errors=True) + log.info(f"lib_updater: Cleaned temporary directory: {trashDir}") + except Exception as error: + log.warning(f"lib_updater: Failed to clean temporary directory {trashDir}: {error}") def initialize() -> None: - """ - Entry point for initialization. Performs cleanup. - """ + """Run startup cleanup for dependency update state.""" cleanupTrash() -def downloadAndExtract(addonDir: str, progressCallback: Callable[[int, str], None]) -> bool: - """ - Downloads and extracts the lib folder. - """ - zipPath = os.path.join(addonDir, "lib.zip") +def parseNvdaVersion(versionText: str) -> tuple[int, int, int] | None: + match = re.search(r"(\d{4})\.(\d+)(?:\.(\d+))?", versionText) + if match is None: + return None + year = int(match.group(1)) + major = int(match.group(2)) + minor = int(match.group(3) or 0) + return year, major, minor + +def getCurrentNvdaVersionText() -> str: try: - wx.CallAfter(progressCallback, 10, _("Downloading libraries...")) + import buildVersion + + return str(buildVersion.version) + except Exception as error: + log.warning(f"lib_updater: Could not read NVDA version: {error}", exc_info=True) + return "" - # Download the file (Standard HTTP, no custom SSL context as requested) - with urllib.request.urlopen(LIB_URL, timeout=30) as response, open(zipPath, "wb") as outFile: - totalLength = response.length - if totalLength: - dl = 0 - while True: - data = response.read(8192) - if not data: - break - dl += len(data) - outFile.write(data) - percent = 10 + int(dl / totalLength * 70) - wx.CallAfter(progressCallback, percent, _("Downloading...")) - else: # No content length - outFile.write(response.read()) - - log.info("Download complete. Extracting...") - wx.CallAfter(progressCallback, 80, _("Extracting libraries...")) - - # Extract the zip file - with zipfile.ZipFile(zipPath, "r") as zipRef: - # Extract into the package directory (NativeSpeechGeneration) - zipRef.extractall(os.path.dirname(os.path.abspath(__file__))) - - log.info("Extraction complete.") - wx.CallAfter(progressCallback, 100, _("Extraction complete.")) - - if os.path.exists(zipPath): - os.remove(zipPath) - return True - except Exception as e: - log.error(f"Failed to download or extract libraries: {e}", exc_info=True) +def getRuntimeAssetName(versionText: str | None = None) -> str: + if versionText is None: + versionText = getCurrentNvdaVersionText() + nvdaVersion = parseNvdaVersion(versionText) + if nvdaVersion is None: + log.warning( + "lib_updater: Could not parse NVDA version for dependency selection; using lib.zip.", + ) + return "lib.zip" + if nvdaVersion >= NVDA_2026_RUNTIME_VERSION: + return "lib64.zip" + return "lib.zip" + + +def getApprovedLibraryAsset(assetName: str | None = None) -> LibraryAsset: + if assetName is None: + assetName = getRuntimeAssetName() + sha256 = APPROVED_LIBRARY_SHA256.get(assetName, "").strip() + if not sha256: + raise LibraryUpdateError( + # Translators: Error shown when this add-on does not include trusted checksum metadata for a library. + _("No approved checksum is bundled for {assetName}.").format(assetName=assetName), + ) + return LibraryAsset( + version=APPROVED_LIBRARY_VERSION, + name=assetName, + url=f"{LIBRARY_RELEASE_DOWNLOAD_BASE}/{APPROVED_LIBRARY_VERSION}/{assetName}", + sha256=sha256, + source="approved", + ) + + +def getLatestVerifiedLibraryAsset(assetName: str | None = None) -> LibraryAsset: + if assetName is None: + assetName = getRuntimeAssetName() + release = _readJsonUrl(LIBRARY_RELEASE_API_URL) + version = str(release.get("tag_name") or "") + if not version: + raise LibraryUpdateError( + # Translators: Error shown when the GitHub release metadata cannot identify the release version. + _("The latest library release does not include a version tag."), + ) + asset = _findReleaseAsset(release, assetName) + checksum = _findReleaseChecksum(release, assetName) + return LibraryAsset( + version=version, + name=assetName, + url=str(asset["browser_download_url"]), + sha256=checksum, + source="latest", + ) + + +def downloadAndExtract( + _addonDir: str, + progressCallback: Callable[[int, str], None], + *, + forceLatest: bool = False, +) -> bool: + """Download, verify, and install the dependency library for this NVDA runtime.""" + try: + asset = _resolveLibraryAsset(forceLatest=forceLatest) + _installLibraryAsset(asset, progressCallback) + return True + except Exception as error: + log.error(f"Failed to download or install libraries: {error}", exc_info=True) wx.CallAfter( gui.messageBox, + # Translators: Error shown when dependency download, verification, or extraction fails. _( - "Failed to download or extract required libraries. The add-on might not work correctly.\n\nError: {error}", - ).format(error=e), + "Failed to download or install required libraries. The add-on might not work correctly.\n\nError: {error}", + ).format(error=error), _("Error"), wx.OK | wx.ICON_ERROR, ) - if os.path.exists(zipPath): - os.remove(zipPath) return False def checkAndInstallDependencies(forceReinstall: bool = False) -> None: - # Verifies if the 'lib' folder exists, and if not (or if forced), instigates the download process - addonDir = ADDON_DIR - libDir = LIB_DIR - - if not forceReinstall and os.path.exists(libDir): + """Prompt the user and install dependency libraries when needed.""" + if not forceReinstall and os.path.exists(LIB_DIR): log.info("Dependencies already installed, skipping check.") return def runInstallation() -> None: - # Sets up the progress UI and runs the download/extract thread progressDialog = wx.ProgressDialog( + # Translators: Title of a progress dialog shown while installing add-on dependencies. _("Installing Dependencies"), + # Translators: Initial progress message while dependency installation is being prepared. _("Checking for required libraries..."), maximum=100, parent=gui.mainFrame, @@ -122,26 +188,20 @@ def runInstallation() -> None: ) def updateProgress(progress: int, message: str) -> None: - # CallAfter target to update the progress dialog from the background thread if progress == 100: + # Translators: Progress message shown when dependency installation has completed. progressDialog.Update(100, _("Installation complete!")) wx.CallLater(500, progressDialog.Destroy) else: progressDialog.Update(progress, message) def doWork() -> None: - # The background worker that performs cleanup, download, and extraction - if forceReinstall and os.path.exists(libDir): - try: - shutil.rmtree(libDir) - except Exception as e: - log.warning(f"Failed to remove existing lib dir during reinstall: {e}") - - success = downloadAndExtract(addonDir, updateProgress) + success = downloadAndExtract(ADDON_DIR, updateProgress, forceLatest=forceReinstall) def finalMessage() -> None: if success: message = _( + # Translators: Message shown after dependencies are installed and NVDA must restart. "The Native Speech Generation libraries have been successfully installed/updated.\n\nPlease restart NVDA for the changes to take effect.", ) title = _("Installation Complete") @@ -155,21 +215,23 @@ def finalMessage() -> None: wx.CallAfter(finalMessage) - threading.Thread(target=doWork).start() + threading.Thread(target=doWork, daemon=True).start() def confirmAction() -> None: - # Prompts the user for confirmation before starting the installation if forceReinstall: msg = _( - "Are you sure you want to reinstall the libraries? This will redownload the dependencies and require an NVDA restart.", + # Translators: Confirmation before reinstalling or updating external Python dependencies. + "This will download the latest verified libraries for your NVDA version and require an NVDA restart. Continue?", ) - title = _("Confirm Reinstall") + title = _("Confirm Library Update") else: - msg = _("Required libraries for Native Speech Generation are missing. Click OK to download them.") + msg = _( + # Translators: Confirmation shown when required libraries are missing. + "Required libraries for Native Speech Generation are missing. Click OK to download the verified package for your NVDA version.", + ) title = _("Missing Dependencies") res = wx.MessageBox(msg, title, wx.OK | wx.CANCEL | wx.ICON_INFORMATION) - if res == wx.OK: runInstallation() else: @@ -179,5 +241,295 @@ def confirmAction() -> None: def reinstallDependencies() -> None: - """Public wrapper to force reinstallation.""" + """Public wrapper to install the latest verified dependency package.""" checkAndInstallDependencies(forceReinstall=True) + + +def _resolveLibraryAsset(*, forceLatest: bool) -> LibraryAsset: + assetName = getRuntimeAssetName() + if forceLatest: + try: + return getLatestVerifiedLibraryAsset(assetName) + except Exception as latestError: + log.warning(f"lib_updater: Latest verified library lookup failed: {latestError}", exc_info=True) + if _askInstallApprovedFallback(assetName, latestError): + return getApprovedLibraryAsset(assetName) + raise + + try: + return getApprovedLibraryAsset(assetName) + except Exception as approvedError: + log.warning(f"lib_updater: Approved library metadata unavailable: {approvedError}", exc_info=True) + return getLatestVerifiedLibraryAsset(assetName) + + +def _askInstallApprovedFallback(assetName: str, latestError: BaseException) -> bool: + try: + getApprovedLibraryAsset(assetName) + except Exception: + return False + message = _( + # Translators: Question shown when the latest dependency release cannot be verified. + "The latest library package could not be verified for {assetName}.\n\nError: {error}\n\nInstall the bundled approved library version instead?", + ).format( + assetName=assetName, + error=latestError, + ) + return _askUserYesNo(message, _("Use Approved Libraries")) + + +def _askUserYesNo(message: str, title: str) -> bool: + answer = {"value": False} + ready = threading.Event() + + def ask() -> None: + try: + answer["value"] = ( + wx.MessageBox(message, title, wx.YES_NO | wx.NO_DEFAULT | wx.ICON_WARNING) == wx.YES + ) + finally: + ready.set() + + wx.CallAfter(ask) + ready.wait() + return answer["value"] + + +def _installLibraryAsset( + asset: LibraryAsset, + progressCallback: Callable[[int, str], None], +) -> None: + zipPath = "" + candidateDir = "" + try: + fd, zipPath = tempfile.mkstemp(prefix="nsg_lib_", suffix=f"_{asset.name}") + os.close(fd) + _downloadLibraryZip(asset, zipPath, progressCallback) + _verifySha256(zipPath, asset.sha256) + wx.CallAfter( + progressCallback, + 80, + # Translators: Progress message shown while verified libraries are being extracted. + _("Extracting libraries..."), + ) + candidateDir = _extractLibraryCandidate(zipPath) + wx.CallAfter( + progressCallback, + 92, + # Translators: Progress message shown while replacing the dependency library folder. + _("Installing libraries..."), + ) + replaceLibraryDirectory(candidateDir) + candidateDir = "" + wx.CallAfter( + progressCallback, + 100, + # Translators: Progress message shown after library extraction and installation. + _("Library installation complete."), + ) + log.info( + f"lib_updater: Installed {asset.name} from {asset.source} release {asset.version}.", + ) + finally: + if zipPath and os.path.exists(zipPath): + os.remove(zipPath) + if candidateDir and os.path.isdir(candidateDir): + shutil.rmtree(candidateDir, ignore_errors=True) + + +def _downloadLibraryZip( + asset: LibraryAsset, + zipPath: str, + progressCallback: Callable[[int, str], None], +) -> None: + wx.CallAfter( + progressCallback, + 10, + # Translators: Progress message shown before dependency download begins. + _("Downloading libraries..."), + ) + with _openUrl(asset.url, timeout=30) as response, open(zipPath, "wb") as outFile: + totalLength = _getResponseLength(response) + downloaded = 0 + while True: + data = response.read(8192) + if not data: + break + downloaded += len(data) + outFile.write(data) + if totalLength: + percent = 10 + int(downloaded / totalLength * 60) + wx.CallAfter( + progressCallback, + min(percent, 70), + # Translators: Progress message shown while dependency download is in progress. + _("Downloading..."), + ) + wx.CallAfter( + progressCallback, + 72, + # Translators: Progress message shown while checking the downloaded dependency package. + _("Verifying libraries..."), + ) + + +def _extractLibraryCandidate(zipPath: str) -> str: + stagingDir = tempfile.mkdtemp(prefix=".lib_staging_", dir=PACKAGE_DIR) + try: + with zipfile.ZipFile(zipPath, "r") as zipFile: + _validateZipMembers(zipFile, stagingDir) + zipFile.extractall(stagingDir) + sourceDir = _getLibrarySourceDir(stagingDir) + readyDir = os.path.join(PACKAGE_DIR, f".lib_ready_{int(time.time())}_{threading.get_ident()}") + shutil.move(sourceDir, readyDir) + if os.path.isdir(stagingDir): + shutil.rmtree(stagingDir, ignore_errors=True) + return readyDir + except Exception: + if os.path.isdir(stagingDir): + shutil.rmtree(stagingDir, ignore_errors=True) + raise + + +def replaceLibraryDirectory(candidateDir: str) -> None: + trashDir = "" + if os.path.exists(LIB_DIR): + trashDir = os.path.join(PACKAGE_DIR, f"lib_trash_{int(time.time())}_{threading.get_ident()}") + os.rename(LIB_DIR, trashDir) + try: + os.rename(candidateDir, LIB_DIR) + except Exception: + if trashDir and os.path.isdir(trashDir) and not os.path.exists(LIB_DIR): + os.rename(trashDir, LIB_DIR) + raise + if trashDir and os.path.isdir(trashDir): + shutil.rmtree(trashDir, ignore_errors=True) + + +def _validateZipMembers(zipFile: zipfile.ZipFile, stagingDir: str) -> None: + stagingAbs = os.path.abspath(stagingDir) + for member in zipFile.infolist(): + memberName = member.filename.replace("\\", "/") + normalized = os.path.normpath(memberName) + parts = normalized.split(os.sep) + if ( + not memberName + or os.path.isabs(memberName) + or os.path.splitdrive(memberName)[0] + or normalized.startswith("..") + or ".." in parts + ): + raise LibraryUpdateError( + # Translators: Error shown when a downloaded dependency archive contains an unsafe path. + _("The library archive contains an unsafe path: {path}").format(path=member.filename), + ) + targetAbs = os.path.abspath(os.path.join(stagingDir, normalized)) + if os.path.commonpath([stagingAbs, targetAbs]) != stagingAbs: + raise LibraryUpdateError( + # Translators: Error shown when a downloaded dependency archive would extract outside the target folder. + _("The library archive contains a path outside the installation directory: {path}").format( + path=member.filename, + ), + ) + + +def _getLibrarySourceDir(stagingDir: str) -> str: + topLevelLib = os.path.join(stagingDir, "lib") + if os.path.isdir(topLevelLib): + return topLevelLib + return stagingDir + + +def _verifySha256(filePath: str, expectedSha256: str) -> None: + actualSha256 = _calculateSha256(filePath) + if actualSha256.lower() != expectedSha256.lower(): + raise LibraryUpdateError( + # Translators: Error shown when a downloaded dependency package fails checksum verification. + _("Library checksum mismatch. Expected {expected}, got {actual}.").format( + expected=expectedSha256, + actual=actualSha256, + ), + ) + + +def _calculateSha256(filePath: str) -> str: + hashObj = hashlib.sha256() + with open(filePath, "rb") as fileObj: + for chunk in iter(lambda: fileObj.read(1024 * 1024), b""): + hashObj.update(chunk) + return hashObj.hexdigest() + + +def _readJsonUrl(url: str) -> dict[str, Any]: + with _openUrl(url, timeout=20) as response: + return json.loads(response.read().decode("utf-8")) + + +def _readTextUrl(url: str) -> str: + with _openUrl(url, timeout=20) as response: + return response.read().decode("utf-8", errors="replace") + + +def _openUrl(url: str, *, timeout: int): + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + return urllib.request.urlopen(request, timeout=timeout) + + +def _getResponseLength(response: Any) -> int: + contentLength = response.headers.get("Content-Length", "") + try: + return int(contentLength) + except (TypeError, ValueError): + return 0 + + +def _findReleaseAsset(release: dict[str, Any], assetName: str) -> dict[str, Any]: + for asset in release.get("assets", []): + if asset.get("name") == assetName and asset.get("browser_download_url"): + return asset + raise LibraryUpdateError( + # Translators: Error shown when a GitHub release lacks the dependency asset needed for this NVDA version. + _("The latest library release does not contain {assetName}.").format(assetName=assetName), + ) + + +def _findReleaseChecksum(release: dict[str, Any], assetName: str) -> str: + checksumAssetNames = (f"{assetName}.sha256", "checksums.txt") + for checksumAssetName in checksumAssetNames: + checksumAsset = _findOptionalReleaseAsset(release, checksumAssetName) + if checksumAsset is None: + continue + checksumText = _readTextUrl(str(checksumAsset["browser_download_url"])) + checksum = _parseChecksumText( + checksumText, + assetName, + allowFallback=checksumAssetName != "checksums.txt", + ) + if checksum: + return checksum + raise LibraryUpdateError( + # Translators: Error shown when a dependency release lacks checksum metadata. + _("The latest library release does not include a checksum for {assetName}.").format( + assetName=assetName, + ), + ) + + +def _findOptionalReleaseAsset(release: dict[str, Any], assetName: str) -> dict[str, Any] | None: + for asset in release.get("assets", []): + if asset.get("name") == assetName and asset.get("browser_download_url"): + return asset + return None + + +def _parseChecksumText(checksumText: str, assetName: str, *, allowFallback: bool) -> str: + fallback = "" + for line in checksumText.splitlines(): + match = SHA256_RE.search(line) + if match is None: + continue + if assetName in line: + return match.group(1) + if not fallback: + fallback = match.group(1) + return fallback if allowFallback else "" diff --git a/addon/globalPlugins/NativeSpeechGeneration/talkWithAI.py b/addon/globalPlugins/NativeSpeechGeneration/talkWithAI.py index dbd1d3c..f80ec71 100644 --- a/addon/globalPlugins/NativeSpeechGeneration/talkWithAI.py +++ b/addon/globalPlugins/NativeSpeechGeneration/talkWithAI.py @@ -13,6 +13,7 @@ import wx import ui from logHandler import log +from typing import Any from .core.gemini_imports import ( GENAI_AVAILABLE, @@ -54,7 +55,9 @@ class TalkWithAIRuntimeError(RuntimeError): class TalkWithAIDialog(wx.Dialog): - def __init__(self, parent, apiKey, voiceName, systemInstruction): + """Dialog and runtime controller for Gemini Live voice conversation.""" + + def __init__(self, parent: wx.Window, apiKey: str, voiceName: str, systemInstruction: str) -> None: # Translators: Title of the dialog for the "Talk With AI" feature (REAL-TIME conversation). super().__init__(parent, title=_("Talk With AI"), size=(420, 320)) self.apiKey = apiKey @@ -128,10 +131,10 @@ def __init__(self, parent, apiKey, voiceName, systemInstruction): self.Bind(wx.EVT_CLOSE, self.onClose) self.Bind(wx.EVT_CHAR_HOOK, self.onCharHook) - def _logCleanupFailure(self, action, error): + def _logCleanupFailure(self, action: str, error: BaseException) -> None: log.debug(f"Talk With AI cleanup issue during {action}: {error}", exc_info=True) - def _getDeviceList(self, input=True): + def _getDeviceList(self, input: bool = True) -> list[dict[str, Any]]: """Returns a list of dicts: {'index': int, 'name': str}""" devices = [] if not PYAUDIO_AVAILABLE: @@ -155,7 +158,8 @@ def _getDeviceList(self, input=True): p.terminate() return devices - def _buildUi(self): + def _buildUi(self) -> None: + """Build the accessible controls for starting and managing a Live API session.""" mainSizer = wx.BoxSizer(wx.VERTICAL) panel = wx.Panel(self) panelSizer = wx.BoxSizer(wx.VERTICAL) @@ -250,7 +254,7 @@ def _buildUi(self): self.SetSizer(mainSizer) self.CenterOnParent() - def _announceStatus(self, text, force=False): + def _announceStatus(self, text: str, force: bool = False) -> None: message = str(text or "").strip() if not message: return @@ -262,7 +266,7 @@ def _announceStatus(self, text, force=False): except Exception as error: self._logCleanupFailure("status announcement", error) - def updateStatus(self, text, announce=False, forceAnnouncement=False): + def updateStatus(self, text: str, announce: bool = False, forceAnnouncement: bool = False) -> None: try: if self: self.statusLabel.SetLabel(_("Status: {status}").format(status=text)) @@ -271,7 +275,7 @@ def updateStatus(self, text, announce=False, forceAnnouncement=False): if announce: self._announceStatus(text, force=forceAnnouncement) - def reportError(self, msg): + def reportError(self, msg: object) -> None: try: if self: wx.MessageBox(str(msg), _("Error"), wx.OK | wx.ICON_ERROR) @@ -279,25 +283,25 @@ def reportError(self, msg): except RuntimeError: return - def onMicToggle(self, evt): + def onMicToggle(self, evt: wx.Event) -> None: self.micOn = self.micBtn.GetValue() label = _("Microphone: ON") if self.micOn else _("Microphone: OFF") self.micBtn.SetLabel(label) - def onVolumeChange(self, evt): + def onVolumeChange(self, evt: wx.Event) -> None: self.volume = self.volSlider.GetValue() - def _getSelectedThinkingLevel(self): + def _getSelectedThinkingLevel(self) -> str: selection = self.thinkingChoice.GetSelection() if selection == wx.NOT_FOUND: return "minimal" return self.thinkingChoices[selection][1] - def _clearSessionHistory(self): + def _clearSessionHistory(self) -> None: with self.historyLock: self.sessionHistory = [] - def _buildMissingDependencyMessage(self, baseMessage, errorDetail): + def _buildMissingDependencyMessage(self, baseMessage: str, errorDetail: str | None) -> str: if not errorDetail: return baseMessage return _("{baseMessage}\n\nImport detail: {errorDetail}").format( @@ -305,7 +309,7 @@ def _buildMissingDependencyMessage(self, baseMessage, errorDetail): errorDetail=errorDetail, ) - def _mergeHistoryText(self, existing, incoming): + def _mergeHistoryText(self, existing: str, incoming: str) -> str: if not existing: return incoming if incoming == existing or existing.endswith(incoming): @@ -316,7 +320,7 @@ def _mergeHistoryText(self, existing, incoming): return existing return f"{existing} {incoming}" - def _rememberConversationTurn(self, role, text): + def _rememberConversationTurn(self, role: str, text: str) -> None: cleaned = str(text or "").strip() if not cleaned: return @@ -330,14 +334,14 @@ def _rememberConversationTurn(self, role, text): self.sessionHistory.append({"role": role, "text": cleaned}) self._trimSessionHistory() - def _trimSessionHistory(self): + def _trimSessionHistory(self) -> None: if len(self.sessionHistory) > HISTORY_MAX_TURNS: self.sessionHistory = self.sessionHistory[-HISTORY_MAX_TURNS:] totalChars = sum(len(turn["text"]) for turn in self.sessionHistory) while self.sessionHistory and totalChars > HISTORY_MAX_CHARS: totalChars -= len(self.sessionHistory.pop(0)["text"]) - def _buildReconnectHistoryTurns(self): + def _buildReconnectHistoryTurns(self) -> list[Any]: with self.historyLock: return [ types.Content( @@ -348,7 +352,7 @@ def _buildReconnectHistoryTurns(self): if turn["text"].strip() ] - def _buildSystemInstruction(self): + def _buildSystemInstruction(self) -> str: baseRules = ( "You are a voice assistant for blind and low-vision users. " "Never fabricate facts. If uncertain, explicitly say you are not sure." @@ -359,11 +363,11 @@ def _buildSystemInstruction(self): parts.append(f"User preference:\n{userInstruction}") return "\n\n".join(parts) - def _buildReconnectDelay(self, attempt): + def _buildReconnectDelay(self, attempt: int) -> float: baseDelay = min(BACKOFF_MAX_SECONDS, BACKOFF_BASE_SECONDS * (2 ** max(0, attempt - 1))) return baseDelay + random.uniform(0.0, BACKOFF_JITTER_SECONDS) - def _getRuntimeCompatibilityError(self): + def _getRuntimeCompatibilityError(self) -> str | None: if not GENAI_AVAILABLE: return None requiredTypeNames = ( @@ -399,7 +403,7 @@ def _getRuntimeCompatibilityError(self): ) return None - def onConnect(self, evt): + def onConnect(self, evt: wx.Event) -> None: if self.compatibilityError: self.reportError(self.compatibilityError) return @@ -440,14 +444,14 @@ def onConnect(self, evt): self.loopThread = threading.Thread(target=self._startAsyncLoop, daemon=True) self.loopThread.start() - def onDisconnect(self, evt): + def onDisconnect(self, evt: wx.Event) -> None: self.disconnectBtn.Disable() self.updateStatus(_("Disconnecting..."), announce=True) if self.loop and self.loop.is_running(): asyncio.run_coroutine_threadsafe(self.cleanupAsync(), self.loop) - def _playSoundEffect(self, path): - def _bgPlay(): + def _playSoundEffect(self, path: str) -> None: + def _bgPlay() -> None: try: if os.path.exists(path): winsound.PlaySound(path, winsound.SND_FILENAME | winsound.SND_ASYNC) @@ -456,13 +460,13 @@ def _bgPlay(): threading.Thread(target=_bgPlay, daemon=True).start() - def onCharHook(self, evt): + def onCharHook(self, evt: wx.Event) -> None: if evt.GetKeyCode() == wx.WXK_ESCAPE: self.Close() else: evt.Skip() - def onClose(self, evt): + def onClose(self, evt: wx.Event) -> None: self._isClosing = True self.sessionActive = False self.isPlaying = False @@ -483,14 +487,14 @@ def onClose(self, evt): self.Destroy() - async def _shutdownLoop(self): + async def _shutdownLoop(self) -> None: try: await self.cleanupAsync() finally: loop = asyncio.get_running_loop() loop.stop() - def _startAsyncLoop(self): + def _startAsyncLoop(self) -> None: try: self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) @@ -511,14 +515,15 @@ def _startAsyncLoop(self): except Exception as error: self._logCleanupFailure("async loop finalization", error) - def _flushAudioQueue(self): + def _flushAudioQueue(self) -> None: while not self.audioQueue.empty(): try: self.audioQueue.get_nowait() except queue.Empty: break - async def cleanupAsync(self): + async def cleanupAsync(self) -> None: + """Stop the active Live API session and release audio resources.""" self.sessionActive = False self.isPlaying = False self.session = None @@ -542,7 +547,7 @@ async def cleanupAsync(self): self.audioInterface.terminate() self.audioInterface = None - def _audioPlayerWorker(self): + def _audioPlayerWorker(self) -> None: buffer = [] buffering = True self.bufferThreshold = BUFFER_THRESHOLD @@ -597,7 +602,7 @@ def _audioPlayerWorker(self): log.error(f"Audio Player Error: {error}") break - def _buildLiveConfig(self, includeHistorySeed): + def _buildLiveConfig(self, includeHistorySeed: bool) -> Any: if not types: raise TalkWithAIRuntimeError(_("Google GenAI types are not available.")) try: @@ -625,7 +630,7 @@ def _buildLiveConfig(self, includeHistorySeed): _("Failed to prepare the Gemini Live configuration. Please update the add-on libraries."), ) from error - def _assertSessionCompatibility(self, session): + def _assertSessionCompatibility(self, session: Any) -> None: for methodName in ("send_realtime_input", "send_client_content"): if not hasattr(session, methodName): version = VENDOR_VERSIONS.get("google.genai", "") @@ -646,19 +651,20 @@ def _assertSessionCompatibility(self, session): ), ) - async def _seedSessionHistory(self, session): + async def _seedSessionHistory(self, session: Any) -> None: historyTurns = self._buildReconnectHistoryTurns() if not historyTurns: return await session.send_client_content(turns=historyTurns, turn_complete=False) - def _shouldRetryWithoutHistoryConfig(self, error, usedHistoryConfig): + def _shouldRetryWithoutHistoryConfig(self, error: BaseException, usedHistoryConfig: bool) -> bool: if not usedHistoryConfig or not self.historyConfigSupported: return False message = f"{error!r}".lower() return "history_config" in message or "initial_history_in_client_content" in message - async def sendAudioLoop(self, session): + async def sendAudioLoop(self, session: Any) -> None: + """Read microphone audio and stream it to the active Live API session.""" while self.sessionActive: if self.micOn and self.inputStream and self.inputStream.is_active(): try: @@ -677,11 +683,11 @@ async def sendAudioLoop(self, session): else: await asyncio.sleep(0.1) - def _queueAudioData(self, data): + def _queueAudioData(self, data: bytes) -> None: if data: self.audioQueue.put(data) - def _handleServerContent(self, serverContent): + def _handleServerContent(self, serverContent: Any) -> bool: queuedAudio = False if getattr(serverContent, "interrupted", False): log.debug("TalkWithAI: Server Interrupted") @@ -709,7 +715,8 @@ def _handleServerContent(self, serverContent): self._rememberConversationTurn("model", textPart) return queuedAudio - async def receiveLoop(self, session): + async def receiveLoop(self, session: Any) -> None: + """Receive text/audio events from the Live API and queue audio playback.""" try: async for response in session.receive(): if not self.sessionActive: @@ -741,7 +748,8 @@ async def receiveLoop(self, session): finally: log.debug("TalkWithAI: Receive loop ended") - async def runSession(self): + async def runSession(self) -> None: + """Open audio devices and keep the Live API session connected with retry backoff.""" try: with getRuntimeScope(): self.audioInterface = pyaudio.PyAudio() @@ -867,7 +875,8 @@ async def runSession(self): self.audioInterface = None self.session = None - def resetUi(self): + def resetUi(self) -> None: + """Restore controls after a Live API session ends.""" if self: try: self.connectBtn.Enable() diff --git a/addon/locale/de/LC_MESSAGES/nvda.po b/addon/locale/de/LC_MESSAGES/nvda.po index b0740e8..ccb6ebd 100644 --- a/addon/locale/de/LC_MESSAGES/nvda.po +++ b/addon/locale/de/LC_MESSAGES/nvda.po @@ -6,7 +6,7 @@ #, fuzzy msgid "" msgstr "" -"Project-Id-Version: 'NativeSpeechGeneration' '1.6.0'\n" +"Project-Id-Version: 'NativeSpeechGeneration' '1.7.0'\n" "Report-Msgid-Bugs-To: 'nvda-translations@groups.io'\n" "POT-Creation-Date: 2026-04-08 16:09+0200\n" "PO-Revision-Date: 2026-04-14 14:36+0200\n" diff --git a/addon/locale/es/LC_MESSAGES/nvda.po b/addon/locale/es/LC_MESSAGES/nvda.po index faf0ed6..5014916 100644 --- a/addon/locale/es/LC_MESSAGES/nvda.po +++ b/addon/locale/es/LC_MESSAGES/nvda.po @@ -1,6 +1,6 @@ msgid "" msgstr "" -"Project-Id-Version: 'NativeSpeechGeneration' '1.6.0'\n" +"Project-Id-Version: 'NativeSpeechGeneration' '1.7.0'\n" "Report-Msgid-Bugs-To: 'nvda-translations@groups.io'\n" "POT-Creation-Date: 2026-04-04 16:18+0800\n" "PO-Revision-Date: 2026-04-04 16:46+0800\n" diff --git a/addon/locale/id/LC_MESSAGES/nvda.po b/addon/locale/id/LC_MESSAGES/nvda.po index 1149253..43cc40a 100644 --- a/addon/locale/id/LC_MESSAGES/nvda.po +++ b/addon/locale/id/LC_MESSAGES/nvda.po @@ -6,7 +6,7 @@ #, fuzzy msgid "" msgstr "" -"Project-Id-Version: 'NativeSpeechGeneration' '1.6.0'\n" +"Project-Id-Version: 'NativeSpeechGeneration' '1.7.0'\n" "Report-Msgid-Bugs-To: 'nvda-translations@groups.io'\n" "POT-Creation-Date: 2026-04-04 16:18+0800\n" "PO-Revision-Date: 2026-04-04 16:45+0800\n" diff --git a/addon/locale/ru/LC_MESSAGES/nvda.po b/addon/locale/ru/LC_MESSAGES/nvda.po index f5a2f06..70421e2 100644 --- a/addon/locale/ru/LC_MESSAGES/nvda.po +++ b/addon/locale/ru/LC_MESSAGES/nvda.po @@ -1,6 +1,6 @@ msgid "" msgstr "" -"Project-Id-Version: 'NativeSpeechGeneration' '1.6.0'\n" +"Project-Id-Version: 'NativeSpeechGeneration' '1.7.0'\n" "Report-Msgid-Bugs-To: 'nvda-translations@groups.io'\n" "POT-Creation-Date: 2026-04-04 16:18+0800\n" "PO-Revision-Date: 2026-04-04 16:47+0800\n" diff --git a/addon/locale/uk/LC_MESSAGES/nvda.po b/addon/locale/uk/LC_MESSAGES/nvda.po index e55f949..2c52849 100644 --- a/addon/locale/uk/LC_MESSAGES/nvda.po +++ b/addon/locale/uk/LC_MESSAGES/nvda.po @@ -1,6 +1,6 @@ msgid "" msgstr "" -"Project-Id-Version: 'NativeSpeechGeneration' '1.6.0'\n" +"Project-Id-Version: 'NativeSpeechGeneration' '1.7.0'\n" "Report-Msgid-Bugs-To: 'nvda-translations@groups.io'\n" "POT-Creation-Date: 2026-04-04 16:18+0800\n" "PO-Revision-Date: 2026-04-06 09:21+0300\n" diff --git a/buildVars.py b/buildVars.py index 83affab..fb72ed8 100644 --- a/buildVars.py +++ b/buildVars.py @@ -7,30 +7,29 @@ addon_description=_("""Harness the power of Google's state-of-the-art Gemini AI for high-quality speech generation directly within NVDA. This add-on provides a user-friendly dialog to convert text into natural-sounding audio. Key Features: -- High-Quality Voices: Choose between Gemini Pro for premium, life-like speech and Gemini Flash for standard quality, responsive generation. +- High-Quality Voices: Choose between Gemini Flash 3.1 Preview for powerful, low-latency short audio, Gemini Flash 2.5 for standard responsive generation, and Gemini Pro 2.5 for premium, life-like speech. - Single and Multi-Speaker Modes: Easily generate audio for a single speaker or create dynamic dialogues with two distinct speakers. Simply format your text with "SpeakerName:" to assign voices. - Advanced Voice Control: Fine-tune the output by adjusting the temperature for more creative or stable results, and provide custom style instructions. - Accessible Interface: All controls are fully accessible, including a collapsible panel for advanced settings to keep the interface clean and easy to navigate. - Seamless Workflow: The add-on provides instant audio playback upon generation and allows you to save the resulting .wav file for later use. To get started, obtain a Gemini API key from Google AI Studio and enter it in the add-on's settings panel, found under NVDA's Tools menu."""), - addon_version="1.6.0", - addon_changelog=_("""- Security: Gemini API keys are now stored with Windows DPAPI instead of plaintext config. -- Stability: Preserved add-on configuration across updates so API keys are no longer wiped by the uninstall/update flow. -- Deployment: Added GEMINI_API_KEY environment variable fallback for managed setups. -- Talk With AI: Migrated to gemini-3.1-flash-live-preview with Live API thinking controls. -- Talk With AI: Replaced the memory UI with No Thinking, Low, Medium, and High. -- Talk With AI: Preserved reconnect continuity internally by replaying recent transcript history after reconnects. -- Talk With AI: Kept style instructions as the Live API system instruction and retained Google Search grounding. -- Talk With AI: Improved stream stability with reconnect backoff/retry and adaptive buffering. -- Documentation: Added Spanish-language documentation. + addon_version="1.7.0", + addon_changelog=_("""- Compatibility: Added support for NVDA 2026.1. +- Dependencies: Added verified, NVDA-version-aware library downloads that select lib.zip for older NVDA versions and lib64.zip for NVDA 2026.1 and newer. +- Dependencies: Added SHA-256 verification and safe archive extraction before replacing the add-on library folder. +- Speech Generation: Added gemini-3.1-flash-tts-preview as the default model. +- Speech Generation: Captured dialog values before background generation to avoid reading wx controls from worker threads. +- Speech Generation: Saved generated audio outside the add-on folder so runtime output is not bundled accidentally. +- Security: Fixed the Windows DPAPI ctypes fallback to keep input buffers alive during encryption and decryption. +- Maintenance: Removed the direct requests dependency, tightened package exclusions, and refreshed NVDA-style naming/type hints. """), addon_author="Muhammad ", addon_url="https://github.com/muhammadGagah/native-speech-generation/", addon_sourceURL="https://github.com/muhammadGagah/native-speech-generation/", addon_docFileName="readme.html", addon_minimumNVDAVersion="2024.1", - addon_lastTestedNVDAVersion="2025.3.3", + addon_lastTestedNVDAVersion="2026.1", addon_updateChannel=None, addon_license="GPL-2.0", addon_licenseURL="https://www.gnu.org/licenses/gpl-2.0.html", @@ -45,7 +44,18 @@ i18nSources: list[str] = pythonSources + ["buildVars.py"] -excludedFiles: list[str] = [] +excludedFiles: list[str] = [ + "**/__pycache__/*", + "**/*.pyc", + "**/*.pyo", + "**/lib.zip", + "**/lib64.zip", + "**/.lib_staging_*/*", + "**/.lib_ready_*/*", + "**/lib_trash_*/*", + "**/last_audio_generated*", + "**/voices_cache.json", +] baseLanguage: str = "en" diff --git a/changelog.md b/changelog.md index cddab01..7863a06 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,16 @@ # Changelog +## version 1.7.0 + +- Compatibility: Added support for NVDA 2026.1. +- Dependencies: Added verified, NVDA-version-aware library downloads that select `lib.zip` for older NVDA versions and `lib64.zip` for NVDA 2026.1 and newer. +- Dependencies: Added SHA-256 verification and safe archive extraction before replacing the add-on library folder. +- Speech Generation: Added `gemini-3.1-flash-tts-preview` as the default model. +- Speech Generation: Captured dialog values before background generation to avoid reading wx controls from worker threads. +- Speech Generation: Saved generated audio outside the add-on folder so runtime output is not bundled accidentally. +- Security: Fixed the Windows DPAPI ctypes fallback to keep input buffers alive during encryption and decryption. +- Maintenance: Removed the direct `requests` dependency, tightened package exclusions, and refreshed NVDA-style naming/type hints. + ## version 1.6.0 - Security: Gemini API keys are now stored with Windows DPAPI instead of plaintext config. diff --git a/pyproject.toml b/pyproject.toml index bfb77bc..6e65b9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,8 @@ include = [ exclude = [ "sconstruct", ".git", + ".agent", + ".venv", "__pycache__", # When excluding concrete paths relative to a directory, # not matching multiple folders by name e.g. `__pycache__`, diff --git a/readme.md b/readme.md index e7d7d7c..797ef2d 100644 --- a/readme.md +++ b/readme.md @@ -15,8 +15,9 @@ This add-on is designed for smooth workflows, accessibility-first interaction, a * Choose between: - * **Gemini Flash** Standard quality, fast generation, low latency. - * **Gemini Pro** Premium, more realistic voices (paid model). + * **Gemini Flash 3.1 Preview** Powerful, low-latency speech generation, very good for short audio. + * **Gemini Flash 2.5** Standard quality, fast generation, low latency. + * **Gemini Pro 2.5** Premium, more realistic voices (paid model). ### Single & Multi-Speaker Modes @@ -66,7 +67,7 @@ This add-on is designed for smooth workflows, accessibility-first interaction, a ## Requirements -* NVDA (latest version recommended). +* NVDA 2024.1 or newer, tested through NVDA 2026.1. * Active internet connection. * A valid **Google Gemini API Key**. @@ -117,8 +118,9 @@ Open the dialog using: Provide guidance for tone, emotion, or delivery. * **Select Model** - * Flash (Standard Quality) - * Pro (High Quality) + * Flash 3.1 Preview + * Flash 2.5 (Standard Quality) + * Pro 2.5 (High Quality) * **Speaker Mode** * Single-speaker @@ -247,6 +249,13 @@ Adjust the path according to your local add-on source directory. For the current audio-only Talk With AI implementation, you do not need `opencv-python`, `pillow`, or `mss`. +For release packages, the add-on downloads verified dependency archives based on the running NVDA version: + +* `lib.zip` for NVDA 2025.3.3 and older supported builds. +* `lib64.zip` for NVDA 2026.1 and newer. + +Both release assets must include matching SHA-256 files (`lib.zip.sha256` and `lib64.zip.sha256`). The extracted folder is always installed as `addon/globalPlugins/NativeSpeechGeneration/lib`. + Then copy the following from your Python installation into: ``` From 96173e6f78d2e38c0f430bdae36d8827d9973156 Mon Sep 17 00:00:00 2001 From: Muhammad Date: Fri, 29 May 2026 18:32:31 +0700 Subject: [PATCH 2/2] Sync add-on with NVDA 2026.1 and latest dependency flow --- .github/dependabot.yml | 12 +- .github/workflows/build_addon.yml | 61 +- .pre-commit-config.yaml | 6 +- addon/doc/de/readme.md | 59 +- addon/doc/es/readme.md | 29 +- addon/doc/id/readme.md | 29 +- addon/doc/ru/readme.md | 29 +- .../NativeSpeechGeneration/__init__.py | 223 +++--- .../core/audio_utils.py | 10 +- .../interface/generation_dialog.py | 87 ++- .../interface/settings.py | 3 + .../NativeSpeechGeneration/lib_updater.py | 103 ++- .../NativeSpeechGeneration/talkWithAI.py | 25 + addon/locale/id/LC_MESSAGES/nvda.po | 668 ++++++++++-------- buildVars.py | 22 +- changelog.md | 8 + pyproject.toml | 49 ++ readme.md | 28 +- sconstruct | 1 + site_scons/site_tools/NVDATool/__init__.py | 4 + site_scons/site_tools/NVDATool/manifests.py | 10 +- site_scons/site_tools/NVDATool/typings.py | 6 + tests/test_lib_updater.py | 243 +++++++ uv.lock | 477 +++++++++++++ 24 files changed, 1600 insertions(+), 592 deletions(-) create mode 100644 tests/test_lib_updater.py create mode 100644 uv.lock diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 66973ff..a5566a4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,8 +1,16 @@ version: 2 updates: - # Maintain dependencies for GitHub Actions + - package-ecosystem: "uv" + directory: "/" + schedule: + interval: "weekly" + groups: + python-build: + update-types: + - minor + - patch + - package-ecosystem: "github-actions" - # Workflow files stored in the default location of `.github/workflows`. (You don't need to specify `/.github/workflows` for `directory`. You can use `directory: "/"`.) directory: "/" schedule: interval: "weekly" diff --git a/.github/workflows/build_addon.yml b/.github/workflows/build_addon.yml index e91321d..62e839f 100644 --- a/.github/workflows/build_addon.yml +++ b/.github/workflows/build_addon.yml @@ -3,8 +3,6 @@ name: build addon on: push: tags: ["*"] - # To build on main/master branch, uncomment the following line: - # branches: [ main , master ] pull_request: branches: [main, master] @@ -18,37 +16,48 @@ jobs: steps: - uses: actions/checkout@v6 - - run: echo -e "pre-commit\nscons\nmarkdown">requirements.txt - - - name: Set up Python - uses: actions/setup-python@v6 + - name: Install uv + uses: astral-sh/setup-uv@v7 with: - python-version: 3.11 - cache: "pip" + enable-cache: true - name: Install dependencies run: | - python -m pip install --upgrade pip wheel - pip install -r requirements.txt - pip install uv - sudo apt-get update -y + sudo apt-get update -y sudo apt-get install -y gettext - - - name: Install pyright - run: pip install pyright + uv sync - name: Code checks - run: export SKIP=no-commit-to-branch; pre-commit run --all + run: export SKIP=no-commit-to-branch; uv run pre-commit run --all-files - - name: building addon - run: scons && scons pot + - name: Build add-on + run: uv run scons && uv run scons pot - uses: actions/upload-artifact@v7 with: name: packaged_addon - path: | - ./*.nvda-addon - ./*.pot + path: ./*.nvda-addon + if-no-files-found: error + + - uses: actions/upload-artifact@v7 + with: + name: pot + path: ./*.pot + if-no-files-found: error + + python311_compatibility: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Check Python 3.11 syntax compatibility + run: python -m compileall addon buildVars.py sconstruct site_scons tests upload_release: runs-on: ubuntu-latest @@ -58,10 +67,16 @@ jobs: contents: write steps: - uses: actions/checkout@v6 - - name: download releases files + + - name: Download release files uses: actions/download-artifact@v8 - - name: Display structure of downloaded files + with: + path: . + merge-multiple: true + + - name: Display release files run: ls -R + - name: Calculate sha256 run: | echo -e "\nSHA256: " >> changelog.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 60798f1..f1c49b6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ ci: submodules: true default_language_version: - python: python3.11 + python: python3.13 repos: - repo: https://github.com/pre-commit-ci/pre-commit-ci-config @@ -69,7 +69,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Matches Ruff version in pyproject. - rev: v0.12.7 + rev: v0.14.10 hooks: - id: ruff name: lint with ruff @@ -81,6 +81,6 @@ repos: hooks: - id: pyright name: type check with pyright - entry: pyright + entry: uv run pyright language: system types: [python] diff --git a/addon/doc/de/readme.md b/addon/doc/de/readme.md index 2166b45..8926564 100644 --- a/addon/doc/de/readme.md +++ b/addon/doc/de/readme.md @@ -2,7 +2,7 @@ **Autor:** Muhammad Gagah muha.aku@gmail.com ** -Natürliche Spracherzeugung ist ein NVDA‑Add-on, das **Google Gemini AI** integriert, um hochwertige, natürlich klingende Sprache direkt in NVDA zu erzeugen. +Natürliche Spracherzeugung ist ein NVDA‑Add-on, das **Google Gemini AI** integriert, um hochwertige, natürlich klingende Sprache direkt in NVDA zu erzeugen. Es bietet eine klare, vollständig zugängliche Oberfläche zur Umwandlung von Text in Audio und unterstützt sowohl **Einzelsprecher‑Vorlesemodi** als auch **dynamische Dialoge mit zwei Sprechern**. @@ -25,7 +25,7 @@ Dieses Addon ist für reibungslose Arbeitsabläufe, barrierefreie Bedienung und ### Erweiterte Stimmkontrolle * **Sprechernamen** - Weise im Mehrsprecher‑Modus individuelle Namen zu (z. B. *John*, *Mary*). + Weise im Mehrsprecher‑Modus individuelle Namen zu (z. B. *John*, *Mary*). Die KI ordnet Stimmen automatisch anhand der Namen im Skript zu. * **Stil‑Anweisungen** Gib Hinweise wie *„Sprich in fröhlichem Ton“* oder *„Ruhig erzählen“*, um die Sprechweise zu steuern. @@ -69,17 +69,17 @@ Dieses Addon ist für reibungslose Arbeitsabläufe, barrierefreie Bedienung und -## Installation -1. Lade das neueste Add-on von der - **Veröffentlichungsseite:** - `https://github.com/MuhammadGagah/native-speech-generation/releases` +1. Lade das neueste Add-on von der + **Veröffentlichungsseite:** + `https://github.com/MuhammadGagah/native-speech-generation/releases` 2. Installiere es wie jedes NVDA‑Add-on. 3. Starte NVDA neu, wenn du dazu aufgefordert wirst. -## API‑Schlüssel einrichten (erforderlich) -1. Erstelle einen API‑Schlüssel in **Google AI Studio**: +1. Erstelle einen API‑Schlüssel in **Google AI Studio**: -2. Öffne NVDA und gehe zu: +2. Öffne NVDA und gehe zu: **NVDA-Menü → Werkzeuge → Natürliche Spracherzeugung** 3. Klicke auf **„API Key Einstellungen“**. 4. Dadurch öffnet sich der NVDA‑Einstellungsdialog direkt im Bereich *Natürliche Spracherzeugung*. @@ -88,7 +88,7 @@ Dieses Addon ist für reibungslose Arbeitsabläufe, barrierefreie Bedienung und Gespeicherte Schlüssel werden sicher über **Windows DPAPI** verschlüsselt – sie können auf anderen Windows‑Systemen oder Benutzerkonten nicht entschlüsselt werden. -Für fortgeschrittene Bereitstellungen kannst du den Schlüssel auch über die Umgebungsvariable +Für fortgeschrittene Bereitstellungen kannst du den Schlüssel auch über die Umgebungsvariable **`GEMINI_API_KEY`** bereitstellen. Das Add-on nutzt ihn automatisch, wenn kein gespeicherter Schlüssel vorhanden ist. ## Verwendung @@ -100,9 +100,9 @@ Für fortgeschrittene Bereitstellungen kannst du den Schlüssel auch über die U ### Hauptelemente der Oberfläche -* **Text zum Konvertieren** +* **Text zum Konvertieren** Gib den Text ein oder füge ihn ein. -* **Stil‑Anweisungen (optional)** +* **Stil‑Anweisungen (optional)** Hinweise zu Ton, Emotion oder Sprechweise. * **Modell auswählen** @@ -141,21 +141,21 @@ Alice: Hallo Bob, wie geht es dir heute? Bob: Mir geht’s super, Alice! Das Wetter ist fantastisch. ``` -4. Klicke **Sprache erzeugen*. +4. Klicke **Sprache erzeugen*. Stimmen werden automatisch anhand der Namen zugeordnet. ## Sprechen mit der KI (Live‑Modus) Erlebe ein natürliches Sprachgespräch mit Gemini. -1. Stimme und Stil‑Anweisungen im Hauptdialog konfigurieren. +1. Stimme und Stil‑Anweisungen im Hauptdialog konfigurieren. *(Hinweis: Sprechen mit der KI unterstützt derzeit nur Einzelsprecher‑Modus.)* 2. Klicke **Sprechen mit der KI**. 3. Im neuen Fenster: * **Gespräch beginnen** – beginnt die Sitzung, Mikrofon aktiv. * **Gespräch stoppen** – beendet die Sitzung. - * **Verbindung mit Google-Suche** – erlaubt Web‑Recherche. + * **Verbindung mit Google-Suche** – erlaubt Web‑Recherche. *(Während einer aktiven Sitzung ausgeblendet.)* * **Verarbeitungstiefe: ** – `Nein`, `Niedrig`, `Medium`, `Hoch`. * **Mikrofon‑Schalter** – Stummschalten/aktivieren. @@ -182,7 +182,7 @@ Erlebe ein natürliches Sprachgespräch mit Gemini. ## Tastenkombinationen -Anpassbar unter: +Anpassbar unter: **NVDA-Menü → Optionen → Tastenbefehle → Natürliche Spracherzeugung** Standard: @@ -195,28 +195,28 @@ Wenn du das Add-on Weiterentwickeln oder anpassen möchtest: ### Entwicklungsumgebung -* **Python 32‑bit (3.11.9 empfohlen)** - `https://www.python.org/downloads/release/python-3119/` [(python.org in Bing)](https://www.bing.com/search?q="https%3A%2F%2Fwww.python.org%2Fdownloads%2Frelease%2Fpython-3119%2F") -* **SCons 4.9.1 oder neuer** +* **Python passend zur Ziel-NVDA-Laufzeit** + * Verwende **Python 3.13 64-bit** für NVDA 2026.1 und neuer. + * Verwende **Python 3.11 32-bit** nur zum Paketieren von Abhängigkeiten für ältere unterstützte NVDA-Versionen. +* **uv** für die festgelegte Build- und Lint-Toolchain. ``` - pip install scons + uv sync + uv run pre-commit run --all-files + uv run scons + uv run scons pot ``` + SCons 4.10.1, Markdown 3.10, Ruff 0.14.10, Pyright 1.1.407 und die weiteren Build-Tools werden aus `uv.lock` installiert. + * **GNU Gettext Tools** (optional, empfohlen) * Unter Linux/Cygwin meist vorinstalliert. * Windows: `https://gnuwin32.sourceforge.net/downlinks/gettext.php` [(gnuwin32.sourceforge.net in Bing)](https://www.bing.com/search?q="https%3A%2F%2Fgnuwin32.sourceforge.net%2Fdownlinks%2Fgettext.php") -* **Markdown 3.8+** (für Dokumentationskonvertierung) - - ``` - pip install markdown - ``` - ### Zusätzliche Abhängigkeiten -Installiere die Audio‑Abhängigkeiten für Sprechen mit der KI direkt in den Add-on‑Lib‑Ordner: +Installiere die Audio‑Abhängigkeiten für Sprechen mit der KI nur für lokale Entwicklung direkt in den Add-on‑Lib‑Ordner. Verwende dabei die Python-Version und Architektur, die zur getesteten NVDA-Laufzeit passen: ``` python.exe -m pip install google-genai pyaudio --target "D:/myAdd-on/Native-Speech-Generation/addon/globalPlugins/NativeSpeechGeneration/lib" @@ -226,6 +226,13 @@ Pfad entsprechend anpassen. Für die aktuelle Audio‑only‑Implementierung werden **opencv-python**, **pillow** und **mss** nicht benötigt. +Für Release-Pakete lädt das Add-on das neueste verifizierte Abhängigkeitsarchiv passend zur laufenden NVDA-Version herunter: + +* `lib.zip` für NVDA 2025.3.3 und ältere unterstützte Builds. +* `lib64.zip` für NVDA 2026.1 und neuer. + +Das Add-on liest die SHA-256-Daten aus dem neuesten GitHub-Abhängigkeitsrelease, entweder aus dem Release-Asset-Digest oder aus Checksum-Dateien. Mitgelieferte genehmigte Checksums bleiben nur als Fallback für Erstinstallationen erhalten, wenn die Abfrage des neuesten Releases fehlschlägt. Manuelle Bibliotheks-Neuinstallationen verlangen das neueste verifizierte Release. Der extrahierte Ordner wird immer als `addon/globalPlugins/NativeSpeechGeneration/lib` installiert. + Kopiere anschließend aus deiner Python‑Installation in: ``` @@ -249,4 +256,4 @@ Beiträge, Vorschläge und Fehlerberichte sind willkommen. ## Übersetzung -Diese Erweiterung wurde von BFW Würzburg im Rahmen des Projektes "NVDA Nachhaltig" ins Deutsche übersetzt. \ No newline at end of file +Diese Erweiterung wurde von BFW Würzburg im Rahmen des Projektes "NVDA Nachhaltig" ins Deutsche übersetzt. diff --git a/addon/doc/es/readme.md b/addon/doc/es/readme.md index f1b4202..3cfa797 100644 --- a/addon/doc/es/readme.md +++ b/addon/doc/es/readme.md @@ -209,25 +209,25 @@ Si quieres desarrollar o modificar este complemento, sigue los pasos siguientes. ### Configuración del entorno -* **Python de 32 bits (se recomienda 3.11.9)** - [https://www.python.org/downloads/release/python-3119/](https://www.python.org/downloads/release/python-3119/) -* **SCons 4.9.1 o superior** +* **Python correspondiente al runtime de NVDA objetivo** + * Usa **Python 3.13 de 64 bits** para NVDA 2026.1 y versiones posteriores. + * Usa **Python 3.11 de 32 bits** solo para empaquetar dependencias de versiones anteriores de NVDA compatibles. +* **uv** para la cadena de herramientas de compilación y lint fijada. ``` - pip install scons + uv sync + uv run pre-commit run --all-files + uv run scons + uv run scons pot ``` + + SCons 4.10.1, Markdown 3.10, Ruff 0.14.10, Pyright 1.1.407 y las demás herramientas de compilación se instalan desde `uv.lock`. * **Herramientas GNU Gettext** (opcional, recomendado para localización) * Normalmente vienen preinstaladas en Linux/Cygwin. * Windows: [https://gnuwin32.sourceforge.net/downlinks/gettext.php](https://gnuwin32.sourceforge.net/downlinks/gettext.php) -* **Markdown 3.8+** (para conversión de documentación) - - ``` - pip install markdown - ``` - ### Dependencias adicionales -Instala las dependencias de audio de Talk With AI directamente en la ruta de bibliotecas del complemento: +Solo para desarrollo local, instala las dependencias de audio de Talk With AI directamente en la ruta de bibliotecas del complemento usando la versión y arquitectura de Python que coincidan con el runtime de NVDA que estás probando: ``` python.exe -m pip install google-genai pyaudio --target "D:/myAdd-on/Native-Speech-Generation/addon/globalPlugins/NativeSpeechGeneration/lib" @@ -237,6 +237,13 @@ Ajusta la ruta según tu directorio local del código fuente del complemento. Para la implementación actual de Talk With AI basada solo en audio, no necesitas `opencv-python`, `pillow` ni `mss`. +Para paquetes de lanzamiento, el complemento descarga el archivo de dependencias verificado más reciente según la versión de NVDA en ejecución: + +* `lib.zip` para NVDA 2025.3.3 y compilaciones anteriores compatibles. +* `lib64.zip` para NVDA 2026.1 y versiones posteriores. + +El complemento lee los datos SHA-256 desde la versión de dependencias más reciente en GitHub, usando el digest del asset de la versión o archivos de checksum. Los checksums aprobados incluidos se conservan solo como fallback para primeras instalaciones cuando falla la consulta de la versión más reciente. La reinstalación manual de bibliotecas requiere la versión verificada más reciente. La carpeta extraída siempre se instala como `addon/globalPlugins/NativeSpeechGeneration/lib`. + Después, copia lo siguiente desde tu instalación de Python a: ``` diff --git a/addon/doc/id/readme.md b/addon/doc/id/readme.md index ea2bf74..eff5e8f 100644 --- a/addon/doc/id/readme.md +++ b/addon/doc/id/readme.md @@ -209,25 +209,25 @@ Jika Anda ingin mengembangkan atau memodifikasi add-on ini, ikuti langkah-langka ### Pengaturan Lingkungan -* **Python 32-bit (disarankan 3.11.9)** - [https://www.python.org/downloads/release/python-3119/](https://www.python.org/downloads/release/python-3119/) -* **SCons 4.9.1 atau lebih baru** +* **Python yang sesuai dengan runtime NVDA target** + * Gunakan **Python 3.13 64-bit** untuk NVDA 2026.1 dan yang lebih baru. + * Gunakan **Python 3.11 32-bit** hanya untuk paket dependensi NVDA lama yang masih didukung. +* **uv** untuk toolchain build dan lint yang dipin. ``` - pip install scons + uv sync + uv run pre-commit run --all-files + uv run scons + uv run scons pot ``` + + SCons 4.10.1, Markdown 3.10, Ruff 0.14.10, Pyright 1.1.407, dan tool build lainnya diinstal dari `uv.lock`. * **GNU Gettext Tools** (opsional, disarankan untuk lokalisasi) * Biasanya sudah terpasang di Linux/Cygwin. * Windows: [https://gnuwin32.sourceforge.net/downlinks/gettext.php](https://gnuwin32.sourceforge.net/downlinks/gettext.php) -* **Markdown 3.8+** (untuk konversi dokumentasi) - - ``` - pip install markdown - ``` - ### Dependensi Tambahan -Instal dependensi audio untuk Talk With AI langsung ke jalur pustaka add-on: +Untuk pengembangan lokal saja, instal dependensi audio untuk Talk With AI langsung ke jalur pustaka add-on menggunakan versi dan arsitektur Python yang sesuai dengan runtime NVDA yang diuji: ``` python.exe -m pip install google-genai pyaudio --target "D:/myAdd-on/Native-Speech-Generation/addon/globalPlugins/NativeSpeechGeneration/lib" @@ -237,6 +237,13 @@ Sesuaikan jalur tersebut dengan direktori sumber add-on di komputer Anda. Untuk implementasi Talk With AI versi audio saat ini, Anda tidak memerlukan `opencv-python`, `pillow`, atau `mss`. +Untuk paket rilis, add-on mengunduh arsip dependensi terverifikasi terbaru berdasarkan versi NVDA yang berjalan: + +* `lib.zip` untuk NVDA 2025.3.3 dan build lama yang masih didukung. +* `lib64.zip` untuk NVDA 2026.1 dan yang lebih baru. + +Add-on membaca data SHA-256 dari rilis dependensi GitHub terbaru, menggunakan digest asset rilis atau file checksum. Checksum bawaan yang disetujui hanya disimpan sebagai fallback untuk instalasi pertama ketika lookup rilis terbaru gagal. Reinstall library manual mewajibkan rilis terbaru yang terverifikasi. Folder hasil ekstraksi selalu diinstal sebagai `addon/globalPlugins/NativeSpeechGeneration/lib`. + Lalu salin file berikut dari instalasi Python Anda ke: ``` diff --git a/addon/doc/ru/readme.md b/addon/doc/ru/readme.md index 4ea46ff..d6aa005 100644 --- a/addon/doc/ru/readme.md +++ b/addon/doc/ru/readme.md @@ -209,25 +209,25 @@ Native Speech Generation — это дополнение для NVDA, котор ### Настройка окружения -* **Python 32-bit (рекомендуется 3.11.9)** - [https://www.python.org/downloads/release/python-3119/](https://www.python.org/downloads/release/python-3119/) -* **SCons 4.9.1 или новее** +* **Python, соответствующий целевой среде NVDA** + * Используйте **Python 3.13 64-bit** для NVDA 2026.1 и новее. + * Используйте **Python 3.11 32-bit** только для упаковки зависимостей старых поддерживаемых версий NVDA. +* **uv** для закрепленной цепочки инструментов сборки и lint-проверок. ``` - pip install scons + uv sync + uv run pre-commit run --all-files + uv run scons + uv run scons pot ``` + + SCons 4.10.1, Markdown 3.10, Ruff 0.14.10, Pyright 1.1.407 и другие инструменты сборки устанавливаются из `uv.lock`. * **GNU Gettext Tools** (необязательно, рекомендуется для локализации) * Обычно уже установлены в Linux/Cygwin. * Windows: [https://gnuwin32.sourceforge.net/downlinks/gettext.php](https://gnuwin32.sourceforge.net/downlinks/gettext.php) -* **Markdown 3.8+** (для преобразования документации) - - ``` - pip install markdown - ``` - ### Дополнительные зависимости -Установите аудиозависимости для Talk With AI прямо в каталог библиотек дополнения: +Только для локальной разработки установите аудиозависимости для Talk With AI прямо в каталог библиотек дополнения, используя версию и архитектуру Python, соответствующие тестируемой среде NVDA: ``` python.exe -m pip install google-genai pyaudio --target "D:/myAdd-on/Native-Speech-Generation/addon/globalPlugins/NativeSpeechGeneration/lib" @@ -237,6 +237,13 @@ python.exe -m pip install google-genai pyaudio --target "D:/myAdd-on/Native-Spee Для текущей аудио-реализации Talk With AI вам не нужны `opencv-python`, `pillow` и `mss`. +Для релизных пакетов дополнение загружает последний проверенный архив зависимостей в зависимости от запущенной версии NVDA: + +* `lib.zip` для NVDA 2025.3.3 и более ранних поддерживаемых сборок. +* `lib64.zip` для NVDA 2026.1 и новее. + +Дополнение считывает SHA-256 из последнего релиза зависимостей на GitHub, используя digest asset релиза или файлы checksum. Встроенные одобренные checksum остаются только как fallback для первой установки, если не удалось получить данные последнего релиза. Ручная переустановка библиотек требует последний проверенный релиз. Извлечённая папка всегда устанавливается как `addon/globalPlugins/NativeSpeechGeneration/lib`. + Затем скопируйте следующее из вашей установки Python в: ``` diff --git a/addon/globalPlugins/NativeSpeechGeneration/__init__.py b/addon/globalPlugins/NativeSpeechGeneration/__init__.py index b323026..ac3a498 100644 --- a/addon/globalPlugins/NativeSpeechGeneration/__init__.py +++ b/addon/globalPlugins/NativeSpeechGeneration/__init__.py @@ -1,12 +1,13 @@ # -*- coding: utf-8 -*- import os -import wx +from typing import Any, TYPE_CHECKING + import addonHandler import globalPluginHandler import gui +import wx from logHandler import log from scriptHandler import script -from typing import Any, TYPE_CHECKING from .core import config_store @@ -20,128 +21,146 @@ def _(msg: str) -> str: pkgDir = os.path.dirname(os.path.abspath(__file__)) -try: - from . import lib_updater - lib_updater.initialize() - libDir = lib_updater.LIB_DIR -except Exception as e: - log.error(f"Failed to initialize lib_updater: {e}", exc_info=True) - libDir = os.path.join(pkgDir, "lib") +class GlobalPlugin(globalPluginHandler.GlobalPlugin): + """NVDA global plugin entrypoint for Native Speech Generation.""" + + def __init__(self) -> None: + super().__init__() + self.dialog = None + self.menuItem = None + self._settingsPanelClass = None + self._libsAvailable = False + self._initializeAddonState() -LIBS_AVAILABLE = False + def _initializeAddonState(self) -> None: + self._libsAvailable = os.path.isdir(self._getLibraryDirectory()) + self._runDependencyStartupCleanup() + if not self._libsAvailable: + wx.CallAfter(self._checkAndInstallDependencies) + return -if not os.path.isdir(libDir): + config_store.prepareConfigForStartup(persist=True) + self._registerSettingsPanel() + self._registerToolsMenuItem() - def runCheck() -> None: + def _getLibraryDirectory(self) -> str: try: from . import lib_updater - lib_updater.checkAndInstallDependencies(forceReinstall=False) - except Exception as e: - log.error(f"Failed to run lib_updater check: {e}", exc_info=True) + return lib_updater.LIB_DIR + except Exception as error: + log.error(f"Failed to resolve Native Speech Generation library directory: {error}", exc_info=True) + return os.path.join(pkgDir, "lib") + + def _runDependencyStartupCleanup(self) -> None: + try: + from . import lib_updater + + lib_updater.initialize() + except Exception as error: + log.error( + f"Failed to initialize Native Speech Generation dependency updater: {error}", + exc_info=True, + ) - wx.CallAfter(runCheck) -else: - LIBS_AVAILABLE = True + def _checkAndInstallDependencies(self) -> None: + try: + from . import lib_updater -if not LIBS_AVAILABLE: + lib_updater.checkAndInstallDependencies(forceReinstall=False) + except Exception as error: + log.error(f"Failed to run Native Speech Generation dependency check: {error}", exc_info=True) - class GlobalPlugin(globalPluginHandler.GlobalPlugin): - """ - A dummy plugin that informs the user that the addon is not ready - and that a restart is required. - """ + def _registerSettingsPanel(self) -> None: + from .interface.settings import NativeSpeechSettingsPanel - @script( - description=_("Open the Native Speech Generation dialog"), - category=_("Native Speech Generation"), - gesture="kb:NVDA+Control+Shift+G", + self._settingsPanelClass = NativeSpeechSettingsPanel + if NativeSpeechSettingsPanel not in gui.settingsDialogs.NVDASettingsDialog.categoryClasses: + gui.settingsDialogs.NVDASettingsDialog.categoryClasses.append(NativeSpeechSettingsPanel) + + def _registerToolsMenuItem(self) -> None: + toolsMenu = gui.mainFrame.sysTrayIcon.toolsMenu + self.menuItem = toolsMenu.Append( + wx.ID_ANY, + # Translators: Name of the add-on in the NVDA Tools menu. + _("&Native Speech Generation"), + # Translators: Tooltip or description for the Native Speech Generation Tools menu item. + _("Generate speech using Gemini TTS"), ) - def script_openDialog(self, gesture: Any) -> None: + gui.mainFrame.sysTrayIcon.Bind(wx.EVT_MENU, self.onShowDialog, self.menuItem) + + @script( + # Translators: Input gesture description for opening the Native Speech Generation dialog. + description=_("Open the Native Speech Generation dialog"), + # Translators: Input gestures category name for Native Speech Generation. + category=_("Native Speech Generation"), + gesture="kb:NVDA+Control+Shift+G", + ) + def script_openDialog(self, gesture: Any) -> None: + self._openDialog() + + def onShowDialog(self, evt: wx.Event) -> None: + wx.CallAfter(self._openDialog) + + def _openDialog(self) -> None: + if not self._libsAvailable: + self._showDependenciesPendingMessage() + return + if self.dialog and self.dialog.IsShown(): wx.CallAfter( wx.MessageBox, _( - "Native Speech Generation is installing dependencies. " - "Please restart NVDA for the changes to take effect.", + # Translators: Warning shown when the user tries to open the add-on dialog twice. + "The Native Speech Generation add-on is already open. Please close the dialog before opening it again.", ), - # Translators: Title of the information dialog recommending a restart. - _("Restart Required"), - wx.OK | wx.ICON_INFORMATION, + # Translators: Title of warning dialog when the user tries to open the add-on twice. + _("Add-on Already Running"), + wx.OK | wx.ICON_WARNING, ) + return + try: + from .interface.generation_dialog import NativeSpeechDialog -else: - try: - from .interface.settings import NativeSpeechSettingsPanel - from .interface.generation_dialog import NativeSpeechDialog - except ImportError as e: - log.error(f"Failed to import GUI components: {e}", exc_info=True) - raise - - class GlobalPlugin(globalPluginHandler.GlobalPlugin): - def __init__(self) -> None: - super().__init__() - self.dialog = None - config_store.prepareConfigForStartup(persist=True) - - if NativeSpeechSettingsPanel not in gui.settingsDialogs.NVDASettingsDialog.categoryClasses: - gui.settingsDialogs.NVDASettingsDialog.categoryClasses.append(NativeSpeechSettingsPanel) - - toolsMenu = gui.mainFrame.sysTrayIcon.toolsMenu - self.menuItem = toolsMenu.Append( - wx.ID_ANY, - # Translators: Name of the add-on in the NVDA Tools menu. - _("&Native Speech Generation"), - # Translators: Tooltip or description for the menu item. - _("Generate speech using Gemini TTS"), + self.dialog = NativeSpeechDialog(gui.mainFrame) + self.dialog.Bind(wx.EVT_CLOSE, self.onDialogClose) + self.dialog.Show() + except Exception as error: + log.error(f"Error showing NativeSpeechDialog: {error}", exc_info=True) + wx.CallAfter( + wx.MessageBox, + # Translators: Error shown when the main add-on dialog cannot be opened. + _("Failed to open Native Speech Generation dialog: {error}").format(error=str(error)), + _("Error"), + wx.OK | wx.ICON_ERROR, ) - gui.mainFrame.sysTrayIcon.Bind(wx.EVT_MENU, self.onShowDialog, self.menuItem) - @script( - description=_("Open the Native Speech Generation dialog"), - category=_("Native Speech Generation"), - gesture="kb:NVDA+Control+Shift+G", + def _showDependenciesPendingMessage(self) -> None: + wx.CallAfter( + wx.MessageBox, + _( + # Translators: Message shown when dependency installation is still pending. + "Native Speech Generation is installing dependencies. " + "Please restart NVDA for the changes to take effect.", + ), + # Translators: Title of the information dialog recommending an NVDA restart. + _("Restart Required"), + wx.OK | wx.ICON_INFORMATION, ) - def script_openDialog(self, gesture: Any) -> None: - self._openDialog() - - def onShowDialog(self, evt: wx.Event) -> None: - wx.CallAfter(self._openDialog) - - def _openDialog(self) -> None: - if self.dialog and self.dialog.IsShown(): - wx.CallAfter( - wx.MessageBox, - _( - "The Native Speech Generation add-on is already open. Please close the dialog before opening it again.", - ), - # Translators: Title of warning dialog when user tries to open the add-on twice. - _("Add-on Already Running"), - wx.OK | wx.ICON_WARNING, - ) - return - try: - self.dialog = NativeSpeechDialog(gui.mainFrame) - self.dialog.Bind(wx.EVT_CLOSE, self.onDialogClose) - self.dialog.Show() - except Exception as e: - log.error(f"Error showing NativeSpeechDialog: {e}", exc_info=True) - wx.CallAfter( - wx.MessageBox, - _("Failed to open Native Speech Generation dialog: {error}").format(error=str(e)), - _("Error"), - wx.OK | wx.ICON_ERROR, - ) - - def onDialogClose(self, event: wx.Event) -> None: - self.dialog = None - event.Skip() - - def terminate(self) -> None: - if NativeSpeechSettingsPanel in gui.settingsDialogs.NVDASettingsDialog.categoryClasses: - gui.settingsDialogs.NVDASettingsDialog.categoryClasses.remove(NativeSpeechSettingsPanel) + + def onDialogClose(self, event: wx.Event) -> None: + self.dialog = None + event.Skip() + + def terminate(self) -> None: + if ( + self._settingsPanelClass is not None + and self._settingsPanelClass in gui.settingsDialogs.NVDASettingsDialog.categoryClasses + ): + gui.settingsDialogs.NVDASettingsDialog.categoryClasses.remove(self._settingsPanelClass) + if self.menuItem is not None: try: gui.mainFrame.sysTrayIcon.toolsMenu.Remove(self.menuItem) except Exception as error: log.debug(f"Failed to remove Native Speech Generation menu item: {error}", exc_info=True) - super().terminate() + super().terminate() diff --git a/addon/globalPlugins/NativeSpeechGeneration/core/audio_utils.py b/addon/globalPlugins/NativeSpeechGeneration/core/audio_utils.py index 8f18213..0e61582 100644 --- a/addon/globalPlugins/NativeSpeechGeneration/core/audio_utils.py +++ b/addon/globalPlugins/NativeSpeechGeneration/core/audio_utils.py @@ -6,7 +6,7 @@ from logHandler import log import wx import addonHandler -from typing import TYPE_CHECKING +from typing import Any, TYPE_CHECKING if TYPE_CHECKING: @@ -76,11 +76,12 @@ def mergeWavFiles(inputPaths: list[str], outputPath: str) -> None: with wave.open(inputPaths[0], "rb") as w0: params = w0.getparams() + formatParams = _getWavFormatParams(params) frames = [w0.readframes(w0.getnframes())] for p in inputPaths[1:]: with wave.open(p, "rb") as wi: - if wi.getparams() != params: + if _getWavFormatParams(wi.getparams()) != formatParams: raise ValueError("WAV files have different parameters; cannot merge safely.") frames.append(wi.readframes(wi.getnframes())) @@ -91,6 +92,11 @@ def mergeWavFiles(inputPaths: list[str], outputPath: str) -> None: log.info(f"Merged {len(inputPaths)} WAV files -> {outputPath}") +def _getWavFormatParams(params: Any) -> tuple[int, int, int, str, str]: + """Return WAV parameters that must match for safe concatenation.""" + return params.nchannels, params.sampwidth, params.framerate, params.comptype, params.compname + + def saveBinaryFile(fileName: str, data: bytes) -> None: """Writes binary data to a file, creating parent directories if necessary.""" os.makedirs(os.path.dirname(fileName) or ".", exist_ok=True) diff --git a/addon/globalPlugins/NativeSpeechGeneration/interface/generation_dialog.py b/addon/globalPlugins/NativeSpeechGeneration/interface/generation_dialog.py index 6fa64d9..0930fbc 100644 --- a/addon/globalPlugins/NativeSpeechGeneration/interface/generation_dialog.py +++ b/addon/globalPlugins/NativeSpeechGeneration/interface/generation_dialog.py @@ -41,6 +41,8 @@ def _(msg: str) -> str: @dataclass(frozen=True) class GenerationRequest: + """Snapshot of user-selected generation options safe to pass to a worker thread.""" + apiKey: str text: str model: str @@ -54,6 +56,8 @@ class GenerationRequest: class NativeSpeechDialog(wx.Dialog): + """Main dialog for generating Gemini TTS audio from user-provided text.""" + def __init__(self, parent: wx.Window) -> None: # Translators: The title of the main dialog window for generating speech. super().__init__(parent, title=_("Native Speech Generation (Gemini TTS)")) @@ -69,6 +73,7 @@ def __init__(self, parent: wx.Window) -> None: self.client = None self.currentStream = None self.isClosed = False + self._modelDescriptionAnnouncementId = 0 self._buildUi() threading.Thread(target=self.loadVoices, daemon=True).start() @@ -86,7 +91,7 @@ def _getModelOptions(self) -> list[tuple[str, str, str]]: ( FLASH_25_MODEL, # Translators: Model option for the older Gemini Flash text-to-speech preview. - _("Flash 2.5 (Standard Quality)"), + _("Flash 2.5"), # Translators: Description for the Gemini Flash 2.5 model. _("Standard quality, responsive speech generation."), ), @@ -119,10 +124,12 @@ def _buildUi(self) -> None: # Translators: Label for selecting the AI model to use for generation. modelLabel = wx.StaticText(self, label=_("Select &Model:")) self.modelChoice = wx.Choice( - self, choices=[label for _model, label, _description in self.modelOptions] + self, + choices=[label for _model, label, _description in self.modelOptions], ) self.modelChoice.SetSelection(0) self.modelChoice.Bind(wx.EVT_CHOICE, self.onModelChange) + self.modelChoice.Bind(wx.EVT_SET_FOCUS, self.onModelFocus) modelSizer.Add(modelLabel, flag=wx.ALIGN_CENTER_VERTICAL | wx.ALL, border=6) modelSizer.Add(self.modelChoice, flag=wx.ALL, border=6) @@ -212,6 +219,7 @@ def _buildUi(self) -> None: footerSizer.Add(self.viewVoicesBtn, flag=wx.ALL, border=6) mainSizer.Add(footerSizer, flag=wx.ALIGN_CENTER | wx.ALL, border=5) + # Translators: Button to close the Native Speech Generation dialog. self.closeBtn = wx.Button(self, wx.ID_CANCEL, _("&Close")) mainSizer.Add(self.closeBtn, flag=wx.ALIGN_CENTER | wx.ALL, border=5) @@ -231,6 +239,7 @@ def _buildVoicePanelSingle(self) -> wx.Panel: sizer = wx.BoxSizer(wx.HORIZONTAL) # Translators: Label for selecting a voice in single speaker mode. label = wx.StaticText(panel, label=_("Select &Voice:")) + # Translators: Temporary item shown while the add-on prepares the voice list. self.voiceChoiceSingle = wx.Choice(panel, choices=[_("Loading voices...")]) self.voiceChoiceSingle.SetSelection(0) self.voiceChoiceSingle.Bind(wx.EVT_CHOICE, self.onVoiceChange) @@ -246,9 +255,13 @@ def _buildVoicePanelMulti(self) -> wx.Panel: sizer = wx.BoxSizer(wx.VERTICAL) spk1Sizer = wx.BoxSizer(wx.HORIZONTAL) + # Translators: Label for entering the first speaker name in multi-speaker mode. spk1Label = wx.StaticText(panel, label=_("Speaker 1 Name:")) + # Translators: Default name for the first speaker in multi-speaker mode. self.spk1NameCtrl = wx.TextCtrl(panel, value=_("Speaker1"), size=(100, -1)) + # Translators: Label for choosing the first speaker voice in multi-speaker mode. voice1Label = wx.StaticText(panel, label=_("Voice:")) + # Translators: Temporary item shown while the add-on prepares the voice list. self.voiceChoiceMulti1 = wx.Choice(panel, choices=[_("Loading voices...")]) self.voiceChoiceMulti1.SetSelection(0) self.voiceChoiceMulti1.Bind(wx.EVT_CHOICE, self.onVoiceChange) @@ -261,9 +274,13 @@ def _buildVoicePanelMulti(self) -> wx.Panel: sizer.Add(spk1Sizer, flag=wx.EXPAND | wx.ALL, border=6) spk2Sizer = wx.BoxSizer(wx.HORIZONTAL) + # Translators: Label for entering the second speaker name in multi-speaker mode. spk2Label = wx.StaticText(panel, label=_("Speaker 2 Name:")) + # Translators: Default name for the second speaker in multi-speaker mode. self.spk2NameCtrl = wx.TextCtrl(panel, value=_("Speaker2"), size=(100, -1)) + # Translators: Label for choosing the second speaker voice in multi-speaker mode. voice2Label = wx.StaticText(panel, label=_("Voice:")) + # Translators: Temporary item shown while the add-on prepares the voice list. self.voiceChoiceMulti2 = wx.Choice(panel, choices=[_("Loading voices...")]) self.voiceChoiceMulti2.SetSelection(0) self.voiceChoiceMulti2.Bind(wx.EVT_CHOICE, self.onVoiceChange2) @@ -320,14 +337,44 @@ def onToggleSettings(self, evt: wx.Event) -> None: self.Fit() def onModelChange(self, evt: wx.Event) -> None: + self._updateModelDescription(announce=True) + + def onModelFocus(self, evt: wx.Event) -> None: + self._announceModelDescription(self._getSelectedModelDescription()) + evt.Skip() + + def _updateModelDescription(self, *, announce: bool) -> None: sel = self.modelChoice.GetSelection() if sel == wx.NOT_FOUND: sel = 0 self.model = self.modelOptions[sel][0] - self.modelDescriptionLabel.SetLabel(self.modelOptions[sel][2]) + description = self.modelOptions[sel][2] + self.modelDescriptionLabel.SetLabel(description) self.modelDescriptionLabel.Wrap(520) self.GetSizer().Layout() self.Fit() + if announce: + self._announceModelDescription(description) + + def _getSelectedModelDescription(self) -> str: + sel = self.modelChoice.GetSelection() + if sel == wx.NOT_FOUND: + sel = 0 + return self.modelOptions[sel][2] + + def _announceModelDescription(self, description: str) -> None: + description = description.strip() + if not description: + return + self._modelDescriptionAnnouncementId += 1 + announcementId = self._modelDescriptionAnnouncementId + + def announce() -> None: + if self.isClosed or announcementId != self._modelDescriptionAnnouncementId: + return + ui.message(description) + + wx.CallLater(120, announce) def onModeChange(self, evt: wx.Event) -> None: self.modeMulti = self.modeMultiRb.GetValue() @@ -387,6 +434,7 @@ def _showApiKeyUnavailableMessage(self, resolution: config_store.ApiKeyResolutio "No Gemini API key is configured. Set it in NVDA settings, or define {envVarName} " "in the environment.", ).format(envVarName=config_store.API_KEY_ENV_VAR) + # Translators: Title of an API key configuration error dialog. wx.CallAfter(wx.MessageBox, message, _("Error"), wx.OK | wx.ICON_ERROR) def onSettings(self, evt: wx.Event) -> None: @@ -405,6 +453,7 @@ def onTalkWithAi(self, evt: wx.Event) -> None: if not talkWithAI: wx.CallAfter( wx.MessageBox, + # Translators: Error shown if the optional Talk With AI dialog module cannot be loaded. _("Talk With AI module is missing."), _("Error"), wx.OK | wx.ICON_ERROR, @@ -415,6 +464,7 @@ def onTalkWithAi(self, evt: wx.Event) -> None: wx.CallAfter( wx.MessageBox, _( + # Translators: Warning shown when Talk With AI is opened while multi-speaker mode is selected. "Talk With AI currently does not support multi-speaker mode. Please select Single-speaker.", ), _("Feature Limitation"), @@ -456,7 +506,9 @@ def _buildGenerationRequest(self, text: str, apiKey: str) -> GenerationRequest: modeMulti=self.modeMulti, voiceName=self._getSelectedVoiceName(primaryVoiceCtrl, primaryVoiceIdx), voiceName2=self._getSelectedVoiceName(self.voiceChoiceMulti2, secondaryVoiceIdx), + # Translators: Fallback speaker name used when the first speaker field is blank. speaker1Name=self.spk1NameCtrl.GetValue().strip() or _("Speaker1"), + # Translators: Fallback speaker name used when the second speaker field is blank. speaker2Name=self.spk2NameCtrl.GetValue().strip() or _("Speaker2"), ) @@ -465,9 +517,11 @@ def onGenerate(self, evt: wx.Event) -> None: return if not GENAI_AVAILABLE: message = _( + # Translators: Error shown when the bundled google-genai dependency cannot be imported. "google-genai is not available. Please restart NVDA after updating the add-on libraries.", ) if GENAI_IMPORT_ERROR: + # Translators: Error details appended to a dependency import failure. message = _("{baseMessage}\n\nImport detail: {errorDetail}").format( baseMessage=message, errorDetail=GENAI_IMPORT_ERROR, @@ -486,6 +540,7 @@ def onGenerate(self, evt: wx.Event) -> None: if not text: wx.CallAfter( wx.MessageBox, + # Translators: Error shown when the user tries to generate speech without entering text. _("Please enter text to generate."), _("Error"), wx.OK | wx.ICON_ERROR, @@ -494,6 +549,7 @@ def onGenerate(self, evt: wx.Event) -> None: generationRequest = self._buildGenerationRequest(text, apiKey) self.isGenerating = True + # Translators: Temporary Generate button label while speech generation is running. self.generateBtn.SetLabel(_("Generating...")) self.playBtn.Enable(False) self.saveBtn.Enable(False) @@ -501,6 +557,7 @@ def onGenerate(self, evt: wx.Event) -> None: threading.Thread(target=self._generateThread, args=(generationRequest,), daemon=True).start() def _generateThread(self, generationRequest: GenerationRequest) -> None: + # Translators: Status announcement made when speech generation starts. ui.message(_("Generating speech, please wait...")) try: with getRuntimeScope(): @@ -521,8 +578,10 @@ def handleSuccess(savedPath: str | None) -> None: if self.isClosed: return if not savedPath: + # Translators: Status announcement made when speech generation fails. ui.message(_("Failed to generate audio.")) return + # Translators: Status announcement made when speech generation succeeds. ui.message(_("Generation complete.")) self.lastAudioPath = savedPath safeStartFile(self.lastAudioPath) @@ -542,7 +601,7 @@ def handleSuccess(savedPath: str | None) -> None: speechConfig = types.SpeechConfig( voice_config=types.VoiceConfig( prebuilt_voice_config=types.PrebuiltVoiceConfig( - voice_name=generationRequest.voiceName + voice_name=generationRequest.voiceName, ), ), ) @@ -596,6 +655,7 @@ def handleSuccess(savedPath: str | None) -> None: except Exception as e: if self.isClosed: return + # Translators: Status announcement made when speech generation raises an unexpected error. ui.message(_("An error occurred during generation.")) log.error(f"Unexpected error in generateThread: {e}", exc_info=True) wx.CallAfter( @@ -609,6 +669,7 @@ def handleSuccess(savedPath: str | None) -> None: wx.CallAfter(self._restoreGenerateButton) def _restoreGenerateButton(self) -> None: + # Translators: Button label restored after speech generation finishes. self.generateBtn.SetLabel(_("&Generate Speech")) self.talkBtn.Enable(True) self.isGenerating = False @@ -664,7 +725,9 @@ def _streamAndSaveAudio( for part in self._iterResponseParts(chunk): if not getattr(part, "inline_data", None) or not getattr( - part.inline_data, "data", None + part.inline_data, + "data", + None, ): continue inline = part.inline_data @@ -690,14 +753,16 @@ def _streamAndSaveAudio( ) return None - if len(savedPaths) > 1 and all(p.lower().endswith(".wav") for p in savedPaths): + if len(savedPaths) > 1 and self._shouldMergeAudioChunks(model, savedPaths): outAll = f"{outPathBase}_combined.wav" try: mergeWavFiles(savedPaths, outAll) return outAll except Exception as e: log.error(f"Failed to merge WAV parts: {e}", exc_info=True) - return savedPaths[0] + return self._selectBestGeneratedAudioPath(savedPaths) + if len(savedPaths) > 1: + return self._selectBestGeneratedAudioPath(savedPaths) return savedPaths[0] except Exception as e: @@ -714,6 +779,14 @@ def _streamAndSaveAudio( finally: self.currentStream = None + def _shouldMergeAudioChunks(self, model: str, savedPaths: list[str]) -> bool: + """Return whether streamed audio parts should be concatenated.""" + return model == DEFAULT_MODEL and all(path.lower().endswith(".wav") for path in savedPaths) + + def _selectBestGeneratedAudioPath(self, savedPaths: list[str]) -> str: + """Choose the most complete single audio file when stream chunks overlap.""" + return max(savedPaths, key=os.path.getsize) + def onPlay(self, evt: wx.Event) -> None: if not self.lastAudioPath or not os.path.exists(self.lastAudioPath): return diff --git a/addon/globalPlugins/NativeSpeechGeneration/interface/settings.py b/addon/globalPlugins/NativeSpeechGeneration/interface/settings.py index 2719639..dbe23ae 100644 --- a/addon/globalPlugins/NativeSpeechGeneration/interface/settings.py +++ b/addon/globalPlugins/NativeSpeechGeneration/interface/settings.py @@ -19,6 +19,8 @@ def _(msg: str) -> str: class NativeSpeechSettingsPanel(gui.settingsDialogs.SettingsPanel): + """Settings panel for API key storage and dependency maintenance.""" + # Translators: Title of the settings panel in NVDA preferences. title = _("Native Speech Generation") @@ -120,6 +122,7 @@ def onToggleApiVisibility(self, event: wx.Event | None) -> None: targetCtrl.SetFocus() def onGetKey(self, evt: wx.Event) -> None: + """Open Google AI Studio in the user's default browser.""" webbrowser.open("https://aistudio.google.com/apikey") def _getCurrentApiKeyFieldValue(self) -> str: diff --git a/addon/globalPlugins/NativeSpeechGeneration/lib_updater.py b/addon/globalPlugins/NativeSpeechGeneration/lib_updater.py index 5c1ba16..8d1ecfb 100644 --- a/addon/globalPlugins/NativeSpeechGeneration/lib_updater.py +++ b/addon/globalPlugins/NativeSpeechGeneration/lib_updater.py @@ -26,11 +26,10 @@ LIBRARY_RELEASE_DOWNLOAD_BASE = ( "https://github.com/muhammadGagah/python-library-add-on-Native-Speech-Generation/releases/download" ) -APPROVED_LIBRARY_VERSION = "1.70.0" +APPROVED_LIBRARY_VERSION = "2.2.0" APPROVED_LIBRARY_SHA256 = { - "lib.zip": "8F09EEFBD099067CAF7A977A9D93B109B641ED3CAB9DC8F58B751EA13DAE9555", - # Fill this after publishing a release that contains lib64.zip. - "lib64.zip": "", + "lib.zip": "96140636befa9880fbe48efc309f71f6057e80f48a7e58299d9657287df76d90", + "lib64.zip": "f8082c18d503454728b8d7ab97dbc407cd74c8ee086f6e2a6fde27dff9945b37", } NVDA_2026_RUNTIME_VERSION = (2026, 1, 0) USER_AGENT = "NativeSpeechGeneration-NVDA-Addon" @@ -93,6 +92,7 @@ def getCurrentNvdaVersionText() -> str: def getRuntimeAssetName(versionText: str | None = None) -> str: + """Return the dependency archive name for the running NVDA runtime.""" if versionText is None: versionText = getCurrentNvdaVersionText() nvdaVersion = parseNvdaVersion(versionText) @@ -107,12 +107,13 @@ def getRuntimeAssetName(versionText: str | None = None) -> str: def getApprovedLibraryAsset(assetName: str | None = None) -> LibraryAsset: + """Return the pinned dependency archive approved for stable add-on releases.""" if assetName is None: assetName = getRuntimeAssetName() sha256 = APPROVED_LIBRARY_SHA256.get(assetName, "").strip() if not sha256: raise LibraryUpdateError( - # Translators: Error shown when this add-on does not include trusted checksum metadata for a library. + # Translators: Error shown when this add-on has no trusted checksum for a dependency archive. _("No approved checksum is bundled for {assetName}.").format(assetName=assetName), ) return LibraryAsset( @@ -125,6 +126,7 @@ def getApprovedLibraryAsset(assetName: str | None = None) -> LibraryAsset: def getLatestVerifiedLibraryAsset(assetName: str | None = None) -> LibraryAsset: + """Return a checksum-verified asset from the latest GitHub release.""" if assetName is None: assetName = getRuntimeAssetName() release = _readJsonUrl(LIBRARY_RELEASE_API_URL) @@ -135,24 +137,29 @@ def getLatestVerifiedLibraryAsset(assetName: str | None = None) -> LibraryAsset: _("The latest library release does not include a version tag."), ) asset = _findReleaseAsset(release, assetName) - checksum = _findReleaseChecksum(release, assetName) + checksum = _findReleaseChecksum(release, assetName, asset) return LibraryAsset( version=version, name=assetName, url=str(asset["browser_download_url"]), sha256=checksum, - source="latest", + source="github", ) +getVerifiedLibraryAsset = getLatestVerifiedLibraryAsset + + def downloadAndExtract( _addonDir: str, progressCallback: Callable[[int, str], None], *, forceLatest: bool = False, ) -> bool: - """Download, verify, and install the dependency library for this NVDA runtime.""" + """Download, verify, and install dependency libraries for this NVDA runtime.""" try: + if forceLatest: + log.info("lib_updater: User requested dependency reinstall from the latest verified release.") asset = _resolveLibraryAsset(forceLatest=forceLatest) _installLibraryAsset(asset, progressCallback) return True @@ -204,12 +211,15 @@ def finalMessage() -> None: # Translators: Message shown after dependencies are installed and NVDA must restart. "The Native Speech Generation libraries have been successfully installed/updated.\n\nPlease restart NVDA for the changes to take effect.", ) + # Translators: Title of the dialog shown after dependency installation completes. title = _("Installation Complete") res = wx.MessageBox(message, title, wx.OK | wx.ICON_INFORMATION) if res == wx.OK: core.restart() else: + # Translators: Error shown when dependency installation fails. message = _("Library installation failed. Please check the log.") + # Translators: Title of a dependency installation error dialog. title = _("Error") wx.CallAfter(wx.MessageBox, message, title, wx.OK | wx.ICON_ERROR) @@ -220,15 +230,17 @@ def finalMessage() -> None: def confirmAction() -> None: if forceReinstall: msg = _( - # Translators: Confirmation before reinstalling or updating external Python dependencies. + # Translators: Confirmation before reinstalling or updating external Python dependencies from GitHub. "This will download the latest verified libraries for your NVDA version and require an NVDA restart. Continue?", ) + # Translators: Title of the dialog confirming a dependency library reinstall. title = _("Confirm Library Update") else: msg = _( # Translators: Confirmation shown when required libraries are missing. - "Required libraries for Native Speech Generation are missing. Click OK to download the verified package for your NVDA version.", + "Required libraries for Native Speech Generation are missing. Click OK to download the latest verified package for your NVDA version.", ) + # Translators: Title of the dialog shown when dependency libraries are missing. title = _("Missing Dependencies") res = wx.MessageBox(msg, title, wx.OK | wx.CANCEL | wx.ICON_INFORMATION) @@ -241,58 +253,22 @@ def confirmAction() -> None: def reinstallDependencies() -> None: - """Public wrapper to install the latest verified dependency package.""" + """Public wrapper to reinstall the latest verified dependency package.""" checkAndInstallDependencies(forceReinstall=True) -def _resolveLibraryAsset(*, forceLatest: bool) -> LibraryAsset: +def _resolveLibraryAsset(*, forceLatest: bool = False) -> LibraryAsset: assetName = getRuntimeAssetName() - if forceLatest: - try: - return getLatestVerifiedLibraryAsset(assetName) - except Exception as latestError: - log.warning(f"lib_updater: Latest verified library lookup failed: {latestError}", exc_info=True) - if _askInstallApprovedFallback(assetName, latestError): - return getApprovedLibraryAsset(assetName) - raise - try: - return getApprovedLibraryAsset(assetName) - except Exception as approvedError: - log.warning(f"lib_updater: Approved library metadata unavailable: {approvedError}", exc_info=True) return getLatestVerifiedLibraryAsset(assetName) - - -def _askInstallApprovedFallback(assetName: str, latestError: BaseException) -> bool: - try: - getApprovedLibraryAsset(assetName) - except Exception: - return False - message = _( - # Translators: Question shown when the latest dependency release cannot be verified. - "The latest library package could not be verified for {assetName}.\n\nError: {error}\n\nInstall the bundled approved library version instead?", - ).format( - assetName=assetName, - error=latestError, - ) - return _askUserYesNo(message, _("Use Approved Libraries")) - - -def _askUserYesNo(message: str, title: str) -> bool: - answer = {"value": False} - ready = threading.Event() - - def ask() -> None: - try: - answer["value"] = ( - wx.MessageBox(message, title, wx.YES_NO | wx.NO_DEFAULT | wx.ICON_WARNING) == wx.YES - ) - finally: - ready.set() - - wx.CallAfter(ask) - ready.wait() - return answer["value"] + except Exception as error: + if forceLatest: + raise + log.warning( + f"lib_updater: Could not resolve latest verified {assetName}; falling back to approved release: {error}", + exc_info=True, + ) + return getApprovedLibraryAsset(assetName) def _installLibraryAsset( @@ -493,7 +469,7 @@ def _findReleaseAsset(release: dict[str, Any], assetName: str) -> dict[str, Any] ) -def _findReleaseChecksum(release: dict[str, Any], assetName: str) -> str: +def _findReleaseChecksum(release: dict[str, Any], assetName: str, asset: dict[str, Any]) -> str: checksumAssetNames = (f"{assetName}.sha256", "checksums.txt") for checksumAssetName in checksumAssetNames: checksumAsset = _findOptionalReleaseAsset(release, checksumAssetName) @@ -507,6 +483,9 @@ def _findReleaseChecksum(release: dict[str, Any], assetName: str) -> str: ) if checksum: return checksum + checksum = _parseReleaseAssetDigest(asset) + if checksum: + return checksum raise LibraryUpdateError( # Translators: Error shown when a dependency release lacks checksum metadata. _("The latest library release does not include a checksum for {assetName}.").format( @@ -522,6 +501,16 @@ def _findOptionalReleaseAsset(release: dict[str, Any], assetName: str) -> dict[s return None +def _parseReleaseAssetDigest(asset: dict[str, Any]) -> str: + digest = str(asset.get("digest") or "") + if not digest.lower().startswith("sha256:"): + return "" + checksum = digest.split(":", 1)[1].strip() + if SHA256_RE.fullmatch(checksum) is None: + return "" + return checksum + + def _parseChecksumText(checksumText: str, assetName: str, *, allowFallback: bool) -> str: fallback = "" for line in checksumText.splitlines(): diff --git a/addon/globalPlugins/NativeSpeechGeneration/talkWithAI.py b/addon/globalPlugins/NativeSpeechGeneration/talkWithAI.py index f80ec71..ba8b5e1 100644 --- a/addon/globalPlugins/NativeSpeechGeneration/talkWithAI.py +++ b/addon/globalPlugins/NativeSpeechGeneration/talkWithAI.py @@ -51,6 +51,8 @@ class TalkWithAIRuntimeError(RuntimeError): + """Raised when the Live API runtime does not support the required feature set.""" + pass @@ -164,15 +166,18 @@ def _buildUi(self) -> None: panel = wx.Panel(self) panelSizer = wx.BoxSizer(wx.VERTICAL) + # Translators: Group label for connection status in the Talk With AI dialog. statusBox = wx.StaticBox(panel, label=_("Status")) statusSizer = wx.StaticBoxSizer(statusBox, wx.VERTICAL) self.statusLabel = wx.StaticText( panel, + # Translators: Status label. {status} is replaced with the current Talk With AI status. label=_("Status: {status}").format(status=_("Ready to Connect")), ) statusSizer.Add(self.statusLabel, 0, wx.ALL | wx.EXPAND, 5) panelSizer.Add(statusSizer, 0, wx.ALL | wx.EXPAND, 5) + # Translators: Group label for Talk With AI controls. controlsBox = wx.StaticBox(panel, label=_("Controls")) controlsSizer = wx.StaticBoxSizer(controlsBox, wx.VERTICAL) @@ -198,6 +203,7 @@ def _buildUi(self) -> None: self.deviceSizer = wx.BoxSizer(wx.VERTICAL) inputSizer = wx.BoxSizer(wx.HORIZONTAL) + # Translators: Label for selecting the microphone input device. inputLabel = wx.StaticText(panel, label=_("Microphone:")) inputChoices = [device["name"] for device in self.inputDevices] self.inputChoice = wx.Choice(panel, choices=inputChoices) @@ -208,6 +214,7 @@ def _buildUi(self) -> None: self.deviceSizer.Add(inputSizer, 0, wx.ALL | wx.EXPAND, 5) outputSizer = wx.BoxSizer(wx.HORIZONTAL) + # Translators: Label for selecting the speaker output device. outputLabel = wx.StaticText(panel, label=_("Speaker:")) outputChoices = [device["name"] for device in self.outputDevices] self.outputChoice = wx.Choice(panel, choices=outputChoices) @@ -234,6 +241,7 @@ def _buildUi(self) -> None: controlsSizer.Add(thinkingSizer, 0, wx.ALL | wx.EXPAND, 5) volSizer = wx.BoxSizer(wx.HORIZONTAL) + # Translators: Label for the Talk With AI playback volume slider. volLabel = wx.StaticText(panel, label=_("Volume:")) self.volSlider = wx.Slider(panel, value=self.volume, minValue=0, maxValue=100, style=wx.SL_HORIZONTAL) self.volSlider.Bind(wx.EVT_SLIDER, self.onVolumeChange) @@ -245,6 +253,7 @@ def _buildUi(self) -> None: infoLabel = wx.StaticText( panel, + # Translators: Informational label. {voiceName} is replaced with the selected Gemini voice. label=_("Voice: {voiceName}").format(voiceName=str(self.voiceName)), ) panelSizer.Add(infoLabel, 0, wx.ALL | wx.ALIGN_CENTER_HORIZONTAL, 10) @@ -269,6 +278,7 @@ def _announceStatus(self, text: str, force: bool = False) -> None: def updateStatus(self, text: str, announce: bool = False, forceAnnouncement: bool = False) -> None: try: if self: + # Translators: Status label. {status} is replaced with the current Talk With AI status. self.statusLabel.SetLabel(_("Status: {status}").format(status=text)) except RuntimeError: return @@ -278,13 +288,16 @@ def updateStatus(self, text: str, announce: bool = False, forceAnnouncement: boo def reportError(self, msg: object) -> None: try: if self: + # Translators: Title of an error dialog in Talk With AI. wx.MessageBox(str(msg), _("Error"), wx.OK | wx.ICON_ERROR) + # Translators: Status shown when Talk With AI enters an error state. self.updateStatus(_("Error")) except RuntimeError: return def onMicToggle(self, evt: wx.Event) -> None: self.micOn = self.micBtn.GetValue() + # Translators: Toggle button label indicating microphone state. label = _("Microphone: ON") if self.micOn else _("Microphone: OFF") self.micBtn.SetLabel(label) @@ -304,6 +317,7 @@ def _clearSessionHistory(self) -> None: def _buildMissingDependencyMessage(self, baseMessage: str, errorDetail: str | None) -> str: if not errorDetail: return baseMessage + # Translators: Dependency error detail. {baseMessage} is the main error and {errorDetail} is the import exception. return _("{baseMessage}\n\nImport detail: {errorDetail}").format( baseMessage=baseMessage, errorDetail=errorDetail, @@ -391,12 +405,14 @@ def _getRuntimeCompatibilityError(self) -> str | None: version = VENDOR_VERSIONS.get("google.genai", "") if version: return _( + # Translators: Dependency compatibility error for Talk With AI. "Installed google-genai library ({version}) does not support the Gemini 3.1 Live API features required by Talk With AI. Missing: {missing}. Please update the add-on libraries.", ).format( version=version, missing=", ".join(missing), ) return _( + # Translators: Dependency compatibility error for Talk With AI when no library version is known. "Installed google-genai library does not support the Gemini 3.1 Live API features required by Talk With AI. Missing: {missing}. Please update the add-on libraries.", ).format( missing=", ".join(missing), @@ -438,6 +454,7 @@ def onConnect(self, evt: wx.Event) -> None: nestedWindow.Hide() self.Layout() + # Translators: Status shown while Talk With AI is connecting. self.updateStatus(_("Connecting..."), announce=True) self.sessionActive = True @@ -446,6 +463,7 @@ def onConnect(self, evt: wx.Event) -> None: def onDisconnect(self, evt: wx.Event) -> None: self.disconnectBtn.Disable() + # Translators: Status shown while Talk With AI is disconnecting. self.updateStatus(_("Disconnecting..."), announce=True) if self.loop and self.loop.is_running(): asyncio.run_coroutine_threadsafe(self.cleanupAsync(), self.loop) @@ -604,6 +622,7 @@ def _audioPlayerWorker(self) -> None: def _buildLiveConfig(self, includeHistorySeed: bool) -> Any: if not types: + # Translators: Error shown when google-genai type helpers are missing. raise TalkWithAIRuntimeError(_("Google GenAI types are not available.")) try: with getRuntimeScope(): @@ -627,6 +646,7 @@ def _buildLiveConfig(self, includeHistorySeed: bool) -> Any: ) except Exception as error: raise TalkWithAIRuntimeError( + # Translators: Error shown when Live API configuration cannot be built. _("Failed to prepare the Gemini Live configuration. Please update the add-on libraries."), ) from error @@ -637,6 +657,7 @@ def _assertSessionCompatibility(self, session: Any) -> None: if version: raise TalkWithAIRuntimeError( _( + # Translators: Dependency compatibility error for Talk With AI. "The installed google-genai library ({version}) is too old for Gemini 3.1 Live sessions. Missing session method: {methodName}. Please update the add-on libraries.", ).format( version=version, @@ -645,6 +666,7 @@ def _assertSessionCompatibility(self, session: Any) -> None: ) raise TalkWithAIRuntimeError( _( + # Translators: Dependency compatibility error for Talk With AI when no library version is known. "The installed google-genai library is too old for Gemini 3.1 Live sessions. Missing session method: {methodName}. Please update the add-on libraries.", ).format( methodName=methodName, @@ -796,6 +818,7 @@ async def runSession(self) -> None: retryAttempt = 0 if firstConnect: + # Translators: Status shown when Talk With AI connects successfully. wx.CallAfter(self.updateStatus, _("Connected"), True) self._playSoundEffect(STREAM_START_SOUND_PATH) firstConnect = False @@ -843,6 +866,7 @@ async def runSession(self) -> None: if now - self.lastStatusAt > 1.0: wx.CallAfter( self.updateStatus, + # Translators: Status shown while Talk With AI waits before reconnecting. _("Connection lost. Retrying in {seconds:.1f}s").format(seconds=delay), True, ) @@ -897,6 +921,7 @@ def resetUi(self) -> None: nestedWindow.Show() self.Layout() + # Translators: Status shown when Talk With AI is ready for a new session. self.updateStatus(_("Ready"), announce=True) except RuntimeError: pass diff --git a/addon/locale/id/LC_MESSAGES/nvda.po b/addon/locale/id/LC_MESSAGES/nvda.po index 43cc40a..aa5940a 100644 --- a/addon/locale/id/LC_MESSAGES/nvda.po +++ b/addon/locale/id/LC_MESSAGES/nvda.po @@ -3,13 +3,12 @@ # This file is distributed under the same license as the 'NativeSpeechGeneration' package. # FIRST AUTHOR , YEAR. # -#, fuzzy msgid "" msgstr "" "Project-Id-Version: 'NativeSpeechGeneration' '1.7.0'\n" "Report-Msgid-Bugs-To: 'nvda-translations@groups.io'\n" -"POT-Creation-Date: 2026-04-04 16:18+0800\n" -"PO-Revision-Date: 2026-04-04 16:45+0800\n" +"POT-Creation-Date: 2026-05-14 02:02+0700\n" +"PO-Revision-Date: 2026-05-14 02:26+0700\n" "Last-Translator: \n" "Language-Team: \n" "Language: id\n" @@ -18,68 +17,60 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "X-Generator: Poedit 3.9\n" -#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:57 -msgid "Downloading libraries..." -msgstr "Mengunduh library ..." - -#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:71 -msgid "Downloading..." -msgstr "Mengunduh ..." - -#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:76 -msgid "Extracting libraries..." -msgstr "Mengekstraksi library yang diperlukan ..." +#. Translators: Error shown when the GitHub release metadata cannot identify the release version. +#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:135 +msgid "The latest library release does not include a version tag." +msgstr "Rilis library terbaru tidak menyertakan tag versi." -#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:84 -msgid "Extraction complete." -msgstr "Ekstraksi selesai." - -#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:95 +#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:165 #, python-brace-format msgid "" -"Failed to download or extract required libraries. The add-on might not work " +"Failed to download or install required libraries. The add-on might not work " "correctly.\n" "\n" "Error: {error}" msgstr "" -"Gagal mengunduh atau mengekstrak pustaka yang diperlukan. Add-on mungkin " +"Gagal mengunduh atau menginstal library yang diperlukan. Add-on mungkin " "tidak berfungsi dengan benar.\n" "\n" "Kesalahan: {error}" -#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:97 -#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:153 -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:277 -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:278 -#: addon\globalPlugins\NativeSpeechGeneration\__init__.py:131 -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:336 -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:355 -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:388 -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:405 -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:417 -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:440 -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:531 -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:599 -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:621 -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:659 -#: addon\globalPlugins\NativeSpeechGeneration\interface\settings.py:139 -#: addon\globalPlugins\NativeSpeechGeneration\interface\settings.py:191 +#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:167 +#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:213 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:281 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:282 +#: addon\globalPlugins\NativeSpeechGeneration\__init__.py:132 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:390 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:409 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:442 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:478 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:490 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:514 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:604 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:688 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:710 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:748 +#: addon\globalPlugins\NativeSpeechGeneration\interface\settings.py:137 msgid "Error" msgstr "Kesalahan" -#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:117 +#. Translators: Title of a progress dialog shown while installing add-on dependencies. +#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:182 msgid "Installing Dependencies" msgstr "Menginstal dependensi" -#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:118 +#. Translators: Initial progress message while dependency installation is being prepared. +#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:184 msgid "Checking for required libraries..." -msgstr "Memeriksa library yang diperlukan ..." +msgstr "Memeriksa library yang diperlukan..." -#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:127 +#. Translators: Progress message shown when dependency installation has completed. +#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:193 msgid "Installation complete!" msgstr "Instalasi selesai!" -#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:145 +#. Translators: Message shown after dependencies are installed and NVDA must restart. +#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:205 msgid "" "The Native Speech Generation libraries have been successfully installed/" "updated.\n" @@ -90,65 +81,131 @@ msgstr "" "\n" "Silakan mulai ulang NVDA agar perubahan diterapkan." -#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:147 +#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:207 msgid "Installation Complete" msgstr "Instalasi Selesai" -#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:152 +#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:212 msgid "Library installation failed. Please check the log." -msgstr "Instalasi lib gagal. Silakan periksa lognya." +msgstr "Instalasi library gagal. Silakan periksa log." -#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:164 +#. Translators: Confirmation before reinstalling or updating external Python dependencies. +#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:224 msgid "" -"Are you sure you want to reinstall the libraries? This will redownload the " -"dependencies and require an NVDA restart." +"This will download the latest verified libraries for your NVDA version and " +"require an NVDA restart. Continue?" msgstr "" -"Apakah Anda yakin ingin menginstal ulang lib? Ini akan mengunduh ulang " -"dependensi dan memerlukan restart NVDA." +"Ini akan mengunduh library terverifikasi terbaru untuk versi NVDA Anda dan " +"memerlukan NVDA dimulai ulang. Lanjutkan?" -#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:166 -#: addon\globalPlugins\NativeSpeechGeneration\interface\settings.py:159 -msgid "Confirm Reinstall" -msgstr "Konfirmasikan Instal Ulang" +#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:226 +msgid "Confirm Library Update" +msgstr "Konfirmasi Pembaruan Library" -#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:168 +#. Translators: Confirmation shown when required libraries are missing. +#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:230 msgid "" "Required libraries for Native Speech Generation are missing. Click OK to " -"download them." +"download the verified package for your NVDA version." msgstr "" "Library yang diperlukan untuk menjalankan add-on Native Speech Generation " -"tidak ada. Klik OK untuk mengunduhnya." +"tidak ada. Klik OK untuk mengunduh paket terverifikasi sesuai versi NVDA " +"Anda." -#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:169 +#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:232 msgid "Missing Dependencies" -msgstr "Kehilangan dependensi" +msgstr "Dependensi tidak ditemukan" + +#. Translators: Progress message shown while verified libraries are being extracted. +#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:313 +msgid "Extracting libraries..." +msgstr "Mengekstraksi library..." + +#. Translators: Progress message shown while replacing the dependency library folder. +#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:320 +msgid "Installing libraries..." +msgstr "Menginstal library..." + +#. Translators: Progress message shown after library extraction and installation. +#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:328 +msgid "Library installation complete." +msgstr "Pemasangan library selesai." + +#. Translators: Progress message shown before dependency download begins. +#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:349 +msgid "Downloading libraries..." +msgstr "Mengunduh library..." + +#. Translators: Progress message shown while dependency download is in progress. +#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:366 +msgid "Downloading..." +msgstr "Mengunduh..." + +#. Translators: Progress message shown while checking the downloaded dependency package. +#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:372 +msgid "Verifying libraries..." +msgstr "Memverifikasi library..." + +#. Translators: Error shown when a downloaded dependency archive contains an unsafe path. +#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:424 +#, python-brace-format +msgid "The library archive contains an unsafe path: {path}" +msgstr "Arsip library berisi jalur yang tidak aman: {path}" + +#. Translators: Error shown when a downloaded dependency archive would extract outside the target folder. +#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:430 +#, python-brace-format +msgid "" +"The library archive contains a path outside the installation directory: " +"{path}" +msgstr "Arsip library berisi jalur di luar direktori instalasi: {path}" + +#. Translators: Error shown when a downloaded dependency package fails checksum verification. +#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:448 +#, python-brace-format +msgid "Library checksum mismatch. Expected {expected}, got {actual}." +msgstr "" +"Checksum library tidak cocok. Nilai yang diharapkan: {expected}; nilai " +"aktual: {actual}." + +#. Translators: Error shown when a GitHub release lacks the dependency asset needed for this NVDA version. +#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:492 +#, python-brace-format +msgid "The latest library release does not contain {assetName}." +msgstr "Rilis library terbaru tidak berisi {assetName}." + +#. Translators: Error shown when a dependency release lacks checksum metadata. +#: addon\globalPlugins\NativeSpeechGeneration\lib_updater.py:512 +#, python-brace-format +msgid "The latest library release does not include a checksum for {assetName}." +msgstr "Rilis library terbaru tidak menyertakan checksum untuk {assetName}." #. Translators: Title of the dialog for the "Talk With AI" feature (REAL-TIME conversation). -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:59 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:62 msgid "Talk With AI" msgstr "Bicara dengan AI" #. Translators: Choice label for the lowest reasoning setting in Talk With AI. -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:94 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:97 msgid "No Thinking" msgstr "Tanpa Penalaran" #. Translators: Choice label for low reasoning depth in Talk With AI. -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:96 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:99 msgid "Low" msgstr "Rendah" #. Translators: Choice label for medium reasoning depth in Talk With AI. -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:98 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:101 msgid "Medium" msgstr "Sedang" #. Translators: Choice label for high reasoning depth in Talk With AI. -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:100 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:103 msgid "High" msgstr "Tinggi" -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:110 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:113 msgid "" "PyAudio is not available. This feature requires a working PyAudio " "installation." @@ -156,77 +213,77 @@ msgstr "" "PyAudio tidak tersedia. Fitur ini memerlukan instalasi PyAudio yang " "berfungsi." -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:119 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:122 msgid "Google GenAI is not available." msgstr "Google GenAI tidak tersedia." -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:163 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:167 msgid "Status" msgstr "Status" -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:167 -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:268 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:171 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:272 #, python-brace-format msgid "Status: {status}" msgstr "Status: {status}" -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:167 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:171 msgid "Ready to Connect" msgstr "Siap untuk terhubung" -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:172 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:176 msgid "Controls" msgstr "Kontrol" #. Translators: Button to start the voice conversation. -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:177 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:181 msgid "Start Conversation" msgstr "Mulai percakapan" #. Translators: Button to stop the voice conversation. -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:180 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:184 msgid "Stop Conversation" msgstr "Hentikan percakapan" #. Translators: Toggle button label indicating microphone is ON. -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:189 -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:284 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:193 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:288 msgid "Microphone: ON" msgstr "Mikrofon: AKTIF" -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:197 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:201 msgid "Microphone:" msgstr "Mikrofon:" -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:207 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:211 msgid "Speaker:" msgstr "Speaker:" #. Translators: Checkbox to enable grounding with Google Search in Talk With AI. -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:219 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:223 msgid "Grounding with Google Search" msgstr "Grounding dengan Google Search" #. Translators: Label for choosing the reasoning depth in Talk With AI. -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:225 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:229 msgid "Thinking level:" msgstr "Tingkat penalaran:" -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:233 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:237 msgid "Volume:" msgstr "Volume:" -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:244 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:248 #, python-brace-format msgid "Voice: {voiceName}" msgstr "Suara: {voiceName}" -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:284 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:288 msgid "Microphone: OFF" msgstr "Mikrofon: NONAKTIF" -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:303 -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:398 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:307 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:471 #, python-brace-format msgid "" "{baseMessage}\n" @@ -235,120 +292,120 @@ msgid "" msgstr "" "{baseMessage}\n" "\n" -"Import detail: {errorDetail}" +"Detail impor: {errorDetail}" -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:387 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:394 #, python-brace-format msgid "" "Installed google-genai library ({version}) does not support the Gemini 3.1 " "Live API features required by Talk With AI. Missing: {missing}. Please " "update the add-on libraries." msgstr "" -"Pustaka google-genai yang terpasang ({version}) tidak mendukung fitur Gemini " +"Library google-genai yang terpasang ({version}) tidak mendukung fitur Gemini " "3.1 Live API yang diperlukan untuk Bicara dengan AI. Yang tidak tersedia: " -"{missing}. Harap perbarui pustaka add-on." +"{missing}. Harap perbarui library add-on." -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:393 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:400 #, python-brace-format msgid "" "Installed google-genai library does not support the Gemini 3.1 Live API " "features required by Talk With AI. Missing: {missing}. Please update the add-" "on libraries." msgstr "" -"Pustaka google-genai yang terpasang tidak mendukung fitur Gemini 3.1 Live " +"Library google-genai yang terpasang tidak mendukung fitur Gemini 3.1 Live " "API yang diperlukan untuk Bicara dengan AI. Yang tidak tersedia: {missing}. " -"Harap perbarui pustaka add-on." +"Harap perbarui library add-on." -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:434 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:441 msgid "Connecting..." msgstr "Menghubungkan..." -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:442 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:449 msgid "Disconnecting..." msgstr "Memutuskan koneksi..." -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:599 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:607 msgid "Google GenAI types are not available." msgstr "Tipe Google GenAI tidak tersedia." -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:622 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:630 msgid "" "Failed to prepare the Gemini Live configuration. Please update the add-on " "libraries." msgstr "" -"Gagal menyiapkan konfigurasi Gemini Live. Harap perbarui pustaka add-on." +"Gagal menyiapkan konfigurasi Gemini Live. Harap perbarui library add-on." -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:632 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:640 #, python-brace-format msgid "" "The installed google-genai library ({version}) is too old for Gemini 3.1 " "Live sessions. Missing session method: {methodName}. Please update the add-" "on libraries." msgstr "" -"Pustaka google-genai yang terpasang ({version}) terlalu lama untuk sesi " -"Gemini 3.1 Live. Metode sesi yang tidak tersedia: {methodName}. Harap " -"perbarui pustaka add-on." +"Library google-genai yang terpasang ({version}) terlalu usang untuk sesi " +"Gemini 3.1 Live. Metode sesi yang belum tersedia: {methodName}. Harap " +"perbarui library add-on." -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:640 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:648 #, python-brace-format msgid "" "The installed google-genai library is too old for Gemini 3.1 Live sessions. " "Missing session method: {methodName}. Please update the add-on libraries." msgstr "" -"Pustaka google-genai yang terpasang terlalu lama untuk sesi Gemini 3.1 Live. " -"Metode sesi yang tidak tersedia: {methodName}. Harap perbarui pustaka add-on." +"Library google-genai yang terpasang terlalu usang untuk sesi Gemini 3.1 " +"Live. Metode sesi yang belum tersedia: {methodName}. Harap perbarui library " +"add-on." -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:788 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:799 msgid "Connected" msgstr "Terhubung" -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:835 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:846 #, python-brace-format msgid "Connection lost. Retrying in {seconds:.1f}s" -msgstr "Koneksi terputus. Mencoba lagi dalam {seconds:.1f}s" +msgstr "Koneksi terputus. Mencoba ulang dalam {seconds:.1f}s" -#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:888 +#: addon\globalPlugins\NativeSpeechGeneration\talkWithAI.py:900 msgid "Ready" msgstr "Siap" -#: addon\globalPlugins\NativeSpeechGeneration\__init__.py:56 -#: addon\globalPlugins\NativeSpeechGeneration\__init__.py:100 +#: addon\globalPlugins\NativeSpeechGeneration\__init__.py:57 +#: addon\globalPlugins\NativeSpeechGeneration\__init__.py:101 msgid "Open the Native Speech Generation dialog" -msgstr "Membuka add-on Native Speech Generation" +msgstr "Buka dialog Native Speech Generation" #. Translators: Title of the settings panel in NVDA preferences. -#: addon\globalPlugins\NativeSpeechGeneration\__init__.py:57 -#: addon\globalPlugins\NativeSpeechGeneration\__init__.py:101 -#: addon\globalPlugins\NativeSpeechGeneration\interface\settings.py:25 +#: addon\globalPlugins\NativeSpeechGeneration\__init__.py:58 +#: addon\globalPlugins\NativeSpeechGeneration\__init__.py:102 +#: addon\globalPlugins\NativeSpeechGeneration\interface\settings.py:23 #: buildVars.py:6 msgid "Native Speech Generation" msgstr "Native Speech Generation" -#: addon\globalPlugins\NativeSpeechGeneration\__init__.py:64 +#: addon\globalPlugins\NativeSpeechGeneration\__init__.py:65 msgid "" "Native Speech Generation is installing dependencies. Please restart NVDA for " "the changes to take effect." msgstr "" -"Sedang memasang dependensi Native Speech Generation. Harap restart NVDA agar " -"perubahan berlaku." +"Native Speech Generation sedang memasang dependensi. Silakan mulai ulang " +"NVDA agar perubahan diterapkan." #. Translators: Title of the information dialog recommending a restart. -#: addon\globalPlugins\NativeSpeechGeneration\__init__.py:68 -#: addon\globalPlugins\NativeSpeechGeneration\interface\settings.py:180 +#: addon\globalPlugins\NativeSpeechGeneration\__init__.py:69 msgid "Restart Required" -msgstr "Restart diperlukan" +msgstr "Perlu Mulai Ulang" #. Translators: Name of the add-on in the NVDA Tools menu. -#: addon\globalPlugins\NativeSpeechGeneration\__init__.py:93 +#: addon\globalPlugins\NativeSpeechGeneration\__init__.py:94 msgid "&Native Speech Generation" msgstr "&Native Speech Generation" #. Translators: Tooltip or description for the menu item. -#: addon\globalPlugins\NativeSpeechGeneration\__init__.py:95 +#: addon\globalPlugins\NativeSpeechGeneration\__init__.py:96 msgid "Generate speech using Gemini TTS" -msgstr "Hasilkan Ucapan Menggunakan Gemini TTS" +msgstr "Hasilkan ucapan menggunakan Gemini TTS" -#: addon\globalPlugins\NativeSpeechGeneration\__init__.py:115 +#: addon\globalPlugins\NativeSpeechGeneration\__init__.py:116 msgid "" "The Native Speech Generation add-on is already open. Please close the dialog " "before opening it again." @@ -357,133 +414,168 @@ msgstr "" "membukanya lagi." #. Translators: Title of warning dialog when user tries to open the add-on twice. -#: addon\globalPlugins\NativeSpeechGeneration\__init__.py:118 +#: addon\globalPlugins\NativeSpeechGeneration\__init__.py:119 msgid "Add-on Already Running" msgstr "Add-on sudah berjalan" -#: addon\globalPlugins\NativeSpeechGeneration\__init__.py:130 +#: addon\globalPlugins\NativeSpeechGeneration\__init__.py:131 #, python-brace-format msgid "Failed to open Native Speech Generation dialog: {error}" -msgstr "Gagal menjalankan add-on Native Speech Generation: {error}" +msgstr "Gagal membuka dialog Native Speech Generation: {error}" + +#. Translators: Message shown when generated audio was saved but could not be opened automatically. +#: addon\globalPlugins\NativeSpeechGeneration\core\audio_utils.py:111 +#, python-brace-format +msgid "Audio generated, but failed to play automatically: {error}" +msgstr "Audio dihasilkan, namun gagal diputar secara otomatis: {error}" + +#. Translators: Title of an informational message dialog. +#: addon\globalPlugins\NativeSpeechGeneration\core\audio_utils.py:113 +msgid "Info" +msgstr "Info" #. Translators: The title of the main dialog window for generating speech. -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:44 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:59 msgid "Native Speech Generation (Gemini TTS)" msgstr "Native Speech Generation (Gemini TTS)" +#. Translators: Model option for the newest Gemini Flash text-to-speech preview. +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:82 +msgid "Flash 3.1 Preview" +msgstr "Flash 3.1 Preview" + +#. Translators: Description for the Gemini Flash 3.1 Preview model. +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:84 +msgid "Powerful, low-latency speech generation, very good for short audio." +msgstr "" +"Pembuatan ucapan yang kuat dan berlatensi rendah, sangat cocok untuk audio " +"pendek." + +#. Translators: Model option for the older Gemini Flash text-to-speech preview. +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:89 +msgid "Flash 2.5" +msgstr "Flash 2.5" + +#. Translators: Description for the Gemini Flash 2.5 model. +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:91 +msgid "Standard quality, responsive speech generation." +msgstr "Pembuatan ucapan yang responsif dengan kualitas standar." + +#. Translators: Model option for the Gemini Pro text-to-speech preview. +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:96 +msgid "Pro 2.5 (High Quality)" +msgstr "Pro 2.5 (Kualitas Tinggi)" + +#. Translators: Description for the Gemini Pro 2.5 model. +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:98 +msgid "Premium speech generation with more realistic voices." +msgstr "Pembuatan ucapan premium dengan suara yang lebih realistis." + #. Translators: Label for the text area where user inputs text to be converted to speech. -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:65 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:106 msgid "&Type text to convert here:" -msgstr "&Masukkan teks yang ingin anda jadikan audio:" +msgstr "&Masukkan teks yang ingin Anda ubah menjadi audio:" #. Translators: Label for optional instructions on how the speech should be spoken (e.g. "Happy", "Sad"). -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:71 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:112 msgid "&Style instructions (optional):" -msgstr "&Gaya bicara TTS (optional):" +msgstr "&Instruksi gaya bicara (opsional):" #. Translators: Label for selecting the AI model to use for generation. -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:78 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:120 msgid "Select &Model:" msgstr "Pilih &Model:" -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:79 -msgid "Flash (Standard Quality)" -msgstr "Flash (kualitas standar)" - -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:79 -msgid "Pro (High Quality)" -msgstr "Pro (kualitas tinggi)" - #. Translators: Radio button to select single speaker mode. -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:86 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:130 msgid "Single-speaker" msgstr "Satu pembicara" #. Translators: Radio button to select multi-speaker mode. -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:88 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:132 msgid "Multi-speaker (2)" -msgstr "Multi pembicara" +msgstr "Multi-pembicara (2)" #. Translators: Checkbox to show advanced settings like Temperature. -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:97 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:145 msgid "Advanced Settings (&Temperature)" -msgstr "Pengaturan lanjutan (&Temperature)" +msgstr "Pengaturan lanjutan (&Temperatur)" #. Translators: Label for the temperature slider which controls creativity of the AI. -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:107 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:155 msgid "Temperature:" -msgstr "Temperature:" +msgstr "Temperatur:" #. Translators: Button to start generating the speech audio. -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:133 -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:539 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:181 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:612 msgid "&Generate Speech" msgstr "&Hasilkan Audio" #. Translators: Button to play the generated audio. -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:138 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:186 msgid "&Play" msgstr "&Putar" #. Translators: Button to save the generated audio to a file. -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:144 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:192 msgid "Save &Audio" msgstr "Simpan &Audio" #. Translators: Button to open the real-time conversation dialog. -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:152 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:200 msgid "Talk With &AI" -msgstr "Bicara Dengan & AI" +msgstr "Bicara dengan &AI" #. Translators: Button to open settings specifically for configuring the API key. -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:158 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:206 msgid "API Key Settings" msgstr "Pengaturan Kunci API" #. Translators: Button that opens a web browser to view available voices in Google AI Studio. -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:161 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:209 msgid "View voices in AI Studio" -msgstr "Periksa Suara di AI Studio" +msgstr "Lihat suara di AI Studio" -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:167 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:215 msgid "&Close" msgstr "&Tutup" #. Translators: Label for selecting a voice in single speaker mode. -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:185 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:233 msgid "Select &Voice:" msgstr "Pilih &Suara:" -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:186 -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:204 -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:219 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:234 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:252 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:267 msgid "Loading voices..." -msgstr "Memuat suara ..." +msgstr "Memuat suara..." -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:201 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:249 msgid "Speaker 1 Name:" msgstr "Nama Pembicara 1:" -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:202 -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:477 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:250 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:459 msgid "Speaker1" -msgstr "Pembicara1" +msgstr "Pembicara 1" -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:203 -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:218 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:251 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:266 msgid "Voice:" msgstr "Suara:" -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:216 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:264 msgid "Speaker 2 Name:" msgstr "Nama Pembicara 2:" -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:217 -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:478 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:265 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:460 msgid "Speaker2" -msgstr "Pembicara2" +msgstr "Pembicara 2" -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:327 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:381 #, python-brace-format msgid "" "The stored Gemini API key could not be decrypted on this Windows user or " @@ -491,145 +583,145 @@ msgid "" "the environment." msgstr "" "Kunci API Gemini yang tersimpan tidak dapat didekripsi pada pengguna atau " -"mesin Windows ini. Silakan masukkan kembali di pengaturan NVDA, atau " -"definisikan {envVarName} di lingkungan." +"mesin Windows ini. Silakan masukkan kembali di pengaturan NVDA, atau atur " +"{envVarName} sebagai variabel lingkungan." -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:333 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:387 #, python-brace-format msgid "" "No Gemini API key is configured. Set it in NVDA settings, or define " "{envVarName} in the environment." msgstr "" "Tidak ada kunci API Gemini yang dikonfigurasi. Atur di pengaturan NVDA, atau " -"tentukan {envVarName} di lingkungan." +"atur {envVarName} sebagai variabel lingkungan." -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:354 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:408 msgid "Talk With AI module is missing." -msgstr "Modul Bicara Dengan AI tidak ada." +msgstr "Modul Bicara dengan AI tidak ditemukan." -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:364 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:418 msgid "" "Talk With AI currently does not support multi-speaker mode. Please select " "Single-speaker." msgstr "" -"Talk With AI saat ini tidak mendukung mode multi-speaker. Harap pilih " -"singgle speaker." +"Bicara dengan AI saat ini belum mendukung mode multi-pembicara. Harap pilih " +"Satu pembicara." -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:366 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:420 msgid "Feature Limitation" msgstr "Batasan Fitur" -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:387 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:441 #, python-brace-format msgid "Failed to open Talk With AI: {error}" -msgstr "Gagal membuka Bicara Dengan AI: {error}" +msgstr "Gagal membuka Bicara dengan AI: {error}" -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:396 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:468 msgid "" "google-genai is not available. Please restart NVDA after updating the add-on " "libraries." msgstr "" "google-genai tidak tersedia. Silakan mulai ulang NVDA setelah memperbarui " -"pustaka add-on." +"library add-on." -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:416 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:489 msgid "Please enter text to generate." msgstr "Harap masukkan teks terlebih dahulu." -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:423 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:497 msgid "Generating..." msgstr "Sedang memproses..." -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:430 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:504 msgid "Generating speech, please wait..." msgstr "Sedang menghasilkan audio, harap tunggu..." -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:439 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:513 #, python-brace-format msgid "Failed to initialize Google GenAI client: {error}" -msgstr "Gagal menginisialisasi klien Google Genai: {error}" +msgstr "Gagal menginisialisasi klien Google GenAI: {error}" -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:450 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:524 msgid "Failed to generate audio." msgstr "Gagal menghasilkan audio." -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:452 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:526 msgid "Generation complete." -msgstr "Selesai membuat audio." +msgstr "Pembuatan audio selesai." -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:526 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:599 msgid "An error occurred during generation." msgstr "Terjadi kesalahan selama menghasilkan audio." -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:530 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:603 #, python-brace-format msgid "An unexpected error occurred: {error}" msgstr "Terjadi kesalahan yang tidak terduga: {error}" -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:598 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:687 msgid "No inline audio data returned by model." -msgstr "Tidak ada data audio inline yang dikembalikan oleh model." +msgstr "Model tidak mengembalikan data audio inline." -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:620 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:709 #, python-brace-format msgid "Failed to generate speech: {error}" msgstr "Gagal menghasilkan ucapan: {error}" -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:638 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:727 msgid "Save Audio File" msgstr "Simpan file audio" -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:639 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:728 msgid "WAV files (*.wav)|*.wav|MP3 files (*.mp3)|*.mp3" -msgstr "Berkas WAV (*.wav)|*.wav|Berkas Mp3 (*.mp3)|*.mp3" +msgstr "Berkas WAV (*.wav)|*.wav|Berkas MP3 (*.mp3)|*.mp3" -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:651 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:740 #, python-brace-format msgid "Audio saved to {path}" msgstr "Audio disimpan ke {path}" -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:652 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:741 msgid "Success" -msgstr "Success" +msgstr "Berhasil" -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:658 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:747 #, python-brace-format msgid "Failed to save audio: {error}" msgstr "Gagal menyimpan audio: {error}" -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:673 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:765 msgid "Sample not available" -msgstr "Suara sampel tidak tersedia" +msgstr "Sampel suara tidak tersedia" -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:678 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:770 msgid "Playing voice sample" msgstr "Memainkan sampel suara" -#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:683 +#: addon\globalPlugins\NativeSpeechGeneration\interface\generation_dialog.py:775 msgid "Failed to play sample" -msgstr "Gagal memainkan sampel audio" +msgstr "Gagal memutar sampel suara" #. Translators: Label for the input field where user enters their Gemini API Key. -#: addon\globalPlugins\NativeSpeechGeneration\interface\settings.py:39 +#: addon\globalPlugins\NativeSpeechGeneration\interface\settings.py:37 msgid "&Gemini API Key:" -msgstr "& Kunci API Gemini:" +msgstr "&Kunci API Gemini:" #. Translators: Checkbox to toggle visibility of the API key (show/hide characters). -#: addon\globalPlugins\NativeSpeechGeneration\interface\settings.py:52 +#: addon\globalPlugins\NativeSpeechGeneration\interface\settings.py:50 msgid "Show API Key" msgstr "Tampilkan kunci API" #. Translators: Button starting a process to help user get an API key (opens a website). -#: addon\globalPlugins\NativeSpeechGeneration\interface\settings.py:67 +#: addon\globalPlugins\NativeSpeechGeneration\interface\settings.py:65 msgid "&How to get API Key..." -msgstr "&Cara Mendapatkan Kunci API..." +msgstr "&Cara mendapatkan kunci API..." #. Translators: Button to force a reinstallation of external dependencies (Python libraries). -#: addon\globalPlugins\NativeSpeechGeneration\interface\settings.py:72 +#: addon\globalPlugins\NativeSpeechGeneration\interface\settings.py:70 msgid "&Reinstall Libraries" -msgstr "&Instal ulang Librarys" +msgstr "&Instal ulang library" -#: addon\globalPlugins\NativeSpeechGeneration\interface\settings.py:81 +#: addon\globalPlugins\NativeSpeechGeneration\interface\settings.py:79 #, python-brace-format msgid "" "The stored API key could not be decrypted on this Windows user or machine. " @@ -637,59 +729,33 @@ msgid "" "replace it." msgstr "" "Kunci API yang tersimpan tidak dapat didekripsi pada pengguna atau perangkat " -"Windows ini. Sebagai gantinya, {envVarName} dari lingkungan akan digunakan. " -"Masukkan kunci baru di sini untuk menggantikannya." +"Windows ini. Sebagai gantinya, variabel lingkungan {envVarName} akan " +"digunakan. Masukkan kunci baru di sini untuk menggantikannya." -#: addon\globalPlugins\NativeSpeechGeneration\interface\settings.py:87 +#: addon\globalPlugins\NativeSpeechGeneration\interface\settings.py:85 #, python-brace-format msgid "" "The stored API key could not be decrypted on this Windows user or machine. " "Enter a new key here, or set {envVarName} in the environment." msgstr "" "Kunci API yang tersimpan tidak dapat didekripsi pada pengguna atau perangkat " -"Windows ini. Masukkan kunci baru di sini, atau atur {envVarName} di " -"lingkungan." +"Windows ini. Masukkan kunci baru di sini, atau atur {envVarName} sebagai " +"variabel lingkungan." -#: addon\globalPlugins\NativeSpeechGeneration\interface\settings.py:94 +#: addon\globalPlugins\NativeSpeechGeneration\interface\settings.py:92 #, python-brace-format msgid "" "Using {envVarName} from the environment. Saving a key here will override it." msgstr "" -"Menggunakan {envVarName} dari lingkungan. Menyimpan kunci di sini akan " +"Menggunakan variabel lingkungan {envVarName}. Menyimpan kunci di sini akan " "menimpanya." #. Translators: Error shown if Windows DPAPI storage fails while saving the API key. -#: addon\globalPlugins\NativeSpeechGeneration\interface\settings.py:138 +#: addon\globalPlugins\NativeSpeechGeneration\interface\settings.py:136 #, python-brace-format msgid "Failed to save the Gemini API key securely: {error}" msgstr "Gagal menyimpan kunci API Gemini dengan aman: {error}" -#: addon\globalPlugins\NativeSpeechGeneration\interface\settings.py:158 -msgid "" -"This will delete the existing library and restart NVDA to redownload it.\n" -"Are you sure?" -msgstr "" -"Tindakan ini akan menghapus library yang ada dan restart NVDA untuk " -"mengunduhnya ulang.\n" -"Apakah kamu yakin?" - -#: addon\globalPlugins\NativeSpeechGeneration\interface\settings.py:179 -msgid "" -"Library removed successfully. NVDA will now restart to download the latest " -"version." -msgstr "" -"Library berhasil dihapus. NVDA sekarang akan memulai ulang untuk mengunduh " -"versi terbaru." - -#: addon\globalPlugins\NativeSpeechGeneration\interface\settings.py:190 -#, python-brace-format -msgid "" -"Failed to remove library: {error}\n" -"Please check log." -msgstr "" -"Gagal menghapus pustaka: {error}\n" -"Silakan periksa log." - #: buildVars.py:7 msgid "" "Harness the power of Google's state-of-the-art Gemini AI for high-quality " @@ -697,8 +763,9 @@ msgid "" "dialog to convert text into natural-sounding audio.\n" "\n" "Key Features:\n" -"- High-Quality Voices: Choose between Gemini Pro for premium, life-like " -"speech and Gemini Flash for standard quality, responsive generation.\n" +"- High-Quality Voices: Choose between Gemini Flash 3.1 Preview for powerful, " +"low-latency short audio, Gemini Flash 2.5 for standard responsive " +"generation, and Gemini Pro 2.5 for premium, life-like speech.\n" "- Single and Multi-Speaker Modes: Easily generate audio for a single speaker " "or create dynamic dialogues with two distinct speakers. Simply format your " "text with \"SpeakerName:\" to assign voices.\n" @@ -713,68 +780,41 @@ msgid "" "To get started, obtain a Gemini API key from Google AI Studio and enter it " "in the add-on's settings panel, found under NVDA's Tools menu." msgstr "" -"Manfaatkan kekuatan AI Gemini canggih dari Google untuk menghasilkan suara " -"berkualitas tinggi langsung di dalam NVDA.\n" -"Add-on ini menyediakan dialog yang ramah pengguna untuk mengubah teks " -"menjadi audio yang terdengar alami.\n" +"Manfaatkan kekuatan AI Gemini mutakhir dari Google untuk menghasilkan ucapan " +"berkualitas tinggi langsung di NVDA. Add-on ini menyediakan dialog yang " +"mudah digunakan untuk mengubah teks menjadi audio yang terdengar alami.\n" "\n" "Fitur Utama:\n" -"- Suara Berkualitas Tinggi: Pilih antara Gemini Pro untuk suara premium yang " -"mirip dengan kehidupan nyata dan Gemini Flash untuk kualitas standar yang " -"responsif.\n" -"\n" -"- Mode Satu dan Multi-Pembicara: Dengan mudah hasilkan audio untuk satu " -"pembicara atau buat dialog dinamis dengan dua pembicara yang berbeda. Cukup " -"format teks Anda dengan \"NamaPembicara:\" untuk menetapkan suara.\n" -"\n" -"- Kontrol Suara Lanjutan: Sesuaikan output dengan mengatur temperature untuk " -"hasil yang lebih kreatif atau stabil, dan berikan instruksi gaya pembicara " -"sesuai keinginan.\n" -"\n" -"- Antarmuka yang Dapat Diakses: Semua kontrol sepenuhnya dapat diakses, " -"termasuk panel yang dapat diciutkan untuk pengaturan lanjutan agar antarmuka " -"tetap bersih dan mudah dinavigasi.\n" +"- Suara Berkualitas Tinggi: Pilih Gemini Flash 3.1 Preview untuk audio " +"pendek yang kuat dan berlatensi rendah, Gemini Flash 2.5 untuk pembuatan " +"ucapan standar yang responsif, atau Gemini Pro 2.5 untuk ucapan premium yang " +"lebih hidup.\n" +"- Mode Satu dan Multi-Pembicara: Hasilkan audio untuk satu pembicara atau " +"buat dialog dinamis dengan dua pembicara berbeda. Cukup format teks dengan " +"\"NamaPembicara:\" untuk menetapkan suara.\n" +"- Kontrol Suara Lanjutan: Sesuaikan output dengan mengatur temperatur untuk " +"hasil yang lebih kreatif atau lebih stabil, dan tambahkan instruksi gaya " +"khusus.\n" +"- Antarmuka yang Aksesibel: Semua kontrol sepenuhnya dapat diakses, termasuk " +"panel pengaturan lanjutan yang dapat diciutkan agar antarmuka tetap rapi dan " +"mudah dinavigasi.\n" +"- Alur Kerja yang Lancar: Add-on ini langsung memutar audio setelah dibuat " +"dan memungkinkan Anda menyimpan file .wav yang dihasilkan untuk digunakan " +"nanti.\n" "\n" -"- Alur Kerja yang Lancar: Add-on ini menyediakan pemutaran audio instan " -"setelah pembuatan dan memungkinkan Anda menyimpan file .wav yang dihasilkan " -"untuk digunakan nanti.\n" "Untuk memulai, dapatkan kunci API Gemini dari Google AI Studio dan masukkan " -"ke dalam panel pengaturan add-on, yang dapat ditemukan di menu Alat NVDA." +"di panel pengaturan add-on yang tersedia di menu Alat NVDA." #: buildVars.py:18 msgid "" -"- Security: Gemini API keys are now stored with Windows DPAPI instead of " -"plaintext config.\n" -"- Stability: Preserved add-on configuration across updates so API keys are " -"no longer wiped by the uninstall/update flow.\n" -"- Deployment: Added GEMINI_API_KEY environment variable fallback for managed " -"setups.\n" -"- Talk With AI: Migrated to gemini-3.1-flash-live-preview with Live API " -"thinking controls.\n" -"- Talk With AI: Replaced the memory UI with No Thinking, Low, Medium, and " -"High.\n" -"- Talk With AI: Preserved reconnect continuity internally by replaying " -"recent transcript history after reconnects.\n" -"- Talk With AI: Kept style instructions as the Live API system instruction " -"and retained Google Search grounding.\n" -"- Talk With AI: Improved stream stability with reconnect backoff/retry and " -"adaptive buffering.\n" -"- Documentation: Added Spanish-language documentation.\n" +"- Compatibility: Added support for NVDA 2026.1.\n" +"- Dependencies now use verified GitHub release metadata for NVDA-version-aware " +"library packages.\n" +"- Added Gemini Flash 3.1 Preview as the default TTS model.\n" +"- See changelog.md for full details.\n" msgstr "" -"- Keamanan: Kunci API Gemini sekarang disimpan dengan Windows DPAPI, bukan " -"lagi dalam konfigurasi teks biasa.\n" -"- Stabilitas: Konfigurasi add-on kini tetap dipertahankan saat pembaruan, " -"sehingga kunci API tidak lagi terhapus saat proses uninstall atau update.\n" -"- Deployment: Menambahkan dukungan variabel lingkungan GEMINI_API_KEY untuk " -"lingkungan yang dikelola.\n" -"- Bicara dengan AI: Bermigrasi ke gemini-3.1-flash-live-preview dengan " -"kontrol penalaran Live API.\n" -"- Bicara dengan AI: Antarmuka memori diganti dengan Tanpa Penalaran, Rendah, " -"Sedang, dan Tinggi.\n" -"- Bicara dengan AI: Kontinuitas setelah koneksi ulang kini dipertahankan " -"secara internal dengan memutar ulang riwayat transkrip terbaru.\n" -"- Bicara dengan AI: Instruksi gaya tetap digunakan sebagai instruksi sistem " -"Live API, dan grounding Google Search tetap dipertahankan.\n" -"- Bicara dengan AI: Stabilitas streaming ditingkatkan dengan reconnect/retry " -"dan buffering adaptif.\n" -"- Dokumentasi: Menambahkan dokumentasi berbahasa Spanyol.\n" +"- Kompatibilitas: Menambahkan dukungan untuk NVDA 2026.1.\n" +"- Dependensi sekarang memakai metadata rilis GitHub terverifikasi untuk paket " +"library yang sadar versi NVDA.\n" +"- Menambahkan Gemini Flash 3.1 Preview sebagai model TTS default.\n" +"- Baca changelog.md untuk detail lengkap.\n" diff --git a/buildVars.py b/buildVars.py index fb72ed8..2d39946 100644 --- a/buildVars.py +++ b/buildVars.py @@ -1,9 +1,16 @@ -from site_scons.site_tools.NVDATool.typings import AddonInfo, BrailleTables, SymbolDictionaries +from site_scons.site_tools.NVDATool.typings import ( + AddonInfo, + BrailleTables, + SpeechDictionaries, + SymbolDictionaries, +) from site_scons.site_tools.NVDATool.utils import _ addon_info = AddonInfo( addon_name="NativeSpeechGeneration", + # Translators: Summary for this add-on shown in NVDA's Add-ons Store and Add-ons Manager. addon_summary=_("Native Speech Generation"), + # Translators: Long description for this add-on shown in NVDA's Add-ons Store and Add-ons Manager. addon_description=_("""Harness the power of Google's state-of-the-art Gemini AI for high-quality speech generation directly within NVDA. This add-on provides a user-friendly dialog to convert text into natural-sounding audio. Key Features: @@ -15,14 +22,11 @@ To get started, obtain a Gemini API key from Google AI Studio and enter it in the add-on's settings panel, found under NVDA's Tools menu."""), addon_version="1.7.0", + # Translators: Short release notes shown for this add-on version. addon_changelog=_("""- Compatibility: Added support for NVDA 2026.1. -- Dependencies: Added verified, NVDA-version-aware library downloads that select lib.zip for older NVDA versions and lib64.zip for NVDA 2026.1 and newer. -- Dependencies: Added SHA-256 verification and safe archive extraction before replacing the add-on library folder. -- Speech Generation: Added gemini-3.1-flash-tts-preview as the default model. -- Speech Generation: Captured dialog values before background generation to avoid reading wx controls from worker threads. -- Speech Generation: Saved generated audio outside the add-on folder so runtime output is not bundled accidentally. -- Security: Fixed the Windows DPAPI ctypes fallback to keep input buffers alive during encryption and decryption. -- Maintenance: Removed the direct requests dependency, tightened package exclusions, and refreshed NVDA-style naming/type hints. +- Dependencies now use the latest verified library package for each supported NVDA runtime. +- Added Gemini Flash 3.1 Preview as the default TTS model. +- See changelog.md for full details. """), addon_author="Muhammad ", addon_url="https://github.com/muhammadGagah/native-speech-generation/", @@ -64,3 +68,5 @@ brailleTables: BrailleTables = {} symbolDictionaries: SymbolDictionaries = {} + +speechDictionaries: SpeechDictionaries = {} diff --git a/changelog.md b/changelog.md index 7863a06..c9f7f60 100644 --- a/changelog.md +++ b/changelog.md @@ -5,11 +5,19 @@ - Compatibility: Added support for NVDA 2026.1. - Dependencies: Added verified, NVDA-version-aware library downloads that select `lib.zip` for older NVDA versions and `lib64.zip` for NVDA 2026.1 and newer. - Dependencies: Added SHA-256 verification and safe archive extraction before replacing the add-on library folder. +- Dependencies: Default installs now use the latest verified GitHub library release when available, with pinned 2.2.0 assets kept as a fallback. +- Dependencies: Manual library reinstalls now require the latest verified GitHub release, so updating `lib.zip` or `lib64.zip` in a new dependency release automatically updates the SHA-256 used by the add-on. - Speech Generation: Added `gemini-3.1-flash-tts-preview` as the default model. +- Speech Generation: Fixed streamed WAV merging so Flash 3.1 Preview chunks with different durations are combined safely. +- Speech Generation: Avoided replay-like duplicate audio by selecting the best single stream chunk for Flash 2.5 and Pro 2.5 instead of merging overlapping chunks. +- UI: Renamed the Flash 2.5 model option to `Flash 2.5`; quality details remain in the model description. +- UI: Model descriptions are now announced by NVDA when focusing or changing the model selection. - Speech Generation: Captured dialog values before background generation to avoid reading wx controls from worker threads. - Speech Generation: Saved generated audio outside the add-on folder so runtime output is not bundled accidentally. - Security: Fixed the Windows DPAPI ctypes fallback to keep input buffers alive during encryption and decryption. +- Build: Synced the add-on scaffolding with the current NVDA AddonTemplate, including `uv.lock`, GitHub Actions `uv sync`, Dependabot `uv`, and manifest `speechDictionaries` support. - Maintenance: Removed the direct `requests` dependency, tightened package exclusions, and refreshed NVDA-style naming/type hints. +- Localization: Added German-language and documentation. ## version 1.6.0 diff --git a/pyproject.toml b/pyproject.toml index 6e65b9f..9049f75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,47 @@ +[build-system] +requires = ["setuptools~=80.9", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "NativeSpeechGeneration" +version = "1.7.0" +description = "NVDA add-on for Gemini-based native speech generation." +maintainers = [ + {name = "Muhammad", email = "muha.aku@gmail.com"}, +] +requires-python = ">=3.13,<3.14" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", + "Operating System :: Microsoft :: Windows", + "Programming Language :: Python :: 3", + "Topic :: Accessibility", +] +readme = "readme.md" +license = {file = "COPYING.txt"} +dependencies = [ + "scons==4.10.1", + "Markdown==3.10", + "requests==2.33.0", + "nh3==0.3.2", + "crowdin-api-client==1.24.1", + "lxml==6.1.0", + "mdx_truly_sane_lists==1.3", + "markdown-link-attr-modifier==0.2.1", + "mdx-gh-links==0.4", + "uv==0.11.6", + "ruff==0.14.10", + "pre-commit==4.2.0", + "pyright[nodejs]==1.1.407", +] + +[project.urls] +Repository = "https://github.com/muhammadGagah/native-speech-generation/" + [tool.ruff] line-length = 110 +target-version = "py311" builtins = [ # translation lookup @@ -20,10 +62,12 @@ include = [ exclude = [ ".git", "__pycache__", + ".venv", ] [tool.ruff.format] indent-style = "tab" +line-ending = "lf" [tool.ruff.lint.mccabe] max-complexity = 15 @@ -33,6 +77,7 @@ ignore = [ # indentation contains tabs "W191", ] +logger-objects = ["logHandler.log"] [tool.ruff.lint.per-file-ignores] # sconstruct contains many inbuilt functions not recognised by the lint, @@ -40,7 +85,10 @@ ignore = [ "sconstruct" = ["F821"] [tool.pyright] +venvPath = ".venv" +venv = "." pythonPlatform = "Windows" +pythonVersion = "3.11" typeCheckingMode = "strict" include = [ @@ -61,6 +109,7 @@ exclude = [ # Tell pyright where to load python code from extraPaths = [ "./addon", + "../nvda/source", ] # General config diff --git a/readme.md b/readme.md index 797ef2d..c57cda5 100644 --- a/readme.md +++ b/readme.md @@ -119,7 +119,7 @@ Open the dialog using: * **Select Model** * Flash 3.1 Preview - * Flash 2.5 (Standard Quality) + * Flash 2.5 * Pro 2.5 (High Quality) * **Speaker Mode** @@ -220,26 +220,26 @@ If you want to develop or modify this add-on, follow the steps below. ### Environment Setup -* **Python 32-bit (3.11.9 recommended)** - [https://www.python.org/downloads/release/python-3119/](https://www.python.org/downloads/release/python-3119/) -* **SCons 4.9.1 or newer** +* **Python matching your target NVDA runtime** + * Use **Python 3.13 64-bit** when testing or packaging dependencies for NVDA 2026.1 and newer. + * Use **Python 3.11 32-bit** only when packaging dependencies for older supported NVDA builds. +* **uv** for the pinned build and lint toolchain. ``` - pip install scons + uv sync + uv run pre-commit run --all-files + uv run scons + uv run scons pot ``` + + SCons 4.10.1, Markdown 3.10, Ruff 0.14.10, Pyright 1.1.407, and the other build tools are installed from `uv.lock`. * **GNU Gettext Tools** (optional, recommended for localization) * Usually preinstalled on Linux/Cygwin. * Windows: [https://gnuwin32.sourceforge.net/downlinks/gettext.php](https://gnuwin32.sourceforge.net/downlinks/gettext.php) -* **Markdown 3.8+** (for documentation conversion) - - ``` - pip install markdown - ``` - ### Additional Dependencies -Install the audio-only Talk With AI dependencies directly into the add-on library path: +For local development only, install the audio-only Talk With AI dependencies directly into the add-on library path using the Python version and architecture that match the NVDA runtime you are testing: ``` python.exe -m pip install google-genai pyaudio --target "D:/myAdd-on/Native-Speech-Generation/addon/globalPlugins/NativeSpeechGeneration/lib" @@ -249,12 +249,12 @@ Adjust the path according to your local add-on source directory. For the current audio-only Talk With AI implementation, you do not need `opencv-python`, `pillow`, or `mss`. -For release packages, the add-on downloads verified dependency archives based on the running NVDA version: +For release packages, the add-on downloads the latest verified dependency archive based on the running NVDA version: * `lib.zip` for NVDA 2025.3.3 and older supported builds. * `lib64.zip` for NVDA 2026.1 and newer. -Both release assets must include matching SHA-256 files (`lib.zip.sha256` and `lib64.zip.sha256`). The extracted folder is always installed as `addon/globalPlugins/NativeSpeechGeneration/lib`. +The add-on reads SHA-256 data from the latest GitHub dependency release, using the release asset digest or checksum files. Bundled approved checksums are kept only as a fallback for first-time installs when the latest-release lookup fails. Manual library reinstalls require the latest verified release. The extracted folder is always installed as `addon/globalPlugins/NativeSpeechGeneration/lib`. Then copy the following from your Python installation into: diff --git a/sconstruct b/sconstruct index 5feb445..62ebe1a 100644 --- a/sconstruct +++ b/sconstruct @@ -60,6 +60,7 @@ env = Environment(variables=vars, ENV=os.environ, tools=["gettexttool", "NVDAToo env.Append( addon_info=buildVars.addon_info, brailleTables=buildVars.brailleTables, + speechDictionaries=buildVars.speechDictionaries, symbolDictionaries=buildVars.symbolDictionaries, ) diff --git a/site_scons/site_tools/NVDATool/__init__.py b/site_scons/site_tools/NVDATool/__init__.py index aa413e4..1c5259e 100644 --- a/site_scons/site_tools/NVDATool/__init__.py +++ b/site_scons/site_tools/NVDATool/__init__.py @@ -12,6 +12,7 @@ - addon_info: .typing.AddonInfo - brailleTables: .typings.BrailleTables +- speechDictionaries: .typings.SpeechDictionaries - symbolDictionaries: .typings.SymbolDictionaries The following environment variables are required to build the HTML: @@ -54,6 +55,7 @@ def addon_string(target: list[Any], source: list[Any], env: Any): ) env.SetDefault(brailleTables={}) + env.SetDefault(speechDictionaries={}) env.SetDefault(symbolDictionaries={}) def manifest_action(target: list[Any], source: list[Any], env: Any): @@ -63,6 +65,7 @@ def manifest_action(target: list[Any], source: list[Any], env: Any): target[0].abspath, addon_info=env["addon_info"], brailleTables=env["brailleTables"], + speechDictionaries=env["speechDictionaries"], symbolDictionaries=env["symbolDictionaries"], ) and None @@ -86,6 +89,7 @@ def translated_manifest_action(target: list[Any], source: list[Any], env: Any): mo=source[0].abspath, addon_info=env["addon_info"], brailleTables=env["brailleTables"], + speechDictionaries=env["speechDictionaries"], symbolDictionaries=env["symbolDictionaries"], ) and None diff --git a/site_scons/site_tools/NVDATool/manifests.py b/site_scons/site_tools/NVDATool/manifests.py index 911bbb6..2fcbcaf 100644 --- a/site_scons/site_tools/NVDATool/manifests.py +++ b/site_scons/site_tools/NVDATool/manifests.py @@ -1,7 +1,7 @@ import gettext from functools import partial -from .typings import AddonInfo, BrailleTables, SymbolDictionaries +from .typings import AddonInfo, BrailleTables, SpeechDictionaries, SymbolDictionaries from .utils import format_nested_section @@ -10,6 +10,7 @@ def generateManifest( dest: str, addon_info: AddonInfo, brailleTables: BrailleTables, + speechDictionaries: SpeechDictionaries, symbolDictionaries: SymbolDictionaries, ): # Prepare the root manifest section @@ -25,6 +26,9 @@ def generateManifest( if symbolDictionaries: manifest += format_nested_section("symbolDictionaries", symbolDictionaries) + if speechDictionaries: + manifest += format_nested_section("speechDictionaries", speechDictionaries) + with open(dest, "w", encoding="utf-8") as f: f.write(manifest) @@ -36,6 +40,7 @@ def generateTranslatedManifest( mo: str, addon_info: AddonInfo, brailleTables: BrailleTables, + speechDictionaries: SpeechDictionaries, symbolDictionaries: SymbolDictionaries, ): with open(mo, "rb") as f: @@ -62,5 +67,8 @@ def generateTranslatedManifest( if symbolDictionaries: manifest += _format_section_only_with_displayName("symbolDictionaries", symbolDictionaries) + if speechDictionaries: + manifest += _format_section_only_with_displayName("speechDictionaries", speechDictionaries) + with open(dest, "w", encoding="utf-8") as f: f.write(manifest) diff --git a/site_scons/site_tools/NVDATool/typings.py b/site_scons/site_tools/NVDATool/typings.py index 650a759..4c9c845 100644 --- a/site_scons/site_tools/NVDATool/typings.py +++ b/site_scons/site_tools/NVDATool/typings.py @@ -30,7 +30,13 @@ class SymbolDictionaryAttributes(TypedDict): mandatory: bool +class SpeechDictionaryAttributes(TypedDict): + displayName: str + mandatory: bool + + BrailleTables = dict[str, BrailleTableAttributes] +SpeechDictionaries = dict[str, SpeechDictionaryAttributes] SymbolDictionaries = dict[str, SymbolDictionaryAttributes] diff --git a/tests/test_lib_updater.py b/tests/test_lib_updater.py new file mode 100644 index 0000000..e01c758 --- /dev/null +++ b/tests/test_lib_updater.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +import importlib.util +import builtins +import sys +import tempfile +import types +import unittest +import zipfile +from collections.abc import Callable +from pathlib import Path +from typing import Any + + +REPO_ROOT = Path(__file__).resolve().parents[1] +LIB_UPDATER_PATH = REPO_ROOT / "addon" / "globalPlugins" / "NativeSpeechGeneration" / "lib_updater.py" + + +class _Log: + def debug(self, *_args: object, **_kwargs: object) -> None: + pass + + def error(self, *_args: object, **_kwargs: object) -> None: + pass + + def info(self, *_args: object, **_kwargs: object) -> None: + pass + + def warning(self, *_args: object, **_kwargs: object) -> None: + pass + + +class _ProgressDialog: + def __init__(self, *_args: object, **_kwargs: object) -> None: + super().__init__() + pass + + def Destroy(self) -> None: + pass + + def Update(self, *_args: object, **_kwargs: object) -> None: + pass + + +def _translate(message: str) -> str: + return message + + +def _callAfter(function: Callable[..., Any], *args: object, **kwargs: object) -> Any: + return function(*args, **kwargs) + + +def _callLater(_delay: object, function: Callable[..., Any], *args: object, **kwargs: object) -> Any: + return function(*args, **kwargs) + + +def _messageBox(*_args: object, **_kwargs: object) -> int: + return 1 + + +def _installNvdaStubs() -> None: + setattr(builtins, "_", _translate) + + addonHandler = types.ModuleType("addonHandler") + setattr(addonHandler, "initTranslation", lambda: None) + sys.modules["addonHandler"] = addonHandler + + core = types.ModuleType("core") + setattr(core, "restart", lambda: None) + sys.modules["core"] = core + + gui = types.ModuleType("gui") + setattr(gui, "mainFrame", object()) + setattr(gui, "messageBox", _messageBox) + sys.modules["gui"] = gui + + logHandler = types.ModuleType("logHandler") + setattr(logHandler, "log", _Log()) + sys.modules["logHandler"] = logHandler + + wx = types.ModuleType("wx") + setattr(wx, "CallAfter", _callAfter) + setattr(wx, "CallLater", _callLater) + setattr(wx, "MessageBox", _messageBox) + setattr(wx, "ProgressDialog", _ProgressDialog) + setattr(wx, "OK", 1) + setattr(wx, "CANCEL", 2) + setattr(wx, "ICON_ERROR", 4) + setattr(wx, "ICON_INFORMATION", 8) + setattr(wx, "PD_APP_MODAL", 16) + setattr(wx, "PD_AUTO_HIDE", 32) + sys.modules["wx"] = wx + + buildVersion = types.ModuleType("buildVersion") + setattr(buildVersion, "version", "2026.1") + sys.modules["buildVersion"] = buildVersion + + +def _loadLibUpdater() -> Any: + _installNvdaStubs() + moduleName = "nsg_lib_updater_under_test" + sys.modules.pop(moduleName, None) + spec = importlib.util.spec_from_file_location(moduleName, LIB_UPDATER_PATH) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[moduleName] = module + spec.loader.exec_module(module) + return module + + +class LibUpdaterTests(unittest.TestCase): + libUpdater: Any = None + + def setUp(self) -> None: + self.libUpdater = _loadLibUpdater() + + def testNvdaVersionParsingAndRuntimeAssetSelection(self) -> None: + self.assertEqual(self.libUpdater.parseNvdaVersion("2026.1"), (2026, 1, 0)) + self.assertEqual(self.libUpdater.parseNvdaVersion("NVDA 2025.3.3"), (2025, 3, 3)) + self.assertIsNone(self.libUpdater.parseNvdaVersion("alpha")) + self.assertEqual(self.libUpdater.getRuntimeAssetName("2026.1"), "lib64.zip") + self.assertEqual(self.libUpdater.getRuntimeAssetName("2025.3.3"), "lib.zip") + self.assertEqual(self.libUpdater.getRuntimeAssetName("not-a-version"), "lib.zip") + + def testApprovedLibraryAssetsArePinned(self) -> None: + lib32 = self.libUpdater.getApprovedLibraryAsset("lib.zip") + lib64 = self.libUpdater.getApprovedLibraryAsset("lib64.zip") + + self.assertEqual(lib32.version, "2.2.0") + self.assertEqual(lib32.source, "approved") + self.assertEqual( + lib32.sha256, + "96140636befa9880fbe48efc309f71f6057e80f48a7e58299d9657287df76d90", + ) + self.assertEqual(lib64.version, "2.2.0") + self.assertEqual(lib64.source, "approved") + self.assertEqual( + lib64.sha256, + "f8082c18d503454728b8d7ab97dbc407cd74c8ee086f6e2a6fde27dff9945b37", + ) + + def testLatestLibraryAssetUsesGithubDigest(self) -> None: + checksum = "d" * 64 + release = { + "tag_name": "2.3.0", + "assets": [ + { + "name": "lib64.zip", + "browser_download_url": "https://example.test/lib64.zip", + "digest": f"sha256:{checksum}", + }, + ], + } + + def readJson(_url: str) -> dict[str, Any]: + return release + + self.libUpdater._readJsonUrl = readJson + + asset = self.libUpdater.getLatestVerifiedLibraryAsset("lib64.zip") + + self.assertEqual(asset.version, "2.3.0") + self.assertEqual(asset.name, "lib64.zip") + self.assertEqual(asset.url, "https://example.test/lib64.zip") + self.assertEqual(asset.sha256, checksum) + self.assertEqual(asset.source, "github") + + def testResolveLibraryAssetUsesLatestReleaseByDefault(self) -> None: + checksum = "e" * 64 + release = { + "tag_name": "2.3.0", + "assets": [ + { + "name": "lib64.zip", + "browser_download_url": "https://example.test/lib64.zip", + "digest": f"sha256:{checksum}", + }, + ], + } + + def readJson(_url: str) -> dict[str, Any]: + return release + + self.libUpdater._readJsonUrl = readJson + + asset = self.libUpdater._resolveLibraryAsset() + + self.assertEqual(asset.version, "2.3.0") + self.assertEqual(asset.sha256, checksum) + self.assertEqual(asset.source, "github") + + def testResolveLibraryAssetFallbackIsSkippedForForcedLatest(self) -> None: + def fail(_url: str) -> dict[str, Any]: + raise OSError("offline") + + self.libUpdater._readJsonUrl = fail + + asset = self.libUpdater._resolveLibraryAsset() + + self.assertEqual(asset.version, "2.2.0") + self.assertEqual(asset.name, "lib64.zip") + self.assertEqual(asset.source, "approved") + with self.assertRaises(OSError): + self.libUpdater._resolveLibraryAsset(forceLatest=True) + + def testReleaseAssetDigestCanBeParsedForMaintainerChecks(self) -> None: + checksum = "a" * 64 + asset = { + "name": "lib.zip", + "browser_download_url": "https://example.test/lib.zip", + "digest": f"sha256:{checksum}", + } + release = {"assets": [asset]} + + self.assertEqual(self.libUpdater._findReleaseChecksum(release, "lib.zip", asset), checksum) + + def testChecksumTextRequiresMatchingAssetWhenReadingChecksumsFile(self) -> None: + checksum = "b" * 64 + checksumText = f"{checksum} lib64.zip\n{'c' * 64} lib.zip\n" + + self.assertEqual( + self.libUpdater._parseChecksumText(checksumText, "lib64.zip", allowFallback=False), + checksum, + ) + self.assertEqual( + self.libUpdater._parseChecksumText(checksumText, "missing.zip", allowFallback=False), + "", + ) + + def testUnsafeZipMembersAreRejected(self) -> None: + with tempfile.TemporaryDirectory() as tempDir: + zipPath = Path(tempDir) / "unsafe.zip" + with zipfile.ZipFile(zipPath, "w") as archive: + archive.writestr("../evil.txt", "no") + + with zipfile.ZipFile(zipPath, "r") as archive: + with self.assertRaises(self.libUpdater.LibraryUpdateError): + self.libUpdater._validateZipMembers(archive, tempDir) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..fcc7634 --- /dev/null +++ b/uv.lock @@ -0,0 +1,477 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "crowdin-api-client" +version = "1.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/fc/ec5564928057aac9cae7e78ed324898b3134369b100bbb2b5c97ad1ad548/crowdin_api_client-1.24.1.tar.gz", hash = "sha256:d2a385c2b3f8e985d5bb084524ae14aef9045094fba0b2df1df82d9da97155b1", size = 70629, upload-time = "2025-08-26T13:20:34.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/74/118d8f5e592a1fe75b793346a599d57746b18b8875c31e956022b63ba173/crowdin_api_client-1.24.1-py3-none-any.whl", hash = "sha256:a07365a2a0d42830ee4eb188e3820603e1420421575637b1ddd8dffe1d2fe14c", size = 109654, upload-time = "2025-08-26T13:20:33.673Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + +[[package]] +name = "idna" +version = "3.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/28/99c51f664567218d824af024c0251650fb27e4ca066df188dab0769c5b91/idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f", size = 196048, upload-time = "2026-05-28T14:32:38.55Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c", size = 65316, upload-time = "2026-05-28T14:32:37.035Z" }, +] + +[[package]] +name = "lxml" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/03/69347590f1cf4a6d5a4944bb6099e6d37f334784f16062234e1f892fdb1d/lxml-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a0092f2b107b69601adf562a57c956fbb596e05e3e6651cabd3054113b007e45", size = 8559689, upload-time = "2026-04-18T04:31:57.785Z" }, + { url = "https://files.pythonhosted.org/packages/3f/58/25e00bb40b185c974cfe156c110474d9a8a8390d5f7c92a4e328189bb60e/lxml-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc7140d7a7386e6b545d41b7358f4d02b656d4053f5fa6859f92f4b9c2572c4d", size = 4617892, upload-time = "2026-04-18T04:32:01.78Z" }, + { url = "https://files.pythonhosted.org/packages/f5/54/92ad98a94ac318dc4f97aaac22ff8d1b94212b2ae8af5b6e9b354bf825f7/lxml-6.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:419c58fc92cc3a2c3fa5f78c63dbf5da70c1fa9c1b25f25727ecee89a96c7de2", size = 4923489, upload-time = "2026-04-18T04:33:31.401Z" }, + { url = "https://files.pythonhosted.org/packages/15/3b/a20aecfab42bdf4f9b390590d345857ad3ffd7c51988d1c89c53a0c73faf/lxml-6.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:37fabd1452852636cf38ecdcc9dd5ca4bba7a35d6c53fa09725deeb894a87491", size = 5082162, upload-time = "2026-04-18T04:33:34.262Z" }, + { url = "https://files.pythonhosted.org/packages/45/26/2cdb3d281ac1bd175603e290cbe4bad6eff127c0f8de90bafd6f8548f0fd/lxml-6.1.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2853c8b2170cc6cd54a6b4d50d2c1a8a7aeca201f23804b4898525c7a152cfc", size = 4993247, upload-time = "2026-04-18T04:33:36.674Z" }, + { url = "https://files.pythonhosted.org/packages/f6/05/d735aef963740022a08185c84821f689fc903acb3d50326e6b1e9886cc22/lxml-6.1.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e369cbd690e788c8d15e56222d91a09c6a417f49cbc543040cba0fe2e25a79e", size = 5613042, upload-time = "2026-04-18T04:33:39.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b8/ead7c10efff731738c72e59ed6eb5791854879fbed7ae98781a12006263a/lxml-6.1.0-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e69aa6805905807186eb00e66c6d97a935c928275182eb02ee40ba00da9623b2", size = 5228304, upload-time = "2026-04-18T04:33:41.647Z" }, + { url = "https://files.pythonhosted.org/packages/6b/10/e9842d2ec322ea65f0a7270aa0315a53abed06058b88ef1b027f620e7a5f/lxml-6.1.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:4bd1bdb8a9e0e2dd229de19b5f8aebac80e916921b4b2c6ef8a52bc131d0c1f9", size = 5341578, upload-time = "2026-04-18T04:33:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/89/54/40d9403d7c2775fa7301d3ddd3464689bfe9ba71acc17dfff777071b4fdc/lxml-6.1.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:cbd7b79cdcb4986ad78a2662625882747f09db5e4cd7b2ae178a88c9c51b3dfe", size = 4700209, upload-time = "2026-04-18T04:33:47.552Z" }, + { url = "https://files.pythonhosted.org/packages/85/b2/bbdcc2cf45dfc7dfffef4fd97e5c47b15919b6a365247d95d6f684ef5e82/lxml-6.1.0-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:43e4d297f11080ec9d64a4b1ad7ac02b4484c9f0e2179d9c4ef78e886e747b88", size = 5232365, upload-time = "2026-04-18T04:33:50.249Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/b06875665e53aaba7127611a7bed3b7b9658e20b22bc2dd217a0b7ab0091/lxml-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cc16682cc987a3da00aa56a3aa3075b08edb10d9b1e476938cfdbee8f3b67181", size = 5043654, upload-time = "2026-04-18T04:33:52.71Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9c/e71a069d09641c1a7abeb30e693f828c7c90a41cbe3d650b2d734d876f85/lxml-6.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d6d8efe71429635f0559579092bb5e60560d7b9115ee38c4adbea35632e7fa24", size = 4769326, upload-time = "2026-04-18T04:33:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/cc/06/7a9cd84b3d4ed79adf35f874750abb697dec0b4a81a836037b36e47c091a/lxml-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e39ab3a28af7784e206d8606ec0e4bcad0190f63a492bca95e94e5a4aef7f6e", size = 5635879, upload-time = "2026-04-18T04:33:58.509Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f0/9d57916befc1e54c451712c7ee48e9e74e80ae4d03bdce49914e0aee42cd/lxml-6.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9eb667bf50856c4a58145f8ca2d5e5be160191e79eb9e30855a476191b3c3495", size = 5224048, upload-time = "2026-04-18T04:34:00.943Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/90c4eefda0c08c92221fe0753db2d6699a4c628f76ff4465ec20dea84cc1/lxml-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7f4a77d6f7edf9230cee3e1f7f6764722a41604ee5681844f18db9a81ea0ec33", size = 5250241, upload-time = "2026-04-18T04:34:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/5e/73/16596f7e4e38fa33084b9ccbccc22a15f82a290a055126f2c1541236d2ff/lxml-6.1.0-cp313-cp313-win32.whl", hash = "sha256:28902146ffbe5222df411c5d19e5352490122e14447e98cd118907ee3fd6ee62", size = 3596938, upload-time = "2026-04-18T04:31:56.206Z" }, + { url = "https://files.pythonhosted.org/packages/8e/63/981401c5680c1eb30893f00a19641ac80db5d1e7086c62cb4b13ed813038/lxml-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:4a1503c56e4e2b38dc76f2f2da7bae69670c0f1933e27cfa34b2fa5876410b16", size = 3995728, upload-time = "2026-04-18T04:31:58.763Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/c358a38ac3e541d16a1b527e4e9cb78c0419b0506a070ace11777e5e8404/lxml-6.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:e0af85773850417d994d019741239b901b22c6680206f46a34766926e466141d", size = 3658372, upload-time = "2026-04-18T04:32:03.629Z" }, +] + +[[package]] +name = "markdown" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/7dd27d9d863b3376fcf23a5a13cb5d024aed1db46f963f1b5735ae43b3be/markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e", size = 364931, upload-time = "2025-11-03T19:51:15.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/81/54e3ce63502cd085a0c556652a4e1b919c45a446bd1e5300e10c44c8c521/markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c", size = 107678, upload-time = "2025-11-03T19:51:13.887Z" }, +] + +[[package]] +name = "markdown-link-attr-modifier" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/30/d35aad054a27f119bff2408523d82c3f9a6d9936712c872f5b9fe817de5b/markdown_link_attr_modifier-0.2.1.tar.gz", hash = "sha256:18df49a9fe7b5c87dad50b75c2a2299ae40c65674f7b1263fb12455f5df7ac99", size = 18408, upload-time = "2023-04-13T16:00:12.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/82/9262a67313847fdcc6252a007f032924fe0c0b6d6b9ef0d0b1fa58952c72/markdown_link_attr_modifier-0.2.1-py3-none-any.whl", hash = "sha256:6b4415319648cbe6dfb7a54ca12fa69e61a27c86a09d15f2a9a559ace0aa87c5", size = 17146, upload-time = "2023-04-13T16:00:06.559Z" }, +] + +[[package]] +name = "mdx-gh-links" +version = "0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/ea/bf1f721a8dc0ff83b426480f040ac68dbe3d7898b096c1277a5a4e3da0ec/mdx_gh_links-0.4.tar.gz", hash = "sha256:41d5aac2ab201425aa0a19373c4095b79e5e015fdacfe83c398199fe55ca3686", size = 5783, upload-time = "2023-12-22T19:54:02.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/c7/ccfe05ade98ba7a63f05d1b05b7508d9af743cbd1f1681aa0c9900a8cd40/mdx_gh_links-0.4-py3-none-any.whl", hash = "sha256:9057bca1fa5280bf1fcbf354381e46c9261cc32c2d5c0407801f8a910be5f099", size = 7166, upload-time = "2023-12-22T19:54:00.384Z" }, +] + +[[package]] +name = "mdx-truly-sane-lists" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/27/16456314311abac2cedef4527679924e80ac4de19dd926699c1b261e0b9b/mdx_truly_sane_lists-1.3.tar.gz", hash = "sha256:b661022df7520a1e113af7c355c62216b384c867e4f59fb8ee7ad511e6e77f45", size = 5359, upload-time = "2022-07-19T13:42:45.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/9e/dcd1027f7fd193aed152e01c6651a197c36b858f2cd1425ad04cb31a34fc/mdx_truly_sane_lists-1.3-py3-none-any.whl", hash = "sha256:b9546a4c40ff8f1ab692f77cee4b6bfe8ddf9cccf23f0a24e71f3716fe290a37", size = 6071, upload-time = "2022-07-19T13:42:43.375Z" }, +] + +[[package]] +name = "nativespeechgeneration" +version = "1.7.0" +source = { editable = "." } +dependencies = [ + { name = "crowdin-api-client" }, + { name = "lxml" }, + { name = "markdown" }, + { name = "markdown-link-attr-modifier" }, + { name = "mdx-gh-links" }, + { name = "mdx-truly-sane-lists" }, + { name = "nh3" }, + { name = "pre-commit" }, + { name = "pyright", extra = ["nodejs"] }, + { name = "requests" }, + { name = "ruff" }, + { name = "scons" }, + { name = "uv" }, +] + +[package.metadata] +requires-dist = [ + { name = "crowdin-api-client", specifier = "==1.24.1" }, + { name = "lxml", specifier = "==6.1.0" }, + { name = "markdown", specifier = "==3.10" }, + { name = "markdown-link-attr-modifier", specifier = "==0.2.1" }, + { name = "mdx-gh-links", specifier = "==0.4" }, + { name = "mdx-truly-sane-lists", specifier = "==1.3" }, + { name = "nh3", specifier = "==0.3.2" }, + { name = "pre-commit", specifier = "==4.2.0" }, + { name = "pyright", extras = ["nodejs"], specifier = "==1.1.407" }, + { name = "requests", specifier = "==2.33.0" }, + { name = "ruff", specifier = "==0.14.10" }, + { name = "scons", specifier = "==4.10.1" }, + { name = "uv", specifier = "==0.11.6" }, +] + +[[package]] +name = "nh3" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/a5/34c26015d3a434409f4d2a1cd8821a06c05238703f49283ffeb937bef093/nh3-0.3.2.tar.gz", hash = "sha256:f394759a06df8b685a4ebfb1874fb67a9cbfd58c64fc5ed587a663c0e63ec376", size = 19288, upload-time = "2025-10-30T11:17:45.948Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/3e/f5a5cc2885c24be13e9b937441bd16a012ac34a657fe05e58927e8af8b7a/nh3-0.3.2-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7064ccf5ace75825bd7bf57859daaaf16ed28660c1c6b306b649a9eda4b54b1e", size = 1431980, upload-time = "2025-10-30T11:17:25.457Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f7/529a99324d7ef055de88b690858f4189379708abae92ace799365a797b7f/nh3-0.3.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8745454cdd28bbbc90861b80a0111a195b0e3961b9fa2e672be89eb199fa5d8", size = 820805, upload-time = "2025-10-30T11:17:26.98Z" }, + { url = "https://files.pythonhosted.org/packages/3d/62/19b7c50ccd1fa7d0764822d2cea8f2a320f2fd77474c7a1805cb22cf69b0/nh3-0.3.2-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72d67c25a84579f4a432c065e8b4274e53b7cf1df8f792cf846abfe2c3090866", size = 803527, upload-time = "2025-10-30T11:17:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ca/f022273bab5440abff6302731a49410c5ef66b1a9502ba3fbb2df998d9ff/nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:13398e676a14d6233f372c75f52d5ae74f98210172991f7a3142a736bd92b131", size = 1051674, upload-time = "2025-10-30T11:17:29.909Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f7/5728e3b32a11daf5bd21cf71d91c463f74305938bc3eb9e0ac1ce141646e/nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03d617e5c8aa7331bd2659c654e021caf9bba704b109e7b2b28b039a00949fe5", size = 1004737, upload-time = "2025-10-30T11:17:31.205Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/f17e0dba0a99cee29e6cee6d4d52340ef9cb1f8a06946d3a01eb7ec2fb01/nh3-0.3.2-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2f55c4d2d5a207e74eefe4d828067bbb01300e06e2a7436142f915c5928de07", size = 911745, upload-time = "2025-10-30T11:17:32.945Z" }, + { url = "https://files.pythonhosted.org/packages/42/0f/c76bf3dba22c73c38e9b1113b017cf163f7696f50e003404ec5ecdb1e8a6/nh3-0.3.2-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb18403f02b655a1bbe4e3a4696c2ae1d6ae8f5991f7cacb684b1ae27e6c9f7", size = 797184, upload-time = "2025-10-30T11:17:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/08/a1/73d8250f888fb0ddf1b119b139c382f8903d8bb0c5bd1f64afc7e38dad1d/nh3-0.3.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6d66f41672eb4060cf87c037f760bdbc6847852ca9ef8e9c5a5da18f090abf87", size = 838556, upload-time = "2025-10-30T11:17:35.875Z" }, + { url = "https://files.pythonhosted.org/packages/d1/09/deb57f1fb656a7a5192497f4a287b0ade5a2ff6b5d5de4736d13ef6d2c1f/nh3-0.3.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f97f8b25cb2681d25e2338148159447e4d689aafdccfcf19e61ff7db3905768a", size = 1006695, upload-time = "2025-10-30T11:17:37.071Z" }, + { url = "https://files.pythonhosted.org/packages/b6/61/8f4d41c4ccdac30e4b1a4fa7be4b0f9914d8314a5058472f84c8e101a418/nh3-0.3.2-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2ab70e8c6c7d2ce953d2a58102eefa90c2d0a5ed7aa40c7e29a487bc5e613131", size = 1075471, upload-time = "2025-10-30T11:17:38.225Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c6/966aec0cb4705e69f6c3580422c239205d5d4d0e50fac380b21e87b6cf1b/nh3-0.3.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:1710f3901cd6440ca92494ba2eb6dc260f829fa8d9196b659fa10de825610ce0", size = 1002439, upload-time = "2025-10-30T11:17:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c8/97a2d5f7a314cce2c5c49f30c6f161b7f3617960ade4bfc2fd1ee092cb20/nh3-0.3.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91e9b001101fb4500a2aafe3e7c92928d85242d38bf5ac0aba0b7480da0a4cd6", size = 987439, upload-time = "2025-10-30T11:17:40.81Z" }, + { url = "https://files.pythonhosted.org/packages/0d/95/2d6fc6461687d7a171f087995247dec33e8749a562bfadd85fb5dbf37a11/nh3-0.3.2-cp38-abi3-win32.whl", hash = "sha256:169db03df90da63286e0560ea0efa9b6f3b59844a9735514a1d47e6bb2c8c61b", size = 589826, upload-time = "2025-10-30T11:17:42.239Z" }, + { url = "https://files.pythonhosted.org/packages/64/9a/1a1c154f10a575d20dd634e5697805e589bbdb7673a0ad00e8da90044ba7/nh3-0.3.2-cp38-abi3-win_amd64.whl", hash = "sha256:562da3dca7a17f9077593214a9781a94b8d76de4f158f8c895e62f09573945fe", size = 596406, upload-time = "2025-10-30T11:17:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7e/a96255f63b7aef032cbee8fc4d6e37def72e3aaedc1f72759235e8f13cb1/nh3-0.3.2-cp38-abi3-win_arm64.whl", hash = "sha256:cf5964d54edd405e68583114a7cba929468bcd7db5e676ae38ee954de1cfc104", size = 584162, upload-time = "2025-10-30T11:17:44.96Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "nodejs-wheel-binaries" +version = "24.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/70/a1e4f4d5986768ab90cc860b1cc3660fd2ded74ca175a900a5c29f839c7d/nodejs_wheel_binaries-24.15.0.tar.gz", hash = "sha256:b43f5c4f6e5768d8845b2ae4682eb703a19bf7aadc84187e2d903ed3a611c859", size = 8057, upload-time = "2026-04-19T15:48:16.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/66/54051d14853d6ab4fb85f8be9b042b530be653357fb9a19557498bc91ab7/nodejs_wheel_binaries-24.15.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:a6232fa8b754220941f52388c8ead923f7c1c7fdf0ea0d98f657523bd9a81ef4", size = 55173485, upload-time = "2026-04-19T15:47:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5f/66acada164da5ca10a0824db021aa7394ae18396c550cd9280e839a43126/nodejs_wheel_binaries-24.15.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:001a6b62c69d9109c1738163cca00608dd2722e8663af59300054ea02610972d", size = 55348100, upload-time = "2026-04-19T15:47:40.521Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2d/0cbd5ff40c9bb030ca1735d8f8793bd74f08a4cbd49100a1d19313ea57ab/nodejs_wheel_binaries-24.15.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:0fbc48765e60ed0ff30d43898dbf5cadbadf2e5f1e7f204afc2b01493b7ebce6", size = 59668206, upload-time = "2026-04-19T15:47:46.848Z" }, + { url = "https://files.pythonhosted.org/packages/da/d5/91ac63951ec75927a486b83b8cafe650e360fa70ac01dc94adfb32b93b97/nodejs_wheel_binaries-24.15.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:20ee0536809795da8a4942fc1ab4cbdebbcaaf29383eab67ba8874268fb00008", size = 60206736, upload-time = "2026-04-19T15:47:52.668Z" }, + { url = "https://files.pythonhosted.org/packages/db/72/dc22776974d928869c0c30d23ee98ed7df254243c2df68f09f5963e8e8b8/nodejs_wheel_binaries-24.15.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1fade6c214285e72472ca40a631e98ff36559671cd5eefc8bf009471d67f04b4", size = 61720456, upload-time = "2026-04-19T15:47:58.325Z" }, + { url = "https://files.pythonhosted.org/packages/01/0a/34461b9050cb45ee371dccdefc622aef6351506ea2691b08fc761ca67150/nodejs_wheel_binaries-24.15.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3984cb8d87766567aee67a49743227ab40ede6f47734ec990ff90e50b74e7740", size = 62326172, upload-time = "2026-04-19T15:48:04.094Z" }, + { url = "https://files.pythonhosted.org/packages/c9/17/09252bf35672dba926649d59dfe51443a0f6955ad13784e91131d5ec82a2/nodejs_wheel_binaries-24.15.0-py2.py3-none-win_amd64.whl", hash = "sha256:a437601956b532dcb3082046e6978e622733f90edc0932cbb9adb3bb97a16501", size = 41543461, upload-time = "2026-04-19T15:48:09.332Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/b649777d148e1e0c2ce349156603cdb12f7ed99921b95d93717393650193/nodejs_wheel_binaries-24.15.0-py2.py3-none-win_arm64.whl", hash = "sha256:bdf4a431e08321a32efc604111c6f23941f87055d796a537e8c4110daecad23f", size = 39233248, upload-time = "2026-04-19T15:48:13.326Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424, upload-time = "2025-03-18T21:35:20.987Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.407" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/1b/0aa08ee42948b61745ac5b5b5ccaec4669e8884b53d31c8ec20b2fcd6b6f/pyright-1.1.407.tar.gz", hash = "sha256:099674dba5c10489832d4a4b2d302636152a9a42d317986c38474c76fe562262", size = 4122872, upload-time = "2025-10-24T23:17:15.145Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/93/b69052907d032b00c40cb656d21438ec00b3a471733de137a3f65a49a0a0/pyright-1.1.407-py3-none-any.whl", hash = "sha256:6dd419f54fcc13f03b52285796d65e639786373f433e243f8b94cf93a7444d21", size = 5997008, upload-time = "2025-10-24T23:17:13.159Z" }, +] + +[package.optional-dependencies] +nodejs = [ + { name = "nodejs-wheel-binaries" }, +] + +[[package]] +name = "python-discovery" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/12/38c1a0b1e64806780c9563e3fc9f6e472251839662587cfbe9bfaf2ae10a/python_discovery-1.4.0.tar.gz", hash = "sha256:eb8bc7daad3c226c147e45bb4e970a1feb1bf4048ee178e6db59e197b8010ce3", size = 68455, upload-time = "2026-05-28T01:15:37.639Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/8d/3d316429f65029532bb1e28ff77b797d86b5ac3915bb44ca4e19aa283d43/python_discovery-1.4.0-py3-none-any.whl", hash = "sha256:26ed78d703e234879a66244c7d4114563fb13ec5cd30a2d1357e5fb4850782da", size = 33217, upload-time = "2026-05-28T01:15:36.573Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, +] + +[[package]] +name = "requests" +version = "2.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, + { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, + { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, + { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, + { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, + { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, +] + +[[package]] +name = "scons" +version = "4.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/c9/2f430bb39e4eccba32ce8008df4a3206df651276422204e177a09e12b30b/scons-4.10.1.tar.gz", hash = "sha256:99c0e94a42a2c1182fa6859b0be697953db07ba936ecc9817ae0d218ced20b15", size = 3258403, upload-time = "2025-11-16T22:43:39.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/bf/931fb9fbb87234c32b8b1b1c15fba23472a10777c12043336675633809a7/scons-4.10.1-py3-none-any.whl", hash = "sha256:bd9d1c52f908d874eba92a8c0c0a8dcf2ed9f3b88ab956d0fce1da479c4e7126", size = 4136069, upload-time = "2025-11-16T22:43:35.933Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uv" +version = "0.11.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/f3/8aceeab67ea69805293ab290e7ca8cc1b61a064d28b8a35c76d8eba063dd/uv-0.11.6.tar.gz", hash = "sha256:e3b21b7e80024c95ff339fcd147ac6fc3dd98d3613c9d45d3a1f4fd1057f127b", size = 4073298, upload-time = "2026-04-09T12:09:01.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/fe/4b61a3d5ad9d02e8a4405026ccd43593d7044598e0fa47d892d4dafe44c9/uv-0.11.6-py3-none-linux_armv6l.whl", hash = "sha256:ada04dcf89ddea5b69d27ac9cdc5ef575a82f90a209a1392e930de504b2321d6", size = 23780079, upload-time = "2026-04-09T12:08:56.609Z" }, + { url = "https://files.pythonhosted.org/packages/52/db/d27519a9e1a5ffee9d71af1a811ad0e19ce7ab9ae815453bef39dd479389/uv-0.11.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5be013888420f96879c6e0d3081e7bcf51b539b034a01777041934457dfbedf3", size = 23214721, upload-time = "2026-04-09T12:09:32.228Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8f/4399fa8b882bd7e0efffc829f73ab24d117d490a93e6bc7104a50282b854/uv-0.11.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ffa5dc1cbb52bdce3b8447e83d1601a57ad4da6b523d77d4b47366db8b1ceb18", size = 21750109, upload-time = "2026-04-09T12:09:24.357Z" }, + { url = "https://files.pythonhosted.org/packages/32/07/5a12944c31c3dda253632da7a363edddb869ed47839d4d92a2dc5f546c93/uv-0.11.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:bfb107b4dade1d2c9e572992b06992d51dd5f2136eb8ceee9e62dd124289e825", size = 23551146, upload-time = "2026-04-09T12:09:10.439Z" }, + { url = "https://files.pythonhosted.org/packages/79/5b/2ec8b0af80acd1016ed596baf205ddc77b19ece288473b01926c4a9cf6db/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:9e2fe7ce12161d8016b7deb1eaad7905a76ff7afec13383333ca75e0c4b5425d", size = 23331192, upload-time = "2026-04-09T12:09:34.792Z" }, + { url = "https://files.pythonhosted.org/packages/62/7d/eea35935f2112b21c296a3e42645f3e4b1aa8bcd34dcf13345fbd55134b7/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ed9c6f70c25e8dfeedddf4eddaf14d353f5e6b0eb43da9a14d3a1033d51d915", size = 23337686, upload-time = "2026-04-09T12:09:18.522Z" }, + { url = "https://files.pythonhosted.org/packages/21/47/2584f5ab618f6ebe9bdefb2f765f2ca8540e9d739667606a916b35449eec/uv-0.11.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d68a013e609cebf82077cbeeb0809ed5e205257814273bfd31e02fc0353bbfc2", size = 25008139, upload-time = "2026-04-09T12:09:03.983Z" }, + { url = "https://files.pythonhosted.org/packages/95/81/497ae5c1d36355b56b97dc59f550c7e89d0291c163a3f203c6f341dff195/uv-0.11.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93f736dddca03dae732c6fdea177328d3bc4bf137c75248f3d433c57416a4311", size = 25712458, upload-time = "2026-04-09T12:09:07.598Z" }, + { url = "https://files.pythonhosted.org/packages/3c/1c/74083238e4fab2672b63575b9008f1ea418b02a714bcfcf017f4f6a309b6/uv-0.11.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e96a66abe53fced0e3389008b8d2eff8278cfa8bb545d75631ae8ceb9c929aba", size = 24915507, upload-time = "2026-04-09T12:08:50.892Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ee/e14fe10ba455a823ed18233f12de6699a601890905420b5c504abf115116/uv-0.11.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b096311b2743b228df911a19532b3f18fa420bf9530547aecd6a8e04bbfaccd", size = 24971011, upload-time = "2026-04-09T12:08:54.016Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/7b9c83eaadf98e343317ff6384a7227a4855afd02cdaf9696bcc71ee6155/uv-0.11.6-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:904d537b4a6e798015b4a64ff5622023bd4601b43b6cd1e5f423d63471f5e948", size = 23640234, upload-time = "2026-04-09T12:09:15.735Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/75ccdd23e76ff1703b70eb82881cd5b4d2a954c9679f8ef7e0136ef2cfab/uv-0.11.6-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:4ed8150c26b5e319381d75ae2ce6aba1e9c65888f4850f4e3b3fa839953c90a5", size = 24452664, upload-time = "2026-04-09T12:09:26.875Z" }, + { url = "https://files.pythonhosted.org/packages/4d/86/ace80fe47d8d48b5e3b5aee0b6eb1a49deaacc2313782870250b3faa36f5/uv-0.11.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1c9218c8d4ac35ca6e617fb0951cc0ab2d907c91a6aea2617de0a5494cf162c0", size = 24494599, upload-time = "2026-04-09T12:09:37.368Z" }, + { url = "https://files.pythonhosted.org/packages/05/2d/4b642669b56648194f026de79bc992cbfc3ac2318b0a8d435f3c284934e8/uv-0.11.6-py3-none-musllinux_1_1_i686.whl", hash = "sha256:9e211c83cc890c569b86a4183fcf5f8b6f0c7adc33a839b699a98d30f1310d3a", size = 24159150, upload-time = "2026-04-09T12:09:13.17Z" }, + { url = "https://files.pythonhosted.org/packages/ae/24/7eecd76fe983a74fed1fc700a14882e70c4e857f1d562a9f2303d4286c12/uv-0.11.6-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:d2a1d2089afdf117ad19a4c1dd36b8189c00ae1ad4135d3bfbfced82342595cf", size = 25164324, upload-time = "2026-04-09T12:08:59.56Z" }, + { url = "https://files.pythonhosted.org/packages/27/e0/bbd4ba7c2e5067bbba617d87d306ec146889edaeeaa2081d3e122178ca08/uv-0.11.6-py3-none-win32.whl", hash = "sha256:6e8344f38fa29f85dcfd3e62dc35a700d2448f8e90381077ef393438dcd5012e", size = 22865693, upload-time = "2026-04-09T12:09:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/a5/33/1983ce113c538a856f2d620d16e39691962ecceef091a84086c5785e32e5/uv-0.11.6-py3-none-win_amd64.whl", hash = "sha256:a28bea69c1186303d1200f155c7a28c449f8a4431e458fcf89360cc7ef546e40", size = 25371258, upload-time = "2026-04-09T12:09:40.52Z" }, + { url = "https://files.pythonhosted.org/packages/35/01/be0873f44b9c9bc250fcbf263367fcfc1f59feab996355bcb6b52fff080d/uv-0.11.6-py3-none-win_arm64.whl", hash = "sha256:a78f6d64b9950e24061bc7ec7f15ff8089ad7f5a976e7b65fcadce58fe02f613", size = 23869585, upload-time = "2026-04-09T12:09:29.425Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/f0/b47ecf438211a25a97f8f0e4b23c22bc2496ebfea18dd6ec16210f09cc36/virtualenv-21.4.1.tar.gz", hash = "sha256:2ca543c713b72840ceffd94e9bdedfbd09a661defa1f7f69e5429ad4059442e2", size = 7613344, upload-time = "2026-05-28T04:12:49.905Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/dc/ac4f3a987a87e1a18556896f257c4e15c95ed157b7975347ec6b313b75ce/virtualenv-21.4.1-py3-none-any.whl", hash = "sha256:caf4ff72d1b4039057f41d8e8466e859513d67c0400d9c6b62c02c9d1ebc3e12", size = 7594078, upload-time = "2026-05-28T04:12:47.686Z" }, +] + +[[package]] +name = "wrapt" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/9f/06263fcd8ad6c405f05a3905fd7a84dd3176eb5ad46e44bccc0cd16348bb/wrapt-2.2.1.tar.gz", hash = "sha256:6744f504375775d7609c82c8d3d94af1c9a6f05586984536905908ba905277b9", size = 127620, upload-time = "2026-05-22T14:49:43.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/d1/a1b08f8f4fac8cbb156fa51cf64ee2c7f7f74f9875ba3cf70b3c58368694/wrapt-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d2beb1c7cab10603aecdc42f8edd6ff013f9a32e4543474e38e6b77ce9975aeb", size = 80831, upload-time = "2026-05-22T14:48:15.598Z" }, + { url = "https://files.pythonhosted.org/packages/54/ce/57890814991446a845e09b3445ce8b694f27eb0577004f2c2a36a9772ed4/wrapt-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0cb7e4dd71f4c32e5e84843cd3c4cd65dda034314004bbe1d7f99af2426ab80", size = 81375, upload-time = "2026-05-22T14:48:17.071Z" }, + { url = "https://files.pythonhosted.org/packages/38/65/08d7a6c76ac4493bdb668205ee9c1de1bd5daca61717c3e9aa49b4c01499/wrapt-2.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95821352042722cd9f1108874579a47989d0a7e12a37d87d2fc4af20fd99ab8a", size = 167417, upload-time = "2026-05-22T14:48:18.303Z" }, + { url = "https://files.pythonhosted.org/packages/62/ce/f1ccbee7a1bfe5cdc6b3da6bab4b45713d628b9294da32a39f563d648140/wrapt-2.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abd621552ede77c4c69be7fac44ba911225b0c812b6ba604e5964cf98085b474", size = 166948, upload-time = "2026-05-22T14:48:19.768Z" }, + { url = "https://files.pythonhosted.org/packages/86/2a/f85d48d1cd4869aee6704028d257d740a47c1c467b457ce396b4b5b55d07/wrapt-2.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e3677c7146ce694874941ba82b57092cc4875445aadf29d72807351023105143", size = 158148, upload-time = "2026-05-22T14:48:21.96Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5c/93939ad11d4a12358ab1aab219a2ef5efa5612e0db6b9fc65af8af1a891b/wrapt-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9a5934eaea872e17936b5f45501eba5ab0bce9a74122e172b663d7c28c459c4a", size = 165905, upload-time = "2026-05-22T14:48:23.373Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/b8c2aa89862ff58605934d7abf4b70e6a5a1c33df96656f49035ccdf1c8a/wrapt-2.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f5b9daf6b629fce418e0cc3dd0436eac045188fa35deadb7a7f3941d5b8203f9", size = 156712, upload-time = "2026-05-22T14:48:24.767Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/bf00a7b02239c12bb02ddcc3c0b971bfcc36e578c5a44f1ccfef5b458545/wrapt-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f53ac9f3ef573326d009ed809beff4efcac6451931c2b8132586da4b9e53ff31", size = 166560, upload-time = "2026-05-22T14:48:26.83Z" }, + { url = "https://files.pythonhosted.org/packages/fe/93/6390ca9c5b787683cef588d04f57c8d41b9a2323b5597a65f18638c90ef2/wrapt-2.2.1-cp313-cp313-win32.whl", hash = "sha256:1ffa9cfd4bdb581539951b14ae661ff20ed0c3599b3e911a131ee0ec5ac11337", size = 77817, upload-time = "2026-05-22T14:48:28.221Z" }, + { url = "https://files.pythonhosted.org/packages/97/73/ce10f0e71c0cfaa1a65faadb8efd4852028b3bb9ba28932b8889df769d38/wrapt-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:368eac1e20fd0bb03dd3cc42bf9887154c3861b60989389ccb5fac032617d215", size = 80736, upload-time = "2026-05-22T14:48:30.139Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4c/89f4a6818fafbbd840330e4fa3873073e1bfc166133a64cac7f8fde7a5e3/wrapt-2.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:c754dafdf5aaf0b401b644a90a30046929a0dd1a536e0ff0ec959a59155d9c7f", size = 79099, upload-time = "2026-05-22T14:48:31.405Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f2/9a8741c46f8c208ac0a45b25ba170bcb4fb72a2781d5fb97dbd7b6be73cb/wrapt-2.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ed928d0fda15fc0adc8d13305c8b3c0f2fba5b0669950c9e6d019d9162a3b3e8", size = 82802, upload-time = "2026-05-22T14:48:33.307Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0d/e9c855716a3705eef1416456bdf062b60620726fdc59428ff670fc3c60dc/wrapt-2.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fafb4e739e43544d12cb4abd1605fd4683b6ca6a9ad682b7fd8f4d21973eafa8", size = 83329, upload-time = "2026-05-22T14:48:34.593Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d6/a88f1c13112b7831adac75cea65d8310e0d696d570c8961844c90a57b865/wrapt-2.2.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:74d6a0c31472fe5d814917266b9f46495d7c61ed890af08b468acea92fb89a8d", size = 202937, upload-time = "2026-05-22T14:48:35.859Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/e29d54aef06a4d898a5b8a25589a0b3769bde454f922fad8f6f89fbfb650/wrapt-2.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab5be648d5a0b86b7438864f8df3c705a65cef35a2fd3e5561e3e203167e0f27", size = 209997, upload-time = "2026-05-22T14:48:38.153Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/e4454263516cf0e12640912fbca9a83654e424f0a6ddb79f5cd7ce14bf33/wrapt-2.2.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d8f204c8e3a8bf9ece17e0a83d137fd807440977f8a5e762d59306795011440", size = 194856, upload-time = "2026-05-22T14:48:39.69Z" }, + { url = "https://files.pythonhosted.org/packages/de/d0/fe0ee202286afdf4a7f77dd29f195703145764d572aec209c5086e57d924/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d047f6498c973874ba08ac3f97c69a2c4b2211c8de6f4c205f75cb1c9522596e", size = 205654, upload-time = "2026-05-22T14:48:43.456Z" }, + { url = "https://files.pythonhosted.org/packages/23/b6/87d860dfc6460c246af70b1fd5c8b76df77571b42a493459423ded94fd7d/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:7a4fdb9326aab4a5a477a1640e5ad786a8495901009d7e7b038371edd23a9d2b", size = 192206, upload-time = "2026-05-22T14:48:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/df/46/3eea8cde077d985f239a38c0257087b8064fd9ee9b1a99e282d2c86da4ef/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8cc5094b08abeae52da9c73c8a32003623be691a5193df2f4e3eac3d557c394", size = 198428, upload-time = "2026-05-22T14:48:46.319Z" }, + { url = "https://files.pythonhosted.org/packages/18/dc/b927ee9c7fc67adc3a5658f246a0d275425eb840ba36e7b702e70f18bde8/wrapt-2.2.1-cp313-cp313t-win32.whl", hash = "sha256:9907a4402ab6db12b7077a0ea5d7a4d028ecb22c8eee2b53527080d347cd1562", size = 79448, upload-time = "2026-05-22T14:48:47.901Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b3/fd30b473fe498c70e6b9a5f328b8d3fbaf1b8c3c481465f59724bba8eb70/wrapt-2.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:5590d63f5243251641cf543009b4c9314a79d0598fdb8a8e4cfc918494536c53", size = 83021, upload-time = "2026-05-22T14:48:49.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/96c39153a8737a6e9aa85adef254ac4195bea3f2d24efc60472ccc3c9e2e/wrapt-2.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:c318a64b53d97b841d7b5e637517e50a27be64bc695128422953d4b21710954e", size = 80295, upload-time = "2026-05-22T14:48:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000, upload-time = "2026-05-22T14:49:41.593Z" }, +]