├── requirements.txt ├── __init__.py ├── telly-spelly.png ├── __pycache__ ├── window.cpython-313.pyc ├── mic_test.cpython-313.pyc ├── recorder.cpython-313.pyc ├── settings.cpython-313.pyc ├── mic_debug.cpython-313.pyc ├── shortcuts.cpython-313.pyc ├── transcriber.cpython-313.pyc ├── volume_meter.cpython-313.pyc ├── loading_window.cpython-313.pyc ├── progress_window.cpython-313.pyc ├── settings_window.cpython-313.pyc ├── clipboard_manager.cpython-313.pyc └── processing_window.cpython-313.pyc ├── ~ └── .config │ └── autostart │ └── voice-recorder.desktop ├── org.kde.telly_spelly.desktop ├── uninstall.py ├── clipboard_manager.py ├── processing_window.py ├── setup.sh ├── loading_window.py ├── mic_debug.py ├── settings.py ├── shortcuts.py ├── progress_window.py ├── README.md ├── volume_meter.py ├── install.py ├── transcriber.py ├── mic_test.py ├── settings_window.py ├── recorder.py ├── window.py └── main.py /requirements.txt: -------------------------------------------------------------------------------- 1 | PyQt6 2 | numpy 3 | pyaudio 4 | scipy -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | # Empty file to make the directory a Python package -------------------------------------------------------------------------------- /telly-spelly.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbasilveira/telly-spelly/HEAD/telly-spelly.png -------------------------------------------------------------------------------- /__pycache__/window.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbasilveira/telly-spelly/HEAD/__pycache__/window.cpython-313.pyc -------------------------------------------------------------------------------- /__pycache__/mic_test.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbasilveira/telly-spelly/HEAD/__pycache__/mic_test.cpython-313.pyc -------------------------------------------------------------------------------- /__pycache__/recorder.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbasilveira/telly-spelly/HEAD/__pycache__/recorder.cpython-313.pyc -------------------------------------------------------------------------------- /__pycache__/settings.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbasilveira/telly-spelly/HEAD/__pycache__/settings.cpython-313.pyc -------------------------------------------------------------------------------- /__pycache__/mic_debug.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbasilveira/telly-spelly/HEAD/__pycache__/mic_debug.cpython-313.pyc -------------------------------------------------------------------------------- /__pycache__/shortcuts.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbasilveira/telly-spelly/HEAD/__pycache__/shortcuts.cpython-313.pyc -------------------------------------------------------------------------------- /__pycache__/transcriber.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbasilveira/telly-spelly/HEAD/__pycache__/transcriber.cpython-313.pyc -------------------------------------------------------------------------------- /__pycache__/volume_meter.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbasilveira/telly-spelly/HEAD/__pycache__/volume_meter.cpython-313.pyc -------------------------------------------------------------------------------- /__pycache__/loading_window.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbasilveira/telly-spelly/HEAD/__pycache__/loading_window.cpython-313.pyc -------------------------------------------------------------------------------- /__pycache__/progress_window.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbasilveira/telly-spelly/HEAD/__pycache__/progress_window.cpython-313.pyc -------------------------------------------------------------------------------- /__pycache__/settings_window.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbasilveira/telly-spelly/HEAD/__pycache__/settings_window.cpython-313.pyc -------------------------------------------------------------------------------- /__pycache__/clipboard_manager.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbasilveira/telly-spelly/HEAD/__pycache__/clipboard_manager.cpython-313.pyc -------------------------------------------------------------------------------- /__pycache__/processing_window.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbasilveira/telly-spelly/HEAD/__pycache__/processing_window.cpython-313.pyc -------------------------------------------------------------------------------- /~/.config/autostart/voice-recorder.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Type=Application 3 | Name=Telly Spelly 4 | Exec=/path/to/your/python /path/to/your/main.py 5 | Icon=telly-spelly 6 | X-GNOME-Autostart-enabled=true -------------------------------------------------------------------------------- /org.kde.telly_spelly.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Telly Spelly 3 | Comment=Record and transcribe audio using Whisper 4 | Version=1.0 5 | Exec=telly-spelly 6 | Icon=telly-spelly 7 | Type=Application 8 | Categories=Qt;KDE;Audio;AudioVideo; 9 | Terminal=false 10 | X-KDE-StartupNotify=true 11 | -------------------------------------------------------------------------------- /uninstall.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import shutil 4 | from pathlib import Path 5 | import sys 6 | 7 | def uninstall_application(): 8 | home = Path.home() 9 | 10 | # Remove application files 11 | app_dir = home / ".local/share/telly-spelly" 12 | if app_dir.exists(): 13 | shutil.rmtree(app_dir) 14 | 15 | # Remove launcher 16 | launcher = home / ".local/bin/telly-spelly" 17 | if launcher.exists(): 18 | launcher.unlink() 19 | 20 | # Remove desktop file 21 | desktop_file = home / ".local/share/applications/org.kde.telly_spelly.desktop" 22 | if desktop_file.exists(): 23 | desktop_file.unlink() 24 | 25 | # Remove icon 26 | icon_file = home / ".local/share/icons/hicolor/256x256/apps/telly-spelly.png" 27 | if icon_file.exists(): 28 | icon_file.unlink() 29 | 30 | print("Application uninstalled successfully!") 31 | 32 | if __name__ == "__main__": 33 | try: 34 | uninstall_application() 35 | except Exception as e: 36 | print(f"Uninstallation failed: {e}") 37 | sys.exit(1) 38 | -------------------------------------------------------------------------------- /clipboard_manager.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtCore import QObject 2 | from PyQt6.QtGui import QClipboard, QGuiApplication 3 | import subprocess 4 | import logging 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | class ClipboardManager(QObject): 9 | def __init__(self): 10 | super().__init__() 11 | self.clipboard = QGuiApplication.clipboard() 12 | 13 | def paste_text(self, text): 14 | if not text: 15 | logger.warning("Received empty text, skipping clipboard operation") 16 | return 17 | 18 | logger.info(f"Copying text to clipboard: {text[:50]}...") 19 | self.clipboard.setText(text) 20 | 21 | # If set to paste to active window, simulate Ctrl+V 22 | if self.should_paste_to_active_window(): 23 | self.paste_to_active_window() 24 | 25 | def should_paste_to_active_window(self): 26 | # TODO: Get this from settings 27 | return False 28 | 29 | def paste_to_active_window(self): 30 | try: 31 | # Use xdotool to simulate Ctrl+V 32 | subprocess.run(['xdotool', 'key', 'ctrl+v'], check=True) 33 | except Exception as e: 34 | logger.error(f"Failed to paste to active window: {e}") -------------------------------------------------------------------------------- /processing_window.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QProgressBar, QApplication 2 | from PyQt6.QtCore import Qt, pyqtSignal 3 | 4 | class ProcessingWindow(QWidget): 5 | def __init__(self): 6 | super().__init__() 7 | self.setWindowTitle("Processing Recording") 8 | self.setWindowFlags(Qt.WindowType.WindowStaysOnTopHint) 9 | 10 | # Create layout 11 | layout = QVBoxLayout() 12 | self.setLayout(layout) 13 | 14 | # Add status label 15 | self.status_label = QLabel("Transcribing audio...") 16 | layout.addWidget(self.status_label) 17 | 18 | # Add progress bar 19 | self.progress_bar = QProgressBar() 20 | self.progress_bar.setRange(0, 0) # Indeterminate progress 21 | layout.addWidget(self.progress_bar) 22 | 23 | # Set window size 24 | self.setFixedSize(300, 100) 25 | 26 | # Center the window 27 | screen = QApplication.primaryScreen().geometry() 28 | self.move( 29 | screen.center().x() - self.width() // 2, 30 | screen.center().y() - self.height() // 2 31 | ) 32 | 33 | def set_status(self, text): 34 | self.status_label.setText(text) -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Check and install required Python packages first 4 | if [ -f /etc/fedora-release ]; then 5 | echo "Installing required Python packages..." 6 | sudo dnf install -y python3-libs python3-devel python3 7 | fi 8 | 9 | # Create virtual environment if it doesn't exist 10 | if [ ! -d "venv" ]; then 11 | python3 -m venv venv || { 12 | echo "Failed to create virtual environment. Please ensure Python is properly installed." 13 | exit 1 14 | } 15 | fi 16 | 17 | # Activate virtual environment 18 | source venv/bin/activate 19 | 20 | # Upgrade pip 21 | pip install --upgrade pip 22 | 23 | # Install dependencies 24 | pip install -r requirements.txt 25 | 26 | # Check if ffmpeg is installed 27 | if ! command -v ffmpeg &> /dev/null; then 28 | echo "ffmpeg is not installed. Installing..." 29 | if [ -f /etc/fedora-release ]; then 30 | sudo dnf install -y ffmpeg 31 | elif [ -f /etc/debian_version ]; then 32 | sudo apt-get update && sudo apt-get install -y ffmpeg 33 | else 34 | echo "Please install ffmpeg manually for your distribution" 35 | fi 36 | fi 37 | 38 | # Check for portaudio development files 39 | if [ -f /etc/fedora-release ]; then 40 | sudo dnf install -y portaudio-devel python3-devel 41 | elif [ -f /etc/debian_version ]; then 42 | sudo apt-get update && sudo apt-get install -y portaudio19-dev python3-dev 43 | fi 44 | 45 | echo "Setup complete! Run 'source venv/bin/activate' to activate the virtual environment" -------------------------------------------------------------------------------- /loading_window.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import QDialog, QVBoxLayout, QLabel, QProgressBar 2 | from PyQt6.QtCore import Qt, pyqtSignal 3 | from PyQt6.QtGui import QIcon 4 | 5 | class LoadingWindow(QDialog): 6 | def __init__(self, parent=None): 7 | super().__init__(parent) 8 | self.setWindowTitle("Loading Telly Spelly") 9 | self.setFixedSize(400, 150) 10 | self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.CustomizeWindowHint) 11 | 12 | layout = QVBoxLayout(self) 13 | 14 | # Icon and title 15 | title_layout = QVBoxLayout() 16 | icon_label = QLabel() 17 | icon_label.setPixmap(QIcon.fromTheme('audio-input-microphone').pixmap(64, 64)) 18 | icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 19 | title_label = QLabel("Loading Telly Spelly") 20 | title_label.setStyleSheet("font-size: 16pt; font-weight: bold; color: #1d99f3;") 21 | title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 22 | 23 | title_layout.addWidget(icon_label) 24 | title_layout.addWidget(title_label) 25 | layout.addLayout(title_layout) 26 | 27 | # Status message 28 | self.status_label = QLabel("Initializing...") 29 | self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 30 | self.status_label.setStyleSheet("color: #666;") 31 | layout.addWidget(self.status_label) 32 | 33 | # Progress bar 34 | self.progress = QProgressBar() 35 | self.progress.setRange(0, 0) # Infinite progress 36 | layout.addWidget(self.progress) 37 | 38 | def set_status(self, message): 39 | self.status_label.setText(message) -------------------------------------------------------------------------------- /mic_debug.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel 2 | from PyQt6.QtCore import Qt 3 | import numpy as np 4 | 5 | class MicDebugWindow(QWidget): 6 | def __init__(self): 7 | super().__init__() 8 | self.setWindowTitle("Microphone Debug") 9 | self.setMinimumSize(300, 150) 10 | 11 | layout = QVBoxLayout() 12 | 13 | # Current value display 14 | self.value_label = QLabel("Current Value: 0") 15 | self.value_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 16 | layout.addWidget(self.value_label) 17 | 18 | # Peak value display 19 | self.peak_label = QLabel("Peak Value: 0") 20 | self.peak_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 21 | layout.addWidget(self.peak_label) 22 | 23 | # Min/Max display 24 | self.minmax_label = QLabel("Min/Max: 0/0") 25 | self.minmax_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 26 | layout.addWidget(self.minmax_label) 27 | 28 | self.setLayout(layout) 29 | 30 | # Keep track of values 31 | self.min_value = float('inf') 32 | self.max_value = float('-inf') 33 | self.peak_value = 0 34 | 35 | def update_values(self, value): 36 | if value is None: 37 | return 38 | 39 | # Update min/max 40 | self.min_value = min(self.min_value, value) 41 | self.max_value = max(self.max_value, value) 42 | 43 | # Update peak with decay 44 | self.peak_value = max(value, self.peak_value * 0.95) 45 | 46 | # Update labels 47 | self.value_label.setText(f"Current Value: {value:.6f}") 48 | self.peak_label.setText(f"Peak Value: {self.peak_value:.6f}") 49 | self.minmax_label.setText(f"Min/Max: {self.min_value:.6f}/{self.max_value:.6f}") -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtCore import QSettings 2 | 3 | class Settings: 4 | VALID_MODELS = ['tiny', 'base', 'small', 'medium', 'large', 'turbo'] 5 | # List of valid language codes for Whisper 6 | VALID_LANGUAGES = { 7 | 'auto': 'Auto-detect', 8 | 'en': 'English', 9 | 'es': 'Spanish', 10 | 'fr': 'French', 11 | 'de': 'German', 12 | 'it': 'Italian', 13 | 'pt': 'Portuguese', 14 | 'nl': 'Dutch', 15 | 'pl': 'Polish', 16 | 'ja': 'Japanese', 17 | 'zh': 'Chinese', 18 | 'ru': 'Russian', 19 | # Add more languages as needed 20 | } 21 | 22 | def __init__(self): 23 | self.settings = QSettings('TellySpelly', 'TellySpelly') 24 | 25 | def get(self, key, default=None): 26 | value = self.settings.value(key, default) 27 | 28 | # Validate specific settings 29 | if key == 'model' and value not in self.VALID_MODELS: 30 | return default 31 | elif key == 'mic_index': 32 | try: 33 | return int(value) 34 | except (ValueError, TypeError): 35 | return default 36 | elif key == 'language' and value not in self.VALID_LANGUAGES: 37 | return 'auto' # Default to auto-detect 38 | 39 | return value 40 | 41 | def set(self, key, value): 42 | # Validate before saving 43 | if key == 'model' and value not in self.VALID_MODELS: 44 | raise ValueError(f"Invalid model: {value}") 45 | elif key == 'mic_index': 46 | try: 47 | value = int(value) 48 | except (ValueError, TypeError): 49 | raise ValueError(f"Invalid mic_index: {value}") 50 | elif key == 'language' and value not in self.VALID_LANGUAGES: 51 | raise ValueError(f"Invalid language: {value}") 52 | 53 | self.settings.setValue(key, value) 54 | self.settings.sync() -------------------------------------------------------------------------------- /shortcuts.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtCore import QObject, pyqtSignal 2 | from PyQt6.QtGui import QKeySequence, QShortcut 3 | from PyQt6.QtWidgets import QApplication 4 | import logging 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | class GlobalShortcuts(QObject): 9 | start_recording_triggered = pyqtSignal() 10 | stop_recording_triggered = pyqtSignal() 11 | 12 | def __init__(self): 13 | super().__init__() 14 | self.start_shortcut = None 15 | self.stop_shortcut = None 16 | 17 | def setup_shortcuts(self, start_key='Ctrl+Alt+R', stop_key='Ctrl+Alt+S'): 18 | """Setup global keyboard shortcuts""" 19 | try: 20 | # Remove any existing shortcuts 21 | self.remove_shortcuts() 22 | 23 | # Create new shortcuts 24 | self.start_shortcut = QShortcut(QKeySequence(start_key), QApplication.instance()) 25 | self.start_shortcut.setContext(Qt.ShortcutContext.ApplicationShortcut) 26 | self.start_shortcut.activated.connect(self._on_start_triggered) 27 | 28 | self.stop_shortcut = QShortcut(QKeySequence(stop_key), QApplication.instance()) 29 | self.stop_shortcut.setContext(Qt.ShortcutContext.ApplicationShortcut) 30 | self.stop_shortcut.activated.connect(self._on_stop_triggered) 31 | 32 | logger.info(f"Global shortcuts registered - Start: {start_key}, Stop: {stop_key}") 33 | return True 34 | 35 | except Exception as e: 36 | logger.error(f"Failed to register global shortcuts: {e}") 37 | return False 38 | 39 | def remove_shortcuts(self): 40 | """Remove existing shortcuts""" 41 | if self.start_shortcut: 42 | self.start_shortcut.setEnabled(False) 43 | self.start_shortcut.deleteLater() 44 | self.start_shortcut = None 45 | 46 | if self.stop_shortcut: 47 | self.stop_shortcut.setEnabled(False) 48 | self.stop_shortcut.deleteLater() 49 | self.stop_shortcut = None 50 | 51 | def _on_start_triggered(self): 52 | """Called when start recording shortcut is pressed""" 53 | logger.info("Start recording shortcut triggered") 54 | self.start_recording_triggered.emit() 55 | 56 | def _on_stop_triggered(self): 57 | """Called when stop recording shortcut is pressed""" 58 | logger.info("Stop recording shortcut triggered") 59 | self.stop_recording_triggered.emit() 60 | 61 | def __del__(self): 62 | self.remove_shortcuts() -------------------------------------------------------------------------------- /progress_window.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QLabel, QProgressBar, 2 | QApplication, QPushButton, QHBoxLayout) 3 | from PyQt6.QtCore import Qt, pyqtSignal 4 | from volume_meter import VolumeMeter 5 | 6 | class ProgressWindow(QWidget): 7 | stop_clicked = pyqtSignal() # Signal emitted when stop button is clicked 8 | 9 | def __init__(self, title="Recording"): 10 | super().__init__() 11 | self.setWindowTitle(title) 12 | self.setWindowFlags(Qt.WindowType.WindowStaysOnTopHint | 13 | Qt.WindowType.CustomizeWindowHint | 14 | Qt.WindowType.WindowTitleHint) 15 | 16 | # Prevent closing while processing 17 | self.processing = False 18 | 19 | # Create main layout 20 | layout = QVBoxLayout() 21 | self.setLayout(layout) 22 | 23 | # Add status label 24 | self.status_label = QLabel("Recording...") 25 | self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 26 | layout.addWidget(self.status_label) 27 | 28 | # Create volume meter 29 | self.volume_meter = VolumeMeter() 30 | layout.addWidget(self.volume_meter) 31 | 32 | # Add stop button 33 | self.stop_button = QPushButton("Stop Recording") 34 | self.stop_button.clicked.connect(self.stop_clicked.emit) 35 | layout.addWidget(self.stop_button) 36 | 37 | # Set window size 38 | self.setFixedSize(350, 150) 39 | 40 | # Center the window 41 | screen = QApplication.primaryScreen().geometry() 42 | self.move( 43 | screen.center().x() - self.width() // 2, 44 | screen.center().y() - self.height() // 2 45 | ) 46 | 47 | def closeEvent(self, event): 48 | if self.processing: 49 | event.ignore() 50 | else: 51 | super().closeEvent(event) 52 | 53 | def set_status(self, text): 54 | self.status_label.setText(text) 55 | 56 | def update_volume(self, value): 57 | self.volume_meter.set_value(value) 58 | 59 | def set_processing_mode(self): 60 | """Switch UI to processing mode""" 61 | self.processing = True 62 | self.volume_meter.hide() 63 | self.stop_button.hide() 64 | self.status_label.setText("Processing audio with Whisper...") 65 | self.setFixedHeight(80) 66 | 67 | def set_recording_mode(self): 68 | """Switch back to recording mode""" 69 | self.processing = False 70 | self.volume_meter.show() 71 | self.stop_button.show() 72 | self.status_label.setText("Recording...") 73 | self.setFixedHeight(150) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Telly Spelly for KDE Plasma 2 | 3 | A sleek KDE Plasma application that records audio and transcribes it in real-time using OpenAI's Whisper. Created by Guilherme da Silveira. 4 | 5 | ## Features 6 | 7 | - 🎙️ **Easy Recording**: Start/stop recording with a single click in the system tray 8 | - 🔊 **Live Volume Meter**: Visual feedback while recording 9 | - ⚡ **Global Shortcuts**: Configurable keyboard shortcuts for quick recording 10 | - 🎯 **Microphone Selection**: Choose your preferred input device 11 | - 📋 **Instant Clipboard**: Transcribed text is automatically copied to your clipboard 12 | - 🎨 **Native KDE Integration**: Follows your system theme and integrates seamlessly with Plasma 13 | 14 | ## Installation 15 | 16 | 1. Clone the repository: 17 | ```bash 18 | git clone https://github.com/gbasilveira/telly-spelly.git 19 | cd telly-spelly 20 | ``` 21 | 22 | 2. Run the installer: 23 | ```bash 24 | python3 install.py 25 | ``` 26 | 27 | The installer will: 28 | - Install all required dependencies 29 | - Set up the application in your user directory 30 | - Create desktop entries and icons 31 | - Configure the launcher 32 | 33 | ## Requirements 34 | 35 | - Python 3.8 or higher 36 | - KDE Plasma desktop environment 37 | - PortAudio (for audio recording) 38 | - CUDA-capable GPU (optional, for faster transcription) 39 | 40 | System packages (Ubuntu/Debian): 41 | ```bash 42 | sudo apt install python3-pyaudio portaudio19-dev 43 | ``` 44 | 45 | System packages (Fedora): 46 | ```bash 47 | sudo dnf install python3-pyaudio portaudio-devel 48 | ``` 49 | 50 | ## Usage 51 | 52 | 1. Launch "Telly Spelly" from your application menu or run: 53 | ```bash 54 | telly-spelly 55 | ``` 56 | 57 | 2. Click the tray icon or use configured shortcuts to start/stop recording 58 | 3. When recording stops, the audio will be automatically transcribed 59 | 4. The transcribed text is copied to your clipboard 60 | 61 | ## Configuration 62 | 63 | - Right-click the tray icon and select "Settings" 64 | - Configure: 65 | - Input device selection 66 | - Global keyboard shortcuts 67 | - Whisper model selection 68 | - Interface preferences 69 | 70 | ## Uninstallation 71 | 72 | To remove the application: 73 | ```bash 74 | python3 uninstall.py 75 | ``` 76 | 77 | ## Technical Details 78 | 79 | - Built with PyQt6 for the GUI 80 | - Uses OpenAI's Whisper for transcription 81 | - Integrates with KDE Plasma using system tray and global shortcuts 82 | - Records audio using PyAudio 83 | - Processes audio with scipy for optimal quality 84 | 85 | ## Contributing 86 | 87 | Contributions are welcome! Feel free to: 88 | - Report issues 89 | - Suggest features 90 | - Submit pull requests 91 | 92 | ## License 93 | 94 | This project is licensed under the MIT License - see the LICENSE file for details. 95 | 96 | ## Acknowledgments 97 | 98 | - [OpenAI Whisper](https://github.com/openai/whisper) for the amazing speech recognition model 99 | - KDE Community for the excellent desktop environment 100 | - All contributors and users of this project 101 | 102 | ## Author 103 | 104 | **Guilherme da Silveira** 105 | 106 | --- 107 | 108 | Made with ❤️ for the KDE Community 109 | -------------------------------------------------------------------------------- /volume_meter.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import QWidget 2 | from PyQt6.QtCore import Qt 3 | from PyQt6.QtGui import QPainter, QColor, QLinearGradient 4 | import numpy as np 5 | from collections import deque 6 | 7 | class VolumeMeter(QWidget): 8 | def __init__(self, parent=None): 9 | super().__init__(parent) 10 | self.setMinimumSize(200, 20) 11 | self.value = 0 12 | self.peaks = [] 13 | self.gradient = self._create_gradient() 14 | 15 | # Smaller buffer for less lag 16 | self.buffer_size = 3 # Reduced from 10 17 | self.value_buffer = deque(maxlen=self.buffer_size) 18 | 19 | # Adjusted sensitivity and response 20 | self.sensitivity = 0.002 # Slightly more sensitive 21 | self.smoothing = 0.5 # Less smoothing for faster response 22 | self.last_value = 0 23 | 24 | def _create_gradient(self): 25 | gradient = QLinearGradient(0, 0, self.width(), 0) 26 | gradient.setColorAt(0.0, QColor(0, 255, 0)) # Green 27 | gradient.setColorAt(0.5, QColor(255, 255, 0)) # Yellow 28 | gradient.setColorAt(0.8, QColor(255, 128, 0)) # Orange 29 | gradient.setColorAt(1.0, QColor(255, 0, 0)) # Red 30 | return gradient 31 | 32 | def resizeEvent(self, event): 33 | self.gradient = self._create_gradient() 34 | super().resizeEvent(event) 35 | 36 | def set_value(self, value): 37 | # Add value to buffer 38 | self.value_buffer.append(value) 39 | 40 | # Calculate smoothed value 41 | if len(self.value_buffer) > 0: 42 | # Use weighted average favoring recent values 43 | weights = np.array([0.5, 0.3, 0.2][:len(self.value_buffer)]) 44 | weights = weights / weights.sum() # Normalize weights 45 | avg_value = np.average(self.value_buffer, weights=weights) 46 | 47 | # More responsive scaling 48 | target_value = min(1.0, avg_value / self.sensitivity) 49 | 50 | # Faster smoothing 51 | smoothed = (self.smoothing * self.last_value + 52 | (1 - self.smoothing) * target_value) 53 | 54 | # Less aggressive curve 55 | self.value = np.power(smoothed, 0.9) 56 | self.last_value = smoothed 57 | else: 58 | self.value = 0 59 | 60 | # Faster peak decay 61 | if not self.peaks or value > self.peaks[-1][0]: 62 | self.peaks.append((self.value, 15)) # Shorter hold time 63 | 64 | # Update peaks with faster decay 65 | new_peaks = [] 66 | for peak, frames in self.peaks: 67 | if frames > 0: 68 | decayed_peak = peak * 0.95 # Faster decay 69 | if decayed_peak > 0.01: 70 | new_peaks.append((decayed_peak, frames - 1)) 71 | self.peaks = new_peaks 72 | 73 | self.update() 74 | 75 | def paintEvent(self, event): 76 | painter = QPainter(self) 77 | painter.setRenderHint(QPainter.RenderHint.Antialiasing) 78 | 79 | # Draw background 80 | painter.fillRect(self.rect(), Qt.GlobalColor.black) 81 | 82 | # Draw meter 83 | width = self.width() - 4 84 | height = self.height() - 4 85 | x = 2 86 | y = 2 87 | 88 | meter_width = int(width * self.value) 89 | if meter_width > 0: 90 | rect = self.rect().adjusted(2, 2, -2, -2) 91 | rect.setWidth(meter_width) 92 | painter.fillRect(rect, self.gradient) 93 | 94 | # Draw peak markers 95 | painter.setPen(Qt.GlobalColor.white) 96 | for peak, _ in self.peaks: 97 | peak_x = x + int(width * peak) 98 | painter.drawLine(peak_x, y, peak_x, y + height) -------------------------------------------------------------------------------- /install.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import shutil 5 | from pathlib import Path 6 | import sys 7 | import subprocess 8 | import pkg_resources 9 | 10 | def check_pip(): 11 | try: 12 | import pip 13 | return True 14 | except ImportError: 15 | return False 16 | 17 | def install_requirements(): 18 | """Install required Python packages""" 19 | if not check_pip(): 20 | print("pip is not installed. Please install pip first.") 21 | return False 22 | 23 | print("Installing required packages...") 24 | try: 25 | # Install latest whisper from GitHub first 26 | print("Installing latest Whisper from GitHub...") 27 | subprocess.check_call([ 28 | sys.executable, '-m', 'pip', 'install', '--user', 29 | 'git+https://github.com/openai/whisper.git' 30 | ]) 31 | 32 | # Install other requirements 33 | print("Installing other dependencies...") 34 | subprocess.check_call([sys.executable, '-m', 'pip', 'install', '--user', '-r', 'requirements.txt']) 35 | return True 36 | except subprocess.CalledProcessError as e: 37 | print(f"Failed to install requirements: {e}") 38 | return False 39 | 40 | def install_application(): 41 | # Define paths 42 | home = Path.home() 43 | app_name = "telly-spelly" 44 | 45 | # Check and install requirements first 46 | if not install_requirements(): 47 | print("Failed to install required packages. Installation aborted.") 48 | return False 49 | 50 | # Create application directories 51 | app_dir = home / ".local/share/telly-spelly" 52 | bin_dir = home / ".local/bin" 53 | desktop_dir = home / ".local/share/applications" 54 | icon_dir = home / ".local/share/icons/hicolor/256x256/apps" 55 | 56 | # Create directories if they don't exist 57 | for directory in [app_dir, bin_dir, desktop_dir, icon_dir]: 58 | directory.mkdir(parents=True, exist_ok=True) 59 | 60 | # Copy application files 61 | python_files = ["main.py", "recorder.py", "transcriber.py", "settings.py", 62 | "progress_window.py", "processing_window.py", "settings_window.py", 63 | "loading_window.py", "shortcuts.py", "volume_meter.py"] 64 | 65 | for file in python_files: 66 | if os.path.exists(file): 67 | shutil.copy2(file, app_dir) 68 | else: 69 | print(f"Warning: Could not find {file}") 70 | 71 | # Copy requirements.txt 72 | if os.path.exists('requirements.txt'): 73 | shutil.copy2('requirements.txt', app_dir) 74 | 75 | # Create launcher script with proper Python path 76 | launcher_path = bin_dir / app_name 77 | with open(launcher_path, 'w') as f: 78 | f.write(f'''#!/bin/bash 79 | export PYTHONPATH="$HOME/.local/lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages:$PYTHONPATH" 80 | cd {app_dir} 81 | exec python3 {app_dir}/main.py "$@" 82 | ''') 83 | 84 | # Make launcher executable 85 | launcher_path.chmod(0o755) 86 | 87 | # Copy desktop file 88 | desktop_file = "org.kde.telly_spelly.desktop" 89 | if os.path.exists(desktop_file): 90 | shutil.copy2(desktop_file, desktop_dir) 91 | else: 92 | print(f"Warning: Could not find {desktop_file}") 93 | 94 | # Copy icon 95 | icon_file = "telly-spelly.png" 96 | if os.path.exists(icon_file): 97 | shutil.copy2(icon_file, icon_dir) 98 | else: 99 | print(f"Warning: Could not find {icon_file}") 100 | 101 | print("Installation completed!") 102 | print(f"Application installed to: {app_dir}") 103 | print("You may need to log out and back in for the application to appear in your menu") 104 | return True 105 | 106 | if __name__ == "__main__": 107 | try: 108 | success = install_application() 109 | sys.exit(0 if success else 1) 110 | except Exception as e: 111 | print(f"Installation failed: {e}") 112 | sys.exit(1) 113 | -------------------------------------------------------------------------------- /transcriber.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtCore import QObject, pyqtSignal, QThread, QTimer 2 | import whisper 3 | import os 4 | import logging 5 | import time 6 | from settings import Settings 7 | logger = logging.getLogger(__name__) 8 | 9 | class TranscriptionWorker(QThread): 10 | finished = pyqtSignal(str) 11 | progress = pyqtSignal(str) 12 | error = pyqtSignal(str) 13 | 14 | def __init__(self, model, audio_file): 15 | super().__init__() 16 | self.model = model 17 | self.audio_file = audio_file 18 | 19 | def run(self): 20 | try: 21 | if not os.path.exists(self.audio_file): 22 | raise FileNotFoundError(f"Audio file not found: {self.audio_file}") 23 | 24 | self.progress.emit("Loading audio file...") 25 | 26 | # Load and transcribe 27 | self.progress.emit("Processing audio with Whisper...") 28 | result = self.model.transcribe( 29 | self.audio_file, 30 | fp16=False, 31 | language='en' 32 | ) 33 | 34 | text = result["text"].strip() 35 | if not text: 36 | raise ValueError("No text was transcribed") 37 | 38 | self.progress.emit("Transcription completed!") 39 | logger.info(f"Transcribed text: {text[:100]}...") 40 | self.finished.emit(text) 41 | 42 | except Exception as e: 43 | logger.error(f"Transcription error: {e}") 44 | self.error.emit(f"Transcription failed: {str(e)}") 45 | self.finished.emit("") 46 | finally: 47 | # Clean up the temporary file 48 | try: 49 | if os.path.exists(self.audio_file): 50 | os.remove(self.audio_file) 51 | except Exception as e: 52 | logger.error(f"Failed to remove temporary file: {e}") 53 | 54 | class WhisperTranscriber(QObject): 55 | transcription_progress = pyqtSignal(str) 56 | transcription_finished = pyqtSignal(str) 57 | transcription_error = pyqtSignal(str) 58 | 59 | def __init__(self): 60 | super().__init__() 61 | self.model = None 62 | self.worker = None 63 | self._cleanup_timer = QTimer() 64 | self._cleanup_timer.timeout.connect(self._cleanup_worker) 65 | self._cleanup_timer.setSingleShot(True) 66 | self.load_model() 67 | 68 | def load_model(self): 69 | try: 70 | settings = Settings() 71 | model_name = settings.get('model', 'turbo') 72 | logger.info(f"Loading Whisper model: {model_name}") 73 | 74 | # Redirect whisper's logging to our logger 75 | import logging as whisper_logging 76 | whisper_logging.getLogger("whisper").setLevel(logging.WARNING) 77 | 78 | self.model = whisper.load_model(model_name) 79 | logger.info("Model loaded successfully") 80 | 81 | except Exception as e: 82 | logger.error(f"Failed to load Whisper model: {e}") 83 | raise 84 | 85 | def _cleanup_worker(self): 86 | if self.worker: 87 | if self.worker.isFinished(): 88 | self.worker.deleteLater() 89 | self.worker = None 90 | 91 | def transcribe(self, audio_file): 92 | """Transcribe audio file using Whisper""" 93 | try: 94 | settings = Settings() 95 | language = settings.get('language', 'auto') 96 | 97 | # Emit progress update 98 | self.transcription_progress.emit("Processing audio...") 99 | 100 | # Run transcription with language setting 101 | result = self.model.transcribe( 102 | audio_file, 103 | fp16=False, 104 | language=None if language == 'auto' else language 105 | ) 106 | 107 | text = result["text"].strip() 108 | if not text: 109 | raise ValueError("No text was transcribed") 110 | 111 | self.transcription_progress.emit("Transcription completed!") 112 | logger.info(f"Transcribed text: {text[:100]}...") 113 | self.transcription_finished.emit(text) 114 | 115 | # Clean up the temporary file 116 | try: 117 | if os.path.exists(audio_file): 118 | os.remove(audio_file) 119 | except Exception as e: 120 | logger.error(f"Failed to remove temporary file: {e}") 121 | 122 | except Exception as e: 123 | logger.error(f"Transcription failed: {e}") 124 | self.transcription_error.emit(str(e)) 125 | 126 | def transcribe_file(self, audio_file): 127 | if self.worker and self.worker.isRunning(): 128 | logger.warning("Transcription already in progress") 129 | return 130 | 131 | # Emit initial progress status before starting worker 132 | self.transcription_progress.emit("Starting transcription...") 133 | 134 | self.worker = TranscriptionWorker(self.model, audio_file) 135 | self.worker.finished.connect(self.transcription_finished) 136 | self.worker.progress.connect(self.transcription_progress) 137 | self.worker.error.connect(self.transcription_error) 138 | self.worker.finished.connect(lambda: self._cleanup_timer.start(1000)) 139 | self.worker.start() -------------------------------------------------------------------------------- /mic_test.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QComboBox, 2 | QPushButton, QLabel) 3 | from PyQt6.QtCore import Qt, QTimer 4 | import pyaudio 5 | from volume_meter import VolumeMeter 6 | import numpy as np 7 | import logging 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | class MicTestDialog(QDialog): 12 | def __init__(self, parent=None): 13 | super().__init__(parent) 14 | self.setWindowTitle("Microphone Test") 15 | self.setFixedSize(400, 200) 16 | 17 | # Initialize PyAudio 18 | self.audio = pyaudio.PyAudio() 19 | self.stream = None 20 | self.is_testing = False 21 | 22 | self.init_ui() 23 | 24 | def init_ui(self): 25 | layout = QVBoxLayout(self) 26 | 27 | # Mic selection 28 | mic_layout = QHBoxLayout() 29 | mic_label = QLabel("Select Microphone:") 30 | self.mic_combo = QComboBox() 31 | self.populate_mic_list() 32 | mic_layout.addWidget(mic_label) 33 | mic_layout.addWidget(self.mic_combo) 34 | layout.addLayout(mic_layout) 35 | 36 | # Volume meter 37 | self.volume_meter = VolumeMeter() 38 | layout.addWidget(self.volume_meter) 39 | 40 | # Level indicator 41 | self.level_label = QLabel("Level: -∞ dB") 42 | layout.addWidget(self.level_label) 43 | 44 | # Buttons 45 | button_layout = QHBoxLayout() 46 | self.test_button = QPushButton("Start Test") 47 | self.test_button.clicked.connect(self.toggle_test) 48 | self.ok_button = QPushButton("OK") 49 | self.ok_button.clicked.connect(self.accept) 50 | 51 | button_layout.addWidget(self.test_button) 52 | button_layout.addWidget(self.ok_button) 53 | layout.addLayout(button_layout) 54 | 55 | # Timer for updating the meter 56 | self.update_timer = QTimer() 57 | self.update_timer.setInterval(50) # 20fps 58 | self.update_timer.timeout.connect(self.update_level) 59 | 60 | def populate_mic_list(self): 61 | self.mic_combo.clear() 62 | for i in range(self.audio.get_device_count()): 63 | device_info = self.audio.get_device_info_by_index(i) 64 | if device_info.get('maxInputChannels') > 0: # If it's an input device 65 | name = device_info.get('name') 66 | self.mic_combo.addItem(name, device_info) 67 | logger.info(f"Found input device: {name}") 68 | 69 | def toggle_test(self): 70 | if not self.is_testing: 71 | self.start_test() 72 | else: 73 | self.stop_test() 74 | 75 | def start_test(self): 76 | try: 77 | device_info = self.mic_combo.currentData() 78 | if not device_info: 79 | raise ValueError("No microphone selected") 80 | 81 | self.stream = self.audio.open( 82 | format=pyaudio.paFloat32, 83 | channels=1, 84 | rate=44100, 85 | input=True, 86 | input_device_index=device_info['index'], 87 | frames_per_buffer=1024, 88 | stream_callback=self._audio_callback 89 | ) 90 | 91 | self.stream.start_stream() 92 | self.is_testing = True 93 | self.test_button.setText("Stop Test") 94 | self.update_timer.start() 95 | self.mic_combo.setEnabled(False) 96 | logger.info(f"Started testing microphone: {device_info['name']}") 97 | 98 | except Exception as e: 99 | logger.error(f"Failed to start microphone test: {e}") 100 | self.level_label.setText(f"Error: {str(e)}") 101 | 102 | def stop_test(self): 103 | if self.stream: 104 | self.stream.stop_stream() 105 | self.stream.close() 106 | self.stream = None 107 | 108 | self.is_testing = False 109 | self.test_button.setText("Start Test") 110 | self.update_timer.stop() 111 | self.mic_combo.setEnabled(True) 112 | self.volume_meter.set_value(0) 113 | self.level_label.setText("Level: -∞ dB") 114 | logger.info("Stopped microphone test") 115 | 116 | def _audio_callback(self, in_data, frame_count, time_info, status): 117 | if status: 118 | logger.warning(f"Audio callback status: {status}") 119 | return (in_data, pyaudio.paContinue) 120 | 121 | def update_level(self): 122 | if not self.stream or not self.is_testing: 123 | return 124 | 125 | try: 126 | data = self.stream.read(1024, exception_on_overflow=False) 127 | audio_data = np.frombuffer(data, dtype=np.float32) 128 | rms = np.sqrt(np.mean(np.square(audio_data))) 129 | 130 | # Convert to dB 131 | if rms > 0: 132 | db = 20 * np.log10(rms) 133 | else: 134 | db = -float('inf') 135 | 136 | # Update UI 137 | self.volume_meter.set_value(rms) 138 | self.level_label.setText(f"Level: {db:.1f} dB") 139 | 140 | except Exception as e: 141 | logger.error(f"Error reading audio data: {e}") 142 | 143 | def get_selected_mic_index(self): 144 | device_info = self.mic_combo.currentData() 145 | return device_info['index'] if device_info else None 146 | 147 | def closeEvent(self, event): 148 | self.stop_test() 149 | super().closeEvent(event) -------------------------------------------------------------------------------- /settings_window.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QLabel, QComboBox, 2 | QGroupBox, QFormLayout, QProgressBar, QPushButton, 3 | QLineEdit, QMessageBox) 4 | from PyQt6.QtCore import Qt, QTimer, pyqtSignal 5 | import logging 6 | import keyboard 7 | from PyQt6.QtGui import QKeySequence 8 | from settings import Settings # Add this import at the top 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | class ShortcutEdit(QLineEdit): 13 | def __init__(self, parent=None): 14 | super().__init__(parent) 15 | self.setReadOnly(True) 16 | self.setPlaceholderText("Click to set shortcut...") 17 | self.recording = False 18 | 19 | def keyPressEvent(self, event): 20 | if not self.recording: 21 | return 22 | 23 | modifiers = event.modifiers() 24 | key = event.key() 25 | 26 | if key == Qt.Key.Key_Escape: 27 | self.recording = False 28 | return 29 | 30 | if key in (Qt.Key.Key_Control, Qt.Key.Key_Shift, Qt.Key.Key_Alt, Qt.Key.Key_Meta): 31 | return 32 | 33 | # Create key sequence 34 | sequence = QKeySequence(modifiers | key) 35 | self.setText(sequence.toString()) 36 | self.recording = False 37 | self.clearFocus() 38 | 39 | def mousePressEvent(self, event): 40 | self.recording = True 41 | self.setText("Press shortcut keys...") 42 | 43 | def focusOutEvent(self, event): 44 | super().focusOutEvent(event) 45 | self.recording = False 46 | 47 | class SettingsWindow(QWidget): 48 | initialization_complete = pyqtSignal() 49 | shortcuts_changed = pyqtSignal(str, str) # start_key, stop_key 50 | 51 | def __init__(self): 52 | super().__init__() 53 | self.setWindowTitle("Telly Spelly Settings") 54 | self.setWindowFlags(Qt.WindowType.WindowStaysOnTopHint) 55 | 56 | # Initialize settings 57 | self.settings = Settings() 58 | 59 | layout = QVBoxLayout() 60 | self.setLayout(layout) 61 | 62 | # Model settings group 63 | model_group = QGroupBox("Model Settings") 64 | model_layout = QFormLayout() 65 | 66 | self.model_combo = QComboBox() 67 | self.model_combo.addItems(Settings.VALID_MODELS) 68 | current_model = self.settings.get('model', 'base') 69 | self.model_combo.setCurrentText(current_model) 70 | self.model_combo.currentTextChanged.connect(self.on_model_changed) 71 | model_layout.addRow("Whisper Model:", self.model_combo) 72 | 73 | self.lang_combo = QComboBox() 74 | # Add all supported languages 75 | for code, name in Settings.VALID_LANGUAGES.items(): 76 | self.lang_combo.addItem(name, code) 77 | current_lang = self.settings.get('language', 'auto') 78 | # Find and set the current language 79 | index = self.lang_combo.findData(current_lang) 80 | if index >= 0: 81 | self.lang_combo.setCurrentIndex(index) 82 | self.lang_combo.currentIndexChanged.connect(self.on_language_changed) 83 | model_layout.addRow("Language:", self.lang_combo) 84 | 85 | model_group.setLayout(model_layout) 86 | layout.addWidget(model_group) 87 | 88 | # Recording settings group 89 | recording_group = QGroupBox("Recording Settings") 90 | recording_layout = QFormLayout() 91 | 92 | self.device_combo = QComboBox() 93 | self.device_combo.addItems(["Default Microphone"]) # You can populate this with actual devices 94 | current_mic = self.settings.get('mic_index', 0) 95 | self.device_combo.setCurrentIndex(current_mic) 96 | self.device_combo.currentIndexChanged.connect(self.on_device_changed) 97 | recording_layout.addRow("Input Device:", self.device_combo) 98 | 99 | recording_group.setLayout(recording_layout) 100 | layout.addWidget(recording_group) 101 | 102 | # Loading progress 103 | self.progress_group = QGroupBox("Model Loading") 104 | progress_layout = QVBoxLayout() 105 | self.progress_bar = QProgressBar() 106 | self.progress_label = QLabel("Select a model to load") 107 | progress_layout.addWidget(self.progress_label) 108 | progress_layout.addWidget(self.progress_bar) 109 | self.progress_group.setLayout(progress_layout) 110 | layout.addWidget(self.progress_group) 111 | 112 | # Add shortcuts group 113 | shortcuts_group = QGroupBox("Keyboard Shortcuts") 114 | shortcuts_layout = QFormLayout() 115 | 116 | self.start_shortcut = ShortcutEdit() 117 | self.start_shortcut.setText(self.settings.get('start_shortcut', 'ctrl+alt+r')) 118 | self.stop_shortcut = ShortcutEdit() 119 | self.stop_shortcut.setText(self.settings.get('stop_shortcut', 'ctrl+alt+s')) 120 | 121 | shortcuts_layout.addRow("Start Recording:", self.start_shortcut) 122 | shortcuts_layout.addRow("Stop Recording:", self.stop_shortcut) 123 | 124 | apply_btn = QPushButton("Apply Shortcuts") 125 | apply_btn.clicked.connect(self.apply_shortcuts) 126 | shortcuts_layout.addRow(apply_btn) 127 | 128 | shortcuts_group.setLayout(shortcuts_layout) 129 | layout.addWidget(shortcuts_group) 130 | 131 | # Add stretch to keep widgets at the top 132 | layout.addStretch() 133 | 134 | # Set a reasonable size 135 | self.setMinimumWidth(300) 136 | 137 | # Initialize whisper model 138 | self.whisper_model = None 139 | self.current_model = None 140 | 141 | def on_language_changed(self, index): 142 | language_code = self.lang_combo.currentData() 143 | try: 144 | self.settings.set('language', language_code) 145 | except ValueError as e: 146 | logger.error(f"Failed to set language: {e}") 147 | QMessageBox.warning(self, "Error", str(e)) 148 | 149 | def on_device_changed(self, index): 150 | try: 151 | self.settings.set('mic_index', index) 152 | except ValueError as e: 153 | logger.error(f"Failed to set microphone: {e}") 154 | QMessageBox.warning(self, "Error", str(e)) 155 | 156 | def on_model_changed(self, model_name): 157 | if model_name == self.current_model: 158 | return 159 | 160 | try: 161 | self.settings.set('model', model_name) 162 | except ValueError as e: 163 | logger.error(f"Failed to set model: {e}") 164 | QMessageBox.warning(self, "Error", str(e)) 165 | return 166 | 167 | self.progress_label.setText(f"Loading {model_name} model...") 168 | self.progress_bar.setRange(0, 0) # Indeterminate progress 169 | 170 | # Load model in a separate thread to prevent UI freezing 171 | QTimer.singleShot(100, lambda: self.load_model(model_name)) 172 | 173 | def load_model(self, model_name): 174 | try: 175 | import whisper 176 | self.whisper_model = whisper.load_model(model_name) 177 | self.current_model = model_name 178 | self.progress_label.setText(f"Model {model_name} loaded successfully") 179 | self.progress_bar.setRange(0, 100) 180 | self.progress_bar.setValue(100) 181 | self.initialization_complete.emit() 182 | except Exception as e: 183 | logger.exception("Failed to load whisper model") 184 | self.progress_label.setText(f"Failed to load model: {str(e)}") 185 | self.progress_bar.setRange(0, 100) 186 | self.progress_bar.setValue(0) 187 | 188 | def apply_shortcuts(self): 189 | try: 190 | start_key = self.start_shortcut.text() 191 | stop_key = self.stop_shortcut.text() 192 | 193 | if not start_key or not stop_key: 194 | QMessageBox.warning(self, "Invalid Shortcuts", 195 | "Please set both start and stop shortcuts.") 196 | return 197 | 198 | if start_key == stop_key: 199 | QMessageBox.warning(self, "Invalid Shortcuts", 200 | "Start and stop shortcuts must be different.") 201 | return 202 | 203 | # Save shortcuts to settings 204 | self.settings.set('start_shortcut', start_key) 205 | self.settings.set('stop_shortcut', stop_key) 206 | 207 | self.shortcuts_changed.emit(start_key, stop_key) 208 | 209 | except Exception as e: 210 | logger.error(f"Error applying shortcuts: {e}") 211 | QMessageBox.critical(self, "Error", 212 | "Failed to apply shortcuts. Please try different combinations.") -------------------------------------------------------------------------------- /recorder.py: -------------------------------------------------------------------------------- 1 | import pyaudio 2 | import wave 3 | from PyQt6.QtCore import QObject, pyqtSignal 4 | import tempfile 5 | import os 6 | import logging 7 | import numpy as np 8 | from settings import Settings 9 | from scipy import signal 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | class AudioRecorder(QObject): 14 | recording_finished = pyqtSignal(str) # Emits path to recorded file 15 | recording_error = pyqtSignal(str) 16 | volume_updated = pyqtSignal(float) 17 | 18 | def __init__(self): 19 | super().__init__() 20 | self.audio = pyaudio.PyAudio() 21 | self.stream = None 22 | self.frames = [] 23 | self.is_recording = False 24 | self.is_testing = False 25 | self.test_stream = None 26 | self.current_device_info = None 27 | # Keep a reference to self to prevent premature deletion 28 | self._instance = self 29 | 30 | def start_recording(self): 31 | if self.is_recording: 32 | return 33 | 34 | try: 35 | self.frames = [] 36 | self.is_recording = True 37 | 38 | # Get selected mic index from settings 39 | settings = Settings() 40 | mic_index = settings.get('mic_index') 41 | 42 | try: 43 | mic_index = int(mic_index) if mic_index is not None else None 44 | except (ValueError, TypeError): 45 | mic_index = None 46 | 47 | if mic_index is not None: 48 | device_info = self.audio.get_device_info_by_index(mic_index) 49 | logger.info(f"Using selected input device: {device_info['name']}") 50 | else: 51 | device_info = self.audio.get_default_input_device_info() 52 | logger.info(f"Using default input device: {device_info['name']}") 53 | mic_index = device_info['index'] 54 | 55 | # Store device info for later use 56 | self.current_device_info = device_info 57 | 58 | # Get supported sample rate from device 59 | sample_rate = int(device_info['defaultSampleRate']) 60 | logger.info(f"Using sample rate: {sample_rate}") 61 | 62 | self.stream = self.audio.open( 63 | format=pyaudio.paInt16, 64 | channels=1, 65 | rate=sample_rate, 66 | input=True, 67 | input_device_index=mic_index, 68 | frames_per_buffer=1024, 69 | stream_callback=self._callback 70 | ) 71 | 72 | self.stream.start_stream() 73 | logger.info("Recording started") 74 | 75 | except Exception as e: 76 | logger.error(f"Failed to start recording: {e}") 77 | self.recording_error.emit(f"Failed to start recording: {e}") 78 | self.is_recording = False 79 | 80 | def _callback(self, in_data, frame_count, time_info, status): 81 | if status: 82 | logger.warning(f"Recording status: {status}") 83 | try: 84 | if self.is_recording: 85 | self.frames.append(in_data) 86 | # Calculate and emit volume level 87 | try: 88 | audio_data = np.frombuffer(in_data, dtype=np.int16) 89 | if len(audio_data) > 0: 90 | # Calculate RMS with protection against zero/negative values 91 | squared = np.abs(audio_data)**2 92 | mean_squared = np.mean(squared) if np.any(squared) else 0 93 | rms = np.sqrt(mean_squared) if mean_squared > 0 else 0 94 | # Normalize to 0-1 range 95 | volume = min(1.0, max(0.0, rms / 32768.0)) 96 | else: 97 | volume = 0.0 98 | self.volume_updated.emit(volume) 99 | except Exception as e: 100 | logger.warning(f"Error calculating volume: {e}") 101 | self.volume_updated.emit(0.0) 102 | return (in_data, pyaudio.paContinue) 103 | except RuntimeError: 104 | # Handle case where object is being deleted 105 | logger.warning("AudioRecorder object is being cleaned up") 106 | return (in_data, pyaudio.paComplete) 107 | return (in_data, pyaudio.paComplete) 108 | 109 | def stop_recording(self): 110 | if not self.is_recording: 111 | return 112 | 113 | logger.info("Stopping recording") 114 | self.is_recording = False 115 | 116 | try: 117 | # Stop and close the stream first 118 | if self.stream: 119 | self.stream.stop_stream() 120 | self.stream.close() 121 | self.stream = None 122 | 123 | # Check if we have any recorded frames 124 | if not self.frames: 125 | logger.error("No audio data recorded") 126 | self.recording_error.emit("No audio was recorded") 127 | return 128 | 129 | # Process the recording 130 | self._process_recording() 131 | 132 | except Exception as e: 133 | logger.error(f"Error stopping recording: {e}") 134 | self.recording_error.emit(f"Error stopping recording: {e}") 135 | 136 | def _process_recording(self): 137 | """Process and save the recording""" 138 | try: 139 | temp_file = tempfile.mktemp(suffix='.wav') 140 | logger.info("Processing recording...") 141 | self.save_audio(temp_file) 142 | logger.info(f"Recording processed and saved to: {os.path.abspath(temp_file)}") 143 | self.recording_finished.emit(temp_file) 144 | except Exception as e: 145 | logger.error(f"Failed to process recording: {e}") 146 | self.recording_error.emit(f"Failed to process recording: {e}") 147 | 148 | def save_audio(self, filename): 149 | """Save recorded audio to a WAV file""" 150 | try: 151 | # Convert frames to numpy array 152 | audio_data = np.frombuffer(b''.join(self.frames), dtype=np.int16) 153 | 154 | if self.current_device_info is None: 155 | raise ValueError("No device info available") 156 | 157 | # Get original sample rate from stored device info 158 | original_rate = int(self.current_device_info['defaultSampleRate']) 159 | 160 | # Resample to 16000Hz if needed 161 | if original_rate != 16000: 162 | # Calculate resampling ratio 163 | ratio = 16000 / original_rate 164 | output_length = int(len(audio_data) * ratio) 165 | 166 | # Resample audio 167 | audio_data = signal.resample(audio_data, output_length) 168 | 169 | # Save to WAV file 170 | wf = wave.open(filename, 'wb') 171 | wf.setnchannels(1) 172 | wf.setsampwidth(self.audio.get_sample_size(pyaudio.paInt16)) 173 | wf.setframerate(16000) # Always save at 16000Hz for Whisper 174 | wf.writeframes(audio_data.astype(np.int16).tobytes()) 175 | wf.close() 176 | 177 | # Log the saved file location 178 | logger.info(f"Recording saved to: {os.path.abspath(filename)}") 179 | 180 | except Exception as e: 181 | logger.error(f"Failed to save audio file: {e}") 182 | raise 183 | 184 | def start_mic_test(self, device_index): 185 | """Start microphone test""" 186 | if self.is_testing or self.is_recording: 187 | return 188 | 189 | try: 190 | self.test_stream = self.audio.open( 191 | format=pyaudio.paFloat32, 192 | channels=1, 193 | rate=44100, 194 | input=True, 195 | input_device_index=device_index, 196 | frames_per_buffer=1024, 197 | stream_callback=self._test_callback 198 | ) 199 | 200 | self.test_stream.start_stream() 201 | self.is_testing = True 202 | logger.info(f"Started mic test on device {device_index}") 203 | 204 | except Exception as e: 205 | logger.error(f"Failed to start mic test: {e}") 206 | raise 207 | 208 | def stop_mic_test(self): 209 | """Stop microphone test""" 210 | if self.test_stream: 211 | self.test_stream.stop_stream() 212 | self.test_stream.close() 213 | self.test_stream = None 214 | self.is_testing = False 215 | 216 | def _test_callback(self, in_data, frame_count, time_info, status): 217 | """Callback for mic test""" 218 | if status: 219 | logger.warning(f"Test callback status: {status}") 220 | return (in_data, pyaudio.paContinue) 221 | 222 | def get_current_audio_level(self): 223 | """Get current audio level for meter""" 224 | if not self.test_stream or not self.is_testing: 225 | return 0 226 | 227 | try: 228 | data = self.test_stream.read(1024, exception_on_overflow=False) 229 | audio_data = np.frombuffer(data, dtype=np.float32) 230 | return np.sqrt(np.mean(np.square(audio_data))) 231 | except Exception as e: 232 | logger.error(f"Error getting audio level: {e}") 233 | return 0 234 | 235 | def cleanup(self): 236 | """Cleanup resources""" 237 | if self.stream: 238 | self.stream.stop_stream() 239 | self.stream.close() 240 | self.stream = None 241 | if self.test_stream: 242 | self.test_stream.stop_stream() 243 | self.test_stream.close() 244 | self.test_stream = None 245 | if self.audio: 246 | self.audio.terminate() 247 | self.audio = None 248 | self._instance = None -------------------------------------------------------------------------------- /window.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, 2 | QPushButton, QComboBox, QLabel, QDialog, 3 | QProgressBar, QMessageBox, QFrame, QStackedWidget) 4 | from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QSize 5 | from PyQt6.QtGui import QKeySequence, QIcon 6 | from settings import Settings 7 | from volume_meter import VolumeMeter 8 | from mic_test import MicTestDialog 9 | from recorder import AudioRecorder 10 | from transcriber import WhisperTranscriber 11 | import numpy as np 12 | import logging 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | class ModernFrame(QFrame): 17 | """A styled frame for grouping controls""" 18 | def __init__(self, title, parent=None): 19 | super().__init__(parent) 20 | self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Raised) 21 | 22 | layout = QVBoxLayout(self) 23 | 24 | # Add title 25 | title_label = QLabel(title) 26 | title_label.setStyleSheet("font-weight: bold; color: #1d99f3;") 27 | layout.addWidget(title_label) 28 | 29 | # Content widget 30 | self.content = QWidget() 31 | self.content_layout = QVBoxLayout(self.content) 32 | self.content_layout.setContentsMargins(0, 0, 0, 0) 33 | layout.addWidget(self.content) 34 | 35 | class RecordingDialog(QDialog): 36 | def __init__(self, parent=None): 37 | super().__init__(parent) 38 | self.setWindowTitle("Recording...") 39 | self.setFixedSize(300, 150) 40 | 41 | layout = QVBoxLayout(self) 42 | 43 | # Status label 44 | self.label = QLabel("Recording in progress...") 45 | self.label.setAlignment(Qt.AlignmentFlag.AlignCenter) 46 | self.label.setStyleSheet("font-weight: bold;") 47 | 48 | # Progress bar 49 | self.progress = QProgressBar() 50 | self.progress.setRange(0, 0) # Infinite progress animation 51 | 52 | # Volume meter 53 | self.volume_meter = VolumeMeter() 54 | 55 | # Status icon (using system theme icons) 56 | self.status_icon = QLabel() 57 | self.status_icon.setAlignment(Qt.AlignmentFlag.AlignCenter) 58 | self.set_recording_status() 59 | 60 | # Layout 61 | layout.addWidget(self.status_icon) 62 | layout.addWidget(self.label) 63 | layout.addWidget(self.progress) 64 | layout.addWidget(self.volume_meter) 65 | 66 | # Stop button 67 | self.stop_btn = QPushButton(QIcon.fromTheme('media-playback-stop'), "Stop Recording") 68 | layout.addWidget(self.stop_btn) 69 | 70 | # Timer for updating volume meter 71 | self.update_timer = QTimer() 72 | self.update_timer.setInterval(50) # 20fps 73 | self.update_timer.timeout.connect(self.update_volume) 74 | self.update_timer.start() 75 | 76 | def set_recording_status(self): 77 | """Show recording status""" 78 | self.status_icon.setPixmap(QIcon.fromTheme('media-record').pixmap(32, 32)) 79 | self.label.setStyleSheet("font-weight: bold; color: #da4453;") # Red color for recording 80 | 81 | def set_processing_status(self): 82 | """Show processing status""" 83 | self.status_icon.setPixmap(QIcon.fromTheme('view-refresh').pixmap(32, 32)) 84 | self.label.setStyleSheet("font-weight: bold; color: #1d99f3;") # Blue color for processing 85 | 86 | def set_message(self, message): 87 | self.label.setText(message) 88 | 89 | def set_transcribing(self): 90 | self.set_message("Processing audio... Please wait") 91 | self.set_processing_status() 92 | self.stop_btn.setEnabled(False) 93 | self.update_timer.stop() 94 | self.volume_meter.set_value(0) 95 | 96 | def update_volume(self, value=None): 97 | if hasattr(self.parent(), 'recorder'): 98 | if value is None and self.parent().recorder.frames: 99 | # Calculate RMS of the last frame if no value provided 100 | last_frame = np.frombuffer(self.parent().recorder.frames[-1], dtype=np.int16) 101 | value = np.sqrt(np.mean(np.square(last_frame))) / 32768.0 102 | if value is not None: 103 | self.volume_meter.set_value(value) 104 | 105 | class WhisperWindow(QMainWindow): 106 | start_recording = pyqtSignal() 107 | stop_recording = pyqtSignal() 108 | output_method_changed = pyqtSignal(str) 109 | initialization_complete = pyqtSignal() 110 | 111 | def __init__(self): 112 | super().__init__() 113 | self.settings = Settings() 114 | self.recording_dialog = None 115 | self.transcriber = None 116 | self.recorder = None 117 | 118 | # Don't initialize UI yet 119 | self.central_widget = None 120 | 121 | def initialize(self, loading_window): 122 | """Initialize components with loading feedback""" 123 | try: 124 | loading_window.set_status("Loading settings...") 125 | self.settings = Settings() 126 | 127 | loading_window.set_status("Initializing audio system...") 128 | self.recorder = AudioRecorder() 129 | 130 | loading_window.set_status("Loading Whisper model...") 131 | self.transcriber = WhisperTranscriber() 132 | 133 | loading_window.set_status("Creating user interface...") 134 | self.init_ui() 135 | self.setup_shortcuts() 136 | 137 | loading_window.set_status("Ready!") 138 | self.initialization_complete.emit() 139 | 140 | except Exception as e: 141 | logger.error(f"Initialization failed: {e}") 142 | QMessageBox.critical(self, "Error", 143 | f"Failed to initialize application: {str(e)}") 144 | self.initialization_complete.emit() # Emit anyway to close loading window 145 | 146 | def init_ui(self): 147 | self.setWindowTitle('Telly Spelly') 148 | self.setWindowIcon(QIcon.fromTheme('audio-input-microphone')) 149 | self.setMinimumWidth(500) 150 | 151 | central_widget = QWidget() 152 | self.setCentralWidget(central_widget) 153 | main_layout = QVBoxLayout(central_widget) 154 | 155 | # Model selection frame 156 | model_frame = ModernFrame("Whisper Model") 157 | self.model_combo = QComboBox() 158 | self.model_combo.addItems(['tiny', 'base', 'small', 'medium', 'large', 'turbo']) 159 | self.model_combo.setCurrentText(self.settings.get('model', 'turbo')) 160 | model_frame.content_layout.addWidget(self.model_combo) 161 | main_layout.addWidget(model_frame) 162 | 163 | # Microphone frame 164 | mic_frame = ModernFrame("Microphone") 165 | mic_layout = QHBoxLayout() 166 | 167 | # Volume meter 168 | self.volume_meter = VolumeMeter() 169 | self.volume_meter.setMinimumHeight(30) 170 | 171 | # Mic selection 172 | self.mic_combo = QComboBox() 173 | self.populate_mic_list() 174 | 175 | # Test button 176 | self.test_button = QPushButton(QIcon.fromTheme('audio-volume-high'), "Test") 177 | self.test_button.setCheckable(True) 178 | self.test_button.clicked.connect(self.toggle_mic_test) 179 | 180 | mic_layout.addWidget(self.mic_combo, 1) 181 | mic_layout.addWidget(self.test_button) 182 | 183 | mic_frame.content_layout.addLayout(mic_layout) 184 | mic_frame.content_layout.addWidget(self.volume_meter) 185 | 186 | # Level indicator 187 | self.level_label = QLabel("Level: -∞ dB") 188 | self.level_label.setAlignment(Qt.AlignmentFlag.AlignRight) 189 | mic_frame.content_layout.addWidget(self.level_label) 190 | 191 | main_layout.addWidget(mic_frame) 192 | 193 | # Output frame 194 | output_frame = ModernFrame("Output") 195 | self.output_combo = QComboBox() 196 | self.output_combo.addItems(['Clipboard', 'Active Window']) 197 | self.output_combo.setCurrentText(self.settings.get('output', 'Clipboard')) 198 | output_frame.content_layout.addWidget(self.output_combo) 199 | main_layout.addWidget(output_frame) 200 | 201 | # Record button 202 | record_layout = QHBoxLayout() 203 | self.record_btn = QPushButton(QIcon.fromTheme('media-record'), 'Start Recording') 204 | self.record_btn.setIconSize(QSize(32, 32)) 205 | self.record_btn.setStyleSheet(""" 206 | QPushButton { 207 | padding: 10px; 208 | background-color: #1d99f3; 209 | color: white; 210 | border-radius: 5px; 211 | } 212 | QPushButton:hover { 213 | background-color: #2eaaff; 214 | } 215 | QPushButton:pressed { 216 | background-color: #1a87d7; 217 | } 218 | """) 219 | shortcut_label = QLabel("(Ctrl+Alt+R)") 220 | shortcut_label.setStyleSheet("color: gray;") 221 | 222 | record_layout.addWidget(self.record_btn) 223 | record_layout.addWidget(shortcut_label) 224 | record_layout.addStretch() 225 | 226 | main_layout.addLayout(record_layout) 227 | main_layout.addStretch() 228 | 229 | # Timer for updating volume meter 230 | self.update_timer = QTimer() 231 | self.update_timer.setInterval(50) 232 | self.update_timer.timeout.connect(self.update_volume) 233 | 234 | # Connect signals 235 | self.record_btn.clicked.connect(self.toggle_recording) 236 | self.output_combo.currentTextChanged.connect(self.on_output_method_changed) 237 | 238 | def populate_mic_list(self): 239 | self.mic_combo.clear() 240 | if not self.recorder: 241 | return 242 | 243 | for i in range(self.recorder.audio.get_device_count()): 244 | device_info = self.recorder.audio.get_device_info_by_index(i) 245 | if device_info.get('maxInputChannels') > 0: 246 | name = device_info.get('name') 247 | self.mic_combo.addItem(name, i) # Store index directly as integer 248 | 249 | # Select previously used mic 250 | try: 251 | mic_index = int(self.settings.get('mic_index', -1)) 252 | if mic_index >= 0: 253 | for i in range(self.mic_combo.count()): 254 | if self.mic_combo.itemData(i) == mic_index: 255 | self.mic_combo.setCurrentIndex(i) 256 | break 257 | except (ValueError, TypeError): 258 | pass 259 | 260 | def setup_shortcuts(self): 261 | self.shortcut = QKeySequence("Ctrl+Alt+R") 262 | # TODO: Add system-wide shortcut registration 263 | 264 | def set_transcriber(self, transcriber): 265 | """Set up transcriber and connect its signals""" 266 | self.transcriber = transcriber 267 | self.transcriber.transcription_progress.connect(self.update_transcription_progress) 268 | self.transcriber.transcription_finished.connect(self.handle_transcription_finished) 269 | self.transcriber.transcription_error.connect(self.handle_transcription_error) 270 | 271 | def toggle_recording(self): 272 | if not self.recording_dialog: 273 | # Start recording 274 | self.start_recording.emit() 275 | self.recording_dialog = RecordingDialog(self) 276 | self.recording_dialog.recorder = self.recorder # Add reference to recorder 277 | self.recording_dialog.stop_btn.clicked.connect(self.stop_current_recording) 278 | self.recording_dialog.show() 279 | 280 | def stop_current_recording(self): 281 | if self.recording_dialog: 282 | self.stop_recording.emit() 283 | self.recording_dialog.set_transcribing() # Show processing status 284 | 285 | def update_transcription_progress(self, message): 286 | if self.recording_dialog: 287 | self.recording_dialog.set_message(message) 288 | 289 | def handle_transcription_finished(self, text): 290 | if self.recording_dialog: 291 | self.recording_dialog.close() 292 | self.recording_dialog = None 293 | 294 | def on_output_method_changed(self, method): 295 | self.settings.set('output_method', method) 296 | self.output_method_changed.emit(method) 297 | 298 | def handle_transcription_error(self, error_message): 299 | QMessageBox.warning(self, "Transcription Error", error_message) 300 | if self.recording_dialog: 301 | self.recording_dialog.close() 302 | self.recording_dialog = None 303 | 304 | def set_recorder(self, recorder): 305 | """Set up recorder instance""" 306 | self.recorder = recorder 307 | 308 | def update_volume(self): 309 | """Update volume meter during mic test""" 310 | if not self.recorder or not self.test_button.isChecked(): 311 | self.volume_meter.set_value(0) 312 | self.level_label.setText("Level: -∞ dB") 313 | return 314 | 315 | try: 316 | data = self.recorder.get_current_audio_level() 317 | if data > 0: 318 | db = 20 * np.log10(data) 319 | self.level_label.setText(f"Level: {db:.1f} dB") 320 | else: 321 | self.level_label.setText("Level: -∞ dB") 322 | self.volume_meter.set_value(data) 323 | except Exception as e: 324 | logger.error(f"Error updating volume: {e}") 325 | 326 | def toggle_mic_test(self): 327 | """Toggle microphone testing""" 328 | if self.test_button.isChecked(): 329 | # Start testing 330 | self.start_mic_test() 331 | else: 332 | # Stop testing 333 | self.stop_mic_test() 334 | 335 | def start_mic_test(self): 336 | """Start microphone test""" 337 | if not self.recorder: 338 | return 339 | 340 | try: 341 | device_index = self.mic_combo.currentData() 342 | if device_index is not None: 343 | self.recorder.start_mic_test(device_index) 344 | self.update_timer.start() 345 | self.mic_combo.setEnabled(False) 346 | # Save the selected mic index 347 | self.settings.set('mic_index', device_index) 348 | except Exception as e: 349 | logger.error(f"Failed to start mic test: {e}") 350 | self.test_button.setChecked(False) 351 | QMessageBox.warning(self, "Error", f"Failed to start microphone test: {str(e)}") 352 | 353 | def stop_mic_test(self): 354 | """Stop microphone test""" 355 | if self.recorder: 356 | self.recorder.stop_mic_test() 357 | self.update_timer.stop() 358 | self.volume_meter.set_value(0) 359 | self.level_label.setText("Level: -∞ dB") 360 | self.mic_combo.setEnabled(True) -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from PyQt6.QtWidgets import (QApplication, QMessageBox, QSystemTrayIcon, QMenu) 3 | from PyQt6.QtCore import Qt, QTimer, QCoreApplication 4 | from PyQt6.QtGui import QIcon, QAction 5 | import logging 6 | from settings_window import SettingsWindow 7 | from progress_window import ProgressWindow 8 | from processing_window import ProcessingWindow 9 | from recorder import AudioRecorder 10 | from transcriber import WhisperTranscriber 11 | from loading_window import LoadingWindow 12 | from PyQt6.QtCore import pyqtSignal 13 | import warnings 14 | import ctypes 15 | import os 16 | from shortcuts import GlobalShortcuts 17 | from settings import Settings 18 | # from mic_debug import MicDebugWindow 19 | 20 | # Setup logging 21 | logging.basicConfig(level=logging.INFO) 22 | logger = logging.getLogger(__name__) 23 | 24 | # Suppress ALSA error messages 25 | try: 26 | # Load ALSA error handler 27 | ERROR_HANDLER_FUNC = ctypes.CFUNCTYPE(None, ctypes.c_char_p, ctypes.c_int, 28 | ctypes.c_char_p, ctypes.c_int, 29 | ctypes.c_char_p) 30 | 31 | def py_error_handler(filename, line, function, err, fmt): 32 | pass 33 | 34 | c_error_handler = ERROR_HANDLER_FUNC(py_error_handler) 35 | 36 | # Set error handler 37 | asound = ctypes.cdll.LoadLibrary('libasound.so.2') 38 | asound.snd_lib_error_set_handler(c_error_handler) 39 | except: 40 | warnings.warn("Failed to suppress ALSA warnings", RuntimeWarning) 41 | 42 | def check_dependencies(): 43 | required_packages = ['whisper', 'pyaudio', 'keyboard'] 44 | missing_packages = [] 45 | 46 | for package in required_packages: 47 | try: 48 | __import__(package) 49 | except ImportError: 50 | missing_packages.append(package) 51 | logger.error(f"Failed to import required dependency: {package}") 52 | 53 | if missing_packages: 54 | error_msg = ( 55 | "Missing required dependencies:\n" 56 | f"{', '.join(missing_packages)}\n\n" 57 | "Please install them using:\n" 58 | f"pip install {' '.join(missing_packages)}" 59 | ) 60 | QMessageBox.critical(None, "Missing Dependencies", error_msg) 61 | return False 62 | 63 | return True 64 | 65 | class TrayRecorder(QSystemTrayIcon): 66 | initialization_complete = pyqtSignal() 67 | 68 | def __init__(self): 69 | super().__init__() 70 | 71 | # Initialize basic state 72 | self.recording = False 73 | self.settings_window = None 74 | self.progress_window = None 75 | self.processing_window = None 76 | self.recorder = None 77 | self.transcriber = None 78 | 79 | # Create debug window but don't show it 80 | # self.debug_window = MicDebugWindow() 81 | 82 | # Set tooltip 83 | self.setToolTip("Telly Spelly") 84 | 85 | # Enable activation by left click 86 | self.activated.connect(self.on_activate) 87 | 88 | # Add shortcuts handler 89 | self.shortcuts = GlobalShortcuts() 90 | self.shortcuts.start_recording_triggered.connect(self.start_recording) 91 | self.shortcuts.stop_recording_triggered.connect(self.stop_recording) 92 | 93 | def initialize(self): 94 | """Initialize the tray recorder after showing loading window""" 95 | # Set application icon 96 | self.app_icon = QIcon.fromTheme("telly-spelly") 97 | if self.app_icon.isNull(): 98 | # Fallback to theme icons if custom icon not found 99 | self.app_icon = QIcon.fromTheme("media-record") 100 | logger.warning("Could not load telly-spelly icon, using system theme icon") 101 | 102 | # Set the icon for both app and tray 103 | QApplication.instance().setWindowIcon(self.app_icon) 104 | self.setIcon(self.app_icon) 105 | 106 | # Use app icon for normal state and theme icon for recording 107 | self.normal_icon = self.app_icon 108 | self.recording_icon = QIcon.fromTheme("media-playback-stop") 109 | 110 | # Create menu 111 | self.setup_menu() 112 | 113 | # Setup global shortcuts 114 | if not self.shortcuts.setup_shortcuts(): 115 | logger.warning("Failed to register global shortcuts") 116 | 117 | def setup_menu(self): 118 | menu = QMenu() 119 | 120 | # Add recording action 121 | self.record_action = QAction("Start Recording", menu) 122 | self.record_action.triggered.connect(self.toggle_recording) 123 | menu.addAction(self.record_action) 124 | 125 | # Add settings action 126 | self.settings_action = QAction("Settings", menu) 127 | self.settings_action.triggered.connect(self.toggle_settings) 128 | menu.addAction(self.settings_action) 129 | 130 | # Add debug window action 131 | # self.debug_action = QAction("Show Debug Window", menu) 132 | # self.debug_action.triggered.connect(self.toggle_debug_window) 133 | # menu.addAction(self.debug_action) 134 | 135 | # Add separator before quit 136 | menu.addSeparator() 137 | 138 | # Add quit action 139 | quit_action = QAction("Quit", menu) 140 | quit_action.triggered.connect(self.quit_application) 141 | menu.addAction(quit_action) 142 | 143 | # Set the context menu 144 | self.setContextMenu(menu) 145 | 146 | @staticmethod 147 | def isSystemTrayAvailable(): 148 | return QSystemTrayIcon.isSystemTrayAvailable() 149 | 150 | def toggle_recording(self): 151 | if self.recording: 152 | # Stop recording 153 | self.recording = False 154 | self.record_action.setText("Start Recording") 155 | self.setIcon(self.normal_icon) 156 | 157 | # Update progress window before stopping recording 158 | if self.progress_window: 159 | self.progress_window.set_processing_mode() 160 | self.progress_window.set_status("Processing audio...") 161 | 162 | # Stop the actual recording 163 | if self.recorder: 164 | try: 165 | self.recorder.stop_recording() 166 | except Exception as e: 167 | logger.error(f"Error stopping recording: {e}") 168 | if self.progress_window: 169 | self.progress_window.close() 170 | self.progress_window = None 171 | return 172 | else: 173 | # Start recording 174 | self.recording = True 175 | # Show progress window 176 | if not self.progress_window: 177 | self.progress_window = ProgressWindow("Voice Recording") 178 | self.progress_window.stop_clicked.connect(self.stop_recording) 179 | self.progress_window.show() 180 | 181 | # Start recording 182 | self.record_action.setText("Stop Recording") 183 | self.setIcon(self.recording_icon) 184 | self.recorder.start_recording() 185 | 186 | def stop_recording(self): 187 | """Handle stopping the recording and starting processing""" 188 | if not self.recording: 189 | return 190 | 191 | logger.info("TrayRecorder: Stopping recording") 192 | self.toggle_recording() # This is now safe since toggle_recording handles everything 193 | 194 | def toggle_settings(self): 195 | if not self.settings_window: 196 | self.settings_window = SettingsWindow() 197 | self.settings_window.shortcuts_changed.connect(self.update_shortcuts) 198 | 199 | if self.settings_window.isVisible(): 200 | self.settings_window.hide() 201 | else: 202 | self.settings_window.show() 203 | 204 | def update_shortcuts(self, start_key, stop_key): 205 | """Update global shortcuts""" 206 | if self.shortcuts.setup_shortcuts(start_key, stop_key): 207 | self.showMessage("Shortcuts Updated", 208 | f"Start: {start_key}\nStop: {stop_key}", 209 | self.normal_icon) 210 | 211 | def on_activate(self, reason): 212 | if reason == QSystemTrayIcon.ActivationReason.Trigger: # Left click 213 | self.toggle_recording() 214 | 215 | def quit_application(self): 216 | # Cleanup recorder 217 | if self.recorder: 218 | self.recorder.cleanup() 219 | self.recorder = None 220 | 221 | # Close all windows 222 | if self.settings_window and self.settings_window.isVisible(): 223 | self.settings_window.close() 224 | 225 | if self.progress_window and self.progress_window.isVisible(): 226 | self.progress_window.close() 227 | 228 | # Stop recording if active 229 | if self.recording: 230 | self.stop_recording() 231 | 232 | # Quit the application 233 | QApplication.quit() 234 | 235 | def update_volume_meter(self, value): 236 | # Update debug window first 237 | if hasattr(self, 'debug_window'): 238 | self.debug_window.update_values(value) 239 | 240 | # Then update volume meter as before 241 | if self.progress_window and self.recording: 242 | self.progress_window.update_volume(value) 243 | 244 | def handle_recording_finished(self, audio_file): 245 | """Called when recording is saved to file""" 246 | logger.info("TrayRecorder: Recording finished, starting transcription") 247 | 248 | # Ensure progress window is in processing mode 249 | if self.progress_window: 250 | self.progress_window.set_processing_mode() 251 | self.progress_window.set_status("Starting transcription...") 252 | 253 | if self.transcriber: 254 | self.transcriber.transcribe_file(audio_file) 255 | else: 256 | logger.error("Transcriber not initialized") 257 | if self.progress_window: 258 | self.progress_window.close() 259 | self.progress_window = None 260 | QMessageBox.critical(None, "Error", "Transcriber not initialized") 261 | 262 | def handle_recording_error(self, error): 263 | """Handle recording errors""" 264 | logger.error(f"TrayRecorder: Recording error: {error}") 265 | QMessageBox.critical(None, "Recording Error", error) 266 | self.stop_recording() 267 | if self.progress_window: 268 | self.progress_window.close() 269 | self.progress_window = None 270 | 271 | def update_processing_status(self, status): 272 | if self.progress_window: 273 | self.progress_window.set_status(status) 274 | 275 | def handle_transcription_finished(self, text): 276 | if text: 277 | # Copy text to clipboard 278 | QApplication.clipboard().setText(text) 279 | self.showMessage("Transcription Complete", 280 | "Text has been copied to clipboard", 281 | self.normal_icon) 282 | 283 | # Close the progress window 284 | if self.progress_window: 285 | self.progress_window.close() 286 | self.progress_window = None 287 | 288 | def handle_transcription_error(self, error): 289 | QMessageBox.critical(None, "Transcription Error", error) 290 | if self.progress_window: 291 | self.progress_window.close() 292 | self.progress_window = None 293 | 294 | def start_recording(self): 295 | """Start a new recording""" 296 | if not self.recording: 297 | self.toggle_recording() 298 | 299 | def stop_recording(self): 300 | """Stop current recording""" 301 | if self.recording: 302 | self.toggle_recording() 303 | 304 | def toggle_debug_window(self): 305 | """Toggle debug window visibility""" 306 | if self.debug_window.isVisible(): 307 | self.debug_window.hide() 308 | self.debug_action.setText("Show Debug Window") 309 | else: 310 | self.debug_window.show() 311 | self.debug_action.setText("Hide Debug Window") 312 | 313 | def setup_application_metadata(): 314 | QCoreApplication.setApplicationName("Telly Spelly") 315 | QCoreApplication.setApplicationVersion("1.0") 316 | QCoreApplication.setOrganizationName("KDE") 317 | QCoreApplication.setOrganizationDomain("kde.org") 318 | 319 | def main(): 320 | try: 321 | app = QApplication(sys.argv) 322 | setup_application_metadata() 323 | 324 | # Show loading window first 325 | loading_window = LoadingWindow() 326 | loading_window.show() 327 | app.processEvents() # Force update of UI 328 | loading_window.set_status("Checking system requirements...") 329 | app.processEvents() # Force update of UI 330 | 331 | # Check if system tray is available 332 | if not TrayRecorder.isSystemTrayAvailable(): 333 | QMessageBox.critical(None, "Error", 334 | "System tray is not available. Please ensure your desktop environment supports system tray icons.") 335 | return 1 336 | 337 | # Create tray icon but don't initialize yet 338 | tray = TrayRecorder() 339 | 340 | # Connect loading window to tray initialization 341 | tray.initialization_complete.connect(loading_window.close) 342 | 343 | # Check dependencies in background 344 | loading_window.set_status("Checking dependencies...") 345 | app.processEvents() # Force update of UI 346 | if not check_dependencies(): 347 | return 1 348 | 349 | # Ensure the application doesn't quit when last window is closed 350 | app.setQuitOnLastWindowClosed(False) 351 | 352 | # Initialize tray in background 353 | QTimer.singleShot(100, lambda: initialize_tray(tray, loading_window, app)) 354 | 355 | return app.exec() 356 | 357 | except Exception as e: 358 | logger.exception("Failed to start application") 359 | QMessageBox.critical(None, "Error", 360 | f"Failed to start application: {str(e)}") 361 | return 1 362 | 363 | def initialize_tray(tray, loading_window, app): 364 | try: 365 | # Initialize basic tray setup 366 | loading_window.set_status("Initializing application...") 367 | app.processEvents() 368 | tray.initialize() 369 | 370 | # Initialize recorder 371 | loading_window.set_status("Initializing audio system...") 372 | app.processEvents() 373 | tray.recorder = AudioRecorder() 374 | 375 | # Initialize transcriber 376 | loading_window.set_status("Loading Whisper model...") 377 | app.processEvents() 378 | tray.transcriber = WhisperTranscriber() 379 | 380 | # Connect signals 381 | loading_window.set_status("Setting up signal handlers...") 382 | app.processEvents() 383 | tray.recorder.volume_updated.connect(tray.update_volume_meter) 384 | tray.recorder.recording_finished.connect(tray.handle_recording_finished) 385 | tray.recorder.recording_error.connect(tray.handle_recording_error) 386 | 387 | tray.transcriber.transcription_progress.connect(tray.update_processing_status) 388 | tray.transcriber.transcription_finished.connect(tray.handle_transcription_finished) 389 | tray.transcriber.transcription_error.connect(tray.handle_transcription_error) 390 | 391 | # Make tray visible 392 | loading_window.set_status("Starting application...") 393 | app.processEvents() 394 | tray.setVisible(True) 395 | 396 | # Signal completion 397 | tray.initialization_complete.emit() 398 | 399 | except Exception as e: 400 | logger.error(f"Initialization failed: {e}") 401 | QMessageBox.critical(None, "Error", f"Failed to initialize application: {str(e)}") 402 | loading_window.close() 403 | 404 | if __name__ == "__main__": 405 | sys.exit(main()) --------------------------------------------------------------------------------