Initial commit of WhisperVoice
196
src/core/audio_engine.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""
|
||||
Audio Engine Module.
|
||||
====================
|
||||
|
||||
This module handles the low-level audio recording capabilities using `sounddevice`.
|
||||
It manages the input stream, buffers audio data in memory, and provides a callback
|
||||
mechanism for real-time visualization of audio amplitude.
|
||||
|
||||
Classes:
|
||||
AudioEngine: The main controller for recording streams.
|
||||
"""
|
||||
|
||||
import sounddevice as sd
|
||||
import numpy as np
|
||||
import threading
|
||||
import queue
|
||||
import logging
|
||||
from typing import Optional, Callable
|
||||
from src.core.config import ConfigManager
|
||||
|
||||
class AudioEngine:
|
||||
"""
|
||||
Manages audio recording from the default input device.
|
||||
Uses ConfigManager for settings (device, silence detection).
|
||||
"""
|
||||
|
||||
def __init__(self, sample_rate: int = 16000, channels: int = 1):
|
||||
"""
|
||||
Initialize the AudioEngine.
|
||||
"""
|
||||
self.config = ConfigManager()
|
||||
self.sample_rate = sample_rate
|
||||
self.channels = channels
|
||||
self.recording = False
|
||||
self.stream: Optional[sd.InputStream] = None
|
||||
self.visualizer_callback: Optional[Callable[[float], None]] = None
|
||||
self.silence_callback: Optional[Callable[[], None]] = None
|
||||
|
||||
# Audio buffer to store the current session's frames
|
||||
self.frames = []
|
||||
self.last_noise_time = 0.0
|
||||
|
||||
def list_devices(self):
|
||||
"""
|
||||
Query available audio devices.
|
||||
|
||||
Returns:
|
||||
DeviceList: A list of all available input/output devices seen by PortAudio.
|
||||
"""
|
||||
return sd.query_devices()
|
||||
|
||||
def set_visualizer_callback(self, callback: Callable[[float], None]):
|
||||
"""
|
||||
Register a callback function for visualizer updates.
|
||||
|
||||
Args:
|
||||
callback (function): A function that accepts a single float argument (amplitude).
|
||||
This will be called roughly every audio block.
|
||||
"""
|
||||
self.visualizer_callback = callback
|
||||
|
||||
def set_silence_callback(self, callback: Callable[[], None]):
|
||||
"""
|
||||
Register a callback function for silence detection (Auto-Stop).
|
||||
"""
|
||||
self.silence_callback = callback
|
||||
|
||||
def _audio_callback(self, indata: np.ndarray, frames: int, time, status: sd.CallbackFlags):
|
||||
"""
|
||||
Internal callback used by sounddevice to process incoming audio chunks.
|
||||
|
||||
Args:
|
||||
indata (numpy.ndarray): The recorded audio data chunk.
|
||||
frames (int): Number of frames.
|
||||
time: Timestamp info.
|
||||
status: Callback status flags (e.g., overflow warnings).
|
||||
"""
|
||||
if status:
|
||||
logging.warning(f"Audio callback status: {status}")
|
||||
|
||||
if self.recording:
|
||||
# Copy data to avoid buffer race conditions
|
||||
data = indata.copy()
|
||||
self.frames.append(data)
|
||||
|
||||
# Calculate amplitude for visualizer (Root Mean Square)
|
||||
if self.visualizer_callback:
|
||||
# Calculate RMS of the current chunk to determine loudness
|
||||
rms = np.sqrt(np.mean(data**2))
|
||||
|
||||
# Apply logarithmic scaling for better sensitivity to quiet sounds
|
||||
if rms > 0:
|
||||
# Convert to dB scale, normalize, and apply compression
|
||||
db = 20 * np.log10(rms + 1e-10) # Add small value to avoid log(0)
|
||||
# Map from typical range (-60 to 0 dB) to (0 to 1)
|
||||
amp = float(np.clip((db + 60) / 60, 0.0, 1.0))
|
||||
# Apply power curve for better sensitivity at low levels
|
||||
amp = np.power(amp, 0.5) # Square root gives good response to quiet sounds
|
||||
else:
|
||||
amp = 0.0
|
||||
|
||||
# Apply exponential smoothing to prevent jumpy waveform
|
||||
if not hasattr(self, '_smoothed_amp'):
|
||||
self._smoothed_amp = amp
|
||||
else:
|
||||
# Exponential moving average with smoothing factor 0.3
|
||||
self._smoothed_amp = 0.3 * amp + 0.7 * self._smoothed_amp
|
||||
|
||||
self.visualizer_callback(self._smoothed_amp)
|
||||
|
||||
|
||||
|
||||
# --- Silence Detection Logic ---
|
||||
# We calculate this even if visualizer is off
|
||||
# Calculate linear RMS for VAD comparison
|
||||
raw_rms = np.sqrt(np.mean(data**2))
|
||||
# Heuristic mapping: 0.1 RMS = 100% threshold
|
||||
vad_level = float(np.clip(raw_rms * 10, 0.0, 1.0))
|
||||
|
||||
import time
|
||||
current_time = time.time()
|
||||
|
||||
# Fetch params dynamically
|
||||
threshold = float(self.config.get("silence_threshold"))
|
||||
duration = float(self.config.get("silence_duration"))
|
||||
|
||||
if vad_level > threshold:
|
||||
self.last_noise_time = current_time
|
||||
else:
|
||||
# If we have been silent for > silence_duration, trigger auto-stop
|
||||
if (current_time - self.last_noise_time) > duration:
|
||||
if self.silence_callback:
|
||||
logging.info(f"Silence detected ({duration}s). Triggering auto-stop.")
|
||||
# Reset last_noise_time to prevent spamming
|
||||
self.last_noise_time = current_time
|
||||
self.silence_callback()
|
||||
|
||||
def start_recording(self, device: Optional[int] = None):
|
||||
"""
|
||||
Start the recording stream.
|
||||
|
||||
Args:
|
||||
device (int, optional): The device ID to use. Defaults to system default.
|
||||
"""
|
||||
if self.recording:
|
||||
return
|
||||
|
||||
self.frames = [] # Reset buffer
|
||||
self.recording = True
|
||||
import time
|
||||
self.last_noise_time = time.time() # Reset silence timer
|
||||
|
||||
# Determine Device
|
||||
# If passed arg is None, check Config. If Config is None, use Default.
|
||||
if device is None:
|
||||
device = self.config.get("input_device")
|
||||
|
||||
try:
|
||||
self.stream = sd.InputStream(
|
||||
samplerate=self.sample_rate,
|
||||
channels=self.channels,
|
||||
device=device,
|
||||
callback=self._audio_callback
|
||||
)
|
||||
self.stream.start()
|
||||
logging.info("Audio recording started.")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to start recording: {e}")
|
||||
self.recording = False
|
||||
|
||||
def stop_recording(self) -> np.ndarray:
|
||||
"""
|
||||
Stop the current recording session and return the captured audio.
|
||||
|
||||
Returns:
|
||||
np.ndarray: The complete audio recording flattened into a single numpy array.
|
||||
Returns an empty array if nothing was recorded.
|
||||
"""
|
||||
if not self.recording:
|
||||
return np.array([], dtype=np.float32)
|
||||
|
||||
self.recording = False
|
||||
if self.stream:
|
||||
self.stream.stop()
|
||||
self.stream.close()
|
||||
self.stream = None
|
||||
logging.info("Audio recording stopped.")
|
||||
|
||||
if not self.frames:
|
||||
return np.array([], dtype=np.float32)
|
||||
|
||||
# Concatenate all buffered chunks into one continuous array
|
||||
# sounddevice returns (frames, channels), so we get (N, 1).
|
||||
# Whisper expects flattened 1D array (N,).
|
||||
audio = np.concatenate(self.frames, axis=0)
|
||||
return audio.flatten()
|
||||
117
src/core/config.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
Configuration Manager Module.
|
||||
=============================
|
||||
|
||||
Singleton class to manage loading and saving application settings to a JSON file.
|
||||
Ensures robustness by merging with defaults and handling file paths correctly.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from src.core.paths import get_base_path
|
||||
|
||||
# Default Configuration
|
||||
DEFAULT_SETTINGS = {
|
||||
"hotkey": "f8",
|
||||
"model_size": "small",
|
||||
"input_device": None, # Device ID (int) or Name (str), None = Default
|
||||
"save_recordings": False, # Save .wav files for debugging
|
||||
"silence_threshold": 0.02, # Amplitude threshold (0.0 - 1.0)
|
||||
"silence_duration": 1.0, # Seconds of silence to trigger auto-submit
|
||||
"visualizer_style": "line", # 'bar' or 'line'
|
||||
"opacity": 1.0, # Window opacity (0.1 - 1.0)
|
||||
"ui_scale": 1.0, # Global UI Scale (0.75 - 1.5)
|
||||
"always_on_top": True,
|
||||
"run_on_startup": False, # (Placeholder)
|
||||
|
||||
# Window Position
|
||||
"overlay_position": "Bottom Center",
|
||||
"overlay_offset_x": 0,
|
||||
"overlay_offset_y": 0,
|
||||
|
||||
# Input
|
||||
"input_method": "Clipboard Paste", # "Clipboard Paste" or "Simulate Typing"
|
||||
"typing_speed": 100, # CPM (Chars Per Minute) if typing
|
||||
|
||||
# AI - Advanced
|
||||
"language": "auto", # "auto" or ISO code
|
||||
"compute_device": "auto", # "auto", "cuda", "cpu"
|
||||
"compute_type": "int8", # "int8", "float16", "float32"
|
||||
"beam_size": 5,
|
||||
"best_of": 5,
|
||||
"vad_filter": True,
|
||||
"no_repeat_ngram_size": 0,
|
||||
"condition_on_previous_text": True
|
||||
}
|
||||
|
||||
class ConfigManager:
|
||||
"""
|
||||
Singleton Configuration Manager.
|
||||
"""
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(ConfigManager, cls).__new__(cls)
|
||||
cls._instance._init()
|
||||
return cls._instance
|
||||
|
||||
def _init(self):
|
||||
"""Initialize the config manager (called only once)."""
|
||||
self.base_path = get_base_path()
|
||||
self.config_file = self.base_path / "settings.json"
|
||||
self.data = DEFAULT_SETTINGS.copy()
|
||||
self.load()
|
||||
|
||||
def load(self):
|
||||
"""Load settings from JSON file, merging with defaults."""
|
||||
if self.config_file.exists():
|
||||
try:
|
||||
with open(self.config_file, 'r', encoding='utf-8') as f:
|
||||
loaded = json.load(f)
|
||||
|
||||
# Merge loaded data into defaults (preserves new default keys)
|
||||
for key, value in loaded.items():
|
||||
if key in DEFAULT_SETTINGS:
|
||||
self.data[key] = value
|
||||
|
||||
logging.info(f"Settings loaded from {self.config_file}")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to load settings: {e}")
|
||||
else:
|
||||
logging.info("No settings file found. Using defaults.")
|
||||
self.save()
|
||||
|
||||
def save(self):
|
||||
"""Save current settings to JSON file."""
|
||||
try:
|
||||
with open(self.config_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.data, f, indent=4)
|
||||
logging.info("Settings saved.")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to save settings: {e}")
|
||||
|
||||
def get(self, key: str) -> Any:
|
||||
"""Get a setting value."""
|
||||
return self.data.get(key, DEFAULT_SETTINGS.get(key))
|
||||
|
||||
|
||||
|
||||
def set(self, key: str, value: Any):
|
||||
"""Set a setting value and save."""
|
||||
if self.data.get(key) != value:
|
||||
self.data[key] = value
|
||||
self.save()
|
||||
|
||||
def set_bulk(self, updates: Dict[str, Any]):
|
||||
"""Update multiple keys and save once."""
|
||||
changed = False
|
||||
for k, v in updates.items():
|
||||
if self.data.get(k) != v:
|
||||
self.data[k] = v
|
||||
changed = True
|
||||
if changed:
|
||||
self.save()
|
||||
31
src/core/debug_run_worker.bat
Normal file
@@ -0,0 +1,31 @@
|
||||
@echo off
|
||||
echo [DEBUG] LAUNCHER STARTED
|
||||
echo [DEBUG] CWD: %CD%
|
||||
echo [DEBUG] Python Path (expected relative): ..\python\python.exe
|
||||
|
||||
REM Read stdin to a file to verify data input (optional debugging)
|
||||
REM python.exe might be in different relative path depending on where this bat is run
|
||||
REM We assume this bat is in runtime/app/src/core/
|
||||
REM So python is in ../../../python/python.exe
|
||||
|
||||
set PYTHON_EXE=..\..\..\python\python.exe
|
||||
|
||||
if exist "%PYTHON_EXE%" (
|
||||
echo [DEBUG] Found Python at %PYTHON_EXE%
|
||||
) else (
|
||||
echo [ERROR] Python NOT found at %PYTHON_EXE%
|
||||
echo [ERROR] Listing relative directories:
|
||||
dir ..\..\..\
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [DEBUG] Launching script: transcribe_worker.py
|
||||
"%PYTHON_EXE%" transcribe_worker.py
|
||||
if %ERRORLEVEL% NEQ 0 (
|
||||
echo [ERROR] Python script failed with code %ERRORLEVEL%
|
||||
pause
|
||||
) else (
|
||||
echo [SUCCESS] Script finished.
|
||||
pause
|
||||
)
|
||||
95
src/core/hotkey_manager.py
Normal file
@@ -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
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
Paths Module.
|
||||
=============
|
||||
|
||||
This module handles all file system path resolution for the application.
|
||||
It is critical for ensuring portability, distinguishing between:
|
||||
1. Running as a raw Python script (using `__file__`).
|
||||
2. Running as a frozen PyInstaller EXE (using `sys.executable`).
|
||||
|
||||
It creates necessary directories (models, libs) if they do not exist.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
def get_bundle_path() -> Path:
|
||||
"""
|
||||
Returns the root directory of the application bundle.
|
||||
When frozen, this is the internal temporary directory (sys._MEIPASS).
|
||||
When running as script, this is the project root.
|
||||
Use this for bundled assets like QML, SVGs, etc.
|
||||
"""
|
||||
if getattr(sys, 'frozen', False):
|
||||
return Path(sys._MEIPASS)
|
||||
# Project root (assuming this file is at src/core/paths.py)
|
||||
return Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
def get_base_path() -> Path:
|
||||
"""
|
||||
Returns the directory where persistent data should be stored.
|
||||
Always points to the directory containing the .exe or the project root.
|
||||
Use this for models, settings, recordings.
|
||||
"""
|
||||
if getattr(sys, 'frozen', False):
|
||||
return Path(sys.executable).parent
|
||||
return get_bundle_path()
|
||||
|
||||
def get_models_path() -> Path:
|
||||
"""
|
||||
Returns the absolute path to the 'models' directory.
|
||||
|
||||
This directory is used to store the Whisper AI model files.
|
||||
The directory is automatically created if it does not exist.
|
||||
|
||||
Returns:
|
||||
Path: Absolute path to the ./models directory next to the output binary.
|
||||
"""
|
||||
path = get_base_path() / "models"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
def get_libs_path() -> Path:
|
||||
"""
|
||||
Returns the absolute path to the 'libs' directory.
|
||||
|
||||
This directory is used to store external binaries like `ffmpeg.exe`.
|
||||
The directory is automatically created if it does not exist.
|
||||
|
||||
Returns:
|
||||
Path: Absolute path to the ./libs directory next to the output binary.
|
||||
"""
|
||||
path = get_base_path() / "libs"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
def get_ffmpeg_path() -> str:
|
||||
"""
|
||||
Resolves the path to the FFmpeg executable.
|
||||
|
||||
Logic:
|
||||
1. Checks for `ffmpeg.exe` in the local `./libs` folder.
|
||||
2. Fallbacks to the system-wide "ffmpeg" command if local file is missing.
|
||||
|
||||
Returns:
|
||||
str: Absolute path to the local binary, or just "ffmpeg" string for system PATH lookup.
|
||||
"""
|
||||
libs_path = get_libs_path()
|
||||
ffmpeg_exe = libs_path / "ffmpeg.exe"
|
||||
|
||||
if ffmpeg_exe.exists():
|
||||
return str(ffmpeg_exe.absolute())
|
||||
|
||||
# Fallback to system PATH
|
||||
return "ffmpeg"
|
||||
127
src/core/transcribe_worker.py
Normal file
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,47 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
|
||||
Button {
|
||||
id: control
|
||||
text: "Button"
|
||||
|
||||
property color accentColor: "#00f2ff"
|
||||
|
||||
contentItem: Text {
|
||||
text: control.text
|
||||
font.pixelSize: 13
|
||||
font.bold: true
|
||||
color: control.hovered ? "white" : "#9499b0"
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 200 } }
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
implicitWidth: 100
|
||||
implicitHeight: 40
|
||||
opacity: control.down ? 0.7 : 1.0
|
||||
color: control.hovered ? Qt.rgba(1, 1, 1, 0.1) : Qt.rgba(1, 1, 1, 0.05)
|
||||
radius: 8
|
||||
border.color: control.hovered ? control.accentColor : Qt.rgba(1, 1, 1, 0.1)
|
||||
border.width: 1
|
||||
|
||||
Behavior on border.color { ColorAnimation { duration: 200 } }
|
||||
Behavior on color { ColorAnimation { duration: 200 } }
|
||||
|
||||
// Glow effect
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: parent.radius
|
||||
color: "transparent"
|
||||
border.color: control.accentColor
|
||||
border.width: 2
|
||||
opacity: control.hovered ? 0.5 : 0.0
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on opacity { NumberAnimation { duration: 300 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
src/ui/qml/JetBrainsMono.zip
Normal file
186
src/ui/qml/Loader.qml
Normal file
@@ -0,0 +1,186 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Effects
|
||||
|
||||
ApplicationWindow {
|
||||
id: root
|
||||
|
||||
FontLoader {
|
||||
id: jetBrainsMono
|
||||
source: "fonts/ttf/JetBrainsMono-Bold.ttf"
|
||||
}
|
||||
width: 400
|
||||
height: 250
|
||||
visible: true
|
||||
flags: Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool
|
||||
color: "transparent"
|
||||
|
||||
Rectangle {
|
||||
id: bgRect
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20 // Space for shadow
|
||||
radius: 16
|
||||
color: "#1a1a20"
|
||||
border.color: "#40ffffff"
|
||||
border.width: 1
|
||||
|
||||
// --- SHADOW & GLOW ---
|
||||
layer.enabled: true
|
||||
layer.effect: MultiEffect {
|
||||
shadowEnabled: true
|
||||
shadowColor: "#80000000"
|
||||
shadowBlur: 1.0
|
||||
shadowOpacity: 0.8
|
||||
shadowVerticalOffset: 4
|
||||
autoPaddingEnabled: true
|
||||
}
|
||||
|
||||
// --- CONTENT ---
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 60
|
||||
spacing: 24
|
||||
|
||||
// Logo / Icon Area
|
||||
Item {
|
||||
width: 60; height: 60
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: 30
|
||||
color: "transparent"
|
||||
border.width: 2
|
||||
border.color: "#00f2ff"
|
||||
|
||||
// Pulse Animation
|
||||
SequentialAnimation on scale {
|
||||
loops: Animation.Infinite
|
||||
NumberAnimation { from: 1.0; to: 1.1; duration: 1000; easing.type: Easing.InOutSine }
|
||||
NumberAnimation { from: 1.1; to: 1.0; duration: 1000; easing.type: Easing.InOutSine }
|
||||
}
|
||||
}
|
||||
|
||||
Image {
|
||||
source: "microphone.svg"
|
||||
anchors.centerIn: parent
|
||||
width: 32; height: 32
|
||||
sourceSize: Qt.size(32, 32)
|
||||
fillMode: Image.PreserveAspectFit
|
||||
smooth: true
|
||||
|
||||
// Colorize
|
||||
layer.enabled: true
|
||||
layer.effect: MultiEffect {
|
||||
colorization: 1.0
|
||||
colorizationColor: "#00f2ff"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Title
|
||||
Column {
|
||||
spacing: 4
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
|
||||
Text {
|
||||
text: "WHISPER VOICE"
|
||||
color: "#ffffff"
|
||||
font.family: jetBrainsMono.name
|
||||
font.pixelSize: 18
|
||||
font.bold: true
|
||||
font.letterSpacing: 3
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "AI TRANSCRIPTION ENGINE"
|
||||
color: "#80ffffff"
|
||||
font.family: jetBrainsMono.name
|
||||
font.pixelSize: 10
|
||||
font.letterSpacing: 2
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
}
|
||||
}
|
||||
|
||||
// Status & Progress
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
|
||||
// Progress Bar Background
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 4
|
||||
color: "#252530"
|
||||
radius: 2
|
||||
clip: true
|
||||
|
||||
// Progress Fill
|
||||
Rectangle {
|
||||
width: (ui.downloadProgress / 100.0) * parent.width
|
||||
height: parent.height
|
||||
radius: 2
|
||||
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: "#00f2ff" }
|
||||
GradientStop { position: 1.0; color: "#00a0ff" }
|
||||
}
|
||||
|
||||
Behavior on width {
|
||||
NumberAnimation { duration: 250; easing.type: Easing.OutCubic }
|
||||
}
|
||||
|
||||
// Shimmer effect on bar
|
||||
Rectangle {
|
||||
width: 20; height: parent.height
|
||||
color: "#80ffffff"
|
||||
x: -width
|
||||
opacity: 0.5
|
||||
rotation: 20
|
||||
transformOrigin: Item.Center
|
||||
|
||||
NumberAnimation on x {
|
||||
from: 0; to: parent.width + 50
|
||||
duration: 1000
|
||||
loops: Animation.Infinite
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Status Text
|
||||
Text {
|
||||
text: ui.loaderStatus
|
||||
color: "#00f2ff"
|
||||
font.family: jetBrainsMono.name
|
||||
font.pixelSize: 11
|
||||
font.bold: true
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
opacity: 0.8
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Entry Animation
|
||||
Component.onCompleted: {
|
||||
bgRect.scale = 0.9
|
||||
bgRect.opacity = 0
|
||||
entryAnim.start()
|
||||
}
|
||||
|
||||
ParallelAnimation {
|
||||
id: entryAnim
|
||||
|
||||
NumberAnimation {
|
||||
target: bgRect; property: "scale"
|
||||
to: 1.0; duration: 600; easing.type: Easing.OutBack
|
||||
}
|
||||
NumberAnimation {
|
||||
target: bgRect; property: "opacity"
|
||||
to: 1.0; duration: 400
|
||||
}
|
||||
}
|
||||
}
|
||||
127
src/ui/qml/ModernComboBox.qml
Normal file
@@ -0,0 +1,127 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
ComboBox {
|
||||
id: control
|
||||
|
||||
// Custom properties
|
||||
property color accentColor: SettingsStyle.accent
|
||||
property color bgColor: "#1a1a20"
|
||||
property color popupColor: "#252530"
|
||||
|
||||
delegate: ItemDelegate {
|
||||
id: delegate
|
||||
width: control.width
|
||||
height: 40
|
||||
padding: 0
|
||||
|
||||
contentItem: RowLayout {
|
||||
spacing: 8
|
||||
width: parent.width
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 10
|
||||
|
||||
Text {
|
||||
text: control.textRole ? (modelData[control.textRole] || modelData) : modelData
|
||||
color: highlighted ? control.accentColor : "#ffffff"
|
||||
font.family: "JetBrains Mono"
|
||||
font.pixelSize: 14
|
||||
elide: Text.ElideRight
|
||||
Layout.fillWidth: true
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
scale: highlighted ? 1.05 : 1.0
|
||||
Behavior on scale { NumberAnimation { duration: 100 } }
|
||||
}
|
||||
|
||||
// Indicator for "Downloaded" or "Active"
|
||||
Rectangle {
|
||||
width: 6; height: 6; radius: 3
|
||||
color: control.accentColor
|
||||
visible: ui.isModelDownloaded(modelData)
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
color: highlighted ? Qt.rgba(control.accentColor.r, control.accentColor.g, control.accentColor.b, 0.1) : "transparent"
|
||||
radius: 4
|
||||
Behavior on color { ColorAnimation { duration: 100 } }
|
||||
}
|
||||
}
|
||||
|
||||
indicator: Canvas {
|
||||
x: control.width - width - control.rightPadding
|
||||
y: control.topPadding + (control.availableHeight - height) / 2
|
||||
width: 12
|
||||
height: 8
|
||||
contextType: "2d"
|
||||
|
||||
Connections {
|
||||
target: control
|
||||
function onPressedChanged() { control.indicator.requestPaint() }
|
||||
}
|
||||
|
||||
onPaint: {
|
||||
context.reset();
|
||||
context.moveTo(0, 0);
|
||||
context.lineTo(width, 0);
|
||||
context.lineTo(width / 2, height);
|
||||
context.closePath();
|
||||
context.fillStyle = control.pressed ? control.accentColor : "#888888";
|
||||
context.fill();
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: Text {
|
||||
leftPadding: 10
|
||||
rightPadding: control.indicator.width + control.spacing
|
||||
|
||||
text: control.displayText
|
||||
font.family: "JetBrains Mono"
|
||||
font.pixelSize: 14
|
||||
color: control.pressed ? control.accentColor : "#ffffff"
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
implicitWidth: 140
|
||||
implicitHeight: 40
|
||||
color: control.bgColor
|
||||
border.color: control.pressed || control.activeFocus ? control.accentColor : "#40ffffff"
|
||||
border.width: 1
|
||||
radius: 6
|
||||
|
||||
// Glow effect on focus (Simplified to just border for stability)
|
||||
Behavior on border.color { ColorAnimation { duration: 150 } }
|
||||
}
|
||||
|
||||
popup: Popup {
|
||||
y: control.height - 1
|
||||
width: control.width
|
||||
implicitHeight: contentItem.implicitHeight
|
||||
padding: 5
|
||||
|
||||
contentItem: ListView {
|
||||
clip: true
|
||||
implicitHeight: contentHeight
|
||||
model: control.popup.visible ? control.delegateModel : null
|
||||
currentIndex: control.highlightedIndex
|
||||
|
||||
ScrollIndicator.vertical: ScrollIndicator { }
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
color: control.popupColor
|
||||
border.color: "#40ffffff"
|
||||
border.width: 1
|
||||
radius: 6
|
||||
}
|
||||
|
||||
enter: Transition {
|
||||
NumberAnimation { property: "opacity"; from: 0.0; to: 1.0; duration: 100 }
|
||||
NumberAnimation { property: "scale"; from: 0.95; to: 1.0; duration: 100 }
|
||||
}
|
||||
}
|
||||
}
|
||||
111
src/ui/qml/ModernKeySequenceRecorder.qml
Normal file
@@ -0,0 +1,111 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
|
||||
Rectangle {
|
||||
id: control
|
||||
implicitWidth: 140
|
||||
implicitHeight: 32
|
||||
color: "#1a1a20"
|
||||
radius: 6
|
||||
border.width: 1
|
||||
border.color: activeFocus || recording ? SettingsStyle.accent : "#40ffffff"
|
||||
|
||||
property string currentSequence: ""
|
||||
signal sequenceChanged(string seq)
|
||||
|
||||
property bool recording: false
|
||||
|
||||
onRecordingChanged: {
|
||||
if (recording) {
|
||||
ui.hotkeysEnabled = false
|
||||
} else {
|
||||
ui.hotkeysEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: control.recording ? "Listening..." : (control.currentSequence || "None")
|
||||
color: control.recording ? SettingsStyle.accent : (control.currentSequence ? "#ffffff" : "#808080")
|
||||
font.family: "JetBrains Mono"
|
||||
font.pixelSize: 13
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
control.forceActiveFocus()
|
||||
control.recording = true
|
||||
}
|
||||
}
|
||||
|
||||
Keys.onPressed: (event) => {
|
||||
if (!control.recording) return
|
||||
|
||||
// Ignore specific standalone modifiers to allow combos
|
||||
if (event.key === Qt.Key_Control || event.key === Qt.Key_Shift || event.key === Qt.Key_Alt || event.key === Qt.Key_Meta) {
|
||||
return
|
||||
}
|
||||
|
||||
// Build Modifier String
|
||||
var seq = ""
|
||||
if (event.modifiers & Qt.ControlModifier) seq += "ctrl+"
|
||||
if (event.modifiers & Qt.ShiftModifier) seq += "shift+"
|
||||
if (event.modifiers & Qt.AltModifier) seq += "alt+"
|
||||
if (event.modifiers & Qt.MetaModifier) seq += "win+"
|
||||
|
||||
// Get Key Name
|
||||
var keyName = getKeyName(event.key, event.text)
|
||||
seq += keyName
|
||||
|
||||
// Update
|
||||
control.currentSequence = seq
|
||||
control.sequenceChanged(seq)
|
||||
control.recording = false
|
||||
event.accepted = true
|
||||
}
|
||||
|
||||
onActiveFocusChanged: {
|
||||
if (!activeFocus) control.recording = false
|
||||
}
|
||||
|
||||
function getKeyName(key, text) {
|
||||
// F-Keys
|
||||
if (key >= Qt.Key_F1 && key <= Qt.Key_F35) return "f" + (key - Qt.Key_F1 + 1)
|
||||
|
||||
// Common Keys
|
||||
switch (key) {
|
||||
case Qt.Key_Space: return "space"
|
||||
case Qt.Key_Backspace: return "backspace"
|
||||
case Qt.Key_Tab: return "tab"
|
||||
case Qt.Key_Return: return "enter"
|
||||
case Qt.Key_Enter: return "enter"
|
||||
case Qt.Key_Escape: return "esc"
|
||||
case Qt.Key_Delete: return "delete"
|
||||
case Qt.Key_Insert: return "insert"
|
||||
case Qt.Key_Home: return "home"
|
||||
case Qt.Key_End: return "end"
|
||||
case Qt.Key_PageUp: return "pageup"
|
||||
case Qt.Key_PageDown: return "pagedown"
|
||||
case Qt.Key_Up: return "up"
|
||||
case Qt.Key_Down: return "down"
|
||||
case Qt.Key_Left: return "left"
|
||||
case Qt.Key_Right: return "right"
|
||||
case Qt.Key_CapsLock: return "capslock"
|
||||
case Qt.Key_NumLock: return "numlock"
|
||||
case Qt.Key_ScrollLock: return "scrolllock"
|
||||
case Qt.Key_Print: return "printscreen"
|
||||
case Qt.Key_Pause: return "pause"
|
||||
}
|
||||
|
||||
// Letters and Numbers (Use text if available, fallback to manual)
|
||||
if (text && text.length > 0 && text.charCodeAt(0) >= 32) {
|
||||
return text.toLowerCase()
|
||||
}
|
||||
|
||||
return "key" + key
|
||||
}
|
||||
}
|
||||
92
src/ui/qml/ModernSettingsItem.qml
Normal file
@@ -0,0 +1,92 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Controls
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: SettingsStyle.itemHeight
|
||||
Layout.minimumHeight: SettingsStyle.itemHeight
|
||||
Layout.maximumHeight: SettingsStyle.itemHeight
|
||||
implicitHeight: SettingsStyle.itemHeight
|
||||
|
||||
color: hHandler.hovered ? SettingsStyle.surfaceHover : "transparent"
|
||||
radius: 0 // Continuous list style (clipped by container container)
|
||||
|
||||
// Properties
|
||||
property string label: "Setting Name"
|
||||
property string description: ""
|
||||
property alias control: controlContainer.data
|
||||
property bool showSeparator: true
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
|
||||
HoverHandler {
|
||||
id: hHandler
|
||||
}
|
||||
|
||||
// Label & Description
|
||||
Column {
|
||||
id: labelCol
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 16
|
||||
anchors.right: controlContainer.left
|
||||
anchors.rightMargin: 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
id: labelText
|
||||
text: root.label
|
||||
color: SettingsStyle.textPrimary
|
||||
font.family: "JetBrains Mono"
|
||||
font.pixelSize: 13
|
||||
font.weight: Font.Medium
|
||||
width: parent.width
|
||||
elide: Text.ElideRight
|
||||
|
||||
// Text Layout Normalization
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
topPadding: 0
|
||||
bottomPadding: 0
|
||||
}
|
||||
|
||||
Text {
|
||||
id: descText
|
||||
text: root.description
|
||||
color: SettingsStyle.textSecondary
|
||||
font.family: "JetBrains Mono"
|
||||
font.pixelSize: 11
|
||||
width: parent.width
|
||||
visible: text !== ""
|
||||
wrapMode: Text.WordWrap
|
||||
|
||||
// Text Layout Normalization
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
topPadding: 0
|
||||
bottomPadding: 0
|
||||
}
|
||||
}
|
||||
|
||||
// Control Container
|
||||
Item {
|
||||
id: controlContainer
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
implicitWidth: childrenRect.width
|
||||
implicitHeight: childrenRect.height
|
||||
}
|
||||
|
||||
// Bottom Separator
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: 16
|
||||
anchors.rightMargin: 16
|
||||
height: 1
|
||||
color: SettingsStyle.borderSubtle
|
||||
visible: root.showSeparator
|
||||
}
|
||||
}
|
||||
61
src/ui/qml/ModernSettingsSection.qml
Normal file
@@ -0,0 +1,61 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Effects
|
||||
|
||||
ColumnLayout {
|
||||
id: root
|
||||
spacing: 8
|
||||
|
||||
default property alias content: contentColumn.data
|
||||
property string title: ""
|
||||
|
||||
// Section Header
|
||||
Text {
|
||||
text: root.title
|
||||
color: SettingsStyle.accent // Accented header for more color
|
||||
font.family: "JetBrains Mono"
|
||||
font.pixelSize: 12
|
||||
font.bold: true
|
||||
font.capitalization: Font.AllUppercase
|
||||
font.letterSpacing: 1.5
|
||||
Layout.leftMargin: 4
|
||||
Layout.bottomMargin: 4
|
||||
visible: text !== ""
|
||||
}
|
||||
|
||||
// Card Container
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
// Height checks children
|
||||
implicitHeight: contentColumn.implicitHeight
|
||||
|
||||
color: SettingsStyle.surfaceCard
|
||||
radius: SettingsStyle.cardRadius
|
||||
border.color: SettingsStyle.borderSubtle
|
||||
border.width: 1
|
||||
|
||||
ColumnLayout {
|
||||
id: contentColumn
|
||||
anchors.fill: parent
|
||||
anchors.margins: 1 // Minimal margin to not overlap border
|
||||
spacing: 0 // No gaps, rely on separators
|
||||
|
||||
// Clip content to rounded corners
|
||||
layer.enabled: true
|
||||
layer.effect: MultiEffect {
|
||||
maskEnabled: true
|
||||
maskThresholdMin: 0.5
|
||||
maskSpreadAtMin: 1.0
|
||||
maskSource: ShaderEffectSource {
|
||||
sourceItem: Rectangle {
|
||||
width: contentColumn.width
|
||||
height: contentColumn.height
|
||||
radius: SettingsStyle.cardRadius - 1
|
||||
color: "black"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
src/ui/qml/ModernSlider.qml
Normal file
@@ -0,0 +1,61 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Effects
|
||||
|
||||
Slider {
|
||||
id: control
|
||||
|
||||
background: Rectangle {
|
||||
x: control.leftPadding
|
||||
y: control.topPadding + control.availableHeight / 2 - height / 2
|
||||
implicitWidth: 200
|
||||
implicitHeight: 4
|
||||
width: control.availableWidth
|
||||
height: implicitHeight
|
||||
radius: 2
|
||||
color: "#2d2d3d"
|
||||
|
||||
Rectangle {
|
||||
width: control.visualPosition * parent.width
|
||||
height: parent.height
|
||||
color: SettingsStyle.accent
|
||||
radius: 2
|
||||
}
|
||||
}
|
||||
|
||||
handle: Rectangle {
|
||||
x: control.leftPadding + control.visualPosition * (control.availableWidth - width)
|
||||
y: control.topPadding + control.availableHeight / 2 - height / 2
|
||||
implicitWidth: 18
|
||||
implicitHeight: 18
|
||||
radius: 9
|
||||
color: "white"
|
||||
border.color: SettingsStyle.accent
|
||||
border.width: 2
|
||||
|
||||
layer.enabled: control.pressed
|
||||
layer.effect: MultiEffect {
|
||||
blurEnabled: true
|
||||
blur: 0.5
|
||||
shadowEnabled: true
|
||||
shadowColor: SettingsStyle.accent
|
||||
}
|
||||
}
|
||||
// Value Readout (Left side to avoid clipping on right edge)
|
||||
Text {
|
||||
anchors.right: parent.left
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
horizontalAlignment: Text.AlignRight
|
||||
|
||||
text: {
|
||||
var val = control.value
|
||||
return (val % 1 === 0) ? val.toFixed(0) : val.toFixed(1)
|
||||
}
|
||||
|
||||
color: SettingsStyle.textSecondary
|
||||
font.family: "JetBrains Mono"
|
||||
font.pixelSize: 12
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
}
|
||||
40
src/ui/qml/ModernSwitch.qml
Normal file
@@ -0,0 +1,40 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
|
||||
Switch {
|
||||
id: control
|
||||
|
||||
indicator: Rectangle {
|
||||
implicitWidth: 44
|
||||
implicitHeight: 24
|
||||
x: control.leftPadding
|
||||
y: parent.height / 2 - height / 2
|
||||
radius: 12
|
||||
color: control.checked ? SettingsStyle.accent : "#2d2d3d"
|
||||
border.color: control.checked ? SettingsStyle.accent : "#3d3d4d"
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 200 } }
|
||||
|
||||
Rectangle {
|
||||
x: control.checked ? parent.width - width - 3 : 3
|
||||
y: 3
|
||||
width: 18
|
||||
height: 18
|
||||
radius: 9
|
||||
color: "white"
|
||||
|
||||
Behavior on x {
|
||||
NumberAnimation { duration: 200; easing.type: Easing.InOutQuad }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: Text {
|
||||
text: control.text
|
||||
font: control.font
|
||||
opacity: enabled ? 1.0 : 0.3
|
||||
color: "white"
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
leftPadding: control.indicator.width + control.spacing
|
||||
}
|
||||
}
|
||||
27
src/ui/qml/ModernTextField.qml
Normal file
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,905 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Effects
|
||||
|
||||
Window {
|
||||
id: root
|
||||
|
||||
width: 850 * (ui ? ui.uiScale : 1.0)
|
||||
height: 620 * (ui ? ui.uiScale : 1.0)
|
||||
|
||||
visible: false
|
||||
flags: Qt.FramelessWindowHint | Qt.Window
|
||||
color: "transparent"
|
||||
title: "Settings"
|
||||
|
||||
// Explicit sizing for Python to read
|
||||
|
||||
// Prevent destruction on close
|
||||
onClosing: (close) => {
|
||||
close.accepted = false
|
||||
root.visible = false
|
||||
}
|
||||
|
||||
// Load Font
|
||||
FontLoader {
|
||||
id: jetBrainsMono
|
||||
source: "fonts/ttf/JetBrainsMono-Bold.ttf"
|
||||
}
|
||||
|
||||
readonly property string mainFont: jetBrainsMono.name
|
||||
property bool isLoaded: false
|
||||
|
||||
Component.onCompleted: {
|
||||
isLoaded = true
|
||||
}
|
||||
|
||||
// --- REUSABLE COMPONENTS ---
|
||||
component StatBox: Rectangle {
|
||||
property string label: ""
|
||||
property string value: ""
|
||||
property string unit: ""
|
||||
property color accent: SettingsStyle.accent
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 80
|
||||
color: "#16161a"
|
||||
radius: 12
|
||||
border.color: SettingsStyle.borderSubtle
|
||||
border.width: 1
|
||||
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: 4
|
||||
Text { text: label; color: SettingsStyle.textSecondary; font.pixelSize: 11; font.bold: true; anchors.horizontalCenter: parent.horizontalCenter }
|
||||
Row {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: 2
|
||||
Text { text: value; color: accent; font.pixelSize: 22; font.bold: true; font.family: "JetBrains Mono" }
|
||||
Text { text: unit; color: SettingsStyle.textSecondary; font.pixelSize: 12; anchors.baseline: parent.bottom; anchors.baselineOffset: -4 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main Container
|
||||
Rectangle {
|
||||
id: mainContainer
|
||||
anchors.fill: parent
|
||||
radius: 16
|
||||
color: SettingsStyle.background
|
||||
border.color: SettingsStyle.borderSubtle
|
||||
border.width: 1
|
||||
|
||||
opacity: 0
|
||||
transform: Translate {
|
||||
id: entryTranslate
|
||||
y: 20
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
entryAnim.start()
|
||||
}
|
||||
|
||||
ParallelAnimation {
|
||||
id: entryAnim
|
||||
NumberAnimation { target: mainContainer; property: "opacity"; to: 1; duration: 400; easing.type: Easing.OutCubic }
|
||||
NumberAnimation { target: entryTranslate; property: "y"; to: 0; duration: 500; easing.type: Easing.OutBack; easing.overshoot: 0.8 }
|
||||
}
|
||||
|
||||
// --- TITLE BAR ---
|
||||
Item {
|
||||
id: titleBar
|
||||
height: 60
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onPressed: root.startSystemMove()
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 24
|
||||
anchors.rightMargin: 24
|
||||
|
||||
Image {
|
||||
source: "microphone.svg"
|
||||
sourceSize.width: 18
|
||||
sourceSize.height: 18
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
opacity: 0.7
|
||||
// Colorize the icon
|
||||
layer.enabled: true
|
||||
layer.effect: MultiEffect {
|
||||
colorization: 1.0
|
||||
colorizationColor: SettingsStyle.accent
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "SETTINGS"
|
||||
color: SettingsStyle.textSecondary
|
||||
font.family: mainFont
|
||||
font.pixelSize: 13
|
||||
font.letterSpacing: 2
|
||||
font.bold: true
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
// Improved Close Button
|
||||
Rectangle {
|
||||
width: 32; height: 32
|
||||
radius: 8
|
||||
color: closeMa.containsMouse ? "#20ff4b4b" : "transparent"
|
||||
border.color: closeMa.containsMouse ? "#40ff4b4b" : "transparent"
|
||||
border.width: 1
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "×"
|
||||
color: closeMa.containsMouse ? "#ff4b4b" : SettingsStyle.textSecondary
|
||||
font.family: mainFont
|
||||
font.pixelSize: 20
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: closeMa
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.close()
|
||||
}
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
Behavior on border.color { ColorAnimation { duration: 150 } }
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: SettingsStyle.borderSubtle
|
||||
}
|
||||
}
|
||||
|
||||
// --- CONTENT AREA ---
|
||||
RowLayout {
|
||||
anchors.top: titleBar.bottom
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
spacing: 0
|
||||
|
||||
// --- SIDEBAR ---
|
||||
Rectangle {
|
||||
Layout.fillHeight: true
|
||||
Layout.preferredWidth: 220
|
||||
Layout.minimumWidth: 220
|
||||
Layout.maximumWidth: 220
|
||||
color: Qt.rgba(1, 1, 1, 0.02) // Very subtle separation
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 16
|
||||
spacing: 8
|
||||
|
||||
ListModel {
|
||||
id: navModel
|
||||
ListElement { name: "General"; icon: "settings.svg" }
|
||||
ListElement { name: "Audio"; icon: "microphone.svg" }
|
||||
ListElement { name: "Visuals"; icon: "visibility.svg" }
|
||||
ListElement { name: "AI Engine"; icon: "smart_toy.svg" }
|
||||
ListElement { name: "Debug"; icon: "terminal.svg" }
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: navModel
|
||||
delegate: Rectangle {
|
||||
id: navBtnRoot
|
||||
Layout.fillWidth: true
|
||||
height: 38
|
||||
color: stack.currentIndex === index ? SettingsStyle.surfaceHover : (ma.containsMouse ? Qt.rgba(1,1,1,0.03) : "transparent")
|
||||
radius: 6
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
|
||||
// Left active stripe
|
||||
Rectangle {
|
||||
width: 3
|
||||
height: 20
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
radius: 2
|
||||
color: SettingsStyle.accent
|
||||
visible: stack.currentIndex === index
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
spacing: 12
|
||||
|
||||
Image {
|
||||
source: icon
|
||||
sourceSize.width: 16
|
||||
sourceSize.height: 16
|
||||
fillMode: Image.PreserveAspectFit
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
opacity: stack.currentIndex === index ? 1.0 : 0.5
|
||||
|
||||
layer.enabled: true
|
||||
layer.effect: MultiEffect {
|
||||
colorization: 1.0
|
||||
colorizationColor: stack.currentIndex === index ? SettingsStyle.accent : SettingsStyle.textSecondary
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
text: name
|
||||
color: stack.currentIndex === index ? SettingsStyle.textPrimary : SettingsStyle.textSecondary
|
||||
font.family: mainFont
|
||||
font.pixelSize: 13
|
||||
font.weight: stack.currentIndex === index ? Font.Bold : Font.Normal
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: ma
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: stack.currentIndex = index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillHeight: true }
|
||||
}
|
||||
|
||||
// Vertical Divider
|
||||
Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: 1
|
||||
color: SettingsStyle.borderSubtle
|
||||
}
|
||||
}
|
||||
|
||||
// --- MAIN CONTENT STACK ---
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: "transparent"
|
||||
clip: true
|
||||
|
||||
StackLayout {
|
||||
id: stack
|
||||
anchors.fill: parent
|
||||
currentIndex: 0
|
||||
|
||||
// --- TAB: GENERAL ---
|
||||
ScrollView {
|
||||
ScrollBar.vertical.policy: ScrollBar.AsNeeded
|
||||
contentWidth: availableWidth
|
||||
|
||||
ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 24
|
||||
anchors.margins: 32
|
||||
|
||||
// Header
|
||||
ColumnLayout {
|
||||
spacing: 4
|
||||
Layout.topMargin: 32
|
||||
Layout.leftMargin: 32
|
||||
Layout.rightMargin: 32
|
||||
Text { text: "General"; color: SettingsStyle.textPrimary; font.family: mainFont; font.pixelSize: 24; font.bold: true }
|
||||
Text { text: "System behavior and shortcuts"; color: SettingsStyle.textSecondary; font.family: mainFont; font.pixelSize: 14 }
|
||||
}
|
||||
|
||||
ModernSettingsSection {
|
||||
title: "Application"
|
||||
Layout.margins: 32
|
||||
Layout.topMargin: 0
|
||||
|
||||
content: ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 0
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Global Hotkey"
|
||||
description: "Press to record a new shortcut (e.g. Ctrl+Space)"
|
||||
control: ModernKeySequenceRecorder {
|
||||
Layout.preferredWidth: 200
|
||||
currentSequence: ui.getSetting("hotkey")
|
||||
onSequenceChanged: (seq) => ui.setSetting("hotkey", seq)
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Run on Startup"
|
||||
description: "Automatically launch when you log in"
|
||||
control: ModernSwitch {
|
||||
checked: ui.getSetting("run_on_startup")
|
||||
onToggled: ui.setSetting("run_on_startup", checked)
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Input Method"
|
||||
description: "How text is sent to the active window"
|
||||
control: ModernComboBox {
|
||||
width: 160
|
||||
model: ["Clipboard Paste", "Simulate Typing"]
|
||||
currentIndex: model.indexOf(ui.getSetting("input_method"))
|
||||
onActivated: ui.setSetting("input_method", currentText)
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Typing Speed"
|
||||
description: "Chars/min"
|
||||
showSeparator: false
|
||||
control: ModernSlider {
|
||||
Layout.preferredWidth: 200
|
||||
from: 10; to: 6000
|
||||
stepSize: 10
|
||||
snapMode: Slider.SnapAlways
|
||||
value: ui.getSetting("typing_speed")
|
||||
onMoved: ui.setSetting("typing_speed", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- TAB: AUDIO ---
|
||||
ScrollView {
|
||||
ScrollBar.vertical.policy: ScrollBar.AsNeeded
|
||||
contentWidth: availableWidth
|
||||
|
||||
ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 24
|
||||
anchors.margins: 32
|
||||
|
||||
ColumnLayout {
|
||||
spacing: 4
|
||||
Layout.topMargin: 32
|
||||
Layout.leftMargin: 32
|
||||
Layout.rightMargin: 32
|
||||
Text { text: "Audio"; color: SettingsStyle.textPrimary; font.family: mainFont; font.pixelSize: 24; font.bold: true }
|
||||
Text { text: "Input devices and signal processing"; color: SettingsStyle.textSecondary; font.family: mainFont; font.pixelSize: 14 }
|
||||
}
|
||||
|
||||
ModernSettingsSection {
|
||||
title: "Devices"
|
||||
Layout.margins: 32
|
||||
Layout.topMargin: 0
|
||||
|
||||
content: ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 0
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Microphone"
|
||||
description: "Select your primary input device"
|
||||
control: ModernComboBox {
|
||||
Layout.preferredWidth: 280
|
||||
textRole: "name"
|
||||
valueRole: "id"
|
||||
model: ui.getAudioDevices()
|
||||
onActivated: ui.setSetting("input_device", model[currentIndex].id) // Explicitly use model index
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Save Recordings"
|
||||
description: "Save .wav files to ./recordings folder"
|
||||
showSeparator: false
|
||||
control: ModernSwitch {
|
||||
checked: ui.getSetting("save_recordings")
|
||||
onToggled: ui.setSetting("save_recordings", checked)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsSection {
|
||||
title: "Signal Processing"
|
||||
Layout.margins: 32
|
||||
Layout.topMargin: 0
|
||||
|
||||
content: ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 0
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "VAD Threshold"
|
||||
description: "Silence detection sensitivity (" + (ui.getSetting("silence_threshold") * 100).toFixed(0) + "%)"
|
||||
control: ModernSlider {
|
||||
Layout.preferredWidth: 200
|
||||
from: 1; to: 100
|
||||
value: ui.getSetting("silence_threshold") * 100
|
||||
onMoved: ui.setSetting("silence_threshold", Number((value / 100.0).toFixed(2)))
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Silence Timeout"
|
||||
description: "Stop recording after " + (ui.getSetting("silence_duration")).toFixed(1) + "s of silence"
|
||||
showSeparator: false
|
||||
control: ModernSlider {
|
||||
Layout.preferredWidth: 200
|
||||
from: 0.5; to: 5.0
|
||||
value: ui.getSetting("silence_duration")
|
||||
onMoved: ui.setSetting("silence_duration", Number(value.toFixed(1)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- TAB: VISUALS ---
|
||||
ScrollView {
|
||||
ScrollBar.vertical.policy: ScrollBar.AsNeeded
|
||||
contentWidth: availableWidth
|
||||
|
||||
ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 24
|
||||
anchors.margins: 32
|
||||
|
||||
ColumnLayout {
|
||||
spacing: 4
|
||||
Layout.topMargin: 32
|
||||
Layout.leftMargin: 32
|
||||
Layout.rightMargin: 32
|
||||
Text { text: "Visuals"; color: SettingsStyle.textPrimary; font.family: mainFont; font.pixelSize: 24; font.bold: true }
|
||||
Text { text: "Customize the overlay appearance"; color: SettingsStyle.textSecondary; font.family: mainFont; font.pixelSize: 14 }
|
||||
}
|
||||
|
||||
ModernSettingsSection {
|
||||
title: "Overlay"
|
||||
Layout.margins: 32
|
||||
Layout.topMargin: 0
|
||||
|
||||
content: ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 0
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "UI Scale"
|
||||
description: "Global interface scaling factor (" + ui.getSetting("ui_scale").toFixed(2) + "x)"
|
||||
control: ModernSlider {
|
||||
Layout.preferredWidth: 200
|
||||
from: 0.75; to: 1.5
|
||||
value: ui.getSetting("ui_scale")
|
||||
onMoved: ui.setSetting("ui_scale", Number(value.toFixed(2)))
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Always on Top"
|
||||
description: "Keep the overlay visible above other windows"
|
||||
control: ModernSwitch {
|
||||
checked: ui.getSetting("always_on_top")
|
||||
onToggled: ui.setSetting("always_on_top", checked)
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Window Opacity"
|
||||
description: "Transparency level"
|
||||
showSeparator: false
|
||||
control: ModernSlider {
|
||||
Layout.preferredWidth: 200
|
||||
from: 0.1; to: 1.0
|
||||
value: ui.getSetting("opacity")
|
||||
onMoved: ui.setSetting("opacity", Number(value.toFixed(2)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsSection {
|
||||
title: "Window Position"
|
||||
Layout.margins: 32
|
||||
Layout.topMargin: 0
|
||||
|
||||
content: ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 0
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Anchor Position"
|
||||
description: "Where the overlay snaps to on screen"
|
||||
control: ModernComboBox {
|
||||
width: 160
|
||||
model: ["Bottom Center", "Top Center", "Bottom Right", "Top Right", "Bottom Left", "Top Left"]
|
||||
currentIndex: model.indexOf(ui.getSetting("overlay_position"))
|
||||
onActivated: ui.setSetting("overlay_position", currentText)
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Horizontal Offset"
|
||||
description: "Fine-tune X position (" + ui.getSetting("overlay_offset_x") + "px)"
|
||||
control: ModernSlider {
|
||||
Layout.preferredWidth: 200
|
||||
from: -500; to: 500
|
||||
value: ui.getSetting("overlay_offset_x")
|
||||
onMoved: ui.setSetting("overlay_offset_x", value)
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Vertical Offset"
|
||||
description: "Fine-tune Y position (" + ui.getSetting("overlay_offset_y") + "px)"
|
||||
showSeparator: false
|
||||
control: ModernSlider {
|
||||
Layout.preferredWidth: 200
|
||||
from: -500; to: 500
|
||||
value: ui.getSetting("overlay_offset_y")
|
||||
onMoved: ui.setSetting("overlay_offset_y", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- TAB: AI ENGINE ---
|
||||
ScrollView {
|
||||
ScrollBar.vertical.policy: ScrollBar.AsNeeded
|
||||
contentWidth: availableWidth
|
||||
|
||||
ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 24
|
||||
anchors.margins: 32
|
||||
|
||||
ColumnLayout {
|
||||
spacing: 4
|
||||
Layout.topMargin: 32
|
||||
Layout.leftMargin: 32
|
||||
Layout.rightMargin: 32
|
||||
Text { text: "AI Engine"; color: SettingsStyle.textPrimary; font.family: mainFont; font.pixelSize: 24; font.bold: true }
|
||||
Text { text: "Model configuration and performance"; color: SettingsStyle.textSecondary; font.family: mainFont; font.pixelSize: 14 }
|
||||
}
|
||||
|
||||
ModernSettingsSection {
|
||||
title: "Model Config"
|
||||
Layout.margins: 32
|
||||
Layout.topMargin: 0
|
||||
|
||||
content: ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 0
|
||||
|
||||
ListModel {
|
||||
id: modelDetailsModel
|
||||
ListElement { name: "tiny"; info: "39M params • <1GB VRAM • 32x speed. Fastest, best for weak hardware." }
|
||||
ListElement { name: "base"; info: "74M params • ~1GB VRAM • 16x speed. Efficient for simple commands." }
|
||||
ListElement { name: "small"; info: "244M params • ~2GB VRAM • 6x speed. Recommended for most users." }
|
||||
ListElement { name: "medium"; info: "769M params • ~5GB VRAM • Accurate, high fidelity. Mid-range GPU req." }
|
||||
ListElement { name: "large-v3"; info: "1.5B params • ~10GB VRAM • Pro quality. Requires high-end hardware." }
|
||||
ListElement { name: "turbo"; info: "800M params • ~6GB VRAM • Large-v3 quality with 8x speed boost." }
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Model Size"
|
||||
description: "Larger models are smarter but slower"
|
||||
control: ModernComboBox {
|
||||
id: modelSizeCombo
|
||||
width: 140
|
||||
model: ["tiny", "base", "small", "medium", "large-v3", "turbo"]
|
||||
currentIndex: model.indexOf(ui.getSetting("model_size"))
|
||||
onActivated: ui.setSetting("model_size", currentText)
|
||||
}
|
||||
}
|
||||
|
||||
// Model Info Card
|
||||
Rectangle {
|
||||
id: modelInfoCard
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 12
|
||||
Layout.topMargin: 0
|
||||
Layout.bottomMargin: 16
|
||||
height: 54
|
||||
color: "#0a0a0f"
|
||||
radius: 6
|
||||
border.color: SettingsStyle.borderSubtle
|
||||
border.width: 1
|
||||
|
||||
// Improved reactive check
|
||||
property bool isDownloaded: false
|
||||
|
||||
Timer {
|
||||
id: checkTimer
|
||||
interval: 1000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: parent.checkStatus()
|
||||
}
|
||||
|
||||
function checkStatus() {
|
||||
if (modelSizeCombo && modelSizeCombo.currentText) {
|
||||
isDownloaded = ui.isModelDownloaded(modelSizeCombo.currentText)
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh when notified by Python
|
||||
Connections {
|
||||
target: ui
|
||||
function onModelStatesChanged() {
|
||||
checkStatus()
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: checkStatus()
|
||||
|
||||
// Also check when selection changes
|
||||
Connections {
|
||||
target: modelSizeCombo
|
||||
function onActivated() { modelInfoCard.checkStatus() }
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
spacing: 12
|
||||
|
||||
Image {
|
||||
source: "smart_toy.svg"
|
||||
sourceSize: Qt.size(16, 16)
|
||||
layer.enabled: true
|
||||
layer.effect: MultiEffect {
|
||||
colorization: 1.0
|
||||
colorizationColor: modelInfoCard.isDownloaded ? SettingsStyle.accent : "#808080"
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
Text {
|
||||
text: {
|
||||
if (ui.isDownloading) return "Downloading AI Core..."
|
||||
for (var i = 0; i < modelDetailsModel.count; i++) {
|
||||
if (modelDetailsModel.get(i).name === modelSizeCombo.currentText) {
|
||||
return modelDetailsModel.get(i).info
|
||||
}
|
||||
}
|
||||
return "Select a model."
|
||||
}
|
||||
color: "#ffffff"
|
||||
font.family: "JetBrains Mono"
|
||||
font.pixelSize: 10
|
||||
opacity: 0.7
|
||||
elide: Text.ElideRight
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: downloaderBar
|
||||
visible: ui.isDownloading
|
||||
Layout.fillWidth: true
|
||||
height: 2
|
||||
color: "#20ffffff"
|
||||
Rectangle {
|
||||
width: downloaderBar.width * 0.5
|
||||
height: downloaderBar.height
|
||||
color: SettingsStyle.accent
|
||||
SequentialAnimation on x {
|
||||
loops: Animation.Infinite
|
||||
NumberAnimation { from: -width; to: downloaderBar.width; duration: 1500 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
id: downloadBtn
|
||||
text: "Download"
|
||||
visible: !modelInfoCard.isDownloaded && !ui.isDownloading
|
||||
Layout.preferredHeight: 24
|
||||
Layout.preferredWidth: 80
|
||||
|
||||
contentItem: Text {
|
||||
text: "DOWNLOAD"
|
||||
font.pixelSize: 10; font.bold: true; color: "#000000"; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
color: downloadBtn.hovered ? "#ffffff" : SettingsStyle.accent; radius: 4
|
||||
}
|
||||
onClicked: ui.downloadModel(modelSizeCombo.currentText)
|
||||
}
|
||||
|
||||
// Status tag
|
||||
Rectangle {
|
||||
id: statusTag
|
||||
visible: modelInfoCard.isDownloaded && !ui.isDownloading
|
||||
height: 18; width: 64; radius: 4; color: "#1000f2ff"
|
||||
border.color: "#3000f2ff"; border.width: 1
|
||||
Text {
|
||||
anchors.centerIn: statusTag; text: "INSTALLED"; font.pixelSize: 9
|
||||
font.bold: true; color: SettingsStyle.accent; opacity: 0.9
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Language"
|
||||
description: "Force language or Auto-detect"
|
||||
control: ModernComboBox {
|
||||
width: 140
|
||||
model: ["auto", "en", "fr", "de", "es", "it", "ja", "zh", "ru"]
|
||||
currentIndex: model.indexOf(ui.getSetting("language"))
|
||||
onActivated: ui.setSetting("language", currentText)
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Compute Device"
|
||||
description: "Hardware acceleration (CUDA requires NVidia GPU)"
|
||||
control: ModernComboBox {
|
||||
width: 140
|
||||
model: ["auto", "cuda", "cpu"]
|
||||
currentIndex: model.indexOf(ui.getSetting("compute_device"))
|
||||
onActivated: ui.setSetting("compute_device", currentText)
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Precision"
|
||||
description: "Quantization type (int8 is faster, float16 is accurate)"
|
||||
showSeparator: false
|
||||
control: ModernComboBox {
|
||||
width: 140
|
||||
model: ["int8", "float16", "float32"]
|
||||
currentIndex: model.indexOf(ui.getSetting("compute_type"))
|
||||
onActivated: ui.setSetting("compute_type", currentText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsSection {
|
||||
title: "Advanced Decoding"
|
||||
Layout.margins: 32
|
||||
Layout.topMargin: 0
|
||||
|
||||
content: ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 0
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Beam Size"
|
||||
description: "Search width (Higher = Better Accuracy, Slower)"
|
||||
control: ModernSlider {
|
||||
Layout.preferredWidth: 200
|
||||
from: 1; to: 10
|
||||
value: ui.getSetting("beam_size")
|
||||
onMoved: ui.setSetting("beam_size", value)
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "VAD Filter"
|
||||
description: "Skip silent audio segments (Speeds up processing)"
|
||||
control: ModernSwitch {
|
||||
checked: ui.getSetting("vad_filter")
|
||||
onToggled: ui.setSetting("vad_filter", checked)
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Hallucination Check"
|
||||
description: "Prevent repetitive text loops (No Repeat N-Gram)"
|
||||
control: ModernSwitch {
|
||||
checked: ui.getSetting("no_repeat_ngram_size") > 0
|
||||
onToggled: ui.setSetting("no_repeat_ngram_size", checked ? 3 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
ModernSettingsItem {
|
||||
label: "Context History"
|
||||
description: "Use previous text to improve coherence"
|
||||
showSeparator: false
|
||||
control: ModernSwitch {
|
||||
checked: ui.getSetting("condition_on_previous_text")
|
||||
onToggled: ui.setSetting("condition_on_previous_text", checked)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- TAB: DEBUG ---
|
||||
ScrollView {
|
||||
ScrollBar.vertical.policy: ScrollBar.AsNeeded
|
||||
contentWidth: availableWidth
|
||||
|
||||
ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 16
|
||||
anchors.margins: 32
|
||||
|
||||
ColumnLayout {
|
||||
spacing: 4
|
||||
Layout.topMargin: 32
|
||||
Layout.leftMargin: 32
|
||||
Layout.rightMargin: 32
|
||||
Text { text: "System Diagnostics"; color: SettingsStyle.textPrimary; font.family: mainFont; font.pixelSize: 24; font.bold: true }
|
||||
Text { text: "Live performance and logs"; color: SettingsStyle.textSecondary; font.family: mainFont; font.pixelSize: 14 }
|
||||
}
|
||||
|
||||
// --- PERFORMANCE STATS ---
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: 32
|
||||
Layout.rightMargin: 32
|
||||
spacing: 16
|
||||
|
||||
StatBox { label: "APP CPU"; value: ui.appCpu; unit: "%"; accent: "#00f2ff" }
|
||||
StatBox { label: "APP RAM"; value: ui.appRamMb; unit: "MB"; accent: "#bd93f9" }
|
||||
StatBox { label: "GPU VRAM"; value: ui.appVramMb; unit: "MB"; accent: "#ff79c6" }
|
||||
StatBox { label: "GPU LOAD"; value: ui.appVramPercent; unit: "%"; accent: "#ff5555" }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 300
|
||||
Layout.margins: 32
|
||||
Layout.topMargin: 0
|
||||
color: "#0d0d10"
|
||||
radius: 8
|
||||
border.color: SettingsStyle.borderSubtle
|
||||
border.width: 1
|
||||
clip: true
|
||||
|
||||
ScrollView {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 12
|
||||
ScrollBar.vertical.policy: ScrollBar.AlwaysOn
|
||||
|
||||
TextArea {
|
||||
id: logArea
|
||||
text: ui.allLogs
|
||||
readOnly: true
|
||||
color: "#cccccc"
|
||||
font.family: "JetBrains Mono"
|
||||
font.pixelSize: 11
|
||||
wrapMode: TextArea.Wrap
|
||||
background: null
|
||||
selectByMouse: true
|
||||
|
||||
Connections {
|
||||
target: ui
|
||||
function onLogAppended(line) {
|
||||
logArea.append(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
src/ui/qml/SettingsStyle.qml
Normal file
@@ -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
@@ -0,0 +1,56 @@
|
||||
#version 440
|
||||
|
||||
layout(location = 0) in vec2 qt_TexCoord0;
|
||||
layout(location = 0) out vec4 fragColor;
|
||||
|
||||
layout(std140, binding = 0) uniform buf {
|
||||
mat4 qt_Matrix;
|
||||
float qt_Opacity;
|
||||
float time;
|
||||
};
|
||||
|
||||
layout(binding = 1) uniform sampler2D source;
|
||||
|
||||
void main() {
|
||||
vec2 uv = qt_TexCoord0;
|
||||
|
||||
// CRT curvature distortion
|
||||
vec2 crtUV = uv - 0.5;
|
||||
float curvature = 0.03;
|
||||
crtUV *= 1.0 + curvature * (crtUV.x * crtUV.x + crtUV.y * crtUV.y);
|
||||
crtUV += 0.5;
|
||||
|
||||
// Clamp to avoid sampling outside
|
||||
if (crtUV.x < 0.0 || crtUV.x > 1.0 || crtUV.y < 0.0 || crtUV.y > 1.0) {
|
||||
fragColor = vec4(0.0, 0.0, 0.0, 0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sample the texture
|
||||
vec4 color = texture(source, crtUV);
|
||||
|
||||
// Scanlines effect (fine and subtle)
|
||||
float scanlineIntensity = 0.04;
|
||||
float scanline = sin(crtUV.y * 2400.0) * scanlineIntensity;
|
||||
color.rgb -= scanline;
|
||||
|
||||
// RGB color separation (chromatic aberration)
|
||||
float aberration = 0.001;
|
||||
float r = texture(source, crtUV + vec2(aberration, 0.0)).r;
|
||||
float g = color.g;
|
||||
float b = texture(source, crtUV - vec2(aberration, 0.0)).b;
|
||||
color.rgb = vec3(r, g, b);
|
||||
|
||||
// Subtle vignette
|
||||
float vignette = 1.0 - 0.3 * length(crtUV - 0.5);
|
||||
color.rgb *= vignette;
|
||||
|
||||
// Slight green/cyan phosphor tint
|
||||
color.rgb *= vec3(0.95, 1.0, 0.98);
|
||||
|
||||
// Flicker (very subtle)
|
||||
float flicker = 0.98 + 0.02 * sin(time * 15.0);
|
||||
color.rgb *= flicker;
|
||||
|
||||
fragColor = color * qt_Opacity;
|
||||
}
|
||||
BIN
src/ui/qml/crt.qsb
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-Bold.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-BoldItalic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-ExtraBold.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-ExtraBoldItalic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-ExtraLight.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-ExtraLightItalic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-Italic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-Light.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-LightItalic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-Medium.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-MediumItalic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-Regular.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-SemiBold.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-SemiBoldItalic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-Thin.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMono-ThinItalic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-Bold.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-BoldItalic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-ExtraBold.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-ExtraBoldItalic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-ExtraLight.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-ExtraLightItalic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-Italic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-Light.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-LightItalic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-Medium.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-MediumItalic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-Regular.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-SemiBold.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-SemiBoldItalic.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-Thin.ttf
Normal file
BIN
src/ui/qml/fonts/ttf/JetBrainsMonoNL-ThinItalic.ttf
Normal file
BIN
src/ui/qml/fonts/variable/JetBrainsMono-Italic[wght].ttf
Normal file
BIN
src/ui/qml/fonts/variable/JetBrainsMono[wght].ttf
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-Bold.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-BoldItalic.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-ExtraBold.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-ExtraBoldItalic.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-ExtraLight.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-ExtraLightItalic.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-Italic.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-Light.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-LightItalic.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-Medium.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-MediumItalic.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-Regular.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-SemiBold.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-SemiBoldItalic.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-Thin.woff2
Normal file
BIN
src/ui/qml/fonts/webfonts/JetBrainsMono-ThinItalic.woff2
Normal file
50
src/ui/qml/glass.frag
Normal file
@@ -0,0 +1,50 @@
|
||||
#version 440
|
||||
|
||||
layout(location = 0) in vec2 qt_TexCoord0;
|
||||
layout(location = 0) out vec4 fragColor;
|
||||
|
||||
layout(std140, binding = 0) uniform buf {
|
||||
mat4 qt_Matrix;
|
||||
float qt_Opacity;
|
||||
float time;
|
||||
float aberration; // 0.0 to 1.0, controlled by Audio Amplitude
|
||||
};
|
||||
|
||||
float rand(vec2 co) {
|
||||
return fract(sin(dot(co.xy ,vec2(12.9898,78.233))) * 43758.5453);
|
||||
}
|
||||
|
||||
void main() {
|
||||
// 1. Calculate Distortion Offset based on Amplitude (aberration)
|
||||
// We warp the UVs slightly away from center
|
||||
vec2 uv = qt_TexCoord0;
|
||||
vec2 dist = uv - 0.5;
|
||||
|
||||
// 2. Chromatic Aberration
|
||||
// Red Channel shifts OUT
|
||||
// Blue Channel shifts IN
|
||||
float strength = aberration * 0.02; // Max shift 2% of texture size
|
||||
|
||||
vec2 rUV = uv + (dist * strength);
|
||||
vec2 bUV = uv - (dist * strength);
|
||||
|
||||
// Sample texture? We don't have a texture input (source is empty Item), we are generating visuals.
|
||||
// Wait, ShaderEffect usually works on sourceItem.
|
||||
// Here we are generating NOISE on top of a gradient.
|
||||
// So we apply Aberration to the NOISE function?
|
||||
// Or do we want to aberrate the pixels UNDERNEATH?
|
||||
// ShaderEffect with no source property renders purely procedural content.
|
||||
|
||||
// Let's create layered procedural noise with channel offsets
|
||||
float nR = rand(rUV + vec2(time * 0.01, 0.0));
|
||||
float nG = rand(uv + vec2(time * 0.01, 0.0)); // Green is anchor
|
||||
float nB = rand(bUV + vec2(time * 0.01, 0.0));
|
||||
|
||||
// Also modulate alpha by aberration - higher volume = more intense grain?
|
||||
// Or maybe just pure glitch.
|
||||
|
||||
vec4 grainColor = vec4(nR, nG, nB, 1.0);
|
||||
|
||||
// Mix it with opacity
|
||||
fragColor = grainColor * qt_Opacity;
|
||||
}
|
||||
BIN
src/ui/qml/glass.qsb
Normal file
40
src/ui/qml/glow.frag
Normal file
@@ -0,0 +1,40 @@
|
||||
#version 440
|
||||
|
||||
layout(location = 0) in vec2 qt_TexCoord0;
|
||||
layout(location = 0) out vec4 fragColor;
|
||||
|
||||
layout(std140, binding = 0) uniform buf {
|
||||
mat4 qt_Matrix;
|
||||
float qt_Opacity;
|
||||
float time;
|
||||
float intensity; // 0.0 to 1.0, controlled by Audio Amplitude
|
||||
};
|
||||
|
||||
float rand(vec2 co) {
|
||||
return fract(sin(dot(co.xy ,vec2(12.9898,78.233))) * 43758.5453);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = qt_TexCoord0;
|
||||
|
||||
// 1. Base Noise (subtle grain)
|
||||
float noise = rand(uv + vec2(time * 0.01, 0.0));
|
||||
|
||||
// 2. Radial Glow (bright center, fading to edges)
|
||||
vec2 center = vec2(0.5, 0.5);
|
||||
float dist = distance(uv, center);
|
||||
|
||||
// Glow strength based on intensity (audio amplitude)
|
||||
// Higher intensity = brighter, wider glow
|
||||
float glowRadius = 0.3 + (intensity * 0.4); // Radius grows with volume
|
||||
float glow = 1.0 - smoothstep(0.0, glowRadius, dist);
|
||||
glow = pow(glow, 2.0); // Sharpen the falloff
|
||||
|
||||
// 3. Color the glow (warm white/cyan tint)
|
||||
vec3 glowColor = vec3(0.8, 0.9, 1.0); // Slight cyan tint
|
||||
|
||||
// 4. Combine noise + glow
|
||||
vec3 finalColor = mix(vec3(noise), glowColor, glow * intensity);
|
||||
|
||||
fragColor = vec4(finalColor, 1.0) * qt_Opacity;
|
||||
}
|
||||
BIN
src/ui/qml/glow.qsb
Normal file
87
src/ui/qml/gradient_blobs.frag
Normal file
@@ -0,0 +1,87 @@
|
||||
#version 440
|
||||
|
||||
layout(location = 0) in vec2 qt_TexCoord0;
|
||||
layout(location = 0) out vec4 fragColor;
|
||||
|
||||
layout(std140, binding = 0) uniform buf {
|
||||
mat4 qt_Matrix;
|
||||
float qt_Opacity;
|
||||
float time;
|
||||
};
|
||||
|
||||
// Smooth noise function
|
||||
float noise(vec2 p) {
|
||||
return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453);
|
||||
}
|
||||
|
||||
// Smooth interpolation
|
||||
float smoothNoise(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
f = f * f * (3.0 - 2.0 * f); // Smoothstep
|
||||
|
||||
float a = noise(i);
|
||||
float b = noise(i + vec2(1.0, 0.0));
|
||||
float c = noise(i + vec2(0.0, 1.0));
|
||||
float d = noise(i + vec2(1.0, 1.0));
|
||||
|
||||
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
|
||||
}
|
||||
|
||||
// Fractal Brownian Motion
|
||||
float fbm(vec2 p) {
|
||||
float value = 0.0;
|
||||
float amplitude = 0.5;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
value += amplitude * smoothNoise(p);
|
||||
p *= 2.0;
|
||||
amplitude *= 0.5;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// HSV to RGB conversion
|
||||
vec3 hsv2rgb(vec3 c) {
|
||||
vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
|
||||
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
|
||||
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = qt_TexCoord0;
|
||||
float t = time * 0.05;
|
||||
|
||||
// Multiple moving blobs
|
||||
vec2 p1 = uv * 3.0 + vec2(t * 0.3, t * 0.2);
|
||||
vec2 p2 = uv * 2.5 + vec2(-t * 0.4, t * 0.3);
|
||||
vec2 p3 = uv * 4.0 + vec2(t * 0.2, -t * 0.25);
|
||||
|
||||
float blob1 = fbm(p1);
|
||||
float blob2 = fbm(p2);
|
||||
float blob3 = fbm(p3);
|
||||
|
||||
// Combine blobs
|
||||
float combined = (blob1 + blob2 + blob3) / 3.0;
|
||||
|
||||
// Live hue shifting - slowly cycle through hues over time
|
||||
float hueShift = time * 0.02; // Slow hue rotation
|
||||
|
||||
// Create colors using HSV with shifting hue
|
||||
vec3 color1 = hsv2rgb(vec3(fract(0.75 + hueShift), 0.6, 0.35)); // Purple range
|
||||
vec3 color2 = hsv2rgb(vec3(fract(0.85 + hueShift), 0.7, 0.35)); // Magenta range
|
||||
vec3 color3 = hsv2rgb(vec3(fract(0.55 + hueShift), 0.7, 0.35)); // Blue range
|
||||
vec3 color4 = hsv2rgb(vec3(fract(0.08 + hueShift), 0.7, 0.35)); // Orange range
|
||||
vec3 color5 = hsv2rgb(vec3(fract(0.50 + hueShift), 0.6, 0.30)); // Teal range
|
||||
|
||||
// Mix colors based on blob values for visible multi-color effect
|
||||
vec3 color = mix(color1, color2, blob1);
|
||||
color = mix(color, color3, blob2);
|
||||
color = mix(color, color4, blob3);
|
||||
color = mix(color, color5, combined);
|
||||
|
||||
// Moderate brightness for visible but dark background accent
|
||||
float brightness = 0.25 + combined * 0.25;
|
||||
color *= brightness;
|
||||
|
||||
fragColor = vec4(color, qt_Opacity);
|
||||
}
|
||||
BIN
src/ui/qml/gradient_blobs.qsb
Normal file
BIN
src/ui/qml/icon.ico
Normal file
|
After Width: | Height: | Size: 73 KiB |
1
src/ui/qml/microphone.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. --><path fill="#ffffff" d="M192 0C139 0 96 43 96 96l0 160c0 53 43 96 96 96s96-43 96-96l0-160c0-53-43-96-96-96zM64 216c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 40c0 89.1 66.2 162.7 152 174.4l0 33.6-48 0c-13.3 0-24 10.7-24 24s10.7 24 24 24l72 0 72 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-48 0 0-33.6c85.8-11.7 152-85.3 152-174.4l0-40c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 40c0 70.7-57.3 128-128 128s-128-57.3-128-128l0-40z"/></svg>
|
||||
|
After Width: | Height: | Size: 695 B |
25
src/ui/qml/noise.frag
Normal 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
3
src/ui/qml/qmldir
Normal file
@@ -0,0 +1,3 @@
|
||||
singleton SettingsStyle 1.0 SettingsStyle.qml
|
||||
ModernSettingsSection 1.0 ModernSettingsSection.qml
|
||||
ModernSettingsItem 1.0 ModernSettingsItem.qml
|
||||
65
src/ui/qml/rainbow_wave.frag
Normal 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
BIN
src/ui/qml/settings.png
Normal file
|
After Width: | Height: | Size: 492 KiB |
1
src/ui/qml/settings.svg
Normal 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
|
After Width: | Height: | Size: 490 KiB |
1
src/ui/qml/smart_toy.svg
Normal 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
@@ -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
|
After Width: | Height: | Size: 464 KiB |
1
src/ui/qml/visibility.svg
Normal 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
@@ -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
@@ -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)
|
||||