commit 9ff0e8d10835333f9787e04d67157ed301ad8882 Author: Your Name Date: Sat Jan 24 17:03:52 2026 +0200 Initial commit of WhisperVoice diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..efb8eb3 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c652241 --- /dev/null +++ b/README.md @@ -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 diff --git a/app_icon.ico b/app_icon.ico new file mode 100644 index 0000000..cc5cc26 Binary files /dev/null and b/app_icon.ico differ diff --git a/assets/icon.ico b/assets/icon.ico new file mode 100644 index 0000000..cc5cc26 Binary files /dev/null and b/assets/icon.ico differ diff --git a/bootstrapper.py b/bootstrapper.py new file mode 100644 index 0000000..e619d6f --- /dev/null +++ b/bootstrapper.py @@ -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}") diff --git a/build_bootstrapper.py b/build_bootstrapper.py new file mode 100644 index 0000000..b89ffac --- /dev/null +++ b/build_bootstrapper.py @@ -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() diff --git a/build_exe.bat b/build_exe.bat new file mode 100644 index 0000000..336ec27 --- /dev/null +++ b/build_exe.bat @@ -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 diff --git a/convert_icon.py b/convert_icon.py new file mode 100644 index 0000000..5584f90 --- /dev/null +++ b/convert_icon.py @@ -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}") diff --git a/download_icons.py b/download_icons.py new file mode 100644 index 0000000..2e3b237 --- /dev/null +++ b/download_icons.py @@ -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 "= 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() diff --git a/portable_build.py b/portable_build.py new file mode 100644 index 0000000..cd1ea66 --- /dev/null +++ b/portable_build.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c4ce426 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..12cc0ee --- /dev/null +++ b/run.bat @@ -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 diff --git a/run_source.bat b/run_source.bat new file mode 100644 index 0000000..078b1ee --- /dev/null +++ b/run_source.bat @@ -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 diff --git a/src/core/audio_engine.py b/src/core/audio_engine.py new file mode 100644 index 0000000..97e6664 --- /dev/null +++ b/src/core/audio_engine.py @@ -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() diff --git a/src/core/config.py b/src/core/config.py new file mode 100644 index 0000000..023a3d0 --- /dev/null +++ b/src/core/config.py @@ -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() diff --git a/src/core/debug_run_worker.bat b/src/core/debug_run_worker.bat new file mode 100644 index 0000000..4d42cb4 --- /dev/null +++ b/src/core/debug_run_worker.bat @@ -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 +) diff --git a/src/core/hotkey_manager.py b/src/core/hotkey_manager.py new file mode 100644 index 0000000..6a8f3fb --- /dev/null +++ b/src/core/hotkey_manager.py @@ -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() diff --git a/src/core/paths.py b/src/core/paths.py new file mode 100644 index 0000000..e47eb1e --- /dev/null +++ b/src/core/paths.py @@ -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" diff --git a/src/core/transcribe_worker.py b/src/core/transcribe_worker.py new file mode 100644 index 0000000..37a0a04 --- /dev/null +++ b/src/core/transcribe_worker.py @@ -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...") diff --git a/src/core/transcriber.py b/src/core/transcriber.py new file mode 100644 index 0000000..08d8060 --- /dev/null +++ b/src/core/transcriber.py @@ -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 diff --git a/src/ui/bridge.py b/src/ui/bridge.py new file mode 100644 index 0000000..13a1da2 --- /dev/null +++ b/src/ui/bridge.py @@ -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() diff --git a/src/ui/components.py b/src/ui/components.py new file mode 100644 index 0000000..f2ed188 --- /dev/null +++ b/src/ui/components.py @@ -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() diff --git a/src/ui/loader.py b/src/ui/loader.py new file mode 100644 index 0000000..9f7efc4 --- /dev/null +++ b/src/ui/loader.py @@ -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() diff --git a/src/ui/overlay.py b/src/ui/overlay.py new file mode 100644 index 0000000..87fb0af --- /dev/null +++ b/src/ui/overlay.py @@ -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) diff --git a/src/ui/qml/AUTHORS.txt b/src/ui/qml/AUTHORS.txt new file mode 100644 index 0000000..8814941 --- /dev/null +++ b/src/ui/qml/AUTHORS.txt @@ -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 + +JetBrains <> +Philipp Nurullin +Konstantin Bulenkov diff --git a/src/ui/qml/GlowButton.qml b/src/ui/qml/GlowButton.qml new file mode 100644 index 0000000..41020a9 --- /dev/null +++ b/src/ui/qml/GlowButton.qml @@ -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 } } + } + } +} diff --git a/src/ui/qml/JetBrainsMono.zip b/src/ui/qml/JetBrainsMono.zip new file mode 100644 index 0000000..1d05731 Binary files /dev/null and b/src/ui/qml/JetBrainsMono.zip differ diff --git a/src/ui/qml/Loader.qml b/src/ui/qml/Loader.qml new file mode 100644 index 0000000..9b8d23b --- /dev/null +++ b/src/ui/qml/Loader.qml @@ -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 + } + } +} diff --git a/src/ui/qml/ModernComboBox.qml b/src/ui/qml/ModernComboBox.qml new file mode 100644 index 0000000..0bb777d --- /dev/null +++ b/src/ui/qml/ModernComboBox.qml @@ -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 } + } + } +} diff --git a/src/ui/qml/ModernKeySequenceRecorder.qml b/src/ui/qml/ModernKeySequenceRecorder.qml new file mode 100644 index 0000000..e8c3cfe --- /dev/null +++ b/src/ui/qml/ModernKeySequenceRecorder.qml @@ -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 + } +} diff --git a/src/ui/qml/ModernSettingsItem.qml b/src/ui/qml/ModernSettingsItem.qml new file mode 100644 index 0000000..ab7abe6 --- /dev/null +++ b/src/ui/qml/ModernSettingsItem.qml @@ -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 + } +} diff --git a/src/ui/qml/ModernSettingsSection.qml b/src/ui/qml/ModernSettingsSection.qml new file mode 100644 index 0000000..10b0920 --- /dev/null +++ b/src/ui/qml/ModernSettingsSection.qml @@ -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" + } + } + } + } + } +} diff --git a/src/ui/qml/ModernSlider.qml b/src/ui/qml/ModernSlider.qml new file mode 100644 index 0000000..f5ea09c --- /dev/null +++ b/src/ui/qml/ModernSlider.qml @@ -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 + } +} diff --git a/src/ui/qml/ModernSwitch.qml b/src/ui/qml/ModernSwitch.qml new file mode 100644 index 0000000..d2db11b --- /dev/null +++ b/src/ui/qml/ModernSwitch.qml @@ -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 + } +} diff --git a/src/ui/qml/ModernTextField.qml b/src/ui/qml/ModernTextField.qml new file mode 100644 index 0000000..e71c82e --- /dev/null +++ b/src/ui/qml/ModernTextField.qml @@ -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 } } + } +} diff --git a/src/ui/qml/OFL.txt b/src/ui/qml/OFL.txt new file mode 100644 index 0000000..8bee414 --- /dev/null +++ b/src/ui/qml/OFL.txt @@ -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. diff --git a/src/ui/qml/Overlay.qml b/src/ui/qml/Overlay.qml new file mode 100644 index 0000000..04e2bd3 --- /dev/null +++ b/src/ui/qml/Overlay.qml @@ -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 } + } + } + } + } + } +} diff --git a/src/ui/qml/Settings.qml b/src/ui/qml/Settings.qml new file mode 100644 index 0000000..b5b5523 --- /dev/null +++ b/src/ui/qml/Settings.qml @@ -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) + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/src/ui/qml/SettingsStyle.qml b/src/ui/qml/SettingsStyle.qml new file mode 100644 index 0000000..91621eb --- /dev/null +++ b/src/ui/qml/SettingsStyle.qml @@ -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 +} diff --git a/src/ui/qml/crt.frag b/src/ui/qml/crt.frag new file mode 100644 index 0000000..17548ce --- /dev/null +++ b/src/ui/qml/crt.frag @@ -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; +} diff --git a/src/ui/qml/crt.qsb b/src/ui/qml/crt.qsb new file mode 100644 index 0000000..feea122 Binary files /dev/null and b/src/ui/qml/crt.qsb differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMono-Bold.ttf b/src/ui/qml/fonts/ttf/JetBrainsMono-Bold.ttf new file mode 100644 index 0000000..8c93043 Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMono-Bold.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMono-BoldItalic.ttf b/src/ui/qml/fonts/ttf/JetBrainsMono-BoldItalic.ttf new file mode 100644 index 0000000..1ddf216 Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMono-BoldItalic.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMono-ExtraBold.ttf b/src/ui/qml/fonts/ttf/JetBrainsMono-ExtraBold.ttf new file mode 100644 index 0000000..435d7a7 Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMono-ExtraBold.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMono-ExtraBoldItalic.ttf b/src/ui/qml/fonts/ttf/JetBrainsMono-ExtraBoldItalic.ttf new file mode 100644 index 0000000..79e616e Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMono-ExtraBoldItalic.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMono-ExtraLight.ttf b/src/ui/qml/fonts/ttf/JetBrainsMono-ExtraLight.ttf new file mode 100644 index 0000000..c131cbf Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMono-ExtraLight.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMono-ExtraLightItalic.ttf b/src/ui/qml/fonts/ttf/JetBrainsMono-ExtraLightItalic.ttf new file mode 100644 index 0000000..a768985 Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMono-ExtraLightItalic.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMono-Italic.ttf b/src/ui/qml/fonts/ttf/JetBrainsMono-Italic.ttf new file mode 100644 index 0000000..ccc9d6a Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMono-Italic.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMono-Light.ttf b/src/ui/qml/fonts/ttf/JetBrainsMono-Light.ttf new file mode 100644 index 0000000..15f15a2 Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMono-Light.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMono-LightItalic.ttf b/src/ui/qml/fonts/ttf/JetBrainsMono-LightItalic.ttf new file mode 100644 index 0000000..506208f Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMono-LightItalic.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMono-Medium.ttf b/src/ui/qml/fonts/ttf/JetBrainsMono-Medium.ttf new file mode 100644 index 0000000..9767115 Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMono-Medium.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMono-MediumItalic.ttf b/src/ui/qml/fonts/ttf/JetBrainsMono-MediumItalic.ttf new file mode 100644 index 0000000..415a9e3 Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMono-MediumItalic.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMono-Regular.ttf b/src/ui/qml/fonts/ttf/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..dff66cc Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMono-Regular.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMono-SemiBold.ttf b/src/ui/qml/fonts/ttf/JetBrainsMono-SemiBold.ttf new file mode 100644 index 0000000..a70e69b Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMono-SemiBold.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMono-SemiBoldItalic.ttf b/src/ui/qml/fonts/ttf/JetBrainsMono-SemiBoldItalic.ttf new file mode 100644 index 0000000..968602e Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMono-SemiBoldItalic.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMono-Thin.ttf b/src/ui/qml/fonts/ttf/JetBrainsMono-Thin.ttf new file mode 100644 index 0000000..7dbe2ac Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMono-Thin.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMono-ThinItalic.ttf b/src/ui/qml/fonts/ttf/JetBrainsMono-ThinItalic.ttf new file mode 100644 index 0000000..c6ad6c2 Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMono-ThinItalic.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMonoNL-Bold.ttf b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-Bold.ttf new file mode 100644 index 0000000..f78f84f Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-Bold.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMonoNL-BoldItalic.ttf b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-BoldItalic.ttf new file mode 100644 index 0000000..9fb8c83 Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-BoldItalic.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMonoNL-ExtraBold.ttf b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-ExtraBold.ttf new file mode 100644 index 0000000..fe5be6a Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-ExtraBold.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMonoNL-ExtraBoldItalic.ttf b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-ExtraBoldItalic.ttf new file mode 100644 index 0000000..59fc980 Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-ExtraBoldItalic.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMonoNL-ExtraLight.ttf b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-ExtraLight.ttf new file mode 100644 index 0000000..6da7b75 Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-ExtraLight.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMonoNL-ExtraLightItalic.ttf b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-ExtraLightItalic.ttf new file mode 100644 index 0000000..5733efc Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-ExtraLightItalic.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMonoNL-Italic.ttf b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-Italic.ttf new file mode 100644 index 0000000..4e9c380 Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-Italic.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMonoNL-Light.ttf b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-Light.ttf new file mode 100644 index 0000000..0b79b0c Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-Light.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMonoNL-LightItalic.ttf b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-LightItalic.ttf new file mode 100644 index 0000000..b5e0842 Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-LightItalic.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMonoNL-Medium.ttf b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-Medium.ttf new file mode 100644 index 0000000..1454372 Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-Medium.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMonoNL-MediumItalic.ttf b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-MediumItalic.ttf new file mode 100644 index 0000000..8d63c6c Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-MediumItalic.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMonoNL-Regular.ttf b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-Regular.ttf new file mode 100644 index 0000000..70d2ec9 Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-Regular.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMonoNL-SemiBold.ttf b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-SemiBold.ttf new file mode 100644 index 0000000..ce60a88 Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-SemiBold.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMonoNL-SemiBoldItalic.ttf b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-SemiBoldItalic.ttf new file mode 100644 index 0000000..3b3f8f6 Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-SemiBoldItalic.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMonoNL-Thin.ttf b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-Thin.ttf new file mode 100644 index 0000000..bea837e Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-Thin.ttf differ diff --git a/src/ui/qml/fonts/ttf/JetBrainsMonoNL-ThinItalic.ttf b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-ThinItalic.ttf new file mode 100644 index 0000000..f0bfed7 Binary files /dev/null and b/src/ui/qml/fonts/ttf/JetBrainsMonoNL-ThinItalic.ttf differ diff --git a/src/ui/qml/fonts/variable/JetBrainsMono-Italic[wght].ttf b/src/ui/qml/fonts/variable/JetBrainsMono-Italic[wght].ttf new file mode 100644 index 0000000..5414835 Binary files /dev/null and b/src/ui/qml/fonts/variable/JetBrainsMono-Italic[wght].ttf differ diff --git a/src/ui/qml/fonts/variable/JetBrainsMono[wght].ttf b/src/ui/qml/fonts/variable/JetBrainsMono[wght].ttf new file mode 100644 index 0000000..b60e77f Binary files /dev/null and b/src/ui/qml/fonts/variable/JetBrainsMono[wght].ttf differ diff --git a/src/ui/qml/fonts/webfonts/JetBrainsMono-Bold.woff2 b/src/ui/qml/fonts/webfonts/JetBrainsMono-Bold.woff2 new file mode 100644 index 0000000..4917f43 Binary files /dev/null and b/src/ui/qml/fonts/webfonts/JetBrainsMono-Bold.woff2 differ diff --git a/src/ui/qml/fonts/webfonts/JetBrainsMono-BoldItalic.woff2 b/src/ui/qml/fonts/webfonts/JetBrainsMono-BoldItalic.woff2 new file mode 100644 index 0000000..536d3f7 Binary files /dev/null and b/src/ui/qml/fonts/webfonts/JetBrainsMono-BoldItalic.woff2 differ diff --git a/src/ui/qml/fonts/webfonts/JetBrainsMono-ExtraBold.woff2 b/src/ui/qml/fonts/webfonts/JetBrainsMono-ExtraBold.woff2 new file mode 100644 index 0000000..8f88c54 Binary files /dev/null and b/src/ui/qml/fonts/webfonts/JetBrainsMono-ExtraBold.woff2 differ diff --git a/src/ui/qml/fonts/webfonts/JetBrainsMono-ExtraBoldItalic.woff2 b/src/ui/qml/fonts/webfonts/JetBrainsMono-ExtraBoldItalic.woff2 new file mode 100644 index 0000000..d1478ba Binary files /dev/null and b/src/ui/qml/fonts/webfonts/JetBrainsMono-ExtraBoldItalic.woff2 differ diff --git a/src/ui/qml/fonts/webfonts/JetBrainsMono-ExtraLight.woff2 b/src/ui/qml/fonts/webfonts/JetBrainsMono-ExtraLight.woff2 new file mode 100644 index 0000000..b97239f Binary files /dev/null and b/src/ui/qml/fonts/webfonts/JetBrainsMono-ExtraLight.woff2 differ diff --git a/src/ui/qml/fonts/webfonts/JetBrainsMono-ExtraLightItalic.woff2 b/src/ui/qml/fonts/webfonts/JetBrainsMono-ExtraLightItalic.woff2 new file mode 100644 index 0000000..be01aac Binary files /dev/null and b/src/ui/qml/fonts/webfonts/JetBrainsMono-ExtraLightItalic.woff2 differ diff --git a/src/ui/qml/fonts/webfonts/JetBrainsMono-Italic.woff2 b/src/ui/qml/fonts/webfonts/JetBrainsMono-Italic.woff2 new file mode 100644 index 0000000..d60c270 Binary files /dev/null and b/src/ui/qml/fonts/webfonts/JetBrainsMono-Italic.woff2 differ diff --git a/src/ui/qml/fonts/webfonts/JetBrainsMono-Light.woff2 b/src/ui/qml/fonts/webfonts/JetBrainsMono-Light.woff2 new file mode 100644 index 0000000..6538498 Binary files /dev/null and b/src/ui/qml/fonts/webfonts/JetBrainsMono-Light.woff2 differ diff --git a/src/ui/qml/fonts/webfonts/JetBrainsMono-LightItalic.woff2 b/src/ui/qml/fonts/webfonts/JetBrainsMono-LightItalic.woff2 new file mode 100644 index 0000000..66ca3d2 Binary files /dev/null and b/src/ui/qml/fonts/webfonts/JetBrainsMono-LightItalic.woff2 differ diff --git a/src/ui/qml/fonts/webfonts/JetBrainsMono-Medium.woff2 b/src/ui/qml/fonts/webfonts/JetBrainsMono-Medium.woff2 new file mode 100644 index 0000000..669d04c Binary files /dev/null and b/src/ui/qml/fonts/webfonts/JetBrainsMono-Medium.woff2 differ diff --git a/src/ui/qml/fonts/webfonts/JetBrainsMono-MediumItalic.woff2 b/src/ui/qml/fonts/webfonts/JetBrainsMono-MediumItalic.woff2 new file mode 100644 index 0000000..80cfd15 Binary files /dev/null and b/src/ui/qml/fonts/webfonts/JetBrainsMono-MediumItalic.woff2 differ diff --git a/src/ui/qml/fonts/webfonts/JetBrainsMono-Regular.woff2 b/src/ui/qml/fonts/webfonts/JetBrainsMono-Regular.woff2 new file mode 100644 index 0000000..40da427 Binary files /dev/null and b/src/ui/qml/fonts/webfonts/JetBrainsMono-Regular.woff2 differ diff --git a/src/ui/qml/fonts/webfonts/JetBrainsMono-SemiBold.woff2 b/src/ui/qml/fonts/webfonts/JetBrainsMono-SemiBold.woff2 new file mode 100644 index 0000000..5ead7b0 Binary files /dev/null and b/src/ui/qml/fonts/webfonts/JetBrainsMono-SemiBold.woff2 differ diff --git a/src/ui/qml/fonts/webfonts/JetBrainsMono-SemiBoldItalic.woff2 b/src/ui/qml/fonts/webfonts/JetBrainsMono-SemiBoldItalic.woff2 new file mode 100644 index 0000000..c5dd294 Binary files /dev/null and b/src/ui/qml/fonts/webfonts/JetBrainsMono-SemiBoldItalic.woff2 differ diff --git a/src/ui/qml/fonts/webfonts/JetBrainsMono-Thin.woff2 b/src/ui/qml/fonts/webfonts/JetBrainsMono-Thin.woff2 new file mode 100644 index 0000000..17270e4 Binary files /dev/null and b/src/ui/qml/fonts/webfonts/JetBrainsMono-Thin.woff2 differ diff --git a/src/ui/qml/fonts/webfonts/JetBrainsMono-ThinItalic.woff2 b/src/ui/qml/fonts/webfonts/JetBrainsMono-ThinItalic.woff2 new file mode 100644 index 0000000..a643215 Binary files /dev/null and b/src/ui/qml/fonts/webfonts/JetBrainsMono-ThinItalic.woff2 differ diff --git a/src/ui/qml/glass.frag b/src/ui/qml/glass.frag new file mode 100644 index 0000000..8b9c45f --- /dev/null +++ b/src/ui/qml/glass.frag @@ -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; +} diff --git a/src/ui/qml/glass.qsb b/src/ui/qml/glass.qsb new file mode 100644 index 0000000..09ab52f Binary files /dev/null and b/src/ui/qml/glass.qsb differ diff --git a/src/ui/qml/glow.frag b/src/ui/qml/glow.frag new file mode 100644 index 0000000..8afdb74 --- /dev/null +++ b/src/ui/qml/glow.frag @@ -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; +} diff --git a/src/ui/qml/glow.qsb b/src/ui/qml/glow.qsb new file mode 100644 index 0000000..824e3aa Binary files /dev/null and b/src/ui/qml/glow.qsb differ diff --git a/src/ui/qml/gradient_blobs.frag b/src/ui/qml/gradient_blobs.frag new file mode 100644 index 0000000..14dd8d5 --- /dev/null +++ b/src/ui/qml/gradient_blobs.frag @@ -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); +} diff --git a/src/ui/qml/gradient_blobs.qsb b/src/ui/qml/gradient_blobs.qsb new file mode 100644 index 0000000..9e764a7 Binary files /dev/null and b/src/ui/qml/gradient_blobs.qsb differ diff --git a/src/ui/qml/icon.ico b/src/ui/qml/icon.ico new file mode 100644 index 0000000..cc5cc26 Binary files /dev/null and b/src/ui/qml/icon.ico differ diff --git a/src/ui/qml/microphone.svg b/src/ui/qml/microphone.svg new file mode 100644 index 0000000..d07a004 --- /dev/null +++ b/src/ui/qml/microphone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/qml/noise.frag b/src/ui/qml/noise.frag new file mode 100644 index 0000000..713646a --- /dev/null +++ b/src/ui/qml/noise.frag @@ -0,0 +1,25 @@ +#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; +}; + +// High-quality pseudo-random function +float rand(vec2 co) { + return fract(sin(dot(co.xy ,vec2(12.9898,78.233))) * 43758.5453); +} + +void main() { + // Dynamic Noise based on Time + // We add 'time' to the coordinate to animate the grain + float noise = rand(qt_TexCoord0 + vec2(time * 0.01, time * 0.02)); + + // Output grayscale noise with alpha modulation + // We want white noise, applied with qt_Opacity + fragColor = vec4(noise, noise, noise, 1.0) * qt_Opacity; +} diff --git a/src/ui/qml/noise.qsb b/src/ui/qml/noise.qsb new file mode 100644 index 0000000..70a1ff5 Binary files /dev/null and b/src/ui/qml/noise.qsb differ diff --git a/src/ui/qml/qmldir b/src/ui/qml/qmldir new file mode 100644 index 0000000..8172734 --- /dev/null +++ b/src/ui/qml/qmldir @@ -0,0 +1,3 @@ +singleton SettingsStyle 1.0 SettingsStyle.qml +ModernSettingsSection 1.0 ModernSettingsSection.qml +ModernSettingsItem 1.0 ModernSettingsItem.qml diff --git a/src/ui/qml/rainbow_wave.frag b/src/ui/qml/rainbow_wave.frag new file mode 100644 index 0000000..eb352b8 --- /dev/null +++ b/src/ui/qml/rainbow_wave.frag @@ -0,0 +1,65 @@ +#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 amplitude; // Audio amplitude 0.0 to 1.0 +}; + +// Smooth rainbow gradient +vec3 rainbow(float t) { + t = fract(t); + float r = abs(t * 6.0 - 3.0) - 1.0; + float g = 2.0 - abs(t * 6.0 - 2.0); + float b = 2.0 - abs(t * 6.0 - 4.0); + return clamp(vec3(r, g, b), 0.0, 1.0); +} + +// Smooth waveform function +float wave(float x, float t, float amp) { + // Multiple sine waves for organic movement + float w1 = sin(x * 3.0 + t * 2.0) * 0.3; + float w2 = sin(x * 5.0 - t * 1.5) * 0.2; + float w3 = sin(x * 7.0 + t * 3.0) * 0.1; + return (w1 + w2 + w3) * amp; +} + +void main() { + vec2 uv = qt_TexCoord0; + vec2 p = uv * 2.0 - 1.0; // Center coordinates + + float t = time * 0.1; + float amp = amplitude * 1.92 + 0.1; // Reduced by another 20% from 2.4 to 1.92 + + // Calculate distance to waveform (dual waves, mirrored) + float wave1 = wave(uv.x * 3.14159, t, amp); + float wave2 = -wave1; // Mirror + + float dist1 = abs(p.y - wave1); + float dist2 = abs(p.y - wave2); + float dist = min(dist1, dist2); + + // Wide cinematic glow + float glow = 1.0 / (dist * 20.0 + 1.0); + glow = pow(glow, 1.5); // Sharpen the glow + + // Rainbow color based on position and time + float colorPos = uv.x + t * 0.2; + vec3 color = rainbow(colorPos); + + // EVEN longer fade zones for maximum gradual edges (0-45% and 55-100%) + float fadePos = uv.x; + float leftFade = smoothstep(0.0, 0.45, fadePos); + float rightFade = smoothstep(1.0, 0.55, fadePos); + float fade = leftFade * rightFade; + + // Combine everything + vec3 finalColor = color * glow * fade; + float alpha = glow * fade * qt_Opacity; + + fragColor = vec4(finalColor, alpha); +} diff --git a/src/ui/qml/rainbow_wave.qsb b/src/ui/qml/rainbow_wave.qsb new file mode 100644 index 0000000..e559195 Binary files /dev/null and b/src/ui/qml/rainbow_wave.qsb differ diff --git a/src/ui/qml/settings.png b/src/ui/qml/settings.png new file mode 100644 index 0000000..d8dbd4c Binary files /dev/null and b/src/ui/qml/settings.png differ diff --git a/src/ui/qml/settings.svg b/src/ui/qml/settings.svg new file mode 100644 index 0000000..55a88e2 --- /dev/null +++ b/src/ui/qml/settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/qml/smart_toy.png b/src/ui/qml/smart_toy.png new file mode 100644 index 0000000..274d80d Binary files /dev/null and b/src/ui/qml/smart_toy.png differ diff --git a/src/ui/qml/smart_toy.svg b/src/ui/qml/smart_toy.svg new file mode 100644 index 0000000..dbd157b --- /dev/null +++ b/src/ui/qml/smart_toy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/qml/terminal.svg b/src/ui/qml/terminal.svg new file mode 100644 index 0000000..1bbb89e --- /dev/null +++ b/src/ui/qml/terminal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/qml/visibility.png b/src/ui/qml/visibility.png new file mode 100644 index 0000000..d8db75f Binary files /dev/null and b/src/ui/qml/visibility.png differ diff --git a/src/ui/qml/visibility.svg b/src/ui/qml/visibility.svg new file mode 100644 index 0000000..e2c014d --- /dev/null +++ b/src/ui/qml/visibility.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/settings.py b/src/ui/settings.py new file mode 100644 index 0000000..c603343 --- /dev/null +++ b/src/ui/settings.py @@ -0,0 +1,236 @@ +""" +Settings Window Module. +======================= + +Manages the application configuration UI. +Refactored for 2026 Premium Aesthetics with Sidebar navigation. +""" + +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QStackedWidget, + QLabel, QComboBox, QFormLayout, QFrame, QMessageBox, QScrollArea +) +from PySide6.QtCore import Qt, Signal, Slot, QSize +from PySide6.QtGui import QFont, QIcon + +from src.core.config import ConfigManager +from src.ui.styles import Theme, StyleGenerator, load_modern_fonts +from src.ui.components import FramelessWindow, ModernFrame, GlassButton, ModernSwitch, ModernSlider +import sounddevice as sd + +class SettingsWindow(FramelessWindow): + """ + The main settings dialog. + Refactored with 2026 Premium Sidebar Layout. + """ + settings_changed = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self.config = ConfigManager() + self.setFixedSize(700, 500) + + # Main Container + self.bg_frame = ModernFrame() + self.bg_frame.setStyleSheet(StyleGenerator.get_glass_card(radius=20)) + + self.root_layout = QVBoxLayout(self) + self.root_layout.setContentsMargins(10, 10, 10, 10) + self.root_layout.addWidget(self.bg_frame) + + # Title Bar Area (Inside glass card) + self.title_layout = QHBoxLayout() + self.title_layout.setContentsMargins(20, 15, 20, 0) + + title_lbl = QLabel("PREMIUM SETTINGS") + title_lbl.setFont(load_modern_fonts()) + title_lbl.setStyleSheet(f"color: white; font-weight: 900; font-size: 14px; letter-spacing: 2px;") + self.title_layout.addWidget(title_lbl) + + self.title_layout.addStretch() + + self.btn_close = GlassButton("Ɨ", accent_color="#ff4b4b") + self.btn_close.setFixedSize(30, 30) + self.btn_close.clicked.connect(self.close) + self.title_layout.addWidget(self.btn_close) + + # Central Layout (Sidebar + Content) + self.content_layout = QHBoxLayout() + self.content_layout.setContentsMargins(10, 10, 10, 10) + self.content_layout.setSpacing(10) + + # 1. SIDEBAR + self.sidebar = QWidget() + self.sidebar.setFixedWidth(160) + self.sidebar_layout = QVBoxLayout(self.sidebar) + self.sidebar_layout.setContentsMargins(0, 10, 0, 10) + self.sidebar_layout.setSpacing(8) + + self.nav_general = GlassButton("General") + self.nav_audio = GlassButton("Audio") + self.nav_visuals = GlassButton("Visuals") + self.nav_advanced = GlassButton("Advanced/AI") + + self.sidebar_layout.addWidget(self.nav_general) + self.sidebar_layout.addWidget(self.nav_audio) + self.sidebar_layout.addWidget(self.nav_visuals) + self.sidebar_layout.addWidget(self.nav_advanced) + self.sidebar_layout.addStretch() + + self.btn_save = GlassButton("SAVE CHANGES", accent_color=Theme.ACCENT_GREEN) + self.btn_save.clicked.connect(self.save_settings) + self.sidebar_layout.addWidget(self.btn_save) + + # 2. CONTENT STACK + self.stack = QStackedWidget() + self.stack.setStyleSheet("background: transparent;") + + # Connect sidebar to stack + self.nav_general.clicked.connect(lambda: self.stack.setCurrentIndex(0)) + self.nav_audio.clicked.connect(lambda: self.stack.setCurrentIndex(1)) + self.nav_visuals.clicked.connect(lambda: self.stack.setCurrentIndex(2)) + self.nav_advanced.clicked.connect(lambda: self.stack.setCurrentIndex(3)) + + # Main Layout Assembly + self.inner_layout = QVBoxLayout(self.bg_frame) + self.inner_layout.addLayout(self.title_layout) + self.inner_layout.addLayout(self.content_layout) + + self.content_layout.addWidget(self.sidebar) + self.content_layout.addWidget(self.stack) + + self.setup_pages() + self.load_values() + + def setup_pages(self): + """Creates the settings pages.""" + # --- GENERAL --- + self.page_general = QWidget() + l1 = QFormLayout(self.page_general) + l1.setVerticalSpacing(20) + + self.inp_hotkey = QComboBox() + self.inp_hotkey.addItems(["f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", "f11", "f12", "caps lock"]) + self.inp_hotkey.setStyleSheet(f"background: {Theme.BG_DARK}; border-radius: 4px; padding: 5px; color: white;") + l1.addRow(self.create_lbl("Global Hotkey:"), self.inp_hotkey) + + self.chk_top = ModernSwitch() + l1.addRow(self.create_lbl("Always on Top:"), self.chk_top) + + self.stack.addWidget(self.page_general) + + # --- AUDIO --- + self.page_audio = QWidget() + l2 = QFormLayout(self.page_audio) + l2.setVerticalSpacing(15) + + self.inp_device = QComboBox() + self.inp_device.setStyleSheet(f"background: {Theme.BG_DARK}; border-radius: 4px; padding: 5px; color: white;") + self.populate_audio_devices() + l2.addRow(self.create_lbl("Input Device:"), self.inp_device) + + self.sld_threshold = ModernSlider(Qt.Horizontal) + self.sld_threshold.setRange(1, 25) + self.lbl_threshold = self.create_lbl("2%") + self.sld_threshold.valueChanged.connect(lambda v: self.lbl_threshold.setText(f"{v}%")) + l2.addRow(self.create_lbl("Noise Gate:"), self.sld_threshold) + l2.addRow("", self.lbl_threshold) + + self.sld_duration = ModernSlider(Qt.Horizontal) + self.sld_duration.setRange(5, 50) + self.lbl_duration = self.create_lbl("1.0s") + self.sld_duration.valueChanged.connect(lambda v: self.lbl_duration.setText(f"{v/10}s")) + l2.addRow(self.create_lbl("Auto-Submit:"), self.sld_duration) + l2.addRow("", self.lbl_duration) + + self.stack.addWidget(self.page_audio) + + # --- VISUALS --- + self.page_visuals = QWidget() + l3 = QFormLayout(self.page_visuals) + l3.setVerticalSpacing(20) + + self.inp_style = QComboBox() + self.inp_style.addItem("Neon Line (Recommended)", "line") + self.inp_style.addItem("Classic Bars", "bar") + self.inp_style.setStyleSheet(f"background: {Theme.BG_DARK}; border-radius: 4px; padding: 5px; color: white;") + l3.addRow(self.create_lbl("Visualizer:"), self.inp_style) + + self.sld_opacity = ModernSlider(Qt.Horizontal) + self.sld_opacity.setRange(40, 100) + self.lbl_opacity = self.create_lbl("100%") + self.sld_opacity.valueChanged.connect(lambda v: self.lbl_opacity.setText(f"{v}%")) + l3.addRow(self.create_lbl("Opacity:"), self.sld_opacity) + l3.addRow("", self.lbl_opacity) + + self.stack.addWidget(self.page_visuals) + + # --- ADVANCED --- + self.page_adv = QWidget() + l4 = QFormLayout(self.page_adv) + l4.setVerticalSpacing(15) + + self.inp_model = QComboBox() + self.inp_model.setStyleSheet(f"background: {Theme.BG_DARK}; border-radius: 4px; padding: 5px; color: white;") + for id, name in [("tiny", "Tiny (Fast)"), ("base", "Base"), ("small", "Small (Default)"), ("medium", "Medium"), ("large-v3", "Large V3")]: + self.inp_model.addItem(name, id) + l4.addRow(self.create_lbl("Model:"), self.inp_model) + + info = QLabel("Large models provide higher accuracy but require significant RAM/VRAM.") + info.setWordWrap(True) + info.setStyleSheet(f"color: {Theme.TEXT_SECONDARY}; font-style: italic; font-size: 11px;") + l4.addRow("", info) + + self.stack.addWidget(self.page_adv) + + def create_lbl(self, text): + lbl = QLabel(text) + lbl.setStyleSheet(f"color: {Theme.TEXT_SECONDARY}; font-weight: 600; font-size: 13px;") + return lbl + + def populate_audio_devices(self): + try: + self.inp_device.addItem("System Default", -1) + for i, dev in enumerate(sd.query_devices()): + if dev['max_input_channels'] > 0: + self.inp_device.addItem(dev['name'], i) + except: pass + + def load_values(self): + self.inp_hotkey.setCurrentText(self.config.get("hotkey")) + self.chk_top.setChecked(self.config.get("always_on_top")) + + dev_id = self.config.get("input_device") + idx = self.inp_device.findData(dev_id if dev_id is not None else -1) + if idx >= 0: self.inp_device.setCurrentIndex(idx) + + self.sld_threshold.setValue(int(self.config.get("silence_threshold") * 100)) + self.sld_duration.setValue(int(self.config.get("silence_duration") * 10)) + + idx = self.inp_style.findData(self.config.get("visualizer_style")) + if idx >= 0: self.inp_style.setCurrentIndex(idx) + + self.sld_opacity.setValue(int(self.config.get("opacity") * 100)) + + idx = self.inp_model.findData(self.config.get("model_size")) + if idx >= 0: self.inp_model.setCurrentIndex(idx) + + def save_settings(self): + updates = { + "hotkey": self.inp_hotkey.currentText(), + "always_on_top": self.chk_top.isChecked(), + "input_device": self.inp_device.currentData() if self.inp_device.currentData() != -1 else None, + "silence_threshold": self.sld_threshold.value() / 100.0, + "silence_duration": self.sld_duration.value() / 10.0, + "visualizer_style": self.inp_style.currentData(), + "opacity": self.sld_opacity.value() / 100.0, + "model_size": self.inp_model.currentData() + } + + new_model = updates["model_size"] + if new_model != self.config.get("model_size"): + QMessageBox.information(self, "Model Updated", f"Downloaded {new_model} on next launch.") + + self.config.set_bulk(updates) + self.settings_changed.emit() + self.close() diff --git a/src/ui/styles.py b/src/ui/styles.py new file mode 100644 index 0000000..436f82a --- /dev/null +++ b/src/ui/styles.py @@ -0,0 +1,62 @@ +""" +Style Engine Module. +==================== + +Centralized design system for the 2026 Premium UI. +Defines color palettes, glassmorphism templates, and modern font loading. +""" + +from PySide6.QtGui import QColor, QFont, QFontDatabase +import os + +class Theme: + """Premium Dark Theme Palette (2026 Edition).""" + # Backgrounds + BG_DARK = "#0d0d12" # Deep cosmic black + BG_CARD = "#16161e" # Slightly lighter for components + BG_GLASS = "rgba(22, 22, 30, 0.7)" # Semi-transparent for glass effect + + # Neons & Accents + ACCENT_CYAN = "#00f2ff" # Electric cyan + ACCENT_PURPLE = "#7000ff" # Deep cyber purple + ACCENT_GREEN = "#00ff88" # Mint neon + + # Text + TEXT_PRIMARY = "#ffffff" # Pure white + TEXT_SECONDARY = "#9499b0" # Muted blue-gray + TEXT_MUTED = "#565f89" # Darker blue-gray + + # Borders + BORDER_SUBTLE = "rgba(100, 100, 150, 0.2)" + BORDER_GLOW = "rgba(0, 242, 255, 0.5)" + +class StyleGenerator: + """Generates QSS strings for complex effects.""" + + @staticmethod + def get_glass_card(radius=12, border=True): + """Returns QSS for a glassmorphism card.""" + border_css = f"border: 1px solid {Theme.BORDER_SUBTLE};" if border else "border: none;" + return f""" + background-color: {Theme.BG_GLASS}; + border-radius: {radius}px; + {border_css} + """ + + @staticmethod + def get_glow_border(color=Theme.ACCENT_CYAN): + """Returns QSS for a glowing border state.""" + return f"border: 1px solid {color};" + +def load_modern_fonts(): + """Attempts to load a modern font stack for the 2026 look.""" + # Preferred order: Segoe UI Variable, Inter, Segoe UI, sans-serif + families = ["Segoe UI Variable Text", "Inter", "Segoe UI", "sans-serif"] + + for family in families: + font = QFont(family, 10) + if QFontDatabase.families().count(family) > 0: + return font + + # Absolute fallback + return QFont("Arial", 10) diff --git a/src/ui/tray.py b/src/ui/tray.py new file mode 100644 index 0000000..a156b34 --- /dev/null +++ b/src/ui/tray.py @@ -0,0 +1,78 @@ +""" +System Tray Module. +=================== + +This module handles the application's persistent background presence. +It creates an icon in the Windows System Tray (Notification Area), allowing +interaction even when the main overlay is hidden. + +Classes: + SystemTray: QSystemTrayIcon implementation. +""" + +from PySide6.QtWidgets import QSystemTrayIcon, QMenu +from PySide6.QtGui import QIcon +from PySide6.QtCore import Signal + +class SystemTray(QSystemTrayIcon): + """ + Manages the System Tray icon and menu. + + Signals: + quit_requested: Emitted when the user selects 'Quit' from the menu. + """ + quit_requested = Signal() + settings_requested = Signal() + transcribe_file_requested = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + + # Load Icon + # Note: In a production build, this should be verified to exist. + # If 'assets/icon.ico' is missing, Qt may show an empty space or default icon. + self.setIcon(QIcon("assets/icon.ico")) + + self.menu = QMenu(parent) + self.menu.setStyleSheet(""" + QMenu { + background-color: #1a1a20; + color: #ffffff; + border: 1px solid #40ffffff; + border-radius: 8px; + padding: 4px; + } + QMenu::item { + padding: 8px 32px 8px 16px; + border-radius: 4px; + background-color: transparent; + margin: 2px; + } + QMenu::item:selected { + background-color: #00f2ff; + color: #000000; + } + QMenu::separator { + height: 1px; + background: #40ffffff; + margin: 4px 10px; + } + """) + + # Add Actions + self.action_settings = self.menu.addAction("Settings") + self.action_settings.triggered.connect(self.settings_requested.emit) + + self.action_transcribe = self.menu.addAction("Transcribe Audio File...") + self.action_transcribe.triggered.connect(self.transcribe_file_requested.emit) + + self.menu.addSeparator() + + self.quit_action = self.menu.addAction("Quit Whisper Voice") + self.quit_action.triggered.connect(self.quit_requested.emit) + + self.setContextMenu(self.menu) + self.setToolTip("Whisper Voice (Ready) - Press F8 to Record") + + # Display the icon + self.show() diff --git a/src/ui/visualizer.py b/src/ui/visualizer.py new file mode 100644 index 0000000..1e71772 --- /dev/null +++ b/src/ui/visualizer.py @@ -0,0 +1,117 @@ +""" +Audio Visualizer Module. +======================== + +High-Fidelity rendering for the 2026 Premium UI. +Supports 'Classic Bars' and 'Neon Line' with smooth curves and glows. +""" + +from PySide6.QtWidgets import QWidget +from PySide6.QtCore import Qt, QTimer, Slot, QRectF, QPointF +from PySide6.QtGui import QPainter, QBrush, QColor, QPainterPath, QPen, QLinearGradient +import random + +from src.ui.styles import Theme + +class AudioVisualizer(QWidget): + """ + A premium audio visualizer with smooth physics and neon aesthetics. + """ + + def __init__(self, parent=None): + super().__init__(parent) + self.amplitude = 0.0 + self.bars = 12 + self.history = [0.0] * self.bars + + # High-refresh timer for silky smooth motion + self.timer = QTimer(self) + self.timer.timeout.connect(self.update_animation) + self.timer.start(16) # ~60 FPS + + @Slot(float) + def set_amplitude(self, amp: float): + self.amplitude = amp + + def update_animation(self): + self.history.pop(0) + # Smooth interpolation + noise + jitter = random.uniform(0.01, 0.03) + # Decay logic: Gravity-like pull + self.history.append(max(self.amplitude, jitter)) + self.update() + + def paintEvent(self, event): + from src.core.config import ConfigManager + style = ConfigManager().get("visualizer_style") + + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + + w, h = self.width(), self.height() + painter.translate(0, h / 2) + + if style == "bar": + self._draw_bars(painter, w, h) + else: + self._draw_line(painter, w, h) + + def _draw_bars(self, painter, w, h): + bar_w = w / self.bars + spacing = 3 + + for i, val in enumerate(self.history): + bar_h = val * (h * 0.9) + x = i * bar_w + + # Gradient Bar + grad = QLinearGradient(0, -bar_h/2, 0, bar_h/2) + grad.setColorAt(0, QColor(Theme.ACCENT_PURPLE)) + grad.setColorAt(1, QColor(Theme.ACCENT_CYAN)) + + painter.setBrush(grad) + painter.setPen(Qt.NoPen) + painter.drawRoundedRect(QRectF(x + spacing, -bar_h/2, bar_w - spacing*2, bar_h), 3, 3) + + def _draw_line(self, painter, w, h): + path = QPainterPath() + points = len(self.history) + dx = w / (points - 1) + + path.moveTo(0, 0) + + def get_path(multi): + p = QPainterPath() + p.moveTo(0, 0) + for i in range(points): + curr_x = i * dx + curr_y = -self.history[i] * (h * 0.45) * multi + if i == 0: + p.moveTo(curr_x, curr_y) + else: + prev_x = (i-1) * dx + # Simple lerp or quadTo for smoothness + p.lineTo(curr_x, curr_y) + return p + + # Draw Top & Bottom + p_top = get_path(1) + p_bot = get_path(-1) + + # Glow layer + glow_pen = QPen(QColor(Theme.ACCENT_CYAN)) + glow_pen.setWidth(4) + glow_alpha = QColor(Theme.ACCENT_CYAN) + glow_alpha.setAlpha(60) + glow_pen.setColor(glow_alpha) + + painter.setPen(glow_pen) + painter.drawPath(p_top) + painter.drawPath(p_bot) + + # Core layer + core_pen = QPen(Qt.white) + core_pen.setWidth(2) + painter.setPen(core_pen) + painter.drawPath(p_top) + painter.drawPath(p_bot) diff --git a/src/utils/injector.py b/src/utils/injector.py new file mode 100644 index 0000000..fe7f220 --- /dev/null +++ b/src/utils/injector.py @@ -0,0 +1,60 @@ +""" +Input Injection Utility. +======================== + +This module handles the simulation of user input to 'paste' text +into the currently active application. It relies on clipboard manipulation +combined with keyboard shortcuts (Ctrl+V). +""" + +import pyperclip +import keyboard +import time +import logging + +class InputInjector: + """ + Static utility class for text injection. + """ + + @staticmethod + def inject_text(text: str, method: str = "Clipboard Paste", speed: int = 100): + """ + Injects the provided text into the currently focused UI element. + + Args: + text (str): The string to inject. + method (str): "Clipboard Paste" or "Simulate Typing" + speed (int): Chars per minute for typing. + """ + if not text: + return + + try: + logging.info(f"Injecting text via {method}...") + + if method == "Simulate Typing": + # CPM to delay calculation + # 60 seconds / speed chars = seconds per char + delay = 60.0 / max(1, speed) + keyboard.write(text, delay=delay) + + else: + # Default: Clipboard Paste + # We purposefully do not backup/restore the clipboard + # because it is slow and error-prone with large clipboard contents. + # Ideally, we accept that this tool overwrites the clipboard. + + # Step 1: Copy to Clipboard + pyperclip.copy(text) + + # Step 2: Tiny wait to ensure OS clipboard update propagates + time.sleep(0.1) + + # Step 3: Send Paste Command + keyboard.send('ctrl+v') + + logging.info("Text injection complete.") + + except Exception as e: + logging.error(f"Failed to inject text: {e}") diff --git a/src/utils/window_hook.py b/src/utils/window_hook.py new file mode 100644 index 0000000..ffb06f9 --- /dev/null +++ b/src/utils/window_hook.py @@ -0,0 +1,116 @@ +import ctypes +from ctypes import wintypes +import logging + +# Win32 Types & Constants +# Use standard wintypes to ensure architecture safety (32-bit vs 64-bit) +WPARAM = wintypes.WPARAM +LPARAM = wintypes.LPARAM +HWND = wintypes.HWND +UINT = wintypes.UINT + +# LRESULT is technically LONG_PTR (signed) +# In 64-bit, c_longlong. In 32-bit, c_long. +if ctypes.sizeof(ctypes.c_void_p) == 8: + LRESULT = ctypes.c_longlong +else: + LRESULT = ctypes.c_long + +# Callback Type +WNDPROC = ctypes.WINFUNCTYPE(LRESULT, HWND, UINT, WPARAM, LPARAM) + +GWLP_WNDPROC = -4 +WM_NCHITTEST = 0x0084 +HTTRANSPARENT = -1 +HTCLIENT = 1 +HTCAPTION = 2 + +user32 = ctypes.windll.user32 + +# SetWindowLongPtr Check +if hasattr(user32, "SetWindowLongPtrW"): + SetWindowLongPtr = user32.SetWindowLongPtrW + SetWindowLongPtr.argtypes = [HWND, ctypes.c_int, ctypes.c_void_p] + SetWindowLongPtr.restype = ctypes.c_void_p +else: + SetWindowLongPtr = user32.SetWindowLongW + SetWindowLongPtr.argtypes = [HWND, ctypes.c_int, ctypes.c_long] + SetWindowLongPtr.restype = ctypes.c_long + +# Define ArgTypes to prevent truncation/stack corruption +user32.CallWindowProcW.argtypes = [ctypes.c_void_p, HWND, UINT, WPARAM, LPARAM] +user32.CallWindowProcW.restype = LRESULT + +user32.ScreenToClient.argtypes = [HWND, ctypes.POINTER(wintypes.POINT)] +user32.ScreenToClient.restype = ctypes.c_bool + +# DPI API +try: + GetDpiForWindow = user32.GetDpiForWindow + GetDpiForWindow.argtypes = [HWND] + GetDpiForWindow.restype = UINT +except AttributeError: + GetDpiForWindow = None + +def LOWORD(l): return l & 0xffff +def HIWORD(l): return (l >> 16) & 0xffff + +class WindowHook: + def __init__(self, hwnd, width, height, initial_scale=1.0): + self.hwnd = hwnd + self.old_wnd_proc = None + self.new_wnd_proc = WNDPROC(self.wnd_proc_callback) + + # TIGHT FIT: [20, 20, 400, 120] + # (Window 420x140, Pill 380x100) + self.logical_rect = [20, 20, 20+380, 20+100] + self.current_scale = initial_scale + + def install(self): + proc_address = ctypes.cast(self.new_wnd_proc, ctypes.c_void_p) + self.old_wnd_proc = SetWindowLongPtr(self.hwnd, GWLP_WNDPROC, proc_address) + + def wnd_proc_callback(self, hwnd, msg, wParam, lParam): + try: + if msg == WM_NCHITTEST: + res = self.on_nchittest(lParam) + if res != 0: + return res + + return user32.CallWindowProcW(self.old_wnd_proc, hwnd, msg, wParam, lParam) + except Exception: + # Swallow exceptions in callback to prevent recursive crash loops + return 0 + + def on_nchittest(self, lParam): + if GetDpiForWindow: + dpi = GetDpiForWindow(self.hwnd) + if dpi > 0: + self.current_scale = dpi / 96.0 + + # Physics + s = self.current_scale + phys_rect = [ + self.logical_rect[0] * s, + self.logical_rect[1] * s, + self.logical_rect[2] * s, + self.logical_rect[3] * s + ] + + x_screen = LOWORD(lParam) + if x_screen > 32767: x_screen -= 65536 + y_screen = HIWORD(lParam) + if y_screen > 32767: y_screen -= 65536 + + pt = wintypes.POINT(x_screen, y_screen) + user32.ScreenToClient(self.hwnd, ctypes.byref(pt)) + lx = pt.x + ly = pt.y + + inside = (lx >= phys_rect[0] and lx <= phys_rect[2] and + ly >= phys_rect[1] and ly <= phys_rect[3]) + + if inside: + return HTCAPTION + else: + return HTTRANSPARENT