From d226e90e7d7e2fc472f23b6239da217d18691822 Mon Sep 17 00:00:00 2001 From: chatbotmetaverse <183497257+ChrisRedDev@users.noreply.github.com> Date: Mon, 16 Feb 2026 19:38:28 +0000 Subject: [PATCH 1/5] fix: update author URLs to https://github.com/aievolutionpl --- CHANGELOG.md | 2 +- CODE_OF_CONDUCT.md | 2 +- README.md | 4 ++-- SECURITY.md | 2 +- installer.iss | 2 +- pyproject.toml | 8 ++++---- voxflow/__init__.py | 4 ++-- voxflow/app.py | 2 +- 8 files changed, 13 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0df4aae..47c2f75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,4 +38,4 @@ wersjonowanie zgodne z [Semantic Versioning](https://semver.org/lang/pl/). --- -*Zbudowane przez [AI Evolution Polska](https://github.com/AI-Evolution-Polska)* +*Zbudowane przez [AI Evolution Polska](https://github.com/aievolutionpl)* diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 82408c2..0d77f22 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -28,4 +28,4 @@ Ten kodeks oparty jest na [Contributor Covenant v2.1](https://www.contributor-co --- -*Projekt [AI Evolution Polska](https://github.com/AI-Evolution-Polska)* +*Projekt [AI Evolution Polska](https://github.com/aievolutionpl)* diff --git a/README.md b/README.md index a366141..c3bc5e6 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Skrypt automatycznie: ```bash # Sklonuj repozytorium -git clone https://github.com/AI-Evolution-Polska/VoxFlow.git +git clone https://github.com/aievolutionpl/VoxFlow.git cd VoxFlow # Stwórz środowisko wirtualne @@ -239,6 +239,6 @@ Ten projekt jest udostępniony na licencji [MIT](LICENSE). ---

- Zbudowane z ❤️ przez AI Evolution Polska
+ Zbudowane z ❤️ przez AI Evolution Polska
Open-source tools for the Polish AI community

