Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f0d84beb1 | |||
| aa2b0acd86 | |||
| 08b9ecc1cb | |||
| 02ef33023d | |||
| d509eb5efb | |||
| 7c80ecfbed | |||
| d8707b5ade | |||
| 07ad3b220d | |||
| dc15e11e8e | |||
| a70e76b4ab | |||
| d40c83cc45 | |||
| f2f80fc863 | |||
| a6cf9efbcb | |||
| 4615f3084f | |||
| 937061f710 | |||
| 798a35e6d9 | |||
| 6737ed4547 | |||
| aed489dd23 | |||
| e23c492360 | |||
| 84f10092e9 | |||
| 03f46ee1e3 | |||
| 0f1bf5f1af | |||
| 0b2b5848e2 | |||
| f3bf7541cf | |||
| 4b84a27a67 | |||
| f184eb0037 | |||
| 306bd075ed | |||
| a1cc9c61b9 | |||
| e627e1b8aa | |||
| eaa572b42f | |||
| e900201214 | |||
| 0d426aea4b | |||
| b15ce8076f | |||
| 26f1d8a3e7 | |||
| 9ff0e8d108 |
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
## 📡 The Transmission
|
## 📡 The Transmission
|
||||||
|
|
||||||
We are witnessing the **enshittification** of the digital world. What were once thriving social commons are being walled off, strip-mined for data, and degraded into rent-seeking silos. Your voice is no longer your own; it is a training set for a corporate oracle that charges you for the privilege of listening.
|
We are witnessing the **enshittification** of the digital world. What were once vibrant social commons are being walled off, strip-mined for data, and degraded into rent-seeking silos. Your voice is no longer your own; it is a training set for a corporate oracle that charges you for the privilege of listening.
|
||||||
|
|
||||||
**Whisper Voice** is a small act of sabotage against this trend.
|
**Whisper Voice** is a small act of sabotage against this trend.
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ The core engine (`CTranslate2`) is heavily optimized for Nvidia tensor cores.
|
|||||||
|
|
||||||
## 🖋️ Universal Transcription
|
## 🖋️ Universal Transcription
|
||||||
|
|
||||||
Whisper Voice is the bridge between thought and text. It listens with superhuman precision, converting spoken word into written form across **99 languages**.
|
At its core, Whisper Voice is the ultimate bridge between thought and text. It listens with superhuman precision, converting spoken word into written form across **99 languages**.
|
||||||
|
|
||||||
* **Punctuation Mastery**: Automatically handles capitalization and complex punctuation formatting.
|
* **Punctuation Mastery**: Automatically handles capitalization and complex punctuation formatting.
|
||||||
* **Contextual Intelligence**: Smarter than standard dictation; it understands the flow of sentences to resolve homophones and technical jargon ($1.5k vs "fifteen hundred dollars").
|
* **Contextual Intelligence**: Smarter than standard dictation; it understands the flow of sentences to resolve homophones and technical jargon ($1.5k vs "fifteen hundred dollars").
|
||||||
@@ -143,7 +143,7 @@ For users with limited GPU memory (e.g., 4GB cards) or those running heavy games
|
|||||||
|
|
||||||
## ♿ Accessibility (WCAG 2.2 AAA)
|
## ♿ Accessibility (WCAG 2.2 AAA)
|
||||||
|
|
||||||
Whisper Voice is built to be usable by everyone. The entire interface has been engineered to meet **WCAG 2.2 AAA** - the highest tier of accessibility compliance. This is not a checkbox exercise; it is a structural commitment.
|
Whisper Voice is built to be usable by everyone. The entire interface has been engineered to meet **WCAG 2.2 AAA** — the highest tier of accessibility compliance. This is not a checkbox exercise; it is a structural commitment.
|
||||||
|
|
||||||
### Color & Contrast
|
### Color & Contrast
|
||||||
Every design token is calibrated for **Enhanced Contrast** (WCAG 1.4.6, 7:1 minimum):
|
Every design token is calibrated for **Enhanced Contrast** (WCAG 1.4.6, 7:1 minimum):
|
||||||
@@ -156,7 +156,7 @@ Every design token is calibrated for **Enhanced Contrast** (WCAG 1.4.6, 7:1 mini
|
|||||||
| `borderSubtle` | 3:1 | Non-text contrast for borders and separators |
|
| `borderSubtle` | 3:1 | Non-text contrast for borders and separators |
|
||||||
|
|
||||||
### Keyboard Navigation
|
### Keyboard Navigation
|
||||||
Full keyboard access - no mouse required:
|
Full keyboard access — no mouse required:
|
||||||
|
|
||||||
* **Tab / Shift+Tab**: Navigate between all interactive controls (sliders, switches, buttons, dropdowns, text fields).
|
* **Tab / Shift+Tab**: Navigate between all interactive controls (sliders, switches, buttons, dropdowns, text fields).
|
||||||
* **Arrow Keys**: Navigate the Settings sidebar tabs.
|
* **Arrow Keys**: Navigate the Settings sidebar tabs.
|
||||||
@@ -166,7 +166,7 @@ Full keyboard access - no mouse required:
|
|||||||
### Screen Reader Support
|
### Screen Reader Support
|
||||||
Every component is annotated with semantic roles and descriptive names:
|
Every component is annotated with semantic roles and descriptive names:
|
||||||
|
|
||||||
* Buttons, sliders, checkboxes, combo boxes, text fields - all declare their `Accessible.role` and `Accessible.name`.
|
* Buttons, sliders, checkboxes, combo boxes, text fields — all declare their `Accessible.role` and `Accessible.name`.
|
||||||
* Switches report "on" / "off" state in their accessible name.
|
* Switches report "on" / "off" state in their accessible name.
|
||||||
* The loader status uses `AlertMessage` for live-region announcements.
|
* The loader status uses `AlertMessage` for live-region announcements.
|
||||||
* Settings tabs use `Tab` / `PageTab` roles matching WAI-ARIA patterns.
|
* Settings tabs use `Tab` / `PageTab` roles matching WAI-ARIA patterns.
|
||||||
@@ -239,6 +239,8 @@ The engine understands the following 99 languages. You can lock the focus to a s
|
|||||||
### ⚖️ PUBLIC DOMAIN (CC0 1.0)
|
### ⚖️ PUBLIC DOMAIN (CC0 1.0)
|
||||||
*No Rights Reserved. No Gods. No Masters. No Managers.*
|
*No Rights Reserved. No Gods. No Masters. No Managers.*
|
||||||
|
|
||||||
|
This project is developed with the help of a locally-run LLM via opencode for scaffolding, planning, and routine code tasks. Architecture and design decisions are my own.
|
||||||
|
|
||||||
Credit to **OpenAI** (Whisper), **Systran** (Faster-Whisper), and **Silero** (VAD).
|
Credit to **OpenAI** (Whisper), **Systran** (Faster-Whisper), and **Silero** (VAD).
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Release v1.0.4
|
||||||
|
|
||||||
|
**"The Compatibility Update"**
|
||||||
|
|
||||||
|
This release focuses on maximum stability across different hardware configurations (AMD, Intel, Nvidia) and fixing startup crashes related to corrupted models or missing drivers.
|
||||||
|
|
||||||
|
## 🛠️ Critical Fixes
|
||||||
|
|
||||||
|
### 1. Robust CPU Fallback (AMD / Intel Support)
|
||||||
|
* **Problem**: Previously, if an AMD user tried to run the app, it would crash instantly because it tried to load Nvidia CUDA libraries by default.
|
||||||
|
* **Fix**: The app now **silently detects** if CUDA initialization fails (due to missing DLLs or incompatible hardware) and **automatically falls back to CPU mode**.
|
||||||
|
* **Result**: The app "just works" on any Windows machine, regardless of GPU.
|
||||||
|
|
||||||
|
### 2. Startup Crash Protection
|
||||||
|
* **Problem**: If `faster_whisper` was imported before checking for valid drivers, the app would crash on launch for some users.
|
||||||
|
* **Fix**: Implemented **Lazy Loading** for the AI engine. The app now starts the UI first, and only loads the heavy AI libraries inside a safety block that catches errors.
|
||||||
|
|
||||||
|
### 3. Corrupt Model Auto-Repair
|
||||||
|
* **Problem**: Interrupted downloads could leave a corrupted model folder, preventing the app from ever starting again.
|
||||||
|
* **Fix**: If the app detects a "vocabulary missing" or invalid config error, it will now **automatically delete the corrupt folder** and allow you to re-download it cleanly.
|
||||||
|
|
||||||
|
### 4. Windows DLL Injection
|
||||||
|
* **Fix**: Added explicit DLL path injection for `nvidia-cublas` and `nvidia-cudnn` to ensure Python 3.8+ can find the required CUDA libraries on Windows systems that don't have them in PATH.
|
||||||
|
|
||||||
|
## 📦 Installation
|
||||||
|
1. Download `WhisperVoice.exe` below.
|
||||||
|
2. Replace your existing `.exe`.
|
||||||
|
3. Run it.
|
||||||
+7
-8
@@ -1,5 +1,5 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
# WhisperVoice - Single-file portable bootstrapper
|
# WhisperVoice — Single-file portable bootstrapper
|
||||||
#
|
#
|
||||||
# This builds a TINY exe that contains only:
|
# This builds a TINY exe that contains only:
|
||||||
# - The bootstrapper (downloads Python + deps on first run)
|
# - The bootstrapper (downloads Python + deps on first run)
|
||||||
@@ -8,7 +8,6 @@
|
|||||||
# NO heavy dependencies (torch, PySide6, etc.) are bundled.
|
# NO heavy dependencies (torch, PySide6, etc.) are bundled.
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import glob
|
import glob
|
||||||
|
|
||||||
block_cipher = None
|
block_cipher = None
|
||||||
@@ -23,7 +22,7 @@ app_datas.append(('main.py', 'app_source'))
|
|||||||
# requirements.txt
|
# requirements.txt
|
||||||
app_datas.append(('requirements.txt', 'app_source'))
|
app_datas.append(('requirements.txt', 'app_source'))
|
||||||
|
|
||||||
# src/**/*.py (core, ui, utils - preserving directory structure)
|
# src/**/*.py (core, ui, utils — preserving directory structure)
|
||||||
for py in glob.glob('src/**/*.py', recursive=True):
|
for py in glob.glob('src/**/*.py', recursive=True):
|
||||||
dest = os.path.join('app_source', os.path.dirname(py))
|
dest = os.path.join('app_source', os.path.dirname(py))
|
||||||
app_datas.append((py, dest))
|
app_datas.append((py, dest))
|
||||||
@@ -43,7 +42,7 @@ for f in glob.glob(os.path.join(qml_dir, 'fonts', 'ttf', '*.ttf')):
|
|||||||
if os.path.exists(os.path.join('assets', 'icon.ico')):
|
if os.path.exists(os.path.join('assets', 'icon.ico')):
|
||||||
app_datas.append((os.path.join('assets', 'icon.ico'), os.path.join('app_source', 'assets')))
|
app_datas.append((os.path.join('assets', 'icon.ico'), os.path.join('app_source', 'assets')))
|
||||||
|
|
||||||
# ── Analysis - only the bootstrapper, NO heavy imports ────────────────────
|
# ── Analysis — only the bootstrapper, NO heavy imports ────────────────────
|
||||||
|
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
['bootstrapper.py'],
|
['bootstrapper.py'],
|
||||||
@@ -55,14 +54,14 @@ a = Analysis(
|
|||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
excludes=[
|
excludes=[
|
||||||
# Exclude everything heavy - the bootstrapper only uses stdlib
|
# Exclude everything heavy — the bootstrapper only uses stdlib
|
||||||
'torch', 'numpy', 'scipy', 'PySide6', 'shiboken6',
|
'torch', 'numpy', 'scipy', 'PySide6', 'shiboken6',
|
||||||
'faster_whisper', 'ctranslate2', 'llama_cpp',
|
'faster_whisper', 'ctranslate2', 'llama_cpp',
|
||||||
'sounddevice', 'soundfile', 'keyboard', 'pyperclip',
|
'sounddevice', 'soundfile', 'keyboard', 'pyperclip',
|
||||||
'psutil', 'pynvml', 'pystray', 'PIL', 'Pillow',
|
'psutil', 'pynvml', 'pystray', 'PIL', 'Pillow',
|
||||||
'darkdetect', 'huggingface_hub', 'requests',
|
'darkdetect', 'huggingface_hub', 'requests',
|
||||||
'tqdm', 'onnxruntime', 'av',
|
'tqdm', 'onnxruntime', 'av',
|
||||||
'matplotlib', 'notebook', 'IPython',
|
'tkinter', 'matplotlib', 'notebook', 'IPython',
|
||||||
],
|
],
|
||||||
win_no_prefer_redirects=False,
|
win_no_prefer_redirects=False,
|
||||||
win_private_assemblies=False,
|
win_private_assemblies=False,
|
||||||
@@ -86,11 +85,11 @@ exe = EXE(
|
|||||||
bootloader_ignore_signals=False,
|
bootloader_ignore_signals=False,
|
||||||
strip=False,
|
strip=False,
|
||||||
upx=True,
|
upx=True,
|
||||||
console=False, # No console - bootstrapper allocates one when needed
|
console=False, # No console — bootstrapper allocates one when needed
|
||||||
disable_windowed_traceback=False,
|
disable_windowed_traceback=False,
|
||||||
argv_emulation=False,
|
argv_emulation=False,
|
||||||
target_arch=None,
|
target_arch=None,
|
||||||
codesign_identity=None,
|
codesign_identity=None,
|
||||||
entitlements_file=None,
|
entitlements_file=None,
|
||||||
icon='assets/icon.ico' if sys.platform == 'win32' else None,
|
icon='assets/icon.ico',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,14 +9,6 @@ app_dir = os.path.dirname(os.path.abspath(__file__))
|
|||||||
if app_dir not in sys.path:
|
if app_dir not in sys.path:
|
||||||
sys.path.insert(0, app_dir)
|
sys.path.insert(0, app_dir)
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
# LINUX: Force XWayland (X11) for reliable window positioning & overlay behavior.
|
|
||||||
# Our input stack (evdev, UInput, wl-copy) is compositor-agnostic so this is safe.
|
|
||||||
# Native Wayland lacks app-controlled window positioning which the overlay needs.
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
if sys.platform == 'linux' and os.environ.get('WAYLAND_DISPLAY'):
|
|
||||||
os.environ.setdefault('QT_QPA_PLATFORM', 'xcb')
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# WINDOWS DLL FIX (CRITICAL for Portable CUDA)
|
# WINDOWS DLL FIX (CRITICAL for Portable CUDA)
|
||||||
# Python 3.8+ on Windows requires explicit DLL directory addition.
|
# Python 3.8+ on Windows requires explicit DLL directory addition.
|
||||||
@@ -24,32 +16,20 @@ if sys.platform == 'linux' and os.environ.get('WAYLAND_DISPLAY'):
|
|||||||
if os.name == 'nt' and hasattr(os, 'add_dll_directory'):
|
if os.name == 'nt' and hasattr(os, 'add_dll_directory'):
|
||||||
try:
|
try:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
_candidate_dirs = set()
|
# Scan sys.path for site-packages
|
||||||
|
|
||||||
# 1. From sys.path (scan ALL site-packages, not just the first)
|
|
||||||
for p in sys.path:
|
for p in sys.path:
|
||||||
path_obj = Path(p)
|
path_obj = Path(p)
|
||||||
if path_obj.name == 'site-packages' and path_obj.exists():
|
if path_obj.name == 'site-packages' and path_obj.exists():
|
||||||
_candidate_dirs.add(str(path_obj.resolve()))
|
nvidia_path = path_obj / "nvidia"
|
||||||
|
if nvidia_path.exists():
|
||||||
# 2. Relative to the Python executable (critical for embedded Python)
|
for subdir in nvidia_path.iterdir():
|
||||||
_exe_dir = Path(sys.executable).parent
|
# Add 'bin' folder from each nvidia stub (cublas, cudnn, etc.)
|
||||||
for _sp in [_exe_dir / "Lib" / "site-packages", _exe_dir / "lib" / "site-packages"]:
|
bin_path = subdir / "bin"
|
||||||
if _sp.exists():
|
if bin_path.exists():
|
||||||
_candidate_dirs.add(str(_sp.resolve()))
|
os.add_dll_directory(str(bin_path))
|
||||||
|
# Also try adding site-packages itself just in case
|
||||||
# 3. Scan all candidates for nvidia DLL directories
|
# os.add_dll_directory(str(path_obj))
|
||||||
for _sp_str in _candidate_dirs:
|
break
|
||||||
nvidia_path = Path(_sp_str) / "nvidia"
|
|
||||||
if nvidia_path.exists():
|
|
||||||
for subdir in nvidia_path.iterdir():
|
|
||||||
bin_path = subdir / "bin"
|
|
||||||
if bin_path.exists():
|
|
||||||
os.add_dll_directory(str(bin_path))
|
|
||||||
# Also add to PATH as fallback - some libraries
|
|
||||||
# (e.g. CTranslate2) load DLLs lazily via LoadLibrary
|
|
||||||
# which may not respect os.add_dll_directory()
|
|
||||||
os.environ['PATH'] = str(bin_path) + os.pathsep + os.environ.get('PATH', '')
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -69,8 +49,7 @@ from src.core.hotkey_manager import HotkeyManager
|
|||||||
from src.core.config import ConfigManager
|
from src.core.config import ConfigManager
|
||||||
from src.utils.injector import InputInjector
|
from src.utils.injector import InputInjector
|
||||||
from src.core.paths import get_models_path, get_bundle_path
|
from src.core.paths import get_models_path, get_bundle_path
|
||||||
if os.name == 'nt':
|
from src.utils.window_hook import WindowHook
|
||||||
from src.utils.window_hook import WindowHook
|
|
||||||
|
|
||||||
from PySide6.QtGui import QSurfaceFormat
|
from PySide6.QtGui import QSurfaceFormat
|
||||||
|
|
||||||
@@ -84,52 +63,37 @@ os.environ["QT_ENABLE_HIGHDPI_SCALING"] = "1"
|
|||||||
os.environ["QT_AUTOSCREENSCALEFACTOR"] = "1"
|
os.environ["QT_AUTOSCREENSCALEFACTOR"] = "1"
|
||||||
|
|
||||||
# Detect resolution without creating QApplication (Fixes crash)
|
# Detect resolution without creating QApplication (Fixes crash)
|
||||||
if os.name == 'nt':
|
try:
|
||||||
try:
|
import ctypes
|
||||||
import ctypes
|
user32 = ctypes.windll.user32
|
||||||
user32 = ctypes.windll.user32
|
# Get physical screen width (unscaled)
|
||||||
# Get physical screen width (unscaled)
|
# SetProcessDPIAware is needed to get the true resolution
|
||||||
# SetProcessDPIAware is needed to get the true resolution
|
user32.SetProcessDPIAware()
|
||||||
user32.SetProcessDPIAware()
|
width = user32.GetSystemMetrics(0)
|
||||||
width = user32.GetSystemMetrics(0)
|
# Base scale centers around 1920 width.
|
||||||
# Base scale centers around 1920 width.
|
# At 3840 (4k), res_scale is 2.0. If we want it ~40% smaller, we multiply by 0.6 = 1.2
|
||||||
# At 3840 (4k), res_scale is 2.0. If we want it ~40% smaller, we multiply by 0.6 = 1.2
|
res_scale = (width / 1920)
|
||||||
res_scale = (width / 1920)
|
if width >= 3840:
|
||||||
if width >= 3840:
|
res_scale *= 0.6 # Make it significantly smaller at 4k as requested
|
||||||
res_scale *= 0.6 # Make it significantly smaller at 4k as requested
|
|
||||||
|
os.environ["QT_SCALE_FACTOR"] = str(max(1.0, res_scale))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
os.environ["QT_SCALE_FACTOR"] = str(max(1.0, res_scale))
|
# Detect Windows "Reduce Motion" preference
|
||||||
except:
|
try:
|
||||||
pass
|
import ctypes
|
||||||
# On Linux, Qt handles DPI automatically via QT_ENABLE_HIGHDPI_SCALING
|
SPI_GETCLIENTAREAANIMATION = 0x1042
|
||||||
|
animation_enabled = ctypes.c_bool(True)
|
||||||
# Detect "Reduce Motion" preference
|
ctypes.windll.user32.SystemParametersInfoW(
|
||||||
if os.name == 'nt':
|
SPI_GETCLIENTAREAANIMATION, 0,
|
||||||
try:
|
ctypes.byref(animation_enabled), 0
|
||||||
import ctypes
|
)
|
||||||
SPI_GETCLIENTAREAANIMATION = 0x1042
|
if not animation_enabled.value:
|
||||||
animation_enabled = ctypes.c_bool(True)
|
ConfigManager().data["reduce_motion"] = True
|
||||||
ctypes.windll.user32.SystemParametersInfoW(
|
ConfigManager().save()
|
||||||
SPI_GETCLIENTAREAANIMATION, 0,
|
except Exception:
|
||||||
ctypes.byref(animation_enabled), 0
|
pass
|
||||||
)
|
|
||||||
if not animation_enabled.value:
|
|
||||||
ConfigManager().data["reduce_motion"] = True
|
|
||||||
ConfigManager().save()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
elif sys.platform == 'linux':
|
|
||||||
try:
|
|
||||||
import subprocess as _sp
|
|
||||||
result = _sp.run(
|
|
||||||
['gsettings', 'get', 'org.gnome.desktop.interface', 'enable-animations'],
|
|
||||||
capture_output=True, text=True, timeout=2
|
|
||||||
)
|
|
||||||
if result.stdout.strip() == 'false':
|
|
||||||
ConfigManager().data["reduce_motion"] = True
|
|
||||||
ConfigManager().save()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Configure Logging
|
# Configure Logging
|
||||||
class QmlLoggingHandler(logging.Handler, QObject):
|
class QmlLoggingHandler(logging.Handler, QObject):
|
||||||
@@ -176,33 +140,23 @@ class DownloadWorker(QThread):
|
|||||||
def run(self):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
import requests
|
import requests
|
||||||
import shutil
|
from tqdm import tqdm
|
||||||
model_path = get_models_path()
|
model_path = get_models_path()
|
||||||
|
# Determine what to download
|
||||||
dest_dir = model_path / f"faster-whisper-{self.model_name}"
|
dest_dir = model_path / f"faster-whisper-{self.model_name}"
|
||||||
repo_id = f"Systran/faster-whisper-{self.model_name}"
|
repo_id = f"Systran/faster-whisper-{self.model_name}"
|
||||||
required_files = ["config.json", "model.bin", "tokenizer.json", "vocabulary.json"]
|
files = ["config.json", "model.bin", "tokenizer.json", "vocabulary.json"]
|
||||||
base_url = f"https://huggingface.co/{repo_id}/resolve/main"
|
base_url = f"https://huggingface.co/{repo_id}/resolve/main"
|
||||||
|
|
||||||
# Skip if already complete
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
if dest_dir.exists() and all((dest_dir / f).exists() for f in required_files):
|
|
||||||
logging.info(f"Model {self.model_name} already downloaded.")
|
|
||||||
self.finished.emit()
|
|
||||||
return
|
|
||||||
|
|
||||||
# Download to a temp dir first, move on success
|
|
||||||
tmp_dir = model_path / f".tmp-faster-whisper-{self.model_name}"
|
|
||||||
if tmp_dir.exists():
|
|
||||||
shutil.rmtree(tmp_dir)
|
|
||||||
tmp_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
logging.info(f"Downloading {self.model_name} to {dest_dir}...")
|
logging.info(f"Downloading {self.model_name} to {dest_dir}...")
|
||||||
|
|
||||||
# 1. Calculate Total Size
|
# 1. Calculate Total Size
|
||||||
total_size = 0
|
total_size = 0
|
||||||
file_sizes = {}
|
file_sizes = {}
|
||||||
|
|
||||||
with requests.Session() as s:
|
with requests.Session() as s:
|
||||||
for fname in required_files:
|
for fname in files:
|
||||||
url = f"{base_url}/{fname}"
|
url = f"{base_url}/{fname}"
|
||||||
head = s.head(url, allow_redirects=True)
|
head = s.head(url, allow_redirects=True)
|
||||||
if head.status_code == 200:
|
if head.status_code == 200:
|
||||||
@@ -210,48 +164,42 @@ class DownloadWorker(QThread):
|
|||||||
file_sizes[fname] = size
|
file_sizes[fname] = size
|
||||||
total_size += size
|
total_size += size
|
||||||
else:
|
else:
|
||||||
logging.warning(f"HEAD failed for {fname}: HTTP {head.status_code}")
|
# Fallback for vocabulary.json vs vocabulary.txt
|
||||||
|
if fname == "vocabulary.json":
|
||||||
# Abort if any required file is unavailable
|
# Try .txt? Or just skip if not found?
|
||||||
missing = [f for f in required_files if f not in file_sizes]
|
# Faster-whisper usually has vocabulary.json
|
||||||
if missing:
|
pass
|
||||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
||||||
raise RuntimeError(f"Required model files unavailable: {missing}")
|
|
||||||
|
|
||||||
# 2. Download loop
|
# 2. Download loop
|
||||||
downloaded_bytes = 0
|
downloaded_bytes = 0
|
||||||
|
|
||||||
with requests.Session() as s:
|
with requests.Session() as s:
|
||||||
for fname in required_files:
|
for fname in files:
|
||||||
|
if fname not in file_sizes: continue
|
||||||
|
|
||||||
url = f"{base_url}/{fname}"
|
url = f"{base_url}/{fname}"
|
||||||
dest_file = tmp_dir / fname
|
dest_file = dest_dir / fname
|
||||||
|
|
||||||
|
# Resume check?
|
||||||
|
# Simpler to just overwrite for reliability unless we want complex resume logic.
|
||||||
|
# We'll overwrite.
|
||||||
|
|
||||||
resp = s.get(url, stream=True)
|
resp = s.get(url, stream=True)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|
||||||
with open(dest_file, 'wb') as f:
|
with open(dest_file, 'wb') as f:
|
||||||
for chunk in resp.iter_content(chunk_size=8192):
|
for chunk in resp.iter_content(chunk_size=8192):
|
||||||
if chunk:
|
if chunk:
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
downloaded_bytes += len(chunk)
|
downloaded_bytes += len(chunk)
|
||||||
|
|
||||||
|
# Emit Progress
|
||||||
if total_size > 0:
|
if total_size > 0:
|
||||||
pct = int((downloaded_bytes / total_size) * 100)
|
pct = int((downloaded_bytes / total_size) * 100)
|
||||||
self.progress.emit(pct)
|
self.progress.emit(pct)
|
||||||
|
|
||||||
# 3. Validate all files present and non-empty
|
|
||||||
for fname in required_files:
|
|
||||||
fpath = tmp_dir / fname
|
|
||||||
if not fpath.exists() or fpath.stat().st_size == 0:
|
|
||||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
||||||
raise RuntimeError(f"Download incomplete: {fname} missing or empty")
|
|
||||||
|
|
||||||
# 4. Atomic move: replace dest with completed download
|
|
||||||
if dest_dir.exists():
|
|
||||||
shutil.rmtree(dest_dir)
|
|
||||||
tmp_dir.rename(dest_dir)
|
|
||||||
|
|
||||||
self.finished.emit()
|
self.finished.emit()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Download failed: {e}")
|
logging.error(f"Download failed: {e}")
|
||||||
self.error.emit(str(e))
|
self.error.emit(str(e))
|
||||||
@@ -363,8 +311,7 @@ class WhisperApp(QObject):
|
|||||||
self.bridge.llmDownloadRequested.connect(self.on_llm_download_requested)
|
self.bridge.llmDownloadRequested.connect(self.on_llm_download_requested)
|
||||||
|
|
||||||
self.engine.rootContext().setContextProperty("ui", self.bridge)
|
self.engine.rootContext().setContextProperty("ui", self.bridge)
|
||||||
self.engine.rootContext().setContextProperty("isLinux", sys.platform == 'linux')
|
|
||||||
|
|
||||||
# 2. Tray setup
|
# 2. Tray setup
|
||||||
self.tray = SystemTray()
|
self.tray = SystemTray()
|
||||||
self.tray.quit_requested.connect(self.quit_app)
|
self.tray.quit_requested.connect(self.quit_app)
|
||||||
@@ -427,7 +374,7 @@ class WhisperApp(QObject):
|
|||||||
self.overlay_root.setProperty("color", "transparent")
|
self.overlay_root.setProperty("color", "transparent")
|
||||||
|
|
||||||
self.center_overlay()
|
self.center_overlay()
|
||||||
|
|
||||||
# Preload Settings (Invisible)
|
# Preload Settings (Invisible)
|
||||||
logging.info("Preloading Settings window...")
|
logging.info("Preloading Settings window...")
|
||||||
self.open_settings()
|
self.open_settings()
|
||||||
@@ -435,27 +382,23 @@ class WhisperApp(QObject):
|
|||||||
self.settings_root.setVisible(False)
|
self.settings_root.setVisible(False)
|
||||||
|
|
||||||
# Install Low-Level Window Hook for Transparent Hit Test
|
# Install Low-Level Window Hook for Transparent Hit Test
|
||||||
if os.name == 'nt':
|
try:
|
||||||
try:
|
from src.utils.window_hook import WindowHook
|
||||||
from src.utils.window_hook import WindowHook
|
hwnd = self.overlay_root.winId()
|
||||||
hwnd = self.overlay_root.winId()
|
# Initial scale from config
|
||||||
# Initial scale from config
|
scale = float(self.config.get("ui_scale"))
|
||||||
scale = float(self.config.get("ui_scale"))
|
|
||||||
|
# Current Overlay Dimensions
|
||||||
# Current Overlay Dimensions
|
win_w = int(460 * scale)
|
||||||
win_w = int(460 * scale)
|
win_h = int(180 * scale)
|
||||||
win_h = int(180 * scale)
|
|
||||||
|
self.window_hook = WindowHook(hwnd, win_w, win_h, initial_scale=scale)
|
||||||
self.window_hook = WindowHook(hwnd, win_w, win_h, initial_scale=scale)
|
self.window_hook.install()
|
||||||
self.window_hook.install()
|
|
||||||
|
# Initial state: Disabled because we start inactive
|
||||||
# Initial state: Disabled because we start inactive
|
self.window_hook.set_enabled(False)
|
||||||
self.window_hook.set_enabled(False)
|
except Exception as e:
|
||||||
except Exception as e:
|
logging.error(f"Failed to install WindowHook: {e}")
|
||||||
logging.error(f"Failed to install WindowHook: {e}")
|
|
||||||
else:
|
|
||||||
# On Linux, use Qt flag for click-through overlay
|
|
||||||
self.overlay_root.setFlag(Qt.WindowTransparentForInput, True)
|
|
||||||
|
|
||||||
def center_overlay(self):
|
def center_overlay(self):
|
||||||
"""Calculates and sets the Overlay position above the taskbar."""
|
"""Calculates and sets the Overlay position above the taskbar."""
|
||||||
@@ -869,23 +812,19 @@ class WhisperApp(QObject):
|
|||||||
self.bridge.update_status("Error")
|
self.bridge.update_status("Error")
|
||||||
logging.error(f"Download Error: {err}")
|
logging.error(f"Download Error: {err}")
|
||||||
|
|
||||||
def _update_overlay_state(self, is_active):
|
|
||||||
"""Update overlay visibility and input handling based on active state."""
|
|
||||||
if hasattr(self, 'window_hook'):
|
|
||||||
self.window_hook.set_enabled(is_active)
|
|
||||||
elif sys.platform == 'linux' and self.overlay_root:
|
|
||||||
self.overlay_root.setFlag(Qt.WindowTransparentForInput, not is_active)
|
|
||||||
|
|
||||||
@Slot(bool)
|
@Slot(bool)
|
||||||
def on_ui_toggle_request(self, is_recording):
|
def on_ui_toggle_request(self, is_recording):
|
||||||
"""Called when recording state changes."""
|
"""Called when recording state changes."""
|
||||||
|
# Update Window Hook to allow clicking if active
|
||||||
is_active = is_recording or self.bridge.isProcessing
|
is_active = is_recording or self.bridge.isProcessing
|
||||||
self._update_overlay_state(is_active)
|
if hasattr(self, 'window_hook'):
|
||||||
|
self.window_hook.set_enabled(is_active)
|
||||||
|
|
||||||
@Slot(bool)
|
@Slot(bool)
|
||||||
def on_processing_changed(self, is_processing):
|
def on_processing_changed(self, is_processing):
|
||||||
is_active = self.bridge.isRecording or is_processing
|
is_active = self.bridge.isRecording or is_processing
|
||||||
self._update_overlay_state(is_active)
|
if hasattr(self, 'window_hook'):
|
||||||
|
self.window_hook.set_enabled(is_active)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import sys
|
import sys
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ class LLMEngine:
|
|||||||
logging.warning(f"LLM Refusal Detected: '{result}'. Falling back to original.")
|
logging.warning(f"LLM Refusal Detected: '{result}'. Falling back to original.")
|
||||||
return text # Return original text on refusal!
|
return text # Return original text on refusal!
|
||||||
|
|
||||||
# --- Post-Processing ---
|
# --- Robust Post-Processing ---
|
||||||
|
|
||||||
# 1. Strip quotes
|
# 1. Strip quotes
|
||||||
if result.startswith('"') and result.endswith('"') and len(result) > 2 and '"' not in result[1:-1]:
|
if result.startswith('"') and result.endswith('"') and len(result) > 2 and '"' not in result[1:-1]:
|
||||||
|
|||||||
Reference in New Issue
Block a user