Initial commit of WhisperVoice

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

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

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

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

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

View File

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

View File

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Binary file not shown.

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

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

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

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

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

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

Binary file not shown.

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

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

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

Binary file not shown.

View File

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

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

View File

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

After

Width:  |  Height:  |  Size: 695 B

25
src/ui/qml/noise.frag Normal file
View File

@@ -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;
}

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

Binary file not shown.

3
src/ui/qml/qmldir Normal file
View File

@@ -0,0 +1,3 @@
singleton SettingsStyle 1.0 SettingsStyle.qml
ModernSettingsSection 1.0 ModernSettingsSection.qml
ModernSettingsItem 1.0 ModernSettingsItem.qml

View File

@@ -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);
}

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

Binary file not shown.

BIN
src/ui/qml/settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

1
src/ui/qml/settings.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. --><path fill="#ffffff" d="M495.9 166.6c3.2 8.7 .5 18.4-6.4 24.6l-43.3 39.4c1.1 8.3 1.7 16.8 1.7 25.4s-.6 17.1-1.7 25.4l43.3 39.4c6.9 6.2 9.6 15.9 6.4 24.6c-4.4 11.9-9.7 23.3-15.8 34.3l-4.7 8.1c-6.6 11-14 21.4-22.1 31.2c-5.9 7.2-15.7 9.6-24.5 6.8l-55.7-17.7c-13.4 10.3-28.2 18.9-44 25.4l-12.5 57.1c-2 9.1-9 16.3-18.2 17.8c-13.8 2.3-28 3.5-42.5 3.5s-28.7-1.2-42.5-3.5c-9.2-1.5-16.2-8.7-18.2-17.8l-12.5-57.1c-15.8-6.5-30.6-15.1-44-25.4L83.1 425.9c-8.8 2.8-18.6 .3-24.5-6.8c-8.1-9.8-15.5-20.2-22.1-31.2l-4.7-8.1c-6.1-11-11.4-22.4-15.8-34.3c-3.2-8.7-.5-18.4 6.4-24.6l43.3-39.4C64.6 273.1 64 264.6 64 256s.6-17.1 1.7-25.4L22.4 191.2c-6.9-6.2-9.6-15.9-6.4-24.6c4.4-11.9 9.7-23.3 15.8-34.3l4.7-8.1c6.6-11 14-21.4 22.1-31.2c5.9-7.2 15.7-9.6 24.5-6.8l55.7 17.7c13.4-10.3 28.2-18.9 44-25.4l12.5-57.1c2-9.1 9-16.3 18.2-17.8C227.3 1.2 241.5 0 256 0s28.7 1.2 42.5 3.5c9.2 1.5 16.2 8.7 18.2 17.8l12.5 57.1c15.8 6.5 30.6 15.1 44 25.4l55.7-17.7c8.8-2.8 18.6-.3 24.5 6.8c8.1 9.8 15.5 20.2 22.1 31.2l4.7 8.1c6.1 11 11.4 22.4 15.8 34.3zM256 336a80 80 0 1 0 0-160 80 80 0 1 0 0 160z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
src/ui/qml/smart_toy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

1
src/ui/qml/smart_toy.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. --><path fill="#ffffff" d="M184 0c30.9 0 56 25.1 56 56l0 400c0 30.9-25.1 56-56 56c-28.9 0-52.7-21.9-55.7-50.1c-5.2 1.4-10.7 2.1-16.3 2.1c-35.3 0-64-28.7-64-64c0-7.4 1.3-14.6 3.6-21.2C21.4 367.4 0 338.2 0 304c0-31.9 18.7-59.5 45.8-72.3C37.1 220.8 32 207 32 192c0-30.7 21.6-56.3 50.4-62.6C80.8 123.9 80 118 80 112c0-29.9 20.6-55.1 48.3-62.1C131.3 21.9 155.1 0 184 0zM328 0c28.9 0 52.6 21.9 55.7 49.9c27.8 7 48.3 32.1 48.3 62.1c0 6-.8 11.9-2.4 17.4c28.8 6.2 50.4 31.9 50.4 62.6c0 15-5.1 28.8-13.8 39.7C493.3 244.5 512 272.1 512 304c0 34.2-21.4 63.4-51.6 74.8c2.3 6.6 3.6 13.8 3.6 21.2c0 35.3-28.7 64-64 64c-5.6 0-11.1-.7-16.3-2.1c-3 28.2-26.8 50.1-55.7 50.1c-30.9 0-56-25.1-56-56l0-400c0-30.9 25.1-56 56-56z"/></svg>

After

Width:  |  Height:  |  Size: 983 B

1
src/ui/qml/terminal.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><!-- Font Awesome Free 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --><path fill="#ffffff" d="M257.981 272.971L63.638 467.314c-9.373 9.373-24.569 9.373-33.941 0L7.029 444.647c-9.357-9.357-9.375-24.522-.04-33.901L161.011 256 6.99 101.255c-9.335-9.379-9.317-24.544.04-33.901l22.667-22.667c9.373-9.373 24.569-9.373 33.941 0L257.981 239.03c9.373 9.372 9.373 24.568 0 33.941zM640 456v-32c0-13.255-10.745-24-24-24H312c-13.255 0-24 10.745-24 24v32c0 13.255 10.745 24 24 24h304c13.255 0 24-10.745 24-24z"/></svg>

After

Width:  |  Height:  |  Size: 676 B

BIN
src/ui/qml/visibility.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--! Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. --><path fill="#ffffff" d="M288 32c-80.8 0-145.5 36.8-192.6 80.6C48.6 156 17.3 208 2.5 243.7c-3.3 7.9-3.3 16.7 0 24.6C17.3 304 48.6 356 95.4 399.4C142.5 443.2 207.2 480 288 480s145.5-36.8 192.6-80.6c46.8-43.5 78.1-95.4 93-131.1c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C433.5 68.8 368.8 32 288 32zM144 256a144 144 0 1 1 288 0 144 144 0 1 1 -288 0zm144-64c0 35.3-28.7 64-64 64c-7.1 0-13.9-1.2-20.3-3.3c-5.5-1.8-11.9 1.6-11.7 7.4c.3 6.9 1.3 13.8 3.2 20.7c13.7 51.2 66.4 81.6 117.6 67.9s81.6-66.4 67.9-117.6c-11.1-41.5-47.8-69.4-88.6-71.1c-5.8-.2-9.2 6.1-7.4 11.7c2.1 6.4 3.3 13.2 3.3 20.3z"/></svg>

After

Width:  |  Height:  |  Size: 878 B

236
src/ui/settings.py Normal file
View File

@@ -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()

62
src/ui/styles.py Normal file
View File

@@ -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)

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