Initial commit of WhisperVoice

This commit is contained in:
Your Name
2026-01-24 17:03:52 +02:00
commit 9ff0e8d108
118 changed files with 6102 additions and 0 deletions

25
.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# Python
__pycache__/
*.py[cod]
*$py.class
# Virtual Environment
venv/
env/
# Distribution / Build
dist/
build/
*.spec
_unused_files/
runtime/
# IDEs
.vscode/
.idea/
# Application Specific
models/
recordings/
*.log
settings.json

165
README.md Normal file
View File

@@ -0,0 +1,165 @@
# Whisper Voice - Native Windows AI Transcriber
**Whisper Voice** is a high-performance, native Windows application that brings the power of OpenAI's **Whisper** model to your desktop in a seamless, interactive way.
Designed for productivity "power users", it allows you to invoke a global hotkey, dictate your thoughts, and have the transcribed text instantly typed into *any* active application (Notepad, Word, Slack, VS Code, etc.).
It features a modern, floating "Pill" UI with real-time audio visualization, built on top of the robust PySide6 (Qt) framework.
---
## ✨ Features
- **🎙️ Global Hotkey**: Press **F8** anywhere in Windows to start recording. Press again to stop.
- **🤖 Local AI Intelligence**: Powered by `faster-whisper`. Runs entirely on your machine. No cloud API keys, no data leaving your PC.
- **⚡ High Performance**: Uses the 'Small' Whisper model by default (~500MB), optimized for a balance of speed and accuracy.
- **🎨 Modern UI**: A frameless, draggable, floating "Pill" window with a Neon **Audio Visualizer** that reacts to your voice.
- **🔌 Smart Bootstrapper**: The app is portable and self-healing. On the first run, it checks for the AI model and downloads it automatically if missing.
- **✍️ Auto-Type**: Automatically simulates keyboard input to paste the transcribed text where your cursor is.
- **🔋 Portable**: Can be compiled into a single `.exe` file that you can carry on a USB drive.
---
## 🛠️ Requirements
- **OS**: Windows 10 or 11 (64-bit).
- **Python**: 3.10 or newer (if running from source).
- **Hardware**: A reasonable CPU (Modern Intel i5/AMD Ryzen). NVIDIA GPU recommended for instant speed (requires CUDA setup), but runs fine on CPU.
- **Dependencies**:
- **FFmpeg**: Essential for audio processing. (See Setup Guide).
---
## 🚀 Installation & Setup
### Option A: Running from Source (Developers)
1. **Clone the Repository**:
```bash
git clone https://github.com/your/repo.git
cd whisper_voice
```
2. **Environment Setup**:
It is highly recommended to use a virtual environment.
```cmd
python -m venv venv
venv\Scripts\activate
```
3. **Install Python Dependencies**:
```cmd
pip install -r requirements.txt
```
4. **FFmpeg Setup**:
- **Method 1 (System-wide)**: Download FFmpeg and add the `bin` folder to your Windows PATH environment variable.
- **Method 2 (Portable)**: Download `ffmpeg.exe` and place it in a `libs` folder inside the project root:
```text
whisper_voice/
├── main.py
├── libs/
│ └── ffmpeg.exe <-- Place here
```
5. **Run the App**:
```cmd
python main.py
```
*Or use the provided `run_source.bat` script.*
### Option B: Building a Portable EXE
You can compile the application into a single executable file for easy distribution.
1. Follow the **Running from Source** steps above to set up your environment.
2. Install `pyinstaller`:
```cmd
pip install pyinstaller
```
3. Run the Build Script:
```cmd
build_exe.bat
```
*(Or run `pyinstaller build.spec` manually).*
4. **Locate the EXE**:
The result will be in the `dist` folder: `dist/WhisperVoice.exe`.
5. **Distribution**:
- You can send just the `.exe` to anyone.
- **Note**: The end-user will still need FFmpeg. You can zip the `libs` folder alongside the EXE to make it truly "unzip and run".
---
## 🎮 Usage Guide
1. **First Run Initialization**:
- When you launch the app, you will see a **"Initializing..."** window.
- If the AI Model (`models/` folder) is missing, the app will automatically download it (~500MB).
- Once complete, the app minimizes to the System Tray.
2. **Dictation**:
- Focus the text field where you want to type (e.g., click into a Notepad document).
- Press **F8**.
- The **Floating Pill** appears on screen. Use the visualizer to confirm it hears you.
- Speak your sentence.
- Press **F8** again to stop.
- The Pill turns **Blue** ("Thinking...").
- Wait a moment... the text will appear!
3. **System Tray**:
- Look for the application icon in your taskbar tray (near the clock).
- Right-click -> **Quit Whisper Voice** to exit the application completely.
---
## 📁 Project Structure
```text
whisper_voice/
├── main.py # Application Entry Point & Orchestrator
├── task.md # Development Task Tracking
├── requirements.txt # Python Dependencies
├── build.spec # PyInstaller Configuration
├── run_source.bat # Helper script
├── build_exe.bat # Helper script
├── src/
│ ├── core/
│ │ ├── audio_engine.py # Microphone recording logic
│ │ ├── transcriber.py # AI Model wrapper (Faster-Whisper)
│ │ ├── hotkey_manager.py # Global keyboard hooks
│ │ └── paths.py # Path resolution (EXE vs Script)
│ ├── ui/
│ │ ├── overlay.py # Main Pill Window
│ │ ├── visualizer.py # Audio Spectrum Widget
│ │ ├── loader.py # Bootstrapper/Downloader UI
│ │ └── tray.py # System Tray Icon
│ └── utils/
│ ├── injector.py # Clipboard/Paste logic
│ └── downloader.py # File download utilities
```
---
## ❓ Troubleshooting
**Q: Nothing happens when I press F8.**
- Check the System Tray to ensure the app is running.
- Ensure you have given the app "Input Monitoring" permissions if prompted (rare on standard Windows).
- Some Antivirus software might block the "Global Hotkey" feature. Whitelist the app.
**Q: The app crashes with an error about FFmpeg.**
- `faster-whisper` requires FFmpeg. Make sure `ffmpeg.exe` is either in your system PATH or in a `libs` folder next to the `main.py` (or EXE).
**Q: Transcription is slow.**
- The "Small" model is generally fast, but on older CPUs, it might take 2-5 seconds for a long sentence.
- To use a GPU, you must install the NVIDIA cuDNN libraries and the `torch` version with CUDA support. This prototype setup defaults to CPU/Auto for compatibility.
**Q: "Failed to load model" error.**
- Delete the `models` folder and restart the app to force a re-download.
---
**License**: MIT
**Author**: Antigravity

BIN
app_icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

BIN
assets/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

370
bootstrapper.py Normal file
View File

