Initial commit of WhisperVoice
This commit is contained in:
+25
@@ -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
|
||||||
@@ -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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
+370
@@ -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}")
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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}")
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
@@ -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...")
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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>
|
||||||
@@ -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.
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
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.
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.
@@ -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;
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -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;
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -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.
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
@@ -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
Reference in New Issue
Block a user