diff --git a/SECURITY.md b/SECURITY.md index 06e59e9..6697148 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -50,4 +50,4 @@ VoxFlow jest **w 100% lokalne**: --- -*Zbudowane przez [AI Evolution Polska](https://github.com/AI-Evolution-Polska)* +*Zbudowane przez [AI Evolution Polska](https://github.com/aievolutionpl)* diff --git a/installer.iss b/installer.iss index 6747072..ba32f4e 100644 --- a/installer.iss +++ b/installer.iss @@ -5,7 +5,7 @@ #define MyAppVersion "1.0.0" #define MyAppPublisher "AI Evolution Polska" #define MyAppExeName "VoxFlow.exe" -#define MyAppURL "https://github.com/AI-Evolution-Polska/VoxFlow" +#define MyAppURL "https://github.com/aievolutionpl/VoxFlow" [Setup] AppId={{B8E4F2A1-3C7D-4E9F-A5B6-1D2E3F4A5B6C} diff --git a/pyproject.toml b/pyproject.toml index 0930968..1a7defd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,10 +41,10 @@ dependencies = [ ] [project.urls] -Homepage = "https://github.com/AI-Evolution-Polska/VoxFlow" -Repository = "https://github.com/AI-Evolution-Polska/VoxFlow" -Issues = "https://github.com/AI-Evolution-Polska/VoxFlow/issues" -Changelog = "https://github.com/AI-Evolution-Polska/VoxFlow/blob/main/CHANGELOG.md" +Homepage = "https://github.com/aievolutionpl/VoxFlow" +Repository = "https://github.com/aievolutionpl/VoxFlow" +Issues = "https://github.com/aievolutionpl/VoxFlow/issues" +Changelog = "https://github.com/aievolutionpl/VoxFlow/blob/main/CHANGELOG.md" [project.scripts] voxflow = "voxflow.main:main" diff --git a/voxflow/__init__.py b/voxflow/__init__.py index d89aeea..15f6032 100644 --- a/voxflow/__init__.py +++ b/voxflow/__init__.py @@ -1,10 +1,10 @@ """VoxFlow - Local Speech-to-Text Application Built by AI Evolution Polska -https://github.com/AI-Evolution-Polska/VoxFlow +https://github.com/aievolutionpl/VoxFlow """ __version__ = "1.0.0" __app_name__ = "VoxFlow" __author__ = "AI Evolution Polska" __license__ = "MIT" -__url__ = "https://github.com/AI-Evolution-Polska/VoxFlow" +__url__ = "https://github.com/aievolutionpl/VoxFlow" diff --git a/voxflow/app.py b/voxflow/app.py index 14197b5..c1a1f19 100644 --- a/voxflow/app.py +++ b/voxflow/app.py @@ -1,7 +1,7 @@ """VoxFlow Main Application - Beautiful Desktop UI with Hold-to-Record. Built by AI Evolution Polska -https://github.com/AI-Evolution-Polska/VoxFlow +https://github.com/aievolutionpl/VoxFlow """ import threading import time From bb9241124d35c97621926f9cfa4db9cc8e9c5046 Mon Sep 17 00:00:00 2001 From: aichristabasco Date: Fri, 20 Feb 2026 18:31:53 +0000 Subject: [PATCH 2/5] feat: premium UI redesign, mic selection, hotkey picker, Windows/macOS installers - app.py: Complete UI redesign with darker premium palette, microphone device selector dropdown, interactive hotkey picker (press any key to set), scrollable history (up to 20 entries), clear transcript button, better animations on record button - config.py: Added audio_device_index field, expanded hotkeys (Ctrl+Space, CapsLock, Insert, Scroll Lock, Pause) - recorder.py: Added device_index param, set_device(), graceful fallback to default device on error, get_default_device_name() - hotkey_manager.py: Support for combo keys (Ctrl+Space etc.), added capture_next_key() for interactive hotkey recording - install.bat: Windows one-click setup script (Python check, venv, pip install) - install_mac.sh: macOS one-click setup (Homebrew, portaudio, Python, venv, VoxFlow.command launcher) - INSTALL_WINDOWS.md: Step-by-step Windows installation guide - INSTALL_MAC.md: macOS guide with Accessibility permissions instructions - README.md: GitHub-ready README with badges, bilingual feature table, model comparison, installation docs" --- .gitignore | 3 +- BUILD_EXE.bat | 25 +- INSTALL_MAC.md | 91 ++++ INSTALL_WINDOWS.md | 98 ++++ README.md | 270 ++++------- VoxFlow.spec | 56 +++ assets/voxflow.ico | Bin 6328 -> 15086 bytes assets/voxflow_256.png | Bin 2924 -> 72311 bytes install.bat | 126 +++++ install_mac.sh | 190 ++++++++ installer.iss | 2 +- voxflow/app.py | 954 +++++++++++++++++++++++++------------- voxflow/config.py | 19 +- voxflow/hotkey_manager.py | 83 +++- voxflow/main.py | 5 + voxflow/recorder.py | 57 ++- 16 files changed, 1422 insertions(+), 557 deletions(-) create mode 100644 INSTALL_MAC.md create mode 100644 INSTALL_WINDOWS.md create mode 100644 VoxFlow.spec create mode 100644 install.bat create mode 100644 install_mac.sh diff --git a/.gitignore b/.gitignore index 7ebcc17..f7763f2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,8 @@ __pycache__/ *.egg dist/ build/ -*.spec +# *.spec -> We want to track VoxFlow.spec +!VoxFlow.spec # Virtual environments venv/ diff --git a/BUILD_EXE.bat b/BUILD_EXE.bat index 9d49c64..b1bde3f 100644 --- a/BUILD_EXE.bat +++ b/BUILD_EXE.bat @@ -19,30 +19,7 @@ echo STEP 1: Building VoxFlow .exe echo ═══════════════════════════════════════════ echo. -pyinstaller ^ - --name "VoxFlow" ^ - --icon "assets\voxflow.ico" ^ - --onedir ^ - --windowed ^ - --noconfirm ^ - --clean ^ - --add-data "voxflow;voxflow" ^ - --add-data "assets;assets" ^ - --hidden-import "faster_whisper" ^ - --hidden-import "ctranslate2" ^ - --hidden-import "huggingface_hub" ^ - --hidden-import "tokenizers" ^ - --hidden-import "sounddevice" ^ - --hidden-import "customtkinter" ^ - --hidden-import "pystray" ^ - --hidden-import "keyboard" ^ - --hidden-import "pyperclip" ^ - --hidden-import "_sounddevice_data" ^ - --hidden-import "scipy" ^ - --collect-all "customtkinter" ^ - --collect-all "faster_whisper" ^ - --collect-all "ctranslate2" ^ - voxflow/main.py +pyinstaller VoxFlow.spec --noconfirm --clean if %errorlevel% neq 0 ( echo. diff --git a/INSTALL_MAC.md b/INSTALL_MAC.md new file mode 100644 index 0000000..76dc956 --- /dev/null +++ b/INSTALL_MAC.md @@ -0,0 +1,91 @@ +# 🎤 VoxFlow — Instalacja na macOS + +**VoxFlow** — bezpłatne, lokalne narzędzie do dyktowania głosem. 100% offline. +by [AI Evolution Polska](https://github.com/aievolutionpl) + +--- + +## ⚡ Instalacja jednym kliknięciem + +### Krok 1 — Pobierz VoxFlow + +```bash +git clone https://github.com/aievolutionpl/VoxFlow.git +cd VoxFlow +``` + +--- + +### Krok 2 — Uruchom instalator + +```bash +chmod +x install_mac.sh +./install_mac.sh +``` + +Skrypt automatycznie: +- ✅ Sprawdzi i zainstaluje [Homebrew](https://brew.sh) (jeśli brakuje) +- ✅ Zainstaluje `portaudio` (wymagane przez sounddevice) +- ✅ Sprawdzi Python 3.9+ (zainstaluje jeśli brakuje) +- ✅ Stworzy wirtualne środowisko (`venv`) +- ✅ Zainstaluje wszystkie biblioteki +- ✅ Stworzy launcher `VoxFlow.command` + +--- + +### Krok 3 — Uruchomienie + +Kliknij dwukrotnie plik `VoxFlow.command` lub uruchom: + +```bash +./VoxFlow.command +``` + +--- + +## ⚠️ Uprawnienia Accessibility (globalny hotkey) + +> [!IMPORTANT] +> Aby skrót klawiszowy (np. F2) działał we **wszystkich** aplikacjach, macOS wymaga przyznania uprawnień Accessibility. + +1. Otwórz **System Settings** → **Privacy & Security** → **Accessibility** +2. Kliknij `+` i dodaj: + - Terminal (jeśli uruchamiasz przez terminal) + - lub `VoxFlow.command` +3. Uruchom VoxFlow ponownie + +> [!NOTE] +> Bez tych uprawnień hotkey działa tylko gdy VoxFlow jest aktywną aplikacją na pierwszym planie. + +--- + +## 🍎 Apple Silicon (M1/M2/M3) + +Skrypt automatycznie wykrywa architekturę. Na Apple Silicon: +- Homebrew instaluje się do `/opt/homebrew/` +- `faster-whisper` obsługuje ARM64 natywnie +- Wydajność jest doskonała bez GPU + +--- + +## ❓ Częste problemy + +| Problem | Rozwiązanie | +|---------|-------------| +| `command not found: brew` | Zainstaluj [Homebrew](https://brew.sh) ręcznie | +| `portaudio` błąd | `brew install portaudio` | +| Python za stary | `brew install python@3.11` | +| Hotkey nie działa globalnie | Dodaj do Accessibility (powyżej) | +| Błąd pobierania modelu | Sprawdź połączenie internetowe | + +--- + +## 📋 Wymagania systemowe + +- macOS 11 Big Sur lub nowszy +- Intel lub Apple Silicon (M1/M2/M3) +- Python 3.9–3.12 +- Homebrew (instalowany automatycznie) +- 4 GB RAM (8 GB zalecane) +- Mikrofon +- Połączenie internetowe (pierwsze pobranie modelu ~500 MB) diff --git a/INSTALL_WINDOWS.md b/INSTALL_WINDOWS.md new file mode 100644 index 0000000..7bfd119 --- /dev/null +++ b/INSTALL_WINDOWS.md @@ -0,0 +1,98 @@ +# 🎤 VoxFlow — Instalacja na Windows + +**VoxFlow** — bezpłatne, lokalne narzędzie do dyktowania głosem. 100% offline. Brak chmury. +by [AI Evolution Polska](https://github.com/aievolutionpl) + +--- + +## ⚡ Instalacja jednym kliknięciem + +### Krok 1 — Pobierz Python 3.10+ + +> [!IMPORTANT] +> Python **musi** być zainstalowany przed uruchomieniem instalatora VoxFlow. +> **Zaznacz opcję "Add Python to PATH"** podczas instalacji! + +👉 [Pobierz Python ze python.org](https://www.python.org/downloads/) + +Sprawdź instalację (otwórz `cmd`): +``` +python --version +``` +Powinno wyświetlić: `Python 3.10.x` lub nowszy. + +--- + +### Krok 2 — Pobierz VoxFlow + +Pobierz i rozpakuj archiwum lub sklonuj repozytorium: + +```bash +git clone https://github.com/aievolutionpl/VoxFlow.git +cd VoxFlow +``` + +--- + +### Krok 3 — Uruchom instalator + +Kliknij dwukrotnie plik: + +``` +install.bat +``` + +Skrypt automatycznie: +- ✅ Sprawdzi wersję Python +- ✅ Stworzy wirtualne środowisko (`venv`) +- ✅ Zainstaluje wszystkie biblioteki +- ✅ Zweryfikuje instalację +- ✅ Zaproponuje natychmiastowe uruchomienie + +--- + +### Krok 4 — Uruchomienie + +Po instalacji kliknij: +``` +START_VOXFLOW.bat +``` + +Lub uruchom ręcznie: +```bash +venv\Scripts\activate +python -m voxflow.main +``` + +--- + +## 🔨 Budowanie instalatora .exe (opcjonalne) + +Jeśli chcesz stworzyć samodzielny plik `.exe` do dystrybucji: + +```bash +BUILD_EXE.bat +``` + +Wymagany [Inno Setup 6](https://jrsoftware.org/isinfo.php) do stworzenia instalatora `.exe`. + +--- + +## ❓ Częste problemy + +| Problem | Rozwiązanie | +|---------|-------------| +| `python` nie znany | Zainstaluj Python i dodaj do PATH | +| Błąd importu sounddevice | `pip install sounddevice` z C++ Redistributable | +| Hotkey nie działa | Uruchom jako Administrator | +| Błąd modelu Whisper | Sprawdź połączenie internetowe (tylko pierwsze pobieranie) | + +--- + +## 📋 Wymagania systemowe + +- Windows 10 / 11 (64-bit) +- Python 3.9–3.12 +- 4 GB RAM (8 GB zalecane dla modelu `medium`) +- Mikrofon +- Połączenie internetowe (tylko do pierwszego pobrania modelu ~500 MB) diff --git a/README.md b/README.md index c3bc5e6..dace4e2 100644 --- a/README.md +++ b/README.md @@ -1,244 +1,134 @@ -

- VoxFlow Logo -

- -

🎤 VoxFlow

- -

- Lokalne rozpoznawanie mowy — 100% offline, 100% prywatne
- Local Speech-to-Text for Windows • Polish & English -

- -

- Version - Python - License - Platform - AI Evolution Polska -

- -

- Offline - Polish - English - Built by -

+
---- +# 🎤 VoxFlow -## ✨ Co to jest VoxFlow? +### Dyktowanie głosem • 100% Offline • Open Source -**VoxFlow** to darmowa, w pełni lokalna aplikacja do zamiany mowy na tekst dla systemu Windows. Przytrzymaj klawisz **F2**, mów — a VoxFlow automatycznie wpisze tekst w aktywne okno. Żadne dane nie opuszczają Twojego komputera. +[![License: MIT](https://img.shields.io/badge/License-MIT-7c3aed.svg)](LICENSE) +[![Python](https://img.shields.io/badge/Python-3.9%2B-blue)](https://python.org) +[![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20macOS-lightgrey)](https://github.com/aievolutionpl/VoxFlow) +[![AI Evolution Polska](https://img.shields.io/badge/by-AI_Evolution_Polska-7c3aed)](https://github.com/aievolutionpl) -### 🎯 Kluczowe funkcje +**VoxFlow** to bezpłatne, open-source narzędzie do dyktowania głosem na komputer. +Zamień głos w tekst jednym przytrzymaniem klawisza — bez chmury, bez subskrypcji. -| Funkcja | Opis | -|---------|------| -| 🎤 **Hold-to-Record** | Przytrzymaj F2 (lub inny klawisz) i mów | -| 🇵🇱🇬🇧 **Polski i Angielski** | Automatyczne wykrywanie języka | -| 🔒 **100% Lokalne** | Zero internetu, zero chmury, zero śledzenia | -| ✍️ **Auto-wpisywanie** | Tekst trafia od razu do aktywnego okna | -| 📋 **Auto-kopiowanie** | Transkrypcja ląduje w schowku | -| 🧠 **5 modeli AI** | Od tiny (75 MB) po large-v3 (3 GB) | -| ✨ **Autokorekta** | Poprawia polskie znaki diakrytyczne | -| 🎯 **VAD** | Inteligentna detekcja mowy — ignoruje ciszę | -| 🔊 **Dźwięki** | Sygnał start/stop nagrywania | -| 🖥️ **System Tray** | Minimalizuj do zasobnika systemowego | -| 🔴 **Overlay REC** | Animowana nakładka "nagrywam" na ekranie | -| 📚 **Historia** | Ostatnie 10 transkrypcji z kopiowaniem | -| 🎨 **Ciemny motyw** | Piękny UI z animacjami | +[🪟 Instalacja Windows](#-instalacja--windows) • [🍎 Instalacja macOS](#-instalacja--macos) • [✨ Funkcje](#-funkcje) • [📖 Użytkowanie](#-użytkowanie) --- -## 🚀 Instalacja - -### Sposób 1: Pobierz gotowy .exe (zalecane) +> **Free & Open Source speech-to-text dictation for Windows and macOS.** +> Hold your hotkey → speak → text appears. 100% local, no cloud, no subscription. -1. Przejdź do [Releases](../../releases) -2. Pobierz `VoxFlow_Setup_v1.0.0.exe` (instalator) lub `VoxFlow-Portable.zip` (wersja portable) -3. Uruchom i gotowe! +
-### Sposób 2: Uruchomienie jednym kliknięciem (ze źródeł) - -``` -Kliknij dwukrotnie: START_VOXFLOW.bat -``` +--- -Skrypt automatycznie: -- ✅ Sprawdzi Python -- ✅ Stworzy środowisko wirtualne -- ✅ Zainstaluje pakiety -- ✅ Uruchomi VoxFlow +## ✨ Funkcje / Features + +| 🇵🇱 | 🇬🇧 | +|-----|-----| +| 🎤 Dyktowanie trzymając klawisz | Hold-to-record with configurable hotkey | +| 🌍 Polski + Angielski (auto-detekcja) | Polish + English (auto-detection) | +| ⚡ 100% lokalne — zero chmury | 100% local — zero cloud | +| 🧠 OpenAI Whisper (faster-whisper) | OpenAI Whisper (faster-whisper) | +| ✍️ Auto-wpisywanie w aktywne okno | Auto-type into active window | +| 🎙 Wybór urządzenia mikrofonowego | Microphone device selection | +| ⌨️ Dowolny klawisz (naciśnij aby ustawić) | Any hotkey (press to set) | +| 📋 Auto-kopiowanie do schowka | Auto-copy to clipboard | +| 🔲 Ikona w zasobniku systemowym | System tray icon | +| 📚 Historia nagrań | Recording history | +| ✨ Autokorekta tekstu | Text auto-correction | -### Sposób 3: Ręczne uruchomienie +--- -```bash -# Sklonuj repozytorium -git clone https://github.com/aievolutionpl/VoxFlow.git -cd VoxFlow +## 🪟 Instalacja — Windows -# Stwórz środowisko wirtualne -python -m venv venv -venv\Scripts\activate +### Wymagania +- Windows 10 / 11 (64-bit) +- **Python 3.9+** z opcją [Add Python to PATH](https://www.python.org/downloads/) +- Mikrofon -# Zainstaluj zależności -pip install -r requirements.txt +### Instalacja w 2 krokach -# Uruchom -python -m voxflow.main ``` - -### Sposób 4: pip install - -```bash -pip install . -voxflow +1. Pobierz Python 3.10+ ze strony python.org (zaznacz "Add Python to PATH") +2. Kliknij dwukrotnie install.bat ``` ---- - -## ⌨️ Jak używać - -1. **Uruchom VoxFlow** — okno aplikacji pojawi się na ekranie -2. **Przytrzymaj F2** (lub kliknij przycisk mikrofonu) -3. **Mów** — zobaczysz animację nagrywania -4. **Puść F2** — tekst zostanie automatycznie: - - 📝 Wyświetlony w oknie VoxFlow - - ✍️ Wpisany do aktywnego okna (jeśli włączone) - - 📋 Skopiowany do schowka (jeśli włączone) - -> 💡 **Tip**: VoxFlow działa w tle — możesz go zminimalizować do zasobnika systemowego. +Szczegółowa instrukcja: [INSTALL_WINDOWS.md](INSTALL_WINDOWS.md) --- -## 🧠 Modele Whisper +## 🍎 Instalacja — macOS -Przy pierwszym użyciu VoxFlow automatycznie pobierze wybrany model (~1 min dla `small`). +### Wymagania +- macOS 11+ (Intel lub Apple Silicon M1/M2/M3) +- Python 3.9+ (instalowany automatycznie) +- Mikrofon -| Model | Rozmiar | Jakość 🇵🇱 | Szybkość | Rekomendacja | -|-------|---------|-----------|----------|--------------| -| `tiny` | ~75 MB | ⭐⭐ | ⚡⚡⚡⚡⚡ | Szybkie testy | -| `base` | ~150 MB | ⭐⭐⭐ | ⚡⚡⚡⚡ | Podstawowe użycie | -| `small` | ~500 MB | ⭐⭐⭐⭐ | ⚡⚡⚡ | **Zalecany** ⬅️ | -| `medium` | ~1.5 GB | ⭐⭐⭐⭐⭐ | ⚡⚡ | Wysoka jakość | -| `large-v3` | ~3 GB | ⭐⭐⭐⭐⭐ | ⚡ | Maksymalna dokładność | +### Instalacja w 2 krokach -> 🎯 **Domyślnie**: `small` — najlepszy balans jakości i szybkości dla polskiego. +```bash +chmod +x install_mac.sh +./install_mac.sh +``` + +> ⚠️ Globalny hotkey wymaga uprawnień **Accessibility** w System Settings. +> Szczegóły: [INSTALL_MAC.md](INSTALL_MAC.md) --- -## ⚙️ Ustawienia +## 📖 Użytkowanie / Usage -Kliknij ⚙️ w oknie VoxFlow, aby otworzyć zaawansowane ustawienia: +1. **Uruchom VoxFlow** — pojawi się okno aplikacji i ikona w zasobniku +2. **Przytrzymaj klawisz** (domyślnie `F2`) i mów +3. **Zwolnij klawisz** — tekst pojawi się automatycznie w aktywnym oknie +4. **Zmień klawisz** — kliknij przycisk klawisza i naciśnij dowolny klawisz +5. **Zmień mikrofon** — użyj dropdown'u z listą urządzeń -| Ustawienie | Opis | Domyślnie | -|------------|------|-----------| -| **Model** | Rozmiar modelu Whisper | `small` | -| **Język** | Auto / PL / EN | `auto` | -| **Hotkey** | Klawisz hold-to-record | `F2` | -| **Auto-wpisywanie** | Wpisuj tekst do aktywnego okna | ✅ Włączone | -| **Auto-kopiowanie** | Kopiuj do schowka | ✅ Włączone | -| **Metoda wpisywania** | Clipboard (Ctrl+V) / Klawiatura | `clipboard` | -| **VAD** | Detekcja mowy | ✅ Włączona | -| **Autokorekta** | Poprawa tekstu | ✅ Włączona | -| **Dźwięki** | Sygnały start/stop | ✅ Włączone | -| **Beam size** | Dokładność transkrypcji (1-10) | `5` | -| **Autostart** | Uruchamiaj z Windows | ❌ Wyłączone | +### Obsługiwane klawisze +`F2` `F3` `F4` `F5` `F6` `F7` `F8` `F9` `F10` `Ctrl+Space` `CapsLock` `Insert` i inne --- -## 📁 Struktura projektu - -``` -VoxFlow/ -├── voxflow/ -│ ├── __init__.py # Metadata pakietu -│ ├── __main__.py # python -m voxflow -│ ├── main.py # Entry point + test mode -│ ├── app.py # Główne okno UI (CustomTkinter) -│ ├── config.py # Zarządzanie konfiguracją -│ ├── recorder.py # Nagrywanie audio (sounddevice) -│ ├── transcriber.py # Silnik Whisper (faster-whisper) -│ ├── post_processor.py # Autokorekta tekstu -│ ├── hotkey_manager.py # Global hotkey (hold-to-record) -│ ├── auto_typer.py # Auto-wpisywanie tekstu -│ ├── overlay.py # Overlay REC na ekranie -│ ├── tray.py # System tray -│ ├── sounds.py # Generowanie dźwięków -│ ├── autostart.py # Autostart Windows (rejestr) -│ ├── create_shortcut.py # Tworzenie skrótu na pulpicie -│ └── icon_gen.py # Generowanie ikony .ico -├── assets/ -│ ├── voxflow.ico # Ikona aplikacji -│ └── voxflow_256.png # Logo PNG -├── .github/workflows/ -│ └── ci.yml # GitHub Actions CI -├── requirements.txt # Zależności Python -├── pyproject.toml # Konfiguracja pakietu (PEP 621) -├── START_VOXFLOW.bat # Uruchom jednym kliknięciem -├── BUILD_EXE.bat # Zbuduj .exe -├── installer.iss # Instalator Inno Setup -├── LICENSE # Licencja MIT -├── CONTRIBUTING.md # Jak kontrybuować -├── CHANGELOG.md # Historia zmian -├── SECURITY.md # Polityka bezpieczeństwa -├── CODE_OF_CONDUCT.md # Kodeks postępowania -└── README.md # Ten plik -``` - ---- +## 🧠 Wybór modelu Whisper -## 🔧 Wymagania systemowe +| Model | Rozmiar | Szybkość | Jakość | Rekomendacja | +|-------|---------|----------|--------|--------------| +| `tiny` | ~75 MB | ⚡⚡⚡⚡ | ⭐⭐ | Testy | +| `base` | ~150 MB | ⚡⚡⚡ | ⭐⭐⭐ | Słaby PC | +| `small` | ~500 MB | ⚡⚡ | ⭐⭐⭐⭐ | **Zalecany** | +| `medium` | ~1.5 GB | ⚡ | ⭐⭐⭐⭐⭐ | Dobry PC | +| `large-v3` | ~3 GB | 🐢 | ⭐⭐⭐⭐⭐ | GPU | -- **System**: Windows 10/11 (x64) -- **Python**: 3.9 lub nowszy -- **RAM**: 2 GB+ (zależy od modelu) -- **Dysk**: ~500 MB (model `small`) — ~3 GB (model `large-v3`) -- **Mikrofon**: dowolny mikrofon USB/wbudowany -- **Internet**: tylko do pierwszego pobrania modelu; potem **100% offline** +> Pierwsza zmiana modelu wymaga pobrania jego pliku (jednorazowo). --- -## 🏗️ Budowanie .exe +## 🛠️ Budowanie .exe (Windows) ```bash -# Zbuduj standalone .exe za pomocą PyInstaller BUILD_EXE.bat - -# Gotowy plik: dist/VoxFlow/VoxFlow.exe ``` -Opcjonalnie, jeśli masz zainstalowane [Inno Setup 6](https://jrsoftware.org/isinfo.php), skrypt automatycznie zbuduje też instalator `.exe`. +Wymaga [Inno Setup 6](https://jrsoftware.org/isinfo.php) do stworzenia instalatora. --- -## 🔒 Prywatność i bezpieczeństwo - -VoxFlow jest **w 100% lokalne**: +## 📜 Licencja / License -- ❌ Żadne dane audio nie są wysyłane do internetu -- ❌ Żadna telemetria nie jest zbierana -- ❌ Żadne dane użytkownika nie opuszczają komputera -- ✅ Model AI działa w pełni lokalnie na Twoim CPU -- ✅ Konfiguracja przechowywana bezpiecznie w `%APPDATA%/VoxFlow/` - -Więcej informacji: [SECURITY.md](SECURITY.md) +MIT License — bezpłatny, open source. +Możesz używać, modyfikować i dystrybuować bez ograniczeń. --- -## 🤝 Współpraca +## 🤝 Twórca / Author -Chcesz pomóc? Świetnie! Przeczytaj [CONTRIBUTING.md](CONTRIBUTING.md) i otwórz Pull Request. +
---- +**AI Evolution Polska** +Bezpłatne narzędzia AI dla polskich użytkowników -## 📄 Licencja - -Ten projekt jest udostępniony na licencji [MIT](LICENSE). - ---- +[![GitHub](https://img.shields.io/badge/GitHub-aievolutionpl-181717?logo=github)](https://github.com/aievolutionpl) -

- Zbudowane z ❤️ przez AI Evolution Polska
- Open-source tools for the Polish AI community -

+
diff --git a/VoxFlow.spec b/VoxFlow.spec new file mode 100644 index 0000000..f27468b --- /dev/null +++ b/VoxFlow.spec @@ -0,0 +1,56 @@ +# -*- mode: python ; coding: utf-8 -*- +from PyInstaller.utils.hooks import collect_all + +datas = [('voxflow', 'voxflow'), ('assets', 'assets')] +binaries = [] +hiddenimports = ['faster_whisper', 'ctranslate2', 'huggingface_hub', 'tokenizers', 'sounddevice', 'customtkinter', 'pystray', 'keyboard', 'pyperclip', '_sounddevice_data', 'scipy', 'scipy.signal', 'PIL', 'PIL.Image', 'PIL.ImageDraw', 'PIL.ImageFilter'] +tmp_ret = collect_all('customtkinter') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +tmp_ret = collect_all('faster_whisper') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +tmp_ret = collect_all('ctranslate2') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] + + +a = Analysis( + ['voxflow\\main.py'], + pathex=[], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='VoxFlow', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=['assets\\voxflow.ico'], +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='VoxFlow', +) diff --git a/assets/voxflow.ico b/assets/voxflow.ico index 9f3a19d6c24e025a3a4e7fd8a41ebacf8160b04f..997d890ba46de7962fad83d5870ddbd8e9f54b90 100644 GIT binary patch literal 15086 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 literal 6328 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≈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 JPDHLkV1mf>B9;IE literal 2924 zcmYLLdpuO>8-LF+qnT4OO@mCr&(hA2l&Do<6mqM`HLT0(N=bB4GINH?hH_cAS}|5A zYEro*GDC~5)E1)>ri3U4BNJoJ9<`s(@BH(g=Xsy+^SsaVp6C0VbFSOAs33F%0HEUN zV7mhV2!DkDNf}@KBd_iOVE!6MTN{t)+%NC8M=tfGH3gr0{7cAgCGKY~6hP7Zj+M(aiCb#LdKlXmq?!>D@e zz(k37bZTZc;VP!V0H=>?XR==D@c^aULSn{(K<(X^fCvbA%3U;JOJIGTU!anFa72}} zfRjI>&90U1Wn&ZPzsYk>fz!(j4TA2|T>MvX6N;$i9;AOXwnz&8it;5yYS;~mD+#nG zto}_TE4@tAX_1)##Hl{9OI@4A8rIwA3r_1~9bH(=U7nxO`ygp$2)Tk0r*kZG5j#5F zzCb6Nn|aE^7&NM;q>HInO~WatY+vl&sCE680mBNSux-=x;_^eNSJU$UBC=$Q+)O>- zCIprIbbHqoLLv=_=N((_q#J`93b&DW&AI6`xd4)5 z4I%X2w=Ag4x^Bo`>|JA*^P!{HxSDVgPy*;@0uE9cvD)~xZMaXpdej?*!QaljK3ar3 zRE}-2z04vXIP4sL5dd+mQc(#&0L6rBbB@XbV2sK*q3ZyFH6D43T_cSmD#0(nc9-Ml zR^blO_!9!qd^a0h!e88p^yR_~;BZEOmH95xFPK2PaA}7LWa5s0B|Af3vP{|95o97D ze;Nt?+QZBg`jk;0S`b}9sv);V&y~s5zpeK0424i6Nn4@|zH_IS3Y$*$3 zT?!@Z|Lg}LVBEu5_<+WOgao{V3ybly_O}r|oOof;x_6HuJP>rjmt0XXS=2}aE;|gl z`liI|HV|k&XtCf52vTewShENg%f_mHlD~JX*m6{Xv$D;5Y{ukja zF+~9B6h&70My=uL;L_cdpGQ=KwTHD3VU2mryHA`C2Kuuf_QlTQ)mqDoqFZjV$XK}$oW4d;JN`)%W#{ePdp6ZLVzO8hm?w?@tYVxcuhPneq?G!HHvfg`yK^p zbMf~9DXJ+V1qr#$-gJXK>nNXg3i*L-%wSU<(un9P+E>hRP(3IGUL_o|@|?Z*hivi@ z4b0=B&jvspXX>Lshq69_EF^`MWxkm-OvV!NTk|O6t$$GhR2g9ND=*Js(K#YfXn`tE zMlb_(Ot8x>1VFF^{{FxPqSL&0whxD%kcrNXVlghj{@R(XUrt^R+-pojgAIG08j4mo z-4F~1d{G~&hHk3f%vS8p!3i<21`^|!+K(WPZ)*^OM(c`a#9BSln)zpvEbIO!?&kKMqc?OkLHR z?x?86^R3z5uog^jF%H?QCRNXJPDyAN#MUPsfEe758?(^?iRcK^`@xXOab*^H#lS5> zS+ajl}|61G!g}hkPY4DDU!jxSk>GI(ibp zJg&84Me+j{^0Mhrq1Z-ZulF|4Xq7zg{g(&X$YVcQZDSed#+f@qPq-(>T~RrfXT&v#zV)$zQG+=#?VRbH4R*|zLjp5- zO*7|QH6et~@Fs**D_&O~r#rbU3vC!RO;I4nA70WBqI#X`_tdLN&f(@64|+^x7T%m| z@Lg$>xT+vPGXt5GOsKqy<1po6UYs-S-L_EjYPE`3r^8=1%uf{O`_dJ;4Q=|}Ig&000_Eaq7sL((}@T511^Qm311s@>q zB-!?mZe5{|ti_41;D;;09jfxs3ks#>;Pe%`;tk$Zg<6V`rglM#x-bt=Pp_aS3GWw=`c(6~;-I-{jJuE1V8)vk8vMkQNYSk!AB%WmW-Awo*~&mxA~ z!uo$6TlX#i;qV~?xl;}Z$~!435qs~P#haI9=QQV?RUV7Yk(3H21qN|v=kl|8(1WuP z@B8j^$yBs9=#^Pv<~RhG7(jQQ3@;GHOm5i

>KCg{wMC=Z;6BE3|*OG6s}YGQ1rB zx2*y!VK2@xK~vbe&&d?r5S3hL@S0tXfZ%2E{OJ&U79`dUrQn>%j2vvat3txb6mWkv zMql<=j|}@GXczxuHW`qTe^8zfk6QjGg2uCid47@j82)ZPN3iQOzyLs(RrhD&)(706 zRYWkI)tB)f)XyIrPy)X?PdvgQRiz*Y29{xw--UZ~!m_Z5yZiCZN559}rQ&IH_QOF4 zc=P&_U>VsV*O;L?L6w)2&n(0LY=1n$m2n(s|BMGUHcgQ@r*gF0 KX3O6c6!$L?xdMRz diff --git a/install.bat b/install.bat new file mode 100644 index 0000000..c908047 --- /dev/null +++ b/install.bat @@ -0,0 +1,126 @@ +@echo off +chcp 65001 >nul +title VoxFlow — Instalacja (AI Evolution Polska) +echo. +echo ╔══════════════════════════════════════════════════════╗ +echo ║ VoxFlow — Instalacja jednym kliknięciem ║ +echo ║ by AI Evolution Polska ^| Open Source ║ +echo ╚══════════════════════════════════════════════════════╝ +echo. + +REM ─── Check Python ─────────────────────────────────────────────── +python --version >nul 2>&1 +if %errorlevel% neq 0 ( + echo ❌ Python nie jest zainstalowany! + echo. + echo Pobierz Python 3.10+ ze strony: + echo https://www.python.org/downloads/ + echo. + echo WAŻNE: Podczas instalacji zaznacz opcję "Add Python to PATH" + echo. + pause + start https://www.python.org/downloads/ + exit /b 1 +) + +for /f "tokens=2" %%v in ('python --version 2^>^&1') do set PY_VERSION=%%v +echo ✅ Python %PY_VERSION% znaleziony + +REM ─── Check Python version (need 3.9+) ─────────────────────────── +for /f "tokens=1,2 delims=." %%a in ("%PY_VERSION%") do ( + set PY_MAJOR=%%a + set PY_MINOR=%%b +) +if %PY_MAJOR% LSS 3 ( + echo ❌ Wymagany Python 3.9+, znaleziono %PY_VERSION% + pause + exit /b 1 +) +if %PY_MAJOR%==3 if %PY_MINOR% LSS 9 ( + echo ❌ Wymagany Python 3.9+, znaleziono %PY_VERSION% + pause + exit /b 1 +) + +echo. +echo ═══════════════════════════════════════════════════════ +echo KROK 1: Tworzenie środowiska wirtualnego (venv)... +echo ═══════════════════════════════════════════════════════ +echo. + +if exist venv ( + echo ℹ️ Środowisko venv już istnieje — pomijam tworzenie. +) else ( + python -m venv venv + if %errorlevel% neq 0 ( + echo ❌ Nie udało się stworzyć venv! + pause + exit /b 1 + ) + echo ✅ Środowisko venv stworzone. +) + +echo. +echo ═══════════════════════════════════════════════════════ +echo KROK 2: Instalacja wymaganych pakietów... +echo ═══════════════════════════════════════════════════════ +echo. + +call venv\Scripts\activate.bat + +pip install --upgrade pip --quiet --disable-pip-version-check +pip install -r requirements.txt --quiet --disable-pip-version-check + +if %errorlevel% neq 0 ( + echo. + echo ❌ Instalacja pakietów nie powiodła się! + echo. + echo Spróbuj ręcznie uruchomić: + echo venv\Scripts\activate + echo pip install -r requirements.txt + echo. + pause + exit /b 1 +) + +echo ✅ Wszystkie pakiety zainstalowane! + +echo. +echo ═══════════════════════════════════════════════════════ +echo KROK 3: Weryfikacja instalacji... +echo ═══════════════════════════════════════════════════════ +echo. + +python -m voxflow.main --test + +if %errorlevel% neq 0 ( + echo. + echo ⚠️ Weryfikacja nie powiodła się. Sprawdź komunikaty błędów powyżej. + echo. + pause + exit /b 1 +) + +echo. +echo ═══════════════════════════════════════════════════════ +echo ✅ VoxFlow zainstalowany pomyślnie! +echo. +echo Uruchom aplikację: +echo • Uruchom START_VOXFLOW.bat +echo • lub: venv\Scripts\activate ^& python -m voxflow.main +echo. +echo Skrót na pulpicie zostanie utworzony przy pierwszym +echo uruchomieniu START_VOXFLOW.bat +echo ═══════════════════════════════════════════════════════ +echo. + +set /p LAUNCH="Czy uruchomić VoxFlow teraz? [T/n]: " +if /i "%LAUNCH%"=="n" goto :end + +echo. +echo 🚀 Uruchamianie VoxFlow... +start "" pythonw -m voxflow.main +:end +deactivate +echo. +pause diff --git a/install_mac.sh b/install_mac.sh new file mode 100644 index 0000000..2678171 --- /dev/null +++ b/install_mac.sh @@ -0,0 +1,190 @@ +#!/bin/bash +# VoxFlow — Instalacja macOS (Intel & Apple Silicon) +# by AI Evolution Polska | Open Source +# ───────────────────────────────────────────────────────────────── + +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +echo "" +echo -e "${CYAN}${BOLD}" +echo " ╔══════════════════════════════════════════════════════╗" +echo " ║ VoxFlow — Instalacja macOS ║" +echo " ║ by AI Evolution Polska | Open Source ║" +echo " ╚══════════════════════════════════════════════════════╝" +echo -e "${NC}" +echo "" + +# ─── Check macOS ──────────────────────────────────────────────── +if [[ "$OSTYPE" != "darwin"* ]]; then + echo -e "${RED}❌ Ten skrypt jest przeznaczony dla macOS.${NC}" + echo " Dla Windows użyj install.bat" + exit 1 +fi + +ARCH=$(uname -m) +echo -e " ${CYAN}Architektura:${NC} $ARCH" +if [[ "$ARCH" == "arm64" ]]; then + echo -e " ${GREEN}✅ Apple Silicon (M1/M2/M3) wykryty${NC}" +else + echo -e " ${GREEN}✅ Intel Mac wykryty${NC}" +fi + +# ─── Check Homebrew ───────────────────────────────────────────── +echo "" +echo "══════════════════════════════════════════════════════" +echo " KROK 1: Sprawdzanie Homebrew..." +echo "══════════════════════════════════════════════════════" +echo "" + +if ! command -v brew &>/dev/null; then + echo -e "${YELLOW} ℹ️ Homebrew nie znaleziony. Instaluję...${NC}" + /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + + # Add brew to PATH for Apple Silicon + if [[ "$ARCH" == "arm64" ]]; then + echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> "$HOME/.zprofile" + eval "$(/opt/homebrew/bin/brew shellenv)" + fi + echo -e "${GREEN} ✅ Homebrew zainstalowany${NC}" +else + echo -e "${GREEN} ✅ Homebrew już zainstalowany${NC}" +fi + +# ─── Install PortAudio (needed by sounddevice) ────────────────── +echo "" +echo "══════════════════════════════════════════════════════" +echo " KROK 2: Instalacja bibliotek audio (portaudio)..." +echo "══════════════════════════════════════════════════════" +echo "" + +if brew list portaudio &>/dev/null; then + echo -e "${GREEN} ✅ portaudio już zainstalowany${NC}" +else + brew install portaudio + echo -e "${GREEN} ✅ portaudio zainstalowany${NC}" +fi + +# ─── Check Python 3.9+ ───────────────────────────────────────── +echo "" +echo "══════════════════════════════════════════════════════" +echo " KROK 3: Sprawdzanie Python 3.9+..." +echo "══════════════════════════════════════════════════════" +echo "" + +PYTHON_CMD="" +for cmd in python3.12 python3.11 python3.10 python3.9 python3; do + if command -v $cmd &>/dev/null; then + VERSION=$($cmd --version 2>&1 | awk '{print $2}') + MAJOR=$(echo $VERSION | cut -d. -f1) + MINOR=$(echo $VERSION | cut -d. -f2) + if [[ $MAJOR -eq 3 && $MINOR -ge 9 ]]; then + PYTHON_CMD=$cmd + echo -e "${GREEN} ✅ Python $VERSION — używam $cmd${NC}" + break + fi + fi +done + +if [[ -z "$PYTHON_CMD" ]]; then + echo -e "${YELLOW} ℹ️ Python 3.9+ nie znaleziony. Instaluję przez Homebrew...${NC}" + brew install python@3.11 + PYTHON_CMD="python3.11" + echo -e "${GREEN} ✅ Python 3.11 zainstalowany${NC}" +fi + +# ─── Create venv ──────────────────────────────────────────────── +echo "" +echo "══════════════════════════════════════════════════════" +echo " KROK 4: Tworzenie środowiska wirtualnego..." +echo "══════════════════════════════════════════════════════" +echo "" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +if [[ -d venv ]]; then + echo -e " ${CYAN}ℹ️ Środowisko venv już istnieje${NC}" +else + $PYTHON_CMD -m venv venv + echo -e "${GREEN} ✅ Środowisko venv stworzone${NC}" +fi + +source venv/bin/activate + +# ─── Install requirements ──────────────────────────────────────── +echo "" +echo "══════════════════════════════════════════════════════" +echo " KROK 5: Instalacja wymaganych pakietów..." +echo " (pierwsze uruchomienie może potrwać kilka minut)" +echo "══════════════════════════════════════════════════════" +echo "" + +pip install --upgrade pip --quiet +pip install -r requirements.txt --quiet + +echo -e "${GREEN} ✅ Pakiety zainstalowane${NC}" + +# ─── Verify ───────────────────────────────────────────────────── +echo "" +echo "══════════════════════════════════════════════════════" +echo " KROK 6: Weryfikacja instalacji..." +echo "══════════════════════════════════════════════════════" +echo "" + +python -m voxflow.main --test + +# ─── Create launcher ───────────────────────────────────────────── +echo "" +echo "══════════════════════════════════════════════════════" +echo " KROK 7: Tworzenie skrótu uruchamiania..." +echo "══════════════════════════════════════════════════════" +echo "" + +LAUNCHER="$SCRIPT_DIR/VoxFlow.command" +cat > "$LAUNCHER" << 'EOF' +#!/bin/bash +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" +source venv/bin/activate +python -m voxflow.main +EOF +chmod +x "$LAUNCHER" +echo -e "${GREEN} ✅ Launcher VoxFlow.command stworzony${NC}" + +# ─── Accessibility warning (macOS keyboard hook) ───────────────── +echo "" +echo -e "${YELLOW} ⚠️ WAŻNA INFORMACJA — Uprawnienia Accessibility:${NC}" +echo "" +echo " Aby globalny hotkey (np. F2) działał we WSZYSTKICH aplikacjach," +echo " musisz nadać VoxFlow uprawnienia Accessibility:" +echo "" +echo " 1. Otwórz: Apple Menu → System Settings → Privacy & Security" +echo " 2. Kliknij: Accessibility" +echo " 3. Dodaj Terminal lub VoxFlow.command do listy" +echo "" +echo " Bez tych uprawnień: hotkey działa tylko gdy VoxFlow jest na pierwszym planie." +echo "" + +# ─── Done ──────────────────────────────────────────────────────── +echo "══════════════════════════════════════════════════════" +echo -e "${GREEN}${BOLD} ✅ VoxFlow zainstalowany pomyślnie!${NC}" +echo "" +echo " Uruchom aplikację:" +echo " • Kliknij dwukrotnie: VoxFlow.command" +echo " • lub w terminalu: ./VoxFlow.command" +echo "══════════════════════════════════════════════════════" +echo "" + +read -p " Uruchomić VoxFlow teraz? [T/n]: " LAUNCH +if [[ "$LAUNCH" != "n" && "$LAUNCH" != "N" ]]; then + echo "" + echo -e " ${CYAN}🚀 Uruchamianie VoxFlow...${NC}" + python -m voxflow.main & +fi diff --git a/installer.iss b/installer.iss index ba32f4e..ef41a39 100644 --- a/installer.iss +++ b/installer.iss @@ -18,7 +18,7 @@ DefaultDirName={autopf}\{#MyAppName} DefaultGroupName={#MyAppName} DisableProgramGroupPage=yes OutputDir=installer_output -OutputBaseFilename=VoxFlow_Setup_v{#MyAppVersion} +OutputBaseFilename=VoxFlow_Setup_v1.0.0 SetupIconFile=assets\voxflow.ico UninstallDisplayIcon={app}\{#MyAppExeName} Compression=lzma2 diff --git a/voxflow/app.py b/voxflow/app.py index c1a1f19..bed23fb 100644 --- a/voxflow/app.py +++ b/voxflow/app.py @@ -1,4 +1,4 @@ -"""VoxFlow Main Application - Beautiful Desktop UI with Hold-to-Record. +"""VoxFlow Main Application - Premium Desktop UI with Hold-to-Record. Built by AI Evolution Polska https://github.com/aievolutionpl/VoxFlow @@ -26,21 +26,24 @@ # ─── Color Palette ──────────────────────────────────────────────────────────── C = { - "bg": "#0c0c18", - "bg_card": "#161630", - "bg_hover": "#1e1e42", - "bg_input": "#12122a", - "accent": "#8b5cf6", + "bg": "#09091a", + "bg_card": "#12122b", + "bg_hover": "#1a1a3a", + "bg_input": "#0e0e22", + "accent": "#7c3aed", "accent2": "#a78bfa", - "accent_dim": "#6d28d9", + "accent3": "#c4b5fd", + "accent_dim": "#5b21b6", + "accent_glow": "#4c1d95", "rec_red": "#ef4444", "rec_glow": "#dc2626", "ok": "#22c55e", "warn": "#f59e0b", "txt": "#f1f5f9", "txt2": "#94a3b8", - "txt3": "#64748b", - "border": "#2a2a50", + "txt3": "#475569", + "border": "#1e1e42", + "border2": "#2d2d5e", } @@ -56,7 +59,7 @@ def __init__(self): self.title("VoxFlow") self.geometry(f"{self.config.window_width}x{self.config.window_height}") - self.minsize(440, 620) + self.minsize(460, 700) self.configure(fg_color=C["bg"]) # ─── State ──────────────────────────────────────────────── @@ -67,6 +70,8 @@ def __init__(self): self._history: list[dict] = [] self._alive = True self._settings_visible = False + self._capturing_hotkey = False + self._audio_devices: list[dict] = [] # ─── Engine ─────────────────────────────────────────────── self.recorder = AudioRecorder( @@ -75,6 +80,7 @@ def __init__(self): silence_threshold=self.config.silence_threshold, silence_duration=self.config.silence_duration, max_duration=self.config.max_recording_duration, + device_index=self.config.audio_device_index, on_level_change=self._on_level, ) @@ -100,6 +106,7 @@ def __init__(self): self.overlay = RecordingOverlay() # ─── Build ──────────────────────────────────────────────── + self._load_audio_devices() self._build_ui() self._start_services() @@ -107,16 +114,52 @@ def __init__(self): self._animate() # ═══════════════════════════════════════════════════════════════ - # UI + # DEVICE ENUMERATION + # ═══════════════════════════════════════════════════════════════ + + def _load_audio_devices(self): + """Load list of available audio input devices.""" + try: + self._audio_devices = AudioRecorder.list_devices() + except Exception: + self._audio_devices = [] + + def _get_device_names(self) -> list[str]: + """Return device display names for UI dropdown.""" + names = ["🎤 Domyślny mikrofon"] + for d in self._audio_devices: + name = d["name"] + if len(name) > 38: + name = name[:35] + "..." + names.append(f"[{d['index']}] {name}") + return names + + def _get_current_device_label(self) -> str: + """Get label for the currently selected device.""" + idx = self.config.audio_device_index + if idx < 0: + return "🎤 Domyślny mikrofon" + for d in self._audio_devices: + if d["index"] == idx: + name = d["name"] + if len(name) > 38: + name = name[:35] + "..." + return f"[{idx}] {name}" + return "🎤 Domyślny mikrofon" + + # ═══════════════════════════════════════════════════════════════ + # UI BUILDER # ═══════════════════════════════════════════════════════════════ def _build_ui(self): + # Outer scrollable container self.main = ctk.CTkFrame(self, fg_color="transparent") - self.main.pack(fill="both", expand=True, padx=20, pady=10) + self.main.pack(fill="both", expand=True, padx=18, pady=(8, 6)) self._build_header() self._build_record_btn() self._build_level_bar() + self._build_mic_selector() self._build_status() self._build_transcript() self._build_quick_controls() @@ -124,43 +167,76 @@ def _build_ui(self): self._build_settings_panel() self._build_footer() + # ── Header ─────────────────────────────────────────────────── + def _build_header(self): f = ctk.CTkFrame(self.main, fg_color="transparent") - f.pack(fill="x", pady=(8, 2)) + f.pack(fill="x", pady=(6, 4)) row = ctk.CTkFrame(f, fg_color="transparent") row.pack(fill="x") - ctk.CTkLabel(row, text="🎤 VoxFlow", - font=ctk.CTkFont(family="Segoe UI", size=30, weight="bold"), - text_color=C["txt"]).pack(side="left") + # Logo + title + logo_frame = ctk.CTkFrame(row, fg_color="transparent") + logo_frame.pack(side="left") + + ctk.CTkLabel( + logo_frame, text="🎤", + font=ctk.CTkFont(size=28), + ).pack(side="left", padx=(0, 6)) + + ctk.CTkLabel( + logo_frame, text="VoxFlow", + font=ctk.CTkFont(family="Segoe UI", size=26, weight="bold"), + text_color=C["txt"], + ).pack(side="left") + + ctk.CTkLabel( + logo_frame, text=f"v{__version__}", + font=ctk.CTkFont(size=10), + text_color=C["accent3"], + fg_color=C["bg_card"], + corner_radius=6, + padx=6, pady=1, + ).pack(side="left", padx=(8, 0), pady=(4, 0)) + + # Right buttons + btn_frame = ctk.CTkFrame(row, fg_color="transparent") + btn_frame.pack(side="right") - # Version badge - ctk.CTkLabel(row, text=f"v{__version__}", - font=ctk.CTkFont(size=10), - text_color=C["accent2"], - fg_color=C["bg_card"], - corner_radius=6, - padx=6, pady=1).pack(side="left", padx=(8, 0)) - - # Settings gear button self.settings_btn = ctk.CTkButton( - row, text="⚙️", width=36, height=36, - font=ctk.CTkFont(size=16), - fg_color=C["bg_card"], hover_color=C["accent"], + btn_frame, text="⚙", width=34, height=34, + font=ctk.CTkFont(size=15), + fg_color=C["bg_card"], hover_color=C["accent_dim"], + border_width=1, border_color=C["border2"], corner_radius=10, command=self._toggle_settings, ) self.settings_btn.pack(side="right") - ctk.CTkLabel(f, text="Przytrzymaj F2 aby dyktować • 100% offline", - font=ctk.CTkFont(size=12), text_color=C["txt2"]).pack(anchor="w", pady=(2, 0)) + # Subtitle + hk_display = self.config.hotkey.upper().replace("+", " + ") + self.subtitle_label = ctk.CTkLabel( + f, + text=f"Przytrzymaj {hk_display} aby dyktować • 100% offline", + font=ctk.CTkFont(family="Segoe UI", size=11), + text_color=C["txt3"], + ) + self.subtitle_label.pack(anchor="w", pady=(2, 0)) + + # Thin separator line + ctk.CTkFrame(self.main, fg_color=C["border"], height=1).pack(fill="x", pady=(4, 0)) + + # ── Record Button (canvas) ──────────────────────────────────── def _build_record_btn(self): f = ctk.CTkFrame(self.main, fg_color="transparent") - f.pack(fill="x", pady=12) + f.pack(fill="x", pady=(14, 4)) - sz = 150 - self.canvas = ctk.CTkCanvas(f, width=sz, height=sz, bg=C["bg"], highlightthickness=0) + sz = 160 + self.canvas = ctk.CTkCanvas( + f, width=sz, height=sz, + bg=C["bg"], highlightthickness=0, + ) self.canvas.pack() self.canvas.bind("", lambda e: self._start_rec()) self.canvas.bind("", lambda e: self._stop_rec()) @@ -170,301 +246,410 @@ def _build_record_btn(self): def _draw_btn(self): self.canvas.delete("all") cx = cy = self._csz // 2 - r = 52 + r = 55 if self._recording: - # Glow rings + # Outer glow rings for i in range(3): - p = self._phase + i * 0.8 - er = r + 10 + i * 11 + ((math.sin(p) + 1) / 2) * 8 - self.canvas.create_oval(cx-er, cy-er, cx+er, cy+er, - fill="", outline=C["rec_red"], width=max(1, 3-i)) - self.canvas.create_oval(cx-r, cy-r, cx+r, cy+r, - fill=C["rec_red"], outline=C["rec_glow"], width=3) + p = self._phase + i * 0.9 + er = r + 12 + i * 12 + ((math.sin(p) + 1) / 2) * 8 + alpha_hex = ["40", "28", "18"][i] + self.canvas.create_oval( + cx - er, cy - er, cx + er, cy + er, + fill="", outline=C["rec_red"], width=max(1, 3 - i) + ) + # Main circle + self.canvas.create_oval( + cx - r, cy - r, cx + r, cy + r, + fill=C["rec_red"], outline="#b91c1c", width=3, + ) # Waveform bars - for i in range(7): - bx = cx - 24 + i * 8 - amp = abs(math.sin(self._phase * 2 + i * 0.7)) * 18 + 4 - self.canvas.create_rectangle(bx-2, cy-amp, bx+2, cy+amp, - fill="white", outline="") + for i in range(9): + bx = cx - 28 + i * 7 + amp = abs(math.sin(self._phase * 2 + i * 0.65)) * 20 + 5 + lvl_boost = self._level * 15 + amp = min(amp + lvl_boost, 26) + self.canvas.create_rectangle( + bx - 2, cy - amp, bx + 2, cy + amp, + fill="white", outline="", + ) + elif self._processing: - self.canvas.create_oval(cx-r, cy-r, cx+r, cy+r, - fill=C["warn"], outline="#d97706", width=3) + # Processing spinner + self.canvas.create_oval( + cx - r, cy - r, cx + r, cy + r, + fill=C["warn"], outline="#b45309", width=3, + ) for i in range(8): a = self._phase * 3 + i * (math.pi / 4) - dx, dy = cx + math.cos(a) * 25, cy + math.sin(a) * 25 - self.canvas.create_oval(dx-4, dy-4, dx+4, dy+4, fill="white", outline="") + dx = cx + math.cos(a) * 28 + dy = cy + math.sin(a) * 28 + size = 5 - i * 0.3 + self.canvas.create_oval( + dx - size, dy - size, dx + size, dy + size, + fill="white", outline="", + ) + self.canvas.create_text( + cx, cy, text="AI", + fill="white", + font=("Segoe UI", 13, "bold"), + ) + else: - gf = (math.sin(self._phase * 0.4) + 1) / 2 - gr = r + 4 + gf * 5 - self.canvas.create_oval(cx-gr, cy-gr, cx+gr, cy+gr, - fill="", outline=C["accent"], width=2) - self.canvas.create_oval(cx-r, cy-r, cx+r, cy+r, - fill=C["accent"], outline=C["accent2"], width=3) - # Mic icon - self.canvas.create_oval(cx-10, cy-24, cx+10, cy+0, fill="white", outline="") - self.canvas.create_line(cx, cy, cx, cy+14, fill="white", width=3) - self.canvas.create_line(cx-10, cy+14, cx+10, cy+14, fill="white", width=3) - self.canvas.create_arc(cx-16, cy-12, cx+16, cy+8, - start=200, extent=140, style="arc", outline="white", width=2) + # Idle — gentle pulse + gf = (math.sin(self._phase * 0.35) + 1) / 2 + # Outer glow + gr = r + 6 + gf * 6 + self.canvas.create_oval( + cx - gr, cy - gr, cx + gr, cy + gr, + fill="", outline=C["accent"], width=2, + ) + # Second glow ring + gr2 = r + 2 + gf * 3 + self.canvas.create_oval( + cx - gr2, cy - gr2, cx + gr2, cy + gr2, + fill="", outline=C["accent2"], width=1, + ) + # Main circle + self.canvas.create_oval( + cx - r, cy - r, cx + r, cy + r, + fill=C["accent"], outline=C["accent2"], width=3, + ) + # Mic body + self.canvas.create_oval(cx - 11, cy - 26, cx + 11, cy + 2, fill="white", outline="") + # Mic stand + self.canvas.create_line(cx, cy + 2, cx, cy + 16, fill="white", width=3) + self.canvas.create_line(cx - 11, cy + 16, cx + 11, cy + 16, fill="white", width=3) + # Mic arc + self.canvas.create_arc( + cx - 18, cy - 14, cx + 18, cy + 10, + start=200, extent=140, style="arc", outline="white", width=2, + ) + + # ── Level Bar ───────────────────────────────────────────────── def _build_level_bar(self): f = ctk.CTkFrame(self.main, fg_color="transparent") - f.pack(fill="x", pady=(0, 4)) - self.level_bar = ctk.CTkProgressBar(f, height=5, corner_radius=3, - fg_color=C["bg_card"], progress_color=C["accent"], border_width=0) - self.level_bar.pack(fill="x", padx=25) + f.pack(fill="x", pady=(0, 2)) + self.level_bar = ctk.CTkProgressBar( + f, height=4, corner_radius=2, + fg_color=C["bg_card"], + progress_color=C["accent"], + border_width=0, + ) + self.level_bar.pack(fill="x", padx=30) self.level_bar.set(0) + # ── Microphone Selector ─────────────────────────────────────── + + def _build_mic_selector(self): + card = ctk.CTkFrame( + self.main, fg_color=C["bg_card"], + corner_radius=10, + border_width=1, border_color=C["border"], + ) + card.pack(fill="x", pady=(6, 4)) + + inner = ctk.CTkFrame(card, fg_color="transparent") + inner.pack(fill="x", padx=12, pady=8) + + ctk.CTkLabel( + inner, text="🎙 Mikrofon", + font=ctk.CTkFont(size=11, weight="bold"), + text_color=C["txt2"], + ).pack(side="left", padx=(0, 8)) + + device_names = self._get_device_names() + current = self._get_current_device_label() + if current not in device_names: + current = device_names[0] + + self.mic_var = ctk.StringVar(value=current) + self.mic_menu = ctk.CTkOptionMenu( + inner, + values=device_names, + variable=self.mic_var, + font=ctk.CTkFont(size=11), + fg_color=C["bg_input"], + button_color=C["accent_dim"], + button_hover_color=C["accent"], + dropdown_fg_color=C["bg_card"], + corner_radius=8, + command=self._on_mic_change, + dynamic_resizing=False, + width=280, + ) + self.mic_menu.pack(side="right", expand=True, fill="x", padx=(0, 0)) + + # ── Status Label ────────────────────────────────────────────── + def _build_status(self): f = ctk.CTkFrame(self.main, fg_color="transparent") - f.pack(fill="x", pady=(0, 8)) - hk = self.config.hotkey.upper() - self.status = ctk.CTkLabel(f, text=f"✨ Gotowy — Przytrzymaj {hk} lub kliknij przycisk", - font=ctk.CTkFont(size=12), text_color=C["txt2"]) + f.pack(fill="x", pady=(4, 6)) + hk = self.config.hotkey.upper().replace("+", " + ") + self.status = ctk.CTkLabel( + f, + text=f"✨ Gotowy — Przytrzymaj {hk} lub kliknij przycisk", + font=ctk.CTkFont(family="Segoe UI", size=12), + text_color=C["txt2"], + ) self.status.pack() + # ── Transcript Box ──────────────────────────────────────────── + def _build_transcript(self): - card = ctk.CTkFrame(self.main, fg_color=C["bg_card"], corner_radius=12, - border_width=1, border_color=C["border"]) - card.pack(fill="both", expand=True, pady=(0, 8)) + card = ctk.CTkFrame( + self.main, fg_color=C["bg_card"], + corner_radius=12, + border_width=1, border_color=C["border"], + ) + card.pack(fill="both", expand=True, pady=(0, 6)) head = ctk.CTkFrame(card, fg_color="transparent") head.pack(fill="x", padx=14, pady=(10, 0)) - ctk.CTkLabel(head, text="📝 Transkrypcja", - font=ctk.CTkFont(size=13, weight="bold"), - text_color=C["txt2"]).pack(side="left") - - self.copy_btn = ctk.CTkButton(head, text="📋 Kopiuj", width=75, height=26, - font=ctk.CTkFont(size=11), - fg_color=C["bg_hover"], hover_color=C["accent"], - corner_radius=8, command=self._copy_text) - self.copy_btn.pack(side="right") - - self.textbox = ctk.CTkTextbox(card, font=ctk.CTkFont(family="Segoe UI", size=15), - fg_color="transparent", text_color=C["txt"], - wrap="word", height=100, border_width=0) - self.textbox.pack(fill="both", expand=True, padx=14, pady=(4, 10)) + ctk.CTkLabel( + head, text="📝 Transkrypcja", + font=ctk.CTkFont(size=12, weight="bold"), + text_color=C["txt2"], + ).pack(side="left") + + btn_row = ctk.CTkFrame(head, fg_color="transparent") + btn_row.pack(side="right") + + ctk.CTkButton( + btn_row, text="🗑", width=30, height=26, + font=ctk.CTkFont(size=11), + fg_color=C["bg_hover"], hover_color=C["rec_red"], + corner_radius=8, command=self._clear_transcript, + ).pack(side="right", padx=(4, 0)) + + ctk.CTkButton( + btn_row, text="📋 Kopiuj", width=80, height=26, + font=ctk.CTkFont(size=11), + fg_color=C["bg_hover"], hover_color=C["accent"], + corner_radius=8, command=self._copy_text, + ).pack(side="right") + + self.textbox = ctk.CTkTextbox( + card, + font=ctk.CTkFont(family="Segoe UI", size=14), + fg_color="transparent", + text_color=C["txt"], + wrap="word", height=90, border_width=0, + ) + self.textbox.pack(fill="both", expand=True, padx=14, pady=(6, 10)) self.textbox.insert("1.0", "Twój tekst pojawi się tutaj...") self.textbox.configure(state="disabled") + # ── Quick Controls (language / model / hotkey) ───────────────── + def _build_quick_controls(self): - card = ctk.CTkFrame(self.main, fg_color=C["bg_card"], corner_radius=12, - border_width=1, border_color=C["border"]) - card.pack(fill="x", pady=(0, 8)) + card = ctk.CTkFrame( + self.main, fg_color=C["bg_card"], + corner_radius=12, + border_width=1, border_color=C["border"], + ) + card.pack(fill="x", pady=(0, 6)) inner = ctk.CTkFrame(card, fg_color="transparent") - inner.pack(fill="x", padx=14, pady=10) + inner.pack(fill="x", padx=12, pady=10) # Language lf = ctk.CTkFrame(inner, fg_color="transparent") - lf.pack(side="left", expand=True, fill="x", padx=(0, 6)) - ctk.CTkLabel(lf, text="🌍 Język", font=ctk.CTkFont(size=11, weight="bold"), - text_color=C["txt2"]).pack(anchor="w") + lf.pack(side="left", expand=True, fill="x", padx=(0, 5)) + ctk.CTkLabel(lf, text="🌍 Język", font=ctk.CTkFont(size=10, weight="bold"), + text_color=C["txt3"]).pack(anchor="w") self.lang_var = ctk.StringVar(value=self.config.language) - ctk.CTkOptionMenu(lf, values=["auto", "pl", "en"], variable=self.lang_var, - font=ctk.CTkFont(size=12), fg_color=C["bg_input"], - button_color=C["accent"], button_hover_color=C["accent2"], - dropdown_fg_color=C["bg_card"], corner_radius=8, - command=self._on_lang).pack(fill="x", pady=(3, 0)) + ctk.CTkOptionMenu( + lf, values=["auto", "pl", "en"], + variable=self.lang_var, + font=ctk.CTkFont(size=11), + fg_color=C["bg_input"], + button_color=C["accent_dim"], + button_hover_color=C["accent"], + dropdown_fg_color=C["bg_card"], + corner_radius=8, + command=self._on_lang, + ).pack(fill="x", pady=(3, 0)) # Model mf = ctk.CTkFrame(inner, fg_color="transparent") - mf.pack(side="left", expand=True, fill="x", padx=(6, 6)) - ctk.CTkLabel(mf, text="🧠 Model", font=ctk.CTkFont(size=11, weight="bold"), - text_color=C["txt2"]).pack(anchor="w") + mf.pack(side="left", expand=True, fill="x", padx=(5, 5)) + ctk.CTkLabel(mf, text="🧠 Model", font=ctk.CTkFont(size=10, weight="bold"), + text_color=C["txt3"]).pack(anchor="w") self.model_var = ctk.StringVar(value=self.config.model_size) - ctk.CTkOptionMenu(mf, values=self.config.available_models, variable=self.model_var, - font=ctk.CTkFont(size=12), fg_color=C["bg_input"], - button_color=C["accent"], button_hover_color=C["accent2"], - dropdown_fg_color=C["bg_card"], corner_radius=8, - command=self._on_model).pack(fill="x", pady=(3, 0)) - - # Hotkey + ctk.CTkOptionMenu( + mf, values=self.config.available_models, + variable=self.model_var, + font=ctk.CTkFont(size=11), + fg_color=C["bg_input"], + button_color=C["accent_dim"], + button_hover_color=C["accent"], + dropdown_fg_color=C["bg_card"], + corner_radius=8, + command=self._on_model, + ).pack(fill="x", pady=(3, 0)) + + # Hotkey picker hf = ctk.CTkFrame(inner, fg_color="transparent") - hf.pack(side="left", expand=True, fill="x", padx=(6, 0)) - ctk.CTkLabel(hf, text="⌨️ Klawisz", font=ctk.CTkFont(size=11, weight="bold"), - text_color=C["txt2"]).pack(anchor="w") - self.hotkey_var = ctk.StringVar(value=self.config.hotkey) - ctk.CTkOptionMenu(hf, values=self.config.available_hotkeys, variable=self.hotkey_var, - font=ctk.CTkFont(size=12), fg_color=C["bg_input"], - button_color=C["accent"], button_hover_color=C["accent2"], - dropdown_fg_color=C["bg_card"], corner_radius=8, - command=self._on_hotkey_change).pack(fill="x", pady=(3, 0)) + hf.pack(side="left", expand=True, fill="x", padx=(5, 0)) + ctk.CTkLabel(hf, text="⌨️ Klawisz", font=ctk.CTkFont(size=10, weight="bold"), + text_color=C["txt3"]).pack(anchor="w") + self.hotkey_btn = ctk.CTkButton( + hf, + text=self.config.hotkey.upper().replace("+", "+"), + height=32, + font=ctk.CTkFont(size=11, weight="bold"), + fg_color=C["bg_input"], + hover_color=C["accent_dim"], + border_width=1, + border_color=C["border2"], + corner_radius=8, + command=self._start_hotkey_capture, + ) + self.hotkey_btn.pack(fill="x", pady=(3, 0)) + + # ── History ─────────────────────────────────────────────────── def _build_history(self): - card = ctk.CTkFrame(self.main, fg_color=C["bg_card"], corner_radius=12, - border_width=1, border_color=C["border"]) - card.pack(fill="x", pady=(0, 8)) + card = ctk.CTkFrame( + self.main, fg_color=C["bg_card"], + corner_radius=12, + border_width=1, border_color=C["border"], + ) + card.pack(fill="x", pady=(0, 6)) head = ctk.CTkFrame(card, fg_color="transparent") head.pack(fill="x", padx=14, pady=(10, 4)) - ctk.CTkLabel(head, text="📚 Historia", - font=ctk.CTkFont(size=13, weight="bold"), - text_color=C["txt2"]).pack(side="left") - - ctk.CTkButton(head, text="🗑️", width=30, height=26, - font=ctk.CTkFont(size=10), fg_color=C["bg_hover"], - hover_color=C["rec_red"], corner_radius=6, - command=self._clear_history).pack(side="right") + ctk.CTkLabel( + head, text="📚 Historia", + font=ctk.CTkFont(size=12, weight="bold"), + text_color=C["txt2"], + ).pack(side="left") + + ctk.CTkButton( + head, text="🗑", width=30, height=26, + font=ctk.CTkFont(size=10), + fg_color=C["bg_hover"], + hover_color=C["rec_red"], + corner_radius=6, + command=self._clear_history, + ).pack(side="right") self.hist_frame = ctk.CTkFrame(card, fg_color="transparent") - self.hist_frame.pack(fill="x", padx=14, pady=(0, 10)) + self.hist_frame.pack(fill="x", padx=12, pady=(0, 10)) - self.hist_empty = ctk.CTkLabel(self.hist_frame, text="Brak nagrań", - font=ctk.CTkFont(size=11), text_color=C["txt3"]) - self.hist_empty.pack(pady=4) + ctk.CTkLabel( + self.hist_frame, text="Brak nagrań", + font=ctk.CTkFont(size=11), text_color=C["txt3"], + ).pack(pady=4) + + # ── Settings Panel ──────────────────────────────────────────── def _build_settings_panel(self): """Build the collapsible advanced settings panel.""" - self.settings_frame = ctk.CTkFrame(self.main, fg_color=C["bg_card"], corner_radius=12, - border_width=1, border_color=C["accent_dim"]) - # Hidden by default + self.settings_frame = ctk.CTkFrame( + self.main, fg_color=C["bg_card"], + corner_radius=12, + border_width=1, border_color=C["accent_dim"], + ) self._settings_visible = False inner = ctk.CTkFrame(self.settings_frame, fg_color="transparent") inner.pack(fill="x", padx=14, pady=12) - ctk.CTkLabel(inner, text="⚙️ Ustawienia zaawansowane", - font=ctk.CTkFont(size=14, weight="bold"), - text_color=C["txt"]).pack(anchor="w", pady=(0, 10)) + ctk.CTkLabel( + inner, text="⚙️ Ustawienia zaawansowane", + font=ctk.CTkFont(size=13, weight="bold"), + text_color=C["txt"], + ).pack(anchor="w", pady=(0, 10)) + + def sw_row(parent, label, var, cmd): + r = ctk.CTkFrame(parent, fg_color="transparent") + r.pack(fill="x", pady=3) + ctk.CTkLabel(r, text=label, font=ctk.CTkFont(size=12), + text_color=C["txt2"]).pack(side="left") + ctk.CTkSwitch(r, text="", variable=var, + fg_color=C["bg_hover"], progress_color=C["accent"], + button_color=C["txt"], button_hover_color=C["accent3"], + command=cmd).pack(side="right") + + def opt_row(parent, label, values, var, cmd, width=130): + r = ctk.CTkFrame(parent, fg_color="transparent") + r.pack(fill="x", pady=3) + ctk.CTkLabel(r, text=label, font=ctk.CTkFont(size=12), + text_color=C["txt2"]).pack(side="left") + ctk.CTkOptionMenu(r, values=values, variable=var, width=width, + font=ctk.CTkFont(size=11), + fg_color=C["bg_input"], button_color=C["accent_dim"], + button_hover_color=C["accent"], + dropdown_fg_color=C["bg_card"], + corner_radius=8, command=cmd).pack(side="right") - # --- Auto-type toggle --- - row1 = ctk.CTkFrame(inner, fg_color="transparent") - row1.pack(fill="x", pady=3) - ctk.CTkLabel(row1, text="✍️ Auto-wpisywanie tekstu", - font=ctk.CTkFont(size=12), text_color=C["txt2"]).pack(side="left") self.autotype_var = ctk.BooleanVar(value=self.config.auto_type_enabled) - ctk.CTkSwitch(row1, text="", variable=self.autotype_var, - fg_color=C["bg_hover"], progress_color=C["accent"], - button_color=C["txt"], button_hover_color=C["accent2"], - command=self._on_autotype_toggle).pack(side="right") - - # --- Typing method --- - row2 = ctk.CTkFrame(inner, fg_color="transparent") - row2.pack(fill="x", pady=3) - ctk.CTkLabel(row2, text="📋 Metoda wpisywania", - font=ctk.CTkFont(size=12), text_color=C["txt2"]).pack(side="left") + sw_row(inner, "✍️ Auto-wpisywanie tekstu", self.autotype_var, self._on_autotype_toggle) + self.typemethod_var = ctk.StringVar(value=self.config.typing_method) - ctk.CTkOptionMenu(row2, values=["clipboard", "keyboard"], variable=self.typemethod_var, - width=130, font=ctk.CTkFont(size=11), - fg_color=C["bg_input"], button_color=C["accent"], - button_hover_color=C["accent2"], dropdown_fg_color=C["bg_card"], - corner_radius=8, command=self._on_typemethod).pack(side="right") - - # --- Auto-copy --- - row3 = ctk.CTkFrame(inner, fg_color="transparent") - row3.pack(fill="x", pady=3) - ctk.CTkLabel(row3, text="📎 Auto-kopiowanie do schowka", - font=ctk.CTkFont(size=12), text_color=C["txt2"]).pack(side="left") + opt_row(inner, "📋 Metoda wpisywania", ["clipboard", "keyboard"], + self.typemethod_var, self._on_typemethod) + self.autocopy_var = ctk.BooleanVar(value=self.config.auto_copy_to_clipboard) - ctk.CTkSwitch(row3, text="", variable=self.autocopy_var, - fg_color=C["bg_hover"], progress_color=C["accent"], - button_color=C["txt"], button_hover_color=C["accent2"], - command=self._on_autocopy_toggle).pack(side="right") - - # --- Minimize to tray --- - row4 = ctk.CTkFrame(inner, fg_color="transparent") - row4.pack(fill="x", pady=3) - ctk.CTkLabel(row4, text="🔲 Minimalizuj do zasobnika", - font=ctk.CTkFont(size=12), text_color=C["txt2"]).pack(side="left") + sw_row(inner, "📎 Auto-kopiowanie do schowka", self.autocopy_var, self._on_autocopy_toggle) + self.tray_var = ctk.BooleanVar(value=self.config.minimize_to_tray) - ctk.CTkSwitch(row4, text="", variable=self.tray_var, - fg_color=C["bg_hover"], progress_color=C["accent"], - button_color=C["txt"], button_hover_color=C["accent2"], - command=self._on_tray_toggle).pack(side="right") - - # --- Notifications --- - row5 = ctk.CTkFrame(inner, fg_color="transparent") - row5.pack(fill="x", pady=3) - ctk.CTkLabel(row5, text="🔔 Powiadomienia", - font=ctk.CTkFont(size=12), text_color=C["txt2"]).pack(side="left") + sw_row(inner, "🔲 Minimalizuj do zasobnika", self.tray_var, self._on_tray_toggle) + self.notify_var = ctk.BooleanVar(value=self.config.show_notifications) - ctk.CTkSwitch(row5, text="", variable=self.notify_var, - fg_color=C["bg_hover"], progress_color=C["accent"], - button_color=C["txt"], button_hover_color=C["accent2"], - command=self._on_notify_toggle).pack(side="right") - - # --- VAD --- - row6 = ctk.CTkFrame(inner, fg_color="transparent") - row6.pack(fill="x", pady=3) - ctk.CTkLabel(row6, text="🎯 Detekcja mowy (VAD)", - font=ctk.CTkFont(size=12), text_color=C["txt2"]).pack(side="left") + sw_row(inner, "🔔 Powiadomienia", self.notify_var, self._on_notify_toggle) + self.vad_var = ctk.BooleanVar(value=self.config.vad_enabled) - ctk.CTkSwitch(row6, text="", variable=self.vad_var, - fg_color=C["bg_hover"], progress_color=C["accent"], - button_color=C["txt"], button_hover_color=C["accent2"], - command=self._on_vad_toggle).pack(side="right") - - # --- Beam Size --- - row7 = ctk.CTkFrame(inner, fg_color="transparent") - row7.pack(fill="x", pady=3) - ctk.CTkLabel(row7, text="🔬 Beam size (dokładność)", - font=ctk.CTkFont(size=12), text_color=C["txt2"]).pack(side="left") + sw_row(inner, "🎯 Detekcja mowy (VAD)", self.vad_var, self._on_vad_toggle) + self.beam_var = ctk.StringVar(value=str(self.config.beam_size)) - ctk.CTkOptionMenu(row7, values=["1", "3", "5", "8", "10"], variable=self.beam_var, - width=80, font=ctk.CTkFont(size=11), - fg_color=C["bg_input"], button_color=C["accent"], - button_hover_color=C["accent2"], dropdown_fg_color=C["bg_card"], - corner_radius=8, command=self._on_beam_change).pack(side="right") - - # --- Auto-correct --- - row8 = ctk.CTkFrame(inner, fg_color="transparent") - row8.pack(fill="x", pady=3) - ctk.CTkLabel(row8, text="✨ Autokorekta tekstu", - font=ctk.CTkFont(size=12), text_color=C["txt2"]).pack(side="left") + opt_row(inner, "🔬 Beam size (dokładność)", ["1", "3", "5", "8", "10"], + self.beam_var, self._on_beam_change, width=80) + self.autocorrect_var = ctk.BooleanVar(value=self.config.auto_correct) - ctk.CTkSwitch(row8, text="", variable=self.autocorrect_var, - fg_color=C["bg_hover"], progress_color=C["accent"], - button_color=C["txt"], button_hover_color=C["accent2"], - command=self._on_autocorrect_toggle).pack(side="right") - - # --- Sounds toggle --- - row9 = ctk.CTkFrame(inner, fg_color="transparent") - row9.pack(fill="x", pady=3) - ctk.CTkLabel(row9, text="🔊 Dźwięki nagrywania", - font=ctk.CTkFont(size=12), text_color=C["txt2"]).pack(side="left") + sw_row(inner, "✨ Autokorekta tekstu", self.autocorrect_var, self._on_autocorrect_toggle) + self.sounds_var = ctk.BooleanVar(value=self.config.play_sounds) - ctk.CTkSwitch(row9, text="", variable=self.sounds_var, - fg_color=C["bg_hover"], progress_color=C["accent"], - button_color=C["txt"], button_hover_color=C["accent2"], - command=self._on_sounds_toggle).pack(side="right") - - # --- Auto-start with Windows --- - row10 = ctk.CTkFrame(inner, fg_color="transparent") - row10.pack(fill="x", pady=3) - ctk.CTkLabel(row10, text="🚀 Uruchamiaj z Windows", - font=ctk.CTkFont(size=12), text_color=C["txt2"]).pack(side="left") + sw_row(inner, "🔊 Dźwięki nagrywania", self.sounds_var, self._on_sounds_toggle) + self.autostart_var = ctk.BooleanVar(value=is_autostart_enabled()) - ctk.CTkSwitch(row10, text="", variable=self.autostart_var, - fg_color=C["bg_hover"], progress_color=C["accent"], - button_color=C["txt"], button_hover_color=C["accent2"], - command=self._on_autostart_toggle).pack(side="right") + sw_row(inner, "🚀 Uruchamiaj z Windows", self.autostart_var, self._on_autostart_toggle) - # --- Separator --- - ctk.CTkFrame(inner, fg_color=C["border"], height=1).pack(fill="x", pady=8) + ctk.CTkFrame(inner, fg_color=C["border"], height=1).pack(fill="x", pady=(8, 6)) - # Info label - ctk.CTkLabel(inner, - text="💡 Dźwięki informują o starcie/stopie nagrywania.\n" - " Animacja REC pojawia się w prawym górnym rogu ekranu.", - font=ctk.CTkFont(size=11), text_color=C["txt3"], - justify="left").pack(anchor="w") + ctk.CTkLabel( + inner, + text="💡 Wskazówka: Użyj Ctrl+Space lub F2–F10 jako klawisz dyktowania.\n" + " Model 'small' = dobry balans jakości i szybkości.", + font=ctk.CTkFont(size=10), + text_color=C["txt3"], + justify="left", + ).pack(anchor="w") + + # ── Footer ──────────────────────────────────────────────────── def _build_footer(self): f = ctk.CTkFrame(self.main, fg_color="transparent") - f.pack(fill="x", pady=(0, 6)) + f.pack(fill="x", pady=(0, 4)) - # Main footer with branding - ctk.CTkLabel(f, text=f"VoxFlow v{__version__} • faster-whisper • 100% Lokalne", - font=ctk.CTkFont(size=10), text_color=C["txt3"]).pack() + ctk.CTkLabel( + f, + text=f"VoxFlow v{__version__} • faster-whisper • 100% lokalne", + font=ctk.CTkFont(size=9), + text_color=C["txt3"], + ).pack() - # AI Evolution Polska credit - ctk.CTkLabel(f, text=f"Built by {__author__}", - font=ctk.CTkFont(size=9, weight="bold"), - text_color=C["accent_dim"]).pack(pady=(1, 0)) + ctk.CTkLabel( + f, + text=f"Open Source • {__author__}", + font=ctk.CTkFont(size=9, weight="bold"), + text_color=C["accent_dim"], + ).pack(pady=(1, 0)) # ═══════════════════════════════════════════════════════════════ # SETTINGS TOGGLE @@ -476,8 +661,11 @@ def _toggle_settings(self): self._settings_visible = False self.settings_btn.configure(fg_color=C["bg_card"]) else: - # Pack before footer - self.settings_frame.pack(fill="x", pady=(0, 8), before=self.main.winfo_children()[-1]) + children = self.main.winfo_children() + self.settings_frame.pack( + fill="x", pady=(0, 6), + before=children[-1], + ) self._settings_visible = True self.settings_btn.configure(fg_color=C["accent_dim"]) @@ -488,26 +676,105 @@ def _toggle_settings(self): def _animate(self): if not self._alive: return - self._phase += 0.12 + self._phase += 0.10 self._draw_btn() target = self._level if self._recording else 0 cur = self.level_bar.get() - self.level_bar.set(min(1.0, cur + (target - cur) * 0.3) * 5 if self._recording else max(0, cur * 0.85)) - self.level_bar.configure(progress_color=C["rec_red"] if self._recording else C["accent"]) + if self._recording: + self.level_bar.set(min(1.0, cur + (target - cur) * 0.3) * 5) + else: + self.level_bar.set(max(0.0, cur * 0.8)) + self.level_bar.configure( + progress_color=C["rec_red"] if self._recording else C["accent"] + ) self.after(50, self._animate) + # ═══════════════════════════════════════════════════════════════ + # HOTKEY CAPTURE (interactive) + # ═══════════════════════════════════════════════════════════════ + + def _start_hotkey_capture(self): + """Start interactive hotkey capture — next key press becomes the hotkey.""" + if self._capturing_hotkey or self._recording: + return + self._capturing_hotkey = True + self.hotkey_btn.configure( + text="⌨️ Naciśnij klawisz...", + fg_color=C["accent_dim"], + border_color=C["accent"], + ) + self.hotkey_manager.stop() + threading.Thread(target=self._capture_hotkey_thread, daemon=True).start() + + def _capture_hotkey_thread(self): + """Wait for key press in background thread.""" + key = self.hotkey_manager.capture_next_key(timeout=10.0) + self.after(0, lambda: self._finish_hotkey_capture(key)) + + def _finish_hotkey_capture(self, key: Optional[str]): + """Apply captured key as new hotkey.""" + self._capturing_hotkey = False + if key: + self.config.hotkey = key + self.config.save() + self.hotkey_manager.update_hotkey(key) + display = key.upper().replace("+", " + ") + self.hotkey_btn.configure( + text=display, + fg_color=C["bg_input"], + border_color=C["ok"], + ) + self.subtitle_label.configure( + text=f"Przytrzymaj {display} aby dyktować • 100% offline" + ) + hk_disp = key.upper().replace("+", " + ") + self.status.configure( + text=f"⌨️ Hotkey ustawiony: {hk_disp}", text_color=C["ok"] + ) + # Reset border after 2s + self.after(2000, lambda: self.hotkey_btn.configure(border_color=C["border2"])) + else: + # Cancelled / timeout + self.hotkey_btn.configure( + text=self.config.hotkey.upper().replace("+", " + "), + fg_color=C["bg_input"], + border_color=C["border2"], + ) + self.hotkey_manager.start() + + # ═══════════════════════════════════════════════════════════════ + # MICROPHONE SELECTION + # ═══════════════════════════════════════════════════════════════ + + def _on_mic_change(self, label: str): + """Handle microphone device selection change.""" + if label.startswith("🎤 Domyślny"): + idx = -1 + else: + try: + idx = int(label.split("]")[0].replace("[", "").strip()) + except (ValueError, IndexError): + idx = -1 + + self.config.audio_device_index = idx + self.config.save() + self.recorder.set_device(idx) + + dev_name = label if idx < 0 else label[label.index("]") + 2:] + self.status.configure( + text=f"🎙 Mikrofon: {dev_name[:40]}", text_color=C["ok"] + ) + # ═══════════════════════════════════════════════════════════════ # RECORDING (Hold-to-Record) # ═══════════════════════════════════════════════════════════════ def _on_hotkey_press(self): - """Called when hotkey is pressed down — start recording.""" self.after(0, self._start_rec) def _on_hotkey_release(self): - """Called when hotkey is released — stop recording + transcribe.""" self.after(0, self._stop_rec) def _toggle_recording(self): @@ -519,7 +786,7 @@ def _toggle_recording(self): self._start_rec() def _start_rec(self): - if self._recording or self._processing: + if self._recording or self._processing or self._capturing_hotkey: return self._recording = True self.status.configure(text="🔴 Nagrywam... Mów teraz!", text_color=C["rec_red"]) @@ -556,7 +823,9 @@ def _transcribe(self, audio: np.ndarray): beam_size=self.config.beam_size, vad_enabled=self.config.vad_enabled, auto_correct=self.config.auto_correct, - on_progress=lambda m: self.after(0, lambda msg=m: self.status.configure(text=msg)), + on_progress=lambda m: self.after( + 0, lambda msg=m: self.status.configure(text=msg) + ), ) self.after(0, lambda: self._on_done(result)) except Exception as e: @@ -584,7 +853,6 @@ def _on_done(self, result: dict): # Auto-type into active window if self.config.auto_type_enabled: - # Small delay to let the user's target window regain focus threading.Thread( target=self._delayed_auto_type, args=(text,), @@ -594,16 +862,22 @@ def _on_done(self, result: dict): lang = result.get("language", "?") flag = "🇵🇱" if lang == "pl" else "🇬🇧" if lang == "en" else "🌍" dur = result.get("duration", 0) - extra = " • ✍️ Wpisano!" if self.config.auto_type_enabled else "" - extra += " • 📋" if self.config.auto_copy_to_clipboard else "" - self.status.configure(text=f"✅ {flag} {lang.upper()} • {dur:.1f}s{extra}", text_color=C["ok"]) + extras = [] + if self.config.auto_type_enabled: + extras.append("✍️") + if self.config.auto_copy_to_clipboard: + extras.append("📋") + extra_str = " • " + " ".join(extras) if extras else "" + self.status.configure( + text=f"✅ {flag} {lang.upper()} • {dur:.1f}s{extra_str}", + text_color=C["ok"], + ) if self.config.play_sounds: sounds.play("done") self._add_history(text, lang, dur) def _delayed_auto_type(self, text: str): - """Wait briefly for target window to get focus, then type.""" time.sleep(0.15) self.auto_typer.type_text(text, method=self.config.typing_method) @@ -618,32 +892,48 @@ def _on_error(self, err: str): # ═══════════════════════════════════════════════════════════════ def _add_history(self, text: str, lang: str, dur: float): - self._history.insert(0, {"text": text, "language": lang, "duration": dur, - "time": datetime.now().strftime("%H:%M:%S")}) - if len(self._history) > 10: - self._history = self._history[:10] + self._history.insert(0, { + "text": text, "language": lang, + "duration": dur, + "time": datetime.now().strftime("%H:%M"), + }) + if len(self._history) > 20: + self._history = self._history[:20] self._refresh_history() def _refresh_history(self): for w in self.hist_frame.winfo_children(): w.destroy() if not self._history: - ctk.CTkLabel(self.hist_frame, text="Brak nagrań", - font=ctk.CTkFont(size=11), text_color=C["txt3"]).pack(pady=4) + ctk.CTkLabel( + self.hist_frame, text="Brak nagrań", + font=ctk.CTkFont(size=11), text_color=C["txt3"], + ).pack(pady=4) return - for e in self._history[:5]: - row = ctk.CTkFrame(self.hist_frame, fg_color=C["bg_hover"], corner_radius=8, height=34) + for e in self._history[:6]: + row = ctk.CTkFrame( + self.hist_frame, fg_color=C["bg_hover"], + corner_radius=8, height=32, + ) row.pack(fill="x", pady=2) row.pack_propagate(False) flag = "🇵🇱" if e["language"] == "pl" else "🇬🇧" if e["language"] == "en" else "🌍" - preview = e["text"][:45] + ("..." if len(e["text"]) > 45 else "") - ctk.CTkLabel(row, text=f"{flag} {e['time']} • {preview}", - font=ctk.CTkFont(size=11), text_color=C["txt2"], - anchor="w").pack(side="left", padx=8, fill="x", expand=True) + preview = e["text"][:44] + ("…" if len(e["text"]) > 44 else "") + ctk.CTkLabel( + row, + text=f"{flag} {e['time']} • {preview}", + font=ctk.CTkFont(family="Segoe UI", size=10), + text_color=C["txt2"], + anchor="w", + ).pack(side="left", padx=8, fill="x", expand=True) t = e["text"] - ctk.CTkButton(row, text="📋", width=28, height=22, font=ctk.CTkFont(size=10), - fg_color="transparent", hover_color=C["accent"], corner_radius=6, - command=lambda txt=t: pyperclip.copy(txt)).pack(side="right", padx=4) + ctk.CTkButton( + row, text="📋", width=26, height=22, + font=ctk.CTkFont(size=10), + fg_color="transparent", hover_color=C["accent"], + corner_radius=6, + command=lambda txt=t: pyperclip.copy(txt), + ).pack(side="right", padx=4) def _clear_history(self): self._history = [] @@ -654,61 +944,75 @@ def _clear_history(self): # ═══════════════════════════════════════════════════════════════ def _on_lang(self, v): - self.config.language = v; self.config.save() + self.config.language = v + self.config.save() def _on_model(self, v): if v != self.config.model_size: - self.config.model_size = v; self.config.save() + self.config.model_size = v + self.config.save() self.status.configure(text=f"⏳ Ładuję model '{v}'...", text_color=C["warn"]) threading.Thread(target=self._reload_model, args=(v,), daemon=True).start() - def _on_hotkey_change(self, v): - self.config.hotkey = v; self.config.save() - self.hotkey_manager.update_hotkey(v) - self.status.configure(text=f"⌨️ Hotkey: {v.upper()}", text_color=C["ok"]) - def _on_autotype_toggle(self): - self.config.auto_type_enabled = self.autotype_var.get(); self.config.save() + self.config.auto_type_enabled = self.autotype_var.get() + self.config.save() def _on_typemethod(self, v): - self.config.typing_method = v; self.config.save() + self.config.typing_method = v + self.config.save() def _on_autocopy_toggle(self): - self.config.auto_copy_to_clipboard = self.autocopy_var.get(); self.config.save() + self.config.auto_copy_to_clipboard = self.autocopy_var.get() + self.config.save() def _on_tray_toggle(self): - self.config.minimize_to_tray = self.tray_var.get(); self.config.save() + self.config.minimize_to_tray = self.tray_var.get() + self.config.save() def _on_notify_toggle(self): - self.config.show_notifications = self.notify_var.get(); self.config.save() + self.config.show_notifications = self.notify_var.get() + self.config.save() def _on_vad_toggle(self): - self.config.vad_enabled = self.vad_var.get(); self.config.save() + self.config.vad_enabled = self.vad_var.get() + self.config.save() def _on_beam_change(self, v): - self.config.beam_size = int(v); self.config.save() + self.config.beam_size = int(v) + self.config.save() def _on_autocorrect_toggle(self): - self.config.auto_correct = self.autocorrect_var.get(); self.config.save() + self.config.auto_correct = self.autocorrect_var.get() + self.config.save() def _on_sounds_toggle(self): - self.config.play_sounds = self.sounds_var.get(); self.config.save() + self.config.play_sounds = self.sounds_var.get() + self.config.save() def _on_autostart_toggle(self): enabled = self.autostart_var.get() set_autostart(enabled) self.config.start_with_windows = enabled self.config.save() - msg = "🚀 Auto-start włączony" if enabled else "🚀 Auto-start wyłączony" + msg = "🚀 Auto-start włączony" if enabled else "🔕 Auto-start wyłączony" self.status.configure(text=msg, text_color=C["ok"]) def _reload_model(self, sz): try: - self.transcriber.load_model(sz, on_progress=lambda m: self.after( - 0, lambda msg=m: self.status.configure(text=msg))) + self.transcriber.load_model( + sz, + on_progress=lambda m: self.after( + 0, lambda msg=m: self.status.configure(text=msg) + ), + ) except Exception as e: - self.after(0, lambda: self.status.configure( - text=f"❌ Model: {str(e)[:50]}", text_color=C["rec_red"])) + self.after( + 0, + lambda: self.status.configure( + text=f"❌ Model: {str(e)[:50]}", text_color=C["rec_red"] + ), + ) def _copy_text(self): self.textbox.configure(state="normal") @@ -718,6 +1022,12 @@ def _copy_text(self): pyperclip.copy(t) self.status.configure(text="📋 Skopiowano!", text_color=C["ok"]) + def _clear_transcript(self): + self.textbox.configure(state="normal") + self.textbox.delete("1.0", "end") + self.textbox.insert("1.0", "Twój tekst pojawi się tutaj...") + self.textbox.configure(state="disabled") + def _on_level(self, lv): self._level = lv self.overlay.set_level(lv) @@ -727,7 +1037,9 @@ def _on_level(self, lv): # ═══════════════════════════════════════════════════════════════ def _start_services(self): - self.status.configure(text="⏳ Ładowanie modelu Whisper...", text_color=C["warn"]) + self.status.configure( + text="⏳ Ładowanie modelu Whisper...", text_color=C["warn"] + ) threading.Thread(target=self._init_model, daemon=True).start() try: self.hotkey_manager.start() @@ -740,14 +1052,28 @@ def _start_services(self): def _init_model(self): try: - self.transcriber.load_model(on_progress=lambda m: self.after( - 0, lambda msg=m: self.status.configure(text=msg, text_color=C["txt2"]))) - hk = self.config.hotkey.upper() - self.after(0, lambda: self.status.configure( - text=f"✨ Gotowy — Przytrzymaj {hk} i mów", text_color=C["ok"])) + self.transcriber.load_model( + on_progress=lambda m: self.after( + 0, lambda msg=m: self.status.configure( + text=msg, text_color=C["txt2"] + ) + ) + ) + hk = self.config.hotkey.upper().replace("+", " + ") + self.after( + 0, + lambda: self.status.configure( + text=f"✨ Gotowy — Przytrzymaj {hk} i mów", + text_color=C["ok"], + ), + ) except Exception as e: - self.after(0, lambda: self.status.configure( - text=f"❌ {str(e)[:70]}", text_color=C["rec_red"])) + self.after( + 0, + lambda: self.status.configure( + text=f"❌ {str(e)[:70]}", text_color=C["rec_red"] + ), + ) def _show(self): self.after(0, lambda: (self.deiconify(), self.lift(), self.focus_force())) diff --git a/voxflow/config.py b/voxflow/config.py index 87703df..8020e04 100644 --- a/voxflow/config.py +++ b/voxflow/config.py @@ -19,7 +19,11 @@ def get_config_dir() -> Path: # Valid value ranges for security validation _VALID_MODELS = {"tiny", "base", "small", "medium", "large-v3"} _VALID_LANGUAGES = {"auto", "pl", "en"} -_VALID_HOTKEYS = {"f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10"} +_VALID_HOTKEYS = { + "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", + "ctrl+space", "ctrl+shift+space", "caps lock", "insert", + "scroll lock", "pause", +} _VALID_TYPING_METHODS = {"clipboard", "keyboard"} _VALID_THEMES = {"dark", "light"} @@ -82,6 +86,8 @@ def _validate_config(data: dict) -> dict: validated[key] = max(500, min(1080, int(value))) elif key == "vad_silence_ms": validated[key] = max(50, min(5000, int(value))) + elif key == "audio_device_index": + validated[key] = int(value) # -1 = default device else: validated[key] = value @@ -104,6 +110,9 @@ class VoxFlowConfig: silence_duration: float = 2.0 max_recording_duration: float = 300.0 # 5 min max + # Audio device (-1 = system default) + audio_device_index: int = -1 + # Hotkey - hold-to-record hotkey: str = "f2" @@ -120,7 +129,7 @@ class VoxFlowConfig: play_sounds: bool = True theme: str = "dark" window_width: int = 500 - window_height: int = 750 + window_height: int = 780 # Advanced beam_size: int = 5 @@ -166,7 +175,11 @@ def available_languages(self) -> dict: @property def available_hotkeys(self) -> list: - return ["f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10"] + return [ + "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", + "ctrl+space", "ctrl+shift+space", "caps lock", "insert", + "scroll lock", + ] @property def available_typing_methods(self) -> dict: diff --git a/voxflow/hotkey_manager.py b/voxflow/hotkey_manager.py index 94667ca..64300c4 100644 --- a/voxflow/hotkey_manager.py +++ b/voxflow/hotkey_manager.py @@ -1,7 +1,12 @@ -"""VoxFlow Hotkey Manager - Hold-to-record with F2 key. +"""VoxFlow Hotkey Manager - Hold-to-record with configurable key. -Press and HOLD F2 → recording starts -Release F2 → recording stops → auto-transcribe → auto-type +Press and HOLD key → recording starts +Release key → recording stops → auto-transcribe → auto-type + +Supports: +- Single function keys: f2, f3, ..., f10 +- Combo keys: ctrl+space, ctrl+shift+space +- Special keys: caps lock, insert, scroll lock """ import threading from typing import Optional, Callable @@ -10,8 +15,8 @@ class HotkeyManager: """Manages hold-to-record hotkey using keyboard hook. - This uses keyboard.hook_key() which properly detects - both key-down and key-up events for a single key. + Uses keyboard.hook_key() or keyboard.add_hotkey() depending on + whether the hotkey is a single key or a combination. """ def __init__( @@ -26,6 +31,7 @@ def __init__( self._active = False self._is_held = False self._hook_ref = None + self._is_combo = "+" in hotkey def start(self): """Start listening for the hold-to-record hotkey.""" @@ -34,12 +40,17 @@ def start(self): try: import keyboard - # hook_key fires for BOTH down and up events on this key - self._hook_ref = keyboard.hook_key( - self.hotkey, - callback=self._on_key_event, - suppress=False, # Don't suppress — works without admin - ) + + if self._is_combo: + # Combo hotkeys: use suppress=True press/release hooks + self._hook_ref = keyboard.hook(self._on_key_event_combo) + else: + # Single key: hook_key fires for BOTH down and up events + self._hook_ref = keyboard.hook_key( + self.hotkey, + callback=self._on_key_event, + suppress=False, + ) self._active = True print(f"Hotkey '{self.hotkey.upper()}' registered (hold-to-record)") except Exception as e: @@ -65,15 +76,35 @@ def update_hotkey(self, new_hotkey: str): if was_active: self.stop() self.hotkey = new_hotkey.lower() + self._is_combo = "+" in new_hotkey if was_active: self.start() + def capture_next_key(self, timeout: float = 5.0) -> Optional[str]: + """Block and wait for the next key press, return its name. + + Used by the UI to let users interactively pick their hotkey. + Returns None if timeout expires or an error occurs. + """ + try: + import keyboard + event = keyboard.read_event(suppress=False) + if event and event.event_type == "down": + name = event.name.lower() + # Filter out just-modifier presses + if name not in ("shift", "ctrl", "alt", "windows", "left shift", + "right shift", "left ctrl", "right ctrl", + "left alt", "right alt"): + return name + except Exception as e: + print(f"capture_next_key error: {e}") + return None + def _on_key_event(self, event): - """Handle key events — detect press (down) and release (up).""" + """Handle single key events — detect press (down) and release (up).""" try: if event.event_type == "down": if not self._is_held: - # First press (ignore key repeats) self._is_held = True if self.on_press: threading.Thread(target=self.on_press, daemon=True).start() @@ -85,6 +116,28 @@ def _on_key_event(self, event): except Exception as e: print(f"Hotkey event error: {e}") + def _on_key_event_combo(self, event): + """Handle combo hotkey events via global keyboard hook.""" + try: + import keyboard + # Check if currently held keys match the combo + keys = [k.strip() for k in self.hotkey.split("+")] + all_pressed = all(keyboard.is_pressed(k) for k in keys) + + if event.event_type == "down" and all_pressed: + if not self._is_held: + self._is_held = True + if self.on_press: + threading.Thread(target=self.on_press, daemon=True).start() + elif event.event_type == "up" and self._is_held: + main_key = keys[-1] + if event.name.lower() == main_key or not all_pressed: + self._is_held = False + if self.on_release: + threading.Thread(target=self.on_release, daemon=True).start() + except Exception as e: + print(f"Combo hotkey event error: {e}") + @property def is_active(self) -> bool: return self._active @@ -95,7 +148,9 @@ def is_held(self) -> bool: @staticmethod def get_available_hotkeys() -> list[str]: - """Return list of recommended single-key hotkeys for hold-to-record.""" + """Return list of recommended hotkeys for hold-to-record.""" return [ "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", + "ctrl+space", "ctrl+shift+space", "caps lock", "insert", + "scroll lock", ] diff --git a/voxflow/main.py b/voxflow/main.py index ad4f8dd..c6e9f4c 100644 --- a/voxflow/main.py +++ b/voxflow/main.py @@ -10,6 +10,11 @@ def main(): # Set environment for better compatibility os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE") + # Fix encoding for Windows consoles + if sys.platform == "win32": + sys.stdout.reconfigure(encoding='utf-8') + sys.stderr.reconfigure(encoding='utf-8') + # Handle --test mode if "--test" in sys.argv: print("🧪 VoxFlow Test Mode") diff --git a/voxflow/recorder.py b/voxflow/recorder.py index 21c3037..695cdbd 100644 --- a/voxflow/recorder.py +++ b/voxflow/recorder.py @@ -1,4 +1,4 @@ -"""VoxFlow Audio Recorder - Captures microphone input.""" +"""VoxFlow Audio Recorder - Captures microphone input with device selection.""" import threading import numpy as np import sounddevice as sd @@ -15,6 +15,7 @@ def __init__( silence_threshold: float = 0.01, silence_duration: float = 2.0, max_duration: float = 300.0, + device_index: int = -1, on_level_change: Optional[Callable[[float], None]] = None, ): self.sample_rate = sample_rate @@ -22,6 +23,7 @@ def __init__( self.silence_threshold = silence_threshold self.silence_duration = silence_duration self.max_duration = max_duration + self.device_index = device_index # -1 = system default self.on_level_change = on_level_change self._recording = False @@ -35,8 +37,12 @@ def __init__( def is_recording(self) -> bool: return self._recording + def set_device(self, device_index: int): + """Change the audio input device. Takes effect on next start().""" + self.device_index = device_index + def start(self): - """Start recording audio from the default microphone.""" + """Start recording audio from the selected microphone.""" if self._recording: return @@ -45,14 +51,36 @@ def start(self): self._silence_counter = 0 self._has_speech = False - self._stream = sd.InputStream( - samplerate=self.sample_rate, - channels=self.channels, - dtype="float32", - blocksize=int(self.sample_rate * 0.1), # 100ms blocks - callback=self._audio_callback, - ) - self._stream.start() + # Resolve device: -1 → None (sounddevice default) + device = None if self.device_index < 0 else self.device_index + + try: + self._stream = sd.InputStream( + device=device, + samplerate=self.sample_rate, + channels=self.channels, + dtype="float32", + blocksize=int(self.sample_rate * 0.1), # 100ms blocks + callback=self._audio_callback, + ) + self._stream.start() + except Exception as e: + self._recording = False + # Retry with default device if selected one fails + if device is not None: + print(f"Device {device} failed, falling back to default: {e}") + self.device_index = -1 + self._stream = sd.InputStream( + samplerate=self.sample_rate, + channels=self.channels, + dtype="float32", + blocksize=int(self.sample_rate * 0.1), + callback=self._audio_callback, + ) + self._recording = True + self._stream.start() + else: + raise def stop(self) -> Optional[np.ndarray]: """Stop recording and return the audio data as a numpy array.""" @@ -124,3 +152,12 @@ def list_devices() -> list[dict]: "sample_rate": dev["default_samplerate"], }) return input_devices + + @staticmethod + def get_default_device_name() -> str: + """Get the name of the current default input device.""" + try: + default = sd.query_devices(kind="input") + return default.get("name", "Domyślny") + except Exception: + return "Domyślny" From 8dfadaad45d2d2ac550a2f0e5e2533d75d986efb Mon Sep 17 00:00:00 2001 From: aichristabasco Date: Fri, 20 Feb 2026 18:57:57 +0000 Subject: [PATCH 3/5] fix: v1.1.0 - professional code audit fixes - config.py: remove overly restrictive hotkey whitelist validation (any non-empty string is now accepted as hotkey) - recorder.py: add on_device_fallback callback to sync UI+config permanently when mic fallback to default occurs - hotkey_manager.py: rewrite capture_next_key() with threading.Event + strict timeout + Escape to cancel; add _MODIFIER_KEYS frozenset - app.py: lazy imports for tray/autostart with graceful degradation when pystray is unavailable; add _on_device_fallback handler; add debounced _on_window_configure to persist window size; fix _toggle_settings to use self.footer_frame ref instead of children[-1]; guard self.tray calls with None checks - __init__.py: bump version to 1.1.0 - CHANGELOG.md: document all v1.1.0 bug fixes and new features" --- BUILD_EXE.bat | 121 +++++++++++++++++++++++++------------- CHANGELOG.md | 29 +++++++++ install.bat | 1 + installer.iss | 116 +++++++++++++++++++++++++++++++----- voxflow/__init__.py | 2 +- voxflow/app.py | 92 ++++++++++++++++++++++++----- voxflow/config.py | 13 ++-- voxflow/hotkey_manager.py | 60 +++++++++++++------ voxflow/recorder.py | 8 +++ 9 files changed, 346 insertions(+), 96 deletions(-) diff --git a/BUILD_EXE.bat b/BUILD_EXE.bat index b1bde3f..c0cb4e4 100644 --- a/BUILD_EXE.bat +++ b/BUILD_EXE.bat @@ -1,68 +1,109 @@ @echo off -title VoxFlow - Building .exe + Installer (AI Evolution Polska) +chcp 65001 >nul +cd /d "%~dp0" +title VoxFlow — Build Installer + echo. -echo ╔═══════════════════════════════════════════════════╗ -echo ║ 🔨 Building VoxFlow .exe + Installer ║ -echo ║ by AI Evolution Polska ║ -echo ╚═══════════════════════════════════════════════════╝ +echo ╔══════════════════════════════════════════════════════════╗ +echo ║ VoxFlow — Build Setup.exe Installer ║ +echo ║ by AI Evolution Polska ║ +echo ╚══════════════════════════════════════════════════════════╝ echo. -REM Activate virtual environment +REM ─── Step 0: Check venv ───────────────────────────────────────── +if not exist "venv\Scripts\activate.bat" ( + echo ❌ Środowisko venv nie istnieje. Uruchom najpierw install.bat + pause & exit /b 1 +) call venv\Scripts\activate.bat -REM Install PyInstaller if needed -pip install pyinstaller --quiet --disable-pip-version-check 2>nul +REM ─── Step 1: Install/update build tools ───────────────────────── +echo [1/4] Instalowanie narzędzi build... +pip install pyinstaller --quiet --disable-pip-version-check +if %errorlevel% neq 0 ( + echo ❌ Błąd instalacji PyInstaller! + pause & exit /b 1 +) +echo ✅ PyInstaller gotowy + +REM ─── Step 2: Clean previous build ─────────────────────────────── +echo. +echo [2/4] Czyszczenie poprzedniego buildu... +if exist "dist\VoxFlow" rmdir /s /q "dist\VoxFlow" +if exist "build" rmdir /s /q "build" +echo ✅ Wyczyszczono +REM ─── Step 3: Build EXE with PyInstaller ───────────────────────── echo. -echo ═══════════════════════════════════════════ -echo STEP 1: Building VoxFlow .exe -echo ═══════════════════════════════════════════ +echo [3/4] Budowanie VoxFlow.exe przez PyInstaller... +echo (To może potrwać 2-5 minut — proszę czekać) echo. pyinstaller VoxFlow.spec --noconfirm --clean if %errorlevel% neq 0 ( echo. - echo ❌ PyInstaller build failed! - deactivate - pause - exit /b 1 + echo ❌ PyInstaller zakończył się błędem! + echo Sprawdź logi powyżej. + pause & exit /b 1 +) +if not exist "dist\VoxFlow\VoxFlow.exe" ( + echo ❌ Nie znaleziono dist\VoxFlow\VoxFlow.exe! + pause & exit /b 1 ) echo. -echo ✅ .exe built successfully at: dist\VoxFlow\VoxFlow.exe -echo. +echo ✅ VoxFlow.exe zbudowany pomyślnie -echo ═══════════════════════════════════════════ -echo STEP 2: Creating Installer (optional) -echo ═══════════════════════════════════════════ +REM ─── Step 4: Build Installer with Inno Setup ──────────────────── +echo. +echo [4/4] Tworzenie instalatora VoxFlow_Setup.exe... echo. -REM Check if Inno Setup is installed +REM Possible Inno Setup locations +set ISCC="" if exist "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" ( - echo 📦 Inno Setup found! Building installer... - "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" installer.iss - if %errorlevel% equ 0 ( - echo. - echo ✅ Installer created at: installer_output\VoxFlow_Setup_v1.0.0.exe - ) else ( - echo ⚠️ Installer build failed. You can still use the portable .exe. - ) -) else ( - echo ℹ️ Inno Setup not found. Skipping installer creation. - echo Install from: https://jrsoftware.org/isinfo.php - echo Then re-run this script to create an installer. + set ISCC="C:\Program Files (x86)\Inno Setup 6\ISCC.exe" +) +if exist "C:\Program Files\Inno Setup 6\ISCC.exe" ( + set ISCC="C:\Program Files\Inno Setup 6\ISCC.exe" +) + +if %ISCC%=="" ( + echo ⚠️ Inno Setup nie jest zainstalowany! + echo. + echo Pobierz Inno Setup 6 ze strony: + echo https://jrsoftware.org/isinfo.php + echo. + echo Następnie uruchom ten skrypt ponownie, aby stworzyć + echo plik instalacyjny VoxFlow_Setup.exe + echo. + echo Gotowy EXE znajduje się w: dist\VoxFlow\VoxFlow.exe + pause + start "" explorer "dist\VoxFlow" + exit /b 0 +) + +if not exist "installer_output" mkdir installer_output + +%ISCC% installer.iss /Q + +if %errorlevel% neq 0 ( echo. - echo 💡 You can still use the portable .exe at: dist\VoxFlow\VoxFlow.exe + echo ❌ Inno Setup zakończył się błędem! + echo Sprawdź plik installer.iss + pause & exit /b 1 ) echo. -echo ══════════════════════════════════════ -echo 📂 Output files: -echo dist\VoxFlow\VoxFlow.exe (portable) -if exist "installer_output\VoxFlow_Setup_v1.0.0.exe" echo installer_output\VoxFlow_Setup_v1.0.0.exe (installer) -echo ══════════════════════════════════════ +echo ╔══════════════════════════════════════════════════════════╗ +echo ║ ║ +echo ║ ✅ GOTOWE! VoxFlow_Setup.exe zbudowany pomyślnie ║ +echo ║ ║ +echo ║ Lokalizacja: installer_output\VoxFlow_Setup_v*.exe ║ +echo ║ ║ +echo ╚══════════════════════════════════════════════════════════╝ echo. -deactivate +start "" explorer "installer_output" pause diff --git a/CHANGELOG.md b/CHANGELOG.md index 47c2f75..fb7434b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ Wszystkie istotne zmiany w projekcie są dokumentowane w tym pliku. Format oparty na [Keep a Changelog](https://keepachangelog.com/pl/1.0.0/), wersjonowanie zgodne z [Semantic Versioning](https://semver.org/lang/pl/). +--- + +## [1.1.0] — 2026-02-20 + +### ✨ Dodane +- 🎤 Wybór mikrofonu — dropdown z listą urządzeń audio +- ⌨️ Interaktywny hotkey picker — naciśnij dowolny klawisz aby ustawić +- 🔑 Wsparcie kombinacji klawiszy (Ctrl+Space, Ctrl+Shift+Space itp.) +- 🖥️ Zapamiętywanie rozmiaru okna między sesjami +- ⚠️ Automatyczny fallback mikrofonu gdy wybrany jest niedostępny + +### 🐛 Naprawiono +- **[Krytyczny]** Hotkey resetował się do F2 po każdym restarcie — walidacja + konfiguracji blokowała własne klawisze użytkownika +- **[Krytyczny]** Fallback mikrofonu nie aktualizował konfiguracji trwale — + problem powtarzał się przy każdym uruchomieniu +- **[Krytyczny]** `keyboard.read_event()` bez timeout mogło zawiesić wątek UI + podczas hotkey capture — naprawiono przez threading.Event z limitem czasu +- **[Ważny]** Settings panel wstawiał się w losowe miejsce — naprawiono przez + przechowywanie referencji do `footer_frame` +- Graceful degradation gdy `pystray` lub `autostart` są niedostępne (brak crashu) + +### 🔧 Poprawki jakości +- Escape anuluje teraz hotkey capture +- Debounced zapis rozmiaru okna (400ms po ostatnim zdarzeniu resize) +- Tray guard — aplikacja działa poprawnie bez system tray + +--- + ## [1.0.0] — 2026-02-16 ### ✨ Dodane diff --git a/install.bat b/install.bat index c908047..a2a2673 100644 --- a/install.bat +++ b/install.bat @@ -1,5 +1,6 @@ @echo off chcp 65001 >nul +cd /d "%~dp0" title VoxFlow — Instalacja (AI Evolution Polska) echo. echo ╔══════════════════════════════════════════════════════╗ diff --git a/installer.iss b/installer.iss index ef41a39..2979d3b 100644 --- a/installer.iss +++ b/installer.iss @@ -1,48 +1,134 @@ -; VoxFlow Installer Script for Inno Setup -; Requires Inno Setup 6+ (https://jrsoftware.org/isinfo.php) +; ═══════════════════════════════════════════════════════════════ +; VoxFlow — Inno Setup Installer Script +; Requires Inno Setup 6.3+ (https://jrsoftware.org/isinfo.php) +; by AI Evolution Polska | Open Source +; ═══════════════════════════════════════════════════════════════ -#define MyAppName "VoxFlow" -#define MyAppVersion "1.0.0" +#define MyAppName "VoxFlow" +#define MyAppVersion "1.1.0" #define MyAppPublisher "AI Evolution Polska" -#define MyAppExeName "VoxFlow.exe" -#define MyAppURL "https://github.com/aievolutionpl/VoxFlow" +#define MyAppExeName "VoxFlow.exe" +#define MyAppURL "https://github.com/aievolutionpl/VoxFlow" [Setup] +; Unique GUID - do not change after first release AppId={{B8E4F2A1-3C7D-4E9F-A5B6-1D2E3F4A5B6C} + AppName={#MyAppName} AppVersion={#MyAppVersion} +AppVerName={#MyAppName} {#MyAppVersion} AppPublisher={#MyAppPublisher} AppPublisherURL={#MyAppURL} AppSupportURL={#MyAppURL}/issues +AppUpdatesURL={#MyAppURL}/releases +AppCopyright=Copyright © 2025 AI Evolution Polska + +; Installation directory DefaultDirName={autopf}\{#MyAppName} DefaultGroupName={#MyAppName} DisableProgramGroupPage=yes +PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed=dialog + +; Output OutputDir=installer_output -OutputBaseFilename=VoxFlow_Setup_v1.0.0 +OutputBaseFilename=VoxFlow_Setup_v{#MyAppVersion} SetupIconFile=assets\voxflow.ico UninstallDisplayIcon={app}\{#MyAppExeName} -Compression=lzma2 +UninstallDisplayName={#MyAppName} + +; Compression +Compression=lzma2/ultra64 SolidCompression=yes +LZMAUseSeparateProcess=yes +LZMANumBlockThreads=4 + +; Visual style WizardStyle=modern +WizardResizable=no +WizardSizePercent=100 + +; Windows version requirement +MinVersion=10.0 ArchitecturesAllowed=x64compatible ArchitecturesInstallIn64BitMode=x64compatible +; Show "This will take a moment" during extraction +ShowLanguageDialog=auto + +; Restart behavior +CloseApplications=yes +RestartApplications=no + [Languages] -Name: "polish"; MessagesFile: "compiler:Languages\Polish.isl" +Name: "polish"; MessagesFile: "compiler:Languages\Polish.isl" Name: "english"; MessagesFile: "compiler:Default.isl" +; ─── Welcome page text ──────────────────────────────────────────── +[Messages] +polish.WelcomeLabel1=Witaj w kreatorze instalacji programu [name] +polish.WelcomeLabel2=Program [name] to bezpłatne narzędzie do dyktowania głosem na komputer.%n%nDziała w 100%% offline — bez chmury, bez subskrypcji.%n%nZalecane jest zamknięcie wszystkich innych aplikacji przed kontynuowaniem. +english.WelcomeLabel1=Welcome to the [name] Setup Wizard +english.WelcomeLabel2=[name] is a free, open-source voice dictation tool for your PC.%n%nWorks 100%% offline — no cloud, no subscription required.%n%nIt is recommended that you close all other applications before continuing. + +[CustomMessages] +polish.CreateDesktopShortcut=Utwórz skrót na pulpicie +polish.RunAtStartup=Uruchamiaj VoxFlow razem z Windows (zalecane) +polish.PostInstallInfo=VoxFlow został pomyślnie zainstalowany!%n%nPrzy pierwszym uruchomieniu aplikacja pobierze model AI (~500 MB). Wymagane jednorazowe połączenie z internetem. +english.CreateDesktopShortcut=Create a desktop shortcut +english.RunAtStartup=Launch VoxFlow with Windows (recommended) +english.PostInstallInfo=VoxFlow has been successfully installed!%n%nOn first launch, the app will download the AI model (~500 MB). A one-time internet connection is required. + [Tasks] -Name: "desktopicon"; Description: "Utwórz ikonę na pulpicie"; GroupDescription: "Dodatkowe ikony:"; Flags: unchecked -Name: "startupicon"; Description: "Uruchamiaj VoxFlow razem z Windows"; GroupDescription: "Autostart:"; Flags: unchecked +Name: "desktopicon"; Description: "{cm:CreateDesktopShortcut}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: checked +Name: "startupentry"; Description: "{cm:RunAtStartup}"; GroupDescription: "Autostart:"; Flags: unchecked [Files] +; Main application files (built by PyInstaller) Source: "dist\VoxFlow\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs [Icons] -Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +; Start menu +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; WorkingDir: "{app}" Name: "{group}\Odinstaluj {#MyAppName}"; Filename: "{uninstallexe}" -Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon -Name: "{userstartup}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: startupicon + +; Desktop shortcut (optional) +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; WorkingDir: "{app}"; Tasks: desktopicon + +; Startup (optional) +Name: "{userstartup}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; WorkingDir: "{app}"; Tasks: startupentry + +[Registry] +; Register in "Add/Remove Programs" with additional info +Root: HKCU; Subkey: "Software\AI Evolution Polska\VoxFlow"; ValueType: string; ValueName: "InstallPath"; ValueData: "{app}"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\AI Evolution Polska\VoxFlow"; ValueType: string; ValueName: "Version"; ValueData: "{#MyAppVersion}" [Run] -Filename: "{app}\{#MyAppExeName}"; Description: "Uruchom VoxFlow"; Flags: nowait postinstall skipifsilent +; Offer to launch after install +Filename: "{app}\{#MyAppExeName}"; Description: "Uruchom VoxFlow teraz"; Flags: nowait postinstall skipifsilent + +[UninstallDelete] +; Remove config folder on uninstall (optional — comment out to keep user settings) +; Type: filesandordirs; Name: "{userappdata}\VoxFlow" + +[Code] +// ─── Check Windows version ───────────────────────────────────── +function InitializeSetup(): Boolean; +begin + Result := True; + if not IsWin64 then + begin + MsgBox('VoxFlow wymaga 64-bitowej wersji systemu Windows.' + #13#10 + + 'VoxFlow requires 64-bit Windows.', mbError, MB_OK); + Result := False; + end; +end; + +// ─── Show info after install ──────────────────────────────────── +procedure CurStepChanged(CurStep: TSetupStep); +begin + if CurStep = ssPostInstall then + begin + MsgBox(CustomMessage('PostInstallInfo'), mbInformation, MB_OK); + end; +end; diff --git a/voxflow/__init__.py b/voxflow/__init__.py index 15f6032..91fa437 100644 --- a/voxflow/__init__.py +++ b/voxflow/__init__.py @@ -3,7 +3,7 @@ Built by AI Evolution Polska https://github.com/aievolutionpl/VoxFlow """ -__version__ = "1.0.0" +__version__ = "1.1.0" __app_name__ = "VoxFlow" __author__ = "AI Evolution Polska" __license__ = "MIT" diff --git a/voxflow/app.py b/voxflow/app.py index bed23fb..5c5c6d5 100644 --- a/voxflow/app.py +++ b/voxflow/app.py @@ -16,13 +16,26 @@ from voxflow.recorder import AudioRecorder from voxflow.transcriber import VoxTranscriber from voxflow.hotkey_manager import HotkeyManager -from voxflow.tray import TrayManager from voxflow.auto_typer import AutoTyper from voxflow import sounds from voxflow.overlay import RecordingOverlay -from voxflow.autostart import set_autostart, is_autostart_enabled from voxflow import __version__, __author__ +# Optional modules — gracefully degrade if unavailable +try: + from voxflow.tray import TrayManager + _TRAY_AVAILABLE = True +except Exception: + _TRAY_AVAILABLE = False + +try: + from voxflow.autostart import set_autostart, is_autostart_enabled + _AUTOSTART_AVAILABLE = True +except Exception: + _AUTOSTART_AVAILABLE = False + def set_autostart(_enabled: bool) -> None: pass + def is_autostart_enabled() -> bool: return False + # ─── Color Palette ──────────────────────────────────────────────────────────── C = { @@ -82,6 +95,7 @@ def __init__(self): max_duration=self.config.max_recording_duration, device_index=self.config.audio_device_index, on_level_change=self._on_level, + on_device_fallback=self._on_device_fallback, ) self.transcriber = VoxTranscriber( @@ -96,11 +110,14 @@ def __init__(self): on_release=self._on_hotkey_release, ) - self.tray = TrayManager( - on_show=self._show, - on_toggle_recording=lambda: self.after(0, self._toggle_recording), - on_quit=self._quit, - ) + if _TRAY_AVAILABLE: + self.tray = TrayManager( + on_show=self._show, + on_toggle_recording=lambda: self.after(0, self._toggle_recording), + on_quit=self._quit, + ) + else: + self.tray = None self.auto_typer = AutoTyper() self.overlay = RecordingOverlay() @@ -111,6 +128,7 @@ def __init__(self): self._start_services() self.protocol("WM_DELETE_WINDOW", self._on_close) + self.bind("", self._on_window_configure) self._animate() # ═══════════════════════════════════════════════════════════════ @@ -634,18 +652,18 @@ def opt_row(parent, label, values, var, cmd, width=130): # ── Footer ──────────────────────────────────────────────────── def _build_footer(self): - f = ctk.CTkFrame(self.main, fg_color="transparent") - f.pack(fill="x", pady=(0, 4)) + self.footer_frame = ctk.CTkFrame(self.main, fg_color="transparent") + self.footer_frame.pack(fill="x", pady=(0, 4)) ctk.CTkLabel( - f, + self.footer_frame, text=f"VoxFlow v{__version__} • faster-whisper • 100% lokalne", font=ctk.CTkFont(size=9), text_color=C["txt3"], ).pack() ctk.CTkLabel( - f, + self.footer_frame, text=f"Open Source • {__author__}", font=ctk.CTkFont(size=9, weight="bold"), text_color=C["accent_dim"], @@ -661,10 +679,9 @@ def _toggle_settings(self): self._settings_visible = False self.settings_btn.configure(fg_color=C["bg_card"]) else: - children = self.main.winfo_children() self.settings_frame.pack( fill="x", pady=(0, 6), - before=children[-1], + before=self.footer_frame, # Always insert just before footer ) self._settings_visible = True self.settings_btn.configure(fg_color=C["accent_dim"]) @@ -767,6 +784,49 @@ def _on_mic_change(self, label: str): text=f"🎙 Mikrofon: {dev_name[:40]}", text_color=C["ok"] ) + def _on_device_fallback(self, fallback_index: int): + """Called by AudioRecorder when it falls back to the default device.""" + self.config.audio_device_index = fallback_index + self.config.save() + # Update mic dropdown to show "Domyślny mikrofon" + self.after(0, lambda: ( + self.mic_var.set("🎤 Domyślny mikrofon") + if hasattr(self, "mic_var") else None + )) + self.after( + 0, + lambda: self.status.configure( + text="⚠️ Mikrofon niedostępny, przełączono na domyślny", + text_color=C["warn"], + ), + ) + + def _on_window_configure(self, event=None): + """Save window dimensions whenever the window is resized. + + Debounced: cancels any pending save and schedules a new one + 400 ms after the last resize event. + """ + if hasattr(self, "_resize_job") and self._resize_job: + try: + self.after_cancel(self._resize_job) + except Exception: + pass + self._resize_job = self.after(400, self._save_window_size) + + def _save_window_size(self): + """Persist the current window dimensions to config.""" + try: + w = self.winfo_width() + h = self.winfo_height() + if w > 100 and h > 100: # sanity check + self.config.window_width = w + self.config.window_height = h + self.config.save() + except Exception: + pass + self._resize_job = None + # ═══════════════════════════════════════════════════════════════ # RECORDING (Hold-to-Record) # ═══════════════════════════════════════════════════════════════ @@ -790,7 +850,8 @@ def _start_rec(self): return self._recording = True self.status.configure(text="🔴 Nagrywam... Mów teraz!", text_color=C["rec_red"]) - self.tray.set_recording(True) + if self.tray: + self.tray.set_recording(True) if self.config.play_sounds: sounds.play("start") self.overlay.show(self) @@ -801,7 +862,8 @@ def _stop_rec(self): return self._recording = False self._processing = True - self.tray.set_recording(False) + if self.tray: + self.tray.set_recording(False) self.overlay.hide() if self.config.play_sounds: sounds.play("stop") diff --git a/voxflow/config.py b/voxflow/config.py index 8020e04..da626a3 100644 --- a/voxflow/config.py +++ b/voxflow/config.py @@ -19,11 +19,8 @@ def get_config_dir() -> Path: # Valid value ranges for security validation _VALID_MODELS = {"tiny", "base", "small", "medium", "large-v3"} _VALID_LANGUAGES = {"auto", "pl", "en"} -_VALID_HOTKEYS = { - "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", - "ctrl+space", "ctrl+shift+space", "caps lock", "insert", - "scroll lock", "pause", -} +# Hotkey validation: allow any non-empty string (hotkey picker can produce +# arbitrary key names). We only enforce it's a safe non-empty string. _VALID_TYPING_METHODS = {"clipboard", "keyboard"} _VALID_THEMES = {"dark", "light"} @@ -64,8 +61,10 @@ def _validate_config(data: dict) -> dict: validated[key] = default_val elif key == "language" and value not in _VALID_LANGUAGES: validated[key] = default_val - elif key == "hotkey" and str(value).lower() not in _VALID_HOTKEYS: - validated[key] = default_val + elif key == "hotkey": + # Accept any non-empty string — hotkey picker sets arbitrary keys + s = str(value).strip().lower() + validated[key] = s if s else default_val elif key == "typing_method" and value not in _VALID_TYPING_METHODS: validated[key] = default_val elif key == "theme" and value not in _VALID_THEMES: diff --git a/voxflow/hotkey_manager.py b/voxflow/hotkey_manager.py index 64300c4..f7085aa 100644 --- a/voxflow/hotkey_manager.py +++ b/voxflow/hotkey_manager.py @@ -11,6 +11,15 @@ import threading from typing import Optional, Callable +# Keys that should NOT be accepted as standalone hotkeys (only as modifiers) +_MODIFIER_KEYS = frozenset({ + "shift", "ctrl", "alt", "windows", "win", + "left shift", "right shift", + "left ctrl", "right ctrl", + "left alt", "right alt", + "left windows", "right windows", +}) + class HotkeyManager: """Manages hold-to-record hotkey using keyboard hook. @@ -80,25 +89,40 @@ def update_hotkey(self, new_hotkey: str): if was_active: self.start() - def capture_next_key(self, timeout: float = 5.0) -> Optional[str]: - """Block and wait for the next key press, return its name. - - Used by the UI to let users interactively pick their hotkey. - Returns None if timeout expires or an error occurs. + def capture_next_key(self, timeout: float = 8.0) -> Optional[str]: + """Wait for the next key press and return its name. + + Runs in a background thread with a strict timeout so it can't + hang the UI. Press Escape to cancel. + + Returns: + Key name string, or None if timeout/cancelled/error. """ - try: - import keyboard - event = keyboard.read_event(suppress=False) - if event and event.event_type == "down": - name = event.name.lower() - # Filter out just-modifier presses - if name not in ("shift", "ctrl", "alt", "windows", "left shift", - "right shift", "left ctrl", "right ctrl", - "left alt", "right alt"): - return name - except Exception as e: - print(f"capture_next_key error: {e}") - return None + result: list[Optional[str]] = [None] + done = threading.Event() + + def _read(): + try: + import keyboard + while not done.is_set(): + event = keyboard.read_event(suppress=False) + if event and event.event_type == "down": + name = event.name.lower() + if name == "escape": + done.set() # cancelled + return + if name not in _MODIFIER_KEYS: + result[0] = name + done.set() + return + except Exception as e: + print(f"capture_next_key error: {e}") + done.set() + + t = threading.Thread(target=_read, daemon=True) + t.start() + done.wait(timeout=timeout) + return result[0] def _on_key_event(self, event): """Handle single key events — detect press (down) and release (up).""" diff --git a/voxflow/recorder.py b/voxflow/recorder.py index 695cdbd..9b00de1 100644 --- a/voxflow/recorder.py +++ b/voxflow/recorder.py @@ -17,6 +17,7 @@ def __init__( max_duration: float = 300.0, device_index: int = -1, on_level_change: Optional[Callable[[float], None]] = None, + on_device_fallback: Optional[Callable[[int], None]] = None, ): self.sample_rate = sample_rate self.channels = channels @@ -25,6 +26,7 @@ def __init__( self.max_duration = max_duration self.device_index = device_index # -1 = system default self.on_level_change = on_level_change + self.on_device_fallback = on_device_fallback self._recording = False self._audio_chunks: list[np.ndarray] = [] @@ -70,6 +72,12 @@ def start(self): if device is not None: print(f"Device {device} failed, falling back to default: {e}") self.device_index = -1 + # Notify app so it can update UI and config permanently + if self.on_device_fallback: + try: + self.on_device_fallback(-1) + except Exception: + pass self._stream = sd.InputStream( samplerate=self.sample_rate, channels=self.channels, From 15cb95011b12915336413427c853f2523c905677 Mon Sep 17 00:00:00 2001 From: aichristabasco Date: Sat, 21 Feb 2026 14:14:36 +0000 Subject: [PATCH 4/5] feat: recording waveform animation at bottom, new portable script, improved README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - overlay.py: moved recording animation from top-right to bottom-center of screen (560x72px), 28 animated waveform bars + pulsing mic icon, color gradient purple→red based on audio amplitude - CREATE_PORTABLE.bat: new script creating portable version with working START_VOXFLOW.bat launcher (fixes portable startup error) - README.md: rewritten with 3-step quick-start guide at top, clearer usage table, EXE/Portable sections - install.bat: creates START_VOXFLOW.bat automatically after install, fixed launch to use venv/Scripts/pythonw.exe - cleanup: removed old VoxFlow_Portable_v1.0.0 folder and ZIP" --- CREATE_PORTABLE.bat | 133 +++++++++++++++++++++++++++++ README.md | 134 +++++++++++++++-------------- START_VOXFLOW.bat | 45 ---------- install.bat | 21 +++-- voxflow/main.py | 6 +- voxflow/overlay.py | 200 +++++++++++++++++++++++++------------------- 6 files changed, 334 insertions(+), 205 deletions(-) create mode 100644 CREATE_PORTABLE.bat delete mode 100644 START_VOXFLOW.bat diff --git a/CREATE_PORTABLE.bat b/CREATE_PORTABLE.bat new file mode 100644 index 0000000..a39e4ac --- /dev/null +++ b/CREATE_PORTABLE.bat @@ -0,0 +1,133 @@ +@echo off +chcp 65001 >nul +cd /d "%~dp0" +title VoxFlow — Tworzenie wersji Portable + +echo. +echo ╔══════════════════════════════════════════════════════════╗ +echo ║ VoxFlow — Tworzenie wersji Portable ║ +echo ║ by AI Evolution Polska ║ +echo ╚══════════════════════════════════════════════════════════╝ +echo. + +REM ─── Ustal wersję ────────────────────────────────────────────── +set VER=1.1.0 +set PORTABLE_DIR=dist\VoxFlow_Portable_v%VER% +set PORTABLE_APP=%PORTABLE_DIR%\VoxFlow + +REM ─── Krok 0: Sprawdź venv ────────────────────────────────────── +if not exist "venv\Scripts\activate.bat" ( + echo ❌ Nie znaleziono środowiska venv. + echo Uruchom najpierw install.bat + echo. + pause & exit /b 1 +) +call venv\Scripts\activate.bat + +REM ─── Krok 1: Zbuduj EXE jeśli nie istnieje ──────────────────── +echo [1/4] Sprawdzam czy VoxFlow.exe jest zbudowany... +if exist "dist\VoxFlow\VoxFlow.exe" ( + echo ✅ VoxFlow.exe już istnieje — pomijam build +) else ( + echo ⏳ Buduję VoxFlow.exe przez PyInstaller... + echo (To może potrwać 3-6 minut) + pip install pyinstaller --quiet --disable-pip-version-check + pyinstaller VoxFlow.spec --noconfirm --clean + if %errorlevel% neq 0 ( + echo ❌ Build nie powiódł się! + pause & exit /b 1 + ) + if not exist "dist\VoxFlow\VoxFlow.exe" ( + echo ❌ Nie znaleziono dist\VoxFlow\VoxFlow.exe + pause & exit /b 1 + ) + echo ✅ VoxFlow.exe zbudowany +) + +REM ─── Krok 2: Wyczyść stary portable folder ──────────────────── +echo. +echo [2/4] Przygotowanie katalogu portable... +if exist "%PORTABLE_DIR%" ( + echo 🗑 Usuwam stary folder %PORTABLE_DIR%... + rmdir /s /q "%PORTABLE_DIR%" +) +mkdir "%PORTABLE_DIR%" + +REM ─── Krok 3: Kopiuj pliki ───────────────────────────────────── +echo. +echo [3/4] Kopiowanie plików VoxFlow... +xcopy "dist\VoxFlow\*" "%PORTABLE_APP%\" /E /I /Q +if %errorlevel% neq 0 ( + echo ❌ Błąd kopiowania plików! + pause & exit /b 1 +) + +REM ─── Stwórz launcher START_VOXFLOW.bat ──────────────────────── +echo @echo off > "%PORTABLE_DIR%\START_VOXFLOW.bat" +echo chcp 65001 ^>nul >> "%PORTABLE_DIR%\START_VOXFLOW.bat" +echo cd /d "%%~dp0VoxFlow" >> "%PORTABLE_DIR%\START_VOXFLOW.bat" +echo start "" "VoxFlow.exe" >> "%PORTABLE_DIR%\START_VOXFLOW.bat" + +REM ─── Stwórz README_PORTABLE.txt ─────────────────────────────── +( +echo VoxFlow %VER% — Wersja Portable +echo ================================ +echo. +echo JAK URUCHOMIĆ: +echo 1. Kliknij dwukrotnie START_VOXFLOW.bat +echo (lub uruchom VoxFlow\VoxFlow.exe bezpośrednio) +echo. +echo JAK DZIAŁA: +echo - Przytrzymaj F2 i mów - pojawi sie animacja nagrywania na dole ekranu +echo - Zwolnij F2 - tekst zostanie wklejony w aktywnym oknie +echo - Kliknij przycisk klawisza w UI zeby zmienić skrót klawiszowy +echo - Ikona w zasobniku - kliknij prawym przyciskiem dla opcji +echo. +echo WYMAGANIA: +echo - Windows 10/11 (64-bit) +echo - Mikrofon +echo - Połączenie z internetem (TYLKO przy pierwszym uruchomieniu +echo - do pobrania modelu AI ~500 MB) +echo. +echo WAŻNE: +echo - NIE przenoś samego VoxFlow.exe bez całego folderu VoxFlow\ +echo - Możesz przenieść cały folder VoxFlow_Portable_v%VER% gdzie chcesz +echo - Ustawienia są zapisywane w: %%APPDATA%%\VoxFlow\ +echo. +echo by AI Evolution Polska - https://github.com/aievolutionpl/VoxFlow +) > "%PORTABLE_DIR%\README_PORTABLE.txt" + +echo. +echo [4/4] Gotowe! +echo. + +REM ─── Zapytaj czy zipować ────────────────────────────────────── +set /p DOZIP="Czy spakować do ZIP? [T/n]: " +if /i "%DOZIP%"=="n" goto :done + +echo. +echo 📦 Pakuję do ZIP... + +REM Użyj PowerShell do zipowania +powershell -Command "Compress-Archive -Path '%PORTABLE_DIR%' -DestinationPath 'dist\VoxFlow_Portable_v%VER%.zip' -Force" + +if %errorlevel% equ 0 ( + echo ✅ ZIP stworzony: dist\VoxFlow_Portable_v%VER%.zip +) else ( + echo ⚠️ Nie udało się stworzyć ZIP. Folder istnieje: %PORTABLE_DIR% +) + +:done +echo. +echo ╔══════════════════════════════════════════════════════════╗ +echo ║ ║ +echo ║ ✅ Wersja Portable gotowa! ║ +echo ║ ║ +echo ║ Folder: %PORTABLE_DIR% ║ +echo ║ Launcher: START_VOXFLOW.bat ║ +echo ║ ║ +echo ╚══════════════════════════════════════════════════════════╝ +echo. + +start "" explorer "%PORTABLE_DIR%" +pause diff --git a/README.md b/README.md index dace4e2..ee67d50 100644 --- a/README.md +++ b/README.md @@ -7,127 +7,125 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-7c3aed.svg)](LICENSE) [![Python](https://img.shields.io/badge/Python-3.9%2B-blue)](https://python.org) [![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20macOS-lightgrey)](https://github.com/aievolutionpl/VoxFlow) -[![AI Evolution Polska](https://img.shields.io/badge/by-AI_Evolution_Polska-7c3aed)](https://github.com/aievolutionpl) +[![by AI Evolution Polska](https://img.shields.io/badge/by-AI_Evolution_Polska-7c3aed)](https://github.com/aievolutionpl) -**VoxFlow** to bezpłatne, open-source narzędzie do dyktowania głosem na komputer. -Zamień głos w tekst jednym przytrzymaniem klawisza — bez chmury, bez subskrypcji. +**VoxFlow** to bezpłatne narzędzie do dyktowania głosem. +Przytrzymaj klawisz → mów → tekst pojawia się w aktywnym oknie. Zero chmury, zero subskrypcji. -[🪟 Instalacja Windows](#-instalacja--windows) • [🍎 Instalacja macOS](#-instalacja--macos) • [✨ Funkcje](#-funkcje) • [📖 Użytkowanie](#-użytkowanie) + --- -> **Free & Open Source speech-to-text dictation for Windows and macOS.** -> Hold your hotkey → speak → text appears. 100% local, no cloud, no subscription. +## ⚡ Szybki start (Windows) — 3 kroki - +> **Wymagania:** Windows 10/11 (64-bit), mikrofon, połączenie z internetem (tylko przy pierwszym uruchomieniu do pobrania modelu AI ~500 MB) ---- +### Krok 1 — Zainstaluj Python -## ✨ Funkcje / Features +Pobierz Python 3.10+ ze strony **[python.org/downloads](https://www.python.org/downloads/)**. -| 🇵🇱 | 🇬🇧 | -|-----|-----| -| 🎤 Dyktowanie trzymając klawisz | Hold-to-record with configurable hotkey | -| 🌍 Polski + Angielski (auto-detekcja) | Polish + English (auto-detection) | -| ⚡ 100% lokalne — zero chmury | 100% local — zero cloud | -| 🧠 OpenAI Whisper (faster-whisper) | OpenAI Whisper (faster-whisper) | -| ✍️ Auto-wpisywanie w aktywne okno | Auto-type into active window | -| 🎙 Wybór urządzenia mikrofonowego | Microphone device selection | -| ⌨️ Dowolny klawisz (naciśnij aby ustawić) | Any hotkey (press to set) | -| 📋 Auto-kopiowanie do schowka | Auto-copy to clipboard | -| 🔲 Ikona w zasobniku systemowym | System tray icon | -| 📚 Historia nagrań | Recording history | -| ✨ Autokorekta tekstu | Text auto-correction | +> ⚠️ **Ważne:** podczas instalacji zaznacz opcję **"Add Python to PATH"**! ---- +### Krok 2 — Zainstaluj VoxFlow -## 🪟 Instalacja — Windows +Kliknij dwukrotnie plik **`install.bat`** i poczekaj aż zakończy instalację (~2–5 minut). -### Wymagania -- Windows 10 / 11 (64-bit) -- **Python 3.9+** z opcją [Add Python to PATH](https://www.python.org/downloads/) -- Mikrofon +### Krok 3 — Uruchom -### Instalacja w 2 krokach +Kliknij dwukrotnie **`START_VOXFLOW.bat`** (tworzony automatycznie przez install.bat). -``` -1. Pobierz Python 3.10+ ze strony python.org (zaznacz "Add Python to PATH") -2. Kliknij dwukrotnie install.bat -``` +--- + +## 🖥️ Jak działa VoxFlow? + +| Akcja | Efekt | +|-------|-------| +| Przytrzymaj `F2` i mów | 🔴 Nagrywa — animacja fal pojawia się na dole ekranu | +| Zwolnij `F2` | ⏳ AI przetwarza mowę → tekst wklejony w aktywnym oknie | +| Kliknij przycisk klawisza w UI | Ustaw własny klawisz dyktowania | +| Kliknij dropdown mikrofonu | Wybierz urządzenie audio | +| Kliknij `⚙` | Otwórz zaawansowane ustawienia | -Szczegółowa instrukcja: [INSTALL_WINDOWS.md](INSTALL_WINDOWS.md) +Po uruchomieniu VoxFlow minimalizuje się do **ikony w zasobniku systemowym** (prawy dolny róg). +Podwójne kliknięcie na ikonę → przywraca okno. --- -## 🍎 Instalacja — macOS +## 📦 Wersja EXE (bez Pythona) -### Wymagania -- macOS 11+ (Intel lub Apple Silicon M1/M2/M3) -- Python 3.9+ (instalowany automatycznie) -- Mikrofon +Jeśli chcesz wersję gotową `.exe` — **nie wymaga Pythona**: -### Instalacja w 2 krokach +**Opcja A — Instalator (zalecana):** -```bash -chmod +x install_mac.sh -./install_mac.sh +``` +BUILD_EXE.bat → tworzy VoxFlow_Setup.exe ``` -> ⚠️ Globalny hotkey wymaga uprawnień **Accessibility** w System Settings. -> Szczegóły: [INSTALL_MAC.md](INSTALL_MAC.md) +Wymaga [Inno Setup 6](https://jrsoftware.org/isinfo.php). ---- +**Opcja B — Portable:** + +``` +CREATE_PORTABLE.bat → tworzy folder dist\VoxFlow_Portable_v1.1.0\ +``` -## 📖 Użytkowanie / Usage +Uruchom `START_VOXFLOW.bat` z folderu portable — gotowe, bez instalacji. -1. **Uruchom VoxFlow** — pojawi się okno aplikacji i ikona w zasobniku -2. **Przytrzymaj klawisz** (domyślnie `F2`) i mów -3. **Zwolnij klawisz** — tekst pojawi się automatycznie w aktywnym oknie -4. **Zmień klawisz** — kliknij przycisk klawisza i naciśnij dowolny klawisz -5. **Zmień mikrofon** — użyj dropdown'u z listą urządzeń +--- -### Obsługiwane klawisze -`F2` `F3` `F4` `F5` `F6` `F7` `F8` `F9` `F10` `Ctrl+Space` `CapsLock` `Insert` i inne +## ✨ Funkcje + +| 🇵🇱 | 🇬🇧 | +|-----|-----| +| 🎤 Dyktowanie przytrzymując klawisz | Hold-to-record hotkey | +| 🌍 Polski + Angielski (auto-detekcja) | Polish + English (auto-detect) | +| ⚡ 100% lokalne — zero chmury | 100% local — no cloud | +| 🧠 OpenAI Whisper (faster-whisper) | OpenAI Whisper (faster-whisper) | +| ✍️ Auto-wpisywanie w aktywne okno | Auto-type into active window | +| 🎙 Wybór mikrofonu | Microphone device selection | +| ⌨️ Dowolny klawisz dyktowania | Configurable hotkey | +| 📋 Auto-kopiowanie do schowka | Auto-copy to clipboard | +| 🌊 Animacja fal audio podczas nagrywania | Audio waveform animation | +| 🔲 Ikona w zasobniku | System tray icon | +| 📚 Historia nagrań | Recording history | --- -## 🧠 Wybór modelu Whisper +## 🧠 Modele Whisper -| Model | Rozmiar | Szybkość | Jakość | Rekomendacja | -|-------|---------|----------|--------|--------------| +| Model | Rozmiar | Szybkość | Jakość | Dla kogo | +|-------|---------|----------|--------|----------| | `tiny` | ~75 MB | ⚡⚡⚡⚡ | ⭐⭐ | Testy | | `base` | ~150 MB | ⚡⚡⚡ | ⭐⭐⭐ | Słaby PC | | `small` | ~500 MB | ⚡⚡ | ⭐⭐⭐⭐ | **Zalecany** | | `medium` | ~1.5 GB | ⚡ | ⭐⭐⭐⭐⭐ | Dobry PC | | `large-v3` | ~3 GB | 🐢 | ⭐⭐⭐⭐⭐ | GPU | -> Pierwsza zmiana modelu wymaga pobrania jego pliku (jednorazowo). +> Model pobiera się automatycznie przy pierwszej zmianie (jednorazowo). --- -## 🛠️ Budowanie .exe (Windows) +## 🍎 Instalacja macOS ```bash -BUILD_EXE.bat +chmod +x install_mac.sh +./install_mac.sh ``` -Wymaga [Inno Setup 6](https://jrsoftware.org/isinfo.php) do stworzenia instalatora. +> ⚠️ Globalny hotkey wymaga uprawnień **Accessibility** w System Settings. +> Szczegóły: [INSTALL_MAC.md](INSTALL_MAC.md) --- -## 📜 Licencja / License +## 📜 Licencja -MIT License — bezpłatny, open source. -Możesz używać, modyfikować i dystrybuować bez ograniczeń. +MIT License — bezpłatny, open source. Użyj, modyfikuj, dystrybuuj bez ograniczeń. --- -## 🤝 Twórca / Author -

-**AI Evolution Polska** -Bezpłatne narzędzia AI dla polskich użytkowników +**AI Evolution Polska** — Bezpłatne narzędzia AI dla polskich użytkowników [![GitHub](https://img.shields.io/badge/GitHub-aievolutionpl-181717?logo=github)](https://github.com/aievolutionpl) diff --git a/START_VOXFLOW.bat b/START_VOXFLOW.bat deleted file mode 100644 index ca50eae..0000000 --- a/START_VOXFLOW.bat +++ /dev/null @@ -1,45 +0,0 @@ -@echo off -title VoxFlow - Local Speech-to-Text (AI Evolution Polska) -echo. -echo ╔═══════════════════════════════════════════╗ -echo ║ 🎤 VoxFlow Setup ^& Launch ║ -echo ║ Local Speech-to-Text (PL ^& EN) ║ -echo ║ by AI Evolution Polska ║ -echo ╚═══════════════════════════════════════════╝ -echo. - -REM Check if Python is available -python --version >nul 2>&1 -if %errorlevel% neq 0 ( - echo ❌ Python nie jest zainstalowany! - echo Pobierz z: https://python.org - pause - exit /b 1 -) - -REM Check if venv exists, create if not -if not exist "venv" ( - echo 📦 Tworzenie środowiska wirtualnego... - python -m venv venv - if %errorlevel% neq 0 ( - echo ❌ Nie udało się stworzyć venv - pause - exit /b 1 - ) -) - -REM Activate venv -call venv\Scripts\activate.bat - -REM Install/update dependencies -echo 📦 Sprawdzanie zależności... -pip install -r requirements.txt --quiet --disable-pip-version-check 2>nul - -REM Launch VoxFlow -echo. -echo 🚀 Uruchamiam VoxFlow... -echo. -python -m voxflow.main %* - -REM Deactivate on exit -deactivate diff --git a/install.bat b/install.bat index a2a2673..bc80883 100644 --- a/install.bat +++ b/install.bat @@ -105,13 +105,24 @@ if %errorlevel% neq 0 ( echo. echo ═══════════════════════════════════════════════════════ echo ✅ VoxFlow zainstalowany pomyślnie! + +REM ─── Stwórz START_VOXFLOW.bat ────────────────────────────── +echo @echo off > START_VOXFLOW.bat +echo chcp 65001 ^>nul >> START_VOXFLOW.bat +echo cd /d "%%~dp0" >> START_VOXFLOW.bat +echo call venv\Scripts\activate.bat >> START_VOXFLOW.bat +echo start "" venv\Scripts\pythonw.exe -m voxflow.main >> START_VOXFLOW.bat + +echo. +echo ═══════════════════════════════════════════════════════ +echo ✅ Instalacja zakończona! echo. echo Uruchom aplikację: -echo • Uruchom START_VOXFLOW.bat -echo • lub: venv\Scripts\activate ^& python -m voxflow.main +echo 👉 Kliknij dwukrotnie START_VOXFLOW.bat echo. -echo Skrót na pulpicie zostanie utworzony przy pierwszym -echo uruchomieniu START_VOXFLOW.bat +echo Lub ręcznie: +echo venv\Scripts\activate +echo python -m voxflow.main echo ═══════════════════════════════════════════════════════ echo. @@ -120,7 +131,7 @@ if /i "%LAUNCH%"=="n" goto :end echo. echo 🚀 Uruchamianie VoxFlow... -start "" pythonw -m voxflow.main +start "" venv\Scripts\pythonw.exe -m voxflow.main :end deactivate echo. diff --git a/voxflow/main.py b/voxflow/main.py index c6e9f4c..389d69d 100644 --- a/voxflow/main.py +++ b/voxflow/main.py @@ -12,8 +12,10 @@ def main(): # Fix encoding for Windows consoles if sys.platform == "win32": - sys.stdout.reconfigure(encoding='utf-8') - sys.stderr.reconfigure(encoding='utf-8') + if sys.stdout is not None: + sys.stdout.reconfigure(encoding='utf-8') + if sys.stderr is not None: + sys.stderr.reconfigure(encoding='utf-8') # Handle --test mode if "--test" in sys.argv: diff --git a/voxflow/overlay.py b/voxflow/overlay.py index 3dd4743..179f7e7 100644 --- a/voxflow/overlay.py +++ b/voxflow/overlay.py @@ -1,24 +1,31 @@ """VoxFlow Recording Overlay — On-screen animation while recording. -Shows a small floating, always-on-top indicator with a pulsing red dot -and audio waveform animation. The overlay is transparent and click-through +Shows a sleek floating bar at the BOTTOM-CENTER of the screen with +an animated audio waveform. The overlay is transparent and click-through so it doesn't interfere with the user's work. Built by AI Evolution Polska """ import threading import math -import time import tkinter as tk from typing import Optional class RecordingOverlay: - """Floating overlay that shows recording animation on screen.""" + """Floating overlay that shows recording waveform animation at bottom of screen.""" - WIDTH = 200 - HEIGHT = 60 - CORNER_MARGIN = 20 + WIDTH = 560 + HEIGHT = 72 + BOTTOM_MARGIN = 80 # pixels from bottom of screen + + # Colours + BG = "#0d0d1f" + BORDER = "#7c3aed" + RED = "#ef4444" + PURPLE = "#a78bfa" + PURPLE2 = "#7c3aed" + GREY = "#94a3b8" def __init__(self): self._root: Optional[tk.Toplevel] = None @@ -26,9 +33,10 @@ def __init__(self): self._alive = False self._phase = 0.0 self._level = 0.0 - self._thread: Optional[threading.Thread] = None self._parent = None + # ── Public API ──────────────────────────────────────────────── + def show(self, parent=None): """Show the recording overlay on screen.""" if self._alive: @@ -38,8 +46,7 @@ def show(self, parent=None): if parent: parent.after(0, self._create_window) else: - self._thread = threading.Thread(target=self._run_standalone, daemon=True) - self._thread.start() + threading.Thread(target=self._run_standalone, daemon=True).start() def hide(self): """Hide the recording overlay.""" @@ -59,7 +66,9 @@ def hide(self): def set_level(self, level: float): """Update the audio level (0.0 - 1.0).""" - self._level = min(1.0, level * 5) + self._level = min(1.0, max(0.0, level)) + + # ── Window management ───────────────────────────────────────── def _create_window(self): """Create the overlay window (must be called from main thread).""" @@ -70,22 +79,22 @@ def _create_window(self): self._root = tk.Tk() win = self._root - win.overrideredirect(True) # No window decorations - win.attributes("-topmost", True) # Always on top - win.attributes("-alpha", 0.9) # Slightly transparent + win.overrideredirect(True) # No title bar / borders + win.attributes("-topmost", True) # Always on top + win.attributes("-alpha", 0.93) # Slightly transparent - # Position: top-right corner of screen + # Position: bottom-center of screen screen_w = win.winfo_screenwidth() - x = screen_w - self.WIDTH - self.CORNER_MARGIN - y = self.CORNER_MARGIN + screen_h = win.winfo_screenheight() + x = (screen_w - self.WIDTH) // 2 + y = screen_h - self.HEIGHT - self.BOTTOM_MARGIN win.geometry(f"{self.WIDTH}x{self.HEIGHT}+{x}+{y}") - # Dark background with rounded appearance - win.configure(bg="#1a1a2e") - + win.configure(bg=self.BG) + self._canvas = tk.Canvas( win, width=self.WIDTH, height=self.HEIGHT, - bg="#1a1a2e", highlightthickness=0, + bg=self.BG, highlightthickness=0, ) self._canvas.pack(fill="both", expand=True) @@ -94,7 +103,6 @@ def _create_window(self): print(f"Overlay error: {e}") def _destroy_window(self): - """Destroy the overlay window.""" try: if self._root: self._root.destroy() @@ -104,88 +112,110 @@ def _destroy_window(self): self._canvas = None def _run_standalone(self): - """Run overlay in its own event loop (fallback).""" self._create_window() if self._root: self._root.mainloop() + # ── Animation ───────────────────────────────────────────────── + def _animate(self): - """Animation loop — pulsing dot + waveform bars.""" if not self._alive or not self._canvas: return - self._phase += 0.15 + self._phase += 0.12 c = self._canvas + W, H = self.WIDTH, self.HEIGHT c.delete("all") - # Background rounded rect - self._round_rect(c, 2, 2, self.WIDTH - 2, self.HEIGHT - 2, 12, - fill="#1a1a2e", outline="#ef4444", width=2) - - # Pulsing red dot - pulse = (math.sin(self._phase * 2) + 1) / 2 - dot_r = 6 + pulse * 3 - dot_x, dot_y = 22, self.HEIGHT // 2 - # Glow - glow_r = dot_r + 4 + pulse * 4 - c.create_oval(dot_x - glow_r, dot_y - glow_r, - dot_x + glow_r, dot_y + glow_r, - fill="#ef444430", outline="") - # Dot - c.create_oval(dot_x - dot_r, dot_y - dot_r, - dot_x + dot_r, dot_y + dot_r, - fill="#ef4444", outline="#dc2626", width=1) - - # "REC" text - c.create_text(48, dot_y - 8, text="● REC", fill="#ef4444", - font=("Segoe UI", 11, "bold"), anchor="w") - # "VoxFlow" small - c.create_text(48, dot_y + 10, text="VoxFlow • AIEP", fill="#94a3b8", - font=("Segoe UI", 8), anchor="w") - - # Waveform bars (right side) - bar_start = 115 - num_bars = 10 - bar_width = 4 - bar_gap = 3 - max_h = 20 + # ── Rounded background panel ────────────────────────────── + self._round_rect(c, 2, 2, W - 2, H - 2, r=16, + fill=self.BG, outline=self.BORDER, width=2) + + # ── Pulsing mic icon (left side) ────────────────────────── + pulse = (math.sin(self._phase * 2.5) + 1) / 2 + cx, cy = 36, H // 2 + + # Outer glow ring + glow_r = 18 + pulse * 5 + c.create_oval(cx - glow_r, cy - glow_r, + cx + glow_r, cy + glow_r, + fill="", outline=self.RED, width=1) + + # Inner filled dot + dot_r = 12 + pulse * 3 + c.create_oval(cx - dot_r, cy - dot_r, + cx + dot_r, cy + dot_r, + fill=self.RED, outline="") + + # Mic body (white) + c.create_oval(cx - 5, cy - 10, cx + 5, cy + 1, + fill="white", outline="") + # Mic stand + c.create_line(cx, cy + 1, cx, cy + 8, fill="white", width=2) + c.create_line(cx - 6, cy + 8, cx + 6, cy + 8, fill="white", width=2) + + # ── Label text ──────────────────────────────────────────── + c.create_text(64, cy - 10, text="🎤 Nagrywam...", + fill=self.RED, font=("Segoe UI", 11, "bold"), anchor="w") + c.create_text(64, cy + 9, text="VoxFlow • zwolnij klawisz aby zakończyć", + fill=self.GREY, font=("Segoe UI", 8), anchor="w") + + # ── Waveform bars (right section) ───────────────────────── + num_bars = 28 + bar_w = 4 + bar_gap = 2 + bar_section_x = 240 + max_h = 22 for i in range(num_bars): - # Mix of audio level and animation - wave = abs(math.sin(self._phase * 2.5 + i * 0.6)) - amp = (self._level * 0.7 + wave * 0.3) * max_h + 2 - x = bar_start + i * (bar_width + bar_gap) - cy = self.HEIGHT // 2 - - # Color gradient from purple to red based on amplitude - if amp > max_h * 0.7: - color = "#ef4444" - elif amp > max_h * 0.4: - color = "#a78bfa" + # Combine audio level with animated sine waves + t = self._phase * 2.8 + i * 0.42 + wave1 = abs(math.sin(t)) + wave2 = abs(math.sin(t * 1.37 + 1.2)) * 0.5 + combined = wave1 * 0.55 + wave2 * 0.15 + + # Boost by real audio level + amp = (combined + self._level * 0.6) * max_h + 3 + amp = min(amp, max_h) + + x = bar_section_x + i * (bar_w + bar_gap) + bar_cy = cy + + # Colour gradient: centre bars brighter + ratio = amp / max_h + if ratio > 0.75: + color = self.RED + elif ratio > 0.45: + color = self.PURPLE else: - color = "#8b5cf6" + color = self.PURPLE2 - c.create_rectangle(x, cy - amp, x + bar_width, cy + amp, - fill=color, outline="") + # Draw symmetric bar + c.create_rectangle( + x, bar_cy - amp, x + bar_w, bar_cy + amp, + fill=color, outline="", + ) + # Rounded cap top + c.create_oval( + x, bar_cy - amp - 2, x + bar_w, bar_cy - amp + 2, + fill=color, outline="", + ) try: if self._root: - self._root.after(50, self._animate) + self._root.after(40, self._animate) except Exception: pass + # ── Helper ──────────────────────────────────────────────────── + @staticmethod - def _round_rect(canvas, x1, y1, x2, y2, radius, **kwargs): - """Draw a rounded rectangle on a canvas.""" - r = radius - canvas.create_arc(x1, y1, x1 + 2*r, y1 + 2*r, start=90, extent=90, - style="pieslice", **kwargs) - canvas.create_arc(x2 - 2*r, y1, x2, y1 + 2*r, start=0, extent=90, - style="pieslice", **kwargs) - canvas.create_arc(x1, y2 - 2*r, x1 + 2*r, y2, start=180, extent=90, - style="pieslice", **kwargs) - canvas.create_arc(x2 - 2*r, y2 - 2*r, x2, y2, start=270, extent=90, - style="pieslice", **kwargs) - canvas.create_rectangle(x1 + r, y1, x2 - r, y2, **kwargs) - canvas.create_rectangle(x1, y1 + r, x1 + r, y2 - r, **kwargs) - canvas.create_rectangle(x2 - r, y1 + r, x2, y2 - r, **kwargs) + def _round_rect(canvas, x1, y1, x2, y2, r=12, **kw): + """Draw a rounded rectangle on a tk Canvas.""" + canvas.create_arc(x1, y1, x1+2*r, y1+2*r, start=90, extent=90, style="pieslice", **kw) + canvas.create_arc(x2-2*r, y1, x2, y1+2*r, start=0, extent=90, style="pieslice", **kw) + canvas.create_arc(x1, y2-2*r, x1+2*r, y2, start=180, extent=90, style="pieslice", **kw) + canvas.create_arc(x2-2*r, y2-2*r, x2, y2, start=270, extent=90, style="pieslice", **kw) + canvas.create_rectangle(x1+r, y1, x2-r, y2, **kw) + canvas.create_rectangle(x1, y1+r, x1+r, y2-r, **kw) + canvas.create_rectangle(x2-r, y1+r, x2, y2-r, **kw) From f837002fd27db17e021d5a5bc00085bcf4443c0b Mon Sep 17 00:00:00 2001 From: aichristabasco Date: Sat, 21 Feb 2026 14:17:14 +0000 Subject: [PATCH 5/5] docs: improve README - remove bold markers, expand install instructions --- README.md | 54 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index ee67d50..4fa44d0 100644 --- a/README.md +++ b/README.md @@ -9,67 +9,69 @@ [![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20macOS-lightgrey)](https://github.com/aievolutionpl/VoxFlow) [![by AI Evolution Polska](https://img.shields.io/badge/by-AI_Evolution_Polska-7c3aed)](https://github.com/aievolutionpl) -**VoxFlow** to bezpłatne narzędzie do dyktowania głosem. +VoxFlow to bezpłatne narzędzie do dyktowania głosem. Przytrzymaj klawisz → mów → tekst pojawia się w aktywnym oknie. Zero chmury, zero subskrypcji.
--- -## ⚡ Szybki start (Windows) — 3 kroki +## ⚡ Instalacja Windows — krok po kroku -> **Wymagania:** Windows 10/11 (64-bit), mikrofon, połączenie z internetem (tylko przy pierwszym uruchomieniu do pobrania modelu AI ~500 MB) +> Wymagania: Windows 10/11 (64-bit), mikrofon, internet (tylko przy pierwszym uruchomieniu — pobranie modelu AI ~500 MB) ### Krok 1 — Zainstaluj Python -Pobierz Python 3.10+ ze strony **[python.org/downloads](https://www.python.org/downloads/)**. - -> ⚠️ **Ważne:** podczas instalacji zaznacz opcję **"Add Python to PATH"**! +1. Wejdź na stronę [python.org/downloads](https://www.python.org/downloads/) +2. Pobierz Python 3.10 lub nowszy +3. Uruchom instalator +4. WAŻNE: zaznacz opcję "Add Python to PATH" przed kliknięciem Install ### Krok 2 — Zainstaluj VoxFlow -Kliknij dwukrotnie plik **`install.bat`** i poczekaj aż zakończy instalację (~2–5 minut). +1. Pobierz lub rozpakuj folder VoxFlow na dysk +2. Kliknij dwukrotnie plik `install.bat` +3. Poczekaj aż zakończy instalację (2–5 minut) — instaluje wymagane biblioteki AI ### Krok 3 — Uruchom -Kliknij dwukrotnie **`START_VOXFLOW.bat`** (tworzony automatycznie przez install.bat). +Kliknij dwukrotnie `START_VOXFLOW.bat` (tworzony automatycznie przez install.bat). + +Gotowe — VoxFlow jest teraz uruchomiony i widoczny w zasobniku systemowym (prawy dolny róg ekranu). --- -## 🖥️ Jak działa VoxFlow? +## 🖥️ Jak używać | Akcja | Efekt | |-------|-------| -| Przytrzymaj `F2` i mów | 🔴 Nagrywa — animacja fal pojawia się na dole ekranu | -| Zwolnij `F2` | ⏳ AI przetwarza mowę → tekst wklejony w aktywnym oknie | -| Kliknij przycisk klawisza w UI | Ustaw własny klawisz dyktowania | +| Przytrzymaj `F2` i mów | Nagrywa — na dole ekranu pojawia się animacja fal | +| Zwolnij `F2` | AI przetwarza mowę i wkleja tekst w aktywnym oknie | +| Kliknij przycisk klawisza w UI | Zmień skrót klawiszowy na dowolny inny | | Kliknij dropdown mikrofonu | Wybierz urządzenie audio | -| Kliknij `⚙` | Otwórz zaawansowane ustawienia | - -Po uruchomieniu VoxFlow minimalizuje się do **ikony w zasobniku systemowym** (prawy dolny róg). -Podwójne kliknięcie na ikonę → przywraca okno. +| Kliknij ikonę w zasobniku prawym przyciskiem | Menu: pokaż okno, nagraj, zamknij | --- ## 📦 Wersja EXE (bez Pythona) -Jeśli chcesz wersję gotową `.exe` — **nie wymaga Pythona**: +Jeśli chcesz wersję, która nie wymaga instalacji Pythona: -**Opcja A — Instalator (zalecana):** +Opcja A — Instalator (zalecana): ``` -BUILD_EXE.bat → tworzy VoxFlow_Setup.exe +BUILD_EXE.bat → tworzy VoxFlow_Setup.exe ``` Wymaga [Inno Setup 6](https://jrsoftware.org/isinfo.php). -**Opcja B — Portable:** +Opcja B — Portable (nie wymaga instalacji): ``` -CREATE_PORTABLE.bat → tworzy folder dist\VoxFlow_Portable_v1.1.0\ +CREATE_PORTABLE.bat → tworzy dist\VoxFlow_Portable_v1.1.0\ ``` -Uruchom `START_VOXFLOW.bat` z folderu portable — gotowe, bez instalacji. +Uruchom `START_VOXFLOW.bat` z folderu portable. --- @@ -97,7 +99,7 @@ Uruchom `START_VOXFLOW.bat` z folderu portable — gotowe, bez instalacji. |-------|---------|----------|--------|----------| | `tiny` | ~75 MB | ⚡⚡⚡⚡ | ⭐⭐ | Testy | | `base` | ~150 MB | ⚡⚡⚡ | ⭐⭐⭐ | Słaby PC | -| `small` | ~500 MB | ⚡⚡ | ⭐⭐⭐⭐ | **Zalecany** | +| `small` | ~500 MB | ⚡⚡ | ⭐⭐⭐⭐ | Zalecany | | `medium` | ~1.5 GB | ⚡ | ⭐⭐⭐⭐⭐ | Dobry PC | | `large-v3` | ~3 GB | 🐢 | ⭐⭐⭐⭐⭐ | GPU | @@ -112,20 +114,20 @@ chmod +x install_mac.sh ./install_mac.sh ``` -> ⚠️ Globalny hotkey wymaga uprawnień **Accessibility** w System Settings. +> Globalny hotkey wymaga uprawnień Accessibility w System Settings. > Szczegóły: [INSTALL_MAC.md](INSTALL_MAC.md) --- ## 📜 Licencja -MIT License — bezpłatny, open source. Użyj, modyfikuj, dystrybuuj bez ograniczeń. +MIT License — bezpłatny, open source. Używaj, modyfikuj i dystrybuuj bez ograniczeń. ---
-**AI Evolution Polska** — Bezpłatne narzędzia AI dla polskich użytkowników +AI Evolution Polska — Bezpłatne narzędzia AI dla polskich użytkowników [![GitHub](https://img.shields.io/badge/GitHub-aievolutionpl-181717?logo=github)](https://github.com/aievolutionpl)