@@ -0,0 +1,370 @@
"""
WhisperVoice Bootstrapper
=========================
Lightweight launcher that downloads Python + dependencies on first run.
This keeps the initial .exe small (~15-20MB) while downloading the full
runtime (~2-3GB) on first launch.
"""
import os
import sys
import subprocess
import zipfile
import shutil
import threading
import urllib.request
from pathlib import Path
# Use tkinter for the progress UI (built into Python, no extra deps)
import tkinter as tk
from tkinter import ttk
# Configuration
PYTHON_URL = "https://www.python.org/ftp/python/3.11.9/python-3.11.9-embed-amd64.zip"
PYTHON_VERSION = "3.11.9"
GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py"
def get_base_path():
"""Get the directory where the .exe is located."""
if getattr(sys, 'frozen', False):
return Path(sys.executable).parent
return Path(__file__).parent
def log(msg):
"""Safe logging that won't crash if stdout/stderr is None."""
try:
if sys.stdout:
print(msg)
sys.stdout.flush()
except:
pass
class BootstrapperUI:
"""Simple Tkinter UI for download progress."""
def __init__(self):
self.root = tk.Tk()
self.root.title("WhisperVoice Setup")
self.root.geometry("500x220")
self.root.resizable(False, False)
self.root.configure(bg="#1a1a2e")
# Center on screen
self.root.update_idletasks()
x = (self.root.winfo_screenwidth() - 500) // 2
y = (self.root.winfo_screenheight() - 220) // 2
self.root.geometry(f"+{x}+{y}")
# Set Window Icon
try:
if getattr(sys, 'frozen', False):
icon_path = os.path.join(sys._MEIPASS, "app_source", "assets", "icon.ico")
else:
icon_path = os.path.join(os.path.dirname(__file__), "assets", "icon.ico")
if os.path.exists(icon_path):
self.root.iconbitmap(icon_path)
except:
pass
# Title
self.title_label = tk.Label(
self.root,
text="🎤 WhisperVoice Setup",
font=("Segoe UI", 18, "bold"),
fg="#00f2ff",
bg="#1a1a2e"
)
self.title_label.pack(pady=(20, 10))
# Status
self.status_label = tk.Label(
self.root,
text="Initializing...",
font=("Segoe UI", 11),
fg="#ffffff",
bg="#1a1a2e"
)
self.status_label.pack(pady=5)
# Progress bar
style = ttk.Style()
style.theme_use('clam')
style.configure(
"Custom.Horizontal.TProgressbar",
background="#00f2ff",
troughcolor="#0d0d10",
borderwidth=0,
lightcolor="#00f2ff",
darkcolor="#00f2ff"
)
self.progress = ttk.Progressbar(
self.root,
style="Custom.Horizontal.TProgressbar",
length=400,
mode='determinate'
)
self.progress.pack(pady=15)
# Detail label
self.detail_label = tk.Label(
self.root,
text="",
font=("Segoe UI", 9),
fg="#888888",
bg="#1a1a2e"
)
self.detail_label.pack(pady=5)
self.root.protocol("WM_DELETE_WINDOW", self.on_close)
self.cancelled = False
def on_close(self):
self.cancelled = True
self.root.destroy()
sys.exit(0)
def set_status(self, text):
log(f"[STATUS] {text}")
self.status_label.config(text=text)
self.root.update()
def set_detail(self, text):
self.detail_label.config(text=text)
self.root.update()
def set_progress(self, value):
self.progress['value'] = value
self.root.update()
def run_in_thread(self, func):
"""Run a function in a background thread."""
thread = threading.Thread(target=func, daemon=True)
thread.start()
return thread
class Bootstrapper:
"""Main bootstrapper logic."""
def __init__(self, ui=None):
self.ui = ui
self.base_path = get_base_path()
self.runtime_path = self.base_path / "runtime"
self.python_path = self.runtime_path / "python"
self.app_path = self.runtime_path / "app"
if getattr(sys, 'frozen', False):
self.source_path = Path(sys._MEIPASS) / "app_source"
else:
self.source_path = self.base_path
def is_python_ready(self):
"""Check if Python runtime exists."""
return (self.python_path / "python.exe").exists()
def download_file(self, url, dest_path, desc="Downloading"):
"""Download a file with progress."""
if self.ui: self.ui.set_status(desc)
def progress_hook(count, block_size, total_size):
if self.ui and total_size > 0:
percent = min(100, (count * block_size * 100) // total_size)
self.ui.set_progress(percent)
mb_done = (count * block_size) / (1024 * 1024)
mb_total = total_size / (1024 * 1024)
self.ui.set_detail(f"{mb_done:.1f} / {mb_total:.1f} MB")
urllib.request.urlretrieve(url, dest_path, progress_hook)
def download_python(self):
"""Download and extract Python embeddable."""
if self.ui: self.ui.set_status("Downloading Python...")
self.runtime_path.mkdir(parents=True, exist_ok=True)
zip_path = self.runtime_path / "python.zip"
self.download_file(PYTHON_URL, zip_path, "Downloading Python runtime...")
if self.ui:
self.ui.set_status("Extracting Python...")
self.ui.set_progress(0)
self.python_path.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(zip_path, 'r') as zf:
total = len(zf.namelist())
for i, name in enumerate(zf.namelist()):
zf.extract(name, self.python_path)
if self.ui: self.ui.set_progress((i * 100) // total)
zip_path.unlink()
self._fix_pth_file()
def _fix_pth_file(self):
"""CRITICAL: Configure ._pth file to support imports from our app folder."""
# Find the _pth file in the python folder
pth_files = list(self.python_path.glob("*._pth"))
if not pth_files: return
pth_file = pth_files[0]
# Standard names in embeddable: python311.zip, ., import site
content = [
"python311.zip", # Hardcoded for now but works for 3.11
".",
"../app", # Include app folder in search path!
"Lib/site-packages",
"import site" # Enable site-packages
]
pth_file.write_text("\n".join(content) + "\n")
# Ensure site-packages exists
(self.python_path / "Lib" / "site-packages").mkdir(parents=True, exist_ok=True)
def install_pip(self):
"""Install pip into embedded Python."""
if self.ui: self.ui.set_status("Installing pip...")
get_pip_path = self.runtime_path / "get-pip.py"
self.download_file(GET_PIP_URL, get_pip_path, "Downloading pip installer...")
subprocess.run(
[str(self.python_path / "python.exe"), str(get_pip_path), "--no-warn-script-location"],
cwd=str(self.python_path),
capture_output=True,
creationflags=subprocess.CREATE_NO_WINDOW
)
get_pip_path.unlink()
def install_packages(self):
"""Install required packages via pip."""
if self.ui:
self.ui.set_status("Installing packages...")
self.ui.set_progress(10)
req_file = self.source_path / "requirements.txt"
process = subprocess.Popen(
[str(self.python_path / "python.exe"), "-m", "pip", "install", "-r", str(req_file)],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
cwd=str(self.python_path),
creationflags=subprocess.CREATE_NO_WINDOW
)
for line in process.stdout:
if self.ui: self.ui.set_detail(line.strip()[:60])
process.wait()
def refresh_app_source(self):
"""Refresh app source files. Skips if already exists to save time."""
# Optimization: If app/main.py exists, skip update to improve startup speed.
# The user can delete the 'runtime' folder to force an update.
if (self.app_path / "main.py").exists():
log("App already exists. Skipping update.")
return True
if self.ui: self.ui.set_status("Updating app files...")
try:
# Preserve settings.json if it exists
settings_path = self.app_path / "settings.json"
temp_settings = None
if settings_path.exists():
try:
temp_settings = settings_path.read_bytes()
except:
log("Failed to backup settings.json, it involves risk of data loss.")
if self.app_path.exists():
shutil.rmtree(self.app_path, ignore_errors=True)
shutil.copytree(
self.source_path,
self.app_path,
ignore=shutil.ignore_patterns(
'__pycache__', '*.pyc', '.git', 'venv',
'build', 'dist', '*.egg-info', 'runtime'
)
)
# Restore settings.json
if temp_settings:
try:
settings_path.write_bytes(temp_settings)
log("Restored settings.json")
except:
log("Failed to restore settings.json")
return True
except Exception as e:
log(f"Error refreshing app source: {e}")
return False
def run_app(self):
"""Launch the main application and exit."""
python_exe = self.python_path / "python.exe"
main_py = self.app_path / "main.py"
env = os.environ.copy()
env["PYTHONPATH"] = str(self.app_path)
try:
subprocess.Popen(
[str(python_exe), str(main_py)],
cwd=str(self.app_path),
env=env,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
)
return True
except Exception as e:
messagebox.showerror("WhisperVoice Error", f"Failed to launch app: {e}")
return False
def setup_and_run(self):
"""Full setup/update and run flow."""
try:
if not self.is_python_ready():
self.download_python()
self.install_pip()
self.install_packages()
# Always refresh source to ensure we have the latest bundled code
self.refresh_app_source()
# Launch
if self.run_app():
if self.ui: self.ui.root.quit()
except Exception as e:
messagebox.showerror("Setup Error", f"Installation failed: {e}")
import traceback
traceback.print_exc()
def main():
base_path = get_base_path()
python_exe = base_path / "runtime" / "python" / "python.exe"
main_py = base_path / "runtime" / "app" / "main.py"
# Fast path: if already set up and not frozen (development), just launch
if not getattr(sys, 'frozen', False) and python_exe.exists() and main_py.exists():
boot = Bootstrapper()
boot.run_app()
return
# Normal path: Show UI for possible setup/update
ui = BootstrapperUI()
boot = Bootstrapper(ui)
threading.Thread(target=boot.setup_and_run, daemon=True).start()
ui.root.mainloop()
if __name__ == "__main__":
try:
main()
except Exception as e:
import tkinter.messagebox as mb
mb.showerror("Fatal Error", f"Bootstrapper crashed: {e}")

66
build_bootstrapper.py Normal file
View File

@@ -0,0 +1,66 @@
"""
Build the Lightweight Bootstrapper
==================================
This creates a small (~15-20MB) .exe that downloads Python + dependencies on first run.
"""
import os
import shutil
import PyInstaller.__main__
from pathlib import Path
def build_bootstrapper():
project_root = Path(__file__).parent.absolute()
dist_path = project_root / "dist"
# Collect all app source files to bundle
# These will be extracted and used when setting up
app_source_files = [
("src", "app_source/src"),
("assets", "app_source/assets"), # Include icon etc
("main.py", "app_source"),
("requirements.txt", "app_source"),
]
add_data_args = []
for src, dst in app_source_files:
src_path = project_root / src
if src_path.exists():
add_data_args.extend(["--add-data", f"{src}{os.pathsep}{dst}"])
# Use absolute project root for copying
shutil.copy2(project_root / "assets" / "icon.ico", project_root / "app_icon.ico")
print("🚀 Building Lightweight Bootstrapper...")
print("⏳ This creates a small .exe that downloads dependencies on first run.\n")
PyInstaller.__main__.run([
"bootstrapper.py",
"--name=WhisperVoice",
"--onefile",
"--noconsole", # Re-enabled! Error handling in bootstrapper is ready.
"--clean",
"--icon=app_icon.ico", # Simplified path at root
*add_data_args,
])
exe_path = dist_path / "WhisperVoice.exe"
if exe_path.exists():
size_mb = exe_path.stat().st_size / (1024 * 1024)
print("\n" + "="*60)
print("✅ BOOTSTRAPPER BUILD COMPLETE!")
print("="*60)
print(f"\n📍 Output: {exe_path}")
print(f"📦 Size: {size_mb:.1f} MB")
print("\n📋 How it works:")
print(" 1. User runs WhisperVoice.exe")
print(" 2. First run: Downloads Python + packages (~2-3GB)")
print(" 3. Subsequent runs: Launches instantly")
print("\n💡 The 'runtime/' folder will be created next to the .exe")
else:
print("\n❌ Build failed. Check the output above for errors.")
if __name__ == "__main__":
os.chdir(Path(__file__).parent)
build_bootstrapper()

17
build_exe.bat Normal file
View File

@@ -0,0 +1,17 @@
@echo off
echo Building Whisper Voice Portable EXE...
if not exist venv (
echo Please run run_source.bat first to setup environment!
pause
exit /b
)
call venv\Scripts\activate
pip install pyinstaller
echo Running PyInstaller...
pyinstaller build.spec --clean --noconfirm
echo.
echo Build Complete! Check dist/WhisperVoice.exe
pause

14
convert_icon.py Normal file
View File

@@ -0,0 +1,14 @@
from PIL import Image
import os
# Path from the generate_image tool output
src = r"C:/Users/lashman/.gemini/antigravity/brain/9a183770-2481-475b-b748-03f4910f9a8e/app_icon_1769195450659.png"
dst = r"d:\!!! SYSTEM DATA !!!\Desktop\python crap\whisper_voice\assets\icon.ico"
if os.path.exists(src):
img = Image.open(src)
# Resize to standard icon sizes
img.save(dst, format='ICO', sizes=[(256, 256)])
print(f"Icon saved to {dst}")
else:
print(f"Source image not found: {src}")

43
download_icons.py Normal file
View File

@@ -0,0 +1,43 @@
import requests
import os
ICONS = {
"settings.svg": "https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/solid/gear.svg",
"visibility.svg": "https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/solid/eye.svg",
"smart_toy.svg": "https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/solid/brain.svg",
"microphone.svg": "https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/solid/microphone.svg"
}
TARGET_DIR = r"d:\!!! SYSTEM DATA !!!\Desktop\python crap\whisper_voice\src\ui\qml"
def download_icons():
if not os.path.exists(TARGET_DIR):
print(f"Directory not found: {TARGET_DIR}")
return
for filename, url in ICONS.items():
try:
print(f"Downloading {filename} from {url}...")
response = requests.get(url, timeout=10)
response.raise_for_status()
# Force white fill
content = response.text
if "<path" in content and "fill=" not in content:
content = content.replace("<path", '<path fill="#ffffff"')
elif "<path" in content and "fill=" in content:
# Regex or simple replace if possible, but simplest is usually just injecting style or checking common FA format
pass # FA standard usually has no fill.
# Additional safety: Replace currentcolor if present
content = content.replace("currentColor", "#ffffff")
filepath = os.path.join(TARGET_DIR, filename)
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
print(f"Saved {filepath} (modified to white)")
except Exception as e:
print(f"FAILED to download {filename}: {e}")
if __name__ == "__main__":
download_icons()

536
main.py Normal file
View File

@@ -0,0 +1,536 @@
import sys
import threading
import logging
import os
# Add the application directory to sys.path to ensure 'src' is findable
# This is critical for the embedded Python environment in the portable build
app_dir = os.path.dirname(os.path.abspath(__file__))
if app_dir not in sys.path:
sys.path.insert(0, app_dir)
from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox
from PySide6.QtCore import QObject, Slot, Signal, QThread, Qt, QUrl
from PySide6.QtQml import QQmlApplicationEngine
from PySide6.QtQuickControls2 import QQuickStyle
from PySide6.QtGui import QIcon
from src.ui.bridge import UIBridge
from src.ui.tray import SystemTray
from src.core.audio_engine import AudioEngine
from src.core.transcriber import WhisperTranscriber
from src.core.hotkey_manager import HotkeyManager
from src.core.config import ConfigManager
from src.utils.injector import InputInjector
from src.core.paths import get_models_path, get_bundle_path
from src.utils.window_hook import WindowHook
from PySide6.QtGui import QSurfaceFormat
# Configure GPU Surface for Alpha/Transparency (Critical for Blur)
surface_fmt = QSurfaceFormat()
surface_fmt.setAlphaBufferSize(8)
QSurfaceFormat.setDefaultFormat(surface_fmt)
# Configure High DPI behavior for crisp UI
os.environ["QT_ENABLE_HIGHDPI_SCALING"] = "1"
os.environ["QT_AUTOSCREENSCALEFACTOR"] = "1"
# Detect resolution without creating QApplication (Fixes crash)
try:
import ctypes
user32 = ctypes.windll.user32
# Get physical screen width (unscaled)
# SetProcessDPIAware is needed to get the true resolution
user32.SetProcessDPIAware()
width = user32.GetSystemMetrics(0)
# 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
res_scale = (width / 1920)
if width >= 3840:
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
# Configure Logging
class QmlLoggingHandler(logging.Handler, QObject):
sig_log = Signal(str)
def __init__(self, bridge):
logging.Handler.__init__(self)
QObject.__init__(self)
self.bridge = bridge
self.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
self.sig_log.connect(self.bridge.append_log)
def emit(self, record):
msg = self.format(record)
self.sig_log.emit(msg)
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
# Silence shutdown-related tracebacks from Qt/PySide6 signals
def _silent_shutdown_hook(exc_type, exc_value, exc_tb):
# During Python shutdown, some QObject signals may try to call dead slots.
# Ignore these specific tracebacks when they occur in bridge.py.
import traceback
if exc_type in (RuntimeError, SystemError, KeyboardInterrupt):
return # Suppress completely
tb_str = ''.join(traceback.format_exception(exc_type, exc_value, exc_tb))
if 'bridge.py' in tb_str and '@Slot' in tb_str:
return # Suppress bridge signal tracebacks
# For all other exceptions, print normally
sys.__excepthook__(exc_type, exc_value, exc_tb)
sys.excepthook = _silent_shutdown_hook
class DownloadWorker(QThread):
"""Background worker for model downloads."""
progress = Signal(int)
finished = Signal()
error = Signal(str)
def __init__(self, model_name="small", parent=None):
super().__init__(parent)
self.model_name = model_name
def run(self):
try:
from faster_whisper import download_model
model_path = get_models_path()
# Download to a specific subdirectory to keep things clean and predictable
# This matches the logic in transcriber.py which looks for this specific path
dest_dir = model_path / f"faster-whisper-{self.model_name}"
logging.info(f"Downloading Model '{self.model_name}' to {dest_dir}...")
# Ensure parent exists
model_path.mkdir(parents=True, exist_ok=True)
# output_dir in download_model specifies where the model files are saved
download_model(self.model_name, output_dir=str(dest_dir))
self.finished.emit()
except Exception as e:
logging.error(f"Download failed: {e}")
self.error.emit(str(e))
class TranscriptionWorker(QThread):
finished = Signal(str)
def __init__(self, transcriber, audio_data, is_file=False, parent=None):
super().__init__(parent)
self.transcriber = transcriber
self.audio_data = audio_data
self.is_file = is_file
def run(self):
text = self.transcriber.transcribe(self.audio_data, is_file=self.is_file)
self.finished.emit(text)
class WhisperApp(QObject):
def __init__(self):
super().__init__()
# Force a style that supports full customization
QQuickStyle.setStyle("Basic")
self.qt_app = QApplication(sys.argv)
self.qt_app.setQuitOnLastWindowClosed(False)
# Set application-wide window icon (shows in taskbar for all windows)
icon_path = get_bundle_path() / "assets" / "icon.ico"
if icon_path.exists():
self.qt_app.setWindowIcon(QIcon(str(icon_path)))
self.config = ConfigManager()
# 1. Initialize QML Engine & Bridge
self.engine = QQmlApplicationEngine()
self.bridge = UIBridge()
# 0. Attach Logging Handler
logging.getLogger().addHandler(QmlLoggingHandler(self.bridge))
# Connect toggle recording signal
self.bridge.toggleRecordingRequested.connect(self.toggle_recording)
self.bridge.isRecordingChanged.connect(self.on_ui_toggle_request)
self.bridge.settingChanged.connect(self.on_settings_changed)
self.bridge.hotkeysEnabledChanged.connect(self.on_hotkeys_enabled_toggle)
self.bridge.downloadRequested.connect(self.on_download_requested)
self.engine.rootContext().setContextProperty("ui", self.bridge)
# 2. Tray setup
self.tray = SystemTray()
self.tray.quit_requested.connect(self.quit_app)
self.tray.settings_requested.connect(self.open_settings)
self.tray.transcribe_file_requested.connect(self.transcribe_file)
# Init Tooltip
hotkey = self.config.get("hotkey")
self.tray.setToolTip(f"Whisper Voice - Press {hotkey} to Record")
# 3. Logic Components Placeholders
self.audio_engine = None
self.transcriber = None
self.hotkey_manager = None
self.overlay_root = None
# 4. Start Loader
loader_qml = get_bundle_path() / "src/ui/qml/Loader.qml"
self.engine.load(QUrl.fromLocalFile(str(loader_qml)))
self.loader_root = self.engine.rootObjects()[0]
self.loader_root.setProperty("color", "transparent")
self.loader_worker = DownloadWorker()
self.loader_worker.progress.connect(self.on_loader_progress)
self.loader_worker.finished.connect(self.on_loader_done)
self.loader_worker.start()
# Preload audio devices in background to avoid settings lag
import threading
threading.Thread(target=self.bridge.preload_audio_devices, daemon=True).start()
def on_loader_progress(self, percent):
self.bridge.downloadProgress = percent
def on_loader_done(self):
if getattr(self, "_loader_handled", False):
return
self._loader_handled = True
logging.info("Model verification complete.")
# Close Loader Window
if hasattr(self, "loader_root"):
self.loader_root.close()
# Init Backend
self.init_logic()
# Show Overlay (Ensure we don't load multiple times)
overlay_qml = get_bundle_path() / "src/ui/qml/Overlay.qml"
self.engine.load(QUrl.fromLocalFile(str(overlay_qml)))
self.overlay_root = self.engine.rootObjects()[-1]
self.overlay_root.setProperty("color", "transparent")
self.center_overlay()
# Preload Settings (Invisible)
logging.info("Preloading Settings window...")
self.open_settings()
if self.settings_root:
self.settings_root.setVisible(False)
# Install Low-Level Window Hook for Transparent Hit Test
# We must keep a reference to 'self.hook' so it isn't GC'd
# scale = self.overlay_root.devicePixelRatio()
# self.hook = WindowHook(int(self.overlay_root.winId()), 500, 300, scale)
# self.hook.install()
# NOTE: HitTest hook will be installed here later
def center_overlay(self):
"""Calculates and sets the Overlay position above the taskbar."""
from PySide6.QtGui import QGuiApplication
screen = QGuiApplication.primaryScreen()
if not screen or not self.overlay_root: return
geom = screen.availableGeometry()
w = self.overlay_root.width()
h = self.overlay_root.height()
x = geom.x() + (geom.width() - w) // 2
y = geom.bottom() - h - 15
self.overlay_root.setX(x)
self.overlay_root.setY(y)
def init_logic(self):
if getattr(self, "_logic_initialized", False):
return
self._logic_initialized = True
logging.info("Initializing Core Logic...")
self.audio_engine = AudioEngine()
self.audio_engine.set_visualizer_callback(self.bridge.update_amplitude)
self.audio_engine.set_silence_callback(self.on_silence_detected)
self.transcriber = WhisperTranscriber()
self.hotkey_manager = HotkeyManager()
self.hotkey_manager.triggered.connect(self.toggle_recording)
self.hotkey_manager.start()
self.bridge.update_status("Ready")
def run(self):
sys.exit(self.qt_app.exec())
@Slot()
def quit_app(self):
logging.info("Shutting down...")
# [CRITICAL] Stop the StatsWorker FIRST before any UI objects are touched.
# This prevents signal emissions to a dying UIBridge object.
if hasattr(self, 'bridge') and hasattr(self.bridge, 'stats_worker'):
try:
self.bridge.stats_worker.stats_ready.disconnect(self.bridge.update_stats_callback)
except: pass
self.bridge.stats_worker.stop()
if self.hotkey_manager: self.hotkey_manager.stop()
# Close all QML windows to ensure bindings stop before Python objects die
if self.overlay_root:
self.overlay_root.close()
self.overlay_root.deleteLater()
if hasattr(self, 'loader_root') and self.loader_root:
self.loader_root.close()
self.loader_root.deleteLater()
if hasattr(self, 'settings_root') and self.settings_root:
self.settings_root.close()
self.settings_root.deleteLater()
if hasattr(self, 'loader_worker') and self.loader_worker and self.loader_worker.isRunning():
logging.info("Waiting for loader to finish...")
self.loader_worker.quit()
self.loader_worker.wait(1000)
if hasattr(self, 'worker') and self.worker and self.worker.isRunning():
logging.info("Waiting for transcription to finish...")
self.worker.quit()
self.worker.wait(2000)
self.qt_app.quit()
@Slot()
def open_settings(self):
if not hasattr(self, 'settings_root') or self.settings_root is None:
logging.info("Loading Settings window for the first time...")
settings_qml = get_bundle_path() / "src/ui/qml/Settings.qml"
self.engine.load(QUrl.fromLocalFile(str(settings_qml)))
self.settings_root = self.engine.rootObjects()[-1]
self.settings_root.setProperty("color", "transparent")
# Connect the closing signal to just hide/delete reference if needed,
# but better to keep it alive. Actually, QML Window close() hides it by default usually
# unless we set closePolicy. Let's ensure we can re-show it.
# We might need to listen to closing signal to prevent destruction if we want to reuse.
# But simpler: check if it exists, if so, show/raise it.
# Center on screen
from PySide6.QtGui import QGuiApplication
screen = QGuiApplication.primaryScreen()
if screen:
geom = screen.availableGeometry()
self.settings_root.setX(geom.x() + (geom.width() - self.settings_root.width()) // 2)
self.settings_root.setY(geom.y() + (geom.height() - self.settings_root.height()) // 2)
self.settings_root.setVisible(True)
self.settings_root.requestActivate()
@Slot()
def init_settings_preload(self):
"""Preloads settings window to avoid lag on first open."""
# Check if already loaded
if hasattr(self, 'settings_root') and self.settings_root:
return
logging.info("Preloading Settings QML...")
# Load but keep hidden? QML Window visible defaults to true usually,
# so we might see a flicker if we don't be careful.
# Ideally we load it with visible: false property from python or QML.
# For now, let's just let the first open be the load, but since user complained about lag...
# effectively doing nothing different here unless we actually trigger load.
pass
@Slot(str, 'QVariant')
def on_settings_changed(self, key, value):
"""
React to settings changes in real-time.
Some settings require immediate action (reloading model, moving window).
"""
print(f"Setting Changed: {key} = {value}")
# 1. Hotkey Reload
if key == "hotkey":
if self.hotkey_manager: self.hotkey_manager.reload_hotkey()
if self.tray:
self.tray.setToolTip(f"Whisper Voice - Press {value} to Record")
# 2. AI Model Reload (Heavy)
if key in ["model_size", "compute_device", "compute_type"]:
size = self.config.get("model_size")
# Notify UI to check if the new selected model is downloaded
self.bridge.notifyModelStatesChanged()
if self.transcriber.model_exists(size):
logging.info(f"Model '{size}' exists. Reloading engine...")
threading.Thread(target=self.transcriber.load_model, daemon=True).start()
else:
logging.info(f"Model '{size}' not found. Waiting for manual download.")
# 3. Window Positioning
if key in ["overlay_position", "overlay_offset_x", "overlay_offset_y", "ui_scale"]:
self.reposition_overlay()
# 4. Run on Startup
if key == "run_on_startup":
self.handle_startup_shortcut(value)
# 4. Input Device (Audio Engine handles this on next record start typically,
# but we can force a stream restart if we want instant feedback?
# For now, next record is fine as per plan).
def reposition_overlay(self):
"""Calculates and sets the Overlay position based on user settings."""
from PySide6.QtGui import QGuiApplication
screen = QGuiApplication.primaryScreen()
if not screen or not self.overlay_root: return
# Apply UI Scale (Handled in QML now, but we need it for position calc)
scale = float(self.config.get("ui_scale"))
# self.overlay_root.setProperty("scale", scale) # Removed, handled in QML
# Get Geometry
geom = screen.availableGeometry()
# Current Scaled Dimensions (Approximation)
# Note: We must assume the base size is 460x180 (window size)
# But visually it's 380x100 (container) scaled up.
# The Window itself stays fixed size (transparent frame), but content scales.
# Actually, simpler interpretation: The window size is fixed large area, content moves.
# BUT if we want "Edge alignment", we must account for visual bounds.
visual_w = 460 * scale
visual_h = 180 * scale
# We set the WINDOW position anchor.
# Since the window content is centered, the window is effectively the bounding box we care about?
# No, the window is 460x180. The content is smaller 380x100.
# Let's align based on the WINDOW size for now to be safe.
# Wait, if we scale in QML, does the window size change? No.
# So if we scale up 1.5x, content might clip if window doesn't grow.
# To support UI Scale properly without clipping, we should probably resize the window here too.
# Let's resize the window to fit the scaled content.
win_w = int(460 * scale)
win_h = int(180 * scale)
self.overlay_root.setWidth(win_w)
self.overlay_root.setHeight(win_h)
pos_mode = self.config.get("overlay_position")
offset_x = int(self.config.get("overlay_offset_x"))
offset_y = int(self.config.get("overlay_offset_y"))
x = 0
y = 0
if pos_mode == "Bottom Center":
x = geom.x() + (geom.width() - win_w) // 2
y = geom.bottom() - win_h - 15
elif pos_mode == "Top Center":
x = geom.x() + (geom.width() - win_w) // 2
y = geom.top() + 15
elif pos_mode == "Bottom Right":
x = geom.right() - win_w - 15
y = geom.bottom() - win_h - 15
elif pos_mode == "Top Right":
x = geom.right() - win_w - 15
y = geom.top() + 15
elif pos_mode == "Bottom Left":
x = geom.left() + 15
y = geom.bottom() - win_h - 15
elif pos_mode == "Top Left":
x = geom.left() + 15
y = geom.top() + 15
# Apply Offsets
x += offset_x
y += offset_y
self.overlay_root.setX(x)
self.overlay_root.setY(y)
@Slot()
def transcribe_file(self):
file_path, _ = QFileDialog.getOpenFileName(None, "Select Audio", "", "Audio (*.mp3 *.wav *.flac *.m4a *.ogg)")
if file_path:
self.bridge.update_status("Thinking...")
self.worker = TranscriptionWorker(self.transcriber, file_path, is_file=True, parent=self)
self.worker.finished.connect(self.on_transcription_done)
self.worker.start()
@Slot()
def on_silence_detected(self):
from PySide6.QtCore import QMetaObject, Qt
QMetaObject.invokeMethod(self, "toggle_recording", Qt.QueuedConnection)
@Slot()
def toggle_recording(self):
if not self.audio_engine: return
# Prevent starting a new recording while we are still transcribing the last one
if self.bridge.isProcessing:
logging.warning("Ignored toggle request: Transcription in progress.")
return
if self.audio_engine.recording:
self.bridge.update_status("Thinking...")
self.bridge.isRecording = False
self.bridge.isProcessing = True # Start Processing
audio_data = self.audio_engine.stop_recording()
self.worker = TranscriptionWorker(self.transcriber, audio_data, parent=self)
self.worker.finished.connect(self.on_transcription_done)
self.worker.start()
else:
self.bridge.update_status("Recording")
self.bridge.isRecording = True
self.audio_engine.start_recording()
@Slot(bool)
def on_ui_toggle_request(self, state):
if state != self.audio_engine.recording:
self.toggle_recording()
@Slot(str)
def on_transcription_done(self, text: str):
self.bridge.update_status("Ready")
self.bridge.isProcessing = False # End Processing
if text:
method = self.config.get("input_method")
speed = int(self.config.get("typing_speed"))
InputInjector.inject_text(text, method, speed)
@Slot(bool)
def on_hotkeys_enabled_toggle(self, state):
if self.hotkey_manager:
self.hotkey_manager.set_enabled(state)
@Slot(str)
def on_download_requested(self, size):
if self.bridge.isDownloading:
return
self.bridge.update_status("Downloading...")
self.bridge.isDownloading = True
self.download_worker = DownloadWorker(size, parent=self)
self.download_worker.finished.connect(self.on_download_finished)
self.download_worker.error.connect(self.on_download_error)
self.download_worker.start()
def on_download_finished(self):
self.bridge.isDownloading = False
self.bridge.update_status("Ready")
self.bridge.notifyModelStatesChanged() # Refresh UI markers
# Automatically load it now that it's here
threading.Thread(target=self.transcriber.load_model, daemon=True).start()
def on_download_error(self, err):
self.bridge.isDownloading = False
self.bridge.update_status("Error")
logging.error(f"Download Error: {err}")
if __name__ == "__main__":
app = WhisperApp()
app.run()

88
portable_build.py Normal file
View File

@@ -0,0 +1,88 @@
"""
Portable Build Script for WhisperVoice.
=======================================
Creates a single-file portable .exe using PyInstaller.
All data (settings, models) will be stored next to the .exe at runtime.
"""
import os
import shutil
import PyInstaller.__main__
from pathlib import Path
def build_portable():
# 1. Setup Paths
project_root = Path(__file__).parent.absolute()
dist_path = project_root / "dist"
build_path = project_root / "build"
# 2. Define Assets to bundle (into the .exe)
# Format: (Source, Destination relative to bundle root)
data_files = [
# QML files
("src/ui/qml/*.qml", "src/ui/qml"),
("src/ui/qml/*.svg", "src/ui/qml"),
("src/ui/qml/*.qsb", "src/ui/qml"),
("src/ui/qml/fonts/ttf/*.ttf", "src/ui/qml/fonts/ttf"),
# Subprocess worker script (CRITICAL for transcription)
("src/core/transcribe_worker.py", "src/core"),
]
# Convert to PyInstaller format "--add-data source;dest" (Windows uses ';')
add_data_args = []
for src, dst in data_files:
add_data_args.extend(["--add-data", f"{src}{os.pathsep}{dst}"])
# 3. Run PyInstaller
print("🚀 Starting Portable Build...")
print("⏳ This may take 5-10 minutes...")
PyInstaller.__main__.run([
"main.py", # Entry point
"--name=WhisperVoice", # EXE name
"--onefile", # Single EXE (slower startup but portable)
"--noconsole", # No terminal window
"--clean", # Clean cache
*add_data_args, # Bundled assets
# Heavy libraries that need special collection
"--collect-all", "faster_whisper",
"--collect-all", "ctranslate2",
"--collect-all", "PySide6",
"--collect-all", "torch",
"--collect-all", "numpy",
# Hidden imports (modules imported dynamically)
"--hidden-import", "keyboard",
"--hidden-import", "pyperclip",
"--hidden-import", "psutil",
"--hidden-import", "pynvml",
"--hidden-import", "sounddevice",
"--hidden-import", "scipy",
"--hidden-import", "scipy.signal",
"--hidden-import", "huggingface_hub",
"--hidden-import", "tokenizers",
# Qt plugins
"--hidden-import", "PySide6.QtQuickControls2",
"--hidden-import", "PySide6.QtQuick.Controls",
# Icon (convert to .ico for Windows)
# "--icon=icon.ico", # Uncomment if you have a .ico file
])
print("\n" + "="*60)
print("✅ BUILD COMPLETE!")
print("="*60)
print(f"\n📍 Output: {dist_path / 'WhisperVoice.exe'}")
print("\n📋 First run instructions:")
print(" 1. Place WhisperVoice.exe in a folder (e.g., C:\\WhisperVoice\\)")
print(" 2. Run it - it will create 'models' and 'settings.json' folders")
print(" 3. The app will download the Whisper model on first transcription\n")
print("💡 TIP: Keep the .exe with its generated files for true portability!")
if __name__ == "__main__":
# Ensure we are in project root
os.chdir(Path(__file__).parent)
build_portable()

30
requirements.txt Normal file
View File

@@ -0,0 +1,30 @@
# WhisperVoice Dependencies
# =========================
# Core AI
faster-whisper>=1.0.0
torch>=2.0.0
# UI Framework
PySide6>=6.6.0
# Audio
sounddevice>=0.4.6
soundfile>=0.12.0
scipy>=1.11.0
# System / Input
keyboard>=0.13.5
pyperclip>=1.8.2
psutil>=5.9.0
pynvml>=11.0.0
# Utilities
numpy>=1.24.0
requests>=2.31.0
huggingface-hub>=0.20.0
# System Tray
pystray>=0.19.0
Pillow>=10.0.0
darkdetect>=0.8.0

5
run.bat Normal file
View File

@@ -0,0 +1,5 @@
@echo off
echo [LAUNCHER] Starting Fake Blur UI (Python/Qt)...
call venv\Scripts\activate.bat
python main.py
if %errorlevel% neq 0 pause

13
run_source.bat Normal file
View File

@@ -0,0 +1,13 @@
@echo off
echo Starting Whisper Voice (Source Mode)...
if not exist venv (
echo Venv not found. Creating...
python -m venv venv
call venv\Scripts\activate
pip install -r requirements.txt
) else (
call venv\Scripts\activate
)
python main.py
pause

196
src/core/audio_engine.py Normal file
View File

@@ -0,0 +1,196 @@
"""
Audio Engine Module.
====================
This module handles the low-level audio recording capabilities using `sounddevice`.
It manages the input stream, buffers audio data in memory, and provides a callback
mechanism for real-time visualization of audio amplitude.
Classes:
AudioEngine: The main controller for recording streams.
"""
import sounddevice as sd
import numpy as np
import threading
import queue
import logging
from typing import Optional, Callable
from src.core.config import ConfigManager
class AudioEngine:
"""
Manages audio recording from the default input device.
Uses ConfigManager for settings (device, silence detection).
"""
def __init__(self, sample_rate: int = 16000, channels: int = 1):
"""
Initialize the AudioEngine.
"""
self.config = ConfigManager()
self.sample_rate = sample_rate
self.channels = channels
self.recording = False
self.stream: Optional[sd.InputStream] = None
self.visualizer_callback: Optional[Callable[[float], None]] = None
self.silence_callback: Optional[Callable[[], None]] = None
# Audio buffer to store the current session's frames
self.frames = []
self.last_noise_time = 0.0
def list_devices(self):
"""
Query available audio devices.
Returns:
DeviceList: A list of all available input/output devices seen by PortAudio.
"""
return sd.query_devices()
def set_visualizer_callback(self, callback: Callable[[float], None]):
"""
Register a callback function for visualizer updates.
Args:
callback (function): A function that accepts a single float argument (amplitude).
This will be called roughly every audio block.
"""
self.visualizer_callback = callback
def set_silence_callback(self, callback: Callable[[], None]):
"""
Register a callback function for silence detection (Auto-Stop).
"""
self.silence_callback = callback
def _audio_callback(self, indata: np.ndarray, frames: int, time, status: sd.CallbackFlags):
"""
Internal callback used by sounddevice to process incoming audio chunks.
Args:
indata (numpy.ndarray): The recorded audio data chunk.
frames (int): Number of frames.
time: Timestamp info.
status: Callback status flags (e.g., overflow warnings).
"""
if status:
logging.warning(f"Audio callback status: {status}")
if self.recording:
# Copy data to avoid buffer race conditions
data = indata.copy()
self.frames.append(data)
# Calculate amplitude for visualizer (Root Mean Square)
if self.visualizer_callback:
# Calculate RMS of the current chunk to determine loudness
rms = np.sqrt(np.mean(data**2))
# Apply logarithmic scaling for better sensitivity to quiet sounds
if rms > 0:
# Convert to dB scale, normalize, and apply compression
db = 20 * np.log10(rms + 1e-10) # Add small value to avoid log(0)
# Map from typical range (-60 to 0 dB) to (0 to 1)
amp = float(np.clip((db + 60) / 60, 0.0, 1.0))
# Apply power curve for better sensitivity at low levels
amp = np.power(amp, 0.5) # Square root gives good response to quiet sounds
else:
amp = 0.0
# Apply exponential smoothing to prevent jumpy waveform
if not hasattr(self, '_smoothed_amp'):
self._smoothed_amp = amp
else:
# Exponential moving average with smoothing factor 0.3
self._smoothed_amp = 0.3 * amp + 0.7 * self._smoothed_amp
self.visualizer_callback(self._smoothed_amp)
# --- Silence Detection Logic ---
# We calculate this even if visualizer is off
# Calculate linear RMS for VAD comparison
raw_rms = np.sqrt(np.mean(data**2))
# Heuristic mapping: 0.1 RMS = 100% threshold
vad_level = float(np.clip(raw_rms * 10, 0.0, 1.0))
import time
current_time = time.time()
# Fetch params dynamically
threshold = float(self.config.get("silence_threshold"))
duration = float(self.config.get("silence_duration"))
if vad_level > threshold:
self.last_noise_time = current_time
else:
# If we have been silent for > silence_duration, trigger auto-stop
if (current_time - self.last_noise_time) > duration:
if self.silence_callback:
logging.info(f"Silence detected ({duration}s). Triggering auto-stop.")
# Reset last_noise_time to prevent spamming
self.last_noise_time = current_time
self.silence_callback()
def start_recording(self, device: Optional[int] = None):
"""
Start the recording stream.
Args:
device (int, optional): The device ID to use. Defaults to system default.
"""
if self.recording:
return
self.frames = [] # Reset buffer
self.recording = True
import time
self.last_noise_time = time.time() # Reset silence timer
# Determine Device
# If passed arg is None, check Config. If Config is None, use Default.
if device is None:
device = self.config.get("input_device")
try:
self.stream = sd.InputStream(
samplerate=self.sample_rate,
channels=self.channels,
device=device,
callback=self._audio_callback
)
self.stream.start()
logging.info("Audio recording started.")
except Exception as e:
logging.error(f"Failed to start recording: {e}")
self.recording = False
def stop_recording(self) -> np.ndarray:
"""
Stop the current recording session and return the captured audio.
Returns:
np.ndarray: The complete audio recording flattened into a single numpy array.
Returns an empty array if nothing was recorded.
"""
if not self.recording:
return np.array([], dtype=np.float32)
self.recording = False
if self.stream:
self.stream.stop()
self.stream.close()
self.stream = None
logging.info("Audio recording stopped.")
if not self.frames:
return np.array([], dtype=np.float32)
# Concatenate all buffered chunks into one continuous array
# sounddevice returns (frames, channels), so we get (N, 1).
# Whisper expects flattened 1D array (N,).
audio = np.concatenate(self.frames, axis=0)
return audio.flatten()

117
src/core/config.py Normal file
View File

@@ -0,0 +1,117 @@
"""
Configuration Manager Module.
=============================
Singleton class to manage loading and saving application settings to a JSON file.
Ensures robustness by merging with defaults and handling file paths correctly.
"""
import json
import logging
from pathlib import Path
from typing import Any, Dict
from src.core.paths import get_base_path
# Default Configuration
DEFAULT_SETTINGS = {
"hotkey": "f8",
"model_size": "small",
"input_device": None, # Device ID (int) or Name (str), None = Default
"save_recordings": False, # Save .wav files for debugging
"silence_threshold": 0.02, # Amplitude threshold (0.0 - 1.0)
"silence_duration": 1.0, # Seconds of silence to trigger auto-submit
"visualizer_style": "line", # 'bar' or 'line'
"opacity": 1.0, # Window opacity (0.1 - 1.0)
"ui_scale": 1.0, # Global UI Scale (0.75 - 1.5)
"always_on_top": True,
"run_on_startup": False, # (Placeholder)
# Window Position
"overlay_position": "Bottom Center",
"overlay_offset_x": 0,
"overlay_offset_y": 0,
# Input
"input_method": "Clipboard Paste", # "Clipboard Paste" or "Simulate Typing"
"typing_speed": 100, # CPM (Chars Per Minute) if typing
# AI - Advanced
"language": "auto", # "auto" or ISO code
"compute_device": "auto", # "auto", "cuda", "cpu"
"compute_type": "int8", # "int8", "float16", "float32"
"beam_size": 5,
"best_of": 5,
"vad_filter": True,
"no_repeat_ngram_size": 0,
"condition_on_previous_text": True
}
class ConfigManager:
"""
Singleton Configuration Manager.
"""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(ConfigManager, cls).__new__(cls)
cls._instance._init()
return cls._instance
def _init(self):
"""Initialize the config manager (called only once)."""
self.base_path = get_base_path()
self.config_file = self.base_path / "settings.json"
self.data = DEFAULT_SETTINGS.copy()
self.load()
def load(self):
"""Load settings from JSON file, merging with defaults."""
if self.config_file.exists():
try:
with open(self.config_file, 'r', encoding='utf-8') as f:
loaded = json.load(f)
# Merge loaded data into defaults (preserves new default keys)
for key, value in loaded.items():
if key in DEFAULT_SETTINGS:
self.data[key] = value
logging.info(f"Settings loaded from {self.config_file}")
except Exception as e:
logging.error(f"Failed to load settings: {e}")
else:
logging.info("No settings file found. Using defaults.")
self.save()
def save(self):
"""Save current settings to JSON file."""
try:
with open(self.config_file, 'w', encoding='utf-8') as f:
json.dump(self.data, f, indent=4)
logging.info("Settings saved.")
except Exception as e:
logging.error(f"Failed to save settings: {e}")
def get(self, key: str) -> Any:
"""Get a setting value."""
return self.data.get(key, DEFAULT_SETTINGS.get(key))
def set(self, key: str, value: Any):
"""Set a setting value and save."""
if self.data.get(key) != value:
self.data[key] = value
self.save()
def set_bulk(self, updates: Dict[str, Any]):
"""Update multiple keys and save once."""
changed = False
for k, v in updates.items():
if self.data.get(k) != v:
self.data[k] = v
changed = True
if changed:
self.save()

View File

@@ -0,0 +1,31 @@
@echo off
echo [DEBUG] LAUNCHER STARTED
echo [DEBUG] CWD: %CD%
echo [DEBUG] Python Path (expected relative): ..\python\python.exe
REM Read stdin to a file to verify data input (optional debugging)
REM python.exe might be in different relative path depending on where this bat is run
REM We assume this bat is in runtime/app/src/core/
REM So python is in ../../../python/python.exe
set PYTHON_EXE=..\..\..\python\python.exe
if exist "%PYTHON_EXE%" (
echo [DEBUG] Found Python at %PYTHON_EXE%
) else (
echo [ERROR] Python NOT found at %PYTHON_EXE%
echo [ERROR] Listing relative directories:
dir ..\..\..\
pause
exit /b 1
)
echo [DEBUG] Launching script: transcribe_worker.py
"%PYTHON_EXE%" transcribe_worker.py
if %ERRORLEVEL% NEQ 0 (
echo [ERROR] Python script failed with code %ERRORLEVEL%
pause
) else (
echo [SUCCESS] Script finished.
pause
)

View File

@@ -0,0 +1,95 @@
"""
Hotkey Manager Module.
======================
This module wraps the `keyboard` library to provide Global Hotkey functionality.
It allows the application to respond to key presses even when it is not in focus
(background operation).
Classes:
HotkeyManager: Qt-compatible wrapper for keyboard hooks.
"""
import keyboard
import logging
from PySide6.QtCore import QObject, Signal
from typing import Optional
class HotkeyManager(QObject):
"""
Manages global keyboard shortcuts using the `keyboard` library.
inherits from QObject to allow Signal/Slot integration with PySide6.
Signals:
triggered: Emitted when the hotkey is pressed.
Attributes:
hotkey (str): The key combination as a string (e.g. "f8", "ctrl+alt+r").
is_listening (bool): State of the listener.
"""
triggered = Signal()
def __init__(self, hotkey: str = "f8"):
"""
Initialize the HotkeyManager.
Args:
hotkey (str): The global hotkey string description. Default: "f8".
"""
super().__init__()
self.hotkey = hotkey
self.is_listening = False
self._enabled = True
def set_enabled(self, enabled: bool):
"""Enable or disable the hotkey trigger without unhooking."""
self._enabled = enabled
logging.info(f"Hotkey listener {'enabled' if enabled else 'suspended'}")
def start(self):
"""Start listening for the hotkey."""
self.reload_hotkey()
def reload_hotkey(self):
"""Unregister old hotkey and register new one from Config."""
if self.is_listening:
self.stop()
from src.core.config import ConfigManager
config = ConfigManager()
self.hotkey = config.get("hotkey")
logging.info(f"Registering global hotkey: {self.hotkey}")
try:
# We don't suppress=True here because we want the app to see keys during recording
# (Wait, actually if we are recording we WANT keyboard to see it,
# but usually global hotkeys should be suppressed if we don't want them leaking to other apps)
# However, the user is fixing the internal collision.
keyboard.add_hotkey(self.hotkey, self.on_press, suppress=False)
self.is_listening = True
except Exception as e:
logging.error(f"Failed to bind hotkey: {e}")
def stop(self):
"""
Stop listening and unregister the hook.
Safe to call even if not listening.
"""
if self.is_listening:
try:
keyboard.remove_hotkey(self.hotkey)
except:
pass
self.is_listening = False
logging.info(f"Unregistered global hotkey: {self.hotkey}")
def on_press(self):
"""
Callback triggered internally by the keyboard library when the key is pressed.
Emits the Qt `triggered` signal.
"""
if not self._enabled:
return
logging.info(f"Hotkey {self.hotkey} detected.")
self.triggered.emit()

86
src/core/paths.py Normal file
View File

@@ -0,0 +1,86 @@
"""
Paths Module.
=============
This module handles all file system path resolution for the application.
It is critical for ensuring portability, distinguishing between:
1. Running as a raw Python script (using `__file__`).
2. Running as a frozen PyInstaller EXE (using `sys.executable`).
It creates necessary directories (models, libs) if they do not exist.
"""
import sys
import os
from pathlib import Path
from typing import Optional
def get_bundle_path() -> Path:
"""
Returns the root directory of the application bundle.
When frozen, this is the internal temporary directory (sys._MEIPASS).
When running as script, this is the project root.
Use this for bundled assets like QML, SVGs, etc.
"""
if getattr(sys, 'frozen', False):
return Path(sys._MEIPASS)
# Project root (assuming this file is at src/core/paths.py)
return Path(__file__).resolve().parent.parent.parent
def get_base_path() -> Path:
"""
Returns the directory where persistent data should be stored.
Always points to the directory containing the .exe or the project root.
Use this for models, settings, recordings.
"""
if getattr(sys, 'frozen', False):
return Path(sys.executable).parent
return get_bundle_path()
def get_models_path() -> Path:
"""
Returns the absolute path to the 'models' directory.
This directory is used to store the Whisper AI model files.
The directory is automatically created if it does not exist.
Returns:
Path: Absolute path to the ./models directory next to the output binary.
"""
path = get_base_path() / "models"
path.mkdir(parents=True, exist_ok=True)
return path
def get_libs_path() -> Path:
"""
Returns the absolute path to the 'libs' directory.
This directory is used to store external binaries like `ffmpeg.exe`.
The directory is automatically created if it does not exist.
Returns:
Path: Absolute path to the ./libs directory next to the output binary.
"""
path = get_base_path() / "libs"
path.mkdir(parents=True, exist_ok=True)
return path
def get_ffmpeg_path() -> str:
"""
Resolves the path to the FFmpeg executable.
Logic:
1. Checks for `ffmpeg.exe` in the local `./libs` folder.
2. Fallbacks to the system-wide "ffmpeg" command if local file is missing.
Returns:
str: Absolute path to the local binary, or just "ffmpeg" string for system PATH lookup.
"""
libs_path = get_libs_path()
ffmpeg_exe = libs_path / "ffmpeg.exe"
if ffmpeg_exe.exists():
return str(ffmpeg_exe.absolute())
# Fallback to system PATH
return "ffmpeg"

View File

@@ -0,0 +1,127 @@
"""
Transcription Worker Subprocess.
================================
This script is designed to be run as a subprocess. It:
1. Receives configuration and audio data via stdin (pickled)
2. Loads the Whisper model
3. Transcribes the audio
4. Prints the result to stdout
5. Exits (letting the OS reclaim all memory)
This ensures complete RAM/VRAM cleanup after each transcription.
"""
import sys
import pickle
import logging
import os
import traceback
# Enable debug logging to file for definitive troubleshooting
log_file = os.path.join(os.path.dirname(__file__), "worker_debug.log")
logging.basicConfig(
level=logging.DEBUG,
filename=log_file,
filemode='w',
format='[WORKER] %(message)s'
)
def main():
try:
# Read pickled data from stdin
data = pickle.load(sys.stdin.buffer)
config = data['config']
audio_data = data['audio']
model_path = data['model_path']
libs_path = data['libs_path']
# Add libs to PATH for cuDNN etc
os.environ["PATH"] += os.pathsep + str(libs_path)
# Import and load model
from faster_whisper import WhisperModel
config = data.get('config', {})
model_path_arg = config.get('model_size') # Now receives full path
device = config.get("compute_device", "cuda")
compute = config.get("compute_type", "float16")
logging.info(f"Worker initializing model from: '{model_path_arg}'")
# Verify path existence for debugging
if os.path.exists(model_path_arg):
logging.info(f"Path verification: EXISTS. Is dir: {os.path.isdir(model_path_arg)}")
else:
logging.error(f"Path verification: DOES NOT EXIST!")
model = WhisperModel(
model_path_arg,
device=device,
compute_type=compute,
download_root=model_path,
local_files_only=True # FORCE offline mode
)
# Transcription parameters
lang = config.get("language", "auto")
if lang == "auto": lang = None
beam_size = int(config.get("beam_size", 5))
best_of = int(config.get("best_of", 5))
vad = config.get("vad_filter", True)
no_repeat_ngram = int(config.get("no_repeat_ngram_size", 0))
condition_prev = config.get("condition_on_previous_text", True)
# Transcribe with more lenient settings for challenging audio
segments, info = model.transcribe(
audio_data,
beam_size=beam_size,
best_of=best_of,
language=lang,
vad_filter=vad,
vad_parameters=dict(min_silence_duration_ms=500),
no_repeat_ngram_size=no_repeat_ngram,
condition_on_previous_text=condition_prev,
# Lenient thresholds for music/singing
compression_ratio_threshold=10.0, # Default 2.4, higher = more lenient
log_prob_threshold=-2.0, # Default -1.0, lower = more lenient
no_speech_threshold=0.9, # Default 0.6, higher = more lenient
without_timestamps=True, # Faster for file processing
)
text_result = ""
for segment in segments:
text_result += segment.text
text_result = text_result.strip()
# Output result as pickled data
pickle.dump({'success': True, 'text': text_result}, sys.stdout.buffer)
sys.stdout.buffer.flush()
except Exception as e:
# Output error with detailed traceback
error_msg = f"{str(e)}\n{traceback.format_exc()}"
logging.error(f"Worker failed: {error_msg}")
pickle.dump({'success': False, 'error': error_msg}, sys.stdout.buffer)
sys.stdout.buffer.flush()
if __name__ == "__main__":
try:
main()
except Exception:
# Catch ALL errors and print them so the user can see in the console
import traceback
traceback.print_exc()
# Log to file if possible as well
logging.error("CRITICAL WORKER CRASH")
logging.error(traceback.format_exc())
# KEY: Pause so the window doesn't close immediately
print("\n" + "="*60)
print("CRITICAL ERROR IN WORKER PROCESS")
print("Please take a screenshot of this window.")
print("="*60)
input("Press Enter to close this window...")

129
src/core/transcriber.py Normal file
View File

@@ -0,0 +1,129 @@
"""
Whisper Transcriber Module.
===========================
Transcriber Module.
===================
Handles audio transcription using faster-whisper.
Runs IN-PROCESS (no subprocess) to ensure stability on all systems.
"""
import os
import logging
from typing import Optional
import numpy as np
from src.core.config import ConfigManager
from src.core.paths import get_models_path
# Import directly - valid since we are now running in the full environment
from faster_whisper import WhisperModel
class WhisperTranscriber:
"""
Manages the faster-whisper model and transcription process.
"""
def __init__(self):
"""Initialize settings."""
self.config = ConfigManager()
self.model = None
self.current_model_size = None
self.current_compute_device = None
self.current_compute_type = None
def load_model(self):
"""
Loads the model specified in config.
Safe to call multiple times (checks if reload needed).
"""
size = self.config.get("model_size")
device = self.config.get("compute_device")
compute = self.config.get("compute_type")
# Check if already loaded
if (self.model and
self.current_model_size == size and
self.current_compute_device == device and
self.current_compute_type == compute):
return
logging.info(f"Loading Model: {size} on {device} ({compute})...")
try:
# Construct path to local model for offline support
new_path = get_models_path() / f"faster-whisper-{size}"
model_input = str(new_path) if new_path.exists() else size
# Force offline if path exists to avoid HF errors
local_only = new_path.exists()
self.model = WhisperModel(
model_input,
device=device,
compute_type=compute,
download_root=str(get_models_path()),
local_files_only=local_only
)
self.current_model_size = size
self.current_compute_device = device
self.current_compute_type = compute
logging.info("Model loaded successfully.")
except Exception as e:
logging.error(f"Failed to load model: {e}")
self.model = None
def transcribe(self, audio_data, is_file: bool = False) -> str:
"""
Transcribe audio data.
"""
logging.info(f"Starting transcription... (is_file={is_file})")
# Ensure model is loaded
if not self.model:
self.load_model()
if not self.model:
return "Error: Model failed to load."
try:
# Config
beam_size = int(self.config.get("beam_size"))
best_of = int(self.config.get("best_of"))
vad = False if is_file else self.config.get("vad_filter")
# Transcribe
segments, info = self.model.transcribe(
audio_data,
beam_size=beam_size,
best_of=best_of,
vad_filter=vad,
vad_parameters=dict(min_silence_duration_ms=500),
condition_on_previous_text=self.config.get("condition_on_previous_text"),
without_timestamps=True
)
# Aggregate text
text_result = ""
for segment in segments:
text_result += segment.text + " "
return text_result.strip()
except Exception as e:
logging.error(f"Transcription failed: {e}")
return f"Error: {str(e)}"
def model_exists(self, size: str) -> bool:
"""Checks if a model size is already downloaded."""
new_path = get_models_path() / f"faster-whisper-{size}"
if (new_path / "config.json").exists():
return True
# Legacy HF cache check
folder_name = f"models--Systran--faster-whisper-{size}"
path = get_models_path() / folder_name / "snapshots"
if path.exists() and any(path.iterdir()):
return True
return False

387
src/ui/bridge.py Normal file
View File

@@ -0,0 +1,387 @@
"""
QML Bridge Module.
==================
Acts as the mediator between Python business logic and the QML UI layer.
Exposes properties and signals that QML can bind to for real-time updates.
"""
from PySide6.QtCore import QObject, Property, Signal, Slot, QTimer, QThread
import logging
import psutil
import threading
import time
import os
import weakref
from shiboken6 import isValid
try:
import torch
except ImportError:
torch = None
from src.core.config import ConfigManager
class StatsWorker(QThread):
stats_ready = Signal(float, float, float, float)
def __init__(self):
super().__init__()
self.running = True
self.process = psutil.Process()
self.process.cpu_percent() # Prime
# Initialize NVML for accurate GPU monitoring
self.nvml_available = False
self.gpu_handle = None
try:
import pynvml
pynvml.nvmlInit()
self.gpu_handle = pynvml.nvmlDeviceGetHandleByIndex(0)
self.nvml_available = True
except:
pass
def run(self):
while self.running:
try:
# App CPU
cpu = self.process.cpu_percent() / psutil.cpu_count()
# App RAM in MB
ram_bytes = self.process.memory_info().rss
ram_mb = ram_bytes / (1024 * 1024)
# GPU Stats via NVML (accurate for CTranslate2/faster-whisper)
vram_percent = 0.0
vram_mb = 0.0
if self.nvml_available and self.gpu_handle:
try:
import pynvml
mem_info = pynvml.nvmlDeviceGetMemoryInfo(self.gpu_handle)
vram_mb = mem_info.used / (1024 * 1024)
vram_percent = (mem_info.used / mem_info.total * 100) if mem_info.total > 0 else 0.0
except: pass
self.stats_ready.emit(
round(cpu, 1),
round(ram_mb, 1),
round(vram_mb, 1),
round(vram_percent, 1)
)
except Exception:
pass
# Sleep that checks running flag more frequently
for _ in range(10):
if not self.running: break
time.sleep(0.1)
def stop(self):
self.running = False
# Cleanup NVML
if self.nvml_available:
try:
import pynvml
pynvml.nvmlShutdown()
except: pass
self.quit()
self.wait(2000) # Wait up to 2 seconds for clean exit
class UIBridge(QObject):
"""
Main controller exposed to QML.
"""
# Signals for QML to listen to
statusTextChanged = Signal(str)
amplitudeChanged = Signal(float)
isRecordingChanged = Signal(bool)
isProcessingChanged = Signal(bool)
hotkeysEnabledChanged = Signal(bool)
isDownloadingChanged = Signal(bool)
uiScaleChanged = Signal(float)
appCpuChanged = Signal(float)
appRamMbChanged = Signal(float)
appVramMbChanged = Signal(float)
appVramPercentChanged = Signal(float)
downloadProgressChanged = Signal(float)
loaderStatusChanged = Signal(str)
toggleRecordingRequested = Signal()
downloadRequested = Signal(str) # model name
logAppended = Signal(str) # Emits new log line
settingChanged = Signal(str, 'QVariant')
modelStatesChanged = Signal() # Notify UI to re-check isModelDownloaded
def __init__(self, parent=None):
super().__init__(parent)
self._status_text = "Ready"
self._amplitude = 0.0
self._is_recording = False
self._is_processing = False
self._download_progress = 0.0
self._loader_status = "Scanning models..."
self._is_recording = False
self._hotkeys_enabled = True
self._is_downloading = False
self._ui_scale = float(ConfigManager().get("ui_scale"))
self._logs = [] # Store last 1000 lines
self._app_cpu = 0.0
self._app_ram_mb = 0.0
self._app_vram_mb = 0.0
self._app_vram_percent = 0.0
self._is_destroyed = False
# Start QThread Stats Worker
self.stats_worker = StatsWorker()
self.stats_worker.stats_ready.connect(self.update_stats_callback)
self.stats_worker.start()
# Cleanup on destruction
self.destroyed.connect(self._handle_destruction)
def _handle_destruction(self, obj=None):
self._is_destroyed = True
# Explicitly disconnect signal BEFORE stopping the worker
if hasattr(self, 'stats_worker'):
try:
self.stats_worker.stats_ready.disconnect(self.update_stats_callback)
except: pass
self.stats_worker.stop()
@Property(bool, notify=hotkeysEnabledChanged)
def hotkeysEnabled(self):
return self._hotkeys_enabled
@hotkeysEnabled.setter
def hotkeysEnabled(self, val):
if self._hotkeys_enabled != val:
self._hotkeys_enabled = val
self.hotkeysEnabledChanged.emit(val)
@Slot(str)
def append_log(self, line):
self._logs.append(line)
if len(self._logs) > 1000:
self._logs.pop(0)
self.logAppended.emit(line)
@Property(str, notify=logAppended) # Simple full text getter
def allLogs(self):
return "\n".join(self._logs)
@Slot(result=None)
def toggle_recording(self):
"""Called by UI elements to trigger the app's recording logic."""
# Removed debug prints for cleaner logs
self.toggleRecordingRequested.emit()
# --- Properties ---
@Property(float, notify=downloadProgressChanged)
def downloadProgress(self): return self._download_progress
@downloadProgress.setter
def downloadProgress(self, val):
self._download_progress = val
self.downloadProgressChanged.emit(val)
@Property(str, notify=loaderStatusChanged)
def loaderStatus(self): return self._loader_status
@loaderStatus.setter
def loaderStatus(self, val):
self._loader_status = val
self.loaderStatusChanged.emit(val)
@Property(str, notify=statusTextChanged)
def statusText(self):
return self._status_text
@statusText.setter
def statusText(self, val):
if self._status_text != val:
self._status_text = val
self.statusTextChanged.emit(val)
@Property(float, notify=amplitudeChanged)
def amplitude(self):
return self._amplitude
@amplitude.setter
def amplitude(self, val):
if self._amplitude != val:
self._amplitude = val
self.amplitudeChanged.emit(val)
@Property(bool, notify=isRecordingChanged)
def isRecording(self):
return self._is_recording
@isRecording.setter
def isRecording(self, val):
if self._is_recording != val:
self._is_recording = val
self.isRecordingChanged.emit(val)
@Property(bool, notify=isProcessingChanged)
def isProcessing(self):
return self._is_processing
@isProcessing.setter
def isProcessing(self, val):
if self._is_processing != val:
self._is_processing = val
self.isProcessingChanged.emit(val)
# --- Methods called from Python logic ---
@Slot(str)
def update_status(self, text):
self.statusText = text
@Slot(float)
def update_amplitude(self, amp):
self.amplitude = amp
# --- Methods called from QML ---
@Slot(str, result='QVariant')
def getSetting(self, key):
from src.core.config import ConfigManager
return ConfigManager().get(key)
@Slot(str, 'QVariant')
def setSetting(self, key, value):
from src.core.config import ConfigManager
ConfigManager().set(key, value)
if key == "ui_scale":
self.uiScale = float(value)
self.settingChanged.emit(key, value) # Notify listeners (e.g. Overlay)
@Property(float, notify=uiScaleChanged)
def uiScale(self): return self._ui_scale
@uiScale.setter
def uiScale(self, val):
if self._ui_scale != val:
self._ui_scale = val
self.uiScaleChanged.emit(val)
@Property(float, notify=appCpuChanged)
def appCpu(self): return self._app_cpu
@Property(float, notify=appRamMbChanged)
def appRamMb(self): return self._app_ram_mb
@Property(float, notify=appVramMbChanged)
def appVramMb(self): return self._app_vram_mb
@Property(float, notify=appVramPercentChanged)
def appVramPercent(self): return self._app_vram_percent
@Slot(float, float, float, float)
def update_stats_callback(self, cpu, ram_mb, vram_mb, vram_p):
"""Called from the background StatsWorker thread."""
# Root-level try-except to catch any decorator/runtime errors during shutdown
try:
if self._is_destroyed or not isValid(self):
return
if self._app_cpu != cpu:
self._app_cpu = cpu
self.appCpuChanged.emit(cpu)
if self._app_ram_mb != ram_mb:
self._app_ram_mb = ram_mb
self.appRamMbChanged.emit(ram_mb)
if self._app_vram_mb != vram_mb:
self._app_vram_mb = vram_mb
self.appVramMbChanged.emit(vram_mb)
if self._app_vram_percent != vram_p:
self._app_vram_percent = vram_p
self.appVramPercentChanged.emit(vram_p)
except:
pass
@Slot(result='QVariantList')
def getAudioDevices(self):
"""Returns a list of audio input devices. Cached to prevent UI blocking."""
print("[Bridge] getAudioDevices called.")
if hasattr(self, '_cached_devices'):
print(f"[Bridge] Returning cached devices ({len(self._cached_devices)} found)")
return self._cached_devices
# If we are here, it's the first load.
# Since QML expects a return value immediately, we return a placeholder or empty list
# and start a thread to populate it for next time (or emission).
# However, for a settings menu, "loading..." is better than blocking.
# But to properly fix the 5s block, we MUST NOT import sounddevice on the main thread if it's slow.
print("[Bridge] No cached devices yet! Returning empty list.")
return []
def preload_audio_devices(self):
"""Called on startup in a worker thread."""
try:
import sounddevice as sd
devices = []
device_list = sd.query_devices()
for i, dev in enumerate(device_list):
if dev['max_input_channels'] > 0:
devices.append({"id": i, "name": dev['name']})
self._cached_devices = devices
logging.info(f"Audio devices preloaded: {len(devices)} found")
except Exception as e:
logging.error(f"Failed to preload audio devices: {e}")
@Slot()
def toggle_recording(self):
"""Called by UI elements to trigger the app's recording logic."""
# This will be connected to the main app's toggle logic
pass
@Property(bool, notify=isDownloadingChanged)
def isDownloading(self): return self._is_downloading
@isDownloading.setter
def isDownloading(self, val):
if self._is_downloading != val:
self._is_downloading = val
self.isDownloadingChanged.emit(val)
@Slot(str, result=bool)
def isModelDownloaded(self, size):
if not size: return False
try:
from src.core.paths import get_models_path
# Check new simple format used by DownloadWorker
path_simple = get_models_path() / f"faster-whisper-{size}"
if path_simple.exists() and any(path_simple.iterdir()):
return True
# Check HF Cache format (legacy/default)
folder_name = f"models--Systran--faster-whisper-{size}"
path_hf = get_models_path() / folder_name
snapshots = path_hf / "snapshots"
if snapshots.exists() and any(snapshots.iterdir()):
return True
# Check direct folder (simple)
path_direct = get_models_path() / size
if (path_direct / "config.json").exists():
return True
except Exception as e:
logging.error(f"Error checking model status: {e}")
return False
@Slot(str)
def downloadModel(self, size):
self.downloadRequested.emit(size)
@Slot()
def notifyModelStatesChanged(self):
self.modelStatesChanged.emit()

210
src/ui/components.py Normal file
View File

@@ -0,0 +1,210 @@
"""
Modern Components Library.
==========================
Contains custom-painted widgets that move beyond the standard 'amateur' Qt look.
Implements smooth animations, hardware acceleration, and glassmorphism.
"""
from PySide6.QtWidgets import (
QPushButton, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QGraphicsDropShadowEffect, QFrame, QAbstractButton
)
from PySide6.QtCore import Qt, QPropertyAnimation, QEasingCurve, Property, QRect, QPoint, Signal, Slot
from PySide6.QtGui import QPainter, QColor, QBrush, QPen, QLinearGradient, QFont
from src.ui.styles import Theme
class GlassButton(QPushButton):
"""A premium button with gradient hover effects and smooth scaling."""
def __init__(self, text, parent=None, accent_color=Theme.ACCENT_CYAN):
super().__init__(text, parent)
self.accent = QColor(accent_color)
self.setCursor(Qt.PointingHandCursor)
self.setFixedHeight(40)
self._hover_opacity = 0.0
self.setStyleSheet(f"""
QPushButton {{
background-color: rgba(255, 255, 255, 0.05);
border: 1px solid {Theme.BORDER_SUBTLE};
color: {Theme.TEXT_SECONDARY};
border-radius: 8px;
padding: 0 20px;
font-size: 13px;
font-weight: 600;
}}
""")
# Hover Animation
self.anim = QPropertyAnimation(self, b"hover_opacity")
self.anim.setDuration(200)
self.anim.setStartValue(0.0)
self.anim.setEndValue(1.0)
self.anim.setEasingCurve(QEasingCurve.OutCubic)
@Property(float)
def hover_opacity(self): return self._hover_opacity
@hover_opacity.setter
def hover_opacity(self, value):
self._hover_opacity = value
self.update()
def enterEvent(self, event):
self.anim.setDirection(QPropertyAnimation.Forward)
self.anim.start()
super().enterEvent(event)
def leaveEvent(self, event):
self.anim.setDirection(QPropertyAnimation.Backward)
self.anim.start()
super().leaveEvent(event)
def paintEvent(self, event):
"""Custom paint for the glow effect."""
super().paintEvent(event)
if self._hover_opacity > 0:
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
# Subtle Glow Border
color = QColor(self.accent)
color.setAlphaF(self._hover_opacity * 0.5)
painter.setPen(QPen(color, 1.5))
painter.setBrush(Qt.NoBrush)
painter.drawRoundedRect(self.rect().adjusted(1,1,-1,-1), 8, 8)
# Text Glow color shift
self.setStyleSheet(f"""
QPushButton {{
background-color: rgba(255, 255, 255, {0.05 + (self._hover_opacity * 0.05)});
border: 1px solid {Theme.BORDER_SUBTLE};
color: white;
border-radius: 8px;
padding: 0 20px;
font-size: 13px;
font-weight: 600;
}}
""")
class ModernSwitch(QAbstractButton):
"""A sleek iOS-style toggle switch."""
def __init__(self, parent=None, active_color=Theme.ACCENT_GREEN):
super().__init__(parent)
self.setCheckable(True)
self.setFixedSize(44, 24)
self._thumb_pos = 3.0
self.active_color = QColor(active_color)
self.anim = QPropertyAnimation(self, b"thumb_pos")
self.anim.setDuration(200)
self.anim.setEasingCurve(QEasingCurve.InOutCubic)
@Property(float)
def thumb_pos(self): return self._thumb_pos
@thumb_pos.setter
def thumb_pos(self, value):
self._thumb_pos = value
self.update()
def nextCheckState(self):
super().nextCheckState()
self.anim.stop()
if self.isChecked():
self.anim.setEndValue(23.0)
else:
self.anim.setEndValue(3.0)
self.anim.start()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
# Background
bg_color = QColor("#2d2d3d")
if self.isChecked():
bg_color = self.active_color
painter.setBrush(bg_color)
painter.setPen(Qt.NoPen)
painter.drawRoundedRect(self.rect(), 12, 12)
# Thumb
painter.setBrush(Qt.white)
painter.drawEllipse(QPoint(self._thumb_pos + 9, 12), 9, 9)
class ModernFrame(QFrame):
"""A base frame with rounded corners and a shadow."""
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("premiumFrame")
self.setStyleSheet(f"""
#premiumFrame {{
background-color: {Theme.BG_CARD};
border: 1px solid {Theme.BORDER_SUBTLE};
border-radius: 12px;
}}
""")
self.shadow = QGraphicsDropShadowEffect(self)
self.shadow.setBlurRadius(25)
self.shadow.setXOffset(0)
self.shadow.setYOffset(8)
self.shadow.setColor(QColor(0, 0, 0, 180))
self.setGraphicsEffect(self.shadow)
from PySide6.QtWidgets import (
QPushButton, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QGraphicsDropShadowEffect, QFrame, QAbstractButton, QSlider
)
class ModernSlider(QSlider):
"""A custom painted modern slider with a glowing knob."""
def __init__(self, orientation=Qt.Horizontal, parent=None):
super().__init__(orientation, parent)
self.setStyleSheet(f"""
QSlider::groove:horizontal {{
border: 1px solid {Theme.BG_DARK};
height: 4px;
background: {Theme.BG_DARK};
margin: 2px 0;
border-radius: 2px;
}}
QSlider::handle:horizontal {{
background: {Theme.ACCENT_CYAN};
border: 2px solid white;
width: 16px;
height: 16px;
margin: -7px 0;
border-radius: 8px;
}}
QSlider::add-page:horizontal {{
background: {Theme.BG_DARK};
}}
QSlider::sub-page:horizontal {{
background: {Theme.ACCENT_CYAN};
border-radius: 2px;
}}
""")
class FramelessWindow(QWidget):
"""Base class for all premium windows to handle dragging and frameless logic."""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.NoDropShadowWindowHint)
self.setAttribute(Qt.WA_TranslucentBackground)
self._drag_pos = None
def mousePressEvent(self, event):
if event.button() == Qt.LeftButton:
self._drag_pos = event.globalPosition().toPoint() - self.frameGeometry().topLeft()
event.accept()
def mouseMoveEvent(self, event):
if event.buttons() & Qt.LeftButton:
self.move(event.globalPosition().toPoint() - self._drag_pos)
event.accept()

109
src/ui/loader.py Normal file
View File

@@ -0,0 +1,109 @@
"""
Loader Widget Module.
=====================
Handles the application initialization and model checks.
Refactored for 2026 Premium Aesthetics.
"""
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QProgressBar
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtGui import QFont
import os
import logging
from faster_whisper import download_model
from src.core.paths import get_models_path
from src.ui.styles import Theme, StyleGenerator, load_modern_fonts
from src.ui.components import FramelessWindow, ModernFrame
class DownloadWorker(QThread):
"""Background worker for model downloads."""
progress = Signal(str, int)
download_finished = Signal()
error = Signal(str)
def run(self):
try:
model_path = get_models_path()
self.progress.emit("Verifying AI Core...", 10)
os.environ["HF_HOME"] = str(model_path)
self.progress.emit("Downloading Model...", 30)
download_model("small", output_dir=str(model_path))
self.progress.emit("System Ready!", 100)
self.download_finished.emit()
except Exception as e:
logging.error(f"Loader failed: {e}")
self.error.emit(str(e))
class LoaderWidget(FramelessWindow):
"""
Premium bootstrapper UI.
Inherits from FramelessWindow for rounded glass look.
"""
ready_signal = Signal()
def __init__(self):
super().__init__()
self.setFixedSize(400, 180)
# Main Layout
self.root = QVBoxLayout(self)
self.root.setContentsMargins(10, 10, 10, 10)
# Glass Card
self.card = ModernFrame()
self.card.setStyleSheet(StyleGenerator.get_glass_card(radius=20))
self.root.addWidget(self.card)
# Content Layout
self.layout = QVBoxLayout(self.card)
self.layout.setContentsMargins(30,30,30,30)
self.layout.setSpacing(15)
# App Title/Brand
self.brand = QLabel("WHISPER VOICE")
self.brand.setFont(load_modern_fonts())
self.brand.setStyleSheet(f"color: {Theme.ACCENT_CYAN}; font-weight: 900; letter-spacing: 4px; font-size: 14px;")
self.brand.setAlignment(Qt.AlignCenter)
self.layout.addWidget(self.brand)
# Status Label
self.status_label = QLabel("INITIALIZING...")
self.status_label.setStyleSheet(f"color: {Theme.TEXT_SECONDARY}; font-weight: 600; font-size: 11px;")
self.status_label.setAlignment(Qt.AlignCenter)
self.layout.addWidget(self.status_label)
# Progress Bar (Modern Slim style)
self.progress_bar = QProgressBar()
self.progress_bar.setFixedHeight(4)
self.progress_bar.setStyleSheet(f"""
QProgressBar {{
background-color: {Theme.BG_DARK};
border-radius: 2px;
border: none;
text-align: center;
color: transparent;
}}
QProgressBar::chunk {{
background-color: {Theme.ACCENT_CYAN};
border-radius: 2px;
}}
""")
self.layout.addWidget(self.progress_bar)
# Start Worker
self.worker = DownloadWorker()
self.worker.progress.connect(self.update_progress)
self.worker.download_finished.connect(self.on_finished)
self.worker.start()
def update_progress(self, text: str, percent: int):
self.status_label.setText(text.upper())
self.progress_bar.setValue(percent)
def on_finished(self):
self.ready_signal.emit()
self.close()

105
src/ui/overlay.py Normal file
View File

@@ -0,0 +1,105 @@
"""
Overlay Window Module.
======================
Premium High-Fidelity Overlay for Whisper Voice.
Features glassmorphism, pulsating status indicators, and smart positioning.
"""
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel
from PySide6.QtCore import Qt, Slot, QPoint, QPropertyAnimation, QEasingCurve
from PySide6.QtGui import QColor, QFont, QGuiApplication
from src.ui.visualizer import AudioVisualizer
from src.ui.styles import Theme, StyleGenerator, load_modern_fonts
from src.ui.components import FramelessWindow, ModernFrame
class OverlayWindow(FramelessWindow):
"""
The main transparent overlay (The Pill).
Refactored for 2026 Premium Aesthetics.
"""
def __init__(self):
super().__init__()
self.setFixedSize(320, 95)
# Main Layout
self.master_layout = QVBoxLayout(self)
self.master_layout.setContentsMargins(10, 10, 10, 10)
# The Glass Pill Container
self.pill = ModernFrame()
self.pill.setStyleSheet(StyleGenerator.get_glass_card(radius=24))
self.master_layout.addWidget(self.pill)
# Layout inside the pill
self.layout = QHBoxLayout(self.pill)
self.layout.setContentsMargins(20, 10, 20, 10)
self.layout.setSpacing(15)
# Status Visualization (Left Dot)
self.status_dot = QWidget()
self.status_dot.setFixedSize(14, 14)
self.status_dot.setStyleSheet(f"background-color: {Theme.ACCENT_CYAN}; border-radius: 7px; border: 2px solid white;")
self.layout.addWidget(self.status_dot)
# Text/Visualizer Stack
self.content_stack = QVBoxLayout()
self.content_stack.setSpacing(2)
self.content_stack.setContentsMargins(0, 0, 0, 0)
self.status_label = QLabel("READY")
self.status_label.setFont(load_modern_fonts())
self.status_label.setStyleSheet(f"color: white; font-weight: 800; font-size: 11px; letter-spacing: 2px;")
self.content_stack.addWidget(self.status_label)
self.visualizer = AudioVisualizer()
self.visualizer.setFixedHeight(30)
self.content_stack.addWidget(self.visualizer)
self.layout.addLayout(self.content_stack)
# Animations
self.pulse_timer = None # Use style-based pulsing to avoid window flags issues
# Initial State
self.hide()
self.first_show = True
def showEvent(self, event):
"""Handle positioning and config updates."""
from src.core.config import ConfigManager
config = ConfigManager()
self.setWindowOpacity(config.get("opacity"))
if self.first_show:
self.center_above_taskbar()
self.first_show = False
super().showEvent(event)
def center_above_taskbar(self):
screen = QGuiApplication.primaryScreen()
if not screen: return
avail_rect = screen.availableGeometry()
x = avail_rect.x() + (avail_rect.width() - self.width()) // 2
y = avail_rect.bottom() - self.height() - 15
self.move(x, y)
@Slot(str)
def update_status(self, text: str):
"""Updates the status text and visual indicator."""
self.status_label.setText(text.upper())
if "RECORDING" in text.upper():
color = Theme.ACCENT_GREEN
elif "THINKING" in text.upper():
color = Theme.ACCENT_PURPLE
else:
color = Theme.ACCENT_CYAN
self.status_dot.setStyleSheet(f"background-color: {color}; border-radius: 7px; border: 2px solid white;")
@Slot(float)
def update_visualizer(self, amp: float):
self.visualizer.set_amplitude(amp)

10
src/ui/qml/AUTHORS.txt Normal file
View File

@@ -0,0 +1,10 @@
# This is the official list of project authors for copyright purposes.
# This file is distinct from the CONTRIBUTORS.txt file.
# See the latter for an explanation.
#
# Names should be added to this file as:
# Name or Organization <email address>
JetBrains <>
Philipp Nurullin <philipp.nurullin@jetbrains.com>
Konstantin Bulenkov <kb@jetbrains.com>

47
src/ui/qml/GlowButton.qml Normal file
View File

@@ -0,0 +1,47 @@
import QtQuick
import QtQuick.Controls
Button {
id: control
text: "Button"
property color accentColor: "#00f2ff"
contentItem: Text {
text: control.text
font.pixelSize: 13
font.bold: true
color: control.hovered ? "white" : "#9499b0"
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
Behavior on color { ColorAnimation { duration: 200 } }
}
background: Rectangle {
implicitWidth: 100
implicitHeight: 40
opacity: control.down ? 0.7 : 1.0
color: control.hovered ? Qt.rgba(1, 1, 1, 0.1) : Qt.rgba(1, 1, 1, 0.05)
radius: 8
border.color: control.hovered ? control.accentColor : Qt.rgba(1, 1, 1, 0.1)
border.width: 1
Behavior on border.color { ColorAnimation { duration: 200 } }
Behavior on color { ColorAnimation { duration: 200 } }
// Glow effect
Rectangle {
anchors.fill: parent
radius: parent.radius
color: "transparent"
border.color: control.accentColor
border.width: 2
opacity: control.hovered ? 0.5 : 0.0
visible: opacity > 0
Behavior on opacity { NumberAnimation { duration: 300 } }
}
}
}

Binary file not shown.

186
src/ui/qml/Loader.qml Normal file
View File

@@ -0,0 +1,186 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
ApplicationWindow {
id: root
FontLoader {
id: jetBrainsMono
source: "fonts/ttf/JetBrainsMono-Bold.ttf"
}
width: 400
height: 250
visible: true
flags: Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool
color: "transparent"
Rectangle {
id: bgRect
anchors.fill: parent
anchors.margins: 20 // Space for shadow
radius: 16
color: "#1a1a20"
border.color: "#40ffffff"
border.width: 1
// --- SHADOW & GLOW ---
layer.enabled: true
layer.effect: MultiEffect {
shadowEnabled: true
shadowColor: "#80000000"
shadowBlur: 1.0
shadowOpacity: 0.8
shadowVerticalOffset: 4
autoPaddingEnabled: true
}
// --- CONTENT ---
Column {
anchors.centerIn: parent
width: parent.width - 60
spacing: 24
// Logo / Icon Area
Item {
width: 60; height: 60
anchors.horizontalCenter: parent.horizontalCenter
Rectangle {
anchors.fill: parent
radius: 30
color: "transparent"
border.width: 2
border.color: "#00f2ff"
// Pulse Animation
SequentialAnimation on scale {
loops: Animation.Infinite
NumberAnimation { from: 1.0; to: 1.1; duration: 1000; easing.type: Easing.InOutSine }
NumberAnimation { from: 1.1; to: 1.0; duration: 1000; easing.type: Easing.InOutSine }
}
}
Image {
source: "microphone.svg"
anchors.centerIn: parent
width: 32; height: 32
sourceSize: Qt.size(32, 32)
fillMode: Image.PreserveAspectFit
smooth: true
// Colorize
layer.enabled: true
layer.effect: MultiEffect {
colorization: 1.0
colorizationColor: "#00f2ff"
}
}
}
// Title
Column {
spacing: 4
anchors.horizontalCenter: parent.horizontalCenter
Text {
text: "WHISPER VOICE"
color: "#ffffff"
font.family: jetBrainsMono.name
font.pixelSize: 18
font.bold: true
font.letterSpacing: 3
anchors.horizontalCenter: parent.horizontalCenter
}
Text {
text: "AI TRANSCRIPTION ENGINE"
color: "#80ffffff"
font.family: jetBrainsMono.name
font.pixelSize: 10
font.letterSpacing: 2
anchors.horizontalCenter: parent.horizontalCenter
}
}
// Status & Progress
Column {
width: parent.width
spacing: 8
// Progress Bar Background
Rectangle {
width: parent.width
height: 4
color: "#252530"
radius: 2
clip: true
// Progress Fill
Rectangle {
width: (ui.downloadProgress / 100.0) * parent.width
height: parent.height
radius: 2
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: "#00f2ff" }
GradientStop { position: 1.0; color: "#00a0ff" }
}
Behavior on width {
NumberAnimation { duration: 250; easing.type: Easing.OutCubic }
}
// Shimmer effect on bar
Rectangle {
width: 20; height: parent.height
color: "#80ffffff"
x: -width
opacity: 0.5
rotation: 20
transformOrigin: Item.Center
NumberAnimation on x {
from: 0; to: parent.width + 50
duration: 1000
loops: Animation.Infinite
}
}
}
}
// Status Text
Text {
text: ui.loaderStatus
color: "#00f2ff"
font.family: jetBrainsMono.name
font.pixelSize: 11
font.bold: true
anchors.horizontalCenter: parent.horizontalCenter
opacity: 0.8
}
}
}
}
// Entry Animation
Component.onCompleted: {
bgRect.scale = 0.9
bgRect.opacity = 0
entryAnim.start()
}
ParallelAnimation {
id: entryAnim
NumberAnimation {
target: bgRect; property: "scale"
to: 1.0; duration: 600; easing.type: Easing.OutBack
}
NumberAnimation {
target: bgRect; property: "opacity"
to: 1.0; duration: 400
}
}
}

View File

@@ -0,0 +1,127 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
ComboBox {
id: control
// Custom properties
property color accentColor: SettingsStyle.accent
property color bgColor: "#1a1a20"
property color popupColor: "#252530"
delegate: ItemDelegate {
id: delegate
width: control.width
height: 40
padding: 0
contentItem: RowLayout {
spacing: 8
width: parent.width
anchors.leftMargin: 10
anchors.rightMargin: 10
Text {
text: control.textRole ? (modelData[control.textRole] || modelData) : modelData
color: highlighted ? control.accentColor : "#ffffff"
font.family: "JetBrains Mono"
font.pixelSize: 14
elide: Text.ElideRight
Layout.fillWidth: true
verticalAlignment: Text.AlignVCenter
scale: highlighted ? 1.05 : 1.0
Behavior on scale { NumberAnimation { duration: 100 } }
}
// Indicator for "Downloaded" or "Active"
Rectangle {
width: 6; height: 6; radius: 3
color: control.accentColor
visible: ui.isModelDownloaded(modelData)
Layout.alignment: Qt.AlignVCenter
}
}
background: Rectangle {
color: highlighted ? Qt.rgba(control.accentColor.r, control.accentColor.g, control.accentColor.b, 0.1) : "transparent"
radius: 4
Behavior on color { ColorAnimation { duration: 100 } }
}
}
indicator: Canvas {
x: control.width - width - control.rightPadding
y: control.topPadding + (control.availableHeight - height) / 2
width: 12
height: 8
contextType: "2d"
Connections {
target: control
function onPressedChanged() { control.indicator.requestPaint() }
}
onPaint: {
context.reset();
context.moveTo(0, 0);
context.lineTo(width, 0);
context.lineTo(width / 2, height);
context.closePath();
context.fillStyle = control.pressed ? control.accentColor : "#888888";
context.fill();
}
}
contentItem: Text {
leftPadding: 10
rightPadding: control.indicator.width + control.spacing
text: control.displayText
font.family: "JetBrains Mono"
font.pixelSize: 14
color: control.pressed ? control.accentColor : "#ffffff"
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
background: Rectangle {
implicitWidth: 140
implicitHeight: 40
color: control.bgColor
border.color: control.pressed || control.activeFocus ? control.accentColor : "#40ffffff"
border.width: 1
radius: 6
// Glow effect on focus (Simplified to just border for stability)
Behavior on border.color { ColorAnimation { duration: 150 } }
}
popup: Popup {
y: control.height - 1
width: control.width
implicitHeight: contentItem.implicitHeight
padding: 5
contentItem: ListView {
clip: true
implicitHeight: contentHeight
model: control.popup.visible ? control.delegateModel : null
currentIndex: control.highlightedIndex
ScrollIndicator.vertical: ScrollIndicator { }
}
background: Rectangle {
color: control.popupColor
border.color: "#40ffffff"
border.width: 1
radius: 6
}
enter: Transition {
NumberAnimation { property: "opacity"; from: 0.0; to: 1.0; duration: 100 }
NumberAnimation { property: "scale"; from: 0.95; to: 1.0; duration: 100 }
}
}
}

View File

@@ -0,0 +1,111 @@
import QtQuick
import QtQuick.Controls
Rectangle {
id: control
implicitWidth: 140
implicitHeight: 32
color: "#1a1a20"
radius: 6
border.width: 1
border.color: activeFocus || recording ? SettingsStyle.accent : "#40ffffff"
property string currentSequence: ""
signal sequenceChanged(string seq)
property bool recording: false
onRecordingChanged: {
if (recording) {
ui.hotkeysEnabled = false
} else {
ui.hotkeysEnabled = true
}
}
Text {
anchors.centerIn: parent
text: control.recording ? "Listening..." : (control.currentSequence || "None")
color: control.recording ? SettingsStyle.accent : (control.currentSequence ? "#ffffff" : "#808080")
font.family: "JetBrains Mono"
font.pixelSize: 13
font.bold: true
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
control.forceActiveFocus()
control.recording = true
}
}
Keys.onPressed: (event) => {
if (!control.recording) return
// Ignore specific standalone modifiers to allow combos
if (event.key === Qt.Key_Control || event.key === Qt.Key_Shift || event.key === Qt.Key_Alt || event.key === Qt.Key_Meta) {
return
}
// Build Modifier String
var seq = ""
if (event.modifiers & Qt.ControlModifier) seq += "ctrl+"
if (event.modifiers & Qt.ShiftModifier) seq += "shift+"
if (event.modifiers & Qt.AltModifier) seq += "alt+"
if (event.modifiers & Qt.MetaModifier) seq += "win+"
// Get Key Name
var keyName = getKeyName(event.key, event.text)
seq += keyName
// Update
control.currentSequence = seq
control.sequenceChanged(seq)
control.recording = false
event.accepted = true
}
onActiveFocusChanged: {
if (!activeFocus) control.recording = false
}
function getKeyName(key, text) {
// F-Keys
if (key >= Qt.Key_F1 && key <= Qt.Key_F35) return "f" + (key - Qt.Key_F1 + 1)
// Common Keys
switch (key) {
case Qt.Key_Space: return "space"
case Qt.Key_Backspace: return "backspace"
case Qt.Key_Tab: return "tab"
case Qt.Key_Return: return "enter"
case Qt.Key_Enter: return "enter"
case Qt.Key_Escape: return "esc"
case Qt.Key_Delete: return "delete"
case Qt.Key_Insert: return "insert"
case Qt.Key_Home: return "home"
case Qt.Key_End: return "end"
case Qt.Key_PageUp: return "pageup"
case Qt.Key_PageDown: return "pagedown"
case Qt.Key_Up: return "up"
case Qt.Key_Down: return "down"
case Qt.Key_Left: return "left"
case Qt.Key_Right: return "right"
case Qt.Key_CapsLock: return "capslock"
case Qt.Key_NumLock: return "numlock"
case Qt.Key_ScrollLock: return "scrolllock"
case Qt.Key_Print: return "printscreen"
case Qt.Key_Pause: return "pause"
}
// Letters and Numbers (Use text if available, fallback to manual)
if (text && text.length > 0 && text.charCodeAt(0) >= 32) {
return text.toLowerCase()
}
return "key" + key
}
}

View File

@@ -0,0 +1,92 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
Rectangle {
id: root
Layout.fillWidth: true
Layout.preferredHeight: SettingsStyle.itemHeight
Layout.minimumHeight: SettingsStyle.itemHeight
Layout.maximumHeight: SettingsStyle.itemHeight
implicitHeight: SettingsStyle.itemHeight
color: hHandler.hovered ? SettingsStyle.surfaceHover : "transparent"
radius: 0 // Continuous list style (clipped by container container)
// Properties
property string label: "Setting Name"
property string description: ""
property alias control: controlContainer.data
property bool showSeparator: true
Behavior on color { ColorAnimation { duration: 150 } }
HoverHandler {
id: hHandler
}
// Label & Description
Column {
id: labelCol
anchors.left: parent.left
anchors.leftMargin: 16
anchors.right: controlContainer.left
anchors.rightMargin: 16
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
id: labelText
text: root.label
color: SettingsStyle.textPrimary
font.family: "JetBrains Mono"
font.pixelSize: 13
font.weight: Font.Medium
width: parent.width
elide: Text.ElideRight
// Text Layout Normalization
verticalAlignment: Text.AlignVCenter
topPadding: 0
bottomPadding: 0
}
Text {
id: descText
text: root.description
color: SettingsStyle.textSecondary
font.family: "JetBrains Mono"
font.pixelSize: 11
width: parent.width
visible: text !== ""
wrapMode: Text.WordWrap
// Text Layout Normalization
verticalAlignment: Text.AlignVCenter
topPadding: 0
bottomPadding: 0
}
}
// Control Container
Item {
id: controlContainer
anchors.right: parent.right
anchors.rightMargin: 16
anchors.verticalCenter: parent.verticalCenter
implicitWidth: childrenRect.width
implicitHeight: childrenRect.height
}
// Bottom Separator
Rectangle {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: 16
anchors.rightMargin: 16
height: 1
color: SettingsStyle.borderSubtle
visible: root.showSeparator
}
}

