diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml new file mode 100644 index 0000000..ba24239 --- /dev/null +++ b/.github/workflows/windows-build.yml @@ -0,0 +1,66 @@ +name: Build Windows executable + +on: + push: + tags: + - "v*" + +jobs: + build: + runs-on: windows-latest + permissions: + contents: write + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install Poetry + run: | + python -m pip install --upgrade pip + python -m pip install poetry + + - name: Configure Poetry cache + run: | + echo "POETRY_CACHE_DIR=${{ runner.temp }}\\poetry-cache" >> $Env:GITHUB_ENV + + - name: Install dependencies + run: | + poetry install --no-interaction --no-root + + - name: Download Parakeet assets + run: | + poetry run python -c "from huggingface_hub import hf_hub_download; repo_id='istupakov/parakeet-tdt-0.6b-v2-onnx'; [hf_hub_download(repo_id=repo_id, filename=name, local_dir='.', local_dir_use_symlinks=False) for name in ['encoder-model.onnx','decoder_joint-model.onnx','vocab.txt']]" + + - name: Build executable with PyInstaller + run: | + poetry run pyinstaller --log-level=DEBUG Parrator.spec + + - name: List build contents + run: | + dir dist + dir dist\Parrator + + - name: Package release archive + run: | + Compress-Archive -Path dist/Parrator/* -DestinationPath Parrator-windows.zip -Force + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: Parrator-windows-build + path: | + dist/Parrator + Parrator-windows.zip + + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: | + Parrator-windows.zip diff --git a/Parrator.spec b/Parrator.spec index f8d432a..3d07cc9 100644 --- a/Parrator.spec +++ b/Parrator.spec @@ -3,18 +3,20 @@ import sys from PyInstaller.utils.hooks import ( collect_dynamic_libs, collect_data_files, + collect_submodules, ) block_cipher = None -# Data files: (source, destination_folder) -datas = [ - ('vocab.txt', '.'), - ('decoder_joint-model.onnx', '.'), - ('encoder-model.onnx', '.'), - ('parrator/resources/icon.png', 'resources'), - ('parrator/resources/icon.ico', 'resources'), -] + collect_data_files('onnx_asr') +# Data files: (source, destination_folder) +datas = [ + ('vocab.txt', '.'), + ('decoder_joint-model.onnx', '.'), + ('encoder-model.onnx', '.'), + ('parrator/resources/icon.png', 'resources'), + ('parrator/resources/icon.ico', 'resources'), + ('parrator/hotkey_manager.py', 'parrator'), +] + collect_data_files('onnx_asr') # Binaries: dynamic libs from onnxruntime binaries = collect_dynamic_libs('onnxruntime') @@ -24,7 +26,18 @@ a = Analysis( pathex=['.'], binaries=binaries, datas=datas, - hiddenimports=['onnxruntime.capi._pybind_state'], +hiddenimports=[ + 'onnxruntime.capi._pybind_state', + 'pynput', + 'pynput.keyboard', + 'parrator.hotkey_manager', + 'parrator.audio_recorder', + 'parrator.config', + 'parrator.transcriber', + 'parrator.notifications', + 'parrator.startup', + 'parrator.tray_app' + ], hookspath=[], runtime_hooks=[], excludes=[], diff --git a/parrator/__init__.py b/parrator/__init__.py new file mode 100644 index 0000000..8857e68 --- /dev/null +++ b/parrator/__init__.py @@ -0,0 +1,6 @@ +"""Parrator package exports core components.""" + +from .hotkey_manager import HotkeyManager +from .tray_app import ParratorTrayApp + +__all__ = ["ParratorTrayApp", "HotkeyManager"] diff --git a/parrator/__main__.py b/parrator/__main__.py index dc95e69..d4ff8b0 100644 --- a/parrator/__main__.py +++ b/parrator/__main__.py @@ -3,8 +3,9 @@ Parrator Tray - Simple Speech-to-Text System Tray Application """ -import sys import signal +import sys + from parrator.tray_app import ParratorTrayApp diff --git a/parrator/audio_recorder.py b/parrator/audio_recorder.py index 3114cdf..5de96c4 100644 --- a/parrator/audio_recorder.py +++ b/parrator/audio_recorder.py @@ -1,7 +1,6 @@ -""" -Simplified audio recording functionality. -""" +"""Simplified audio recording functionality.""" +import contextlib import tempfile import threading from typing import List, Optional @@ -87,8 +86,6 @@ def save_temp_audio(self, audio_data: np.ndarray) -> Optional[str]: def cleanup(self): """Clean up audio resources.""" if self.stream: - try: + with contextlib.suppress(Exception): self.stream.stop() self.stream.close() - except: - pass diff --git a/parrator/hotkey_manager.py b/parrator/hotkey_manager.py index 94b45b9..92519aa 100644 --- a/parrator/hotkey_manager.py +++ b/parrator/hotkey_manager.py @@ -3,6 +3,7 @@ """ from typing import Callable, Optional + from pynput import keyboard @@ -26,8 +27,10 @@ def start(self) -> bool: self.hotkey_listener = keyboard.GlobalHotKeys(hotkey_map) self.hotkey_listener.start() - print(f"Hotkey '{self.hotkey_combo}' registered successfully as '{ - pynput_hotkey}'") + print( + "Hotkey " + f"'{self.hotkey_combo}' registered successfully as '{pynput_hotkey}'" + ) return True except Exception as e: diff --git a/parrator/notifications.py b/parrator/notifications.py index 312b0e5..6d394c1 100644 --- a/parrator/notifications.py +++ b/parrator/notifications.py @@ -1,9 +1,6 @@ -""" -Cross-platform system notifications. -""" +"""Cross-platform system notifications.""" import platform -from typing import Optional class NotificationManager: @@ -42,7 +39,7 @@ def _show_macos_notification(self, title: str, message: str): import subprocess script = f'display notification "{message}" with title "{title}"' subprocess.run(['osascript', '-e', script], check=True) - except: + except Exception: self._show_plyer_notification(title, message) def _show_linux_notification(self, title: str, message: str): @@ -50,7 +47,7 @@ def _show_linux_notification(self, title: str, message: str): try: import subprocess subprocess.run(['notify-send', title, message], check=True) - except: + except Exception: self._show_plyer_notification(title, message) def _show_plyer_notification(self, title: str, message: str): diff --git a/parrator/startup.py b/parrator/startup.py index 7a8d042..67a8235 100644 --- a/parrator/startup.py +++ b/parrator/startup.py @@ -1,10 +1,8 @@ -""" -Cross-platform startup integration. -""" +"""Cross-platform startup integration.""" import os -import sys import platform +import sys class StartupManager: @@ -60,7 +58,7 @@ def _is_windows_startup_enabled(self) -> bool: winreg.QueryValueEx(key, self.app_name) winreg.CloseKey(key) return True - except: + except OSError: return False def _enable_windows_startup(self) -> bool: @@ -90,7 +88,7 @@ def _disable_windows_startup(self) -> bool: winreg.DeleteValue(key, self.app_name) winreg.CloseKey(key) return True - except: + except OSError: return False # macOS implementation @@ -130,7 +128,7 @@ def _disable_macos_startup(self) -> bool: if os.path.exists(plist_path): os.remove(plist_path) return True - except: + except OSError: return False def _get_macos_plist_path(self) -> str: @@ -169,7 +167,7 @@ def _disable_linux_startup(self) -> bool: if os.path.exists(desktop_path): os.remove(desktop_path) return True - except: + except OSError: return False def _get_linux_desktop_path(self) -> str: diff --git a/parrator/transcriber.py b/parrator/transcriber.py index 9f75397..a5589ef 100644 --- a/parrator/transcriber.py +++ b/parrator/transcriber.py @@ -22,12 +22,15 @@ def __init__(self, config: Config): def load_model(self) -> bool: """Load the transcription model.""" try: - model_name = self.config.get( - 'model_name', 'nemo-parakeet-tdt-0.6b-v2') + model_name = self.config.get("model_name", "nemo-parakeet-tdt-0.6b-v2") # Get available ONNX providers providers = self._get_providers() + # Check if local model files exist (for PyInstaller builds) + if self._try_load_local_model(providers): + return True + print(f"Loading model: {model_name}") self.model = load_model(model_name, providers=providers) self.model_name = model_name @@ -54,10 +57,10 @@ def transcribe_file(self, audio_path: str) -> Tuple[bool, Optional[str]]: if isinstance(result, str): text = result.strip() elif isinstance(result, list) and result: - if isinstance(result[0], dict) and 'text' in result[0]: - text = ' '.join(s.get('text', '') for s in result).strip() + if isinstance(result[0], dict) and "text" in result[0]: + text = " ".join(s.get("text", "") for s in result).strip() else: - text = ' '.join(str(s) for s in result).strip() + text = " ".join(str(s) for s in result).strip() else: text = str(result).strip() @@ -71,11 +74,36 @@ def _get_providers(self): """Get ONNX runtime providers in preferred order.""" available = ort.get_available_providers() preferred = [ - 'DmlExecutionProvider', # DirectML (Windows/WSL) - 'ROCMExecutionProvider', # AMD GPU - 'CUDAExecutionProvider', # NVIDIA GPU - 'CPUExecutionProvider' # CPU fallback + "DmlExecutionProvider", # DirectML (Windows/WSL) + "ROCMExecutionProvider", # AMD GPU + "CUDAExecutionProvider", # NVIDIA GPU + "CPUExecutionProvider", # CPU fallback ] providers = [p for p in preferred if p in available] - return providers or ['CPUExecutionProvider'] + return providers or ["CPUExecutionProvider"] + + def _try_load_local_model(self, providers) -> bool: + """Try to load model from local files (for PyInstaller builds).""" + try: + # Check if model files exist in current directory + encoder_path = "encoder-model.onnx" + decoder_path = "decoder_joint-model.onnx" + vocab_path = "vocab.txt" + + if all(os.path.exists(f) for f in [encoder_path, decoder_path, vocab_path]): + print("Loading model from local files...") + self.model = load_model( + "nemo-parakeet-tdt-0.6b-v2", + encoder_path=encoder_path, + decoder_path=decoder_path, + vocab_path=vocab_path, + providers=providers, + ) + self.model_name = "nemo-parakeet-tdt-0.6b-v2 (local)" + print("Local model loaded successfully") + return True + return False + except Exception as e: + print(f"Failed to load local model: {e}") + return False diff --git a/parrator/tray_app.py b/parrator/tray_app.py index 64795e7..661fb99 100644 --- a/parrator/tray_app.py +++ b/parrator/tray_app.py @@ -2,22 +2,23 @@ Simplified tray application. """ +import contextlib import os +import subprocess import sys import threading import time -import subprocess from typing import Optional import pystray from PIL import Image +from .audio_recorder import AudioRecorder from .config import Config from .hotkey_manager import HotkeyManager -from .audio_recorder import AudioRecorder -from .transcriber import Transcriber from .notifications import NotificationManager from .startup import StartupManager +from .transcriber import Transcriber class ParratorTrayApp: @@ -161,10 +162,8 @@ def process(): success, text = self.transcriber.transcribe_file(temp_path) # Cleanup temp file - try: + with contextlib.suppress(Exception): os.remove(temp_path) - except: - pass if success and text: self._handle_transcription_result(text)