Initial commit of WhisperVoice
This commit is contained in:
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Virtual Environment
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Distribution / Build
|
||||
dist/
|
||||
build/
|
||||
*.spec
|
||||
_unused_files/
|
||||
runtime/
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Application Specific
|
||||
models/
|
||||
recordings/
|
||||
*.log
|
||||
settings.json
|
||||
165
README.md
Normal file
165
README.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Whisper Voice - Native Windows AI Transcriber
|
||||
|
||||
**Whisper Voice** is a high-performance, native Windows application that brings the power of OpenAI's **Whisper** model to your desktop in a seamless, interactive way.
|
||||
|
||||
Designed for productivity "power users", it allows you to invoke a global hotkey, dictate your thoughts, and have the transcribed text instantly typed into *any* active application (Notepad, Word, Slack, VS Code, etc.).
|
||||
|
||||
It features a modern, floating "Pill" UI with real-time audio visualization, built on top of the robust PySide6 (Qt) framework.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **🎙️ Global Hotkey**: Press **F8** anywhere in Windows to start recording. Press again to stop.
|
||||
- **🤖 Local AI Intelligence**: Powered by `faster-whisper`. Runs entirely on your machine. No cloud API keys, no data leaving your PC.
|
||||
- **⚡ High Performance**: Uses the 'Small' Whisper model by default (~500MB), optimized for a balance of speed and accuracy.
|
||||
- **🎨 Modern UI**: A frameless, draggable, floating "Pill" window with a Neon **Audio Visualizer** that reacts to your voice.
|
||||
- **🔌 Smart Bootstrapper**: The app is portable and self-healing. On the first run, it checks for the AI model and downloads it automatically if missing.
|
||||
- **✍️ Auto-Type**: Automatically simulates keyboard input to paste the transcribed text where your cursor is.
|
||||
- **🔋 Portable**: Can be compiled into a single `.exe` file that you can carry on a USB drive.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Requirements
|
||||
|
||||
- **OS**: Windows 10 or 11 (64-bit).
|
||||
- **Python**: 3.10 or newer (if running from source).
|
||||
- **Hardware**: A reasonable CPU (Modern Intel i5/AMD Ryzen). NVIDIA GPU recommended for instant speed (requires CUDA setup), but runs fine on CPU.
|
||||
- **Dependencies**:
|
||||
- **FFmpeg**: Essential for audio processing. (See Setup Guide).
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Installation & Setup
|
||||
|
||||
### Option A: Running from Source (Developers)
|
||||
|
||||
1. **Clone the Repository**:
|
||||
```bash
|
||||
git clone https://github.com/your/repo.git
|
||||
cd whisper_voice
|
||||
```
|
||||
|
||||
2. **Environment Setup**:
|
||||
It is highly recommended to use a virtual environment.
|
||||
```cmd
|
||||
python -m venv venv
|
||||
venv\Scripts\activate
|
||||
```
|
||||
|
||||
3. **Install Python Dependencies**:
|
||||
```cmd
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. **FFmpeg Setup**:
|
||||
- **Method 1 (System-wide)**: Download FFmpeg and add the `bin` folder to your Windows PATH environment variable.
|
||||
- **Method 2 (Portable)**: Download `ffmpeg.exe` and place it in a `libs` folder inside the project root:
|
||||
```text
|
||||
whisper_voice/
|
||||
├── main.py
|
||||
├── libs/
|
||||
│ └── ffmpeg.exe <-- Place here
|
||||
```
|
||||
|
||||
5. **Run the App**:
|
||||
```cmd
|
||||
python main.py
|
||||
```
|
||||
*Or use the provided `run_source.bat` script.*
|
||||
|
||||
### Option B: Building a Portable EXE
|
||||
|
||||
You can compile the application into a single executable file for easy distribution.
|
||||
|
||||
1. Follow the **Running from Source** steps above to set up your environment.
|
||||
2. Install `pyinstaller`:
|
||||
```cmd
|
||||
pip install pyinstaller
|
||||
```
|
||||
3. Run the Build Script:
|
||||
```cmd
|
||||
build_exe.bat
|
||||
```
|
||||
*(Or run `pyinstaller build.spec` manually).*
|
||||
|
||||
4. **Locate the EXE**:
|
||||
The result will be in the `dist` folder: `dist/WhisperVoice.exe`.
|
||||
|
||||
5. **Distribution**:
|
||||
- You can send just the `.exe` to anyone.
|
||||
- **Note**: The end-user will still need FFmpeg. You can zip the `libs` folder alongside the EXE to make it truly "unzip and run".
|
||||
|
||||
---
|
||||
|
||||
## 🎮 Usage Guide
|
||||
|
||||
1. **First Run Initialization**:
|
||||
- When you launch the app, you will see a **"Initializing..."** window.
|
||||
- If the AI Model (`models/` folder) is missing, the app will automatically download it (~500MB).
|
||||
- Once complete, the app minimizes to the System Tray.
|
||||
|
||||
2. **Dictation**:
|
||||
- Focus the text field where you want to type (e.g., click into a Notepad document).
|
||||
- Press **F8**.
|
||||
- The **Floating Pill** appears on screen. Use the visualizer to confirm it hears you.
|
||||
- Speak your sentence.
|
||||
- Press **F8** again to stop.
|
||||
- The Pill turns **Blue** ("Thinking...").
|
||||
- Wait a moment... the text will appear!
|
||||
|
||||
3. **System Tray**:
|
||||
- Look for the application icon in your taskbar tray (near the clock).
|
||||
- Right-click -> **Quit Whisper Voice** to exit the application completely.
|
||||
|
||||
---
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```text
|
||||
whisper_voice/
|
||||
├── main.py # Application Entry Point & Orchestrator
|
||||
├── task.md # Development Task Tracking
|
||||
├── requirements.txt # Python Dependencies
|
||||
├── build.spec # PyInstaller Configuration
|
||||
├── run_source.bat # Helper script
|
||||
├── build_exe.bat # Helper script
|
||||
├── src/
|
||||
│ ├── core/
|
||||
│ │ ├── audio_engine.py # Microphone recording logic
|
||||
│ │ ├── transcriber.py # AI Model wrapper (Faster-Whisper)
|
||||
│ │ ├── hotkey_manager.py # Global keyboard hooks
|
||||
│ │ └── paths.py # Path resolution (EXE vs Script)
|
||||
│ ├── ui/
|
||||
│ │ ├── overlay.py # Main Pill Window
|
||||
│ │ ├── visualizer.py # Audio Spectrum Widget
|
||||
│ │ ├── loader.py # Bootstrapper/Downloader UI
|
||||
│ │ └── tray.py # System Tray Icon
|
||||
│ └── utils/
|
||||
│ ├── injector.py # Clipboard/Paste logic
|
||||
│ └── downloader.py # File download utilities
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ❓ Troubleshooting
|
||||
|
||||
**Q: Nothing happens when I press F8.**
|
||||
- Check the System Tray to ensure the app is running.
|
||||
- Ensure you have given the app "Input Monitoring" permissions if prompted (rare on standard Windows).
|
||||
- Some Antivirus software might block the "Global Hotkey" feature. Whitelist the app.
|
||||
|
||||
**Q: The app crashes with an error about FFmpeg.**
|
||||
- `faster-whisper` requires FFmpeg. Make sure `ffmpeg.exe` is either in your system PATH or in a `libs` folder next to the `main.py` (or EXE).
|
||||
|
||||
**Q: Transcription is slow.**
|
||||
- The "Small" model is generally fast, but on older CPUs, it might take 2-5 seconds for a long sentence.
|
||||
- To use a GPU, you must install the NVIDIA cuDNN libraries and the `torch` version with CUDA support. This prototype setup defaults to CPU/Auto for compatibility.
|
||||
|
||||
**Q: "Failed to load model" error.**
|
||||
- Delete the `models` folder and restart the app to force a re-download.
|
||||
|
||||
---
|
||||
|
||||
**License**: MIT
|
||||
**Author**: Antigravity
|
||||
BIN
app_icon.ico
Normal file
BIN
app_icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
BIN
assets/icon.ico
Normal file
BIN
assets/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
370
bootstrapper.py
Normal file
370
bootstrapper.py
Normal file
@@ -0,0 +1,370 @@
|
||||
"""
|
||||
WhisperVoice Bootstrapper
|
||||
=========================
|
||||
|
||||
Lightweight launcher that downloads Python + dependencies on first run.
|
||||
This keeps the initial .exe small (~15-20MB) while downloading the full
|
||||
runtime (~2-3GB) on first launch.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import zipfile
|
||||
import shutil
|
||||
import threading
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
# Use tkinter for the progress UI (built into Python, no extra deps)
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
# Configuration
|
||||
PYTHON_URL = "https://www.python.org/ftp/python/3.11.9/python-3.11.9-embed-amd64.zip"
|
||||
PYTHON_VERSION = "3.11.9"
|
||||
GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py"
|
||||
|
||||
|
||||
def get_base_path():
|
||||
"""Get the directory where the .exe is located."""
|
||||
if getattr(sys, 'frozen', False):
|
||||
return Path(sys.executable).parent
|
||||
return Path(__file__).parent
|
||||
|
||||
def log(msg):
|
||||
"""Safe logging that won't crash if stdout/stderr is None."""
|
||||
try:
|
||||
if sys.stdout:
|
||||
print(msg)
|
||||
sys.stdout.flush()
|
||||
except:
|
||||
pass
|
||||
|
||||
class BootstrapperUI:
|
||||
"""Simple Tkinter UI for download progress."""
|
||||
|
||||
def __init__(self):
|
||||
self.root = tk.Tk()
|
||||
self.root.title("WhisperVoice Setup")
|
||||
self.root.geometry("500x220")
|
||||
self.root.resizable(False, False)
|
||||
self.root.configure(bg="#1a1a2e")
|
||||
|
||||
# Center on screen
|
||||
self.root.update_idletasks()
|
||||
x = (self.root.winfo_screenwidth() - 500) // 2
|
||||
y = (self.root.winfo_screenheight() - 220) // 2
|
||||
self.root.geometry(f"+{x}+{y}")
|
||||
|
||||
# Set Window Icon
|
||||
try:
|
||||
if getattr(sys, 'frozen', False):
|
||||
icon_path = os.path.join(sys._MEIPASS, "app_source", "assets", "icon.ico")
|
||||
else:
|
||||
icon_path = os.path.join(os.path.dirname(__file__), "assets", "icon.ico")
|
||||
|
||||
if os.path.exists(icon_path):
|
||||
self.root.iconbitmap(icon_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Title
|
||||
self.title_label = tk.Label(
|
||||
self.root,
|
||||
text="🎤 WhisperVoice Setup",
|
||||
font=("Segoe UI", 18, "bold"),
|
||||
fg="#00f2ff",
|
||||
bg="#1a1a2e"
|
||||
)
|
||||
self.title_label.pack(pady=(20, 10))
|
||||
|
||||
# Status
|
||||
self.status_label = tk.Label(
|
||||
self.root,
|
||||
text="Initializing...",
|
||||
font=("Segoe UI", 11),
|
||||
fg="#ffffff",
|
||||
bg="#1a1a2e"
|
||||
)
|
||||
self.status_label.pack(pady=5)
|
||||
|
||||
# Progress bar
|
||||
style = ttk.Style()
|
||||
style.theme_use('clam')
|
||||
style.configure(
|
||||
"Custom.Horizontal.TProgressbar",
|
||||
background="#00f2ff",
|
||||
troughcolor="#0d0d10",
|
||||
borderwidth=0,
|
||||
lightcolor="#00f2ff",
|
||||
darkcolor="#00f2ff"
|
||||
)
|
||||
|
||||
self.progress = ttk.Progressbar(
|
||||
self.root,
|
||||
style="Custom.Horizontal.TProgressbar",
|
||||
length=400,
|
||||
mode='determinate'
|
||||
)
|
||||
self.progress.pack(pady=15)
|
||||
|
||||
# Detail label
|
||||
self.detail_label = tk.Label(
|
||||
self.root,
|
||||
text="",
|
||||
font=("Segoe UI", 9),
|
||||
fg="#888888",
|
||||
bg="#1a1a2e"
|
||||
)
|
||||
self.detail_label.pack(pady=5)
|
||||
|
||||
self.root.protocol("WM_DELETE_WINDOW", self.on_close)
|
||||
self.cancelled = False
|
||||
|
||||
def on_close(self):
|
||||
self.cancelled = True
|
||||
self.root.destroy()
|
||||
sys.exit(0)
|
||||
|
||||
def set_status(self, text):
|
||||
log(f"[STATUS] {text}")
|
||||
self.status_label.config(text=text)
|
||||
self.root.update()
|
||||
|
||||
def set_detail(self, text):
|
||||
self.detail_label.config(text=text)
|
||||
self.root.update()
|
||||
|
||||
def set_progress(self, value):
|
||||
self.progress['value'] = value
|
||||
self.root.update()
|
||||
|
||||
def run_in_thread(self, func):
|
||||
"""Run a function in a background thread."""
|
||||
thread = threading.Thread(target=func, daemon=True)
|
||||
thread.start()
|
||||
return thread
|
||||
|
||||
|
||||
class Bootstrapper:
|
||||
"""Main bootstrapper logic."""
|
||||
|
||||
def __init__(self, ui=None):
|
||||
self.ui = ui
|
||||
self.base_path = get_base_path()
|
||||
self.runtime_path = self.base_path / "runtime"
|
||||
self.python_path = self.runtime_path / "python"
|
||||
self.app_path = self.runtime_path / "app"
|
||||
|
||||
if getattr(sys, 'frozen', False):
|
||||
self.source_path = Path(sys._MEIPASS) / "app_source"
|
||||
else:
|
||||
self.source_path = self.base_path
|
||||
|
||||
def is_python_ready(self):
|
||||
"""Check if Python runtime exists."""
|
||||
return (self.python_path / "python.exe").exists()
|
||||
|
||||
def download_file(self, url, dest_path, desc="Downloading"):
|
||||
"""Download a file with progress."""
|
||||
if self.ui: self.ui.set_status(desc)
|
||||
|
||||
def progress_hook(count, block_size, total_size):
|
||||
if self.ui and total_size > 0:
|
||||
percent = min(100, (count * block_size * 100) // total_size)
|
||||
self.ui.set_progress(percent)
|
||||
mb_done = (count * block_size) / (1024 * 1024)
|
||||
mb_total = total_size / (1024 * 1024)
|
||||
self.ui.set_detail(f"{mb_done:.1f} / {mb_total:.1f} MB")
|
||||
|
||||
urllib.request.urlretrieve(url, dest_path, progress_hook)
|
||||
|
||||
def download_python(self):
|
||||
"""Download and extract Python embeddable."""
|
||||
if self.ui: self.ui.set_status("Downloading Python...")
|
||||
|
||||
self.runtime_path.mkdir(parents=True, exist_ok=True)
|
||||
zip_path = self.runtime_path / "python.zip"
|
||||
self.download_file(PYTHON_URL, zip_path, "Downloading Python runtime...")
|
||||
|
||||
if self.ui:
|
||||
self.ui.set_status("Extracting Python...")
|
||||
self.ui.set_progress(0)
|
||||
|
||||
self.python_path.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(zip_path, 'r') as zf:
|
||||
total = len(zf.namelist())
|
||||
for i, name in enumerate(zf.namelist()):
|
||||
zf.extract(name, self.python_path)
|
||||
if self.ui: self.ui.set_progress((i * 100) // total)
|
||||
|
||||
zip_path.unlink()
|
||||
self._fix_pth_file()
|
||||
|
||||
def _fix_pth_file(self):
|
||||
"""CRITICAL: Configure ._pth file to support imports from our app folder."""
|
||||
# Find the _pth file in the python folder
|
||||
pth_files = list(self.python_path.glob("*._pth"))
|
||||
if not pth_files: return
|
||||
|
||||
pth_file = pth_files[0]
|
||||
|
||||
# Standard names in embeddable: python311.zip, ., import site
|
||||
content = [
|
||||
"python311.zip", # Hardcoded for now but works for 3.11
|
||||
".",
|
||||
"../app", # Include app folder in search path!
|
||||
"Lib/site-packages",
|
||||
"import site" # Enable site-packages
|
||||
]
|
||||
pth_file.write_text("\n".join(content) + "\n")
|
||||
|
||||
# Ensure site-packages exists
|
||||
(self.python_path / "Lib" / "site-packages").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def install_pip(self):
|
||||
"""Install pip into embedded Python."""
|
||||
if self.ui: self.ui.set_status("Installing pip...")
|
||||
get_pip_path = self.runtime_path / "get-pip.py"
|
||||
self.download_file(GET_PIP_URL, get_pip_path, "Downloading pip installer...")
|
||||
|
||||
subprocess.run(
|
||||
[str(self.python_path / "python.exe"), str(get_pip_path), "--no-warn-script-location"],
|
||||
cwd=str(self.python_path),
|
||||
capture_output=True,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW
|
||||
)
|
||||
get_pip_path.unlink()
|
||||
|
||||
def install_packages(self):
|
||||
"""Install required packages via pip."""
|
||||
if self.ui:
|
||||
self.ui.set_status("Installing packages...")
|
||||
self.ui.set_progress(10)
|
||||
|
||||
req_file = self.source_path / "requirements.txt"
|
||||
|
||||
process = subprocess.Popen(
|
||||
[str(self.python_path / "python.exe"), "-m", "pip", "install", "-r", str(req_file)],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
cwd=str(self.python_path),
|
||||
creationflags=subprocess.CREATE_NO_WINDOW
|
||||
)
|
||||
|
||||
for line in process.stdout:
|
||||
if self.ui: self.ui.set_detail(line.strip()[:60])
|
||||
process.wait()
|
||||
|
||||
def refresh_app_source(self):
|
||||
"""Refresh app source files. Skips if already exists to save time."""
|
||||
# Optimization: If app/main.py exists, skip update to improve startup speed.
|
||||
# The user can delete the 'runtime' folder to force an update.
|
||||
if (self.app_path / "main.py").exists():
|
||||
log("App already exists. Skipping update.")
|
||||
return True
|
||||
|
||||
if self.ui: self.ui.set_status("Updating app files...")
|
||||
|
||||
try:
|
||||
# Preserve settings.json if it exists
|
||||
settings_path = self.app_path / "settings.json"
|
||||
temp_settings = None
|
||||
if settings_path.exists():
|
||||
try:
|
||||
temp_settings = settings_path.read_bytes()
|
||||
except:
|
||||
log("Failed to backup settings.json, it involves risk of data loss.")
|
||||
|
||||
if self.app_path.exists():
|
||||
shutil.rmtree(self.app_path, ignore_errors=True)
|
||||
|
||||
shutil.copytree(
|
||||
self.source_path,
|
||||
self.app_path,
|
||||
ignore=shutil.ignore_patterns(
|
||||
'__pycache__', '*.pyc', '.git', 'venv',
|
||||
'build', 'dist', '*.egg-info', 'runtime'
|
||||
)
|
||||
)
|
||||
|
||||
# Restore settings.json
|
||||
if temp_settings:
|
||||
try:
|
||||
settings_path.write_bytes(temp_settings)
|
||||
log("Restored settings.json")
|
||||
except:
|
||||
log("Failed to restore settings.json")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
log(f"Error refreshing app source: {e}")
|
||||
return False
|
||||
|
||||
def run_app(self):
|
||||
"""Launch the main application and exit."""
|
||||
python_exe = self.python_path / "python.exe"
|
||||
main_py = self.app_path / "main.py"
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = str(self.app_path)
|
||||
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[str(python_exe), str(main_py)],
|
||||
cwd=str(self.app_path),
|
||||
env=env,
|
||||
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
messagebox.showerror("WhisperVoice Error", f"Failed to launch app: {e}")
|
||||
return False
|
||||
|
||||
def setup_and_run(self):
|
||||
"""Full setup/update and run flow."""
|
||||
try:
|
||||
if not self.is_python_ready():
|
||||
self.download_python()
|
||||
self.install_pip()
|
||||
self.install_packages()
|
||||
|
||||
# Always refresh source to ensure we have the latest bundled code
|
||||
self.refresh_app_source()
|
||||
|
||||
# Launch
|
||||
if self.run_app():
|
||||
if self.ui: self.ui.root.quit()
|
||||
except Exception as e:
|
||||
messagebox.showerror("Setup Error", f"Installation failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def main():
|
||||
base_path = get_base_path()
|
||||
python_exe = base_path / "runtime" / "python" / "python.exe"
|
||||
main_py = base_path / "runtime" / "app" / "main.py"
|
||||
|
||||
# Fast path: if already set up and not frozen (development), just launch
|
||||
if not getattr(sys, 'frozen', False) and python_exe.exists() and main_py.exists():
|
||||
boot = Bootstrapper()
|
||||
boot.run_app()
|
||||
return
|
||||
|
||||
# Normal path: Show UI for possible setup/update
|
||||
ui = BootstrapperUI()
|
||||
boot = Bootstrapper(ui)
|
||||
|
||||
threading.Thread(target=boot.setup_and_run, daemon=True).start()
|
||||
ui.root.mainloop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
import tkinter.messagebox as mb
|
||||
mb.showerror("Fatal Error", f"Bootstrapper crashed: {e}")
|
||||
66
build_bootstrapper.py
Normal file
66
build_bootstrapper.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
Build the Lightweight Bootstrapper
|
||||
==================================
|
||||
|
||||
This creates a small (~15-20MB) .exe that downloads Python + dependencies on first run.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import PyInstaller.__main__
|
||||
from pathlib import Path
|
||||
|
||||
def build_bootstrapper():
|
||||
project_root = Path(__file__).parent.absolute()
|
||||
dist_path = project_root / "dist"
|
||||
|
||||
# Collect all app source files to bundle
|
||||
# These will be extracted and used when setting up
|
||||
app_source_files = [
|
||||
("src", "app_source/src"),
|
||||
("assets", "app_source/assets"), # Include icon etc
|
||||
("main.py", "app_source"),
|
||||
("requirements.txt", "app_source"),
|
||||
]
|
||||
|
||||
add_data_args = []
|
||||
for src, dst in app_source_files:
|
||||
src_path = project_root / src
|
||||
if src_path.exists():
|
||||
add_data_args.extend(["--add-data", f"{src}{os.pathsep}{dst}"])
|
||||
|
||||
# Use absolute project root for copying
|
||||
shutil.copy2(project_root / "assets" / "icon.ico", project_root / "app_icon.ico")
|
||||
|
||||
print("🚀 Building Lightweight Bootstrapper...")
|
||||
print("⏳ This creates a small .exe that downloads dependencies on first run.\n")
|
||||
|
||||
PyInstaller.__main__.run([
|
||||
"bootstrapper.py",
|
||||
"--name=WhisperVoice",
|
||||
"--onefile",
|
||||
"--noconsole", # Re-enabled! Error handling in bootstrapper is ready.
|
||||
"--clean",
|
||||
"--icon=app_icon.ico", # Simplified path at root
|
||||
*add_data_args,
|
||||
])
|
||||
|
||||
exe_path = dist_path / "WhisperVoice.exe"
|
||||
if exe_path.exists():
|
||||
size_mb = exe_path.stat().st_size / (1024 * 1024)
|
||||
print("\n" + "="*60)
|
||||
print("✅ BOOTSTRAPPER BUILD COMPLETE!")
|
||||
print("="*60)
|
||||
print(f"\n📍 Output: {exe_path}")
|
||||
print(f"📦 Size: {size_mb:.1f} MB")
|
||||
print("\n📋 How it works:")
|
||||
print(" 1. User runs WhisperVoice.exe")
|
||||
print(" 2. First run: Downloads Python + packages (~2-3GB)")
|
||||
print(" 3. Subsequent runs: Launches instantly")
|
||||
print("\n💡 The 'runtime/' folder will be created next to the .exe")
|
||||
else:
|
||||
print("\n❌ Build failed. Check the output above for errors.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.chdir(Path(__file__).parent)
|
||||
build_bootstrapper()
|
||||
17
build_exe.bat
Normal file
17
build_exe.bat
Normal file
@@ -0,0 +1,17 @@
|
||||
@echo off
|
||||
echo Building Whisper Voice Portable EXE...
|
||||
if not exist venv (
|
||||
echo Please run run_source.bat first to setup environment!
|
||||
pause
|
||||
exit /b
|
||||
)
|
||||
|
||||
call venv\Scripts\activate
|
||||
pip install pyinstaller
|
||||
|
||||
echo Running PyInstaller...
|
||||
pyinstaller build.spec --clean --noconfirm
|
||||
|
||||
echo.
|
||||
echo Build Complete! Check dist/WhisperVoice.exe
|
||||
pause
|
||||
14
convert_icon.py
Normal file
14
convert_icon.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from PIL import Image
|
||||
import os
|
||||
|
||||
# Path from the generate_image tool output
|
||||
src = r"C:/Users/lashman/.gemini/antigravity/brain/9a183770-2481-475b-b748-03f4910f9a8e/app_icon_1769195450659.png"
|
||||
dst = r"d:\!!! SYSTEM DATA !!!\Desktop\python crap\whisper_voice\assets\icon.ico"
|
||||
|
||||
if os.path.exists(src):
|
||||
img = Image.open(src)
|
||||
# Resize to standard icon sizes
|
||||
img.save(dst, format='ICO', sizes=[(256, 256)])
|
||||
print(f"Icon saved to {dst}")
|
||||
else:
|
||||
print(f"Source image not found: {src}")
|
||||
43
download_icons.py
Normal file
43
download_icons.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import requests
|
||||
import os
|
||||
|
||||
ICONS = {
|
||||
"settings.svg": "https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/solid/gear.svg",
|
||||
"visibility.svg": "https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/solid/eye.svg",
|
||||
"smart_toy.svg": "https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/solid/brain.svg",
|
||||
"microphone.svg": "https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/solid/microphone.svg"
|
||||
}
|
||||
|
||||
TARGET_DIR = r"d:\!!! SYSTEM DATA !!!\Desktop\python crap\whisper_voice\src\ui\qml"
|
||||
|
||||
def download_icons():
|
||||
if not os.path.exists(TARGET_DIR):
|
||||
print(f"Directory not found: {TARGET_DIR}")
|
||||
return
|
||||
|
||||
for filename, url in ICONS.items():
|
||||
try:
|
||||
print(f"Downloading {filename} from {url}...")
|
||||
response = requests.get(url, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
# Force white fill
|
||||
content = response.text
|
||||
if "<path" in content and "fill=" not in content:
|
||||
content = content.replace("<path", '<path fill="#ffffff"')
|
||||
elif "<path" in content and "fill=" in content:
|
||||
# Regex or simple replace if possible, but simplest is usually just injecting style or checking common FA format
|
||||
pass # FA standard usually has no fill.
|
||||
|
||||
# Additional safety: Replace currentcolor if present
|
||||
content = content.replace("currentColor", "#ffffff")
|
||||
|
||||
filepath = os.path.join(TARGET_DIR, filename)
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
print(f"Saved {filepath} (modified to white)")
|
||||
except Exception as e:
|
||||
print(f"FAILED to download {filename}: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
download_icons()
|
||||
536
main.py
Normal file
536
main.py
Normal file
@@ -0,0 +1,536 @@
|
||||
import sys
|
||||
import threading
|
||||
import logging
|
||||
import os
|
||||
|
||||
# Add the application directory to sys.path to ensure 'src' is findable
|
||||
# This is critical for the embedded Python environment in the portable build
|
||||
app_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if app_dir not in sys.path:
|
||||
sys.path.insert(0, app_dir)
|
||||
|
||||
from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox
|
||||
from PySide6.QtCore import QObject, Slot, Signal, QThread, Qt, QUrl
|
||||
from PySide6.QtQml import QQmlApplicationEngine
|
||||
from PySide6.QtQuickControls2 import QQuickStyle
|
||||
from PySide6.QtGui import QIcon
|
||||
|
||||
from src.ui.bridge import UIBridge
|
||||
from src.ui.tray import SystemTray
|
||||
from src.core.audio_engine import AudioEngine
|
||||
from src.core.transcriber import WhisperTranscriber
|
||||
from src.core.hotkey_manager import HotkeyManager
|
||||
from src.core.config import ConfigManager
|
||||
from src.utils.injector import InputInjector
|
||||
from src.core.paths import get_models_path, get_bundle_path
|
||||
from src.utils.window_hook import WindowHook
|
||||
|
||||
from PySide6.QtGui import QSurfaceFormat
|
||||
|
||||
# Configure GPU Surface for Alpha/Transparency (Critical for Blur)
|
||||
surface_fmt = QSurfaceFormat()
|
||||
surface_fmt.setAlphaBufferSize(8)
|
||||
QSurfaceFormat.setDefaultFormat(surface_fmt)
|
||||
|
||||
# Configure High DPI behavior for crisp UI
|
||||
os.environ["QT_ENABLE_HIGHDPI_SCALING"] = "1"
|
||||
os.environ["QT_AUTOSCREENSCALEFACTOR"] = "1"
|
||||
|
||||
# Detect resolution without creating QApplication (Fixes crash)
|
||||
try:
|
||||
import ctypes
|
||||
user32 = ctypes.windll.user32
|
||||
# Get physical screen width (unscaled)
|
||||
# SetProcessDPIAware is needed to get the true resolution
|
||||
user32.SetProcessDPIAware()
|
||||
width = user32.GetSystemMetrics(0)
|
||||
# Base scale centers around 1920 width.
|
||||
# At 3840 (4k), res_scale is 2.0. If we want it ~40% smaller, we multiply by 0.6 = 1.2
|
||||
res_scale = (width / 1920)
|
||||
if width >= 3840:
|
||||
res_scale *= 0.6 # Make it significantly smaller at 4k as requested
|
||||
|
||||
os.environ["QT_SCALE_FACTOR"] = str(max(1.0, res_scale))
|
||||
except:
|
||||
pass
|
||||
|
||||
# Configure Logging
|
||||
class QmlLoggingHandler(logging.Handler, QObject):
|
||||
sig_log = Signal(str)
|
||||
|
||||
def __init__(self, bridge):
|
||||
logging.Handler.__init__(self)
|
||||
QObject.__init__(self)
|
||||
self.bridge = bridge
|
||||
self.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
|
||||
self.sig_log.connect(self.bridge.append_log)
|
||||
|
||||
def emit(self, record):
|
||||
msg = self.format(record)
|
||||
self.sig_log.emit(msg)
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
# Silence shutdown-related tracebacks from Qt/PySide6 signals
|
||||
def _silent_shutdown_hook(exc_type, exc_value, exc_tb):
|
||||
# During Python shutdown, some QObject signals may try to call dead slots.
|
||||
# Ignore these specific tracebacks when they occur in bridge.py.
|
||||
import traceback
|
||||
if exc_type in (RuntimeError, SystemError, KeyboardInterrupt):
|
||||
return # Suppress completely
|
||||
tb_str = ''.join(traceback.format_exception(exc_type, exc_value, exc_tb))
|
||||
if 'bridge.py' in tb_str and '@Slot' in tb_str:
|
||||
return # Suppress bridge signal tracebacks
|
||||
# For all other exceptions, print normally
|
||||
sys.__excepthook__(exc_type, exc_value, exc_tb)
|
||||
|
||||
sys.excepthook = _silent_shutdown_hook
|
||||
|
||||
class DownloadWorker(QThread):
|
||||
"""Background worker for model downloads."""
|
||||
progress = Signal(int)
|
||||
finished = Signal()
|
||||
error = Signal(str)
|
||||
|
||||
def __init__(self, model_name="small", parent=None):
|
||||
super().__init__(parent)
|
||||
self.model_name = model_name
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
from faster_whisper import download_model
|
||||
model_path = get_models_path()
|
||||
# Download to a specific subdirectory to keep things clean and predictable
|
||||
# This matches the logic in transcriber.py which looks for this specific path
|
||||
dest_dir = model_path / f"faster-whisper-{self.model_name}"
|
||||
logging.info(f"Downloading Model '{self.model_name}' to {dest_dir}...")
|
||||
|
||||
# Ensure parent exists
|
||||
model_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# output_dir in download_model specifies where the model files are saved
|
||||
download_model(self.model_name, output_dir=str(dest_dir))
|
||||
|
||||
self.finished.emit()
|
||||
except Exception as e:
|
||||
logging.error(f"Download failed: {e}")
|
||||
self.error.emit(str(e))
|
||||
|
||||
class TranscriptionWorker(QThread):
|
||||
finished = Signal(str)
|
||||
def __init__(self, transcriber, audio_data, is_file=False, parent=None):
|
||||
super().__init__(parent)
|
||||
self.transcriber = transcriber
|
||||
self.audio_data = audio_data
|
||||
self.is_file = is_file
|
||||
def run(self):
|
||||
text = self.transcriber.transcribe(self.audio_data, is_file=self.is_file)
|
||||
self.finished.emit(text)
|
||||
|
||||
class WhisperApp(QObject):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# Force a style that supports full customization
|
||||
QQuickStyle.setStyle("Basic")
|
||||
|
||||
self.qt_app = QApplication(sys.argv)
|
||||
self.qt_app.setQuitOnLastWindowClosed(False)
|
||||
|
||||
# Set application-wide window icon (shows in taskbar for all windows)
|
||||
icon_path = get_bundle_path() / "assets" / "icon.ico"
|
||||
if icon_path.exists():
|
||||
self.qt_app.setWindowIcon(QIcon(str(icon_path)))
|
||||
|
||||
self.config = ConfigManager()
|
||||
|
||||
# 1. Initialize QML Engine & Bridge
|
||||
self.engine = QQmlApplicationEngine()
|
||||
self.bridge = UIBridge()
|
||||
|
||||
# 0. Attach Logging Handler
|
||||
logging.getLogger().addHandler(QmlLoggingHandler(self.bridge))
|
||||
|
||||
# Connect toggle recording signal
|
||||
self.bridge.toggleRecordingRequested.connect(self.toggle_recording)
|
||||
self.bridge.isRecordingChanged.connect(self.on_ui_toggle_request)
|
||||
self.bridge.settingChanged.connect(self.on_settings_changed)
|
||||
self.bridge.hotkeysEnabledChanged.connect(self.on_hotkeys_enabled_toggle)
|
||||
self.bridge.downloadRequested.connect(self.on_download_requested)
|
||||
|
||||
self.engine.rootContext().setContextProperty("ui", self.bridge)
|
||||
|
||||
# 2. Tray setup
|
||||
self.tray = SystemTray()
|
||||
self.tray.quit_requested.connect(self.quit_app)
|
||||
self.tray.settings_requested.connect(self.open_settings)
|
||||
self.tray.transcribe_file_requested.connect(self.transcribe_file)
|
||||
|
||||
# Init Tooltip
|
||||
hotkey = self.config.get("hotkey")
|
||||
self.tray.setToolTip(f"Whisper Voice - Press {hotkey} to Record")
|
||||
|
||||
# 3. Logic Components Placeholders
|
||||
self.audio_engine = None
|
||||
self.transcriber = None
|
||||
self.hotkey_manager = None
|
||||
self.overlay_root = None
|
||||
|
||||
# 4. Start Loader
|
||||
loader_qml = get_bundle_path() / "src/ui/qml/Loader.qml"
|
||||
self.engine.load(QUrl.fromLocalFile(str(loader_qml)))
|
||||
self.loader_root = self.engine.rootObjects()[0]
|
||||
self.loader_root.setProperty("color", "transparent")
|
||||
|
||||
|
||||
self.loader_worker = DownloadWorker()
|
||||
self.loader_worker.progress.connect(self.on_loader_progress)
|
||||
self.loader_worker.finished.connect(self.on_loader_done)
|
||||
self.loader_worker.start()
|
||||
|
||||
# Preload audio devices in background to avoid settings lag
|
||||
import threading
|
||||
threading.Thread(target=self.bridge.preload_audio_devices, daemon=True).start()
|
||||
|
||||
def on_loader_progress(self, percent):
|
||||
self.bridge.downloadProgress = percent
|
||||
|
||||
def on_loader_done(self):
|
||||
if getattr(self, "_loader_handled", False):
|
||||
return
|
||||
self._loader_handled = True
|
||||
|
||||
logging.info("Model verification complete.")
|
||||
# Close Loader Window
|
||||
if hasattr(self, "loader_root"):
|
||||
self.loader_root.close()
|
||||
|
||||
# Init Backend
|
||||
self.init_logic()
|
||||
|
||||
# Show Overlay (Ensure we don't load multiple times)
|
||||
overlay_qml = get_bundle_path() / "src/ui/qml/Overlay.qml"
|
||||
self.engine.load(QUrl.fromLocalFile(str(overlay_qml)))
|
||||
self.overlay_root = self.engine.rootObjects()[-1]
|
||||
self.overlay_root.setProperty("color", "transparent")
|
||||
|
||||
self.center_overlay()
|
||||
|
||||
# Preload Settings (Invisible)
|
||||
logging.info("Preloading Settings window...")
|
||||
self.open_settings()
|
||||
if self.settings_root:
|
||||
self.settings_root.setVisible(False)
|
||||
|
||||
# Install Low-Level Window Hook for Transparent Hit Test
|
||||
# We must keep a reference to 'self.hook' so it isn't GC'd
|
||||
# scale = self.overlay_root.devicePixelRatio()
|
||||
# self.hook = WindowHook(int(self.overlay_root.winId()), 500, 300, scale)
|
||||
# self.hook.install()
|
||||
|
||||
# NOTE: HitTest hook will be installed here later
|
||||
|
||||
def center_overlay(self):
|
||||
"""Calculates and sets the Overlay position above the taskbar."""
|
||||
from PySide6.QtGui import QGuiApplication
|
||||
screen = QGuiApplication.primaryScreen()
|
||||
if not screen or not self.overlay_root: return
|
||||
|
||||
geom = screen.availableGeometry()
|
||||
w = self.overlay_root.width()
|
||||
h = self.overlay_root.height()
|
||||
|
||||
x = geom.x() + (geom.width() - w) // 2
|
||||
y = geom.bottom() - h - 15
|
||||
|
||||
self.overlay_root.setX(x)
|
||||
self.overlay_root.setY(y)
|
||||
|
||||
def init_logic(self):
|
||||
if getattr(self, "_logic_initialized", False):
|
||||
return
|
||||
self._logic_initialized = True
|
||||
|
||||
logging.info("Initializing Core Logic...")
|
||||
self.audio_engine = AudioEngine()
|
||||
self.audio_engine.set_visualizer_callback(self.bridge.update_amplitude)
|
||||
self.audio_engine.set_silence_callback(self.on_silence_detected)
|
||||
self.transcriber = WhisperTranscriber()
|
||||
self.hotkey_manager = HotkeyManager()
|
||||
self.hotkey_manager.triggered.connect(self.toggle_recording)
|
||||
self.hotkey_manager.start()
|
||||
self.bridge.update_status("Ready")
|
||||
|
||||
def run(self):
|
||||
sys.exit(self.qt_app.exec())
|
||||
|
||||
@Slot()
|
||||
def quit_app(self):
|
||||
logging.info("Shutting down...")
|
||||
|
||||
# [CRITICAL] Stop the StatsWorker FIRST before any UI objects are touched.
|
||||
# This prevents signal emissions to a dying UIBridge object.
|
||||
if hasattr(self, 'bridge') and hasattr(self.bridge, 'stats_worker'):
|
||||
try:
|
||||
self.bridge.stats_worker.stats_ready.disconnect(self.bridge.update_stats_callback)
|
||||
except: pass
|
||||
self.bridge.stats_worker.stop()
|
||||
|
||||
if self.hotkey_manager: self.hotkey_manager.stop()
|
||||
|
||||
# Close all QML windows to ensure bindings stop before Python objects die
|
||||
if self.overlay_root:
|
||||
self.overlay_root.close()
|
||||
self.overlay_root.deleteLater()
|
||||
if hasattr(self, 'loader_root') and self.loader_root:
|
||||
self.loader_root.close()
|
||||
self.loader_root.deleteLater()
|
||||
if hasattr(self, 'settings_root') and self.settings_root:
|
||||
self.settings_root.close()
|
||||
self.settings_root.deleteLater()
|
||||
|
||||
if hasattr(self, 'loader_worker') and self.loader_worker and self.loader_worker.isRunning():
|
||||
logging.info("Waiting for loader to finish...")
|
||||
self.loader_worker.quit()
|
||||
self.loader_worker.wait(1000)
|
||||
|
||||
if hasattr(self, 'worker') and self.worker and self.worker.isRunning():
|
||||
logging.info("Waiting for transcription to finish...")
|
||||
self.worker.quit()
|
||||
self.worker.wait(2000)
|
||||
|
||||
self.qt_app.quit()
|
||||
|
||||
@Slot()
|
||||
def open_settings(self):
|
||||
if not hasattr(self, 'settings_root') or self.settings_root is None:
|
||||
logging.info("Loading Settings window for the first time...")
|
||||
settings_qml = get_bundle_path() / "src/ui/qml/Settings.qml"
|
||||
self.engine.load(QUrl.fromLocalFile(str(settings_qml)))
|
||||
self.settings_root = self.engine.rootObjects()[-1]
|
||||
self.settings_root.setProperty("color", "transparent")
|
||||
|
||||
# Connect the closing signal to just hide/delete reference if needed,
|
||||
# but better to keep it alive. Actually, QML Window close() hides it by default usually
|
||||
# unless we set closePolicy. Let's ensure we can re-show it.
|
||||
# We might need to listen to closing signal to prevent destruction if we want to reuse.
|
||||
# But simpler: check if it exists, if so, show/raise it.
|
||||
|
||||
# Center on screen
|
||||
from PySide6.QtGui import QGuiApplication
|
||||
screen = QGuiApplication.primaryScreen()
|
||||
if screen:
|
||||
geom = screen.availableGeometry()
|
||||
self.settings_root.setX(geom.x() + (geom.width() - self.settings_root.width()) // 2)
|
||||
self.settings_root.setY(geom.y() + (geom.height() - self.settings_root.height()) // 2)
|
||||
|
||||
self.settings_root.setVisible(True)
|
||||
self.settings_root.requestActivate()
|
||||
|
||||
@Slot()
|
||||
def init_settings_preload(self):
|
||||
"""Preloads settings window to avoid lag on first open."""
|
||||
# Check if already loaded
|
||||
if hasattr(self, 'settings_root') and self.settings_root:
|
||||
return
|
||||
|
||||
logging.info("Preloading Settings QML...")
|
||||
# Load but keep hidden? QML Window visible defaults to true usually,
|
||||
# so we might see a flicker if we don't be careful.
|
||||
# Ideally we load it with visible: false property from python or QML.
|
||||
# For now, let's just let the first open be the load, but since user complained about lag...
|
||||
# effectively doing nothing different here unless we actually trigger load.
|
||||
pass
|
||||
|
||||
@Slot(str, 'QVariant')
|
||||
def on_settings_changed(self, key, value):
|
||||
"""
|
||||
React to settings changes in real-time.
|
||||
Some settings require immediate action (reloading model, moving window).
|
||||
"""
|
||||
print(f"Setting Changed: {key} = {value}")
|
||||
|
||||
# 1. Hotkey Reload
|
||||
if key == "hotkey":
|
||||
if self.hotkey_manager: self.hotkey_manager.reload_hotkey()
|
||||
if self.tray:
|
||||
self.tray.setToolTip(f"Whisper Voice - Press {value} to Record")
|
||||
|
||||
# 2. AI Model Reload (Heavy)
|
||||
if key in ["model_size", "compute_device", "compute_type"]:
|
||||
size = self.config.get("model_size")
|
||||
# Notify UI to check if the new selected model is downloaded
|
||||
self.bridge.notifyModelStatesChanged()
|
||||
|
||||
if self.transcriber.model_exists(size):
|
||||
logging.info(f"Model '{size}' exists. Reloading engine...")
|
||||
threading.Thread(target=self.transcriber.load_model, daemon=True).start()
|
||||
else:
|
||||
logging.info(f"Model '{size}' not found. Waiting for manual download.")
|
||||
|
||||
# 3. Window Positioning
|
||||
if key in ["overlay_position", "overlay_offset_x", "overlay_offset_y", "ui_scale"]:
|
||||
self.reposition_overlay()
|
||||
|
||||
# 4. Run on Startup
|
||||
if key == "run_on_startup":
|
||||
self.handle_startup_shortcut(value)
|
||||
|
||||
# 4. Input Device (Audio Engine handles this on next record start typically,
|
||||
# but we can force a stream restart if we want instant feedback?
|
||||
# For now, next record is fine as per plan).
|
||||
|
||||
def reposition_overlay(self):
|
||||
"""Calculates and sets the Overlay position based on user settings."""
|
||||
from PySide6.QtGui import QGuiApplication
|
||||
screen = QGuiApplication.primaryScreen()
|
||||
if not screen or not self.overlay_root: return
|
||||
|
||||
# Apply UI Scale (Handled in QML now, but we need it for position calc)
|
||||
scale = float(self.config.get("ui_scale"))
|
||||
# self.overlay_root.setProperty("scale", scale) # Removed, handled in QML
|
||||
|
||||
# Get Geometry
|
||||
geom = screen.availableGeometry()
|
||||
|
||||
# Current Scaled Dimensions (Approximation)
|
||||
# Note: We must assume the base size is 460x180 (window size)
|
||||
# But visually it's 380x100 (container) scaled up.
|
||||
# The Window itself stays fixed size (transparent frame), but content scales.
|
||||
# Actually, simpler interpretation: The window size is fixed large area, content moves.
|
||||
# BUT if we want "Edge alignment", we must account for visual bounds.
|
||||
|
||||
visual_w = 460 * scale
|
||||
visual_h = 180 * scale
|
||||
|
||||
# We set the WINDOW position anchor.
|
||||
# Since the window content is centered, the window is effectively the bounding box we care about?
|
||||
# No, the window is 460x180. The content is smaller 380x100.
|
||||
# Let's align based on the WINDOW size for now to be safe.
|
||||
|
||||
# Wait, if we scale in QML, does the window size change? No.
|
||||
# So if we scale up 1.5x, content might clip if window doesn't grow.
|
||||
# To support UI Scale properly without clipping, we should probably resize the window here too.
|
||||
# Let's resize the window to fit the scaled content.
|
||||
|
||||
win_w = int(460 * scale)
|
||||
win_h = int(180 * scale)
|
||||
|
||||
self.overlay_root.setWidth(win_w)
|
||||
self.overlay_root.setHeight(win_h)
|
||||
|
||||
pos_mode = self.config.get("overlay_position")
|
||||
offset_x = int(self.config.get("overlay_offset_x"))
|
||||
offset_y = int(self.config.get("overlay_offset_y"))
|
||||
|
||||
x = 0
|
||||
y = 0
|
||||
|
||||
if pos_mode == "Bottom Center":
|
||||
x = geom.x() + (geom.width() - win_w) // 2
|
||||
y = geom.bottom() - win_h - 15
|
||||
elif pos_mode == "Top Center":
|
||||
x = geom.x() + (geom.width() - win_w) // 2
|
||||
y = geom.top() + 15
|
||||
elif pos_mode == "Bottom Right":
|
||||
x = geom.right() - win_w - 15
|
||||
y = geom.bottom() - win_h - 15
|
||||
elif pos_mode == "Top Right":
|
||||
x = geom.right() - win_w - 15
|
||||
y = geom.top() + 15
|
||||
elif pos_mode == "Bottom Left":
|
||||
x = geom.left() + 15
|
||||
y = geom.bottom() - win_h - 15
|
||||
elif pos_mode == "Top Left":
|
||||
x = geom.left() + 15
|
||||
y = geom.top() + 15
|
||||
|
||||
# Apply Offsets
|
||||
x += offset_x
|
||||
y += offset_y
|
||||
|
||||
self.overlay_root.setX(x)
|
||||
self.overlay_root.setY(y)
|
||||
|
||||
@Slot()
|
||||
def transcribe_file(self):
|
||||
file_path, _ = QFileDialog.getOpenFileName(None, "Select Audio", "", "Audio (*.mp3 *.wav *.flac *.m4a *.ogg)")
|
||||
if file_path:
|
||||
self.bridge.update_status("Thinking...")
|
||||
self.worker = TranscriptionWorker(self.transcriber, file_path, is_file=True, parent=self)
|
||||
self.worker.finished.connect(self.on_transcription_done)
|
||||
self.worker.start()
|
||||
|
||||
@Slot()
|
||||
def on_silence_detected(self):
|
||||
from PySide6.QtCore import QMetaObject, Qt
|
||||
QMetaObject.invokeMethod(self, "toggle_recording", Qt.QueuedConnection)
|
||||
|
||||
@Slot()
|
||||
def toggle_recording(self):
|
||||
if not self.audio_engine: return
|
||||
|
||||
# Prevent starting a new recording while we are still transcribing the last one
|
||||
if self.bridge.isProcessing:
|
||||
logging.warning("Ignored toggle request: Transcription in progress.")
|
||||
return
|
||||
|
||||
if self.audio_engine.recording:
|
||||
self.bridge.update_status("Thinking...")
|
||||
self.bridge.isRecording = False
|
||||
self.bridge.isProcessing = True # Start Processing
|
||||
audio_data = self.audio_engine.stop_recording()
|
||||
self.worker = TranscriptionWorker(self.transcriber, audio_data, parent=self)
|
||||
self.worker.finished.connect(self.on_transcription_done)
|
||||
self.worker.start()
|
||||
else:
|
||||
self.bridge.update_status("Recording")
|
||||
self.bridge.isRecording = True
|
||||
self.audio_engine.start_recording()
|
||||
|
||||
@Slot(bool)
|
||||
def on_ui_toggle_request(self, state):
|
||||
if state != self.audio_engine.recording:
|
||||
self.toggle_recording()
|
||||
|
||||
@Slot(str)
|
||||
def on_transcription_done(self, text: str):
|
||||
self.bridge.update_status("Ready")
|
||||
self.bridge.isProcessing = False # End Processing
|
||||
if text:
|
||||
method = self.config.get("input_method")
|
||||
speed = int(self.config.get("typing_speed"))
|
||||
InputInjector.inject_text(text, method, speed)
|
||||
|
||||
@Slot(bool)
|
||||
def on_hotkeys_enabled_toggle(self, state):
|
||||
if self.hotkey_manager:
|
||||
self.hotkey_manager.set_enabled(state)
|
||||
|
||||
@Slot(str)
|
||||
def on_download_requested(self, size):
|
||||
if self.bridge.isDownloading:
|
||||
return
|
||||
|
||||
self.bridge.update_status("Downloading...")
|
||||
self.bridge.isDownloading = True
|
||||
|
||||
self.download_worker = DownloadWorker(size, parent=self)
|
||||
self.download_worker.finished.connect(self.on_download_finished)
|
||||
self.download_worker.error.connect(self.on_download_error)
|
||||
self.download_worker.start()
|
||||
|
||||
def on_download_finished(self):
|
||||
self.bridge.isDownloading = False
|
||||
self.bridge.update_status("Ready")
|
||||
self.bridge.notifyModelStatesChanged() # Refresh UI markers
|
||||
# Automatically load it now that it's here
|
||||
threading.Thread(target=self.transcriber.load_model, daemon=True).start()
|
||||
|
||||
def on_download_error(self, err):
|
||||
self.bridge.isDownloading = False
|
||||
self.bridge.update_status("Error")
|
||||
logging.error(f"Download Error: {err}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = WhisperApp()
|
||||
app.run()
|
||||
88
portable_build.py
Normal file
88
portable_build.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Portable Build Script for WhisperVoice.
|
||||
=======================================
|
||||
|
||||
Creates a single-file portable .exe using PyInstaller.
|
||||
All data (settings, models) will be stored next to the .exe at runtime.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import PyInstaller.__main__
|
||||
from pathlib import Path
|
||||
|
||||
def build_portable():
|
||||
# 1. Setup Paths
|
||||
project_root = Path(__file__).parent.absolute()
|
||||
dist_path = project_root / "dist"
|
||||
build_path = project_root / "build"
|
||||
|
||||
# 2. Define Assets to bundle (into the .exe)
|
||||
# Format: (Source, Destination relative to bundle root)
|
||||
data_files = [
|
||||
# QML files
|
||||
("src/ui/qml/*.qml", "src/ui/qml"),
|
||||
("src/ui/qml/*.svg", "src/ui/qml"),
|
||||
("src/ui/qml/*.qsb", "src/ui/qml"),
|
||||
("src/ui/qml/fonts/ttf/*.ttf", "src/ui/qml/fonts/ttf"),
|
||||
# Subprocess worker script (CRITICAL for transcription)
|
||||
("src/core/transcribe_worker.py", "src/core"),
|
||||
]
|
||||
|
||||
# Convert to PyInstaller format "--add-data source;dest" (Windows uses ';')
|
||||
add_data_args = []
|
||||
for src, dst in data_files:
|
||||
add_data_args.extend(["--add-data", f"{src}{os.pathsep}{dst}"])
|
||||
|
||||
# 3. Run PyInstaller
|
||||
print("🚀 Starting Portable Build...")
|
||||
print("⏳ This may take 5-10 minutes...")
|
||||
|
||||
PyInstaller.__main__.run([
|
||||
"main.py", # Entry point
|
||||
"--name=WhisperVoice", # EXE name
|
||||
"--onefile", # Single EXE (slower startup but portable)
|
||||
"--noconsole", # No terminal window
|
||||
"--clean", # Clean cache
|
||||
*add_data_args, # Bundled assets
|
||||
|
||||
# Heavy libraries that need special collection
|
||||
"--collect-all", "faster_whisper",
|
||||
"--collect-all", "ctranslate2",
|
||||
"--collect-all", "PySide6",
|
||||
"--collect-all", "torch",
|
||||
"--collect-all", "numpy",
|
||||
|
||||
# Hidden imports (modules imported dynamically)
|
||||
"--hidden-import", "keyboard",
|
||||
"--hidden-import", "pyperclip",
|
||||
"--hidden-import", "psutil",
|
||||
"--hidden-import", "pynvml",
|
||||
"--hidden-import", "sounddevice",
|
||||
"--hidden-import", "scipy",
|
||||
"--hidden-import", "scipy.signal",
|
||||
"--hidden-import", "huggingface_hub",
|
||||
"--hidden-import", "tokenizers",
|
||||
|
||||
# Qt plugins
|
||||
"--hidden-import", "PySide6.QtQuickControls2",
|
||||
"--hidden-import", "PySide6.QtQuick.Controls",
|
||||
|
||||
# Icon (convert to .ico for Windows)
|
||||
# "--icon=icon.ico", # Uncomment if you have a .ico file
|
||||
])
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("✅ BUILD COMPLETE!")
|
||||
print("="*60)
|
||||
print(f"\n📍 Output: {dist_path / 'WhisperVoice.exe'}")
|
||||
print("\n📋 First run instructions:")
|
||||
print(" 1. Place WhisperVoice.exe in a folder (e.g., C:\\WhisperVoice\\)")
|
||||
print(" 2. Run it - it will create 'models' and 'settings.json' folders")
|
||||
print(" 3. The app will download the Whisper model on first transcription\n")
|
||||
print("💡 TIP: Keep the .exe with its generated files for true portability!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Ensure we are in project root
|
||||
os.chdir(Path(__file__).parent)
|
||||
build_portable()
|
||||
30
requirements.txt
Normal file
30
requirements.txt
Normal file
@@ -0,0 +1,30 @@
|
||||
# WhisperVoice Dependencies
|
||||
# =========================
|
||||
|
||||
# Core AI
|
||||
faster-whisper>=1.0.0
|
||||
torch>=2.0.0
|
||||
|
||||
# UI Framework
|
||||
PySide6>=6.6.0
|
||||
|
||||
# Audio
|
||||
sounddevice>=0.4.6
|
||||
soundfile>=0.12.0
|
||||
scipy>=1.11.0
|
||||
|
||||
# System / Input
|
||||
keyboard>=0.13.5
|
||||
pyperclip>=1.8.2
|
||||
psutil>=5.9.0
|
||||
pynvml>=11.0.0
|
||||
|
||||
# Utilities
|
||||
numpy>=1.24.0
|
||||
requests>=2.31.0
|
||||
huggingface-hub>=0.20.0
|
||||
|
||||
# System Tray
|
||||
pystray>=0.19.0
|
||||
Pillow>=10.0.0
|
||||
darkdetect>=0.8.0
|
||||
5
run.bat
Normal file
5
run.bat
Normal file
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
echo [LAUNCHER] Starting Fake Blur UI (Python/Qt)...
|
||||
call venv\Scripts\activate.bat
|
||||
python main.py
|
||||
if %errorlevel% neq 0 pause
|
||||
13
run_source.bat
Normal file
13
run_source.bat
Normal file
@@ -0,0 +1,13 @@
|
||||
@echo off
|
||||
echo Starting Whisper Voice (Source Mode)...
|
||||
if not exist venv (
|
||||
echo Venv not found. Creating...
|
||||
python -m venv venv
|
||||
call venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
) else (
|
||||
call venv\Scripts\activate
|
||||
)
|
||||
|
||||
python main.py
|
||||
pause
|
||||
196
src/core/audio_engine.py
Normal file
196
src/core/audio_engine.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""
|
||||
Audio Engine Module.
|
||||
====================
|
||||
|
||||
This module handles the low-level audio recording capabilities using `sounddevice`.
|
||||
It manages the input stream, buffers audio data in memory, and provides a callback
|
||||
mechanism for real-time visualization of audio amplitude.
|
||||
|
||||
Classes:
|
||||
AudioEngine: The main controller for recording streams.
|
||||
"""
|
||||
|
||||
import sounddevice as sd
|
||||
import numpy as np
|
||||
import threading
|
||||
import queue
|
||||
import logging
|
||||
from typing import Optional, Callable
|
||||
from src.core.config import ConfigManager
|
||||
|
||||
class AudioEngine:
|
||||
"""
|
||||
Manages audio recording from the default input device.
|
||||
Uses ConfigManager for settings (device, silence detection).
|
||||
"""
|
||||
|
||||
def __init__(self, sample_rate: int = 16000, channels: int = 1):
|
||||
"""
|
||||
Initialize the AudioEngine.
|
||||
"""
|
||||
self.config = ConfigManager()
|
||||
self.sample_rate = sample_rate
|
||||
self.channels = channels
|
||||
self.recording = False
|
||||
self.stream: Optional[sd.InputStream] = None
|
||||
self.visualizer_callback: Optional[Callable[[float], None]] = None
|
||||
self.silence_callback: Optional[Callable[[], None]] = None
|
||||
|
||||
# Audio buffer to store the current session's frames
|
||||
self.frames = []
|
||||
self.last_noise_time = 0.0
|
||||
|
||||
def list_devices(self):
|
||||
"""
|
||||
Query available audio devices.
|
||||
|
||||
Returns:
|
||||
DeviceList: A list of all available input/output devices seen by PortAudio.
|
||||
"""
|
||||
return sd.query_devices()
|
||||
|
||||
def set_visualizer_callback(self, callback: Callable[[float], None]):
|
||||
"""
|
||||
Register a callback function for visualizer updates.
|
||||
|
||||
Args:
|
||||
callback (function): A function that accepts a single float argument (amplitude).
|
||||
This will be called roughly every audio block.
|
||||
"""
|
||||
self.visualizer_callback = callback
|
||||
|
||||
def set_silence_callback(self, callback: Callable[[], None]):
|
||||
"""
|
||||
Register a callback function for silence detection (Auto-Stop).
|
||||
"""
|
||||
self.silence_callback = callback
|
||||
|
||||
def _audio_callback(self, indata: np.ndarray, frames: int, time, status: sd.CallbackFlags):
|
||||
"""
|
||||
Internal callback used by sounddevice to process incoming audio chunks.
|
||||
|
||||
Args:
|
||||
indata (numpy.ndarray): The recorded audio data chunk.
|
||||
frames (int): Number of frames.
|
||||
time: Timestamp info.
|
||||
status: Callback status flags (e.g., overflow warnings).
|
||||
"""
|
||||
if status:
|
||||
logging.warning(f"Audio callback status: {status}")
|
||||
|
||||
if self.recording:
|
||||
# Copy data to avoid buffer race conditions
|
||||
data = indata.copy()
|
||||
self.frames.append(data)
|
||||
|
||||
# Calculate amplitude for visualizer (Root Mean Square)
|
||||
if self.visualizer_callback:
|
||||
# Calculate RMS of the current chunk to determine loudness
|
||||
rms = np.sqrt(np.mean(data**2))
|
||||
|
||||
# Apply logarithmic scaling for better sensitivity to quiet sounds
|
||||
if rms > 0:
|
||||
# Convert to dB scale, normalize, and apply compression
|
||||
db = 20 * np.log10(rms + 1e-10) # Add small value to avoid log(0)
|
||||
# Map from typical range (-60 to 0 dB) to (0 to 1)
|
||||
amp = float(np.clip((db + 60) / 60, 0.0, 1.0))
|
||||
# Apply power curve for better sensitivity at low levels
|
||||
amp = np.power(amp, 0.5) # Square root gives good response to quiet sounds
|
||||
else:
|
||||
amp = 0.0
|
||||
|
||||
# Apply exponential smoothing to prevent jumpy waveform
|
||||
if not hasattr(self, '_smoothed_amp'):
|
||||
self._smoothed_amp = amp
|
||||
else:
|
||||
# Exponential moving average with smoothing factor 0.3
|
||||
self._smoothed_amp = 0.3 * amp + 0.7 * self._smoothed_amp
|
||||
|
||||
self.visualizer_callback(self._smoothed_amp)
|
||||
|
||||
|
||||
|
||||
# --- Silence Detection Logic ---
|
||||
# We calculate this even if visualizer is off
|
||||
# Calculate linear RMS for VAD comparison
|
||||
raw_rms = np.sqrt(np.mean(data**2))
|
||||
# Heuristic mapping: 0.1 RMS = 100% threshold
|
||||
vad_level = float(np.clip(raw_rms * 10, 0.0, 1.0))
|
||||
|
||||
import time
|
||||
current_time = time.time()
|
||||
|
||||
# Fetch params dynamically
|
||||
threshold = float(self.config.get("silence_threshold"))
|
||||
duration = float(self.config.get("silence_duration"))
|
||||
|
||||
if vad_level > threshold:
|
||||
self.last_noise_time = current_time
|
||||
else:
|
||||
# If we have been silent for > silence_duration, trigger auto-stop
|
||||
if (current_time - self.last_noise_time) > duration:
|
||||
if self.silence_callback:
|
||||
logging.info(f"Silence detected ({duration}s). Triggering auto-stop.")
|
||||
# Reset last_noise_time to prevent spamming
|
||||
self.last_noise_time = current_time
|
||||
self.silence_callback()
|
||||
|
||||
def start_recording(self, device: Optional[int] = None):
|
||||
"""
|
||||
Start the recording stream.
|
||||
|
||||
Args:
|
||||
device (int, optional): The device ID to use. Defaults to system default.
|
||||
"""
|
||||
if self.recording:
|
||||
return
|
||||
|
||||
self.frames = [] # Reset buffer
|
||||
self.recording = True
|
||||
import time
|
||||
self.last_noise_time = time.time() # Reset silence timer
|
||||
|
||||
# Determine Device
|
||||
# If passed arg is None, check Config. If Config is None, use Default.
|
||||
if device is None:
|
||||
device = self.config.get("input_device")
|
||||
|
||||
try:
|
||||
self.stream = sd.InputStream(
|
||||
samplerate=self.sample_rate,
|
||||
channels=self.channels,
|
||||
device=device,
|
||||
callback=self._audio_callback
|
||||
)
|
||||
self.stream.start()
|
||||
logging.info("Audio recording started.")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to start recording: {e}")
|
||||
self.recording = False
|
||||
|
||||
def stop_recording(self) -> np.ndarray:
|
||||
"""
|
||||
Stop the current recording session and return the captured audio.
|
||||
|
||||
Returns:
|
||||
np.ndarray: The complete audio recording flattened into a single numpy array.
|
||||
Returns an empty array if nothing was recorded.
|
||||
"""
|
||||
if not self.recording:
|
||||
return np.array([], dtype=np.float32)
|
||||
|
||||
self.recording = False
|
||||
if self.stream:
|
||||
self.stream.stop()
|
||||
self.stream.close()
|
||||
self.stream = None
|
||||
logging.info("Audio recording stopped.")
|
||||
|
||||
if not self.frames:
|
||||
return np.array([], dtype=np.float32)
|
||||
|
||||
# Concatenate all buffered chunks into one continuous array
|
||||
# sounddevice returns (frames, channels), so we get (N, 1).
|
||||
# Whisper expects flattened 1D array (N,).
|
||||
audio = np.concatenate(self.frames, axis=0)
|
||||
return audio.flatten()
|
||||
117
src/core/config.py
Normal file
117
src/core/config.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
Configuration Manager Module.
|
||||
=============================
|
||||
|
||||
Singleton class to manage loading and saving application settings to a JSON file.
|
||||
Ensures robustness by merging with defaults and handling file paths correctly.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from src.core.paths import get_base_path
|
||||
|
||||
# Default Configuration
|
||||
DEFAULT_SETTINGS = {
|
||||
"hotkey": "f8",
|
||||
"model_size": "small",
|
||||
"input_device": None, # Device ID (int) or Name (str), None = Default
|
||||
"save_recordings": False, # Save .wav files for debugging
|
||||
"silence_threshold": 0.02, # Amplitude threshold (0.0 - 1.0)
|
||||
"silence_duration": 1.0, # Seconds of silence to trigger auto-submit
|
||||
"visualizer_style": "line", # 'bar' or 'line'
|
||||
"opacity": 1.0, # Window opacity (0.1 - 1.0)
|
||||
"ui_scale": 1.0, # Global UI Scale (0.75 - 1.5)
|
||||
"always_on_top": True,
|
||||
"run_on_startup": False, # (Placeholder)
|
||||
|
||||
# Window Position
|
||||
"overlay_position": "Bottom Center",
|
||||
"overlay_offset_x": 0,
|
||||
"overlay_offset_y": 0,
|
||||
|
||||
# Input
|
||||
"input_method": "Clipboard Paste", # "Clipboard Paste" or "Simulate Typing"
|
||||
"typing_speed": 100, # CPM (Chars Per Minute) if typing
|
||||
|
||||
# AI - Advanced
|
||||
"language": "auto", # "auto" or ISO code
|
||||
"compute_device": "auto", # "auto", "cuda", "cpu"
|
||||
"compute_type": "int8", # "int8", "float16", "float32"
|
||||
"beam_size": 5,
|
||||
"best_of": 5,
|
||||
"vad_filter": True,
|
||||
"no_repeat_ngram_size": 0,
|
||||
"condition_on_previous_text": True
|
||||
}
|
||||
|
||||
class ConfigManager:
|
||||
"""
|
||||
Singleton Configuration Manager.
|
||||
"""
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(ConfigManager, cls).__new__(cls)
|
||||
cls._instance._init()
|
||||
return cls._instance
|
||||
|
||||
def _init(self):
|
||||
"""Initialize the config manager (called only once)."""
|
||||
self.base_path = get_base_path()
|
||||
self.config_file = self.base_path / "settings.json"
|
||||
self.data = DEFAULT_SETTINGS.copy()
|
||||
self.load()
|
||||
|
||||
def load(self):
|
||||
"""Load settings from JSON file, merging with defaults."""
|
||||
if self.config_file.exists():
|
||||
try:
|
||||
with open(self.config_file, 'r', encoding='utf-8') as f:
|
||||
loaded = json.load(f)
|
||||
|
||||
# Merge loaded data into defaults (preserves new default keys)
|
||||
for key, value in loaded.items():
|
||||
if key in DEFAULT_SETTINGS:
|
||||
self.data[key] = value
|
||||
|
||||
logging.info(f"Settings loaded from {self.config_file}")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to load settings: {e}")
|
||||
else:
|
||||
logging.info("No settings file found. Using defaults.")
|
||||
self.save()
|
||||
|
||||
def save(self):
|
||||
"""Save current settings to JSON file."""
|
||||
try:
|
||||
with open(self.config_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.data, f, indent=4)
|
||||
logging.info("Settings saved.")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to save settings: {e}")
|
||||
|
||||
def get(self, key: str) -> Any:
|
||||
"""Get a setting value."""
|
||||
return self.data.get(key, DEFAULT_SETTINGS.get(key))
|
||||
|
||||
|
||||
|
||||
def set(self, key: str, value: Any):
|
||||
"""Set a setting value and save."""
|
||||
if self.data.get(key) != value:
|
||||
self.data[key] = value
|
||||
self.save()
|
||||
|
||||
def set_bulk(self, updates: Dict[str, Any]):
|
||||
"""Update multiple keys and save once."""
|
||||
changed = False
|
||||
for k, v in updates.items():
|
||||
if self.data.get(k) != v:
|
||||
self.data[k] = v
|
||||
changed = True
|
||||
if changed:
|
||||
self.save()
|
||||
31
src/core/debug_run_worker.bat
Normal file
31
src/core/debug_run_worker.bat
Normal file
@@ -0,0 +1,31 @@
|
||||
@echo off
|
||||
echo [DEBUG] LAUNCHER STARTED
|
||||
echo [DEBUG] CWD: %CD%
|
||||
echo [DEBUG] Python Path (expected relative): ..\python\python.exe
|
||||
|
||||
REM Read stdin to a file to verify data input (optional debugging)
|
||||
REM python.exe might be in different relative path depending on where this bat is run
|
||||
REM We assume this bat is in runtime/app/src/core/
|
||||
REM So python is in ../../../python/python.exe
|
||||
|
||||
set PYTHON_EXE=..\..\..\python\python.exe
|
||||
|
||||
if exist "%PYTHON_EXE%" (
|
||||
echo [DEBUG] Found Python at %PYTHON_EXE%
|
||||
) else (
|
||||
echo [ERROR] Python NOT found at %PYTHON_EXE%
|
||||
echo [ERROR] Listing relative directories:
|
||||
dir ..\..\..\
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [DEBUG] Launching script: transcribe_worker.py
|
||||
"%PYTHON_EXE%" transcribe_worker.py
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo [ERROR] Python script failed with code %ERRORLEVEL%
|
||||
pause
|
||||
) else (
|
||||
echo [SUCCESS] Script finished.
|
||||
pause
|
||||
)
|
||||
95
src/core/hotkey_manager.py
Normal file
95
src/core/hotkey_manager.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Hotkey Manager Module.
|
||||
======================
|
||||
|
||||
This module wraps the `keyboard` library to provide Global Hotkey functionality.
|
||||
It allows the application to respond to key presses even when it is not in focus
|
||||
(background operation).
|
||||
|
||||
Classes:
|
||||
HotkeyManager: Qt-compatible wrapper for keyboard hooks.
|
||||
"""
|
||||
|
||||
import keyboard
|
||||
import logging
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
from typing import Optional
|
||||
|
||||
class HotkeyManager(QObject):
|
||||
"""
|
||||
Manages global keyboard shortcuts using the `keyboard` library.
|
||||
inherits from QObject to allow Signal/Slot integration with PySide6.
|
||||
|
||||
Signals:
|
||||
triggered: Emitted when the hotkey is pressed.
|
||||
|
||||
Attributes:
|
||||
hotkey (str): The key combination as a string (e.g. "f8", "ctrl+alt+r").
|
||||
is_listening (bool): State of the listener.
|
||||
"""
|
||||
|
||||
triggered = Signal()
|
||||
|
||||
def __init__(self, hotkey: str = "f8"):
|
||||
"""
|
||||
Initialize the HotkeyManager.
|
||||
|
||||
Args:
|
||||
hotkey (str): The global hotkey string description. Default: "f8".
|
||||
"""
|
||||
super().__init__()
|
||||
self.hotkey = hotkey
|
||||
self.is_listening = False
|
||||
self._enabled = True
|
||||
|
||||
def set_enabled(self, enabled: bool):
|
||||
"""Enable or disable the hotkey trigger without unhooking."""
|
||||
self._enabled = enabled
|
||||
logging.info(f"Hotkey listener {'enabled' if enabled else 'suspended'}")
|
||||
|
||||
def start(self):
|
||||
"""Start listening for the hotkey."""
|
||||
self.reload_hotkey()
|
||||
|
||||
def reload_hotkey(self):
|
||||
"""Unregister old hotkey and register new one from Config."""
|
||||
if self.is_listening:
|
||||
self.stop()
|
||||
|
||||
from src.core.config import ConfigManager
|
||||
config = ConfigManager()
|
||||
self.hotkey = config.get("hotkey")
|
||||
|
||||
logging.info(f"Registering global hotkey: {self.hotkey}")
|
||||
try:
|
||||
# We don't suppress=True here because we want the app to see keys during recording
|
||||
# (Wait, actually if we are recording we WANT keyboard to see it,
|
||||
# but usually global hotkeys should be suppressed if we don't want them leaking to other apps)
|
||||
# However, the user is fixing the internal collision.
|
||||
keyboard.add_hotkey(self.hotkey, self.on_press, suppress=False)
|
||||
self.is_listening = True
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to bind hotkey: {e}")
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
Stop listening and unregister the hook.
|
||||
Safe to call even if not listening.
|
||||
"""
|
||||
if self.is_listening:
|
||||
try:
|
||||
keyboard.remove_hotkey(self.hotkey)
|
||||
except:
|
||||
pass
|
||||
self.is_listening = False
|
||||
logging.info(f"Unregistered global hotkey: {self.hotkey}")
|
||||
|
||||
def on_press(self):
|
||||
"""
|
||||
Callback triggered internally by the keyboard library when the key is pressed.
|
||||
Emits the Qt `triggered` signal.
|
||||
"""
|
||||
if not self._enabled:
|
||||
return
|
||||
logging.info(f"Hotkey {self.hotkey} detected.")
|
||||
self.triggered.emit()
|
||||
86
src/core/paths.py
Normal file
86
src/core/paths.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
Paths Module.
|
||||
=============
|
||||
|
||||
This module handles all file system path resolution for the application.
|
||||
It is critical for ensuring portability, distinguishing between:
|
||||
1. Running as a raw Python script (using `__file__`).
|
||||
2. Running as a frozen PyInstaller EXE (using `sys.executable`).
|
||||
|
||||
It creates necessary directories (models, libs) if they do not exist.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
def get_bundle_path() -> Path:
|
||||
"""
|
||||
Returns the root directory of the application bundle.
|
||||
When frozen, this is the internal temporary directory (sys._MEIPASS).
|
||||
When running as script, this is the project root.
|
||||
Use this for bundled assets like QML, SVGs, etc.
|
||||
"""
|
||||
if getattr(sys, 'frozen', False):
|
||||
return Path(sys._MEIPASS)
|
||||
# Project root (assuming this file is at src/core/paths.py)
|
||||
return Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
def get_base_path() -> Path:
|
||||
"""
|
||||
Returns the directory where persistent data should be stored.
|
||||
Always points to the directory containing the .exe or the project root.
|
||||
Use this for models, settings, recordings.
|
||||
"""
|
||||
if getattr(sys, 'frozen', False):
|
||||
return Path(sys.executable).parent
|
||||
return get_bundle_path()
|
||||
|
||||
def get_models_path() -> Path:
|
||||
"""
|
||||
Returns the absolute path to the 'models' directory.
|
||||
|
||||
This directory is used to store the Whisper AI model files.
|
||||
The directory is automatically created if it does not exist.
|
||||
|
||||
Returns:
|
||||
Path: Absolute path to the ./models directory next to the output binary.
|
||||
"""
|
||||
path = get_base_path() / "models"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
def get_libs_path() -> Path:
|
||||
"""
|
||||
Returns the absolute path to the 'libs' directory.
|
||||
|
||||
This directory is used to store external binaries like `ffmpeg.exe`.
|
||||
The directory is automatically created if it does not exist.
|
||||
|
||||
Returns:
|
||||
Path: Absolute path to the ./libs directory next to the output binary.
|
||||
"""
|
||||
path = get_base_path() / "libs"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
def get_ffmpeg_path() -> str:
|
||||
"""
|
||||
Resolves the path to the FFmpeg executable.
|
||||
|
||||
Logic:
|
||||
1. Checks for `ffmpeg.exe` in the local `./libs` folder.
|
||||
2. Fallbacks to the system-wide "ffmpeg" command if local file is missing.
|
||||
|
||||
Returns:
|
||||
str: Absolute path to the local binary, or just "ffmpeg" string for system PATH lookup.
|
||||
"""
|
||||
libs_path = get_libs_path()
|
||||
ffmpeg_exe = libs_path / "ffmpeg.exe"
|
||||
|
||||
if ffmpeg_exe.exists():
|
||||
return str(ffmpeg_exe.absolute())
|
||||
|
||||
# Fallback to system PATH
|
||||
return "ffmpeg"
|
||||
127
src/core/transcribe_worker.py
Normal file
127
src/core/transcribe_worker.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
Transcription Worker Subprocess.
|
||||
================================
|
||||
|
||||
This script is designed to be run as a subprocess. It:
|
||||
1. Receives configuration and audio data via stdin (pickled)
|
||||
2. Loads the Whisper model
|
||||
3. Transcribes the audio
|
||||
4. Prints the result to stdout
|
||||
5. Exits (letting the OS reclaim all memory)
|
||||
|
||||
This ensures complete RAM/VRAM cleanup after each transcription.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import pickle
|
||||
import logging
|
||||
import os
|
||||
import traceback
|
||||
|
||||
# Enable debug logging to file for definitive troubleshooting
|
||||
log_file = os.path.join(os.path.dirname(__file__), "worker_debug.log")
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
filename=log_file,
|
||||
filemode='w',
|
||||
format='[WORKER] %(message)s'
|
||||
)
|
||||
|
||||
def main():
|
||||
try:
|
||||
# Read pickled data from stdin
|
||||
data = pickle.load(sys.stdin.buffer)
|
||||
|
||||
config = data['config']
|
||||
audio_data = data['audio']
|
||||
model_path = data['model_path']
|
||||
libs_path = data['libs_path']
|
||||
|
||||
# Add libs to PATH for cuDNN etc
|
||||
os.environ["PATH"] += os.pathsep + str(libs_path)
|
||||
|
||||
# Import and load model
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
config = data.get('config', {})
|
||||
model_path_arg = config.get('model_size') # Now receives full path
|
||||
device = config.get("compute_device", "cuda")
|
||||
compute = config.get("compute_type", "float16")
|
||||
|
||||
logging.info(f"Worker initializing model from: '{model_path_arg}'")
|
||||
|
||||
# Verify path existence for debugging
|
||||
if os.path.exists(model_path_arg):
|
||||
logging.info(f"Path verification: EXISTS. Is dir: {os.path.isdir(model_path_arg)}")
|
||||
else:
|
||||
logging.error(f"Path verification: DOES NOT EXIST!")
|
||||
|
||||
model = WhisperModel(
|
||||
model_path_arg,
|
||||
device=device,
|
||||
compute_type=compute,
|
||||
download_root=model_path,
|
||||
local_files_only=True # FORCE offline mode
|
||||
)
|
||||
|
||||
# Transcription parameters
|
||||
lang = config.get("language", "auto")
|
||||
if lang == "auto": lang = None
|
||||
|
||||
beam_size = int(config.get("beam_size", 5))
|
||||
best_of = int(config.get("best_of", 5))
|
||||
vad = config.get("vad_filter", True)
|
||||
no_repeat_ngram = int(config.get("no_repeat_ngram_size", 0))
|
||||
condition_prev = config.get("condition_on_previous_text", True)
|
||||
|
||||
# Transcribe with more lenient settings for challenging audio
|
||||
segments, info = model.transcribe(
|
||||
audio_data,
|
||||
beam_size=beam_size,
|
||||
best_of=best_of,
|
||||
language=lang,
|
||||
vad_filter=vad,
|
||||
vad_parameters=dict(min_silence_duration_ms=500),
|
||||
no_repeat_ngram_size=no_repeat_ngram,
|
||||
condition_on_previous_text=condition_prev,
|
||||
# Lenient thresholds for music/singing
|
||||
compression_ratio_threshold=10.0, # Default 2.4, higher = more lenient
|
||||
log_prob_threshold=-2.0, # Default -1.0, lower = more lenient
|
||||
no_speech_threshold=0.9, # Default 0.6, higher = more lenient
|
||||
without_timestamps=True, # Faster for file processing
|
||||
)
|
||||
|
||||
text_result = ""
|
||||
for segment in segments:
|
||||
text_result += segment.text
|
||||
|
||||
text_result = text_result.strip()
|
||||
|
||||
# Output result as pickled data
|
||||
pickle.dump({'success': True, 'text': text_result}, sys.stdout.buffer)
|
||||
sys.stdout.buffer.flush()
|
||||
|
||||
except Exception as e:
|
||||
# Output error with detailed traceback
|
||||
error_msg = f"{str(e)}\n{traceback.format_exc()}"
|
||||
logging.error(f"Worker failed: {error_msg}")
|
||||
pickle.dump({'success': False, 'error': error_msg}, sys.stdout.buffer)
|
||||
sys.stdout.buffer.flush()
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception:
|
||||
# Catch ALL errors and print them so the user can see in the console
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
# Log to file if possible as well
|
||||
logging.error("CRITICAL WORKER CRASH")
|
||||
logging.error(traceback.format_exc())
|
||||
|
||||
# KEY: Pause so the window doesn't close immediately
|
||||
print("\n" + "="*60)
|
||||
print("CRITICAL ERROR IN WORKER PROCESS")
|
||||
print("Please take a screenshot of this window.")
|
||||
print("="*60)
|
||||
input("Press Enter to close this window...")
|
||||
129
src/core/transcriber.py
Normal file
129
src/core/transcriber.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Whisper Transcriber Module.
|
||||
===========================
|
||||
Transcriber Module.
|
||||
===================
|
||||
|
||||
Handles audio transcription using faster-whisper.
|
||||
Runs IN-PROCESS (no subprocess) to ensure stability on all systems.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
from src.core.config import ConfigManager
|
||||
from src.core.paths import get_models_path
|
||||
|
||||
# Import directly - valid since we are now running in the full environment
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
class WhisperTranscriber:
|
||||
"""
|
||||
Manages the faster-whisper model and transcription process.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize settings."""
|
||||
self.config = ConfigManager()
|
||||
self.model = None
|
||||
self.current_model_size = None
|
||||
self.current_compute_device = None
|
||||
self.current_compute_type = None
|
||||
|
||||
def load_model(self):
|
||||
"""
|
||||
Loads the model specified in config.
|
||||
Safe to call multiple times (checks if reload needed).
|
||||
"""
|
||||
size = self.config.get("model_size")
|
||||
device = self.config.get("compute_device")
|
||||
compute = self.config.get("compute_type")
|
||||
|
||||
# Check if already loaded
|
||||
if (self.model and
|
||||
self.current_model_size == size and
|
||||
self.current_compute_device == device and
|
||||
self.current_compute_type == compute):
|
||||
return
|
||||
|
||||
logging.info(f"Loading Model: {size} on {device} ({compute})...")
|
||||
|
||||
try:
|
||||
# Construct path to local model for offline support
|
||||
new_path = get_models_path() / f"faster-whisper-{size}"
|
||||
model_input = str(new_path) if new_path.exists() else size
|
||||
|
||||
# Force offline if path exists to avoid HF errors
|
||||
local_only = new_path.exists()
|
||||
|
||||
self.model = WhisperModel(
|
||||
model_input,
|
||||
device=device,
|
||||
compute_type=compute,
|
||||
download_root=str(get_models_path()),
|
||||
local_files_only=local_only
|
||||
)
|
||||
|
||||
self.current_model_size = size
|
||||
self.current_compute_device = device
|
||||
self.current_compute_type = compute
|
||||
logging.info("Model loaded successfully.")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to load model: {e}")
|
||||
self.model = None
|
||||
|
||||
def transcribe(self, audio_data, is_file: bool = False) -> str:
|
||||
"""
|
||||
Transcribe audio data.
|
||||
"""
|
||||
logging.info(f"Starting transcription... (is_file={is_file})")
|
||||
|
||||
# Ensure model is loaded
|
||||
if not self.model:
|
||||
self.load_model()
|
||||
if not self.model:
|
||||
return "Error: Model failed to load."
|
||||
|
||||
try:
|
||||
# Config
|
||||
beam_size = int(self.config.get("beam_size"))
|
||||
best_of = int(self.config.get("best_of"))
|
||||
vad = False if is_file else self.config.get("vad_filter")
|
||||
|
||||
# Transcribe
|
||||
segments, info = self.model.transcribe(
|
||||
audio_data,
|
||||
beam_size=beam_size,
|
||||
best_of=best_of,
|
||||
vad_filter=vad,
|
||||
vad_parameters=dict(min_silence_duration_ms=500),
|
||||
condition_on_previous_text=self.config.get("condition_on_previous_text"),
|
||||
without_timestamps=True
|
||||
)
|
||||
|
||||
# Aggregate text
|
||||
text_result = ""
|
||||
for segment in segments:
|
||||
text_result += segment.text + " "
|
||||
|
||||
return text_result.strip()
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Transcription failed: {e}")
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
def model_exists(self, size: str) -> bool:
|
||||
"""Checks if a model size is already downloaded."""
|
||||
new_path = get_models_path() / f"faster-whisper-{size}"
|
||||
if (new_path / "config.json").exists():
|
||||
return True
|
||||
|
||||
# Legacy HF cache check
|
||||
folder_name = f"models--Systran--faster-whisper-{size}"
|
||||
path = get_models_path() / folder_name / "snapshots"
|
||||
if path.exists() and any(path.iterdir()):
|
||||
return True
|
||||
|
||||
return False
|
||||
387
src/ui/bridge.py
Normal file
387
src/ui/bridge.py
Normal file
@@ -0,0 +1,387 @@
|
||||
"""
|
||||
QML Bridge Module.
|
||||
==================
|
||||
|
||||
Acts as the mediator between Python business logic and the QML UI layer.
|
||||
Exposes properties and signals that QML can bind to for real-time updates.
|
||||
"""
|
||||
|
||||
from PySide6.QtCore import QObject, Property, Signal, Slot, QTimer, QThread
|
||||
import logging
|
||||
import psutil
|
||||
import threading
|
||||
import time
|
||||
import os
|
||||
import weakref
|
||||
from shiboken6 import isValid
|
||||
try:
|
||||
import torch
|
||||
except ImportError:
|
||||
torch = None
|
||||
|
||||
from src.core.config import ConfigManager
|
||||
|
||||
class StatsWorker(QThread):
|
||||
stats_ready = Signal(float, float, float, float)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.running = True
|
||||
self.process = psutil.Process()
|
||||
self.process.cpu_percent() # Prime
|
||||
|
||||
# Initialize NVML for accurate GPU monitoring
|
||||
self.nvml_available = False
|
||||
self.gpu_handle = None
|
||||
try:
|
||||
import pynvml
|
||||
pynvml.nvmlInit()
|
||||
self.gpu_handle = pynvml.nvmlDeviceGetHandleByIndex(0)
|
||||
self.nvml_available = True
|
||||
except:
|
||||
pass
|
||||
|
||||
def run(self):
|
||||
while self.running:
|
||||
try:
|
||||
# App CPU
|
||||
cpu = self.process.cpu_percent() / psutil.cpu_count()
|
||||
|
||||
# App RAM in MB
|
||||
ram_bytes = self.process.memory_info().rss
|
||||
ram_mb = ram_bytes / (1024 * 1024)
|
||||
|
||||
# GPU Stats via NVML (accurate for CTranslate2/faster-whisper)
|
||||
vram_percent = 0.0
|
||||
vram_mb = 0.0
|
||||
if self.nvml_available and self.gpu_handle:
|
||||
try:
|
||||
import pynvml
|
||||
mem_info = pynvml.nvmlDeviceGetMemoryInfo(self.gpu_handle)
|
||||
vram_mb = mem_info.used / (1024 * 1024)
|
||||
vram_percent = (mem_info.used / mem_info.total * 100) if mem_info.total > 0 else 0.0
|
||||
except: pass
|
||||
|
||||
self.stats_ready.emit(
|
||||
round(cpu, 1),
|
||||
round(ram_mb, 1),
|
||||
round(vram_mb, 1),
|
||||
round(vram_percent, 1)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Sleep that checks running flag more frequently
|
||||
for _ in range(10):
|
||||
if not self.running: break
|
||||
time.sleep(0.1)
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
# Cleanup NVML
|
||||
if self.nvml_available:
|
||||
try:
|
||||
import pynvml
|
||||
pynvml.nvmlShutdown()
|
||||
except: pass
|
||||
self.quit()
|
||||
self.wait(2000) # Wait up to 2 seconds for clean exit
|
||||
|
||||
class UIBridge(QObject):
|
||||
"""
|
||||
Main controller exposed to QML.
|
||||
"""
|
||||
# Signals for QML to listen to
|
||||
statusTextChanged = Signal(str)
|
||||
amplitudeChanged = Signal(float)
|
||||
isRecordingChanged = Signal(bool)
|
||||
isProcessingChanged = Signal(bool)
|
||||
hotkeysEnabledChanged = Signal(bool)
|
||||
isDownloadingChanged = Signal(bool)
|
||||
uiScaleChanged = Signal(float)
|
||||
appCpuChanged = Signal(float)
|
||||
appRamMbChanged = Signal(float)
|
||||
appVramMbChanged = Signal(float)
|
||||
appVramPercentChanged = Signal(float)
|
||||
downloadProgressChanged = Signal(float)
|
||||
loaderStatusChanged = Signal(str)
|
||||
toggleRecordingRequested = Signal()
|
||||
downloadRequested = Signal(str) # model name
|
||||
logAppended = Signal(str) # Emits new log line
|
||||
settingChanged = Signal(str, 'QVariant')
|
||||
modelStatesChanged = Signal() # Notify UI to re-check isModelDownloaded
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._status_text = "Ready"
|
||||
self._amplitude = 0.0
|
||||
self._is_recording = False
|
||||
self._is_processing = False
|
||||
self._download_progress = 0.0
|
||||
self._loader_status = "Scanning models..."
|
||||
self._is_recording = False
|
||||
self._hotkeys_enabled = True
|
||||
self._is_downloading = False
|
||||
self._ui_scale = float(ConfigManager().get("ui_scale"))
|
||||
self._logs = [] # Store last 1000 lines
|
||||
self._app_cpu = 0.0
|
||||
self._app_ram_mb = 0.0
|
||||
self._app_vram_mb = 0.0
|
||||
self._app_vram_percent = 0.0
|
||||
self._is_destroyed = False
|
||||
|
||||
# Start QThread Stats Worker
|
||||
self.stats_worker = StatsWorker()
|
||||
self.stats_worker.stats_ready.connect(self.update_stats_callback)
|
||||
self.stats_worker.start()
|
||||
|
||||
# Cleanup on destruction
|
||||
self.destroyed.connect(self._handle_destruction)
|
||||
|
||||
def _handle_destruction(self, obj=None):
|
||||
self._is_destroyed = True
|
||||
# Explicitly disconnect signal BEFORE stopping the worker
|
||||
if hasattr(self, 'stats_worker'):
|
||||
try:
|
||||
self.stats_worker.stats_ready.disconnect(self.update_stats_callback)
|
||||
except: pass
|
||||
self.stats_worker.stop()
|
||||
|
||||
@Property(bool, notify=hotkeysEnabledChanged)
|
||||
def hotkeysEnabled(self):
|
||||
return self._hotkeys_enabled
|
||||
|
||||
@hotkeysEnabled.setter
|
||||
def hotkeysEnabled(self, val):
|
||||
if self._hotkeys_enabled != val:
|
||||
self._hotkeys_enabled = val
|
||||
self.hotkeysEnabledChanged.emit(val)
|
||||
|
||||
@Slot(str)
|
||||
def append_log(self, line):
|
||||
self._logs.append(line)
|
||||
if len(self._logs) > 1000:
|
||||
self._logs.pop(0)
|
||||
self.logAppended.emit(line)
|
||||
|
||||
@Property(str, notify=logAppended) # Simple full text getter
|
||||
def allLogs(self):
|
||||
return "\n".join(self._logs)
|
||||
|
||||
|
||||
@Slot(result=None)
|
||||
def toggle_recording(self):
|
||||
"""Called by UI elements to trigger the app's recording logic."""
|
||||
# Removed debug prints for cleaner logs
|
||||
self.toggleRecordingRequested.emit()
|
||||
|
||||
# --- Properties ---
|
||||
|
||||
@Property(float, notify=downloadProgressChanged)
|
||||
def downloadProgress(self): return self._download_progress
|
||||
|
||||
@downloadProgress.setter
|
||||
def downloadProgress(self, val):
|
||||
self._download_progress = val
|
||||
self.downloadProgressChanged.emit(val)
|
||||
|
||||
@Property(str, notify=loaderStatusChanged)
|
||||
def loaderStatus(self): return self._loader_status
|
||||
|
||||
@loaderStatus.setter
|
||||
def loaderStatus(self, val):
|
||||
self._loader_status = val
|
||||
self.loaderStatusChanged.emit(val)
|
||||
|
||||
@Property(str, notify=statusTextChanged)
|
||||
def statusText(self):
|
||||
return self._status_text
|
||||
|
||||
@statusText.setter
|
||||
def statusText(self, val):
|
||||
if self._status_text != val:
|
||||
self._status_text = val
|
||||
self.statusTextChanged.emit(val)
|
||||
|
||||
@Property(float, notify=amplitudeChanged)
|
||||
def amplitude(self):
|
||||
return self._amplitude
|
||||
|
||||
@amplitude.setter
|
||||
def amplitude(self, val):
|
||||
if self._amplitude != val:
|
||||
self._amplitude = val
|
||||
self.amplitudeChanged.emit(val)
|
||||
|
||||
@Property(bool, notify=isRecordingChanged)
|
||||
def isRecording(self):
|
||||
return self._is_recording
|
||||
|
||||
@isRecording.setter
|
||||
def isRecording(self, val):
|
||||
if self._is_recording != val:
|
||||
self._is_recording = val
|
||||
self.isRecordingChanged.emit(val)
|
||||
|
||||
@Property(bool, notify=isProcessingChanged)
|
||||
def isProcessing(self):
|
||||
return self._is_processing
|
||||
|
||||
@isProcessing.setter
|
||||
def isProcessing(self, val):
|
||||
if self._is_processing != val:
|
||||
self._is_processing = val
|
||||
self.isProcessingChanged.emit(val)
|
||||
|
||||
# --- Methods called from Python logic ---
|
||||
|
||||
@Slot(str)
|
||||
def update_status(self, text):
|
||||
self.statusText = text
|
||||
|
||||
@Slot(float)
|
||||
def update_amplitude(self, amp):
|
||||
self.amplitude = amp
|
||||
|
||||
# --- Methods called from QML ---
|
||||
|
||||
@Slot(str, result='QVariant')
|
||||
def getSetting(self, key):
|
||||
from src.core.config import ConfigManager
|
||||
return ConfigManager().get(key)
|
||||
|
||||
@Slot(str, 'QVariant')
|
||||
def setSetting(self, key, value):
|
||||
from src.core.config import ConfigManager
|
||||
ConfigManager().set(key, value)
|
||||
if key == "ui_scale":
|
||||
self.uiScale = float(value)
|
||||
self.settingChanged.emit(key, value) # Notify listeners (e.g. Overlay)
|
||||
|
||||
@Property(float, notify=uiScaleChanged)
|
||||
def uiScale(self): return self._ui_scale
|
||||
|
||||
@uiScale.setter
|
||||
def uiScale(self, val):
|
||||
if self._ui_scale != val:
|
||||
self._ui_scale = val
|
||||
self.uiScaleChanged.emit(val)
|
||||
|
||||
@Property(float, notify=appCpuChanged)
|
||||
def appCpu(self): return self._app_cpu
|
||||
|
||||
@Property(float, notify=appRamMbChanged)
|
||||
def appRamMb(self): return self._app_ram_mb
|
||||
|
||||
@Property(float, notify=appVramMbChanged)
|
||||
def appVramMb(self): return self._app_vram_mb
|
||||
|
||||
@Property(float, notify=appVramPercentChanged)
|
||||
def appVramPercent(self): return self._app_vram_percent
|
||||
|
||||
@Slot(float, float, float, float)
|
||||
def update_stats_callback(self, cpu, ram_mb, vram_mb, vram_p):
|
||||
"""Called from the background StatsWorker thread."""
|
||||
# Root-level try-except to catch any decorator/runtime errors during shutdown
|
||||
try:
|
||||
if self._is_destroyed or not isValid(self):
|
||||
return
|
||||
|
||||
if self._app_cpu != cpu:
|
||||
self._app_cpu = cpu
|
||||
self.appCpuChanged.emit(cpu)
|
||||
|
||||
if self._app_ram_mb != ram_mb:
|
||||
self._app_ram_mb = ram_mb
|
||||
self.appRamMbChanged.emit(ram_mb)
|
||||
|
||||
if self._app_vram_mb != vram_mb:
|
||||
self._app_vram_mb = vram_mb
|
||||
self.appVramMbChanged.emit(vram_mb)
|
||||
|
||||
if self._app_vram_percent != vram_p:
|
||||
self._app_vram_percent = vram_p
|
||||
self.appVramPercentChanged.emit(vram_p)
|
||||
except:
|
||||
pass
|
||||
|
||||
@Slot(result='QVariantList')
|
||||
def getAudioDevices(self):
|
||||
"""Returns a list of audio input devices. Cached to prevent UI blocking."""
|
||||
print("[Bridge] getAudioDevices called.")
|
||||
if hasattr(self, '_cached_devices'):
|
||||
print(f"[Bridge] Returning cached devices ({len(self._cached_devices)} found)")
|
||||
return self._cached_devices
|
||||
|
||||
# If we are here, it's the first load.
|
||||
# Since QML expects a return value immediately, we return a placeholder or empty list
|
||||
# and start a thread to populate it for next time (or emission).
|
||||
# However, for a settings menu, "loading..." is better than blocking.
|
||||
# But to properly fix the 5s block, we MUST NOT import sounddevice on the main thread if it's slow.
|
||||
|
||||
print("[Bridge] No cached devices yet! Returning empty list.")
|
||||
return []
|
||||
|
||||
def preload_audio_devices(self):
|
||||
"""Called on startup in a worker thread."""
|
||||
try:
|
||||
import sounddevice as sd
|
||||
devices = []
|
||||
device_list = sd.query_devices()
|
||||
for i, dev in enumerate(device_list):
|
||||
if dev['max_input_channels'] > 0:
|
||||
devices.append({"id": i, "name": dev['name']})
|
||||
self._cached_devices = devices
|
||||
logging.info(f"Audio devices preloaded: {len(devices)} found")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to preload audio devices: {e}")
|
||||
|
||||
@Slot()
|
||||
def toggle_recording(self):
|
||||
"""Called by UI elements to trigger the app's recording logic."""
|
||||
# This will be connected to the main app's toggle logic
|
||||
pass
|
||||
@Property(bool, notify=isDownloadingChanged)
|
||||
def isDownloading(self): return self._is_downloading
|
||||
|
||||
@isDownloading.setter
|
||||
def isDownloading(self, val):
|
||||
if self._is_downloading != val:
|
||||
self._is_downloading = val
|
||||
self.isDownloadingChanged.emit(val)
|
||||
|
||||
@Slot(str, result=bool)
|
||||
def isModelDownloaded(self, size):
|
||||
if not size: return False
|
||||
|
||||
try:
|
||||
from src.core.paths import get_models_path
|
||||
# Check new simple format used by DownloadWorker
|
||||
path_simple = get_models_path() / f"faster-whisper-{size}"
|
||||
if path_simple.exists() and any(path_simple.iterdir()):
|
||||
return True
|
||||
|
||||
# Check HF Cache format (legacy/default)
|
||||
folder_name = f"models--Systran--faster-whisper-{size}"
|
||||
path_hf = get_models_path() / folder_name
|
||||
snapshots = path_hf / "snapshots"
|
||||
if snapshots.exists() and any(snapshots.iterdir()):
|
||||
return True
|
||||
|
||||
# Check direct folder (simple)
|
||||
path_direct = get_models_path() / size
|
||||
if (path_direct / "config.json").exists():
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error checking model status: {e}")
|
||||
|
||||
return False
|
||||
|
||||
@Slot(str)
|
||||
def downloadModel(self, size):
|
||||
self.downloadRequested.emit(size)
|
||||
|
||||
@Slot()
|
||||
def notifyModelStatesChanged(self):
|
||||
self.modelStatesChanged.emit()
|
||||
210
src/ui/components.py
Normal file
210
src/ui/components.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
Modern Components Library.
|
||||
==========================
|
||||
|
||||
Contains custom-painted widgets that move beyond the standard 'amateur' Qt look.
|
||||
Implements smooth animations, hardware acceleration, and glassmorphism.
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QPushButton, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QLabel, QGraphicsDropShadowEffect, QFrame, QAbstractButton
|
||||
)
|
||||
from PySide6.QtCore import Qt, QPropertyAnimation, QEasingCurve, Property, QRect, QPoint, Signal, Slot
|
||||
from PySide6.QtGui import QPainter, QColor, QBrush, QPen, QLinearGradient, QFont
|
||||
|
||||
from src.ui.styles import Theme
|
||||
|
||||
class GlassButton(QPushButton):
|
||||
"""A premium button with gradient hover effects and smooth scaling."""
|
||||
|
||||
def __init__(self, text, parent=None, accent_color=Theme.ACCENT_CYAN):
|
||||
super().__init__(text, parent)
|
||||
self.accent = QColor(accent_color)
|
||||
self.setCursor(Qt.PointingHandCursor)
|
||||
self.setFixedHeight(40)
|
||||
self._hover_opacity = 0.0
|
||||
|
||||
self.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid {Theme.BORDER_SUBTLE};
|
||||
color: {Theme.TEXT_SECONDARY};
|
||||
border-radius: 8px;
|
||||
padding: 0 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}}
|
||||
""")
|
||||
|
||||
# Hover Animation
|
||||
self.anim = QPropertyAnimation(self, b"hover_opacity")
|
||||
self.anim.setDuration(200)
|
||||
self.anim.setStartValue(0.0)
|
||||
self.anim.setEndValue(1.0)
|
||||
self.anim.setEasingCurve(QEasingCurve.OutCubic)
|
||||
|
||||
@Property(float)
|
||||
def hover_opacity(self): return self._hover_opacity
|
||||
|
||||
@hover_opacity.setter
|
||||
def hover_opacity(self, value):
|
||||
self._hover_opacity = value
|
||||
self.update()
|
||||
|
||||
def enterEvent(self, event):
|
||||
self.anim.setDirection(QPropertyAnimation.Forward)
|
||||
self.anim.start()
|
||||
super().enterEvent(event)
|
||||
|
||||
def leaveEvent(self, event):
|
||||
self.anim.setDirection(QPropertyAnimation.Backward)
|
||||
self.anim.start()
|
||||
super().leaveEvent(event)
|
||||
|
||||
def paintEvent(self, event):
|
||||
"""Custom paint for the glow effect."""
|
||||
super().paintEvent(event)
|
||||
if self._hover_opacity > 0:
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
|
||||
# Subtle Glow Border
|
||||
color = QColor(self.accent)
|
||||
color.setAlphaF(self._hover_opacity * 0.5)
|
||||
painter.setPen(QPen(color, 1.5))
|
||||
painter.setBrush(Qt.NoBrush)
|
||||
painter.drawRoundedRect(self.rect().adjusted(1,1,-1,-1), 8, 8)
|
||||
|
||||
# Text Glow color shift
|
||||
self.setStyleSheet(f"""
|
||||
QPushButton {{
|
||||
background-color: rgba(255, 255, 255, {0.05 + (self._hover_opacity * 0.05)});
|
||||
border: 1px solid {Theme.BORDER_SUBTLE};
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
padding: 0 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}}
|
||||
""")
|
||||
|
||||
class ModernSwitch(QAbstractButton):
|
||||
"""A sleek iOS-style toggle switch."""
|
||||
|
||||
def __init__(self, parent=None, active_color=Theme.ACCENT_GREEN):
|
||||
super().__init__(parent)
|
||||
self.setCheckable(True)
|
||||
self.setFixedSize(44, 24)
|
||||
self._thumb_pos = 3.0
|
||||
self.active_color = QColor(active_color)
|
||||
|
||||
self.anim = QPropertyAnimation(self, b"thumb_pos")
|
||||
self.anim.setDuration(200)
|
||||
self.anim.setEasingCurve(QEasingCurve.InOutCubic)
|
||||
|
||||
@Property(float)
|
||||
def thumb_pos(self): return self._thumb_pos
|
||||
|
||||
@thumb_pos.setter
|
||||
def thumb_pos(self, value):
|
||||
self._thumb_pos = value
|
||||
self.update()
|
||||
|
||||
def nextCheckState(self):
|
||||
super().nextCheckState()
|
||||
self.anim.stop()
|
||||
if self.isChecked():
|
||||
self.anim.setEndValue(23.0)
|
||||
else:
|
||||
self.anim.setEndValue(3.0)
|
||||
self.anim.start()
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
|
||||
# Background
|
||||
bg_color = QColor("#2d2d3d")
|
||||
if self.isChecked():
|
||||
bg_color = self.active_color
|
||||
|
||||
painter.setBrush(bg_color)
|
||||
painter.setPen(Qt.NoPen)
|
||||
painter.drawRoundedRect(self.rect(), 12, 12)
|
||||
|
||||
# Thumb
|
||||
painter.setBrush(Qt.white)
|
||||
painter.drawEllipse(QPoint(self._thumb_pos + 9, 12), 9, 9)
|
||||
|
||||
class ModernFrame(QFrame):
|
||||
"""A base frame with rounded corners and a shadow."""
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("premiumFrame")
|
||||
self.setStyleSheet(f"""
|
||||
#premiumFrame {{
|
||||
background-color: {Theme.BG_CARD};
|
||||
border: 1px solid {Theme.BORDER_SUBTLE};
|
||||
border-radius: 12px;
|
||||
}}
|
||||
""")
|
||||
|
||||
self.shadow = QGraphicsDropShadowEffect(self)
|
||||
self.shadow.setBlurRadius(25)
|
||||
self.shadow.setXOffset(0)
|
||||
self.shadow.setYOffset(8)
|
||||
self.shadow.setColor(QColor(0, 0, 0, 180))
|
||||
self.setGraphicsEffect(self.shadow)
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QPushButton, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QLabel, QGraphicsDropShadowEffect, QFrame, QAbstractButton, QSlider
|
||||
)
|
||||
|
||||
class ModernSlider(QSlider):
|
||||
"""A custom painted modern slider with a glowing knob."""
|
||||
def __init__(self, orientation=Qt.Horizontal, parent=None):
|
||||
super().__init__(orientation, parent)
|
||||
self.setStyleSheet(f"""
|
||||
QSlider::groove:horizontal {{
|
||||
border: 1px solid {Theme.BG_DARK};
|
||||
height: 4px;
|
||||
background: {Theme.BG_DARK};
|
||||
margin: 2px 0;
|
||||
border-radius: 2px;
|
||||
}}
|
||||
QSlider::handle:horizontal {{
|
||||
background: {Theme.ACCENT_CYAN};
|
||||
border: 2px solid white;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: -7px 0;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
QSlider::add-page:horizontal {{
|
||||
background: {Theme.BG_DARK};
|
||||
}}
|
||||
QSlider::sub-page:horizontal {{
|
||||
background: {Theme.ACCENT_CYAN};
|
||||
border-radius: 2px;
|
||||
}}
|
||||
""")
|
||||
|
||||
class FramelessWindow(QWidget):
|
||||
"""Base class for all premium windows to handle dragging and frameless logic."""
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.NoDropShadowWindowHint)
|
||||
self.setAttribute(Qt.WA_TranslucentBackground)
|
||||
self._drag_pos = None
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.LeftButton:
|
||||
self._drag_pos = event.globalPosition().toPoint() - self.frameGeometry().topLeft()
|
||||
event.accept()
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
if event.buttons() & Qt.LeftButton:
|
||||
self.move(event.globalPosition().toPoint() - self._drag_pos)
|
||||
event.accept()
|
||||
109
src/ui/loader.py
Normal file
109
src/ui/loader.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Loader Widget Module.
|
||||
=====================
|
||||
|
||||
Handles the application initialization and model checks.
|
||||
Refactored for 2026 Premium Aesthetics.
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QProgressBar
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
from PySide6.QtGui import QFont
|
||||
import os
|
||||
import logging
|
||||
from faster_whisper import download_model
|
||||
|
||||
from src.core.paths import get_models_path
|
||||
from src.ui.styles import Theme, StyleGenerator, load_modern_fonts
|
||||
from src.ui.components import FramelessWindow, ModernFrame
|
||||
|
||||
class DownloadWorker(QThread):
|
||||
"""Background worker for model downloads."""
|
||||
progress = Signal(str, int)
|
||||
download_finished = Signal()
|
||||
error = Signal(str)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
model_path = get_models_path()
|
||||
self.progress.emit("Verifying AI Core...", 10)
|
||||
os.environ["HF_HOME"] = str(model_path)
|
||||
|
||||
self.progress.emit("Downloading Model...", 30)
|
||||
download_model("small", output_dir=str(model_path))
|
||||
|
||||
self.progress.emit("System Ready!", 100)
|
||||
self.download_finished.emit()
|
||||
except Exception as e:
|
||||
logging.error(f"Loader failed: {e}")
|
||||
self.error.emit(str(e))
|
||||
|
||||
class LoaderWidget(FramelessWindow):
|
||||
"""
|
||||
Premium bootstrapper UI.
|
||||
Inherits from FramelessWindow for rounded glass look.
|
||||
"""
|
||||
ready_signal = Signal()
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setFixedSize(400, 180)
|
||||
|
||||
# Main Layout
|
||||
self.root = QVBoxLayout(self)
|
||||
self.root.setContentsMargins(10, 10, 10, 10)
|
||||
|
||||
# Glass Card
|
||||
self.card = ModernFrame()
|
||||
self.card.setStyleSheet(StyleGenerator.get_glass_card(radius=20))
|
||||
self.root.addWidget(self.card)
|
||||
|
||||
# Content Layout
|
||||
self.layout = QVBoxLayout(self.card)
|
||||
self.layout.setContentsMargins(30,30,30,30)
|
||||
self.layout.setSpacing(15)
|
||||
|
||||
# App Title/Brand
|
||||
self.brand = QLabel("WHISPER VOICE")
|
||||
self.brand.setFont(load_modern_fonts())
|
||||
self.brand.setStyleSheet(f"color: {Theme.ACCENT_CYAN}; font-weight: 900; letter-spacing: 4px; font-size: 14px;")
|
||||
self.brand.setAlignment(Qt.AlignCenter)
|
||||
self.layout.addWidget(self.brand)
|
||||
|
||||
# Status Label
|
||||
self.status_label = QLabel("INITIALIZING...")
|
||||
self.status_label.setStyleSheet(f"color: {Theme.TEXT_SECONDARY}; font-weight: 600; font-size: 11px;")
|
||||
self.status_label.setAlignment(Qt.AlignCenter)
|
||||
self.layout.addWidget(self.status_label)
|
||||
|
||||
# Progress Bar (Modern Slim style)
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setFixedHeight(4)
|
||||
self.progress_bar.setStyleSheet(f"""
|
||||
QProgressBar {{
|
||||
background-color: {Theme.BG_DARK};
|
||||
border-radius: 2px;
|
||||
border: none;
|
||||
text-align: center;
|
||||
color: transparent;
|
||||
}}
|
||||
QProgressBar::chunk {{
|
||||
background-color: {Theme.ACCENT_CYAN};
|
||||
border-radius: 2px;
|
||||
}}
|
||||
""")
|
||||
self.layout.addWidget(self.progress_bar)
|
||||
|
||||
# Start Worker
|
||||
self.worker = DownloadWorker()
|
||||
self.worker.progress.connect(self.update_progress)
|
||||
self.worker.download_finished.connect(self.on_finished)
|
||||
self.worker.start()
|
||||
|
||||
def update_progress(self, text: str, percent: int):
|
||||
self.status_label.setText(text.upper())
|
||||
self.progress_bar.setValue(percent)
|
||||
|
||||
def on_finished(self):
|
||||
self.ready_signal.emit()
|
||||
self.close()
|
||||
105
src/ui/overlay.py
Normal file
105
src/ui/overlay.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
Overlay Window Module.
|
||||
======================
|
||||
|
||||
Premium High-Fidelity Overlay for Whisper Voice.
|
||||
Features glassmorphism, pulsating status indicators, and smart positioning.
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel
|
||||
from PySide6.QtCore import Qt, Slot, QPoint, QPropertyAnimation, QEasingCurve
|
||||
from PySide6.QtGui import QColor, QFont, QGuiApplication
|
||||
|
||||
from src.ui.visualizer import AudioVisualizer
|
||||
from src.ui.styles import Theme, StyleGenerator, load_modern_fonts
|
||||
from src.ui.components import FramelessWindow, ModernFrame
|
||||
|
||||
class OverlayWindow(FramelessWindow):
|
||||
"""
|
||||
The main transparent overlay (The Pill).
|
||||
Refactored for 2026 Premium Aesthetics.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setFixedSize(320, 95)
|
||||
|
||||
# Main Layout
|
||||
self.master_layout = QVBoxLayout(self)
|
||||
self.master_layout.setContentsMargins(10, 10, 10, 10)
|
||||
|
||||
# The Glass Pill Container
|
||||
self.pill = ModernFrame()
|
||||
self.pill.setStyleSheet(StyleGenerator.get_glass_card(radius=24))
|
||||
self.master_layout.addWidget(self.pill)
|
||||
|
||||
# Layout inside the pill
|
||||
self.layout = QHBoxLayout(self.pill)
|
||||
self.layout.setContentsMargins(20, 10, 20, 10)
|
||||
self.layout.setSpacing(15)
|
||||
|
||||
# Status Visualization (Left Dot)
|
||||
self.status_dot = QWidget()
|
||||
self.status_dot.setFixedSize(14, 14)
|
||||
self.status_dot.setStyleSheet(f"background-color: {Theme.ACCENT_CYAN}; border-radius: 7px; border: 2px solid white;")
|
||||
self.layout.addWidget(self.status_dot)
|
||||
|
||||
# Text/Visualizer Stack
|
||||
self.content_stack = QVBoxLayout()
|
||||
self.content_stack.setSpacing(2)
|
||||
self.content_stack.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.status_label = QLabel("READY")
|
||||
self.status_label.setFont(load_modern_fonts())
|
||||
self.status_label.setStyleSheet(f"color: white; font-weight: 800; font-size: 11px; letter-spacing: 2px;")
|
||||
self.content_stack.addWidget(self.status_label)
|
||||
|
||||
self.visualizer = AudioVisualizer()
|
||||
self.visualizer.setFixedHeight(30)
|
||||
self.content_stack.addWidget(self.visualizer)
|
||||
|
||||
self.layout.addLayout(self.content_stack)
|
||||
|
||||
# Animations
|
||||
self.pulse_timer = None # Use style-based pulsing to avoid window flags issues
|
||||
|
||||
# Initial State
|
||||
self.hide()
|
||||
self.first_show = True
|
||||
|
||||
def showEvent(self, event):
|
||||
"""Handle positioning and config updates."""
|
||||
from src.core.config import ConfigManager
|
||||
config = ConfigManager()
|
||||
self.setWindowOpacity(config.get("opacity"))
|
||||
|
||||
if self.first_show:
|
||||
self.center_above_taskbar()
|
||||
self.first_show = False
|
||||
super().showEvent(event)
|
||||
|
||||
def center_above_taskbar(self):
|
||||
screen = QGuiApplication.primaryScreen()
|
||||
if not screen: return
|
||||
avail_rect = screen.availableGeometry()
|
||||
x = avail_rect.x() + (avail_rect.width() - self.width()) // 2
|
||||
y = avail_rect.bottom() - self.height() - 15
|
||||
self.move(x, y)
|
||||
|
||||
@Slot(str)
|
||||
def update_status(self, text: str):
|
||||
"""Updates the status text and visual indicator."""
|
||||
self.status_label.setText(text.upper())
|
||||
|
||||
if "RECORDING" in text.upper():
|
||||
color = Theme.ACCENT_GREEN
|
||||
elif "THINKING" in text.upper():
|
||||
color = Theme.ACCENT_PURPLE
|
||||
else:
|
||||
color = Theme.ACCENT_CYAN
|
||||
|
||||
self.status_dot.setStyleSheet(f"background-color: {color}; border-radius: 7px; border: 2px solid white;")
|
||||
|
||||
@Slot(float)
|
||||
def update_visualizer(self, amp: float):
|
||||
self.visualizer.set_amplitude(amp)
|
||||
10
src/ui/qml/AUTHORS.txt
Normal file
10
src/ui/qml/AUTHORS.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
# This is the official list of project authors for copyright purposes.
|
||||
# This file is distinct from the CONTRIBUTORS.txt file.
|
||||
# See the latter for an explanation.
|
||||
#
|
||||
# Names should be added to this file as:
|
||||
# Name or Organization <email address>
|
||||
|
||||
JetBrains <>
|
||||
Philipp Nurullin <philipp.nurullin@jetbrains.com>
|
||||
Konstantin Bulenkov <kb@jetbrains.com>
|
||||
47
src/ui/qml/GlowButton.qml
Normal file
47
src/ui/qml/GlowButton.qml
Normal file
@@ -0,0 +1,47 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
|
||||
Button {
|
||||
id: control
|
||||
text: "Button"
|
||||
|
||||
property color accentColor: "#00f2ff"
|
||||
|
||||
contentItem: Text {
|
||||
text: control.text
|
||||
font.pixelSize: 13
|
||||
font.bold: true
|
||||
color: control.hovered ? "white" : "#9499b0"
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 200 } }
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
implicitWidth: 100
|
||||
implicitHeight: 40
|
||||
opacity: control.down ? 0.7 : 1.0
|
||||
color: control.hovered ? Qt.rgba(1, 1, 1, 0.1) : Qt.rgba(1, 1, 1, 0.05)
|
||||
radius: 8
|
||||
border.color: control.hovered ? control.accentColor : Qt.rgba(1, 1, 1, 0.1)
|
||||
border.width: 1
|
||||
|
||||
Behavior on border.color { ColorAnimation { duration: 200 } }
|
||||
Behavior on color { ColorAnimation { duration: 200 } }
|
||||
|
||||
// Glow effect
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: parent.radius
|
||||
color: "transparent"
|
||||
border.color: control.accentColor
|
||||
border.width: 2
|
||||
opacity: control.hovered ? 0.5 : 0.0
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on opacity { NumberAnimation { duration: 300 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
src/ui/qml/JetBrainsMono.zip
Normal file
BIN
src/ui/qml/JetBrainsMono.zip
Normal file
Binary file not shown.
186
src/ui/qml/Loader.qml
Normal file
186
src/ui/qml/Loader.qml
Normal file
@@ -0,0 +1,186 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Effects
|
||||
|
||||
ApplicationWindow {
|
||||
id: root
|
||||
|
||||
FontLoader {
|
||||
id: jetBrainsMono
|
||||
source: "fonts/ttf/JetBrainsMono-Bold.ttf"
|
||||
}
|
||||
width: 400
|
||||
height: 250
|
||||
visible: true
|
||||
flags: Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool
|
||||
color: "transparent"
|
||||
|
||||
Rectangle {
|
||||
id: bgRect
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20 // Space for shadow
|
||||
radius: 16
|
||||
color: "#1a1a20"
|
||||
border.color: "#40ffffff"
|
||||
border.width: 1
|
||||
|
||||
// --- SHADOW & GLOW ---
|
||||
layer.enabled: true
|
||||
layer.effect: MultiEffect {
|
||||
shadowEnabled: true
|
||||
shadowColor: "#80000000"
|
||||
shadowBlur: 1.0
|
||||
shadowOpacity: 0.8
|
||||
shadowVerticalOffset: 4
|
||||
autoPaddingEnabled: true
|
||||
}
|
||||
|
||||
// --- CONTENT ---
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 60
|
||||
spacing: 24
|
||||
|
||||
// Logo / Icon Area
|
||||
Item {
|
||||
width: 60; height: 60
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: 30
|
||||
color: "transparent"
|
||||
border.width: 2
|
||||
border.color: "#00f2ff"
|
||||
|
||||
// Pulse Animation
|
||||
SequentialAnimation on scale {
|
||||
loops: Animation.Infinite
|
||||
NumberAnimation { from: 1.0; to: 1.1; duration: 1000; easing.type: Easing.InOutSine }
|
||||
NumberAnimation { from: 1.1; to: 1.0; duration: 1000; easing.type: Easing.InOutSine }
|
||||
}
|
||||
}
|
||||
|
||||
Image {
|
||||
source: "microphone.svg"
|
||||
anchors.centerIn: parent
|
||||
width: 32; height: 32
|
||||
sourceSize: Qt.size(32, 32)
|
||||
fillMode: Image.PreserveAspectFit
|
||||
smooth: true
|
||||
|
||||
// Colorize
|
||||
layer.enabled: true
|
||||
layer.effect: MultiEffect {
|
||||
colorization: 1.0
|
||||
colorizationColor: "#00f2ff"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Title
|
||||
Column {
|
||||
spacing: 4
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
Text {
|
||||
text: "WHISPER VOICE"
|
||||
color: "#ffffff"
|
||||
font.family: jetBrainsMono.name
|
||||
font.pixelSize: 18
|
||||
font.bold: true
|
||||
font.letterSpacing: 3
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "AI TRANSCRIPTION ENGINE"
|
||||
color: "#80ffffff"
|
||||
font.family: jetBrainsMono.name
|
||||
font.pixelSize: 10
|
||||
font.letterSpacing: 2
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
}
|
||||
}
|
||||
|
||||
// Status & Progress
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
|
||||
// Progress Bar Background
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 4
|
||||
color: "#252530"
|
||||
radius: 2
|
||||
clip: true
|
||||
|
||||
// Progress Fill
|
||||
Rectangle {
|
||||
width: (ui.downloadProgress / 100.0) * parent.width
|
||||
height: parent.height
|
||||
radius: 2
|
||||
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: "#00f2ff" }
|
||||
GradientStop { position: 1.0; color: "#00a0ff" }
|
||||
}
|
||||
|
||||
Behavior on width {
|
||||
NumberAnimation { duration: 250; easing.type: Easing.OutCubic }
|
||||
}
|
||||
|
||||
// Shimmer effect on bar
|
||||
Rectangle {
|
||||
width: 20; height: parent.height
|
||||
color: "#80ffffff"
|
||||
x: -width
|
||||
opacity: 0.5
|
||||
rotation: 20
|
||||
transformOrigin: Item.Center
|
||||
|
||||
NumberAnimation on x {
|
||||
from: 0; to: parent.width + 50
|
||||
duration: 1000
|
||||
loops: Animation.Infinite
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Status Text
|
||||
Text {
|
||||
text: ui.loaderStatus
|
||||
color: "#00f2ff"
|
||||
font.family: jetBrainsMono.name
|
||||
font.pixelSize: 11
|
||||
font.bold: true
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
opacity: 0.8
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Entry Animation
|
||||
Component.onCompleted: {
|
||||
bgRect.scale = 0.9
|
||||
bgRect.opacity = 0
|
||||
entryAnim.start()
|
||||
}
|
||||
|
||||
ParallelAnimation {
|
||||
id: entryAnim
|
||||
|
||||
NumberAnimation {
|
||||
target: bgRect; property: "scale"
|
||||
to: 1.0; duration: 600; easing.type: Easing.OutBack
|
||||
}
|
||||
NumberAnimation {
|
||||
target: bgRect; property: "opacity"
|
||||
to: 1.0; duration: 400
|
||||
}
|
||||
}
|
||||
}
|
||||
127
src/ui/qml/ModernComboBox.qml
Normal file
127
src/ui/qml/ModernComboBox.qml
Normal file
@@ -0,0 +1,127 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
ComboBox {
|
||||
id: control
|
||||
|
||||
// Custom properties
|
||||
property color accentColor: SettingsStyle.accent
|
||||
property color bgColor: "#1a1a20"
|
||||
property color popupColor: "#252530"
|
||||
|
||||
delegate: ItemDelegate {
|
||||
id: delegate
|
||||
width: control.width
|
||||
height: 40
|
||||
padding: 0
|
||||
|
||||
contentItem: RowLayout {
|
||||
spacing: 8
|
||||
width: parent.width
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 10
|
||||
|
||||
Text {
|
||||
text: control.textRole ? (modelData[control.textRole] || modelData) : modelData
|
||||
color: highlighted ? control.accentColor : "#ffffff"
|
||||
font.family: "JetBrains Mono"
|
||||
font.pixelSize: 14
|
||||
elide: Text.ElideRight
|
||||
Layout.fillWidth: true
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
scale: highlighted ? 1.05 : 1.0
|
||||
Behavior on scale { NumberAnimation { duration: 100 } }
|
||||
}
|
||||
|
||||
// Indicator for "Downloaded" or "Active"
|
||||
Rectangle {
|
||||
width: 6; height: 6; radius: 3
|
||||
color: control.accentColor
|
||||
visible: ui.isModelDownloaded(modelData)
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
color: highlighted ? Qt.rgba(control.accentColor.r, control.accentColor.g, control.accentColor.b, 0.1) : "transparent"
|
||||
radius: 4
|
||||
Behavior on color { ColorAnimation { duration: 100 } }
|
||||
}
|
||||
}
|
||||
|
||||
indicator: Canvas {
|
||||
x: control.width - width - control.rightPadding
|
||||
y: control.topPadding + (control.availableHeight - height) / 2
|
||||
width: 12
|
||||
height: 8
|
||||
contextType: "2d"
|
||||
|
||||
Connections {
|
||||
target: control
|
||||
function onPressedChanged() { control.indicator.requestPaint() }
|
||||
}
|
||||
|
||||
onPaint: {
|
||||
context.reset();
|
||||
context.moveTo(0, 0);
|
||||
context.lineTo(width, 0);
|
||||
context.lineTo(width / 2, height);
|
||||
context.closePath();
|
||||
context.fillStyle = control.pressed ? control.accentColor : "#888888";
|
||||
context.fill();
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: Text {
|
||||
leftPadding: 10
|
||||
rightPadding: control.indicator.width + control.spacing
|
||||
|
||||
text: control.displayText
|
||||
font.family: "JetBrains Mono"
|
||||
font.pixelSize: 14
|
||||
color: control.pressed ? control.accentColor : "#ffffff"
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
implicitWidth: 140
|
||||
implicitHeight: 40
|
||||
color: control.bgColor
|
||||
border.color: control.pressed || control.activeFocus ? control.accentColor : "#40ffffff"
|
||||
border.width: 1
|
||||
radius: 6
|
||||
|
||||
// Glow effect on focus (Simplified to just border for stability)
|
||||
Behavior on border.color { ColorAnimation { duration: 150 } }
|
||||
}
|
||||
|
||||
popup: Popup {
|
||||
y: control.height - 1
|
||||
width: control.width
|
||||
implicitHeight: contentItem.implicitHeight
|
||||
padding: 5
|
||||
|
||||
contentItem: ListView {
|
||||
clip: true
|
||||
implicitHeight: contentHeight
|
||||
model: control.popup.visible ? control.delegateModel : null
|
||||
currentIndex: control.highlightedIndex
|
||||
|
||||
ScrollIndicator.vertical: ScrollIndicator { }
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
color: control.popupColor
|
||||
border.color: "#40ffffff"
|
||||
border.width: 1
|
||||
radius: 6
|
||||
}
|
||||
|
||||
enter: Transition {
|
||||
NumberAnimation { property: "opacity"; from: 0.0; to: 1.0; duration: 100 }
|
||||
NumberAnimation { property: "scale"; from: 0.95; to: 1.0; duration: 100 }
|
||||
}
|
||||
}
|
||||
}
|
||||
111
src/ui/qml/ModernKeySequenceRecorder.qml
Normal file
111
src/ui/qml/ModernKeySequenceRecorder.qml
Normal file
@@ -0,0 +1,111 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
|
||||
Rectangle {
|
||||
id: control
|
||||
implicitWidth: 140
|
||||
implicitHeight: 32
|
||||
color: "#1a1a20"
|
||||
radius: 6
|
||||
border.width: 1
|
||||
border.color: activeFocus || recording ? SettingsStyle.accent : "#40ffffff"
|
||||
|
||||
property string currentSequence: ""
|
||||
signal sequenceChanged(string seq)
|
||||
|
||||
property bool recording: false
|
||||
|
||||
onRecordingChanged: {
|
||||
if (recording) {
|
||||
ui.hotkeysEnabled = false
|
||||
} else {
|
||||
ui.hotkeysEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: control.recording ? "Listening..." : (control.currentSequence || "None")
|
||||
color: control.recording ? SettingsStyle.accent : (control.currentSequence ? "#ffffff" : "#808080")
|
||||
font.family: "JetBrains Mono"
|
||||
font.pixelSize: 13
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
control.forceActiveFocus()
|
||||
control.recording = true
|
||||
}
|
||||
}
|
||||
|
||||
Keys.onPressed: (event) => {
|
||||
if (!control.recording) return
|
||||
|
||||
// Ignore specific standalone modifiers to allow combos
|
||||
if (event.key === Qt.Key_Control || event.key === Qt.Key_Shift || event.key === Qt.Key_Alt || event.key === Qt.Key_Meta) {
|
||||
return
|
||||
}
|
||||
|
||||
// Build Modifier String
|
||||
var seq = ""
|
||||
if (event.modifiers & Qt.ControlModifier) seq += "ctrl+"
|
||||
if (event.modifiers & Qt.ShiftModifier) seq += "shift+"
|
||||
if (event.modifiers & Qt.AltModifier) seq += "alt+"
|
||||
if (event.modifiers & Qt.MetaModifier) seq += "win+"
|
||||
|
||||
// Get Key Name
|
||||
var keyName = getKeyName(event.key, event.text)
|
||||
seq += keyName
|
||||
|
||||
// Update
|
||||
control.currentSequence = seq
|
||||
control.sequenceChanged(seq)
|
||||
control.recording = false
|
||||
event.accepted = true
|
||||
}
|
||||
|
||||
onActiveFocusChanged: {
|
||||
if (!activeFocus) control.recording = false
|
||||
}
|
||||
|
||||
function getKeyName(key, text) {
|
||||
// F-Keys
|
||||
if (key >= Qt.Key_F1 && key <= Qt.Key_F35) return "f" + (key - Qt.Key_F1 + 1)
|
||||
|
||||
// Common Keys
|
||||
switch (key) {
|
||||
case Qt.Key_Space: return "space"
|
||||
case Qt.Key_Backspace: return "backspace"
|
||||
case Qt.Key_Tab: return "tab"
|
||||
case Qt.Key_Return: return "enter"
|
||||
case Qt.Key_Enter: return "enter"
|
||||
case Qt.Key_Escape: return "esc"
|
||||
case Qt.Key_Delete: return "delete"
|
||||
case Qt.Key_Insert: return "insert"
|
||||
case Qt.Key_Home: return "home"
|
||||
case Qt.Key_End: return "end"
|
||||
case Qt.Key_PageUp: return "pageup"
|
||||
case Qt.Key_PageDown: return "pagedown"
|
||||
case Qt.Key_Up: return "up"
|
||||
case Qt.Key_Down: return "down"
|
||||
case Qt.Key_Left: return "left"
|
||||
case Qt.Key_Right: return "right"
|
||||
case Qt.Key_CapsLock: return "capslock"
|
||||
case Qt.Key_NumLock: return "numlock"
|
||||
case Qt.Key_ScrollLock: return "scrolllock"
|
||||
case Qt.Key_Print: return "printscreen"
|
||||
case Qt.Key_Pause: return "pause"
|
||||
}
|
||||
|
||||
// Letters and Numbers (Use text if available, fallback to manual)
|
||||
if (text && text.length > 0 && text.charCodeAt(0) >= 32) {
|
||||
return text.toLowerCase()
|
||||
}
|
||||
|
||||
return "key" + key
|
||||
}
|
||||
}
|
||||
92
src/ui/qml/ModernSettingsItem.qml
Normal file
92
src/ui/qml/ModernSettingsItem.qml
Normal file
@@ -0,0 +1,92 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Controls
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: SettingsStyle.itemHeight
|
||||
Layout.minimumHeight: SettingsStyle.itemHeight
|
||||
Layout.maximumHeight: SettingsStyle.itemHeight
|
||||
implicitHeight: SettingsStyle.itemHeight
|
||||
|
||||
color: hHandler.hovered ? SettingsStyle.surfaceHover : "transparent"
|
||||
radius: 0 // Continuous list style (clipped by container container)
|
||||
|
||||
// Properties
|
||||
property string label: "Setting Name"
|
||||
property string description: ""
|
||||
property alias control: controlContainer.data
|
||||
property bool showSeparator: true
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
|
||||
HoverHandler {
|
||||
id: hHandler
|
||||
}
|
||||
|
||||
// Label & Description
|
||||
Column {
|
||||
id: labelCol
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 16
|
||||
anchors.right: controlContainer.left
|
||||
anchors.rightMargin: 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
id: labelText
|
||||
text: root.label
|
||||
color: SettingsStyle.textPrimary
|
||||
font.family: "JetBrains Mono"
|
||||
font.pixelSize: 13
|
||||
font.weight: Font.Medium
|
||||
width: parent.width
|
||||
elide: Text.ElideRight
|
||||
|
||||
// Text Layout Normalization
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
topPadding: 0
|
||||
bottomPadding: 0
|
||||
}
|
||||
|
||||
Text {
|
||||
id: descText
|
||||
text: root.description
|
||||
color: SettingsStyle.textSecondary
|
||||
font.family: "JetBrains Mono"
|
||||
font.pixelSize: 11
|
||||
width: parent.width
|
||||
visible: text !== ""
|
||||
wrapMode: Text.WordWrap
|
||||
|
||||
// Text Layout Normalization
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
topPadding: 0
|
||||
bottomPadding: 0
|
||||
}
|
||||
}
|
||||
|
||||
// Control Container
|
||||
Item {
|
||||
id: controlContainer
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
implicitWidth: childrenRect.width
|
||||
implicitHeight: childrenRect.height
|
||||
}
|
||||
|
||||
// Bottom Separator
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: 16
|
||||
anchors.rightMargin: 16
|
||||
height: 1
|
||||
color: SettingsStyle.borderSubtle
|
||||
visible: root.showSeparator
|
||||
}
|
||||
}
|
||||
61
src/ui/qml/ModernSettingsSection.qml
Normal file
61
src/ui/qml/ModernSettingsSection.qml
Normal file
@@ -0,0 +1,61 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Effects
|
||||
|
||||
ColumnLayout {
|
||||
id: root
|
||||
spacing: 8
|
||||
|
||||
default property alias content: contentColumn.data
|
||||
property string title: ""
|
||||
|
||||
// Section Header
|
||||
Text {
|
||||
text: root.title
|
||||
color: SettingsStyle.accent // Accented header for more color
|
||||
font.family: "JetBrains Mono"
|
||||
font.pixelSize: 12
|
||||
font.bold: true
|
||||
font.capitalization: Font.AllUppercase
|
||||
font.letterSpacing: 1.5
|
||||
Layout.leftMargin: 4
|
||||
Layout.bottomMargin: 4
|
||||
visible: text !== ""
|
||||
}
|
||||
|
||||
// Card Container
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
// Height checks children
|
||||
implicitHeight: contentColumn.implicitHeight
|
||||
|
||||
color: SettingsStyle.surfaceCard
|
||||
radius: SettingsStyle.cardRadius
|
||||
border.color: SettingsStyle.borderSubtle
|
||||
border.width: 1
|
||||
|
||||
ColumnLayout {
|
||||
id: contentColumn
|
||||
anchors.fill: parent
|
||||
anchors.margins: 1 // Minimal margin to not overlap border
|
||||
spacing: 0 // No gaps, rely on separators
|
||||
|
||||
// Clip content to rounded corners
|
||||
layer.enabled: true
|
||||
layer.effect: MultiEffect {
|
||||
maskEnabled: true
|
||||
maskThresholdMin: 0.5
|
||||
maskSpreadAtMin: 1.0
|
||||
maskSource: ShaderEffectSource {
|
||||
sourceItem: Rectangle {
|
||||
width: contentColumn.width
|
||||
height: contentColumn.height
|
||||
radius: SettingsStyle.cardRadius - 1
|
||||
color: "black"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
src/ui/qml/ModernSlider.qml
Normal file
61
src/ui/qml/ModernSlider.qml
Normal file
@@ -0,0 +1,61 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Effects
|
||||
|
||||
Slider {
|
||||
id: control
|
||||
|
||||
background: Rectangle {
|
||||
x: control.leftPadding
|
||||
y: control.topPadding + control.availableHeight / 2 - height / 2
|
||||
implicitWidth: 200
|
||||
implicitHeight: 4
|
||||
width: control.availableWidth
|
||||
height: implicitHeight
|
||||
radius: 2
|
||||
color: "#2d2d3d"
|
||||
|
||||
Rectangle {
|
||||
width: control.visualPosition * parent.width
|
||||
height: parent.height
|
||||
color: SettingsStyle.accent
|
||||
radius: 2
|
||||
}
|
||||
}
|
||||
|
||||
handle: Rectangle {
|
||||
x: control.leftPadding + control.visualPosition * (control.availableWidth - width)
|
||||
y: control.topPadding + control.availableHeight / 2 - height / 2
|
||||
implicitWidth: 18
|
||||
implicitHeight: 18
|
||||
radius: 9
|
||||
color: "white"
|
||||
border.color: SettingsStyle.accent
|
||||
border.width: 2
|
||||
|
||||
layer.enabled: control.pressed
|
||||
layer.effect: MultiEffect {
|
||||
blurEnabled: true
|
||||
blur: 0.5
|
||||
shadowEnabled: true
|
||||
shadowColor: SettingsStyle.accent
|
||||
}
|
||||
}
|
||||
// Value Readout (Left side to avoid clipping on right edge)
|
||||
Text {
|
||||
anchors.right: parent.left
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
horizontalAlignment: Text.AlignRight
|
||||
|
||||
text: {
|
||||
var val = control.value
|
||||
return (val % 1 === 0) ? val.toFixed(0) : val.toFixed(1)
|
||||
}
|
||||
|
||||
color: SettingsStyle.textSecondary
|
||||
font.family: "JetBrains Mono"
|
||||
font.pixelSize: 12
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
}
|
||||
40
src/ui/qml/ModernSwitch.qml
Normal file
40
src/ui/qml/ModernSwitch.qml
Normal file
@@ -0,0 +1,40 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
|
||||
Switch {
|
||||
id: control
|
||||
|
||||
indicator: Rectangle {
|
||||
implicitWidth: 44
|
||||
implicitHeight: 24
|
||||
x: control.leftPadding
|
||||
y: parent.height / 2 - height / 2
|
||||
radius: 12
|
||||
color: control.checked ? SettingsStyle.accent : "#2d2d3d"
|
||||
border.color: control.checked ? SettingsStyle.accent : "#3d3d4d"
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 200 } }
|
||||
|
||||
Rectangle {
|
||||
x: control.checked ? parent.width - width - 3 : 3
|
||||
y: 3
|
||||
width: 18
|
||||
height: 18
|
||||
radius: 9
|
||||
color: "white"
|
||||
|
||||
Behavior on x {
|
||||
NumberAnimation { duration: 200; easing.type: Easing.InOutQuad }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: Text {
|
||||
text: control.text
|
||||
font: control.font
|
||||
opacity: enabled ? 1.0 : 0.3
|
||||
color: "white"
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
leftPadding: control.indicator.width + control.spacing
|
||||
}
|
||||
}
|
||||
27
src/ui/qml/ModernTextField.qml
Normal file
27
src/ui/qml/ModernTextField.qml
Normal file
@@ -0,0 +1,27 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
|
||||
TextField {
|
||||
id: control
|
||||
|
||||
property color accentColor: "#00f2ff"
|
||||
property color bgColor: "#1a1a20"
|
||||
|
||||
placeholderTextColor: "#606060"
|
||||
color: "#ffffff"
|
||||
font.family: "JetBrains Mono"
|
||||
font.pixelSize: 14
|
||||
selectedTextColor: "#000000"
|
||||
selectionColor: accentColor
|
||||
|
||||
background: Rectangle {
|
||||
implicitWidth: 200
|
||||
implicitHeight: 40
|
||||
color: control.bgColor
|
||||
border.color: control.activeFocus ? control.accentColor : "#40ffffff"
|
||||
border.width: 1
|
||||
radius: 6
|
||||
|
||||
Behavior on border.color { ColorAnimation { duration: 150 } }
|
||||
}
|
||||
}
|
||||
93
src/ui/qml/OFL.txt
Normal file
93
src/ui/qml/OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
353
src/ui/qml/Overlay.qml
Normal file
353
src/ui/qml/Overlay.qml
Normal file
@@ -0,0 +1,353 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Effects
|
||||
import QtQuick.Particles
|
||||
import Qt5Compat.GraphicalEffects
|
||||
|
||||
ApplicationWindow {
|
||||
id: root
|
||||
|
||||
width: 460 * (ui ? ui.uiScale : 1.0)
|
||||
height: 180 * (ui ? ui.uiScale : 1.0)
|
||||
|
||||
visible: true
|
||||
flags: Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool
|
||||
color: "transparent"
|
||||
|
||||
FontLoader {
|
||||
id: jetBrainsMono
|
||||
source: "fonts/ttf/JetBrainsMono-Bold.ttf"
|
||||
}
|
||||
|
||||
property real shimmerPos: -0.5
|
||||
property real windowOpacity: ui.getSetting("opacity")
|
||||
property real uiScale: Number(ui.getSetting("ui_scale")) // Bind Scale
|
||||
|
||||
Connections {
|
||||
target: ui
|
||||
function onSettingChanged(key, value) {
|
||||
if (key === "opacity") windowOpacity = value
|
||||
if (key === "ui_scale") uiScale = Number(value)
|
||||
}
|
||||
}
|
||||
|
||||
// Visibility Logic
|
||||
property bool isActive: ui.isRecording || ui.isProcessing
|
||||
|
||||
SequentialAnimation {
|
||||
running: true
|
||||
loops: Animation.Infinite
|
||||
PauseAnimation { duration: 3000 }
|
||||
NumberAnimation {
|
||||
target: root; property: "shimmerPos"
|
||||
from: -0.5; to: 1.5; duration: 1500
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
|
||||
// Container
|
||||
Item {
|
||||
id: mainContainer
|
||||
width: 380
|
||||
height: 100
|
||||
anchors.centerIn: parent
|
||||
|
||||
// Scale & Opacity Transform
|
||||
scale: root.isActive ? root.uiScale : 0.8
|
||||
opacity: root.isActive ? root.windowOpacity : 0.0
|
||||
visible: opacity > 0.01 // Optimization
|
||||
|
||||
// Motion.dev-like Spring Animation
|
||||
Behavior on scale {
|
||||
SpringAnimation { spring: 3; damping: 0.25; epsilon: 0.005 }
|
||||
}
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: 300; easing.type: Easing.OutCubic }
|
||||
}
|
||||
|
||||
// --- SHADOW ---
|
||||
Item {
|
||||
anchors.fill: bgRect
|
||||
layer.enabled: true
|
||||
layer.effect: MultiEffect {
|
||||
shadowEnabled: true
|
||||
shadowColor: "#A0000000"
|
||||
shadowBlur: 0.4
|
||||
autoPaddingEnabled: true
|
||||
}
|
||||
}
|
||||
|
||||
// --- CHASSIS VISUALS (Hidden Source) ---
|
||||
Item {
|
||||
id: contentSource
|
||||
anchors.fill: bgRect
|
||||
visible: false
|
||||
|
||||
// A. Gradient Background
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0.0; color: "#CC101015" }
|
||||
GradientStop { position: 1.0; color: "#CC050505" }
|
||||
}
|
||||
}
|
||||
|
||||
// B. Animated Gradient Blobs
|
||||
ShaderEffect {
|
||||
anchors.fill: parent
|
||||
opacity: 0.4
|
||||
property real time: 0
|
||||
fragmentShader: "gradient_blobs.qsb"
|
||||
NumberAnimation on time { from: 0; to: 1000; duration: 100000; loops: Animation.Infinite }
|
||||
}
|
||||
|
||||
// C. Glow Shader
|
||||
ShaderEffect {
|
||||
anchors.fill: parent
|
||||
opacity: 0.04
|
||||
property real time: 0
|
||||
property real intensity: ui.amplitude
|
||||
fragmentShader: "glow.qsb"
|
||||
NumberAnimation on time { from: 0; to: 100; duration: 10000; loops: Animation.Infinite }
|
||||
}
|
||||
|
||||
// D. Particles
|
||||
ParticleSystem {
|
||||
id: particles
|
||||
anchors.fill: parent
|
||||
ItemParticle {
|
||||
system: particles
|
||||
delegate: Rectangle { width: 2; height: 2; radius: 1; color: "#10ffffff" }
|
||||
}
|
||||
Emitter {
|
||||
anchors.fill: parent; emitRate: 15; lifeSpan: 4000; size: 4; sizeVariation: 2
|
||||
velocity: AngleDirection { angle: -90; angleVariation: 180; magnitude: 5 }
|
||||
acceleration: PointDirection { y: -2 }
|
||||
}
|
||||
}
|
||||
|
||||
// E. Shimmer
|
||||
Rectangle {
|
||||
width: parent.width * 0.4; height: parent.height * 2.5
|
||||
rotation: 25; anchors.centerIn: parent
|
||||
x: (root.shimmerPos * parent.width * 2) - width; y: -height/4
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: "transparent" }
|
||||
GradientStop { position: 0.5; color: "#15ffffff" }
|
||||
GradientStop { position: 1.0; color: "transparent" }
|
||||
}
|
||||
opacity: 0.8
|
||||
}
|
||||
|
||||
// F. CRT Shader Effect (Overlay on chassis ONLY)
|
||||
ShaderEffect {
|
||||
anchors.fill: parent
|
||||
property real time: 0
|
||||
fragmentShader: "crt.qsb"
|
||||
NumberAnimation on time { from: 0; to: 100; duration: 5000; loops: Animation.Infinite }
|
||||
}
|
||||
}
|
||||
|
||||
// --- MASK ---
|
||||
Rectangle {
|
||||
id: contentMask
|
||||
anchors.fill: bgRect
|
||||
radius: height / 2
|
||||
visible: false; color: "white"
|
||||
smooth: true; antialiasing: true
|
||||
}
|
||||
|
||||
// --- COMPOSITED CHASSIS ---
|
||||
OpacityMask {
|
||||
anchors.fill: bgRect
|
||||
source: contentSource
|
||||
maskSource: contentMask
|
||||
}
|
||||
|
||||
// --- BORDER & INTERACTION ---
|
||||
Rectangle {
|
||||
id: bgRect
|
||||
anchors.fill: parent
|
||||
radius: height / 2
|
||||
color: "transparent"
|
||||
border.width: 1
|
||||
border.color: "#40ffffff"
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent; hoverEnabled: true
|
||||
cursorShape: pressed ? Qt.ClosedHandCursor : Qt.PointingHandCursor
|
||||
onPressed: root.startSystemMove()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: ui.isRecording ? -(ui.amplitude * 3) : 0
|
||||
radius: parent.radius
|
||||
color: "transparent"
|
||||
border.width: ui.isRecording ? 3 : 1.5
|
||||
border.color: ui.isRecording ? "#A0ff4b4b" : "#6000f2ff"
|
||||
Behavior on anchors.margins {
|
||||
NumberAnimation { duration: 150; easing.type: Easing.OutCubic }
|
||||
}
|
||||
Behavior on border.width {
|
||||
NumberAnimation { duration: 150; easing.type: Easing.OutCubic }
|
||||
}
|
||||
SequentialAnimation on border.color {
|
||||
running: ui.isRecording
|
||||
loops: Animation.Infinite
|
||||
ColorAnimation { from: "#A0ff4b4b"; to: "#C0ff6b6b"; duration: 800 }
|
||||
ColorAnimation { from: "#C0ff6b6b"; to: "#A0ff4b4b"; duration: 800 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- MICROPHONE ICON (Enhanced) ---
|
||||
Item {
|
||||
id: micContainer
|
||||
width: 80; height: 80
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
// Make entire button scale with amplitude
|
||||
scale: ui.isRecording ? (1.0 + ui.amplitude * 0.12) : 1.0
|
||||
Behavior on scale {
|
||||
NumberAnimation { duration: 150; easing.type: Easing.OutCubic }
|
||||
}
|
||||
|
||||
// MouseArea at parent level to avoid layer blocking
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
console.log("Microphone clicked! Emitting signal...")
|
||||
ui.toggleRecordingRequested()
|
||||
}
|
||||
}
|
||||
|
||||
layer.enabled: true
|
||||
layer.effect: MultiEffect {
|
||||
shadowEnabled: true
|
||||
shadowColor: ui.isRecording ? "#FF00a0ff" : "#FF00f2ff"
|
||||
shadowBlur: 1.0
|
||||
shadowOpacity: ui.isRecording ? 1.0 : 0.5
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: micCircle
|
||||
anchors.fill: parent
|
||||
radius: width / 2
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0.0; color: "#40ffffff" }
|
||||
GradientStop { position: 1.0; color: "#10ffffff" }
|
||||
}
|
||||
border.width: 2; border.color: "#60ffffff"
|
||||
|
||||
SequentialAnimation on scale {
|
||||
running: ui.isRecording
|
||||
loops: Animation.Infinite
|
||||
NumberAnimation { from: 1.0; to: 1.08; duration: 600; easing.type: Easing.InOutQuad }
|
||||
NumberAnimation { from: 1.08; to: 1.0; duration: 600; easing.type: Easing.InOutQuad }
|
||||
}
|
||||
|
||||
Image {
|
||||
id: micIcon
|
||||
anchors.centerIn: parent
|
||||
width: 40
|
||||
height: 40
|
||||
source: "microphone.svg"
|
||||
smooth: true
|
||||
antialiasing: true
|
||||
mipmap: true
|
||||
fillMode: Image.PreserveAspectFit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- RAINBOW WAVEFORM (Shader) ---
|
||||
Item {
|
||||
id: waveformContainer
|
||||
anchors.left: micContainer.right
|
||||
anchors.leftMargin: 10
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 80
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
height: 90
|
||||
|
||||
ShaderEffect {
|
||||
anchors.fill: parent
|
||||
property real time: 0
|
||||
property real amplitude: ui.amplitude
|
||||
fragmentShader: "rainbow_wave.qsb"
|
||||
NumberAnimation on time { from: 0; to: 1000; duration: 100000; loops: Animation.Infinite }
|
||||
layer.enabled: true
|
||||
layer.effect: MultiEffect {
|
||||
shadowEnabled: true
|
||||
shadowColor: Qt.hsla((Date.now() / 100) % 1.0, 1.0, 0.6, 1.0)
|
||||
shadowBlur: 1.0; shadowOpacity: 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- RECORDING TIMER ---
|
||||
Item {
|
||||
id: recordingTimerContainer
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 20
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 60; height: 30
|
||||
property int recordingSeconds: 0
|
||||
Connections {
|
||||
target: ui
|
||||
function onIsRecordingChanged() {
|
||||
if (!ui.isRecording) recordingTimerContainer.recordingSeconds = 0
|
||||
}
|
||||
}
|
||||
Timer {
|
||||
interval: 1000; running: ui.isRecording; repeat: true
|
||||
onTriggered: recordingTimerContainer.recordingSeconds++
|
||||
}
|
||||
|
||||
// Triple-layer glow for REALLY strong effect
|
||||
layer.enabled: true
|
||||
layer.effect: MultiEffect {
|
||||
shadowEnabled: true
|
||||
shadowColor: ui.isRecording ? "#FFff0000" : "#FFffffff"
|
||||
shadowBlur: 1.0
|
||||
shadowOpacity: 1.0
|
||||
shadowHorizontalOffset: 0
|
||||
shadowVerticalOffset: 0
|
||||
}
|
||||
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
layer.enabled: true
|
||||
layer.effect: MultiEffect {
|
||||
shadowEnabled: true
|
||||
shadowColor: ui.isRecording ? "#FFff3030" : "#FFe0e0e5"
|
||||
shadowBlur: 0.8
|
||||
shadowOpacity: 1.0
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: {
|
||||
var mins = Math.floor(recordingTimerContainer.recordingSeconds / 60)
|
||||
var secs = recordingTimerContainer.recordingSeconds % 60
|
||||
return (mins < 10 ? "0" : "") + mins + ":" + (secs < 10 ? "0" : "") + secs
|
||||
}
|
||||
color: ui.isRecording ? "#ffffff" : "#ffffff"
|
||||
font.family: jetBrainsMono.name; font.pixelSize: 16; font.bold: true; font.letterSpacing: 2
|
||||
style: Text.Outline
|
||||
styleColor: ui.isRecording ? "#ff0000" : "#808085"
|
||||
SequentialAnimation on opacity {
|
||||
running: ui.isRecording; loops: Animation.Infinite
|
||||
NumberAnimation { from: 1.0; to: 0.7; duration: 800 }
|
||||
NumberAnimation { from: 0.7; to: 1.0; duration: 800 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
905
src/ui/qml/Settings.qml
Normal file
905
src/ui/qml/Settings.qml
Normal file
@@ -0,0 +1,905 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Effects
|
||||
|
||||
Window {
|
||||
id: root
|
||||
|
||||
width: 850 * (ui ? ui.uiScale : 1.0)
|
||||
height: 620 * (ui ? ui.uiScale : 1.0)
|
||||
|
||||
visible: false
|
||||
flags: Qt.FramelessWindowHint | Qt.Window
|
||||
color: "transparent"
|
||||
title: "Settings"
|
||||
|
||||
// Explicit sizing for Python to read
|
||||
|
||||
// Prevent destruction on close
|
||||
onClosing: (close) => {
|
||||
close.accepted = false
|
||||
root.visible = false
|
||||
}
|
||||
|
||||
// Load Font
|
||||
FontLoader {
|
||||
id: jetBrainsMono
|
||||
source: "fonts/ttf/JetBrainsMono-Bold.ttf"
|
||||
}
|
||||
|
||||
readonly property string mainFont: jetBrainsMono.name
|
||||
property bool isLoaded: false
|
||||
|
||||
Component.onCompleted: {
|
||||
isLoaded = true
|
||||
}
|
||||
|
||||
// --- REUSABLE COMPONENTS ---
|
||||
component StatBox: Rectangle {
|
||||
property string label: ""
|
||||
property string value: ""
|
||||
property string unit: ""
|
||||
property color accent: SettingsStyle.accent
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 80
|
||||
color: "#16161a"
|
||||
radius: 12
|
||||
border.color: SettingsStyle.borderSubtle
|
||||
border.width: 1
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: 4
|
||||
Text { text: label; color: SettingsStyle.textSecondary; font.pixelSize: 11; font.bold: true; anchors.horizontalCenter: parent.horizontalCenter }
|
||||
Row {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: 2
|
||||
Text { text: value; color: accent; font.pixelSize: 22; font.bold: true; font.family: "JetBrains Mono" }
|
||||
Text { text: unit; color: SettingsStyle.textSecondary; font.pixelSize: 12; anchors.baseline: parent.bottom; anchors.baselineOffset: -4 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main Container
|
||||
Rectangle {
|
||||
id: mainContainer
|
||||
anchors.fill: parent
|
||||
radius: 16
|
||||
color: SettingsStyle.background
|
||||
border.color: SettingsStyle.borderSubtle
|
||||
border.width: 1
|
||||
|
||||
opacity: 0
|
||||
transform: Translate {
|
||||
id: entryTranslate
|
||||
y: 20
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
entryAnim.start()
|
||||
}
|
||||
|
||||
ParallelAnimation {
|
||||
id: entryAnim
|
||||
NumberAnimation { target: mainContainer; property: "opacity"; to: 1; duration: 400; easing.type: Easing.OutCubic }
|
||||
NumberAnimation { target: entryTranslate; property: "y"; to: 0; duration: 500; easing.type: Easing.OutBack; easing.overshoot: 0.8 }
|
||||
}
|
||||
|
||||
// --- TITLE BAR ---
|
||||
Item {
|
||||
id: titleBar
|
||||
height: 60
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onPressed: root.startSystemMove()
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 24
|
||||
anchors.rightMargin: 24
|
||||
|
||||
Image {
|
||||
source: "microphone.svg"
|
||||
sourceSize.width: 18
|
||||
sourceSize.height: 18
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
opacity: 0.7
|
||||
// Colorize the icon
|
||||
layer.enabled: true
|
||||
layer.effect: MultiEffect {
|
||||
colorization: 1.0
|
||||
colorizationColor: SettingsStyle.accent
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "SETTINGS"
|
||||
color: SettingsStyle.textSecondary
|
||||
font.family: mainFont
|
||||
font.pixelSize: 13
|
||||
font.letterSpacing: 2
|
||||
font.bold: true
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
// Improved Close Button
|
||||
Rectangle {
|
||||
width: 32; height: 32
|
||||
radius: 8
|
||||
color: closeMa.containsMouse ? "#20ff4b4b" : "transparent"
|
||||
border.color: closeMa.containsMouse ? "#40ff4b4b" : "transparent"
|
||||
border.width: 1
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "×"
|
||||
color: closeMa.containsMouse ? "#ff4b4b" : SettingsStyle.textSecondary
|
||||
font.family: mainFont
|
||||
font.pixelSize: 20
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: closeMa
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.close()
|
||||
}
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
Behavior on border.color { ColorAnimation { duration: 150 } }
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: SettingsStyle.borderSubtle
|
||||
}
|
||||
}
|
||||
|
||||
// --- CONTENT AREA ---
|
||||
RowLayout {
|
||||
anchors.top: titleBar.bottom
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
spacing: 0
|
||||
|
||||
// --- SIDEBAR ---
|
||||
Rectangle {
|
||||
Layout.fillHeight: true
|
||||
Layout.preferredWidth: 220
|
||||
Layout.minimumWidth: 220
|
||||
Layout.maximumWidth: 220
|
||||
color: Qt.rgba(1, 1, 1, 0.02) // Very subtle separation
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 16
|
||||
spacing: 8
|
||||
|
||||
ListModel {
|
||||
id: navModel
|
||||
ListElement { name: "General"; icon: "settings.svg" }
|
||||
ListElement { name: "Audio"; icon: "microphone.svg" }
|
||||
ListElement { name: "Visuals"; icon: "visibility.svg" }
|
||||
ListElement { name: "AI Engine"; icon: "smart_toy.svg" }
|
||||
ListElement { name: "Debug"; icon: "terminal.svg" }
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: navModel
|
||||
delegate: Rectangle {
|
||||
id: navBtnRoot
|
||||
Layout.fillWidth: true
|
||||
height: 38
|
||||
color: stack.currentIndex === index ? SettingsStyle.surfaceHover : (ma.containsMouse ? Qt.rgba(1,1,1,0.03) : "transparent")
|
||||
radius: 6
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
|
||||
// Left active stripe
|
||||
Rectangle {
|
||||
width: 3
|
||||
height: 20
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
radius: 2
|
||||
color: SettingsStyle.accent
|
||||
visible: stack.currentIndex === index
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
spacing: 12
|
||||
|
||||
Image {
|
||||
source: icon
|
||||
sourceSize.width: 16
|
||||
sourceSize.height: 16
|
||||
fillMode: Image.PreserveAspectFit
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
opacity: stack.currentIndex === index ? 1.0 : 0.5
|
||||
|
||||
layer.enabled: true
|
||||
layer.effect: MultiEffect {
|
||||
colorization: 1.0
|
||||
colorizationColor: stack.currentIndex === index ? SettingsStyle.accent : SettingsStyle.textSecondary
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
text: name
|
||||
color: stack.currentIndex === index ? SettingsStyle.textPrimary : SettingsStyle.textSecondary
|
||||
font.family: mainFont
|
||||
font.pixelSize: 13
|
||||
font.weight: stack.currentIndex === index ? Font.Bold : Font.Normal
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: ma
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: stack.currentIndex = index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillHeight: true }
|
||||
}
|
||||
|
||||
// Vertical Divider
|
||||
Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: 1
|
||||
color: SettingsStyle.borderSubtle
|
||||
}
|
||||
}
|
||||
|
||||
// --- MAIN CONTENT STACK ---
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: "transparent"
|
||||
clip: true
|
||||
|
||||
StackLayout {
|
||||
id: stack
|
||||
anchors.fill: parent
|
||||
currentIndex: 0
|
||||
|
||||
// --- TAB: GENERAL ---
|
||||
ScrollView {
|
||||
ScrollBar.vertical.policy: ScrollBar.AsNeeded
|
||||
contentWidth: availableWidth
|
||||
|
||||
ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 24
|
||||
anchors.margins: 32
|
||||
|
||||
// Header
|
||||
ColumnLayout {
|
||||
spacing: 4
|
||||
Layout.topMargin: 32
|
||||
Layout.leftMargin: 32
|
||||
Layout.rightMargin: 32
|
||||
Text { text: "General"; color: SettingsStyle.textPrimary; font.family: mainFont; font.pixelSize: 24; font.bold: true }
|
||||
Text { text: "System behavior and shortcuts"; color: SettingsStyle.textSecondary; font.family: mainFont; font.pixelSize: 14 }
|
||||
}
|
||||
|
||||
ModernSettingsSection {
|
||||
title: "Application"
|
||||
Layout.margins: 32
|
||||
Layout.topMargin: 0
|
||||
|
||||
content: ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 0
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Global Hotkey"
|
||||
description: "Press to record a new shortcut (e.g. Ctrl+Space)"
|
||||
control: ModernKeySequenceRecorder {
|
||||
Layout.preferredWidth: 200
|
||||
currentSequence: ui.getSetting("hotkey")
|
||||
onSequenceChanged: (seq) => ui.setSetting("hotkey", seq)
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Run on Startup"
|
||||
description: "Automatically launch when you log in"
|
||||
control: ModernSwitch {
|
||||
checked: ui.getSetting("run_on_startup")
|
||||
onToggled: ui.setSetting("run_on_startup", checked)
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Input Method"
|
||||
description: "How text is sent to the active window"
|
||||
control: ModernComboBox {
|
||||
width: 160
|
||||
model: ["Clipboard Paste", "Simulate Typing"]
|
||||
currentIndex: model.indexOf(ui.getSetting("input_method"))
|
||||
onActivated: ui.setSetting("input_method", currentText)
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Typing Speed"
|
||||
description: "Chars/min"
|
||||
showSeparator: false
|
||||
control: ModernSlider {
|
||||
Layout.preferredWidth: 200
|
||||
from: 10; to: 6000
|
||||
stepSize: 10
|
||||
snapMode: Slider.SnapAlways
|
||||
value: ui.getSetting("typing_speed")
|
||||
onMoved: ui.setSetting("typing_speed", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- TAB: AUDIO ---
|
||||
ScrollView {
|
||||
ScrollBar.vertical.policy: ScrollBar.AsNeeded
|
||||
contentWidth: availableWidth
|
||||
|
||||
ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 24
|
||||
anchors.margins: 32
|
||||
|
||||
ColumnLayout {
|
||||
spacing: 4
|
||||
Layout.topMargin: 32
|
||||
Layout.leftMargin: 32
|
||||
Layout.rightMargin: 32
|
||||
Text { text: "Audio"; color: SettingsStyle.textPrimary; font.family: mainFont; font.pixelSize: 24; font.bold: true }
|
||||
Text { text: "Input devices and signal processing"; color: SettingsStyle.textSecondary; font.family: mainFont; font.pixelSize: 14 }
|
||||
}
|
||||
|
||||
ModernSettingsSection {
|
||||
title: "Devices"
|
||||
Layout.margins: 32
|
||||
Layout.topMargin: 0
|
||||
|
||||
content: ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 0
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Microphone"
|
||||
description: "Select your primary input device"
|
||||
control: ModernComboBox {
|
||||
Layout.preferredWidth: 280
|
||||
textRole: "name"
|
||||
valueRole: "id"
|
||||
model: ui.getAudioDevices()
|
||||
onActivated: ui.setSetting("input_device", model[currentIndex].id) // Explicitly use model index
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Save Recordings"
|
||||
description: "Save .wav files to ./recordings folder"
|
||||
showSeparator: false
|
||||
control: ModernSwitch {
|
||||
checked: ui.getSetting("save_recordings")
|
||||
onToggled: ui.setSetting("save_recordings", checked)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsSection {
|
||||
title: "Signal Processing"
|
||||
Layout.margins: 32
|
||||
Layout.topMargin: 0
|
||||
|
||||
content: ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 0
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "VAD Threshold"
|
||||
description: "Silence detection sensitivity (" + (ui.getSetting("silence_threshold") * 100).toFixed(0) + "%)"
|
||||
control: ModernSlider {
|
||||
Layout.preferredWidth: 200
|
||||
from: 1; to: 100
|
||||
value: ui.getSetting("silence_threshold") * 100
|
||||
onMoved: ui.setSetting("silence_threshold", Number((value / 100.0).toFixed(2)))
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Silence Timeout"
|
||||
description: "Stop recording after " + (ui.getSetting("silence_duration")).toFixed(1) + "s of silence"
|
||||
showSeparator: false
|
||||
control: ModernSlider {
|
||||
Layout.preferredWidth: 200
|
||||
from: 0.5; to: 5.0
|
||||
value: ui.getSetting("silence_duration")
|
||||
onMoved: ui.setSetting("silence_duration", Number(value.toFixed(1)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- TAB: VISUALS ---
|
||||
ScrollView {
|
||||
ScrollBar.vertical.policy: ScrollBar.AsNeeded
|
||||
contentWidth: availableWidth
|
||||
|
||||
ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 24
|
||||
anchors.margins: 32
|
||||
|
||||
ColumnLayout {
|
||||
spacing: 4
|
||||
Layout.topMargin: 32
|
||||
Layout.leftMargin: 32
|
||||
Layout.rightMargin: 32
|
||||
Text { text: "Visuals"; color: SettingsStyle.textPrimary; font.family: mainFont; font.pixelSize: 24; font.bold: true }
|
||||
Text { text: "Customize the overlay appearance"; color: SettingsStyle.textSecondary; font.family: mainFont; font.pixelSize: 14 }
|
||||
}
|
||||
|
||||
ModernSettingsSection {
|
||||
title: "Overlay"
|
||||
Layout.margins: 32
|
||||
Layout.topMargin: 0
|
||||
|
||||
content: ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 0
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "UI Scale"
|
||||
description: "Global interface scaling factor (" + ui.getSetting("ui_scale").toFixed(2) + "x)"
|
||||
control: ModernSlider {
|
||||
Layout.preferredWidth: 200
|
||||
from: 0.75; to: 1.5
|
||||
value: ui.getSetting("ui_scale")
|
||||
onMoved: ui.setSetting("ui_scale", Number(value.toFixed(2)))
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Always on Top"
|
||||
description: "Keep the overlay visible above other windows"
|
||||
control: ModernSwitch {
|
||||
checked: ui.getSetting("always_on_top")
|
||||
onToggled: ui.setSetting("always_on_top", checked)
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Window Opacity"
|
||||
description: "Transparency level"
|
||||
showSeparator: false
|
||||
control: ModernSlider {
|
||||
Layout.preferredWidth: 200
|
||||
from: 0.1; to: 1.0
|
||||
value: ui.getSetting("opacity")
|
||||
onMoved: ui.setSetting("opacity", Number(value.toFixed(2)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsSection {
|
||||
title: "Window Position"
|
||||
Layout.margins: 32
|
||||
Layout.topMargin: 0
|
||||
|
||||
content: ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 0
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Anchor Position"
|
||||
description: "Where the overlay snaps to on screen"
|
||||
control: ModernComboBox {
|
||||
width: 160
|
||||
model: ["Bottom Center", "Top Center", "Bottom Right", "Top Right", "Bottom Left", "Top Left"]
|
||||
currentIndex: model.indexOf(ui.getSetting("overlay_position"))
|
||||
onActivated: ui.setSetting("overlay_position", currentText)
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Horizontal Offset"
|
||||
description: "Fine-tune X position (" + ui.getSetting("overlay_offset_x") + "px)"
|
||||
control: ModernSlider {
|
||||
Layout.preferredWidth: 200
|
||||
from: -500; to: 500
|
||||
value: ui.getSetting("overlay_offset_x")
|
||||
onMoved: ui.setSetting("overlay_offset_x", value)
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Vertical Offset"
|
||||
description: "Fine-tune Y position (" + ui.getSetting("overlay_offset_y") + "px)"
|
||||
showSeparator: false
|
||||
control: ModernSlider {
|
||||
Layout.preferredWidth: 200
|
||||
from: -500; to: 500
|
||||
value: ui.getSetting("overlay_offset_y")
|
||||
onMoved: ui.setSetting("overlay_offset_y", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- TAB: AI ENGINE ---
|
||||
ScrollView {
|
||||
ScrollBar.vertical.policy: ScrollBar.AsNeeded
|
||||
contentWidth: availableWidth
|
||||
|
||||
ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 24
|
||||
anchors.margins: 32
|
||||
|
||||
ColumnLayout {
|
||||
spacing: 4
|
||||
Layout.topMargin: 32
|
||||
Layout.leftMargin: 32
|
||||
Layout.rightMargin: 32
|
||||
Text { text: "AI Engine"; color: SettingsStyle.textPrimary; font.family: mainFont; font.pixelSize: 24; font.bold: true }
|
||||
Text { text: "Model configuration and performance"; color: SettingsStyle.textSecondary; font.family: mainFont; font.pixelSize: 14 }
|
||||
}
|
||||
|
||||
ModernSettingsSection {
|
||||
title: "Model Config"
|
||||
Layout.margins: 32
|
||||
Layout.topMargin: 0
|
||||
|
||||
content: ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 0
|
||||
|
||||
ListModel {
|
||||
id: modelDetailsModel
|
||||
ListElement { name: "tiny"; info: "39M params • <1GB VRAM • 32x speed. Fastest, best for weak hardware." }
|
||||
ListElement { name: "base"; info: "74M params • ~1GB VRAM • 16x speed. Efficient for simple commands." }
|
||||
ListElement { name: "small"; info: "244M params • ~2GB VRAM • 6x speed. Recommended for most users." }
|
||||
ListElement { name: "medium"; info: "769M params • ~5GB VRAM • Accurate, high fidelity. Mid-range GPU req." }
|
||||
ListElement { name: "large-v3"; info: "1.5B params • ~10GB VRAM • Pro quality. Requires high-end hardware." }
|
||||
ListElement { name: "turbo"; info: "800M params • ~6GB VRAM • Large-v3 quality with 8x speed boost." }
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Model Size"
|
||||
description: "Larger models are smarter but slower"
|
||||
control: ModernComboBox {
|
||||
id: modelSizeCombo
|
||||
width: 140
|
||||
model: ["tiny", "base", "small", "medium", "large-v3", "turbo"]
|
||||
currentIndex: model.indexOf(ui.getSetting("model_size"))
|
||||
onActivated: ui.setSetting("model_size", currentText)
|
||||
}
|
||||
}
|
||||
|
||||
// Model Info Card
|
||||
Rectangle {
|
||||
id: modelInfoCard
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 12
|
||||
Layout.topMargin: 0
|
||||
Layout.bottomMargin: 16
|
||||
height: 54
|
||||
color: "#0a0a0f"
|
||||
radius: 6
|
||||
border.color: SettingsStyle.borderSubtle
|
||||
border.width: 1
|
||||
|
||||
// Improved reactive check
|
||||
property bool isDownloaded: false
|
||||
|
||||
Timer {
|
||||
id: checkTimer
|
||||
interval: 1000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: parent.checkStatus()
|
||||
}
|
||||
|
||||
function checkStatus() {
|
||||
if (modelSizeCombo && modelSizeCombo.currentText) {
|
||||
isDownloaded = ui.isModelDownloaded(modelSizeCombo.currentText)
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh when notified by Python
|
||||
Connections {
|
||||
target: ui
|
||||
function onModelStatesChanged() {
|
||||
checkStatus()
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: checkStatus()
|
||||
|
||||
// Also check when selection changes
|
||||
Connections {
|
||||
target: modelSizeCombo
|
||||
function onActivated() { modelInfoCard.checkStatus() }
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
spacing: 12
|
||||
|
||||
Image {
|
||||
source: "smart_toy.svg"
|
||||
sourceSize: Qt.size(16, 16)
|
||||
layer.enabled: true
|
||||
layer.effect: MultiEffect {
|
||||
colorization: 1.0
|
||||
colorizationColor: modelInfoCard.isDownloaded ? SettingsStyle.accent : "#808080"
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
Text {
|
||||
text: {
|
||||
if (ui.isDownloading) return "Downloading AI Core..."
|
||||
for (var i = 0; i < modelDetailsModel.count; i++) {
|
||||
if (modelDetailsModel.get(i).name === modelSizeCombo.currentText) {
|
||||
return modelDetailsModel.get(i).info
|
||||
}
|
||||
}
|
||||
return "Select a model."
|
||||
}
|
||||
color: "#ffffff"
|
||||
font.family: "JetBrains Mono"
|
||||
font.pixelSize: 10
|
||||
opacity: 0.7
|
||||
elide: Text.ElideRight
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: downloaderBar
|
||||
visible: ui.isDownloading
|
||||
Layout.fillWidth: true
|
||||
height: 2
|
||||
color: "#20ffffff"
|
||||
Rectangle {
|
||||
width: downloaderBar.width * 0.5
|
||||
height: downloaderBar.height
|
||||
color: SettingsStyle.accent
|
||||
SequentialAnimation on x {
|
||||
loops: Animation.Infinite
|
||||
NumberAnimation { from: -width; to: downloaderBar.width; duration: 1500 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
id: downloadBtn
|
||||
text: "Download"
|
||||
visible: !modelInfoCard.isDownloaded && !ui.isDownloading
|
||||
Layout.preferredHeight: 24
|
||||
Layout.preferredWidth: 80
|
||||
|
||||
contentItem: Text {
|
||||
text: "DOWNLOAD"
|
||||
font.pixelSize: 10; font.bold: true; color: "#000000"; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
color: downloadBtn.hovered ? "#ffffff" : SettingsStyle.accent; radius: 4
|
||||
}
|
||||
onClicked: ui.downloadModel(modelSizeCombo.currentText)
|
||||
}
|
||||
|
||||
// Status tag
|
||||
Rectangle {
|
||||
id: statusTag
|
||||
visible: modelInfoCard.isDownloaded && !ui.isDownloading
|
||||
height: 18; width: 64; radius: 4; color: "#1000f2ff"
|
||||
border.color: "#3000f2ff"; border.width: 1
|
||||
Text {
|
||||
anchors.centerIn: statusTag; text: "INSTALLED"; font.pixelSize: 9
|
||||
font.bold: true; color: SettingsStyle.accent; opacity: 0.9
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Language"
|
||||
description: "Force language or Auto-detect"
|
||||
control: ModernComboBox {
|
||||
width: 140
|
||||
model: ["auto", "en", "fr", "de", "es", "it", "ja", "zh", "ru"]
|
||||
currentIndex: model.indexOf(ui.getSetting("language"))
|
||||
onActivated: ui.setSetting("language", currentText)
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Compute Device"
|
||||
description: "Hardware acceleration (CUDA requires NVidia GPU)"
|
||||
control: ModernComboBox {
|
||||
width: 140
|
||||
model: ["auto", "cuda", "cpu"]
|
||||
currentIndex: model.indexOf(ui.getSetting("compute_device"))
|
||||
onActivated: ui.setSetting("compute_device", currentText)
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Precision"
|
||||
description: "Quantization type (int8 is faster, float16 is accurate)"
|
||||
showSeparator: false
|
||||
control: ModernComboBox {
|
||||
width: 140
|
||||
model: ["int8", "float16", "float32"]
|
||||
currentIndex: model.indexOf(ui.getSetting("compute_type"))
|
||||
onActivated: ui.setSetting("compute_type", currentText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsSection {
|
||||
title: "Advanced Decoding"
|
||||
Layout.margins: 32
|
||||
Layout.topMargin: 0
|
||||
|
||||
content: ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 0
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Beam Size"
|
||||
description: "Search width (Higher = Better Accuracy, Slower)"
|
||||
control: ModernSlider {
|
||||
Layout.preferredWidth: 200
|
||||
from: 1; to: 10
|
||||
value: ui.getSetting("beam_size")
|
||||
onMoved: ui.setSetting("beam_size", value)
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "VAD Filter"
|
||||
description: "Skip silent audio segments (Speeds up processing)"
|
||||
control: ModernSwitch {
|
||||
checked: ui.getSetting("vad_filter")
|
||||
onToggled: ui.setSetting("vad_filter", checked)
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Hallucination Check"
|
||||
description: "Prevent repetitive text loops (No Repeat N-Gram)"
|
||||
control: ModernSwitch {
|
||||
checked: ui.getSetting("no_repeat_ngram_size") > 0
|
||||
onToggled: ui.setSetting("no_repeat_ngram_size", checked ? 3 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Context History"
|
||||
description: "Use previous text to improve coherence"
|
||||
showSeparator: false
|
||||
control: ModernSwitch {
|
||||
checked: ui.getSetting("condition_on_previous_text")
|
||||
onToggled: ui.setSetting("condition_on_previous_text", checked)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- TAB: DEBUG ---
|
||||
ScrollView {
|
||||
ScrollBar.vertical.policy: ScrollBar.AsNeeded
|
||||
contentWidth: availableWidth
|
||||
|
||||
ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 16
|
||||
anchors.margins: 32
|
||||
|
||||
ColumnLayout {
|
||||
spacing: 4
|
||||
Layout.topMargin: 32
|
||||
Layout.leftMargin: 32
|
||||
Layout.rightMargin: 32
|
||||
Text { text: "System Diagnostics"; color: SettingsStyle.textPrimary; font.family: mainFont; font.pixelSize: 24; font.bold: true }
|
||||
Text { text: "Live performance and logs"; color: SettingsStyle.textSecondary; font.family: mainFont; font.pixelSize: 14 }
|
||||
}
|
||||
|
||||
// --- PERFORMANCE STATS ---
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: 32
|
||||
Layout.rightMargin: 32
|
||||
spacing: 16
|
||||
|
||||
StatBox { label: "APP CPU"; value: ui.appCpu; unit: "%"; accent: "#00f2ff" }
|
||||
StatBox { label: "APP RAM"; value: ui.appRamMb; unit: "MB"; accent: "#bd93f9" }
|
||||
StatBox { label: "GPU VRAM"; value: ui.appVramMb; unit: "MB"; accent: "#ff79c6" }
|
||||
StatBox { label: "GPU LOAD"; value: ui.appVramPercent; unit: "%"; accent: "#ff5555" }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 300
|
||||
Layout.margins: 32
|
||||
Layout.topMargin: 0
|
||||
color: "#0d0d10"
|
||||
radius: 8
|
||||
border.color: SettingsStyle.borderSubtle
|
||||
border.width: 1
|
||||
clip: true
|
||||
|
||||
ScrollView {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 12
|
||||
ScrollBar.vertical.policy: ScrollBar.AlwaysOn
|
||||
|
||||
TextArea {
|
||||
id: logArea
|
||||
text: ui.allLogs
|
||||
readOnly: true
|
||||
color: "#cccccc"
|
||||
font.family: "JetBrains Mono"
|
||||
font.pixelSize: 11
|
||||
wrapMode: TextArea.Wrap
|
||||
background: null
|
||||
selectByMouse: true
|
||||
|
||||
Connections {
|
||||
target: ui
|
||||
function onLogAppended(line) {
|
||||
logArea.append(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
src/ui/qml/SettingsStyle.qml
Normal file
25
src/ui/qml/SettingsStyle.qml
Normal file
@@ -0,0 +1,25 @@
|
||||
import QtQuick
|
||||
|
||||
pragma Singleton
|
||||
|
||||
QtObject {
|
||||
// Colors
|
||||
readonly property color background: "#F2121212" // Deep Obsidian with 95% opacity
|
||||
readonly property color surfaceCard: "#1A1A1A" // Layer 1
|
||||
readonly property color surfaceHover: "#2A2A2A" // Layer 2 (Lighter for better contrast)
|
||||
readonly property color borderSubtle: Qt.rgba(1, 1, 1, 0.08)
|
||||
|
||||
readonly property color textPrimary: "#FAFAFA" // Brighter white
|
||||
readonly property color textSecondary: "#999999"
|
||||
|
||||
readonly property color accentPurple: "#7000FF"
|
||||
readonly property color accentCyan: "#00F2FF"
|
||||
|
||||
// Configurable active accent
|
||||
property color accent: accentPurple
|
||||
|
||||
// Dimensions
|
||||
readonly property int cardRadius: 16
|
||||
readonly property int itemRadius: 8
|
||||
readonly property int itemHeight: 60 // Even taller for more breathing room
|
||||
}
|
||||
56
src/ui/qml/crt.frag
Normal file
56
src/ui/qml/crt.frag
Normal file
@@ -0,0 +1,56 @@
|
||||
#version 440
|
||||
|
||||
layout(location = 0) in vec2 qt_TexCoord0;
|
||||
layout(location = 0) out vec4 fragColor;
|
||||
|
||||
layout(std140, binding = 0) uniform buf {
|
||||
mat4 qt_Matrix;
|
||||
float qt_Opacity;
|
||||
float time;
|
||||
};
|
||||
|
||||
layout(binding = 1) uniform sampler2D source;
|
||||
|
||||
void main() {
|
||||
vec2 uv = qt_TexCoord0;
|
||||
|
||||
// CRT curvature distortion
|
||||
vec2 crtUV = uv - 0.5;
|
||||
float curvature = 0.03;
|
||||
crtUV *= 1.0 + curvature * (crtUV.x * crtUV.x + crtUV.y * crtUV.y);
|
||||
crtUV += 0.5;
|
||||
|
||||
// Clamp to avoid sampling outside
|
||||
if (crtUV.x < 0.0 || crtUV.x > 1.0 || crtUV.y < 0.0 || crtUV.y > 1.0) {
|
||||
fragColor = vec4(0.0, 0.0, 0.0, 0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sample the texture
|
||||
vec4 color = texture(source, crtUV);
|
||||
|
||||
// Scanlines effect (fine and subtle)
|
||||
float scanlineIntensity = 0.04;
|
||||
float scanline = sin(crtUV.y * 2400.0) * scanlineIntensity;
|
||||
color.rgb -= scanline;
|
||||
|
||||
// RGB color separation (chromatic aberration)
|
||||
float aberration = 0.001;
|
||||
float r = texture(source, crtUV + vec2(aberration, 0.0)).r;
|
||||
float g = color.g;
|
||||
float b = texture(source, crtUV - vec2(aberration, 0.0)).b;
|
||||
color.rgb = vec3(r, g, b);
|
||||
|
||||
// Subtle vignette
|
||||
float vignette = 1.0 - 0.3 * length(crtUV - 0.5);
|
||||
color.rgb *= vignette;
|
||||
|
||||
// Slight green/cyan phosphor tint
|
||||
color.rgb *= vec3(0.95, 1.0, 0.98);
|
||||
|
||||
// Flicker (very subtle)
|
||||
float flicker = 0.98 + 0.02 * sin(time * 15.0);
|
||||
color.rgb *= flicker;
|
||||
|
||||
fragColor = color * qt_Opacity;
|
||||
}
|
||||
BIN
src/ui/qml/crt.qsb
Normal file
BIN
src/ui/qml/crt.qsb
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-Bold.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-Bold.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-BoldItalic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-ExtraBold.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-ExtraBold.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-ExtraBoldItalic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-ExtraBoldItalic.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-ExtraLight.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-ExtraLight.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-ExtraLightItalic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-ExtraLightItalic.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-Italic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-Italic.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-Light.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-Light.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-LightItalic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-LightItalic.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-Medium.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-Medium.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-MediumItalic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-MediumItalic.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-Regular.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-Regular.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-SemiBold.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-SemiBold.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-SemiBoldItalic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-SemiBoldItalic.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-Thin.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-Thin.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-ThinItalic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-ThinItalic.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-Bold.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-Bold.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-BoldItalic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-ExtraBold.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-ExtraBold.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-ExtraBoldItalic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-ExtraBoldItalic.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-ExtraLight.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-ExtraLight.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-ExtraLightItalic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-ExtraLightItalic.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-Italic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-Italic.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-Light.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-Light.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-LightItalic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-LightItalic.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-Medium.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-Medium.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-MediumItalic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-MediumItalic.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-Regular.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-Regular.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-SemiBold.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-SemiBold.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-SemiBoldItalic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-SemiBoldItalic.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-Thin.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-Thin.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-ThinItalic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-ThinItalic.ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/variable/JetBrainsMono-Italic[wght].ttf
Normal file
BIN
src/ui/qml/fonts/variable/JetBrainsMono-Italic[wght].ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/variable/JetBrainsMono[wght].ttf
Normal file
BIN
src/ui/qml/fonts/variable/JetBrainsMono[wght].ttf
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-Bold.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-Bold.woff2
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-BoldItalic.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-BoldItalic.woff2
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-ExtraBold.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-ExtraBold.woff2
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-ExtraBoldItalic.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-ExtraBoldItalic.woff2
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-ExtraLight.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-ExtraLight.woff2
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-ExtraLightItalic.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-ExtraLightItalic.woff2
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-Italic.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-Italic.woff2
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-Light.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-Light.woff2
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-LightItalic.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-LightItalic.woff2
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-Medium.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-Medium.woff2
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-MediumItalic.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-MediumItalic.woff2
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-Regular.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-Regular.woff2
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-SemiBold.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-SemiBold.woff2
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-SemiBoldItalic.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-SemiBoldItalic.woff2
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-Thin.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-Thin.woff2
Normal file
Binary file not shown.
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-ThinItalic.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-ThinItalic.woff2
Normal file
Binary file not shown.
50
src/ui/qml/glass.frag
Normal file
50
src/ui/qml/glass.frag
Normal file
@@ -0,0 +1,50 @@
|
||||
#version 440
|
||||
|
||||
layout(location = 0) in vec2 qt_TexCoord0;
|
||||
layout(location = 0) out vec4 fragColor;
|
||||
|
||||
layout(std140, binding = 0) uniform buf {
|
||||
mat4 qt_Matrix;
|
||||
float qt_Opacity;
|
||||
float time;
|
||||
float aberration; // 0.0 to 1.0, controlled by Audio Amplitude
|
||||
};
|
||||
|
||||
float rand(vec2 co) {
|
||||
return fract(sin(dot(co.xy ,vec2(12.9898,78.233))) * 43758.5453);
|
||||
}
|
||||
|
||||
void main() {
|
||||
// 1. Calculate Distortion Offset based on Amplitude (aberration)
|
||||
// We warp the UVs slightly away from center
|
||||
vec2 uv = qt_TexCoord0;
|
||||
vec2 dist = uv - 0.5;
|
||||
|
||||
// 2. Chromatic Aberration
|
||||
// Red Channel shifts OUT
|
||||
// Blue Channel shifts IN
|
||||
float strength = aberration * 0.02; // Max shift 2% of texture size
|
||||
|
||||
vec2 rUV = uv + (dist * strength);
|
||||
vec2 bUV = uv - (dist * strength);
|
||||
|
||||
// Sample texture? We don't have a texture input (source is empty Item), we are generating visuals.
|
||||
// Wait, ShaderEffect usually works on sourceItem.
|
||||
// Here we are generating NOISE on top of a gradient.
|
||||
// So we apply Aberration to the NOISE function?
|
||||
// Or do we want to aberrate the pixels UNDERNEATH?
|
||||
// ShaderEffect with no source property renders purely procedural content.
|
||||
|
||||
// Let's create layered procedural noise with channel offsets
|
||||
float nR = rand(rUV + vec2(time * 0.01, 0.0));
|
||||
float nG = rand(uv + vec2(time * 0.01, 0.0)); // Green is anchor
|
||||
float nB = rand(bUV + vec2(time * 0.01, 0.0));
|
||||
|
||||
// Also modulate alpha by aberration - higher volume = more intense grain?
|
||||
// Or maybe just pure glitch.
|
||||
|
||||
vec4 grainColor = vec4(nR, nG, nB, 1.0);
|
||||
|
||||
// Mix it with opacity
|
||||
fragColor = grainColor * qt_Opacity;
|
||||
}
|
||||
BIN
src/ui/qml/glass.qsb
Normal file
BIN
src/ui/qml/glass.qsb
Normal file
Binary file not shown.
40
src/ui/qml/glow.frag
Normal file
40
src/ui/qml/glow.frag
Normal file
@@ -0,0 +1,40 @@
|
||||
#version 440
|
||||
|
||||
layout(location = 0) in vec2 qt_TexCoord0;
|
||||
layout(location = 0) out vec4 fragColor;
|
||||
|
||||
layout(std140, binding = 0) uniform buf {
|
||||
mat4 qt_Matrix;
|
||||
float qt_Opacity;
|
||||
float time;
|
||||
float intensity; // 0.0 to 1.0, controlled by Audio Amplitude
|
||||
};
|
||||
|
||||
float rand(vec2 co) {
|
||||
return fract(sin(dot(co.xy ,vec2(12.9898,78.233))) * 43758.5453);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = qt_TexCoord0;
|
||||
|
||||
// 1. Base Noise (subtle grain)
|
||||
float noise = rand(uv + vec2(time * 0.01, 0.0));
|
||||
|
||||
// 2. Radial Glow (bright center, fading to edges)
|
||||
vec2 center = vec2(0.5, 0.5);
|
||||
float dist = distance(uv, center);
|
||||
|
||||
// Glow strength based on intensity (audio amplitude)
|
||||
// Higher intensity = brighter, wider glow
|
||||
float glowRadius = 0.3 + (intensity * 0.4); // Radius grows with volume
|
||||
float glow = 1.0 - smoothstep(0.0, glowRadius, dist);
|
||||
glow = pow(glow, 2.0); // Sharpen the falloff
|
||||
|
||||
// 3. Color the glow (warm white/cyan tint)
|
||||
vec3 glowColor = vec3(0.8, 0.9, 1.0); // Slight cyan tint
|
||||
|
||||
// 4. Combine noise + glow
|
||||
vec3 finalColor = mix(vec3(noise), glowColor, glow * intensity);
|
||||
|
||||
fragColor = vec4(finalColor, 1.0) * qt_Opacity;
|
||||
}
|
||||
BIN
src/ui/qml/glow.qsb
Normal file
BIN
src/ui/qml/glow.qsb
Normal file
Binary file not shown.
87
src/ui/qml/gradient_blobs.frag
Normal file
87
src/ui/qml/gradient_blobs.frag
Normal file
@@ -0,0 +1,87 @@
|
||||
#version 440
|
||||
|
||||
layout(location = 0) in vec2 qt_TexCoord0;
|
||||
layout(location = 0) out vec4 fragColor;
|
||||
|
||||
layout(std140, binding = 0) uniform buf {
|
||||
mat4 qt_Matrix;
|
||||
float qt_Opacity;
|
||||
float time;
|
||||
};
|
||||
|
||||
// Smooth noise function
|
||||
float noise(vec2 p) {
|
||||
return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453);
|
||||
}
|
||||
|
||||
// Smooth interpolation
|
||||
float smoothNoise(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
f = f * f * (3.0 - 2.0 * f); // Smoothstep
|
||||
|
||||
float a = noise(i);
|
||||
float b = noise(i + vec2(1.0, 0.0));
|
||||
float c = noise(i + vec2(0.0, 1.0));
|
||||
float d = noise(i + vec2(1.0, 1.0));
|
||||
|
||||
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
|
||||
}
|
||||
|
||||
// Fractal Brownian Motion
|
||||
float fbm(vec2 p) {
|
||||
float value = 0.0;
|
||||
float amplitude = 0.5;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
value += amplitude * smoothNoise(p);
|
||||
p *= 2.0;
|
||||
amplitude *= 0.5;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// HSV to RGB conversion
|
||||
vec3 hsv2rgb(vec3 c) {
|
||||
vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
|
||||
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
|
||||
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = qt_TexCoord0;
|
||||
float t = time * 0.05;
|
||||
|
||||
// Multiple moving blobs
|
||||
vec2 p1 = uv * 3.0 + vec2(t * 0.3, t * 0.2);
|
||||
vec2 p2 = uv * 2.5 + vec2(-t * 0.4, t * 0.3);
|
||||
vec2 p3 = uv * 4.0 + vec2(t * 0.2, -t * 0.25);
|
||||
|
||||
float blob1 = fbm(p1);
|
||||
float blob2 = fbm(p2);
|
||||
float blob3 = fbm(p3);
|
||||
|
||||
// Combine blobs
|
||||
float combined = (blob1 + blob2 + blob3) / 3.0;
|
||||
|
||||
// Live hue shifting - slowly cycle through hues over time
|
||||
float hueShift = time * 0.02; // Slow hue rotation
|
||||
|
||||
// Create colors using HSV with shifting hue
|
||||
vec3 color1 = hsv2rgb(vec3(fract(0.75 + hueShift), 0.6, 0.35)); // Purple range
|
||||
vec3 color2 = hsv2rgb(vec3(fract(0.85 + hueShift), 0.7, 0.35)); // Magenta range
|
||||
vec3 color3 = hsv2rgb(vec3(fract(0.55 + hueShift), 0.7, 0.35)); // Blue range
|
||||
vec3 color4 = hsv2rgb(vec3(fract(0.08 + hueShift), 0.7, 0.35)); // Orange range
|
||||
vec3 color5 = hsv2rgb(vec3(fract(0.50 + hueShift), 0.6, 0.30)); // Teal range
|
||||
|
||||
// Mix colors based on blob values for visible multi-color effect
|
||||
vec3 color = mix(color1, color2, blob1);
|
||||
color = mix(color, color3, blob2);
|
||||
color = mix(color, color4, blob3);
|
||||
color = mix(color, color5, combined);
|
||||
|
||||
// Moderate brightness for visible but dark background accent
|
||||
float brightness = 0.25 + combined * 0.25;
|
||||
color *= brightness;
|
||||
|
||||
fragColor = vec4(color, qt_Opacity);
|
||||
}
|
||||
BIN
src/ui/qml/gradient_blobs.qsb
Normal file
BIN
src/ui/qml/gradient_blobs.qsb
Normal file
Binary file not shown.
BIN
src/ui/qml/icon.ico
Normal file
BIN
src/ui/qml/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
1
src/ui/qml/microphone.svg
Normal file
1
src/ui/qml/microphone.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. --><path fill="#ffffff" d="M192 0C139 0 96 43 96 96l0 160c0 53 43 96 96 96s96-43 96-96l0-160c0-53-43-96-96-96zM64 216c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 40c0 89.1 66.2 162.7 152 174.4l0 33.6-48 0c-13.3 0-24 10.7-24 24s10.7 24 24 24l72 0 72 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-48 0 0-33.6c85.8-11.7 152-85.3 152-174.4l0-40c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 40c0 70.7-57.3 128-128 128s-128-57.3-128-128l0-40z"/></svg>
|
||||
|
After Width: | Height: | Size: 695 B |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user