View File

@@ -0,0 +1,61 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Effects
ColumnLayout {
id: root
spacing: 8
default property alias content: contentColumn.data
property string title: ""
// Section Header
Text {
text: root.title
color: SettingsStyle.accent // Accented header for more color
font.family: "JetBrains Mono"
font.pixelSize: 12
font.bold: true
font.capitalization: Font.AllUppercase
font.letterSpacing: 1.5
Layout.leftMargin: 4
Layout.bottomMargin: 4
visible: text !== ""
}
// Card Container
Rectangle {
Layout.fillWidth: true
// Height checks children
implicitHeight: contentColumn.implicitHeight
color: SettingsStyle.surfaceCard
radius: SettingsStyle.cardRadius
border.color: SettingsStyle.borderSubtle
border.width: 1
ColumnLayout {
id: contentColumn
anchors.fill: parent
anchors.margins: 1 // Minimal margin to not overlap border
spacing: 0 // No gaps, rely on separators
// Clip content to rounded corners
layer.enabled: true
layer.effect: MultiEffect {
maskEnabled: true
maskThresholdMin: 0.5
maskSpreadAtMin: 1.0
maskSource: ShaderEffectSource {
sourceItem: Rectangle {
width: contentColumn.width
height: contentColumn.height
radius: SettingsStyle.cardRadius - 1
color: "black"
}
}
}
}
}
}

