Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .github/workflows/windows-build.yml
Original file line number Diff line number Diff line change
@@ -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
31 changes: 22 additions & 9 deletions Parrator.spec
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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=[],
Expand Down
6 changes: 6 additions & 0 deletions parrator/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Parrator package exports core components."""

from .hotkey_manager import HotkeyManager
from .tray_app import ParratorTrayApp

__all__ = ["ParratorTrayApp", "HotkeyManager"]
3 changes: 2 additions & 1 deletion parrator/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
9 changes: 3 additions & 6 deletions parrator/audio_recorder.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""
Simplified audio recording functionality.
"""
"""Simplified audio recording functionality."""

import contextlib
import tempfile
import threading
from typing import List, Optional
Expand Down Expand Up @@ -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
7 changes: 5 additions & 2 deletions parrator/hotkey_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

from typing import Callable, Optional

from pynput import keyboard


Expand All @@ -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:
Expand Down
9 changes: 3 additions & 6 deletions parrator/notifications.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
"""
Cross-platform system notifications.
"""
"""Cross-platform system notifications."""

import platform
from typing import Optional


class NotificationManager:
Expand Down Expand Up @@ -42,15 +39,15 @@ 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):
"""Show Linux notification."""
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):
Expand Down
14 changes: 6 additions & 8 deletions parrator/startup.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
"""
Cross-platform startup integration.
"""
"""Cross-platform startup integration."""

import os
import sys
import platform
import sys


class StartupManager:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
48 changes: 38 additions & 10 deletions parrator/transcriber.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()

Expand All @@ -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
11 changes: 5 additions & 6 deletions parrator/tray_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down