diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1156014..22248c8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,9 +18,9 @@ jobs: - name: Install test tools run: python -m pip install --upgrade pytest ruff - name: Compile source - run: python -m compileall -q voice2text voice2text_ai.py qt_app.py + run: python -m compileall -q voice2text voice2text_ai.py - name: Lint - run: ruff check voice2text tests voice2text_ai.py qt_app.py + run: ruff check voice2text tests voice2text_ai.py - name: Unit tests run: python -m pytest -q @@ -28,7 +28,7 @@ jobs: needs: test runs-on: ubuntu-latest container: - image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-50 + image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-48 options: --privileged steps: - uses: actions/checkout@v4 @@ -36,16 +36,7 @@ jobs: shell: bash run: | git clone --depth=1 https://github.com/flatpak/flatpak-builder-tools.git /tmp/flatpak-builder-tools - python3 /tmp/flatpak-builder-tools/pip/flatpak-pip-generator \ - --requirements-file=packaging/flatpak/requirements.txt \ - - --runtime org.gnome.Sdk//50 \ - - --prefer-wheels=ctranslate2,onnxruntime,tokenizers,av,numpy,pyyaml,protobuf,hf-xet \ - - --wheel-arches=x86_64 \ - - --output=python3-requirements-flatpak + python3 /tmp/flatpak-builder-tools/pip/flatpak-pip-generator --requirements-file=packaging/flatpak/requirements.txt --runtime org.gnome.Sdk//48 --prefer-wheels=ctranslate2,onnxruntime,tokenizers,av,numpy,pyyaml,protobuf,aiohttp --wheel-arches=x86_64 --output=python3-requirements-flatpak - uses: flatpak/flatpak-github-actions/flatpak-builder@v6 with: bundle: Voice2Text-AI.flatpak diff --git a/README.md b/README.md index 3082101..c5c553e 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,99 @@ -# Voice2Text AI microphone dropdown readability fix +# Voice2Text AI -This overlay updates the existing `release/v0.4.0-native-linux` branch. +Fast native Linux dictation with local AI. Record from your microphone, have +speech transcribed on-device with Faster Whisper, prompt a local Ollama model, +and hear the answer spoken back with a natural neural voice and an offline +eSpeak NG fallback. -Changes: +Built with GTK 4 and libadwaita, powered by GStreamer, and distributed as a +Flatpak. Works on Wayland and X11 with PipeWire or PulseAudio. -- Widens the Preferences dialog. -- Shows the complete selected microphone name as the row subtitle and tooltip. -- Uses a wider selected-value label with middle ellipsis only when unavoidable. -- Wraps every microphone source name in the dropdown list, so long PipeWire, - ALSA, USB, monitor, and hardware source names can be read in full. -- Does not alter dictation, rendering, TTS, Flatpak dependencies, or settings. +## Features + +- **On-device dictation** — microphone audio is transcribed locally by Faster + Whisper (nothing leaves your machine). +- **Local AI prompts** — send the transcript to any Ollama model you have + pulled, with streaming responses. +- **Natural speech output** — Edge TTS voices stream immediately; if the + network voice is unavailable, eSpeak NG speaks offline automatically. +- **Adaptive segmentation** — speech is split at pauses, so dictation flows + naturally while transcribing in the background. +- **Coordinated appearance** — follows the system light/dark setting, with an + explicit override in Preferences. + +## Install (Flatpak) + +The easiest way is the Flatpak from the [releases](https://github.com/crhy/Voice2Text-AI/releases): + +```bash +flatpak install --user Voice2Text-AI.flatpak +flatpak run io.github.crhy.voice2textai +``` + +For local AI, install [Ollama](https://ollama.com/) and pull a model, for example: + +```bash +ollama pull llama3.1:8b +``` + +The Whisper model downloads on first launch (the `base` model is the default; +smaller models use less memory and start faster). + +## Run from source + +Requires Python 3.11+, GTK 4, libadwaita, and GStreamer with the Python +bindings. Install the Python dependencies and launch: + +```bash +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt +.venv/bin/python voice2text_ai.py +``` + +`voice2text_ai.py` is a thin launcher over the `voice2text` package; the +PyPI entry point is `voice2text-ai`. + +## Keyboard shortcuts + +| Shortcut | Action | +| ------------------- | ----------------------------- | +| `Ctrl+R` | Start or stop dictation | +| `Ctrl+Enter` | Ask AI | +| `Ctrl+Shift+C` | Copy transcript | +| `Ctrl+L` | Clear | +| `Ctrl+,` | Preferences | +| `Ctrl+Q` | Quit | + +## Building the Flatpak + +The GitHub Actions workflow generates pinned Python dependencies, builds the +Flatpak bundle, and attaches it to a draft release for every `v*` tag. To build +locally, generate the pinned module and run flatpak-builder: + +```bash +git clone https://github.com/flatpak/flatpak-builder-tools.git /tmp/flatpak-builder-tools +python3 /tmp/flatpak-builder-tools/pip/flatpak-pip-generator \ + --requirements-file=packaging/flatpak/requirements.txt \ + --runtime org.gnome.Sdk//48 \ + --prefer-wheels=ctranslate2,onnxruntime,tokenizers,av,numpy,pyyaml,protobuf \ + --wheel-arches=x86_64 \ + --output=python3-requirements-flatpak +flatpak-builder --user --install --force-clean build-dir io.github.crhy.voice2textai.yml +``` + +## Configuration + +Settings are stored in `$XDG_CONFIG_HOME/voice2text-ai/config.json` and edited +from the Preferences dialog. + +## Acknowledgements + +- [Faster Whisper](https://github.com/SYSTRAN/faster-whisper) for on-device transcription +- [Ollama](https://ollama.com/) for local language models +- [Edge TTS](https://github.com/rany2/edge-tts) for natural voices +- [eSpeak NG](https://github.com/espeak-ng/espeak-ng) for offline speech +- GTK 4, libadwaita, and GStreamer + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/config.example.json b/config.example.json deleted file mode 100644 index 11e9616..0000000 --- a/config.example.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "input_device": "", - "output_device": "", - "model": "llama3.2", - "recognizer": "faster-whisper" -} diff --git a/docs/screenshots/AIresponse.png b/docs/screenshots/AIresponse.png deleted file mode 100644 index 4465467..0000000 Binary files a/docs/screenshots/AIresponse.png and /dev/null differ diff --git a/docs/screenshots/mainwindow.png b/docs/screenshots/mainwindow.png deleted file mode 100644 index 21a03c6..0000000 Binary files a/docs/screenshots/mainwindow.png and /dev/null differ diff --git a/docs/screenshots/typicalquery.png b/docs/screenshots/typicalquery.png deleted file mode 100644 index a9569be..0000000 Binary files a/docs/screenshots/typicalquery.png and /dev/null differ diff --git a/io.github.crhy.voice2textai.metainfo.xml b/io.github.crhy.voice2textai.metainfo.xml index 12dfd30..dc1a146 100644 --- a/io.github.crhy.voice2textai.metainfo.xml +++ b/io.github.crhy.voice2textai.metainfo.xml @@ -23,7 +23,7 @@ - +