View File

@@ -0,0 +1,61 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
Slider {
id: control
background: Rectangle {
x: control.leftPadding
y: control.topPadding + control.availableHeight / 2 - height / 2
implicitWidth: 200
implicitHeight: 4
width: control.availableWidth
height: implicitHeight
radius: 2
color: "#2d2d3d"
Rectangle {
width: control.visualPosition * parent.width
height: parent.height
color: SettingsStyle.accent
radius: 2
}
}
handle: Rectangle {
x: control.leftPadding + control.visualPosition * (control.availableWidth - width)
y: control.topPadding + control.availableHeight / 2 - height / 2
implicitWidth: 18
implicitHeight: 18
radius: 9
color: "white"
border.color: SettingsStyle.accent
border.width: 2
layer.enabled: control.pressed
layer.effect: MultiEffect {
blurEnabled: true
blur: 0.5
shadowEnabled: true
shadowColor: SettingsStyle.accent
}
}
// Value Readout (Left side to avoid clipping on right edge)
Text {
anchors.right: parent.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
horizontalAlignment: Text.AlignRight
text: {
var val = control.value
return (val % 1 === 0) ? val.toFixed(0) : val.toFixed(1)
}
color: SettingsStyle.textSecondary
font.family: "JetBrains Mono"
font.pixelSize: 12
font.weight: Font.Medium
}
}

