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..c0cb4e4 100644 --- a/BUILD_EXE.bat +++ b/BUILD_EXE.bat @@ -1,91 +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 ^ - --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. - 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 0df4aae..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 @@ -38,4 +67,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/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/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 a366141..4fa44d0 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) +[![by AI Evolution Polska](https://img.shields.io/badge/by-AI_Evolution_Polska-7c3aed)](https://github.com/aievolutionpl) -### 🎯 Kluczowe funkcje +VoxFlow to bezpłatne narzędzie do dyktowania głosem. +Przytrzymaj klawisz → mów → tekst pojawia się w aktywnym oknie. Zero chmury, zero 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 - -### Sposób 1: Pobierz gotowy .exe (zalecane) - -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ł) +## ⚡ Instalacja Windows — krok po kroku -``` -Kliknij dwukrotnie: START_VOXFLOW.bat -``` +> Wymagania: Windows 10/11 (64-bit), mikrofon, internet (tylko przy pierwszym uruchomieniu — pobranie modelu AI ~500 MB) -Skrypt automatycznie: -- ✅ Sprawdzi Python -- ✅ Stworzy środowisko wirtualne -- ✅ Zainstaluje pakiety -- ✅ Uruchomi VoxFlow +### Krok 1 — Zainstaluj Python -### Sposób 3: Ręczne uruchomienie - -```bash -# Sklonuj repozytorium -git clone https://github.com/AI-Evolution-Polska/VoxFlow.git -cd VoxFlow +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 -# Stwórz środowisko wirtualne -python -m venv venv -venv\Scripts\activate +### Krok 2 — Zainstaluj VoxFlow -# Zainstaluj zależności -pip install -r requirements.txt +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 -# Uruchom -python -m voxflow.main -``` +### Krok 3 — Uruchom -### Sposób 4: pip install +Kliknij dwukrotnie `START_VOXFLOW.bat` (tworzony automatycznie przez install.bat). -```bash -pip install . -voxflow -``` +Gotowe — VoxFlow jest teraz uruchomiony i widoczny w zasobniku systemowym (prawy dolny róg ekranu). --- -## ⌨️ 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) +## 🖥️ Jak używać -> 💡 **Tip**: VoxFlow działa w tle — możesz go zminimalizować do zasobnika systemowego. +| Akcja | Efekt | +|-------|-------| +| 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 ikonę w zasobniku prawym przyciskiem | Menu: pokaż okno, nagraj, zamknij | --- -## 🧠 Modele Whisper - -Przy pierwszym użyciu VoxFlow automatycznie pobierze wybrany model (~1 min dla `small`). +## 📦 Wersja EXE (bez Pythona) -| 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ść | +Jeśli chcesz wersję, która nie wymaga instalacji Pythona: -> 🎯 **Domyślnie**: `small` — najlepszy balans jakości i szybkości dla polskiego. - ---- +Opcja A — Instalator (zalecana): -## ⚙️ Ustawienia - -Kliknij ⚙️ w oknie VoxFlow, aby otworzyć zaawansowane ustawienia: - -| 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 | +``` +BUILD_EXE.bat → tworzy VoxFlow_Setup.exe +``` ---- +Wymaga [Inno Setup 6](https://jrsoftware.org/isinfo.php). -## 📁 Struktura projektu +Opcja B — Portable (nie wymaga instalacji): ``` -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 +CREATE_PORTABLE.bat → tworzy dist\VoxFlow_Portable_v1.1.0\ ``` ---- +Uruchom `START_VOXFLOW.bat` z folderu portable. -## 🔧 Wymagania systemowe +--- -- **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** +## ✨ 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 | --- -## 🏗️ Budowanie .exe - -```bash -# Zbuduj standalone .exe za pomocą PyInstaller -BUILD_EXE.bat +## 🧠 Modele Whisper -# Gotowy plik: dist/VoxFlow/VoxFlow.exe -``` +| 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 | -Opcjonalnie, jeśli masz zainstalowane [Inno Setup 6](https://jrsoftware.org/isinfo.php), skrypt automatycznie zbuduje też instalator `.exe`. +> Model pobiera się automatycznie przy pierwszej zmianie (jednorazowo). --- -## 🔒 Prywatność i bezpieczeństwo - -VoxFlow jest **w 100% lokalne**: +## 🍎 Instalacja macOS -- ❌ Ż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/` +```bash +chmod +x install_mac.sh +./install_mac.sh +``` -Więcej informacji: [SECURITY.md](SECURITY.md) +> Globalny hotkey wymaga uprawnień Accessibility w System Settings. +> Szczegóły: [INSTALL_MAC.md](INSTALL_MAC.md) --- -## 🤝 Współpraca +## 📜 Licencja -Chcesz pomóc? Świetnie! Przeczytaj [CONTRIBUTING.md](CONTRIBUTING.md) i otwórz Pull Request. +MIT License — bezpłatny, open source. Używaj, modyfikuj i dystrybuuj bez ograniczeń. --- -## 📄 Licencja +
-Ten projekt jest udostępniony na licencji [MIT](LICENSE). +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) -

- 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/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/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 9f3a19d..997d890 100644 Binary files a/assets/voxflow.ico and b/assets/voxflow.ico differ diff --git a/assets/voxflow_256.png b/assets/voxflow_256.png index f51c940..389e5ff 100644 Binary files a/assets/voxflow_256.png and b/assets/voxflow_256.png differ diff --git a/install.bat b/install.bat new file mode 100644 index 0000000..bc80883 --- /dev/null +++ b/install.bat @@ -0,0 +1,138 @@ +@echo off +chcp 65001 >nul +cd /d "%~dp0" +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! + +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 👉 Kliknij dwukrotnie START_VOXFLOW.bat +echo. +echo Lub ręcznie: +echo venv\Scripts\activate +echo python -m voxflow.main +echo ═══════════════════════════════════════════════════════ +echo. + +set /p LAUNCH="Czy uruchomić VoxFlow teraz? [T/n]: " +if /i "%LAUNCH%"=="n" goto :end + +echo. +echo 🚀 Uruchamianie VoxFlow... +start "" venv\Scripts\pythonw.exe -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 6747072..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/AI-Evolution-Polska/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_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/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..91fa437 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" +__version__ = "1.1.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..5c5c6d5 100644 --- a/voxflow/app.py +++ b/voxflow/app.py @@ -1,7 +1,7 @@ -"""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/AI-Evolution-Polska/VoxFlow +https://github.com/aievolutionpl/VoxFlow """ import threading import time @@ -16,31 +16,47 @@ 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 = { - "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 +72,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 +83,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,7 +93,9 @@ 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, + on_device_fallback=self._on_device_fallback, ) self.transcriber = VoxTranscriber( @@ -90,33 +110,74 @@ 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() # ─── Build ──────────────────────────────────────────────── + self._load_audio_devices() self._build_ui() self._start_services() self.protocol("WM_DELETE_WINDOW", self._on_close) + self.bind("", self._on_window_configure) 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 +185,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 +264,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") - 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, "🔊 Dźwięki nagrywania", self.sounds_var, self._on_sounds_toggle) - # --- Separator --- - ctk.CTkFrame(inner, fg_color=C["border"], height=1).pack(fill="x", pady=8) + self.autostart_var = ctk.BooleanVar(value=is_autostart_enabled()) + sw_row(inner, "🚀 Uruchamiaj z Windows", self.autostart_var, self._on_autostart_toggle) - # 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.CTkFrame(inner, fg_color=C["border"], height=1).pack(fill="x", pady=(8, 6)) - def _build_footer(self): - f = ctk.CTkFrame(self.main, fg_color="transparent") - f.pack(fill="x", pady=(0, 6)) + 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") - # 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() + # ── Footer ──────────────────────────────────────────────────── - # 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)) + def _build_footer(self): + self.footer_frame = ctk.CTkFrame(self.main, fg_color="transparent") + self.footer_frame.pack(fill="x", pady=(0, 4)) + + ctk.CTkLabel( + self.footer_frame, + text=f"VoxFlow v{__version__} • faster-whisper • 100% lokalne", + font=ctk.CTkFont(size=9), + text_color=C["txt3"], + ).pack() + + ctk.CTkLabel( + self.footer_frame, + 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 +679,10 @@ 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]) + self.settings_frame.pack( + fill="x", pady=(0, 6), + before=self.footer_frame, # Always insert just before footer + ) self._settings_visible = True self.settings_btn.configure(fg_color=C["accent_dim"]) @@ -488,26 +693,148 @@ 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"] + ) + + 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) # ═══════════════════════════════════════════════════════════════ 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,11 +846,12 @@ 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"]) - 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) @@ -534,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") @@ -556,7 +885,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 +915,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 +924,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 +954,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 +1006,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 +1084,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 +1099,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 +1114,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..da626a3 100644 --- a/voxflow/config.py +++ b/voxflow/config.py @@ -19,7 +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"} +# 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"} @@ -60,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: @@ -82,6 +85,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 +109,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 +128,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 +174,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..f7085aa 100644 --- a/voxflow/hotkey_manager.py +++ b/voxflow/hotkey_manager.py @@ -1,17 +1,31 @@ -"""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 +# 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. - 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 +40,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 +49,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 +85,50 @@ 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 = 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. + """ + 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 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 +140,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 +172,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..389d69d 100644 --- a/voxflow/main.py +++ b/voxflow/main.py @@ -10,6 +10,13 @@ def main(): # Set environment for better compatibility os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE") + # Fix encoding for Windows consoles + if sys.platform == "win32": + 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: print("🧪 VoxFlow Test Mode") 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) diff --git a/voxflow/recorder.py b/voxflow/recorder.py index 21c3037..9b00de1 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,14 +15,18 @@ 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, + on_device_fallback: Optional[Callable[[int], None]] = None, ): self.sample_rate = sample_rate self.channels = channels 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.on_device_fallback = on_device_fallback self._recording = False self._audio_chunks: list[np.ndarray] = [] @@ -35,8 +39,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 +53,42 @@ 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 + # 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, + 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 +160,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"