Rebuilt around GTK 4, libadwaita, and GStreamer with a modular, lower-overhead runtime.

diff --git a/io.github.crhy.voice2textai.yml b/io.github.crhy.voice2textai.yml index 888e226..56ba3b8 100644 --- a/io.github.crhy.voice2textai.yml +++ b/io.github.crhy.voice2textai.yml @@ -1,6 +1,6 @@ id: io.github.crhy.voice2textai runtime: org.gnome.Platform -runtime-version: '50' +runtime-version: '48' sdk: org.gnome.Sdk command: voice2text-ai diff --git a/legacy/voice_app_tk_legacy.py b/legacy/voice_app_tk_legacy.py deleted file mode 100644 index f37b9c0..0000000 --- a/legacy/voice_app_tk_legacy.py +++ /dev/null @@ -1,972 +0,0 @@ -#!/usr/bin/env python3 -""" -Voice 2 Text GUI Application - -A standalone GUI app for voice recognition that integrates with AI. -Features a simple interface with start/stop buttons and automatic clipboard copying. -""" - -import os -import sys -import contextlib -import math - -# Linux-only audio environment tweaks. Do not force ALSA on Windows/macOS. -if sys.platform.startswith('linux'): - os.environ.setdefault('JACK_NO_START_SERVER', '1') - os.environ.setdefault('SDL_AUDIODRIVER', 'alsa') - os.environ.setdefault('ALSA_NO_JACK', '1') - -import tkinter as tk -from tkinter import ttk, scrolledtext, messagebox -import tkinter.font as tkfont -import pyperclip -import threading -import time -import json -import pyaudio -import numpy as np -import tempfile -import subprocess -import shutil -from faster_whisper import WhisperModel -from scipy.signal import resample_poly -import requests -import pygame -import asyncio -import edge_tts -from PIL import Image, ImageTk -import datetime -import queue -from tqdm import tqdm - -class GuiTqdm(tqdm): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.app = getattr(GuiTqdm, 'app', None) - - def update(self, n=1): - super().update(n) - if self.app and self.total and self.total > 0: - progress = min(100, self.n / self.total * 100) - self.app.queue.put(('progress', progress)) - - -class VoiceApp: - def __init__(self, root): - self.root = root - self._main_thread_id = threading.get_ident() - self.root.title("Voice 2 Text") - self.root.geometry("900x800") - self.root.configure(bg='black') - self.root.resizable(True, True) - - # Queue for thread-safe GUI updates. Create it before any worker thread. - self.queue = queue.Queue() - self.shutdown_event = threading.Event() - self.model_lock = threading.Lock() - self.audio_lock = threading.Lock() - self.audio_queue = queue.Queue(maxsize=256) - self.model = None - self.sample_rate = 16000 - - # Create canvas with gradient background - self.canvas = tk.Canvas(self.root, width=900, height=800, highlightthickness=0) - self.canvas.pack(fill='both', expand=True) - self.canvas.bind('', self.on_canvas_resize) - self.create_gradient(900, 800) - - # Config - self.config_file = os.path.expanduser('~/.voice_config.json') - print(f"Config file: {self.config_file}") - self.config = self.load_config() - - # TTS settings - self.tts_rate = self.config.get('tts_rate', 180) - self.tts_available = False - - # Audio devices - self.audio = None - try: - self.audio = pyaudio.PyAudio() - self.microphones = self.get_microphones() - except Exception as e: - print(f"Audio initialization failed: {e}") - self.microphones = [] - - self.selected_mic_index = 0 - self.selected_mic_name = self.config.get('microphone_name', '') - - # Create vars - self.whisper_var = tk.StringVar() - self.mic_var = tk.StringVar() - self.model_var = tk.StringVar() - - # Set mic from saved name or default - if self.microphones: - if self.selected_mic_name and self.selected_mic_name in self.microphones: - self.mic_var.set(self.selected_mic_name) - self.selected_mic_index = self.microphones.index(self.selected_mic_name) - else: - self.mic_var.set(self.microphones[0]) - self.selected_mic_index = 0 - self.selected_mic_name = self.microphones[0] - else: - self.mic_var.set("No microphone detected") - self.selected_mic_name = '' - - # Whisper models - self.whisper_models = ["tiny", "base", "small", "medium", "large-v2", "large-v3"] - self.selected_whisper_model = self.config.get('whisper_model', 'tiny') - self.model_info = { - "tiny": {"size": 39, "eta": 1}, - "base": {"size": 74, "eta": 1}, - "small": {"size": 244, "eta": 4}, - "medium": {"size": 769, "eta": 13}, - "large-v2": {"size": 1550, "eta": 26}, - "large-v3": {"size": 1550, "eta": 26}, - } - - # Text-to-speech engine. Some systems have no output device; the app should still open. - try: - pygame.mixer.init() - self.tts_available = True - except Exception as e: - print(f"TTS audio output initialization failed: {e}") - self.tts_playing = False - - # Audio buffering - self.max_audio_frames = 256 - - # Ollama models. Keep timeout short so app startup is not blocked for long. - self.ollama_models = self.get_ollama_models() - self.selected_model = self.config.get( - 'selected_model', - "llama3.2" if "llama3.2" in self.ollama_models else (self.ollama_models[0] if self.ollama_models else "llama3.2"), - ) - if self.ollama_models and self.selected_model not in self.ollama_models: - self.selected_model = self.ollama_models[0] - - self.is_listening = False - self.current_text = "" - self.audio_stream = None - - # Create GUI before background workers that update widgets. - self.create_gui() - self.process_queue() - self.root.protocol("WM_DELETE_WINDOW", self.on_close) - - # Load Whisper after widgets exist. - threading.Thread(target=self.load_whisper_model, daemon=True).start() - - def update_time(self): - current_time = datetime.datetime.now().strftime("%I:%M:%S %p") - self.time_label.config(text=current_time) - self.root.after(1000, self.update_time) - - def process_queue(self): - try: - while True: - msg = self.queue.get_nowait() - kind = msg[0] - if kind == "update_status": - self.update_status(msg[1], msg[2]) - elif kind == "update_transcript": - self.update_transcript(msg[1]) - elif kind == "show_error": - messagebox.showerror("Error", msg[1]) - elif kind == "clear_ai": - self.ai_text_area.delete(1.0, tk.END) - elif kind == "insert_ai": - self.ai_text_area.insert(tk.END, msg[1]) - elif kind == "stop_dictation": - self.stop_dictation() - elif kind == "progress": - self.progress_bar.config(value=msg[1]) - elif kind == "ui_call": - func, args, kwargs = msg[1], msg[2], msg[3] - func(*args, **kwargs) - except queue.Empty: - pass - if not self.shutdown_event.is_set(): - self.root.after(100, self.process_queue) - - def post_ui_call(self, func, *args, **kwargs): - self.queue.put(("ui_call", func, args, kwargs)) - - def get_ollama_models(self): - """Get available Ollama models with improved error handling.""" - try: - response = requests.get('http://localhost:11434/api/tags', timeout=1.5) - response.raise_for_status() # Raise exception for bad status codes - data = response.json() - return [model['name'] for model in data.get('models', [])] - except requests.exceptions.Timeout: - self.update_status("Ollama connection timeout - check if Ollama is running", "orange") - return [] - except requests.exceptions.ConnectionError: - self.update_status("Cannot connect to Ollama - start with 'ollama serve'", "red") - return [] - except requests.exceptions.RequestException as e: - self.update_status(f"Ollama request error: {str(e)[:50]}", "red") - return [] - except (KeyError, ValueError) as e: - self.update_status(f"Invalid Ollama response: {str(e)[:50]}", "red") - return [] - - def load_config(self): - config = {} - if os.path.exists(self.config_file): - try: - with open(self.config_file, 'r') as f: - config = json.load(f) - except Exception as e: - print(f"Error loading config: {e}") - config = {} - print(f"Loaded config: {config}") - return config - - def save_config(self): - config = { - 'microphone_name': self.selected_mic_name, - 'selected_model': self.selected_model, - 'whisper_model': self.selected_whisper_model, - 'tts_rate': self.tts_rate - } - try: - with open(self.config_file, 'w') as f: - json.dump(config, f) - print(f"Saved config: {config}") - except Exception as e: - print(f"Error saving config: {e}") - - def on_close(self): - self.shutdown_event.set() - self.is_listening = False - self.tts_playing = False - self.save_config() - if self.audio_stream: - with contextlib.suppress(Exception): - self.audio_stream.stop_stream() - with contextlib.suppress(Exception): - self.audio_stream.close() - self.audio_stream = None - if self.audio: - with contextlib.suppress(Exception): - self.audio.terminate() - if self.tts_available: - with contextlib.suppress(Exception): - pygame.mixer.music.stop() - with contextlib.suppress(Exception): - pygame.mixer.quit() - self.root.destroy() - - def create_gradient(self, width, height): - """Draw the background gradient without per-pixel Python loops.""" - width = max(1, int(width)) - height = max(1, int(height)) - blue = np.linspace(0, 51, height, dtype=np.uint8)[:, None] - img_array = np.zeros((height, width, 3), dtype=np.uint8) - img_array[:, :, 2] = blue - img = Image.fromarray(img_array, 'RGB') - self.bg_photo = ImageTk.PhotoImage(img) - self.canvas.delete("gradient") - self.canvas.create_image(0, 0, anchor='nw', image=self.bg_photo, tags="gradient") - - def on_canvas_resize(self, event): - width = event.width - height = event.height - self.create_gradient(width, height) - - def get_microphones(self): - microphones = [] - if not self.audio: - return microphones - try: - for i in range(self.audio.get_device_count()): - info = self.audio.get_device_info_by_index(i) - max_input = info.get('maxInputChannels', 0) - if isinstance(max_input, (int, float)) and max_input > 0: - microphones.append(f"{info.get('name')} (Index: {i})") - except Exception as e: - print(f"Could not enumerate microphones: {e}") - return microphones - - def get_mic_device_index(self, mic_string): - import re - match = re.search(r'Index: (\d+)', mic_string) - return int(match.group(1)) if match else 0 - - def load_whisper_model(self): - """Load Whisper model with clearer fallback and UI-thread-safe updates.""" - model_name = self.selected_whisper_model - info = self.model_info.get(model_name, {"size": "unknown", "eta": "unknown"}) - self.update_status( - f"Loading Whisper model: {model_name} ({info['size']} MB) - estimated download time: {info['eta']} min", - "#ffaa00", - ) - self.post_ui_call(self.progress_bar.config, mode='indeterminate') - self.post_ui_call(self.progress_bar.start) - - # Slim release build: do not depend on PyTorch/CUDA. - # Faster Whisper uses CTranslate2 and works well on CPU with int8. - device = "cpu" - attempts = [(device, "int8")] - - last_error = None - for attempt_device, compute_type in attempts: - try: - if attempt_device == "cpu" and device == "cuda": - self.update_status("CUDA failed, falling back to CPU...", "#ffaa00") - model = WhisperModel( - model_name, - device=attempt_device, - compute_type=compute_type, - cpu_threads=1 if attempt_device == "cuda" else max(1, min(4, (os.cpu_count() or 4))), - ) - with self.model_lock: - # Ignore stale loads after the user changes the dropdown. - if model_name != self.selected_whisper_model: - return - self.model = model - self.post_ui_call(self.progress_bar.stop) - self.post_ui_call(self.progress_bar.config, mode='determinate', value=100) - self.post_ui_call(self.loaded_label.config, text=f"Loaded: {model_name}") - suffix = "on CPU" if attempt_device == "cpu" else "successfully" - self.update_status(f"Whisper model loaded {suffix}!", "#00aa00") - return - except Exception as e: - last_error = e - - with self.model_lock: - self.model = None - self.post_ui_call(self.progress_bar.stop) - self.post_ui_call(self.progress_bar.config, value=0) - self.post_ui_call(self.loaded_label.config, text="Loaded: Failed") - self.update_status(f"Failed to load Whisper model: {str(last_error)[:120]}", "red") - - def audio_callback(self, in_data, frame_count, time_info, status): - """PyAudio callback: enqueue bytes only; never do heavy work here.""" - if self.is_listening: - try: - self.audio_queue.put_nowait(in_data) - except queue.Full: - # Drop the oldest frame to keep latency bounded instead of growing memory. - with contextlib.suppress(queue.Empty): - self.audio_queue.get_nowait() - with contextlib.suppress(queue.Full): - self.audio_queue.put_nowait(in_data) - return (in_data, pyaudio.paContinue) - - def create_gui(self): - # Style - style = ttk.Style() - style.configure('TFrame', background='#000022') - style.configure('TButton', font=('Noto Sans', 13), padding=10, background='#000022', foreground='white') - style.configure('TLabel', font=('Noto Sans', 11), background='#000000', foreground='white') - style.configure('TCombobox', font=('Noto Sans', 11), fieldbackground='white', foreground='black', selectbackground='#000055', selectforeground='white') - style.configure('TCombobox.Listbox', background='#000022', foreground='white', selectbackground='#000055', selectforeground='white') - style.configure('Vertical.TScrollbar', background='#000022', troughcolor='#000022', arrowcolor='white', bordercolor='#000022') - style.configure('TProgressbar', background='#00aa00', troughcolor='#000033', bordercolor='#000033') - - # Title - title_label = tk.Label(self.root, text="Voice 2 Text", font=('Noto Sans', 30, 'bold'), bg='black', fg='white') - self.canvas.create_window(450, 50, window=title_label) - - # Version - version_label = tk.Label(self.root, text="v0.3.1", font=('Noto Sans', 9), bg='#000000', fg='white') - self.canvas.create_window(850, 20, window=version_label) - - # Time - self.time_label = tk.Label(self.root, text="", font=('Noto Sans', 11), bg='#000000', fg='white') - self.canvas.create_window(850, 40, window=self.time_label) - self.update_time() - - # Whisper model selection - whisper_frame = ttk.Frame(self.root, style='TFrame') - self.canvas.create_window(450, 610, window=whisper_frame) - - tk.Label(whisper_frame, text="Whisper Model:", bg='#000022', fg='white', font=('Noto Sans', 12, 'bold')).pack(side='left') - self.whisper_combo = ttk.Combobox(whisper_frame, textvariable=self.whisper_var, values=self.whisper_models, state='readonly', width=40) - self.whisper_combo.pack(side='left', padx=(10, 0)) - self.whisper_var.set(self.selected_whisper_model) - self.whisper_combo.bind('<>', self.on_whisper_change) - - # Loaded model indicator - self.loaded_label = tk.Label(whisper_frame, text="Loaded: None", bg='#000010', fg='white', font=('Noto Sans', 9)) - self.loaded_label.pack(side='left', padx=(10, 0)) - - # Microphone selection - mic_frame = ttk.Frame(self.root, style='TFrame') - self.canvas.create_window(450, 650, window=mic_frame) - - tk.Label(mic_frame, text="Microphone:", bg='#000022', fg='white', font=('Noto Sans', 12, 'bold')).pack(side='left') - mic_values = self.microphones if self.microphones else ["No microphone detected"] - self.mic_combo = ttk.Combobox(mic_frame, textvariable=self.mic_var, values=mic_values, state='readonly', width=40) - self.mic_combo.pack(side='left', padx=(10, 0)) - self.mic_combo.bind('<>', self.on_mic_change_combo) - - # AI Model selection - model_frame = ttk.Frame(self.root, style='TFrame') - self.canvas.create_window(450, 690, window=model_frame) - - tk.Label(model_frame, text="AI Model:", bg='#000022', fg='white', font=('Noto Sans', 12, 'bold')).pack(side='left') - model_values = self.ollama_models if self.ollama_models else ["Ollama not running"] - self.model_combo = ttk.Combobox(model_frame, textvariable=self.model_var, values=model_values, state='readonly', width=40) - self.model_combo.pack(side='left', padx=(10, 0)) - if self.ollama_models: - self.model_var.set(self.selected_model) - else: - self.model_var.set("Ollama not running") - self.model_var.trace_add('write', self.on_model_change) - - # Status label with loading indicator - self.status_label = tk.Label(self.root, text="Ready", font=('Noto Sans', 13, 'bold'), bg='#000033', fg='yellow') - self.canvas.create_window(450, 110, window=self.status_label) - - # Progress bar for model download - self.progress_bar = ttk.Progressbar(self.root, orient='horizontal', mode='indeterminate', length=400) - self.canvas.create_window(450, 130, window=self.progress_bar) - - # Text area - text_frame = ttk.Frame(self.root) - self.canvas.create_window(250, 290, window=text_frame) - - tk.Label(text_frame, text="Transcribed Text:", bg='black', fg='white', font=('Noto Sans', 11, 'bold')).pack(fill='x') - self.text_area = scrolledtext.ScrolledText(text_frame, height=15, width=35, wrap=tk.WORD, - bg='black', fg='white', insertbackground='white', - font=('Noto Sans', 12), borderwidth=0, relief='flat') - self.text_area.pack(fill='x', expand=False) - - # AI Response area - ai_frame = ttk.Frame(self.root) - self.canvas.create_window(650, 290, window=ai_frame) - - tk.Label(ai_frame, text="AI Response:", bg='black', fg='white', font=('Noto Sans', 11, 'bold')).pack(fill='x') - self.ai_text_area = scrolledtext.ScrolledText(ai_frame, height=15, width=35, wrap=tk.WORD, - bg='black', fg='white', insertbackground='white', - font=('Noto Sans', 12), borderwidth=0, relief='flat') - self.ai_text_area.pack(fill='x', expand=False) - - # TTS Controls - tts_frame = ttk.Frame(self.root, style='TFrame') - self.canvas.create_window(450, 570, window=tts_frame) - - tk.Label(tts_frame, text="TTS Speed:", bg='#000022', fg='white', font=('Noto Sans', 12, 'bold')).pack(side='left') - self.tts_scale = tk.Scale(tts_frame, from_=100, to=300, orient='horizontal', bg='#000022', fg='white', troughcolor='#000055', highlightbackground='#000022') - self.tts_scale.set(self.tts_rate) - self.tts_scale.pack(side='left', padx=(10, 0)) - self.tts_scale.bind('', self.on_tts_rate_change) - - # Buttons - button_frame = ttk.Frame(self.root) - self.canvas.create_window(450, 500, window=button_frame) - - self.dictation_button = ttk.Button(button_frame, text="Start Dictation", - command=self.toggle_dictation) - self.dictation_button.pack(side='left', padx=5) - - self.copy_button = ttk.Button(button_frame, text="Copy Text", - command=self.copy_text) - self.copy_button.pack(side='left', padx=5) - - self.send_ai_button = ttk.Button(button_frame, text="Query AI", - command=self.send_to_ai) - self.send_ai_button.pack(side='left', padx=5) - - self.stop_tts_button = ttk.Button(button_frame, text="Stop Speech", - command=self.stop_tts) - self.stop_tts_button.pack(side='left', padx=5) - - self.clear_button = ttk.Button(button_frame, text="Clear", - command=self.clear_text) - self.clear_button.pack(side='left', padx=5) - - - - def on_mic_change_combo(self, event=None): - value = self.mic_var.get() - if value in self.microphones: - self.selected_mic_index = self.microphones.index(value) - self.selected_mic_name = value - self.save_config() - self.update_status(f"Selected: {value.split(' (')[0]}") - - def on_model_change(self, *args): - self.selected_model = self.model_var.get() - self.save_config() - self.update_status(f"AI Model: {self.selected_model}") - - def on_whisper_change(self, event=None): - old_model = self.selected_whisper_model - self.selected_whisper_model = self.whisper_var.get() - if self.selected_whisper_model != old_model: - self.save_config() - with self.model_lock: - self.model = None # Free old model - self.loaded_label.config(text="Loaded: None") - self.update_status(f"Loading Whisper model: {self.selected_whisper_model}...", "#ffaa00") - threading.Thread(target=self.load_whisper_model, daemon=True).start() - - def on_tts_rate_change(self, event=None): - self.tts_rate = int(self.tts_scale.get()) - self.save_config() - - def update_status(self, message, color='gray', progress_text=""): - """Update status from any thread without touching Tk widgets off-thread.""" - if threading.get_ident() != getattr(self, '_main_thread_id', None): - self.queue.put(("update_status", message, color)) - return - if hasattr(self, 'status_label'): - self.status_label.config(text=message, fg=color) - self.root.update_idletasks() - - def toggle_dictation(self): - if self.is_listening: - self.stop_dictation() - else: - self.start_dictation() - - def start_dictation(self): - if not self.microphones: - messagebox.showerror("Error", "No microphones found!") - return - with self.model_lock: - model_ready = self.model is not None - if not model_ready: - self.update_status("Whisper model is still loading; try again shortly", "orange") - return - - self.is_listening = True - self.current_text = "" - while True: - try: - self.audio_queue.get_nowait() - except queue.Empty: - break - self.text_area.delete(1.0, tk.END) - self.text_area.insert(tk.END, "Listening... Speak now!\n\n") - - self.dictation_button.config(text="Stop Dictation") - self.update_status("Listening...", "#00aa00") - - # Start listening in background thread - threading.Thread(target=self.listen_loop, daemon=True).start() - - def stop_dictation(self): - self.is_listening = False - self.dictation_button.config(text="Start Dictation") - - if self.current_text.strip(): - self.update_status("Dictation stopped. Ready to query AI or copy text.", "#0066cc") - else: - self.update_status("Ready", "black") - - def copy_text(self): - if self.is_listening: - self.stop_dictation() - text = self.text_area.get(1.0, tk.END).strip() - prompt = "Listening... Speak now!\n\n" - if text.startswith(prompt): - text = text[len(prompt):].strip() - if text: - pyperclip.copy(text) - self.update_status("Text copied to clipboard!", "#0066cc") - else: - self.update_status("No text to copy", "black") - - def send_to_ai(self): - if self.is_listening: - self.stop_dictation() - text = self.text_area.get(1.0, tk.END).strip() - if text: - self.ai_text_area.delete(1.0, tk.END) - self.update_status("Sending to AI...", "#ffaa00") - threading.Thread(target=self.query_ollama_and_speak, args=(text,), daemon=True).start() - else: - self.update_status("No text to send to AI", "black") - - def query_ollama_and_speak(self, user_text): - """Query Ollama with retry logic and improved error handling.""" - if not user_text or not user_text.strip(): - self.update_status("No text to send to AI", "orange") - return - - if not self.ollama_models: - # Try one quick refresh in case Ollama started after the GUI opened. - self.ollama_models = self.get_ollama_models() - if not self.ollama_models: - self.update_status("Ollama not running - start with 'ollama serve'", "red") - return - - user_text = user_text.strip() - if len(user_text) > 10000: - user_text = user_text[:10000] + "..." - self.update_status("Input truncated to 10,000 characters", "orange") - - max_retries = 3 - for attempt in range(max_retries): - try: - self.update_status(f"Querying AI... (attempt {attempt + 1}/{max_retries})", "#ffaa00") - - response = requests.post( - 'http://localhost:11434/api/generate', - json={ - "model": self.selected_model, - "prompt": user_text, - "stream": False, - "options": {"num_predict": 512}, - }, - timeout=(5, 120), - ) - - response.raise_for_status() - ai_response = response.json().get('response', '').strip() - - if not ai_response: - self.update_status("AI gave empty response", "orange") - return - - self.queue.put(("clear_ai",)) - self.queue.put(("insert_ai", ai_response)) - self.update_status("Generating speech...", "#00aa00") - self.speak_with_tts(ai_response) - self.update_status("AI responded successfully!", "#00aa00") - return - - except requests.exceptions.Timeout: - if attempt < max_retries - 1: - self.update_status(f"AI timeout, retrying... ({attempt + 1}/{max_retries})", "orange") - time.sleep(2) - continue - self.update_status("AI timeout - model may be slow or overloaded", "red") - - except requests.exceptions.ConnectionError: - self.update_status("Cannot connect to Ollama - check if running", "red") - break - - except requests.exceptions.HTTPError as e: - status_code = e.response.status_code if e.response else "unknown" - self.update_status(f"Ollama HTTP error {status_code}: {str(e)[:50]}", "red") - break - - except (KeyError, ValueError) as e: - self.update_status(f"Invalid response from Ollama: {str(e)[:50]}", "red") - break - - except Exception as e: - if attempt < max_retries - 1: - self.update_status(f"AI error, retrying... ({attempt + 1}/{max_retries})", "orange") - time.sleep(1) - continue - self.update_status(f"AI error: {str(e)[:50]}", "red") - - def speak_with_tts(self, text): - """Speak text with Edge TTS or offline eSpeak NG fallback.""" - if not text.strip(): - self.update_status("No text to speak", "orange") - return - - if not self.tts_available: - self.update_status("TTS not available", "orange") - return - - temp_files = [] - self.tts_playing = True - - try: - # First choice: Edge TTS. - try: - self.update_status("Generating speech...", "#0066cc") - - # Map app slider 100-300 to Edge's -50% to +50%. - rate_percent = ((self.tts_rate - 180) / 120) * 50 - rate_percent = max(-50, min(50, rate_percent)) - rate_str = f"{rate_percent:+.0f}%" - - voice = "en-US-AriaNeural" - - temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".mp3", mode="w+b") - temp_file_name = temp_file.name - temp_file.close() - temp_files.append(temp_file_name) - - async def save_edge_tts(): - communicate = edge_tts.Communicate(text, voice, rate=rate_str) - await communicate.save(temp_file_name) - - asyncio.run(save_edge_tts()) - - pygame.mixer.music.load(temp_file_name) - pygame.mixer.music.play() - - while pygame.mixer.music.get_busy() and self.tts_playing: - pygame.time.wait(100) - - pygame.mixer.music.stop() - - if self.tts_playing: - self.update_status("Speech completed", "#00aa00") - return - - except Exception as edge_error: - # Offline open-source fallback: eSpeak NG. - try: - if not shutil.which("espeak-ng"): - raise FileNotFoundError("espeak-ng is not installed") - - self.update_status("Edge TTS failed, using offline eSpeak NG...", "orange") - - temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".wav", mode="w+b") - temp_file_name = temp_file.name - temp_file.close() - temp_files.append(temp_file_name) - - subprocess.run( - [ - "espeak-ng", - "-s", - str(int(self.tts_rate)), - "-w", - temp_file_name, - text, - ], - check=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - - pygame.mixer.music.load(temp_file_name) - pygame.mixer.music.play() - - while pygame.mixer.music.get_busy() and self.tts_playing: - pygame.time.wait(100) - - pygame.mixer.music.stop() - - if self.tts_playing: - self.update_status("Speech completed (eSpeak NG)", "#00aa00") - - except Exception as espeak_error: - error_msg = ( - f"TTS error: Edge: {str(edge_error)[:30]}, " - f"eSpeak NG: {str(espeak_error)[:30]}" - ) - self.update_status(error_msg, "orange") - - finally: - self.tts_playing = False - - with contextlib.suppress(Exception): - pygame.mixer.music.unload() - - for temp_file_name in temp_files: - with contextlib.suppress(Exception): - os.unlink(temp_file_name) - - def stop_tts(self): - if self.is_listening: - self.stop_dictation() - self.tts_playing = False - if self.tts_available: - with contextlib.suppress(Exception): - pygame.mixer.music.stop() - self.update_status("TTS stopped", "orange") - - def clear_text(self): - if self.is_listening: - self.stop_dictation() - self.text_area.delete(1.0, tk.END) - self.ai_text_area.delete(1.0, tk.END) - self.current_text = "" - self.update_status("Ready", "black") - - def transcribe_audio(self, audio_data, sample_rate): - """Normalize/resample audio and pass a numpy array directly to Faster Whisper.""" - if audio_data.size == 0: - return "" - - if sample_rate != 16000: - divisor = math.gcd(sample_rate, 16000) - audio_data = resample_poly(audio_data, 16000 // divisor, sample_rate // divisor).astype(np.int16) - - audio_float = audio_data.astype(np.float32) / 32768.0 - with self.model_lock: - model = self.model - if model is None: - raise RuntimeError("Whisper model not loaded") - - segments, _info = model.transcribe( - audio_float, - language="en", - beam_size=1, - vad_filter=True, - condition_on_previous_text=False, - ) - return " ".join(segment.text for segment in segments).strip() - - def listen_loop(self): - try: - if not self.audio: - self.queue.put(("update_status", "No audio input available", "red")) - self.queue.put(("stop_dictation",)) - return - - device_index = self.get_mic_device_index(self.microphones[self.selected_mic_index]) - sample_rates = [48000, 44100, 16000, 22050, 8000] - self.audio_stream = None - - for rate in sample_rates: - try: - self.audio_stream = self.audio.open( - format=pyaudio.paInt16, - channels=1, - rate=rate, - input=True, - input_device_index=device_index, - frames_per_buffer=1024, - stream_callback=self.audio_callback, - ) - self.sample_rate = rate - break - except Exception as e: - print(f"Failed to open stream at {rate} Hz: {e}") - - if self.audio_stream is None: - self.queue.put(("update_status", "No audio device available - check microphone setup", "red")) - self.queue.put(("stop_dictation",)) - return - - self.audio_stream.start_stream() - self.queue.put(("update_status", "Listening... (real-time)", "#00aa00")) - - chunk_duration = 3.0 - silence_threshold = 500 - consecutive_silent_chunks = 0 - max_silent_chunks = 5 - pending_frames = [] - next_process = time.monotonic() + chunk_duration - - while self.is_listening and not self.shutdown_event.is_set(): - timeout = max(0.05, next_process - time.monotonic()) - try: - pending_frames.append(self.audio_queue.get(timeout=timeout)) - except queue.Empty: - pass - - if time.monotonic() < next_process: - continue - next_process = time.monotonic() + chunk_duration - - if not pending_frames: - continue - - chunk = b''.join(pending_frames) - pending_frames.clear() - audio_data = np.frombuffer(chunk, dtype=np.int16) - - if audio_data.size == 0: - continue - - try: - rms = float(np.sqrt(np.mean(audio_data.astype(np.float32) ** 2))) - if rms < silence_threshold: - consecutive_silent_chunks += 1 - if consecutive_silent_chunks >= max_silent_chunks: - self.queue.put(("update_status", "Silence detected, stopping...", "#ffaa00")) - self.is_listening = False - break - continue - consecutive_silent_chunks = 0 - except Exception: - pass - - self.queue.put(("update_status", "Recognizing...", "#ffaa00")) - try: - text = self.transcribe_audio(audio_data, self.sample_rate) - if text: - self.current_text += text + " " - self.queue.put(("update_transcript", text)) - self.queue.put(("update_status", "Listening... (real-time)", "#00aa00")) - except Exception as e: - self.queue.put(("update_transcript", f"[Error: {e}]")) - self.queue.put(("update_status", "Listening... (real-time)", "#00aa00")) - - if pending_frames: - self.queue.put(("update_status", "Finalizing...", "#ffaa00")) - try: - audio_data = np.frombuffer(b''.join(pending_frames), dtype=np.int16) - text = self.transcribe_audio(audio_data, self.sample_rate) - if text: - self.current_text += text + " " - self.queue.put(("update_transcript", text)) - except Exception as e: - self.queue.put(("update_transcript", f"[Error: {e}]")) - - if self.audio_stream: - with contextlib.suppress(Exception): - self.audio_stream.stop_stream() - with contextlib.suppress(Exception): - self.audio_stream.close() - self.audio_stream = None - - self.queue.put(("update_status", "Ready", "black")) - self.queue.put(("stop_dictation",)) - - except Exception as e: - self.queue.put(("show_error", f"Recognition error: {e}")) - self.queue.put(("stop_dictation",)) - - def update_transcript(self, text): - self.text_area.insert(tk.END, f"{text}\n") - self.text_area.see(tk.END) - self.root.update_idletasks() - -def main(): - try: - root = tk.Tk() - - # Flatpak/Tk font fix: force Tk named fonts to bundled DejaVu Sans. - try: - root.tk.call('tk', 'scaling', 1.35) - - for font_name in ( - 'TkDefaultFont', - 'TkTextFont', - 'TkMenuFont', - 'TkHeadingFont', - 'TkCaptionFont', - 'TkSmallCaptionFont', - 'TkIconFont', - 'TkTooltipFont', - ): - try: - tkfont.nametofont(font_name).configure( - family='Noto Sans', - size=11, - ) - except Exception: - pass - - try: - tkfont.nametofont('TkFixedFont').configure( - family='Noto Sans', - size=11, - ) - except Exception: - pass - except Exception: - pass - with contextlib.suppress(Exception): - root.tk.call('tk', 'scaling', 1.35) - app = VoiceApp(root) - root.mainloop() - except ImportError as e: - print(f"Missing dependency: {e}") - print("Install required packages:") - print("pip install SpeechRecognition pyperclip pyaudio pocketsphinx") - except Exception as e: - print(f"Error: {e}") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/packaging/flatpak/requirements.txt b/packaging/flatpak/requirements.txt index b3d05b4..c1d263d 100644 --- a/packaging/flatpak/requirements.txt +++ b/packaging/flatpak/requirements.txt @@ -1,3 +1,6 @@ faster-whisper>=1.2,<2 numpy>=2.0,<3 edge-tts>=7.2,<8 +# Pin the pre-1.0 hub that still uses requests: newer hubs pull hf-xet and the +# httpx stack, adding several MB to the flatpak with no benefit for model downloads. +huggingface-hub==0.28.1 diff --git a/pyproject.toml b/pyproject.toml index 1048edc..99c4f01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,6 @@ pythonpath = ["."] [tool.ruff] target-version = "py311" line-length = 120 -exclude = ["legacy"] [tool.ruff.lint] select = ["E", "F", "I", "UP", "B"] diff --git a/python3-requirements-flatpak.json b/python3-requirements-flatpak.json deleted file mode 100644 index 997fc54..0000000 --- a/python3-requirements-flatpak.json +++ /dev/null @@ -1,241 +0,0 @@ -{ - "name": "python3-requirements-flatpak", - "buildsystem": "simple", - "build-commands": [], - "modules": [ - { - "name": "python3-faster-whisper", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"faster-whisper>=1.2,<2\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", - "sha256": "9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/6d/b9/7708c43fed7ae28b4a1bad060b4221e3334cd827cec24f7165902a6ac1f4/av-18.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", - "sha256": "ae56b40b6f8b067a8ad2dac664fbfbabac7f7a55b9a7bb031eb99289252bc017", - "only-arches": [ - "x86_64" - ] - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", - "sha256": "62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", - "sha256": "e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/99/4a/21f325a9d0925d8ad24b04249adf29bf9909442967603634f7f6d4acbb79/ctranslate2-4.8.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", - "sha256": "4242a7f8e285f922525f4cffd5b1fb43cbacc61d0611cf54832e9c447d030840", - "only-arches": [ - "x86_64" - ] - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/05/99/49ee85903dee060d9f08297b4a342e5e0bcfca2f027a07b4ee0a38ab13f9/faster_whisper-1.2.1-py3-none-any.whl", - "sha256": "79a66ad50688c0b794dd501dc340a736992a6342f7f95e5811be60b5224a26a7" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/06/79/b4c714bef36bc4ec2beeae1e0c124f0223888cd8c6feb1cdc56038116920/filelock-3.32.0-py3-none-any.whl", - "sha256": "d396bea984af47333ef05e50eae7eff88c84256de6112aea0ec48a233c064fe3" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", - "sha256": "7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", - "sha256": "b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", - "sha256": "63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/d3/35/db860aa3a0780660324a506ad4b3d322ddc6ecbba4b9340aed0942cbf21c/hf_xet-1.5.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", - "sha256": "db78c39c83d6279daddc98e2238f373ab8980685556d42472b4ec51abcf03e8c", - "only-arches": [ - "x86_64" - ] - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", - "sha256": "2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", - "sha256": "d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/f7/3f/21e816831c6d16f88a6c784974413fa0421ce8a5d04380c2666ed5b503e5/huggingface_hub-1.25.1-py3-none-any.whl", - "sha256": "004d4e70350517e24c68a7dbb7dc5e40b2b6aefef8f94bf7a85f6f9835102ea5" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", - "sha256": "7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", - "sha256": "9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", - "only-arches": [ - "x86_64" - ] - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/78/b6/8c5396e7894e77c5a7d1e026f3acb9dd39c4b5644e412e37a0055eaa3bc5/onnxruntime-1.28.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", - "sha256": "54fa221d669282bd8f582708ce4c96010a7e9fb0661f9006b37fe2fedafb73fe", - "only-arches": [ - "x86_64" - ] - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", - "sha256": "4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", - "sha256": "0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", - "only-arches": [ - "x86_64" - ] - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/0d/d5/1353e5f677ec27c2494fb6a6725e82d56c985f53e90ec511369e7e4f02c6/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", - "sha256": "5075b405006415ea148a992d093699c66eb01952bf59f4d5727089a98bda45a4", - "only-arches": [ - "x86_64" - ] - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", - "sha256": "7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", - "sha256": "481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8" - } - ] - }, - { - "name": "python3-numpy", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"numpy>=2.0,<3\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", - "sha256": "9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", - "only-arches": [ - "x86_64" - ] - } - ] - }, - { - "name": "python3-edge-tts", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"edge-tts>=7.2,<8\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", - "sha256": "9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", - "sha256": "d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", - "only-arches": [ - "x86_64" - ] - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", - "sha256": "053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", - "sha256": "c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", - "sha256": "62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/8c/2b/a8cb687b92a2690d2ad171f0c2fd1c8f18690363cca7618bab2bbe4cdf2b/edge_tts-7.2.8-py3-none-any.whl", - "sha256": "361fe48ce7ef613adbe30f664e3765dd71029c6cb57427279eff8ad6df2eb211" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", - "sha256": "0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", - "sha256": "7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", - "sha256": "55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", - "sha256": "be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", - "sha256": "f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", - "sha256": "481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", - "sha256": "a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7" - } - ] - } - ] -} diff --git a/qt_app.py b/qt_app.py deleted file mode 100755 index eb2e8da..0000000 --- a/qt_app.py +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env python3 -"""Compatibility launcher for older source-install instructions.""" - -from voice2text.__main__ import main - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/requirements-flatpak.txt b/requirements-flatpak.txt deleted file mode 100644 index c55be7a..0000000 --- a/requirements-flatpak.txt +++ /dev/null @@ -1,25 +0,0 @@ -requests -charset-normalizer -idna -urllib3 -certifi -numpy -protobuf -filelock -fsspec -packaging -pyyaml -tqdm -huggingface-hub==0.28.1 -tokenizers -ctranslate2==4.7.2 -onnxruntime -av -faster-whisper==1.2.1 -pyperclip -PyAudio -scipy -pygame -pillow -edge-tts -soundfile diff --git a/voice2text/transcription.py b/voice2text/transcription.py index e86d90f..a1269e5 100644 --- a/voice2text/transcription.py +++ b/voice2text/transcription.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib import os import threading from collections.abc import Callable @@ -61,7 +62,10 @@ def _load( try: from faster_whisper import WhisperModel - cpu_threads = max(1, min(8, os.cpu_count() or 4)) + available = 0 + with contextlib.suppress(OSError): + available = len(os.sched_getaffinity(0)) + cpu_threads = max(1, min(8, available or os.cpu_count() or 4)) attempts = [ (os.environ.get("VOICE2TEXT_DEVICE", "auto"), "default"), ("cpu", "int8"), diff --git a/voice_config.example.json b/voice_config.example.json deleted file mode 100644 index d6f439b..0000000 --- a/voice_config.example.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "microphone_name": "", - "selected_model": "llama3.2", - "whisper_model": "tiny", - "tts_rate": 200 -}