View File

@@ -0,0 +1,40 @@
import QtQuick
import QtQuick.Controls
Switch {
id: control
indicator: Rectangle {
implicitWidth: 44
implicitHeight: 24
x: control.leftPadding
y: parent.height / 2 - height / 2
radius: 12
color: control.checked ? SettingsStyle.accent : "#2d2d3d"
border.color: control.checked ? SettingsStyle.accent : "#3d3d4d"
Behavior on color { ColorAnimation { duration: 200 } }
Rectangle {
x: control.checked ? parent.width - width - 3 : 3
y: 3
width: 18
height: 18
radius: 9
color: "white"
Behavior on x {
NumberAnimation { duration: 200; easing.type: Easing.InOutQuad }
}
}
}
contentItem: Text {
text: control.text
font: control.font
opacity: enabled ? 1.0 : 0.3
color: "white"
verticalAlignment: Text.AlignVCenter
leftPadding: control.indicator.width + control.spacing
}
}

View File

@@ -0,0 +1,27 @@
import QtQuick
import QtQuick.Controls
TextField {
id: control
property color accentColor: "#00f2ff"
property color bgColor: "#1a1a20"
placeholderTextColor: "#606060"
color: "#ffffff"
font.family: "JetBrains Mono"
font.pixelSize: 14
selectedTextColor: "#000000"
selectionColor: accentColor
background: Rectangle {
implicitWidth: 200
implicitHeight: 40
color: control.bgColor
border.color: control.activeFocus ? control.accentColor : "#40ffffff"
border.width: 1
radius: 6
Behavior on border.color { ColorAnimation { duration: 150 } }
}
}

93
src/ui/qml/OFL.txt Normal file
View File

@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

353
src/ui/qml/Overlay.qml Normal file
View File

@@ -0,0 +1,353 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import QtQuick.Particles
import Qt5Compat.GraphicalEffects
ApplicationWindow {
id: root
width: 460 * (ui ? ui.uiScale : 1.0)
height: 180 * (ui ? ui.uiScale : 1.0)
visible: true
flags: Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool
color: "transparent"
FontLoader {
id: jetBrainsMono
source: "fonts/ttf/JetBrainsMono-Bold.ttf"
}
property real shimmerPos: -0.5
property real windowOpacity: ui.getSetting("opacity")
property real uiScale: Number(ui.getSetting("ui_scale")) // Bind Scale
Connections {
target: ui
function onSettingChanged(key, value) {
if (key === "opacity") windowOpacity = value
if (key === "ui_scale") uiScale = Number(value)
}
}
// Visibility Logic
property bool isActive: ui.isRecording || ui.isProcessing
SequentialAnimation {
running: true
loops: Animation.Infinite
PauseAnimation { duration: 3000 }
NumberAnimation {
target: root; property: "shimmerPos"
from: -0.5; to: 1.5; duration: 1500
easing.type: Easing.InOutQuad
}
}
// Container
Item {
id: mainContainer
width: 380
height: 100
anchors.centerIn: parent
// Scale & Opacity Transform
scale: root.isActive ? root.uiScale : 0.8
opacity: root.isActive ? root.windowOpacity : 0.0
visible: opacity > 0.01 // Optimization
// Motion.dev-like Spring Animation
Behavior on scale {
SpringAnimation { spring: 3; damping: 0.25; epsilon: 0.005 }
}
Behavior on opacity {
NumberAnimation { duration: 300; easing.type: Easing.OutCubic }
}
// --- SHADOW ---
Item {
anchors.fill: bgRect
layer.enabled: true
layer.effect: MultiEffect {
shadowEnabled: true
shadowColor: "#A0000000"
shadowBlur: 0.4
autoPaddingEnabled: true
}
}
// --- CHASSIS VISUALS (Hidden Source) ---
Item {
id: contentSource
anchors.fill: bgRect
visible: false
// A. Gradient Background
Rectangle {
anchors.fill: parent
gradient: Gradient {
GradientStop { position: 0.0; color: "#CC101015" }
GradientStop { position: 1.0; color: "#CC050505" }
}
}
// B. Animated Gradient Blobs
ShaderEffect {
anchors.fill: parent
opacity: 0.4
property real time: 0
fragmentShader: "gradient_blobs.qsb"
NumberAnimation on time { from: 0; to: 1000; duration: 100000; loops: Animation.Infinite }
}
// C. Glow Shader
ShaderEffect {
anchors.fill: parent
opacity: 0.04
property real time: 0
property real intensity: ui.amplitude
fragmentShader: "glow.qsb"
NumberAnimation on time { from: 0; to: 100; duration: 10000; loops: Animation.Infinite }
}
// D. Particles
ParticleSystem {
id: particles
anchors.fill: parent
ItemParticle {
system: particles
delegate: Rectangle { width: 2; height: 2; radius: 1; color: "#10ffffff" }
}
Emitter {
anchors.fill: parent; emitRate: 15; lifeSpan: 4000; size: 4; sizeVariation: 2
velocity: AngleDirection { angle: -90; angleVariation: 180; magnitude: 5 }
acceleration: PointDirection { y: -2 }
}
}
// E. Shimmer
Rectangle {
width: parent.width * 0.4; height: parent.height * 2.5
rotation: 25; anchors.centerIn: parent
x: (root.shimmerPos * parent.width * 2) - width; y: -height/4
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: "transparent" }
GradientStop { position: 0.5; color: "#15ffffff" }
GradientStop { position: 1.0; color: "transparent" }
}
opacity: 0.8
}
// F. CRT Shader Effect (Overlay on chassis ONLY)
ShaderEffect {
anchors.fill: parent
property real time: 0
fragmentShader: "crt.qsb"
NumberAnimation on time { from: 0; to: 100; duration: 5000; loops: Animation.Infinite }
}
}
// --- MASK ---
Rectangle {
id: contentMask
anchors.fill: bgRect
radius: height / 2
visible: false; color: "white"
smooth: true; antialiasing: true
}
// --- COMPOSITED CHASSIS ---
OpacityMask {
anchors.fill: bgRect
source: contentSource
maskSource: contentMask
}
// --- BORDER & INTERACTION ---
Rectangle {
id: bgRect
anchors.fill: parent
radius: height / 2
color: "transparent"
border.width: 1
border.color: "#40ffffff"
MouseArea {
anchors.fill: parent; hoverEnabled: true
cursorShape: pressed ? Qt.ClosedHandCursor : Qt.PointingHandCursor
onPressed: root.startSystemMove()
}
Rectangle {
anchors.fill: parent
anchors.margins: ui.isRecording ? -(ui.amplitude * 3) : 0
radius: parent.radius
color: "transparent"
border.width: ui.isRecording ? 3 : 1.5
border.color: ui.isRecording ? "#A0ff4b4b" : "#6000f2ff"
Behavior on anchors.margins {
NumberAnimation { duration: 150; easing.type: Easing.OutCubic }
}
Behavior on border.width {
NumberAnimation { duration: 150; easing.type: Easing.OutCubic }
}
SequentialAnimation on border.color {
running: ui.isRecording
loops: Animation.Infinite
ColorAnimation { from: "#A0ff4b4b"; to: "#C0ff6b6b"; duration: 800 }
ColorAnimation { from: "#C0ff6b6b"; to: "#A0ff4b4b"; duration: 800 }
}
}
}
// --- MICROPHONE ICON (Enhanced) ---
Item {
id: micContainer
width: 80; height: 80
anchors.left: parent.left
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
// Make entire button scale with amplitude
scale: ui.isRecording ? (1.0 + ui.amplitude * 0.12) : 1.0
Behavior on scale {
NumberAnimation { duration: 150; easing.type: Easing.OutCubic }
}
// MouseArea at parent level to avoid layer blocking
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
console.log("Microphone clicked! Emitting signal...")
ui.toggleRecordingRequested()
}
}
layer.enabled: true
layer.effect: MultiEffect {
shadowEnabled: true
shadowColor: ui.isRecording ? "#FF00a0ff" : "#FF00f2ff"
shadowBlur: 1.0
shadowOpacity: ui.isRecording ? 1.0 : 0.5
}
Rectangle {
id: micCircle
anchors.fill: parent
radius: width / 2
gradient: Gradient {
GradientStop { position: 0.0; color: "#40ffffff" }
GradientStop { position: 1.0; color: "#10ffffff" }
}
border.width: 2; border.color: "#60ffffff"
SequentialAnimation on scale {
running: ui.isRecording
loops: Animation.Infinite
NumberAnimation { from: 1.0; to: 1.08; duration: 600; easing.type: Easing.InOutQuad }
NumberAnimation { from: 1.08; to: 1.0; duration: 600; easing.type: Easing.InOutQuad }
}
Image {
id: micIcon
anchors.centerIn: parent
width: 40
height: 40
source: "microphone.svg"
smooth: true
antialiasing: true
mipmap: true
fillMode: Image.PreserveAspectFit
}
}
}
// --- RAINBOW WAVEFORM (Shader) ---
Item {
id: waveformContainer
anchors.left: micContainer.right
anchors.leftMargin: 10
anchors.right: parent.right
anchors.rightMargin: 80
anchors.verticalCenter: parent.verticalCenter
height: 90
ShaderEffect {
anchors.fill: parent
property real time: 0
property real amplitude: ui.amplitude
fragmentShader: "rainbow_wave.qsb"
NumberAnimation on time { from: 0; to: 1000; duration: 100000; loops: Animation.Infinite }
layer.enabled: true
layer.effect: MultiEffect {
shadowEnabled: true
shadowColor: Qt.hsla((Date.now() / 100) % 1.0, 1.0, 0.6, 1.0)
shadowBlur: 1.0; shadowOpacity: 1.0
}
}
}
// --- RECORDING TIMER ---
Item {
id: recordingTimerContainer
anchors.right: parent.right
anchors.rightMargin: 20
anchors.verticalCenter: parent.verticalCenter
width: 60; height: 30
property int recordingSeconds: 0
Connections {
target: ui
function onIsRecordingChanged() {
if (!ui.isRecording) recordingTimerContainer.recordingSeconds = 0
}
}
Timer {
interval: 1000; running: ui.isRecording; repeat: true
onTriggered: recordingTimerContainer.recordingSeconds++
}
// Triple-layer glow for REALLY strong effect
layer.enabled: true
layer.effect: MultiEffect {
shadowEnabled: true
shadowColor: ui.isRecording ? "#FFff0000" : "#FFffffff"
shadowBlur: 1.0
shadowOpacity: 1.0
shadowHorizontalOffset: 0
shadowVerticalOffset: 0
}
Item {
anchors.fill: parent
layer.enabled: true
layer.effect: MultiEffect {
shadowEnabled: true
shadowColor: ui.isRecording ? "#FFff3030" : "#FFe0e0e5"
shadowBlur: 0.8
shadowOpacity: 1.0
}
Text {
anchors.centerIn: parent
text: {
var mins = Math.floor(recordingTimerContainer.recordingSeconds / 60)
var secs = recordingTimerContainer.recordingSeconds % 60
return (mins < 10 ? "0" : "") + mins + ":" + (secs < 10 ? "0" : "") + secs
}
color: ui.isRecording ? "#ffffff" : "#ffffff"
font.family: jetBrainsMono.name; font.pixelSize: 16; font.bold: true; font.letterSpacing: 2
style: Text.Outline
styleColor: ui.isRecording ? "#ff0000" : "#808085"
SequentialAnimation on opacity {
running: ui.isRecording; loops: Animation.Infinite
NumberAnimation { from: 1.0; to: 0.7; duration: 800 }
NumberAnimation { from: 0.7; to: 1.0; duration: 800 }
}
}
}
}
}
}

905
src/ui/qml/Settings.qml Normal file
View File

@@ -0,0 +1,905 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Effects
Window {
id: root
width: 850 * (ui ? ui.uiScale : 1.0)
height: 620 * (ui ? ui.uiScale : 1.0)
visible: false
flags: Qt.FramelessWindowHint | Qt.Window
color: "transparent"
title: "Settings"
// Explicit sizing for Python to read
// Prevent destruction on close
onClosing: (close) => {
close.accepted = false
root.visible = false
}
// Load Font
FontLoader {
id: jetBrainsMono
source: "fonts/ttf/JetBrainsMono-Bold.ttf"
}
readonly property string mainFont: jetBrainsMono.name
property bool isLoaded: false
Component.onCompleted: {
isLoaded = true
}
// --- REUSABLE COMPONENTS ---
component StatBox: Rectangle {
property string label: ""
property string value: ""
property string unit: ""
property color accent: SettingsStyle.accent
Layout.fillWidth: true
Layout.preferredHeight: 80
color: "#16161a"
radius: 12
border.color: SettingsStyle.borderSubtle
border.width: 1
Column {
anchors.centerIn: parent
spacing: 4
Text { text: label; color: SettingsStyle.textSecondary; font.pixelSize: 11; font.bold: true; anchors.horizontalCenter: parent.horizontalCenter }
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: 2
Text { text: value; color: accent; font.pixelSize: 22; font.bold: true; font.family: "JetBrains Mono" }
Text { text: unit; color: SettingsStyle.textSecondary; font.pixelSize: 12; anchors.baseline: parent.bottom; anchors.baselineOffset: -4 }
}
}
}
// Main Container
Rectangle {
id: mainContainer
anchors.fill: parent
radius: 16
color: SettingsStyle.background
border.color: SettingsStyle.borderSubtle
border.width: 1
opacity: 0
transform: Translate {
id: entryTranslate
y: 20
}
Component.onCompleted: {
entryAnim.start()
}
ParallelAnimation {
id: entryAnim
NumberAnimation { target: mainContainer; property: "opacity"; to: 1; duration: 400; easing.type: Easing.OutCubic }
NumberAnimation { target: entryTranslate; property: "y"; to: 0; duration: 500; easing.type: Easing.OutBack; easing.overshoot: 0.8 }
}
// --- TITLE BAR ---
Item {
id: titleBar
height: 60
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
MouseArea {
anchors.fill: parent
onPressed: root.startSystemMove()
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 24
anchors.rightMargin: 24
Image {
source: "microphone.svg"
sourceSize.width: 18
sourceSize.height: 18
Layout.alignment: Qt.AlignVCenter
opacity: 0.7
// Colorize the icon
layer.enabled: true
layer.effect: MultiEffect {
colorization: 1.0
colorizationColor: SettingsStyle.accent
}
}
Text {
text: "SETTINGS"
color: SettingsStyle.textSecondary
font.family: mainFont
font.pixelSize: 13
font.letterSpacing: 2
font.bold: true
Layout.alignment: Qt.AlignVCenter
}
Item { Layout.fillWidth: true }
// Improved Close Button
Rectangle {
width: 32; height: 32
radius: 8
color: closeMa.containsMouse ? "#20ff4b4b" : "transparent"
border.color: closeMa.containsMouse ? "#40ff4b4b" : "transparent"
border.width: 1
Text {
anchors.centerIn: parent
text: "×"
color: closeMa.containsMouse ? "#ff4b4b" : SettingsStyle.textSecondary
font.family: mainFont
font.pixelSize: 20
font.bold: true
}
MouseArea {
id: closeMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.close()
}
Behavior on color { ColorAnimation { duration: 150 } }
Behavior on border.color { ColorAnimation { duration: 150 } }
}
}
Rectangle {
anchors.bottom: parent.bottom
width: parent.width
height: 1
color: SettingsStyle.borderSubtle
}
}
// --- CONTENT AREA ---
RowLayout {
anchors.top: titleBar.bottom
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
spacing: 0
// --- SIDEBAR ---
Rectangle {
Layout.fillHeight: true
Layout.preferredWidth: 220
Layout.minimumWidth: 220
Layout.maximumWidth: 220
color: Qt.rgba(1, 1, 1, 0.02) // Very subtle separation
ColumnLayout {
anchors.fill: parent
anchors.margins: 16
spacing: 8
ListModel {
id: navModel
ListElement { name: "General"; icon: "settings.svg" }
ListElement { name: "Audio"; icon: "microphone.svg" }
ListElement { name: "Visuals"; icon: "visibility.svg" }
ListElement { name: "AI Engine"; icon: "smart_toy.svg" }
ListElement { name: "Debug"; icon: "terminal.svg" }
}
Repeater {
model: navModel
delegate: Rectangle {
id: navBtnRoot
Layout.fillWidth: true
height: 38
color: stack.currentIndex === index ? SettingsStyle.surfaceHover : (ma.containsMouse ? Qt.rgba(1,1,1,0.03) : "transparent")
radius: 6
Behavior on color { ColorAnimation { duration: 150 } }
// Left active stripe
Rectangle {
width: 3
height: 20
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
radius: 2
color: SettingsStyle.accent
visible: stack.currentIndex === index
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 12
spacing: 12
Image {
source: icon
sourceSize.width: 16
sourceSize.height: 16
fillMode: Image.PreserveAspectFit
Layout.alignment: Qt.AlignVCenter
opacity: stack.currentIndex === index ? 1.0 : 0.5
layer.enabled: true
layer.effect: MultiEffect {
colorization: 1.0
colorizationColor: stack.currentIndex === index ? SettingsStyle.accent : SettingsStyle.textSecondary
}
}
Text {
text: name
color: stack.currentIndex === index ? SettingsStyle.textPrimary : SettingsStyle.textSecondary
font.family: mainFont
font.pixelSize: 13
font.weight: stack.currentIndex === index ? Font.Bold : Font.Normal
}
}
MouseArea {
id: ma
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: stack.currentIndex = index
}
}
}
Item { Layout.fillHeight: true }
}
// Vertical Divider
Rectangle {
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
width: 1
color: SettingsStyle.borderSubtle
}
}
// --- MAIN CONTENT STACK ---
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: "transparent"
clip: true
StackLayout {
id: stack
anchors.fill: parent
currentIndex: 0
// --- TAB: GENERAL ---
ScrollView {
ScrollBar.vertical.policy: ScrollBar.AsNeeded
contentWidth: availableWidth
ColumnLayout {
width: parent.width
spacing: 24
anchors.margins: 32
// Header
ColumnLayout {
spacing: 4
Layout.topMargin: 32
Layout.leftMargin: 32
Layout.rightMargin: 32
Text { text: "General"; color: SettingsStyle.textPrimary; font.family: mainFont; font.pixelSize: 24; font.bold: true }
Text { text: "System behavior and shortcuts"; color: SettingsStyle.textSecondary; font.family: mainFont; font.pixelSize: 14 }
}
ModernSettingsSection {
title: "Application"
Layout.margins: 32
Layout.topMargin: 0
content: ColumnLayout {
width: parent.width
spacing: 0
ModernSettingsItem {
label: "Global Hotkey"
description: "Press to record a new shortcut (e.g. Ctrl+Space)"
control: ModernKeySequenceRecorder {
Layout.preferredWidth: 200
currentSequence: ui.getSetting("hotkey")
onSequenceChanged: (seq) => ui.setSetting("hotkey", seq)
}
}
ModernSettingsItem {
label: "Run on Startup"
description: "Automatically launch when you log in"
control: ModernSwitch {
checked: ui.getSetting("run_on_startup")
onToggled: ui.setSetting("run_on_startup", checked)
}
}
ModernSettingsItem {
label: "Input Method"
description: "How text is sent to the active window"
control: ModernComboBox {
width: 160
model: ["Clipboard Paste", "Simulate Typing"]
currentIndex: model.indexOf(ui.getSetting("input_method"))
onActivated: ui.setSetting("input_method", currentText)
}
}
ModernSettingsItem {
label: "Typing Speed"
description: "Chars/min"
showSeparator: false
control: ModernSlider {
Layout.preferredWidth: 200
from: 10; to: 6000
stepSize: 10
snapMode: Slider.SnapAlways
value: ui.getSetting("typing_speed")
onMoved: ui.setSetting("typing_speed", value)
}
}
}
}
}
}
// --- TAB: AUDIO ---
ScrollView {
ScrollBar.vertical.policy: ScrollBar.AsNeeded
contentWidth: availableWidth
ColumnLayout {
width: parent.width
spacing: 24
anchors.margins: 32
ColumnLayout {
spacing: 4
Layout.topMargin: 32
Layout.leftMargin: 32
Layout.rightMargin: 32
Text { text: "Audio"; color: SettingsStyle.textPrimary; font.family: mainFont; font.pixelSize: 24; font.bold: true }
Text { text: "Input devices and signal processing"; color: SettingsStyle.textSecondary; font.family: mainFont; font.pixelSize: 14 }
}
ModernSettingsSection {
title: "Devices"
Layout.margins: 32
Layout.topMargin: 0
content: ColumnLayout {
width: parent.width
spacing: 0
ModernSettingsItem {
label: "Microphone"
description: "Select your primary input device"
control: ModernComboBox {
Layout.preferredWidth: 280
textRole: "name"
valueRole: "id"
model: ui.getAudioDevices()
onActivated: ui.setSetting("input_device", model[currentIndex].id) // Explicitly use model index
}
}
ModernSettingsItem {
label: "Save Recordings"
description: "Save .wav files to ./recordings folder"
showSeparator: false
control: ModernSwitch {
checked: ui.getSetting("save_recordings")
onToggled: ui.setSetting("save_recordings", checked)
}
}
}
}
ModernSettingsSection {
title: "Signal Processing"
Layout.margins: 32
Layout.topMargin: 0
content: ColumnLayout {
width: parent.width
spacing: 0
ModernSettingsItem {
label: "VAD Threshold"
description: "Silence detection sensitivity (" + (ui.getSetting("silence_threshold") * 100).toFixed(0) + "%)"
control: ModernSlider {
Layout.preferredWidth: 200
from: 1; to: 100
value: ui.getSetting("silence_threshold") * 100
onMoved: ui.setSetting("silence_threshold", Number((value / 100.0).toFixed(2)))
}
}
ModernSettingsItem {
label: "Silence Timeout"
description: "Stop recording after " + (ui.getSetting("silence_duration")).toFixed(1) + "s of silence"
showSeparator: false
control: ModernSlider {
Layout.preferredWidth: 200
from: 0.5; to: 5.0
value: ui.getSetting("silence_duration")
onMoved: ui.setSetting("silence_duration", Number(value.toFixed(1)))
}
}
}
}
}
}
// --- TAB: VISUALS ---
ScrollView {
ScrollBar.vertical.policy: ScrollBar.AsNeeded
contentWidth: availableWidth
ColumnLayout {
width: parent.width
spacing: 24
anchors.margins: 32
ColumnLayout {
spacing: 4
Layout.topMargin: 32
Layout.leftMargin: 32
Layout.rightMargin: 32
Text { text: "Visuals"; color: SettingsStyle.textPrimary; font.family: mainFont; font.pixelSize: 24; font.bold: true }
Text { text: "Customize the overlay appearance"; color: SettingsStyle.textSecondary; font.family: mainFont; font.pixelSize: 14 }
}
ModernSettingsSection {
title: "Overlay"
Layout.margins: 32
Layout.topMargin: 0
content: ColumnLayout {
width: parent.width
spacing: 0
ModernSettingsItem {
label: "UI Scale"
description: "Global interface scaling factor (" + ui.getSetting("ui_scale").toFixed(2) + "x)"
control: ModernSlider {
Layout.preferredWidth: 200
from: 0.75; to: 1.5
value: ui.getSetting("ui_scale")
onMoved: ui.setSetting("ui_scale", Number(value.toFixed(2)))
}
}
ModernSettingsItem {
label: "Always on Top"
description: "Keep the overlay visible above other windows"
control: ModernSwitch {
checked: ui.getSetting("always_on_top")
onToggled: ui.setSetting("always_on_top", checked)
}
}
ModernSettingsItem {
label: "Window Opacity"
description: "Transparency level"
showSeparator: false
control: ModernSlider {
Layout.preferredWidth: 200
from: 0.1; to: 1.0
value: ui.getSetting("opacity")
onMoved: ui.setSetting("opacity", Number(value.toFixed(2)))
}
}
}
}
ModernSettingsSection {
title: "Window Position"
Layout.margins: 32
Layout.topMargin: 0
content: ColumnLayout {
width: parent.width
spacing: 0
ModernSettingsItem {
label: "Anchor Position"
description: "Where the overlay snaps to on screen"
control: ModernComboBox {
width: 160
model: ["Bottom Center", "Top Center", "Bottom Right", "Top Right", "Bottom Left", "Top Left"]
currentIndex: model.indexOf(ui.getSetting("overlay_position"))
onActivated: ui.setSetting("overlay_position", currentText)
}
}
ModernSettingsItem {
label: "Horizontal Offset"
description: "Fine-tune X position (" + ui.getSetting("overlay_offset_x") + "px)"
control: ModernSlider {
Layout.preferredWidth: 200
from: -500; to: 500
value: ui.getSetting("overlay_offset_x")
onMoved: ui.setSetting("overlay_offset_x", value)
}
}
ModernSettingsItem {
label: "Vertical Offset"
description: "Fine-tune Y position (" + ui.getSetting("overlay_offset_y") + "px)"
showSeparator: false
control: ModernSlider {
Layout.preferredWidth: 200
from: -500; to: 500
value: ui.getSetting("overlay_offset_y")
onMoved: ui.setSetting("overlay_offset_y", value)
}
}
}
}
}
}
// --- TAB: AI ENGINE ---
ScrollView {
ScrollBar.vertical.policy: ScrollBar.AsNeeded
contentWidth: availableWidth
ColumnLayout {
width: parent.width
spacing: 24
anchors.margins: 32
ColumnLayout {
spacing: 4
Layout.topMargin: 32
Layout.leftMargin: 32
Layout.rightMargin: 32
Text { text: "AI Engine"; color: SettingsStyle.textPrimary; font.family: mainFont; font.pixelSize: 24; font.bold: true }
Text { text: "Model configuration and performance"; color: SettingsStyle.textSecondary; font.family: mainFont; font.pixelSize: 14 }
}
ModernSettingsSection {
title: "Model Config"
Layout.margins: 32
Layout.topMargin: 0
content: ColumnLayout {
width: parent.width
spacing: 0
ListModel {
id: modelDetailsModel
ListElement { name: "tiny"; info: "39M params • <1GB VRAM • 32x speed. Fastest, best for weak hardware." }
ListElement { name: "base"; info: "74M params • ~1GB VRAM • 16x speed. Efficient for simple commands." }
ListElement { name: "small"; info: "244M params • ~2GB VRAM • 6x speed. Recommended for most users." }
ListElement { name: "medium"; info: "769M params • ~5GB VRAM • Accurate, high fidelity. Mid-range GPU req." }
ListElement { name: "large-v3"; info: "1.5B params • ~10GB VRAM • Pro quality. Requires high-end hardware." }
ListElement { name: "turbo"; info: "800M params • ~6GB VRAM • Large-v3 quality with 8x speed boost." }
}
ModernSettingsItem {
label: "Model Size"
description: "Larger models are smarter but slower"
control: ModernComboBox {
id: modelSizeCombo
width: 140
model: ["tiny", "base", "small", "medium", "large-v3", "turbo"]
currentIndex: model.indexOf(ui.getSetting("model_size"))
onActivated: ui.setSetting("model_size", currentText)
}
}
// Model Info Card
Rectangle {
id: modelInfoCard
Layout.fillWidth: true
Layout.margins: 12
Layout.topMargin: 0
Layout.bottomMargin: 16
height: 54
color: "#0a0a0f"
radius: 6
border.color: SettingsStyle.borderSubtle
border.width: 1
// Improved reactive check
property bool isDownloaded: false
Timer {
id: checkTimer
interval: 1000
running: true
repeat: true
onTriggered: parent.checkStatus()
}
function checkStatus() {
if (modelSizeCombo && modelSizeCombo.currentText) {
isDownloaded = ui.isModelDownloaded(modelSizeCombo.currentText)
}
}
// Refresh when notified by Python
Connections {
target: ui
function onModelStatesChanged() {
checkStatus()
}
}
Component.onCompleted: checkStatus()
// Also check when selection changes
Connections {
target: modelSizeCombo
function onActivated() { modelInfoCard.checkStatus() }
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 12
Image {
source: "smart_toy.svg"
sourceSize: Qt.size(16, 16)
layer.enabled: true
layer.effect: MultiEffect {
colorization: 1.0
colorizationColor: modelInfoCard.isDownloaded ? SettingsStyle.accent : "#808080"
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Text {
text: {
if (ui.isDownloading) return "Downloading AI Core..."
for (var i = 0; i < modelDetailsModel.count; i++) {
if (modelDetailsModel.get(i).name === modelSizeCombo.currentText) {
return modelDetailsModel.get(i).info
}
}
return "Select a model."
}
color: "#ffffff"
font.family: "JetBrains Mono"
font.pixelSize: 10
opacity: 0.7
elide: Text.ElideRight
Layout.fillWidth: true
}
Rectangle {
id: downloaderBar
visible: ui.isDownloading
Layout.fillWidth: true
height: 2
color: "#20ffffff"
Rectangle {
width: downloaderBar.width * 0.5
height: downloaderBar.height
color: SettingsStyle.accent
SequentialAnimation on x {
loops: Animation.Infinite
NumberAnimation { from: -width; to: downloaderBar.width; duration: 1500 }
}
}
}
}
Button {
id: downloadBtn
text: "Download"
visible: !modelInfoCard.isDownloaded && !ui.isDownloading
Layout.preferredHeight: 24
Layout.preferredWidth: 80
contentItem: Text {
text: "DOWNLOAD"
font.pixelSize: 10; font.bold: true; color: "#000000"; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
color: downloadBtn.hovered ? "#ffffff" : SettingsStyle.accent; radius: 4
}
onClicked: ui.downloadModel(modelSizeCombo.currentText)
}
// Status tag
Rectangle {
id: statusTag
visible: modelInfoCard.isDownloaded && !ui.isDownloading
height: 18; width: 64; radius: 4; color: "#1000f2ff"
border.color: "#3000f2ff"; border.width: 1
Text {
anchors.centerIn: statusTag; text: "INSTALLED"; font.pixelSize: 9
font.bold: true; color: SettingsStyle.accent; opacity: 0.9
}
}
}
}
ModernSettingsItem {
label: "Language"
description: "Force language or Auto-detect"
control: ModernComboBox {
width: 140
model: ["auto", "en", "fr", "de", "es", "it", "ja", "zh", "ru"]
currentIndex: model.indexOf(ui.getSetting("language"))
onActivated: ui.setSetting("language", currentText)
}
}
ModernSettingsItem {
label: "Compute Device"
description: "Hardware acceleration (CUDA requires NVidia GPU)"
control: ModernComboBox {
width: 140
model: ["auto", "cuda", "cpu"]
currentIndex: model.indexOf(ui.getSetting("compute_device"))
onActivated: ui.setSetting("compute_device", currentText)
}
}
ModernSettingsItem {
label: "Precision"
description: "Quantization type (int8 is faster, float16 is accurate)"
showSeparator: false
control: ModernComboBox {
width: 140
model: ["int8", "float16", "float32"]
currentIndex: model.indexOf(ui.getSetting("compute_type"))
onActivated: ui.setSetting("compute_type", currentText)
}
}
}
}
ModernSettingsSection {
title: "Advanced Decoding"
Layout.margins: 32
Layout.topMargin: 0
content: ColumnLayout {
width: parent.width
spacing: 0
ModernSettingsItem {
label: "Beam Size"
description: "Search width (Higher = Better Accuracy, Slower)"
control: ModernSlider {
Layout.preferredWidth: 200
from: 1; to: 10
value: ui.getSetting("beam_size")
onMoved: ui.setSetting("beam_size", value)
}
}
ModernSettingsItem {
label: "VAD Filter"
description: "Skip silent audio segments (Speeds up processing)"
control: ModernSwitch {
checked: ui.getSetting("vad_filter")
onToggled: ui.setSetting("vad_filter", checked)
}
}
ModernSettingsItem {
label: "Hallucination Check"
description: "Prevent repetitive text loops (No Repeat N-Gram)"
control: ModernSwitch {
checked: ui.getSetting("no_repeat_ngram_size") > 0
onToggled: ui.setSetting("no_repeat_ngram_size", checked ? 3 : 0)
}
}
ModernSettingsItem {
label: "Context History"
description: "Use previous text to improve coherence"
showSeparator: false
control: ModernSwitch {
checked: ui.getSetting("condition_on_previous_text")
onToggled: ui.setSetting("condition_on_previous_text", checked)
}
}
}
}
}
}
// --- TAB: DEBUG ---
ScrollView {
ScrollBar.vertical.policy: ScrollBar.AsNeeded
contentWidth: availableWidth
ColumnLayout {
width: parent.width
spacing: 16
anchors.margins: 32
ColumnLayout {
spacing: 4
Layout.topMargin: 32
Layout.leftMargin: 32
Layout.rightMargin: 32
Text { text: "System Diagnostics"; color: SettingsStyle.textPrimary; font.family: mainFont; font.pixelSize: 24; font.bold: true }
Text { text: "Live performance and logs"; color: SettingsStyle.textSecondary; font.family: mainFont; font.pixelSize: 14 }
}
// --- PERFORMANCE STATS ---
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: 32
Layout.rightMargin: 32
spacing: 16
StatBox { label: "APP CPU"; value: ui.appCpu; unit: "%"; accent: "#00f2ff" }
StatBox { label: "APP RAM"; value: ui.appRamMb; unit: "MB"; accent: "#bd93f9" }
StatBox { label: "GPU VRAM"; value: ui.appVramMb; unit: "MB"; accent: "#ff79c6" }
StatBox { label: "GPU LOAD"; value: ui.appVramPercent; unit: "%"; accent: "#ff5555" }
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 300
Layout.margins: 32
Layout.topMargin: 0
color: "#0d0d10"
radius: 8
border.color: SettingsStyle.borderSubtle
border.width: 1
clip: true
ScrollView {
anchors.fill: parent
anchors.margins: 12
ScrollBar.vertical.policy: ScrollBar.AlwaysOn
TextArea {
id: logArea
text: ui.allLogs
readOnly: true
color: "#cccccc"
font.family: "JetBrains Mono"
font.pixelSize: 11
wrapMode: TextArea.Wrap
background: null
selectByMouse: true
Connections {
target: ui
function onLogAppended(line) {
logArea.append(line)
}
}
}
}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,25 @@
import QtQuick
pragma Singleton
QtObject {
// Colors
readonly property color background: "#F2121212" // Deep Obsidian with 95% opacity
readonly property color surfaceCard: "#1A1A1A" // Layer 1
readonly property color surfaceHover: "#2A2A2A" // Layer 2 (Lighter for better contrast)
readonly property color borderSubtle: Qt.rgba(1, 1, 1, 0.08)
readonly property color textPrimary: "#FAFAFA" // Brighter white
readonly property color textSecondary: "#999999"
readonly property color accentPurple: "#7000FF"
readonly property color accentCyan: "#00F2FF"
// Configurable active accent
property color accent: accentPurple
// Dimensions
readonly property int cardRadius: 16
readonly property int itemRadius: 8
readonly property int itemHeight: 60 // Even taller for more breathing room
}

56
src/ui/qml/crt.frag Normal file
View File

@@ -0,0 +1,56 @@
#version 440
layout(location = 0) in vec2 qt_TexCoord0;
layout(location = 0) out vec4 fragColor;
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
float qt_Opacity;
float time;
};
layout(binding = 1) uniform sampler2D source;
void main() {
vec2 uv = qt_TexCoord0;
// CRT curvature distortion
vec2 crtUV = uv - 0.5;
float curvature = 0.03;
crtUV *= 1.0 + curvature * (crtUV.x * crtUV.x + crtUV.y * crtUV.y);
crtUV += 0.5;
// Clamp to avoid sampling outside
if (crtUV.x < 0.0 || crtUV.x > 1.0 || crtUV.y < 0.0 || crtUV.y > 1.0) {
fragColor = vec4(0.0, 0.0, 0.0, 0.0);
return;
}
// Sample the texture
vec4 color = texture(source, crtUV);
// Scanlines effect (fine and subtle)
float scanlineIntensity = 0.04;
float scanline = sin(crtUV.y * 2400.0) * scanlineIntensity;
color.rgb -= scanline;
// RGB color separation (chromatic aberration)
float aberration = 0.001;
float r = texture(source, crtUV + vec2(aberration, 0.0)).r;
float g = color.g;
float b = texture(source, crtUV - vec2(aberration, 0.0)).b;
color.rgb = vec3(r, g, b);
// Subtle vignette
float vignette = 1.0 - 0.3 * length(crtUV - 0.5);
color.rgb *= vignette;
// Slight green/cyan phosphor tint
color.rgb *= vec3(0.95, 1.0, 0.98);
// Flicker (very subtle)
float flicker = 0.98 + 0.02 * sin(time * 15.0);
color.rgb *= flicker;
fragColor = color * qt_Opacity;
}

BIN
src/ui/qml/crt.qsb Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

50
src/ui/qml/glass.frag Normal file
View File

@@ -0,0 +1,50 @@
#version 440
layout(location = 0) in vec2 qt_TexCoord0;
layout(location = 0) out vec4 fragColor;
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
float qt_Opacity;
float time;
float aberration; // 0.0 to 1.0, controlled by Audio Amplitude
};
float rand(vec2 co) {
return fract(sin(dot(co.xy ,vec2(12.9898,78.233))) * 43758.5453);
}
void main() {
// 1. Calculate Distortion Offset based on Amplitude (aberration)
// We warp the UVs slightly away from center
vec2 uv = qt_TexCoord0;
vec2 dist = uv - 0.5;
// 2. Chromatic Aberration
// Red Channel shifts OUT
// Blue Channel shifts IN
float strength = aberration * 0.02; // Max shift 2% of texture size
vec2 rUV = uv + (dist * strength);
vec2 bUV = uv - (dist * strength);
// Sample texture? We don't have a texture input (source is empty Item), we are generating visuals.
// Wait, ShaderEffect usually works on sourceItem.
// Here we are generating NOISE on top of a gradient.
// So we apply Aberration to the NOISE function?
// Or do we want to aberrate the pixels UNDERNEATH?
// ShaderEffect with no source property renders purely procedural content.
// Let's create layered procedural noise with channel offsets
float nR = rand(rUV + vec2(time * 0.01, 0.0));
float nG = rand(uv + vec2(time * 0.01, 0.0)); // Green is anchor
float nB = rand(bUV + vec2(time * 0.01, 0.0));
// Also modulate alpha by aberration - higher volume = more intense grain?
// Or maybe just pure glitch.
vec4 grainColor = vec4(nR, nG, nB, 1.0);
// Mix it with opacity
fragColor = grainColor * qt_Opacity;
}

BIN
src/ui/qml/glass.qsb Normal file

Binary file not shown.

40
src/ui/qml/glow.frag Normal file
View File

@@ -0,0 +1,40 @@
#version 440
layout(location = 0) in vec2 qt_TexCoord0;
layout(location = 0) out vec4 fragColor;
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
float qt_Opacity;
float time;
float intensity; // 0.0 to 1.0, controlled by Audio Amplitude
};
float rand(vec2 co) {
return fract(sin(dot(co.xy ,vec2(12.9898,78.233))) * 43758.5453);
}
void main() {
vec2 uv = qt_TexCoord0;
// 1. Base Noise (subtle grain)
float noise = rand(uv + vec2(time * 0.01, 0.0));
// 2. Radial Glow (bright center, fading to edges)
vec2 center = vec2(0.5, 0.5);
float dist = distance(uv, center);
// Glow strength based on intensity (audio amplitude)
// Higher intensity = brighter, wider glow
float glowRadius = 0.3 + (intensity * 0.4); // Radius grows with volume
float glow = 1.0 - smoothstep(0.0, glowRadius, dist);
glow = pow(glow, 2.0); // Sharpen the falloff
// 3. Color the glow (warm white/cyan tint)
vec3 glowColor = vec3(0.8, 0.9, 1.0); // Slight cyan tint
// 4. Combine noise + glow
vec3 finalColor = mix(vec3(noise), glowColor, glow * intensity);
fragColor = vec4(finalColor, 1.0) * qt_Opacity;
}

BIN
src/ui/qml/glow.qsb Normal file

Binary file not shown.

View File

@@ -0,0 +1,87 @@
#version 440
layout(location = 0) in vec2 qt_TexCoord0;
layout(location = 0) out vec4 fragColor;
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
float qt_Opacity;
float time;
};
// Smooth noise function
float noise(vec2 p) {
return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453);
}
// Smooth interpolation
float smoothNoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f); // Smoothstep
float a = noise(i);
float b = noise(i + vec2(1.0, 0.0));
float c = noise(i + vec2(0.0, 1.0));
float d = noise(i + vec2(1.0, 1.0));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
// Fractal Brownian Motion
float fbm(vec2 p) {
float value = 0.0;
float amplitude = 0.5;
for (int i = 0; i < 4; i++) {
value += amplitude * smoothNoise(p);
p *= 2.0;
amplitude *= 0.5;
}
return value;
}
// HSV to RGB conversion
vec3 hsv2rgb(vec3 c) {
vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}
void main() {
vec2 uv = qt_TexCoord0;
float t = time * 0.05;
// Multiple moving blobs
vec2 p1 = uv * 3.0 + vec2(t * 0.3, t * 0.2);
vec2 p2 = uv * 2.5 + vec2(-t * 0.4, t * 0.3);
vec2 p3 = uv * 4.0 + vec2(t * 0.2, -t * 0.25);
float blob1 = fbm(p1);
float blob2 = fbm(p2);
float blob3 = fbm(p3);
// Combine blobs
float combined = (blob1 + blob2 + blob3) / 3.0;
// Live hue shifting - slowly cycle through hues over time
float hueShift = time * 0.02; // Slow hue rotation
// Create colors using HSV with shifting hue
vec3 color1 = hsv2rgb(vec3(fract(0.75 + hueShift), 0.6, 0.35)); // Purple range
vec3 color2 = hsv2rgb(vec3(fract(0.85 + hueShift), 0.7, 0.35)); // Magenta range
vec3 color3 = hsv2rgb(vec3(fract(0.55 + hueShift), 0.7, 0.35)); // Blue range
vec3 color4 = hsv2rgb(vec3(fract(0.08 + hueShift), 0.7, 0.35)); // Orange range
vec3 color5 = hsv2rgb(vec3(fract(0.50 + hueShift), 0.6, 0.30)); // Teal range
// Mix colors based on blob values for visible multi-color effect
vec3 color = mix(color1, color2, blob1);
color = mix(color, color3, blob2);
color = mix(color, color4, blob3);
color = mix(color, color5, combined);
// Moderate brightness for visible but dark background accent
float brightness = 0.25 + combined * 0.25;
color *= brightness;
fragColor = vec4(color, qt_Opacity);
}

Binary file not shown.

BIN
src/ui/qml/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. --><path fill="#ffffff" d="M192 0C139 0 96 43 96 96l0 160c0 53 43 96 96 96s96-43 96-96l0-160c0-53-43-96-96-96zM64 216c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 40c0 89.1 66.2 162.7 152 174.4l0 33.6-48 0c-13.3 0-24 10.7-24 24s10.7 24 24 24l72 0 72 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-48 0 0-33.6c85.8-11.7 152-85.3 152-174.4l0-40c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 40c0 70.7-57.3 128-128 128s-128-57.3-128-128l0-40z"/></svg>

After

Width:  |  Height:  |  Size: 695 B

Some files were not shown because too many files have changed in this diff Show More