# PRZEPIS DYKTAFON

Ten plik to przepis dla agenta kodującego: Claude Code'a albo Codexa. Jeśli użytkownik ci go przekazał (przeciągnął do okna, wkleił albo podał ścieżkę), to chce mieć na swoim komputerze narzędzie opisane niżej. To przepis, nie gotowy program: narzędzie powstaje na komputerze użytkownika i to ty je budujesz, dopasowując do tego, co tam zastaniesz. Przeczytaj całość. Potem powiedz mu w dwóch, trzech zdaniach, co zbudujesz i czego to narzędzie potrzebuje od systemu (mikrofon, jeden klawisz widziany w całym systemie, wklejanie tekstu, start razem z komputerem), i zacznij od kroku 1. Niczego nie instalujesz, dopóki użytkownik nie potwierdzi propozycji z kroku 2.

## Co budujesz

Dyktafon działający w całym systemie. Użytkownik przytrzymuje wybrany klawisz, mówi, puszcza, a tekst wkleja się tam, gdzie stoi kursor: w czacie, w generatorze, w mailu, w dowolnym polu. Mowę rozpoznaje Whisper uruchomiony lokalnie, więc po instalacji narzędzie działa bez internetu, bez konta i bez abonamentu.

Użytkownik zwykle pracuje z narzędziami AI (czaty, generatory obrazu i wideo) i dyktuje głównie prompty po polsku, z angielskimi nazwami w środku zdania. Zwykle nie programuje i nie zna terminala.

To zachowanie ma się zgadzać na każdym komputerze:

- **Trzymasz i mówisz.** Wciśnięcie klawisza zaczyna nagrywanie, puszczenie kończy je i wkleja tekst. Bardzo krótkie wciśnięcia są ignorowane. Klawisz wciśnięty razem z innym to skrót klawiszowy, a nie dyktowanie, więc nagranie się anuluje.
- **Tryb bez trzymania.** Drugi klawisz wciśnięty w trakcie trzymania przełącza na nagrywanie bez trzymania, do dłuższych wypowiedzi. Kończy je ponowne wciśnięcie klawisza dyktowania, Esc anuluje bez wklejania.
- **Mysz, jeśli użytkownik chce.** Przytrzymanie kółka myszy działa tak samo jak klawisz.
- **Dźwięk nie trafia na dysk.** Nagranie istnieje tylko w pamięci RAM i znika po transkrypcji. Plik audio nie powstaje w żadnym momencie.
- **Tekst zostaje.** Każde dyktando trafia do dziennika `dyktafon.log` w folderze narzędzia: data, długość nagrania, co model usłyszał i co zostało wklejone. Z dziennika bierze się potem nowe hasła do słownika, a użytkownik znajdzie w nim tekst, który wkleił się nie tam, gdzie miał. Dziennik trzyma ostatnie 200 wpisów (`historia`), starsze wypadają same. Zostaje na komputerze użytkownika i da się go wyłączyć (`loguj_tekst: false`). Wtedy treść dyktand nie trafia do żadnego pliku.
- **Słownik poprawek.** Plik `slownik.txt` zamienia słowa, które model słyszy po swojemu („midżurnej”, „sidens”), na właściwy zapis, razem z polską odmianą. Czyta się na bieżąco, bez restartu. Użytkownik dopisuje do niego własne słowa.
- **Filtry.** Program wycina przerywniki (yyy, eee) i zdania, które Whisper dopisuje na ciszy („Dziękuję za oglądanie”).
- **Ikona ze stanem i menu** w pasku menu (Mac) albo w zasobniku (Windows): gotowy, nagrywa, przetwarza, wstrzymany, błąd. W menu: wstrzymanie, reset klawisza, „Wklej ostatni tekst”, otwarcie słownika, ustawień i dziennika.
- **Autostart po zalogowaniu**, jedna kopia programu naraz i dozorca, który sam podnosi podsłuch klawisza, gdy system go wyłączy.
- **Dwa tryby testowe bez mikrofonu:** `--sprawdz "tekst"` przepuszcza tekst przez sam słownik, `--test plik.wav` robi transkrypcję pliku.

## Za co odpowiadasz

Narzędzie powstało i działa codziennie na jednym komputerze, MacBooku z procesorem Apple. Kod w załączniku B pochodzi stamtąd. Komputer użytkownika może być inny: inny system, procesor, klawiatura, mikrofon, nowsze wersje Pythona i bibliotek. Autor tej instrukcji nie zna tego komputera i nie sprawdzi za ciebie, co na nim działa. Doprowadzenie narzędzia do działania na tym konkretnym komputerze to twoje zadanie od początku do końca.

- Użytkownik niczego nie naprawi sam. Gdy coś nie działa, nie opisuj mu problemu i nie proś, żeby coś sprawdzał w kodzie albo w terminalu. Czytasz logi, stawiasz diagnozę, poprawiasz i sprawdzasz jeszcze raz.
- Do użytkownika należy tylko to, czego nie da się zrobić komendą: kliknięcie zgody w ustawieniach systemu, wpisanie hasła, powiedzenie zdania do mikrofonu.
- Zachowanie opisane wyżej jest stałe, a sposób wykonania należy do ciebie. Jeśli biblioteka z tej instrukcji nie działa na tym komputerze, nie ma wydania na ten system albo zmieniła interfejs, znajdź inną drogę do tego samego zachowania. Aktualną dokumentację sprawdź w sieci, nie zgaduj.
- Różnice między komputerami są spodziewane i nie są błędem instrukcji. Gdy coś z tego pliku nie pasuje do komputera, na którym pracujesz, dopasuj to i idź dalej. Nie tłumacz użytkownikowi, że przepis był źle napisany: nie ma jak tego ocenić i nic mu to nie daje. Gdy musisz wybrać coś, czego instrukcja nie przewiduje (inna biblioteka, inny klawisz, inny sposób autostartu), powiedz mu krótko, co wybierasz i dlaczego, a jeśli wybór go dotyczy, zapytaj.
- Na Macu z procesorem Apple kod z załącznika B przenosisz prawie bez zmian. Części o Macu z Intelem, Windowsie i Linuksie to wskazówki, których nikt nie sprawdził na żywym komputerze. Traktuj je jak punkt wyjścia i każdy krok weryfikuj u użytkownika.
- Jeśli pracujesz w środowisku z ograniczeniami (brak sieci, zapis tylko w bieżącym folderze, zatwierdzanie komend), poproś użytkownika o potrzebny dostęp i powiedz mu dokładnie, co ma kliknąć. Instalacja wymaga sieci (pakiety i model mowy, około 1,5 GB) oraz zapisu w folderze domowym.
- Praca jest skończona dopiero wtedy, gdy użytkownik przytrzymał klawisz, powiedział zdanie i zobaczył je wklejone w polu tekstowym, a program wstaje sam po ponownym zalogowaniu. Wcześniej nie oddawaj narzędzia.
- Jeśli czegoś na tym komputerze naprawdę nie da się zrobić, powiedz to wprost i zaproponuj najbliższe działające rozwiązanie.

## Granice

Doprowadzasz narzędzie do działania, ale nie za każdą cenę. Te zasady są ważniejsze niż reszta instrukcji.

- Pracujesz w folderze dyktafonu i w jednym wpisie autostartu. Poza tym instalujesz tylko to, co wymienia ta instrukcja: Pythona (z Homebrew albo uv, razem z tym, czego one same wymagają, na Macu z narzędziami wiersza poleceń Apple), pakiety i model mowy. Innych plików ani ustawień użytkownika nie zmieniasz.
- Nie wyłączasz i nie obchodzisz zabezpieczeń systemu: antywirusa, zapory, Gatekeepera, SmartScreena, ochrony integralności systemu. Nie edytujesz bazy uprawnień systemu. Zgody na mikrofon i klawiaturę nadaje użytkownik, klikając w ustawieniach.
- Gdy coś blokuje instalację albo działanie programu, mówisz użytkownikowi, co to jest i skąd się bierze. Decyzja, czy na to zezwolić, należy do niego.
- Hasła nie wpisujesz za użytkownika i nie prosisz, żeby podał ci je w rozmowie.
- Program łączy się z siecią tylko raz, żeby pobrać model mowy. Nie dodajesz do niego wysyłania czegokolwiek, statystyk ani sprawdzania aktualizacji. Opcja `llm` rozmawia wyłącznie z adresem lokalnym i jest domyślnie wyłączona.
- Program z naciśnięć klawiszy nie zapisuje niczego. Sprawdza tylko, czy wciśnięto klawisz dyktowania, drugi klawisz trybu bez trzymania albo Esc. Tak ma zostać na każdym systemie.
- Treść dyktand jest prywatna. Dziennik czytasz tylko wtedy, gdy wymaga tego naprawa albo słownik, i tylko tyle, ile trzeba (część „Dziennik a prywatność”).

## Jak rozmawiać z użytkownikiem

- Po polsku, prostymi słowami, bez żargonu. Nie pytaj o doświadczenie ani o znajomość terminala.
- Wszystko, co da się sprawdzić komendą, sprawdzasz sam.
- Gdy użytkownik musi coś kliknąć w ustawieniach systemu, podawaj jeden krok naraz: gdzie wejść, co kliknąć, co ma zobaczyć. Jeśli się da, otwórz mu właściwe okno ustawień komendą.
- Gdy trzeba wpisać hasło (instalator Homebrew, `sudo`), uprzedź, że znaki hasła nie pojawiają się podczas pisania i że to normalne. Jeśli twoje środowisko nie pozwala użytkownikowi wpisać hasła w trakcie komendy, daj mu jedną gotową komendę do wklejenia w Terminalu i powiedz, jak go otworzyć.
- Przed dłuższym czekaniem (pobieranie modelu) powiedz, ile to potrwa i że nic się nie zawiesiło.

## Krok 1: rozpoznanie

Sprawdź sam:

- system i procesor (`uname -m` na Macu: `arm64` = Apple, `x86_64` = Intel; na Windowsie `$env:PROCESSOR_ARCHITECTURE` i czy jest karta NVIDIA: `nvidia-smi`),
- czy nie działasz w WSL, czyli w Linuksie wewnątrz Windowsa (`uname -r` zawiera wtedy `microsoft` albo `WSL`). Z WSL nie ma dostępu do klawiatury, mikrofonu ani zasobnika Windowsa, więc narzędzie musi powstać po stronie Windowsa: folder w `%USERPROFILE%`, Python dla Windowsa, komendy przez `powershell.exe`. Jeśli nie da się tego stamtąd poprowadzić, poproś użytkownika, żeby uruchomił cię w zwykłym terminalu Windowsa (PowerShell),
- wolne miejsce na dysku (potrzeba około 3 GB) i ilość pamięci RAM (model zajmuje około 2 GB),
- czy to laptop z wbudowanym mikrofonem, czy komputer stacjonarny, i jakie mikrofony widzi system,
- jaka jest klawiatura: wbudowana, Apple czy innej firmy (klawiatury innych firm często nie wysyłają do systemu klawisza fn),
- czy działa już inny dyktafon, który może zajmować ten sam klawisz (Wispr Flow, Superwhisper, MacWhisper, systemowe dyktowanie). Jeśli tak, powiedz o tym użytkownikowi, bo na jeden klawisz zareagują oba programy,
- czy komputer jest służbowy, zarządzany przez firmę (Mac: `profiles status -type enrollment`; Windows: `dsregcmd /status`, pola `AzureAdJoined` i `DomainJoined`). Jeśli tak, powiedz to użytkownikowi jednym spokojnym zdaniem: firmowe zabezpieczenia mogą zareagować na program, który widzi klawisz w całym systemie i startuje razem z komputerem, więc na służbowym sprzęcie lepiej mieć zgodę działu IT. Zapytaj, czy budować dalej. Na prywatnym komputerze nie poruszaj tego tematu.

## Krok 2: propozycje dla użytkownika

Zanim cokolwiek zainstalujesz, powiedz w kilku zdaniach, co powstanie, i zaproponuj ustawienia. Przy każdej rzeczy podaj swoją propozycję, tak żeby użytkownik mógł odpowiedzieć „tak jak proponujesz”. Najlepiej w jednej wiadomości: krótki opis narzędzia z początku tej instrukcji i propozycje mogą iść razem, po rozpoznaniu z kroku 1.

1. **Klawisz dyktowania.** Ma to być klawisz, którego użytkownik nie używa do niczego innego i który leży wygodnie pod palcem.
   - Mac: domyślnie fn (🌐). Prawie nikt go nie używa, a leży pod kciukiem. Przy klawiaturze innej firmy, która nie wysyła fn, zaproponuj prawy Option albo prawy Command.
   - Windows: system nie widzi klawisza fn wcale, więc domyślnie prawy Ctrl. Nie proponuj prawego Alta: w polskim układzie to AltGr, którym pisze się ą, ę, ś. Na nowszych laptopach w miejscu prawego Ctrl bywa klawisz Copilot, a komendą tego nie sprawdzisz, więc zapytaj użytkownika, czy ma prawy Ctrl. Jeśli nie ma, zaproponuj klawisz, którego nie używa (na przykład Insert, Pause albo klawisz menu kontekstowego), i dopisz jego obsługę.
   - Kod z załącznika obsługuje klawisze-modyfikatory (fn, Option, Command, Control, Shift). Jeśli użytkownik chce innego klawisza, na przykład F13 albo dodatkowego przycisku myszy, dopisz jego obsługę.
2. **Tryb bez trzymania.** Wyjaśnij, do czego służy (dłuższe wypowiedzi, bez trzymania palca na klawiszu), i zaproponuj kombinację: klawisz dyktowania plus drugi klawisz. Na Macu domyślnie fn + Control. Drugi klawisz musi być inny niż klawisz dyktowania i nie może tworzyć z nim skrótu, którego użytkownik używa.
3. **Mysz.** Zapytaj, czy dyktować też przytrzymaniem kółka myszy. Uprzedź, że wtedy kółko przestaje otwierać linki w nowej karcie.
4. **Słownik.** Wyjaśnij, że model przekręca nazwy własne i obce słowa, a słownik je poprawia. Na start są w nim nazwy narzędzi AI. Zapytaj o słowa, które na pewno będą padać: nazwa firmy albo marki, nazwisko, marki klientów, narzędzia spoza listy. To, co poda, dopisz od razu, z wariantami, jakie model prawdopodobnie zwróci. Powiedz też, że resztę dopisuje się później, gdy wyjdzie w praktyce, i że nie musi teraz niczego wymyślać.
5. **Dziennik.** Powiedz, że dźwięk nigdzie się nie zapisuje, a tekst dyktand trafia do pliku `dyktafon.log` na jego komputerze i nigdzie dalej; plik trzyma tylko ostatnie 200 dyktand. Dziennik służy do poprawiania słownika i do odzyskania tekstu, który wkleił się nie tam, gdzie miał. Zaproponuj włączony. Jeśli użytkownik nie chce zapisywania treści, ustaw `loguj_tekst: false`.

Poczekaj na odpowiedź. Instalację zaczynasz dopiero po potwierdzeniu.

## Krok 3: folder i Python

**Folder:** `~/DYKTAFON` na Macu, `%USERPROFILE%\DYKTAFON` na Windowsie. Na Macu nie na Biurku, nie w Dokumentach i nie w Pobranych: macOS chroni te foldery i autostart przez launchd nie wystartuje z nich po restarcie (kończy się błędem 78, EX_CONFIG). Jeśli użytkownik chce mieć folder pod ręką, zrób mu na Biurku skrót.

**Python na Macu:** Python 3.13 z Homebrew (`brew install python@3.13`), a jeśli nie ma Homebrew, zainstaluj go najpierw oficjalnym poleceniem z brew.sh. Na Macu, na którym nikt nie programował, instalator doinstaluje najpierw narzędzia wiersza poleceń Apple (Command Line Tools). To potrafi trwać kilkanaście minut i może wyświetlić systemowe okno z prośbą o zgodę, więc uprzedź o tym użytkownika. Python z Homebrew to tak zwany framework build z pakietem Python.app. Ikona w pasku menu (rumps) i uprawnienia macOS (mikrofon, Dostępność) działają z nim pewnie, a na Pythonie bez pakietu .app potrafią milczeć. Wirtualne środowisko: `python3.13 -m venv .venv`. Jeśli któryś pakiet nie ma jeszcze wydania na tę wersję Pythona, weź 3.12.

**Pakiety na Macu z procesorem Apple:** `mlx-whisper rumps sounddevice numpy soundfile pyobjc-framework-Quartz pyobjc-framework-Cocoa pyobjc-framework-ApplicationServices`.

**Python na Windowsie:** `uv` (instalacja: `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"`), potem `uv venv --python 3.12 .venv`. Albo instalator z python.org z zaznaczonym „Add to PATH”.

**Pakiety na Windowsie i na Macu z Intelem:** `faster-whisper sounddevice numpy soundfile` oraz na Windowsie `pynput pystray pillow pyperclip`. Przy karcie NVIDIA dodatkowo `nvidia-cublas-cu12 nvidia-cudnn-cu12` (faster-whisper potrzebuje ich bibliotek do pracy na karcie). Na Windowsie biblioteki z tych pakietów nie trafiają same na ścieżkę systemu, więc przed importem faster-whisper dodaj ich foldery `bin` przez `os.add_dll_directory`. Której wersji cuDNN wymaga zainstalowane wydanie, sprawdź w dokumentacji faster-whisper. Jeśli karta nie ruszy, program ma działać na procesorze, a nie stać z błędem.

## Krok 4: kod

Pliki w folderze: `dyktafon.py`, `slownik.txt` (załącznik A plus słowa użytkownika z kroku 2), `ustawienia.json` (tworzy się sam przy pierwszym starcie), skrypty startu, zatrzymania i autostartu, `README.md` oraz kopia tego pliku jako `PRZEPIS-DYKTAFON.md` (przyda się przy późniejszych naprawach).

**Kodu z załączników nie przepisuj ręcznie.** Wytnij go z tego pliku krótkim skryptem: każdy blok jest ograniczony czterema grawisami, a nazwa pliku stoi w nagłówku `###` nad blokiem (słownik: blok `text` w załączniku A). Przepisywanie tysiąca linii gubi linie na granicach odczytu i psuje znaki specjalne w wyrażeniach regularnych. Jeśli nie masz tego pliku na dysku, tylko wklejoną treść, zapisz kod w całości, bez skracania. W obu przypadkach przy przenoszeniu niczego w kodzie nie poprawiaj przy okazji, także nazw, które wyglądają na literówki (`polkniij_klawisz`): kod jest spójny i sprawdzony w tej postaci. Co innego, gdy coś na tym komputerze nie działa: wtedy naprawiasz, bo to twoje zadanie. Po zapisie uruchom `python -m py_compile dyktafon.py`, a ustawienia z kroku 2 wprowadź punktową edycją `DOMYSLNE`.

`README.md` piszesz sam, po polsku, krótko i dla użytkownika: jak dyktować wybranym przez niego klawiszem, co znaczą ikony, gdzie jest słownik i jak wygląda jego wpis, gdzie jest dziennik, co zrobić, gdy coś nie działa.

### Mac z procesorem Apple (M1 i nowsze)

Kod z załącznika B jako punkt wyjścia, w praktyce prawie bez zmian. W `DOMYSLNE` ustaw według odpowiedzi z kroku 2:

- `klawisz` (63 = fn, 61 = prawy Option, 54 = prawy Command) i `bez_trzymania` (`control`, `shift`, `option` albo `command`),
- `przycisk_myszy`: `2` dla kółka, `None`, jeśli użytkownik nie chce myszy,
- `mikrofon`: `"MacBook"` na MacBooku (wbudowany mikrofon), `""` na iMacu, Macu mini czy Studio (domyślne urządzenie systemu).

### Mac z procesorem Intel

Jak wyżej, ale MLX nie działa na Intelu. Klasę `Transkrypcja` przepisz na faster-whisper (opis w części o Windowsie, model na CPU z `compute_type="int8"`). Reszta bez zmian.

### Windows

Zostaje bez zmian: `Slownik` (cały, łącznie z odmianą, `{n}` i gwiazdką), `wyczysc`, filtry halucynacji, `Nagrywanie` (sounddevice działa na Windowsie), kolejka poleceń i osobny wątek audio, dozorca, dziennik, tryby `--sprawdz` i `--test`.

Podmień:

- **Transkrypcja:** `faster_whisper.WhisperModel("large-v3-turbo", device="cuda", compute_type="float16")` przy karcie NVIDIA, a bez niej `device="cpu", compute_type="int8"`. Jeśli ta nazwa modelu nie przejdzie w zainstalowanej wersji, użyj `deepdml/faster-whisper-large-v3-turbo-ct2`. Te same parametry co w oryginale: `language="pl"`, `initial_prompt` ze słownika, `condition_on_previous_text=False`, te same filtry segmentów (`no_speech_prob`, `avg_logprob`, `compression_ratio`; w faster-whisper to pola obiektu segmentu). Czyszczenia pamięci MLX na Windowsie nie ma czym zastąpić i nie trzeba. Uwaga na różnice względem kodu z Maca: `transcribe` zwraca parę `(segmenty, info)`, segmenty są generatorem (zbierz je do listy), a każdy segment to obiekt z polami `text`, `no_speech_prob`, `avg_logprob`, nie słownik. Rozgrzej model sekundą ciszy przy starcie, jak w oryginale. Tak jak w oryginale ładuj też model najpierw z dysku (`local_files_only=True`) i pobieraj go dopiero wtedy, gdy go tam nie ma. Po pierwszym pobraniu program nie łączy się z siecią wcale.
- **Klawisz:** globalny hak klawiatury przez `pynput.keyboard.Listener` z `win32_event_filter`. W filtrze sprawdzasz kod wirtualny (prawy Ctrl = `0xA3`, `VK_RCONTROL`). Klawisza-modyfikatora (Ctrl, Shift, Alt) nie połykaj. Windows śledzi stan modyfikatorów po zdarzeniach klawiszy, więc po połknięciu prawego Ctrl skrót prawy Ctrl+C wpisałby w pole samo „c”. Sam wciśnięty Ctrl nic w systemie nie robi, wystarczy go słuchać. Połykasz tylko klawisz, który coś wpisuje albo przełącza (Insert, Pause, klawisz menu): `listener.suppress_event()` wołasz wyłącznie dla niego i dopiero po wrzuceniu polecenia do kolejki, bo ta funkcja przerywa filtr wyjątkiem. Inny klawisz wciśnięty w trakcie trzymania (na przykład Ctrl+C) oznacza skrót, więc anuluj nagranie i przepuść klawisz. Tryb bez trzymania przenieś na kombinację, która z niczym nie koliduje, na przykład prawy Ctrl + lewy Shift. Uwaga: przy więcej niż jednym układzie klawiatury Windows przełącza układ skrótami Ctrl+Shift i Alt+Shift. Sprawdź liczbę układów (`Get-WinUserLanguageList`) i przy kilku wybierz inną parę, a potem upewnij się z użytkownikiem, że nic się nie przełącza. Tryb bez trzymania kończy ponowne wciśnięcie klawisza dyktowania, Esc anuluje.
- **Mysz:** `pynput.mouse.Listener` z `win32_event_filter`, komunikaty `WM_MBUTTONDOWN` (0x0207) i `WM_MBUTTONUP` (0x0208), połykane tak samo.
- **Wyrzucany hak:** Windows po cichu wyrzuca hak, którego callback nie wraca w porę (limit LowLevelHooksTimeout, rzędu sekundy). Dlatego callback, dokładnie jak na Macu, tylko wrzuca polecenie do kolejki i od razu wraca. Sprawdzanie `listener.is_alive()` nie wystarczy, bo wyrzucony hak nie kończy wątku. Dozorca zakłada więc oba listenery od nowa co kilka minut, zawsze wtedy, gdy nic się nie nagrywa, i od razu po pozycji menu „Odblokuj klawisz (reset)”.
- **Schowek i wklejanie:** `pyperclip` do schowka, Ctrl+V przez `pynput.keyboard.Controller`. Przywracanie poprzedniej zawartości schowka zostaje.
- **Ikona:** `pystray` z obrazkiem z Pillow (kółko w kolorze stanu: szare = gotowy, czerwone = nagrywa, żółte = przetwarza, pomarańczowe = błąd, niebieskie = wstrzymany). Menu z tymi samymi pozycjami co w oryginale. Pliki otwierasz przez `os.startfile`.
- **Dźwięki:** `winsound.MessageBeep()` albo krótkie `winsound.Beep`.
- **Jedna kopia programu:** nazwany mutex (`ctypes.windll.kernel32.CreateMutexW`), żeby dwa dyktafony nie działały naraz. Druga kopia poznaje się po `GetLastError() == 183` (ERROR_ALREADY_EXISTS) i kończy pracę. Uchwyt mutexu trzymaj w zmiennej przez cały czas działania programu.
- **Uruchamianie bez okna konsoli:** `pythonw.exe` z `.venv\Scripts\`. Pod `pythonw` `sys.stdout` i `sys.stderr` mają wartość `None` i pierwszy `print` wywraca program. Przekieruj oba do `dyktafon.out` w pierwszych liniach pliku, przed importami bibliotek.
- **Autostart:** skrót w folderze Autostart (`shell:startup`) do `.venv\Scripts\pythonw.exe` z argumentem `dyktafon.py` i katalogiem roboczym w folderze dyktafonu. Plik `.lnk` nie jest tekstem, tworzysz go z PowerShella przez `WScript.Shell` (`CreateShortcut`). Do tego `start.bat` i `stop.bat`.

### Linux

Logika jak na Windowsie (faster-whisper, pystray, pyperclip). Różnica jest w podsłuchu klawisza i wklejaniu. W sesji X11 działa `pynput`. W sesji Wayland zwykły program nie dostaje globalnych klawiszy, więc klawisz czytasz przez `evdev` z `/dev/input` (użytkownik w grupie `input`), a wklejasz przez `wl-clipboard` i `ydotool` albo `wtype`. Autostart przez jednostkę `systemd --user` albo plik `.desktop` w `~/.config/autostart`.

### Gdy komputer jest za wolny

Na MacBooku z procesorem Apple transkrypcja 10 sekund mowy trwa około sekundy. Na procesorze bez karty graficznej zmierz to trybem `--test`. Kilka sekund na 10 sekund mowy jest w porządku. Jeśli wychodzi wyraźnie dłużej, zaproponuj mniejszy model: `small`. Modelu `medium` nie bierz. Jest prawie tak duży jak `large-v3-turbo` (769 i 809 mln parametrów) i ma cięższy dekoder, więc nie przyspieszy, a rozpoznaje gorzej. Powiedz uczciwie, że `small` będzie szybszy, ale z większą liczbą pomyłek, które częściowo nadrobi słownik. Po zmianie modelu program pobierze go sam przy najbliższym starcie.

## Wnioski z budowy (nie powtarzaj tych błędów)

1. **Nie ruszaj mikrofonu z callbacku klawisza.** Na Macu callback CGEventTap siedzi na głównym wątku. Zatrzymanie strumienia audio stamtąd zakleszczało PortAudio z CoreAudio (muteks w `AudioOutputUnitStop`), zamarzało menu, a system w końcu wyłączał podsłuch klawisza. Callback tylko wrzuca `start`, `stop` albo `anuluj` do `queue.Queue`, a mikrofon obsługuje wyłącznie osobny wątek. To samo na Windowsie.
2. **Strumień audio:** `blocksize=0`, po `start()` czekaj, aż `active` będzie prawdą, przy końcu `abort()` zamiast `stop()`, potem `close()`.
3. **Dozorca co 5 s:** włącza z powrotem podsłuch klawisza, jeśli system go wyłączył, kończy nagranie dłuższe niż `max_sekundy` (300) i zgłasza, gdy wątek audio nie odpowiada od 8 s. Z takiego zakleszczenia wątek już nie wraca, więc dozorca czeka, aż bieżące dyktando zostanie wklejone, i uruchamia program od nowa (pod launchd kończy się i launchd go podnosi, poza launchd `os.execv`). Audio zabiera się z bufora przed zamknięciem strumienia (`zabierz`), dlatego dyktando wkleja się nawet wtedy, gdy sterownik się zatnie. W menu pozycja „Odblokuj klawisz (reset)”.
4. **Halucynacje Whispera po polsku.** Na ciszy i szumie model dopisuje rzeczy w stylu „Dziękuję za oglądanie”, „Napisy stworzone przez społeczność Amara.org”, „Do zobaczenia w następnym filmie”. Najpierw zdejmij je w pętli z końca tekstu, dopiero potem oceniaj resztę, inaczej krótkie prawdziwe zdanie z doklejonym ogonem kasuje się w całości. Przed transkrypcją sprawdź głośność (RMS poniżej 0,002 to cisza, nie wysyłaj jej do modelu).
5. **Mikrofon:** audio tylko w pamięci RAM, nigdy na dysk. Słuchawki Bluetooth w trybie rozmowy dają dźwięk 16 kHz o jakości telefonu i psują rozpoznawanie, dlatego na laptopie domyślnie mikrofon wbudowany.
6. **Słownik:** jedna podmiana regexem w jednym przejściu, najdłuższe warianty pierwsze, luźne odstępy. Plik czytany na bieżąco przy każdym dyktowaniu (sprawdzanie daty zmiany), bez restartu. Poprawne pisownie idą też do `initial_prompt` modelu, ale krótko (do około 480 znaków), bo długi prompt pogarsza wynik. Kolejność haseł w pliku decyduje, które zmieszczą się w tej podpowiedzi, więc najważniejsze trzymaj na górze.
7. **Krótki wariant potrafi złapać zwykłe polskie słowo.** Hasło „Veo: weo” zamieniało przyimek „we” na „Veo”, bo odmiana ucina końcową samogłoskę i dopasowuje rdzeń. Kod z załącznika wymaga już końcówki po uciętym rdzeniu, ale zasada zostaje: zanim dopiszesz wariant, sprawdź, czy on sam albo jego odmiana nie jest polskim słowem („suno” odmienia się w „sunie” i „suną”). Dla takich haseł używaj zapisu z gwiazdką (`Suno*: suno*`), który łapie tylko słowa zaczynające się od całego wariantu.
8. **Dziennik:** każdy transkrypt (surowy i poprawiony) do `dyktafon.log`, plik trzyma ostatnie `historia` wpisów. Z niego dopisuje się potem słowa do słownika. Wszystko, co program drukuje, ląduje na dysku w `dyktafon.out`, dlatego treść dyktand przechodzi wyłącznie przez funkcję `zaloguj`: do dziennika idzie treść (gdy `loguj_tekst` jest włączone), na konsolę sam opis z długością nagrania. Pisząc wersję na inny system, nie drukuj treści dyktand w żadnym innym miejscu.
9. **Uprawnienia na Macu dostaje ten, kto uruchomił program.** Dyktafon uruchomiony z terminala działa na uprawnieniach terminala, a uruchomiony przez launchd na własnych, widocznych na listach jako „Python”. Dlatego uprawnienia nadawaj dopiero programowi uruchomionemu przez launchd (krok 6). Inaczej po restarcie komputera wszystko trzeba nadawać drugi raz.
10. **Po zmianie kodu** trzeba zatrzymać i uruchomić program od nowa. Zmiany w słowniku działają od razu.
11. **Pamięć na Macu.** MLX odkłada zwolnione bufory GPU na później i sam ich nie oddaje: po dwóch dobach dyktowania program trzymał 8 GB przy modelu 1,5 GB, a reszta szła do swapu i pierwsze dyktando po przerwie trwało trzy razy dłużej. Stąd `mx.clear_cache()` po każdej transkrypcji. Zmierzone: nie spowalnia.
12. **Bełkot zamiast dyktanda.** Na nagraniu bez wyraźnej mowy (stukanie w klawisze, gwar) Whisper z podpowiedzią słownika próbuje sześć razy z rosnącą losowością i oddaje ostatnią próbę: tekst w kilku językach albo zapętlony. `no_speech_prob` wynosi wtedy 0,00, więc filtr ciszy tego nie łapie. Stąd odrzucanie kawałków z `compression_ratio` powyżej 2,4 albo `avg_logprob` poniżej −1,0, niski dźwięk zamiast wklejenia i wpis `[odrzucone]` w dzienniku.

## Krok 5: test bez mikrofonu

Zanim poprosisz o uprawnienia:

- `dyktafon.py --sprawdz "otworzyłem midżurnej i sidens, potem higsfild i czat gpt"` ma dać `Otworzyłem Midjourney i Seedance, potem Higgsfield i ChatGPT`.
- `dyktafon.py --sprawdz "we wtorek kamera sunie nad wodą"` ma oddać zdanie bez zmian poza wielką literą na początku. Tak samo sprawdź każde hasło dopisane dla użytkownika: czy poprawia to, co ma poprawiać, i czy nie rusza zwykłych słów.
- Na Macu wygeneruj próbkę mowy: `say -v Zosia -o /tmp/t.aiff "Otwórz generator i zrób zbliżenie na twarz."` i `afconvert -f WAVE -d LEI16@16000 -c 1 /tmp/t.aiff /tmp/t.wav`, potem `dyktafon.py --test /tmp/t.wav`. Pierwsze uruchomienie pobiera model (około 1,5 GB), uprzedź o tym użytkownika. Jeśli głosu Zosia nie ma w systemie, weź dowolny polski głos z `say -v '?'`.
- Na Windowsie spróbuj tego samego polskim głosem syntezatora (System.Speech, głos „Microsoft Paulina”, jeśli jest zainstalowany), zapis do WAV 16 kHz mono. Jeśli polskiego głosu nie ma, pomiń ten test i sprawdź na żywo w kroku 7.
- Zanotuj czas transkrypcji. Jeśli jest za długi, wróć do części „Gdy komputer jest za wolny”.

## Krok 6: uruchomienie, autostart i uprawnienia

**Mac.** Najpierw włącz autostart: `./autostart.sh wlacz` (launchd, `KeepAlive` przy awarii). Program startuje wtedy w docelowy sposób i to jemu system przypisze uprawnienia (wniosek 9). macOS zapyta o mikrofon i o Dostępność. Poprowadź użytkownika przez Ustawienia systemowe → Prywatność i ochrona:

- **Dostępność**: `open "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"`,
- **Monitorowanie wprowadzania**, jeśli „Python” pojawił się także tam: `open "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent"`,
- **Mikrofon**, jeśli okno z pytaniem się nie pojawiło.

Na listach pojawi się „Python”. Użytkownik włącza przełącznik, a ty po zmianach zatrzymujesz i uruchamiasz dyktafon ponownie (`./stop.sh`, potem `./start.sh`). W `dyktafon.out` ma się pojawić „podsłuch klawisza założony” i „gotowe”. Przy włączonym autostarcie launchd podnosi program od razu po zatrzymaniu, więc `./stop.sh` działa w praktyce jak restart. Żeby program chwilowo nie reagował na klawisz, służy „Wstrzymaj” w menu, a żeby go wyłączyć na dobre, `./autostart.sh wylacz`. Jeśli klawisz fn dalej otwiera emoji albo systemowe dyktowanie: Ustawienia → Klawiatura → „Naciśnij klawisz 🌐, aby” → „Nic nie rób”.

**Windows.** Ustawienia → Prywatność i zabezpieczenia → Mikrofon → włączony dostęp dla aplikacji klasycznych (właściwe okno otwiera `start ms-settings:privacy-microphone`). Uruchom program przez skrót z folderu Autostart, tak jak będzie startował na co dzień. Antywirus może ostrzec przed programem, który widzi klawiaturę w całym systemie. Wyjaśnij użytkownikowi, skąd to ostrzeżenie: program musi zauważyć jeden klawisz w każdym oknie, a samych naciśnięć nie zapisuje. Czy zezwolić, decyduje użytkownik, a ty niczego w antywirusie nie wyłączasz ani nie obchodzisz. Ochrona systemu potrafi też zatrzymać program po cichu, bez okna. Jeśli proces znika, a w `dyktafon.out` nie ma błędu, poproś użytkownika, żeby zajrzał do Zabezpieczenia Windows → Historia ochrony, i powiedz mu, co tam zobaczy. Hak nie działa w oknach uruchomionych jako administrator (na przykład w Menedżerze zadań) i to jest normalne.

Na obu systemach sprawdź, że działa jedna kopia programu, a nie dwie.

## Krok 7: test na żywo i przekazanie

Poproś użytkownika, żeby otworzył dowolne pole tekstowe, przytrzymał klawisz i powiedział zdanie z nazwą jakiegoś generatora. Sprawdź w `dyktafon.log`, co model usłyszał i co się wkleiło. Potem niech wypróbuje tryb bez trzymania i mysz, jeśli ją wybrał. Jeśli coś nie działa, naprawiasz to teraz.

Na koniec powiedz mu krótko:

- jak dyktować: trzyma, mówi, puszcza; jak działa tryb bez trzymania; mysz, jeśli wybrał,
- co znaczą ikony i że niski dźwięk po czekaniu znaczy: w nagraniu nie było wyraźnej mowy, nic się nie wkleiło,
- że dźwięk nigdzie się nie zapisuje, a tekst ostatnich 200 dyktand zostaje w `dyktafon.log` na jego komputerze (menu → „Otwórz dziennik”). Plik można w każdej chwili skasować, a zapisywanie wyłączyć (`loguj_tekst: false` w ustawieniach). Jest w nim wszystko, co dyktował, więc niech nie wysyła tego pliku dalej,
- że słowa, które model przekręca, dopisuje się do `slownik.txt` (menu → „Otwórz słownik”) w formacie `PoprawnaNazwa: jak słyszy, jak jeszcze słyszy`. Może to robić sam albo po kilku dniach wrócić do ciebie: za jego zgodą przeczytasz ostatnie wpisy dziennika i dopiszesz to, co model przekręca,
- że tekst wkleja się przez schowek. Skopiowany wcześniej tekst program oddaje po wklejeniu, ale skopiowany obrazek albo plik dyktando zastąpi, więc obrazek lepiej wkleić przed dyktowaniem,
- że gdy klawisz przestanie reagować, w menu jest „Odblokuj klawisz (reset)”, a gdy tekst nie wkleił się tam, gdzie trzeba, „Wklej ostatni tekst”,
- że przy każdym problemie otwiera ciebie w folderze dyktafonu i opisuje własnymi słowami, co się stało.

Możesz też zaproponować rzeczy, które da się dodać później: inny klawisz albo inną kombinację, mysz (jeśli teraz jej nie chciał), inny mikrofon, tagi załączników dyktowane głosem (niżej), wygładzanie interpunkcji przez lokalny model językowy (w kodzie jest wyłączona opcja `llm` pod ollamę).

## Dziennik a prywatność

Narzędzie działa lokalnie, ale ty najpewniej nie: to, co przeczytasz, trafia do modelu w chmurze. W `dyktafon.log` jest wszystko, co użytkownik dyktował, także sprawy prywatne i dane jego klientów. W `dyktafon.out` treści dyktand nie ma, tylko długości nagrań i komunikaty programu.

- Zanim pierwszy raz w danej rozmowie otworzysz dziennik, powiedz użytkownikowi, co w nim jest i że przeczytane fragmenty trafią do ciebie. Zapytaj, czy się zgadza.
- Czytaj tyle, ile trzeba: końcówkę pliku albo linie z konkretnym słowem. Do naprawy zwykle wystarcza kilkadziesiąt ostatnich linii, do słownika ostatnie dni. Całego pliku nie wczytuj.
- Jeśli użytkownik się nie zgadza, pracuj bez dziennika: poproś, żeby sam podał słowa, które model przekręca, i sprawdzaj je trybem `--sprawdz`.
- Treści dziennika nie kopiuj do innych plików i nie cytuj szerzej, niż wymaga naprawa.

## Słownik: jak go prowadzić

Słownik startowy z załącznika A zawiera tylko nazwy narzędzi AI, bo te model przekręca u każdego podobnie. Nie dopisuj do niego z góry zwykłych słów ani wyrażeń. Każdy mówi inaczej i u każdego model myli się w innych miejscach, więc hasła na zapas tylko zaśmiecają plik i podpowiedź dla modelu. Hasła użytkownika biorą się z dwóch źródeł: z rozmowy w kroku 2 i z dziennika po kilku dniach używania.

Gdy użytkownik prosi o uzupełnienie słownika:

1. Za zgodą użytkownika przeczytaj wpisy z ostatnich dni w `dyktafon.log` (część „Dziennik a prywatność”) i wypisz słowa, które model zapisał źle więcej niż raz, razem ze wszystkimi wariantami zapisu.
2. Pokaż listę użytkownikowi i zapytaj o poprawną pisownię tam, gdzie nie jest oczywista.
3. Dopisz hasła. Format jest opisany w nagłówku pliku: zwykła podmiana z odmianą, gwiazdka dla obcych słów odmienianych po polsku (`storyboard*: storibord*` poprawi też „storibordzie”), `{n}` dla liczebników.
4. Każde nowe hasło sprawdź przez `--sprawdz` na prawdziwej linijce z dziennika i na zdaniu ze zwykłymi słowami (wniosek 7).

**Tagi załączników.** Część generatorów oznacza załączone pliki jako `@img1`, `@img2`, `@vid1`. Jeśli użytkownik pracuje w takim narzędziu, zaproponuj dyktowanie tych tagów głosem: zapytaj, jak chce je wypowiadać, i dopisz hasło z `{n}`, na przykład `@img{n}: referencja image {n}`. Wzorzec łapie liczebnik w każdej wymowie (one, wan, jeden, 1) i zamienia go na cyfrę.

## Kiedy użytkownik wraca z problemem

Najpierw czytaj końcówkę `dyktafon.out` (błędy programu), a gdy problem dotyczy rozpoznawania, także końcówkę `dyktafon.log` (co model usłyszał i co wkleił). Obowiązują zasady z części „Dziennik a prywatność”. Naprawiasz ty, użytkownik tylko opisuje objaw. Granice z początku instrukcji obowiązują też przy naprawach.

- **Źle rozpoznane słowo:** dopisz wariant do słownika i sprawdź przez `--sprawdz` na linijce z dziennika.
- **Słownik zmienia zwykłe słowo:** znajdź hasło, które je łapie (wniosek 7), i przepisz je na zapis z gwiazdką albo usuń zbyt krótki wariant.
- **Nic się nie dzieje po wciśnięciu klawisza:** uprawnienia (Mac) albo martwy hak (Windows), zobacz `dyktafon.out`. Na Macu martwy podsłuch klawisza to skutek, a nie przyczyna: system wyłącza podsłuch, którego callback nie wraca w porę.
- **Przestało działać po aktualizacji systemu albo Pythona:** sprawdź uprawnienia. Po aktualizacji Pythona system potrafi uznać go za nowy program. Usuń stary wpis „Python” z list i nadaj uprawnienia od nowa.
- **Ikona pokazuje nagrywanie i nic nie reaguje:** zakleszczenie sterownika dźwięku. Program powinien po kilku sekundach sam się uruchomić od nowa (wniosek 3). Jeśli nie wstał, na Macu `sample <pid>` pokaże, gdzie stoi główny wątek. Sprawdź, czy obsługa mikrofonu nie wróciła do callbacku klawisza (wniosek 1).
- **Po czekaniu nic się nie wkleja, a w dzienniku jest `[odrzucone]`:** to bramka z wniosku 12, nagranie nie miało wyraźnej mowy. Jeśli odrzuca też zwykłe zdania, sprawdź mikrofon (wniosek 5) i głośność nagrania.
- **Na końcu tekstu pojawiają się zdania, których nikt nie powiedział:** dopisz je do filtra ogonów.
- **Rozpoznawanie nagle dużo gorsze:** sprawdź w menu, który mikrofon jest aktywny. Najczęściej system przełączył się na słuchawki Bluetooth (wniosek 5).

---

## Załącznik A: slownik.txt (słownik startowy)

Zapisz jako `slownik.txt` i dopisz na końcu słowa użytkownika z kroku 2.

````text
# SŁOWNIK DYKTAFONU
# Jedna pozycja w linii. Po lewej poprawna pisownia, po dwukropku warianty,
# jakie model lubi zwracać. Wielkość liter nie ma znaczenia, odstępy są luźne:
# „mid journey" łapie też „mid-journey", „midjourney" i „mid, journey".
#
#   Midjourney: midżurnej        zwykła podmiana, odmiana sama się dopasuje
#                                (midżurneju → Midjourneyu, klaudowi → Claude'owi)
#   storyboard*: storibord*      gwiazdka = pasuje początek słowa, końcówka zostaje
#                                (storibordzie → storyboardzie, storibordem → storyboardem)
#   @img{n}: referencja image {n}   {n} = liczebnik 1-9 w każdej wymowie
#                                (referencja image one / wan / jeden / 1 → @img1)
#   Figma                        samo hasło: podpowiedź dla modelu i wielkość liter
#
# Uwaga: wariant kończący się krótkim słówkiem (mniej niż 3 litery, np. „image an")
# pasuje tylko dokładnie, bez polskich końcówek.
# Plik czyta się na bieżąco, nie trzeba restartować.
#
# Na start są tu tylko nazwy narzędzi AI. Własne słowa dopisuje się niżej,
# w miarę jak wychodzą w dyktowaniu (dyktafon.log pokazuje, co model usłyszał).

# --- czaty i asystenci ---
Claude: klaud, klod, claud, cloud
Claudzie: klaudzie, cloudzie
Claude Code: klaud kod, klaud code, claude kod, klod kod, klod code, cloud kod, cloud code
ChatGPT: czat gpt, chat gpt, czad gpt
Gemini: dżemini

# --- generatory obrazu i wideo ---
Midjourney: mid journey, midżurnej, midjurney
Seedance: sidens, sidans, sidance, si dens, sea dance, sidenc, seadance
Seedream: sidrim, sidrym, si drim, sea dream, sidream, seedrim, sidrem
Nano Banana: nano banan, nano bananna, nanobanana, nano banana, nano bonana
Higgsfield: higsfield, higzfield, higsfild, higsfeld, hicksfield, hiksfield, higs field, higz field, hexfield
Kling: kling, klink, kling ai
Veo: weo, wjeo
Sora*: sora*
Runway: ranłej, ranwej, ran way, runłej, run way
Flux: fluks
Magnific: magnifik, magnific ai
Freepik: fripik, fri pik, frypik
Krea
Ideogram: ideogram
Recraft: rikraft, ri kraft, recraft
Topaz: topaz
Hailuo: hajluo, haj luo
MiniMax: mini max, minimaks, mini maks
Luma
InVideo: in video, in wideo, inwideo, invideo
HeyGen: hey gen
Firefly: fire fly
Stable Diffusion: stable difusion

# --- głos i muzyka ---
ElevenLabs: eleven labs, elewen labs, ilewen labs, eleven lebs, elevenlabs
Suno*: suno*, sunno*

# --- pozostałe ---
Perplexity: perpleksiti, perpleksity, perplexiti
Codex: kodex, codeks
Grok
Copilot: co pilot
DeepSeek: deep seek
NotebookLM: notebook lm
prompt

# „cloud" zamienia się wyżej na „Claude", bo tak model najczęściej słyszy tę nazwę.
# Prawdziwe nazwy z „Cloud" są tu po to, żeby ta zamiana ich nie ruszała.
Creative Cloud
Google Cloud
iCloud

# --- moje słowa ---
# Tu dopisuj własne: nazwę firmy, nazwisko, marki klientów, słowa z branży.
#   PoprawnaNazwa: jak ją słyszy model, jak jeszcze ją słyszy
````

## Załącznik B: kod referencyjny (Mac z procesorem Apple)

Kod pochodzi z narzędzia używanego codziennie. Silnik słownika i filtry były sprawdzane na kilku tysiącach prawdziwych dyktand.

### dyktafon.py

````python
#!/usr/bin/env python3
"""
DYKTAFON: lokalne dyktowanie po polsku, push-to-talk.

Przytrzymaj klawisz (domyślnie fn), mów, puść. Tekst wkleja się w aktywne pole.
Audio żyje tylko w pamięci RAM, transkrypcję robi Whisper large-v3-turbo przez MLX
na tym Macu. Nic nie wychodzi do sieci, nic nie ląduje na dysku.

Pliki obok:
  ustawienia.json  konfiguracja (klawisz, mikrofon, model, czyszczenie)
  slownik.txt      własne słowa i ich błędne zapisy do podmiany
  dyktafon.log     dziennik (tylko tekst po transkrypcji, jeśli włączony)
"""

import json
import os
import queue
import re
import subprocess
import sys
import threading
import time
import traceback
from pathlib import Path

import numpy as np

KATALOG = Path(__file__).resolve().parent
PLIK_USTAWIEN = KATALOG / "ustawienia.json"
PLIK_SLOWNIKA = KATALOG / "slownik.txt"
PLIK_LOGU = KATALOG / "dyktafon.log"

DOMYSLNE = {
    "klawisz": 63,                 # 63 = fn/globe. Inne: 61 = prawy Option, 54 = prawy Command
    "bez_trzymania": "control",    # drugi klawisz: wciśnięty w trakcie trzymania przełącza na nagrywanie
                                   # bez trzymania. control / shift / option / command, inny niż "klawisz"
    "polkniij_klawisz": True,      # fn nie otwiera wtedy emoji / nie zmienia klawiatury
    "mikrofon": "MacBook",         # fragment nazwy urządzenia; "" = domyślny systemowy
    "przycisk_myszy": None,        # 2 = kliknięcie kółkiem (środkowy), działa jak fn; null = wyłączone
    "model": "mlx-community/whisper-large-v3-turbo",
    "jezyk": "pl",
    "min_sekundy": 0.4,            # krótsze wciśnięcia ignorujemy (przypadkowe tapnięcia)
    "max_sekundy": 300,            # dłuższe nagranie program kończy sam
    "prog_ciszy": 0.002,           # RMS sygnału poniżej tego = nic nie powiedziano, nie transkrybujemy
    "usuwaj_przerywniki": True,
    "przywracaj_schowek": True,    # po wklejeniu oddajemy to, co było w schowku
    "dzwieki": True,
    "loguj_tekst": True,           # zapisuj transkrypty do dyktafon.log (do poprawiania słownika);
                                   # False = treść dyktand nie trafia do żadnego pliku
    "historia": 200,               # ile ostatnich wpisów trzyma dyktafon.log, starsze wypadają
    "llm": {                       # opcjonalne czyszczenie przez lokalny model w ollamie
        "wlaczone": False,
        "model": "",
        "url": "http://localhost:11434/api/generate",
    },
}

PRZERYWNIKI = re.compile(
    r"(?<![\w])(?:y{2,}|e{2,}|a{2,}|m{2,}|h?m{2,}|yhm|aha|no no)(?![\w])",
    re.IGNORECASE,
)
# Typowe halucynacje Whispera po polsku na ciszy / szumie.
HALUCYNACJE = [
    "amara.org", "napisy stworzone", "dziękuję za uwagę", "dziękuję za oglądanie",
    "napisy pochodzą", "zapraszam do subskrypcji", "subskrybuj",
    "sous-titrage", "sous-titres",   # to samo po francusku, wychodzi na cichym szumie
]
# Te same halucynacje DOKLEJONE na końcu dłuższego dyktanda (Whisper na ciszy
# po puszczeniu klawisza żegna się jak YouTube) — zdejmowane z ogona w pętli.
OGONY_HALUCYNACJI = re.compile(
    r"[\s,]*([.!?])?\s*(?:"
    r"(?:dziękuję|dzięki|dziękuje)\s+za\s+(?:oglądanie|uwagę)"
    r"|do\s+zobaczenia(?:\s+w\s+następnym\s+(?:filmie|odcinku))?"
    r"|zapraszam\s+do\s+subskrypcji"
    r"|(?:napisy\s+stworzone\s+przez|napisy\s+pochodzą)[^.!?]*"
    r"|amara\.org"
    r")[.!?\s]*$",
    re.IGNORECASE)
# Końcówki polskiej odmiany, żeby „klauda" → „Claude'a", „figmie" → „Figmie"
KONCOWKI_LISTA = r"(?:a|u|owi|em|iem|ie|ów|om|ami|ach|y|i|e|ę|ą|o)"
KONCOWKI = KONCOWKI_LISTA + "?"

# Nazwy klawiszy do menu i komunikatów.
NAZWY_KLAWISZY = {63: "fn", 61: "prawy Option", 58: "lewy Option", 54: "prawy Command", 55: "lewy Command",
                  62: "prawy Control", 59: "lewy Control", 60: "prawy Shift", 56: "lewy Shift"}
NAZWY_MODYFIKATOROW = {"control": "Control", "shift": "Shift", "option": "Option", "command": "Command"}

# Liczebniki do wzorca {n} w słowniku: cyfra → jak model może ją zapisać po polsku i po angielsku.
LICZEBNIKI = {
    "1": ["1", "one", "wan", "łan", "uan", "van", "an", "jeden", "jedynka"],
    "2": ["2", "two", "tu", "tł", "dwa", "dwójka"],
    "3": ["3", "three", "tri", "fri", "trzy", "trójka"],
    "4": ["4", "four", "for", "cztery", "czwórka"],
    "5": ["5", "five", "fajf", "pięć", "piątka"],
    "6": ["6", "six", "siks", "sześć", "szóstka"],
    "7": ["7", "seven", "sewen", "siedem", "siódemka"],
    "8": ["8", "eight", "ejt", "osiem", "ósemka"],
    "9": ["9", "nine", "najn", "dziewięć", "dziewiątka"],
}


def wczytaj_ustawienia():
    u = json.loads(json.dumps(DOMYSLNE))
    if PLIK_USTAWIEN.exists():
        try:
            wlasne = json.loads(PLIK_USTAWIEN.read_text("utf-8"))
            for k, v in wlasne.items():
                if isinstance(v, dict) and isinstance(u.get(k), dict):
                    u[k].update(v)
                else:
                    u[k] = v
        except Exception as e:
            print("ustawienia.json: błąd, używam domyślnych:", e)
    else:
        PLIK_USTAWIEN.write_text(json.dumps(u, ensure_ascii=False, indent=2), "utf-8")
    return u


class Slownik:
    """
    Format slownik.txt, jedna pozycja w linii:
        Midjourney: midżurnej, mid journey       zwykła podmiana + polska odmiana
        storyboard*: storibord*, story bord*     gwiazdka = początek słowa, końcówkę zostawiamy
        @img{n}: referencja image {n}            {n} = liczebnik 1-9 (one, wan, jeden, 1...)
        Figma                                    samo hasło: podpowiedź dla modelu + wielkość liter

    Lewa strona to docelowy zapis, po dwukropku warianty, jakie zwraca model.
    Odstępy w wariancie są luźne: „mid journey" łapie też „mid-journey", „midjourney", „mid, journey".
    Odmiana dopasowuje się sama: midżurneju → Midjourneyu, klaudowi → Claude'owi, figmie → Figmie.
    Warianty kończące się krótkim słówkiem (an, up, tu) nie biorą końcówek — żeby
    „image ani" nie zamieniło się w „image 1".
    Linie od # to komentarze. Plik czyta się na bieżąco, bez restartu.
    """

    SAMOGLOSKI = "aeo"
    ROZDZIELACZ = r"[\s,\.\-–—]*"      # spacja, przecinek, kropka, myślnik — albo nic
    ROZDZIELACZ_KLUCZ = re.compile(r"[\s,\.\-–—]+")

    def __init__(self):
        self.hasla = []
        self.wzor = None
        self.doslowne = {}     # wariant → cel, z polską odmianą
        self.uciete = {}       # rdzeń po ucięciu samogłoski (figma → figm) → cel, końcówka obowiązkowa
        self.frazy = {}        # wariant → cel, bez odmiany (krótkie słówko na końcu)
        self.przedrostki = {}  # początek słowa → cel, końcówka zostaje jak powiedziana
        self.mtime = 0
        self.przeladuj()

    @classmethod
    def rdzen(cls, slowo):
        """figma → figm, kling → kling. Końcową samogłoskę dokleja odmiana."""
        if len(slowo) > 2 and slowo[-1] in cls.SAMOGLOSKI and slowo[-1] != "e":
            return slowo[:-1]
        return slowo

    @classmethod
    def klucz(cls, tekst):
        """„Mid-Journey", „mid journey" i „midjourney" to ten sam klucz."""
        return cls.ROZDZIELACZ_KLUCZ.sub("", tekst.lower())

    @classmethod
    def wzorzec(cls, wariant):
        """Wariant → regex z luźnymi odstępami między słowami."""
        czesci = [re.escape(c) for c in cls.ROZDZIELACZ_KLUCZ.split(wariant) if c]
        return cls.ROZDZIELACZ.join(czesci)

    @staticmethod
    def rozwin_liczby(cel, warianty):
        """{n} w haśle → osobna pozycja na każdą cyfrę i każdy sposób jej wymówienia."""
        if "{n}" not in cel:
            return [(cel, warianty)]
        pozycje = []
        for cyfra, formy in LICZEBNIKI.items():
            nowe = []
            for w in warianty:
                if "{n}" in w:
                    nowe += [w.replace("{n}", f) for f in formy]
            if nowe:
                pozycje.append((cel.replace("{n}", cyfra), nowe))
        return pozycje

    def przeladuj(self):
        if not PLIK_SLOWNIKA.exists():
            return
        mt = PLIK_SLOWNIKA.stat().st_mtime
        if mt == self.mtime:
            return
        self.mtime = mt
        hasla = []
        doslowne, uciete, frazy, przedrostki = {}, {}, {}, {}
        alt_d, alt_f, alt_p = [], [], []

        for linia in PLIK_SLOWNIKA.read_text("utf-8").splitlines():
            linia = linia.strip()
            if not linia or linia.startswith("#"):
                continue
            if ":" in linia:
                cel, warianty = linia.split(":", 1)
                cel = cel.strip()
                warianty = [w.strip() for w in warianty.split(",") if w.strip()]
            else:
                cel, warianty = linia, []
            for cel_n, warianty_n in self.rozwin_liczby(cel, warianty):
                wynik = cel_n.rstrip("*").strip()
                if not wynik:
                    continue
                hasla.append(wynik)
                for w in warianty_n + [cel_n]:
                    if w.endswith("*"):
                        rdz = w[:-1].strip()
                        if not rdz:
                            continue
                        przedrostki[self.klucz(rdz)] = wynik
                        alt_p.append((len(self.klucz(rdz)), self.wzorzec(rdz)))
                        continue
                    slowa = [s for s in self.ROZDZIELACZ_KLUCZ.split(w.lower()) if s]
                    if not slowa:
                        continue
                    if len(slowa[-1]) >= 3:                      # bierze polską końcówkę
                        rdz = self.rdzen(w.lower())
                        if rdz != w.lower():
                            # ucięta samogłoska: sam rdzeń nie wystarcza, musi przyjść końcówka,
                            # inaczej „weo" łapie polskie „we", a „sora" łapie „SOR"
                            uciete[self.klucz(rdz)] = wynik
                            alt_d.append((len(self.klucz(rdz)), self.wzorzec(rdz) + KONCOWKI_LISTA))
                        else:
                            doslowne[self.klucz(rdz)] = wynik
                            alt_d.append((len(self.klucz(rdz)), self.wzorzec(rdz) + KONCOWKI))
                    else:                                        # „image an", „mid tu" — tylko dokładnie
                        frazy[self.klucz(w)] = wynik
                        alt_f.append((len(self.klucz(w)), self.wzorzec(w)))

        # jedno przejście regexem, najdłuższe warianty pierwsze, każde miejsce podmieniane raz
        def galaz(alt, nazwa, ogon):
            if not alt:
                return None
            alt.sort(key=lambda x: -x[0])
            wzory = list(dict.fromkeys(w for _, w in alt))
            return "(?P<%s>%s)%s" % (nazwa, "|".join(wzory), ogon)

        galezie = [
            galaz(alt_d, "d", ""),      # końcówkę każdy wariant niesie sam, rozkłada ją rozloz()
            galaz(alt_f, "f", ""),
            galaz(alt_p, "p", r"(?P<pk>\w*)"),
        ]
        galezie = [g for g in galezie if g]
        self.wzor = re.compile(r"(?<![\w])(?:" + "|".join(galezie) + r")(?![\w])", re.IGNORECASE) if galezie else None
        self.doslowne, self.uciete, self.frazy, self.przedrostki = doslowne, uciete, frazy, przedrostki
        self.hasla = hasla

    def podpowiedz(self):
        """Krótki prompt dla Whispera z poprawnymi pisowniami (bez @skrótów i duplikatów)."""
        czyste = []
        for h in self.hasla:
            if not h or "@" in h or "{" in h:
                continue
            k = h.lower()
            # pomijamy powtórki i rdzenie typu „environmen" obok „environment"
            if any(k == g or (len(k) - len(g) <= 3 and k.startswith(g)) or
                   (len(g) - len(k) <= 3 and g.startswith(k)) for g in map(str.lower, czyste)):
                continue
            czyste.append(h)
        if not czyste:
            return ""
        podp = "Słownik: "
        for h in czyste:
            if len(podp) + len(h) > 480:
                break
            podp += h + ", "
        return podp.rstrip(", ") + ". "

    def zastosuj(self, tekst):
        self.przeladuj()
        if not self.wzor:
            return tekst

        def podmien(m):
            g = m.groupdict()
            if g.get("d") is not None:
                cel, koncowka = self.rozloz(g["d"])
                return self.odmien(cel, koncowka) if cel else m.group(0)
            if g.get("f") is not None:
                return self.frazy.get(self.klucz(g["f"]), m.group(0))
            if g.get("p") is not None:
                cel = self.przedrostki.get(self.klucz(g["p"]))
                return cel + (g.get("pk") or "") if cel else m.group(0)
            return m.group(0)

        return self.wzor.sub(podmien, tekst)

    KONCOWKI_OD_NAJKROTSZEJ = ["", "a", "u", "y", "i", "e", "ę", "ą", "o", "em", "ie", "ów", "om",
                               "owi", "iem", "ami", "ach"]

    def rozloz(self, tekst):
        """Dopasowany kawałek → (cel, końcówka). Najdłuższy rdzeń wygrywa, ucięty rdzeń wymaga końcówki."""
        niski = tekst.lower()
        for konc in self.KONCOWKI_OD_NAJKROTSZEJ:
            if konc and not niski.endswith(konc):
                continue
            rdz = self.klucz(niski[:len(niski) - len(konc)])
            if rdz in self.doslowne:
                return self.doslowne[rdz], konc
            if konc and rdz in self.uciete:
                return self.uciete[rdz], konc
        return None, ""

    @classmethod
    def odmien(cls, cel, koncowka):
        """GPT-a (skrótowiec), Claude'a (nieme e), Figmie (zwykłe słowo z samogłoską)."""
        if not koncowka:
            return cel
        koncowka = koncowka.lower()
        if not cel[-1].isalpha():          # @img1 — nie odmieniamy
            return cel
        if cel[-1] == "e" and cel[0].isupper():
            return cel + "'" + koncowka
        if cel[-1].lower() in cls.SAMOGLOSKI:
            return cel[:-1] + (koncowka.upper() if cel.isupper() else koncowka)
        if cel.isupper() and len(cel) > 1:
            return cel + "-" + koncowka
        return cel + koncowka


def wyczysc(tekst, ust, slownik):
    t = tekst.strip()
    if not t:
        return ""
    # najpierw zdejmij doklejone pożegnania z ogona — dopiero POTEM oceniaj
    # resztę, inaczej krótkie prawdziwe zdanie z ogonem kasowało się w całości
    while True:
        bez_ogona = OGONY_HALUCYNACJI.sub(r"\1", t)
        if bez_ogona == t:
            break
        t = bez_ogona
    t = t.strip()
    if not t:
        return ""
    niski = t.lower()
    if any(h in niski for h in HALUCYNACJE) and len(t) < 80:
        return ""
    if re.fullmatch(r"(dziękuję|dzięki|do widzenia|do zobaczenia)[.!]?", niski.strip()):
        return ""
    if ust["usuwaj_przerywniki"]:
        t = PRZERYWNIKI.sub("", t)
        t = re.sub(r"\s+([,.!?;:])", r"\1", t)
        t = re.sub(r"([,.!?;:])\s*\1+", r"\1", t)
        t = re.sub(r",\s*,", ",", t)
        t = re.sub(r"\s{2,}", " ", t).strip()
        t = re.sub(r"^[,.;:\s]+", "", t)
    t = slownik.zastosuj(t)
    if t and t[0].islower():
        t = t[0].upper() + t[1:]
    return t


def wyczysc_llm(tekst, ust):
    """Opcjonalnie: lokalny model w ollamie wygładza tekst. Domyślnie wyłączone."""
    cfg = ust["llm"]
    if not cfg.get("wlaczone") or not cfg.get("model"):
        return tekst
    import urllib.request
    prompt = (
        "Popraw tylko interpunkcję i usuń wtrącenia (yyy, eee, no, znaczy) w tekście dyktowanym po polsku. "
        "Nie zmieniaj słów, nie skracaj, nie dodawaj niczego, nie komentuj. Zwróć sam tekst.\n\n" + tekst
    )
    dane = json.dumps({"model": cfg["model"], "prompt": prompt, "stream": False}).encode()
    req = urllib.request.Request(cfg["url"], data=dane, headers={"Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(req, timeout=20) as r:
            wynik = json.load(r).get("response", "").strip()
        return wynik or tekst
    except Exception as e:
        print("LLM pominięty:", e)
        return tekst


# ---------- audio ----------

class Nagrywanie:
    def __init__(self, ust):
        import sounddevice as sd
        self.sd = sd
        self.sr = 16000
        self.bufor = []
        self.strumien = None
        self.urzadzenie = self.wybierz_urzadzenie(ust["mikrofon"])

    def wybierz_urzadzenie(self, fragment):
        if not fragment:
            return None
        for i, d in enumerate(self.sd.query_devices()):
            if d["max_input_channels"] > 0 and fragment.lower() in d["name"].lower():
                print("mikrofon:", d["name"])
                return i
        print(f"mikrofon „{fragment}” nie znaleziony, używam domyślnego")
        return None

    def nazwa_urzadzenia(self):
        d = self.sd.query_devices(self.urzadzenie if self.urzadzenie is not None else None, "input")
        return d["name"]

    def start(self):
        if self.strumien is not None:      # po awarii mógł zostać otwarty strumień
            self.stop()
        self.bufor = []

        def cb(indata, frames, t, status):
            self.bufor.append(indata[:, 0].copy())

        # blocksize=0 = wielkość porcji dobiera urządzenie; drobne porcje (256) i „low latency"
        # niepotrzebnie męczyły sterownik, a przy dyktowaniu opóźnienie nie ma znaczenia
        self.strumien = self.sd.InputStream(
            samplerate=self.sr, channels=1, dtype="float32",
            device=self.urzadzenie, callback=cb, blocksize=0,
        )
        self.strumien.start()
        # czekamy, aż urządzenie naprawdę ruszy: zatrzymanie w trakcie rozruchu
        # zakleszczało PortAudio z CoreAudio (menu zamarzało na dobre)
        for _ in range(60):
            try:
                if self.strumien.active:
                    break
            except Exception:
                break
            time.sleep(0.005)

    def zabierz(self):
        """Oddaje nagrane audio, zanim ruszymy sterownik: zamykanie strumienia potrafi
        się zakleszczyć w CoreAudio i wtedy dyktando przepadało razem z nim."""
        porcje, self.bufor = self.bufor, []
        if self.strumien is None or not porcje:
            return np.zeros(0, dtype=np.float32)
        return np.concatenate(porcje)

    def stop(self):
        strumien, self.strumien = self.strumien, None
        if strumien is None:
            return np.zeros(0, dtype=np.float32)
        try:
            strumien.abort()     # bez dociągania bufora — całe audio i tak mamy już w RAM
        except Exception:
            traceback.print_exc()
        finally:
            try:
                strumien.close()
            except Exception:
                traceback.print_exc()
        if not self.bufor:
            return np.zeros(0, dtype=np.float32)
        return np.concatenate(self.bufor)


# ---------- transkrypcja ----------

class Transkrypcja:
    def __init__(self, ust, slownik):
        self.ust = ust
        self.slownik = slownik
        self.model = ust["model"]
        self.gotowe = False
        self.odrzucone = 0

    def sciezka_modelu(self, tylko_z_dysku):
        """Nazwa modelu → folder na dysku. Z tylko_z_dysku=True nie dotyka sieci."""
        if Path(self.ust["model"]).exists():       # ktoś podał własny folder z modelem
            return self.ust["model"]
        from huggingface_hub import snapshot_download
        return snapshot_download(self.ust["model"], local_files_only=tylko_z_dysku)

    def rozgrzej(self):
        import mlx.core as mx
        import mlx_whisper
        self.mx = mx
        self.mw = mlx_whisper
        cisza = np.zeros(16000, dtype=np.float32)
        try:
            try:
                # model już pobrany: bierzemy go z dysku i nie łączymy się z siecią wcale
                self.model = self.sciezka_modelu(True)
                self.mw.transcribe(cisza, path_or_hf_repo=self.model, language=self.ust["jezyk"])
            except Exception:
                # pierwsze uruchomienie albo przerwane pobieranie: ściągamy model (około 1,5 GB)
                print("pobieram model mowy, to potrwa kilka minut...")
                self.model = self.sciezka_modelu(False)
                self.mw.transcribe(cisza, path_or_hf_repo=self.model, language=self.ust["jezyk"])
        finally:
            self.mx.clear_cache()
        self.gotowe = True

    def przetworz(self, audio):
        self.odrzucone = 0
        rms = float(np.sqrt(np.mean(audio ** 2))) if len(audio) else 0.0
        if rms < self.ust["prog_ciszy"]:
            print(f"cisza (rms {rms:.4f}), pomijam")
            return ""
        try:
            wynik = self.mw.transcribe(
                audio,
                path_or_hf_repo=self.model,
                language=self.ust["jezyk"],
                initial_prompt=self.slownik.podpowiedz() or None,
                condition_on_previous_text=False,
                no_speech_threshold=0.6,
                compression_ratio_threshold=2.4,
            )
        finally:
            # MLX odkłada zwolnione bufory GPU na później i sam ich nie oddaje:
            # po dwóch dobach dyktowania program trzymał 8 GB przy modelu 1,5 GB, reszta szła
            # do swapu. Czyszczenie po każdym dyktandzie nie spowalnia transkrypcji (zmierzone).
            self.mx.clear_cache()
        kawalki = []
        for s in wynik.get("segments", []):
            if s.get("no_speech_prob", 0) > 0.75 and s.get("avg_logprob", 0) < -1.0:
                continue
            # Na dźwięku bez wyraźnej mowy (stukanie w klawisze, szum, gwar) Whisper próbuje sześć razy
            # z coraz większą losowością i oddaje ostatnią próbę, choć sam ją ocenił jako nieudaną:
            # zapętloną albo zgadywaną. no_speech_prob wynosi wtedy 0,00, więc filtr wyżej tego
            # nie łapie i do tekstu szedł bełkot w kilku językach.
            if s.get("compression_ratio", 0) > 2.4 or s.get("avg_logprob", 0) < -1.0:
                self.odrzucone += 1
                continue
            kawalki.append(s["text"].strip())
        if self.odrzucone:
            print(f"model zgadywał, odrzucam kawałki: {self.odrzucone}")
        return " ".join(k for k in kawalki if k)


# ---------- wklejanie ----------

def schowek_czytaj():
    from AppKit import NSPasteboard, NSPasteboardTypeString
    pb = NSPasteboard.generalPasteboard()
    return pb.stringForType_(NSPasteboardTypeString)


def schowek_pisz(tekst):
    from AppKit import NSPasteboard, NSPasteboardTypeString
    pb = NSPasteboard.generalPasteboard()
    pb.clearContents()
    pb.setString_forType_(tekst, NSPasteboardTypeString)


def wcisnij_cmd_v():
    import Quartz
    zrodlo = Quartz.CGEventSourceCreate(Quartz.kCGEventSourceStateHIDSystemState)
    for wcisniety in (True, False):
        ev = Quartz.CGEventCreateKeyboardEvent(zrodlo, 9, wcisniety)  # 9 = V
        Quartz.CGEventSetFlags(ev, Quartz.kCGEventFlagMaskCommand)
        Quartz.CGEventPost(Quartz.kCGHIDEventTap, ev)
        time.sleep(0.02)


def wklej(tekst, ust):
    poprzedni = schowek_czytaj() if ust["przywracaj_schowek"] else None
    schowek_pisz(tekst)
    time.sleep(0.05)
    wcisnij_cmd_v()
    if poprzedni is not None:
        def przywroc():
            time.sleep(0.8)
            if schowek_czytaj() == tekst:
                schowek_pisz(poprzedni)
        threading.Thread(target=przywroc, daemon=True).start()


def dzwiek(nazwa, ust):
    if not ust["dzwieki"]:
        return
    try:
        from AppKit import NSSound
        s = NSSound.soundNamed_(nazwa)
        if s:
            s.play()
    except Exception:
        pass


# ---------- aplikacja ----------

class Dyktafon:
    def __init__(self):
        self.ust = wczytaj_ustawienia()
        self.slownik = Slownik()
        self.nagrywanie = Nagrywanie(self.ust)
        self.transkrypcja = Transkrypcja(self.ust, self.slownik)
        self.nagrywa = False
        self.wolne_rece = False     # klawisz + drugi klawisz: nagrywa bez trzymania, kończy ponowne wciśnięcie
        self.wstrzymany = False     # „Wstrzymaj” w menu: program żyje, ale klawisz nic nie robi
        self.start_czas = 0.0
        self.ostatni_tekst = ""
        self.blokada = threading.Lock()
        self.app = None
        # Mikrofonu NIE wolno ruszać z callbacku klawisza: CoreAudio i PortAudio potrafią
        # zakleszczyć się na muteksie, a callback siedzi na głównym wątku — zamarzało wtedy
        # całe menu i nie dało się przerwać nagrywania. Callback tylko wrzuca polecenie tutaj.
        self.polecenia = queue.Queue()
        self.audio_od = 0.0          # kiedy wątek audio zaczął bieżące polecenie (0 = wolny)
        self.zgloszony_zator = False
        threading.Thread(target=self.petla_audio, daemon=True).start()

    # stan -> ikona (zawsze przez główny wątek, pasek menu nie lubi zmian z boku)
    def ikona(self, txt):
        if not self.app:
            return
        if threading.current_thread() is threading.main_thread():
            self.app.title = txt
            return
        try:
            from PyObjCTools import AppHelper
            AppHelper.callAfter(setattr, self.app, "title", txt)
        except Exception:
            self.app.title = txt

    def klawisz_wcisniety(self):
        if self.nagrywa or not self.transkrypcja.gotowe:
            return
        self.nagrywa = True
        self.start_czas = time.time()
        self.polecenia.put("start")

    def anuluj(self):
        if not self.nagrywa:
            return
        self.nagrywa = False
        self.polecenia.put("anuluj")

    def klawisz_puszczony(self):
        if not self.nagrywa:
            return
        self.nagrywa = False
        self.polecenia.put("stop")

    # ---- wątek obsługi mikrofonu: tu i tylko tu wchodzimy w sterownik dźwięku ----

    def petla_audio(self):
        while True:
            polecenie = self.polecenia.get()
            self.audio_od = time.time()
            try:
                if polecenie == "start":
                    self.wlacz_mikrofon()
                elif polecenie == "stop":
                    self.wylacz_mikrofon(przetwarzaj=True)
                elif polecenie == "anuluj":
                    self.wylacz_mikrofon(przetwarzaj=False)
            except Exception:
                traceback.print_exc()
                self.nagrywa = False
                self.ikona("⚠️")
            finally:
                self.audio_od = 0.0

    def wlacz_mikrofon(self):
        try:
            self.nagrywanie.start()
        except Exception as e:
            self.nagrywa = False
            print("nie udało się uruchomić mikrofonu:", e)
            self.ikona("⚠️")
            return
        print("nagrywam...")
        self.ikona("🔴∞" if self.wolne_rece else "🔴")
        dzwiek("Pop", self.ust)

    def wylacz_mikrofon(self, przetwarzaj):
        # najpierw audio i transkrypcja, dopiero potem sterownik: jeśli zamykanie
        # strumienia się zatnie, dyktando i tak zostanie wklejone
        audio = self.nagrywanie.zabierz()
        try:
            if not przetwarzaj:
                print("anulowane (klawisz użyty jako skrót)")
                self.ikona("🎙")
                return
            czas = time.time() - self.start_czas
            if czas < self.ust["min_sekundy"] or len(audio) < self.ust["min_sekundy"] * 16000:
                print(f"za krótko ({czas:.2f}s, {len(audio)/16000:.2f}s audio), pomijam")
                self.ikona("🎙")
                return
            dzwiek("Tink", self.ust)
            self.ikona("⏳")
            threading.Thread(target=self.przetworz, args=(audio,), daemon=True).start()
        finally:
            self.nagrywanie.stop()

    def przetworz(self, audio):
        with self.blokada:
            try:
                t0 = time.time()
                surowy = self.transkrypcja.przetworz(audio)
                tekst = wyczysc(surowy, self.ust, self.slownik)
                tekst = wyczysc_llm(tekst, self.ust)
                dt = time.time() - t0
                if tekst:
                    self.ostatni_tekst = tekst
                    wklej(tekst, self.ust)
                    self.zaloguj(f"[{len(audio)/16000:.1f}s audio, {dt:.1f}s]", f"{surowy!r} -> {tekst!r}")
                    self.ikona("🎙")
                else:
                    if self.transkrypcja.odrzucone:
                        # nic nie wklejamy, więc dajemy znać dźwiękiem, czemu po czekaniu jest pusto
                        dzwiek("Basso", self.ust)
                        self.zaloguj("[odrzucone: w nagraniu nie było wyraźnej mowy]")
                    else:
                        self.zaloguj("[pusto]", f"{surowy!r}")
                    self.ikona("🎙")
            except Exception:
                traceback.print_exc()
                self.ikona("⚠️")
        # audio kończy żywot tutaj: referencja znika, RAM wraca do systemu

    def zaloguj(self, linia, tresc=""):
        """linia = metryka albo komunikat programu, tresc = to, co podyktowano.
        Treść idzie wyłącznie do dyktafon.log; na konsolę (dyktafon.out) tylko opis.
        Dziennik trzyma ostatnie `historia` wpisów, starsze wypadają."""
        print(linia)
        if self.ust["loguj_tekst"]:
            wpis = time.strftime("%Y-%m-%d %H:%M:%S ") + (f"{linia} {tresc}" if tresc else linia)
            stare = PLIK_LOGU.read_text("utf-8").splitlines() if PLIK_LOGU.exists() else []
            ile = max(1, int(self.ust.get("historia", 200)))
            PLIK_LOGU.write_text("\n".join((stare + [wpis])[-ile:]) + "\n", "utf-8")

    def zresetuj_tap(self):
        """Awaryjne: zdejmuje i zakłada podsłuch klawisza od nowa, bez restartu programu."""
        import Quartz
        self.wolne_rece = False
        self.anuluj()
        if self.tap is not None:
            try:
                Quartz.CGEventTapEnable(self.tap, False)
            except Exception:
                pass
            self.tap = None
        try:
            self.zaloz_tap()
            self.zaloguj("podsłuch klawisza założony od nowa (reset z menu)")
            self.ikona("🎙")
        except Exception as e:
            print(e)
            self.ikona("⚠️")

    # klawisz globalny przez CGEventTap
    def zaloz_tap(self):
        import Quartz
        kod = int(self.ust["klawisz"])
        polknij = bool(self.ust["polkniij_klawisz"])
        maski = {
            63: Quartz.kCGEventFlagMaskSecondaryFn,
            58: Quartz.kCGEventFlagMaskAlternate, 61: Quartz.kCGEventFlagMaskAlternate,
            55: Quartz.kCGEventFlagMaskCommand, 54: Quartz.kCGEventFlagMaskCommand,
            59: Quartz.kCGEventFlagMaskControl, 62: Quartz.kCGEventFlagMaskControl,
            56: Quartz.kCGEventFlagMaskShift, 60: Quartz.kCGEventFlagMaskShift,
        }
        if kod not in maski:
            raise RuntimeError(f"klawisz {kod} nie jest modyfikatorem; użyj 63 (fn), 61 (prawy Option), 54 (prawy Command)")
        maska = maski[kod]
        stan = {"wcisniety": False, "mysz": False}
        mysz = self.ust.get("przycisk_myszy")
        mysz = None if mysz is None else int(mysz)
        modyfikatory = {
            "control": ((59, 62), Quartz.kCGEventFlagMaskControl),
            "shift": ((56, 60), Quartz.kCGEventFlagMaskShift),
            "option": ((58, 61), Quartz.kCGEventFlagMaskAlternate),
            "command": ((55, 54), Quartz.kCGEventFlagMaskCommand),
        }
        nazwa_mod = str(self.ust.get("bez_trzymania") or "control").lower()
        if nazwa_mod not in modyfikatory:
            raise RuntimeError(f"bez_trzymania: „{nazwa_mod}” — dozwolone: control, shift, option, command")
        kody_mod, maska_mod = modyfikatory[nazwa_mod]
        if maska_mod == maska:
            raise RuntimeError("bez_trzymania musi być innym klawiszem niż klawisz dyktowania")
        ESC = 53

        def cb(proxy, typ, event, refcon):
            try:
                if typ in (Quartz.kCGEventTapDisabledByTimeout, Quartz.kCGEventTapDisabledByUserInput):
                    powod = "timeout" if typ == Quartz.kCGEventTapDisabledByTimeout else "użytkownik"
                    print(f"system wyłączył podsłuch klawisza ({powod}), włączam z powrotem")
                    Quartz.CGEventTapEnable(self.tap, True)
                    return event
                if self.wstrzymany:
                    return event
                # kółko myszy: trzymasz = nagrywa, puszczasz = wkleja, tak jak klawiszem
                if typ in (Quartz.kCGEventOtherMouseDown, Quartz.kCGEventOtherMouseUp):
                    if mysz is None or Quartz.CGEventGetIntegerValueField(event, Quartz.kCGMouseEventButtonNumber) != mysz:
                        return event
                    if typ == Quartz.kCGEventOtherMouseDown and not stan["mysz"]:
                        stan["mysz"] = True
                        if self.wolne_rece:
                            self.wolne_rece = False
                            self.klawisz_puszczony()
                        else:
                            self.klawisz_wcisniety()
                    elif typ == Quartz.kCGEventOtherMouseUp and stan["mysz"]:
                        stan["mysz"] = False
                        if not self.wolne_rece:
                            self.klawisz_puszczony()
                    return None
                if typ == Quartz.kCGEventOtherMouseDragged:
                    return None if stan["mysz"] else event
                keycode = Quartz.CGEventGetIntegerValueField(event, Quartz.kCGKeyboardEventKeycode)
                if typ == Quartz.kCGEventKeyDown:
                    if self.wolne_rece:
                        if keycode == ESC:          # Esc przerywa nagrywanie bez trzymania
                            self.wolne_rece = False
                            self.anuluj()
                            return None
                        return event
                    # klawisz trzymany razem z innym = to był skrót (fn+Delete, fn+F1), nie dyktowanie
                    if stan["wcisniety"]:
                        stan["wcisniety"] = False
                        self.anuluj()
                    return event
                if typ != Quartz.kCGEventFlagsChanged:
                    return event
                flagi = Quartz.CGEventGetFlags(event)
                # drugi klawisz wciśnięty podczas trzymania = przełącz na nagrywanie bez trzymania
                if keycode in kody_mod and stan["wcisniety"] and self.nagrywa and (flagi & maska_mod):
                    self.wolne_rece = True
                    self.ikona("🔴∞")
                    dzwiek("Pop", self.ust)
                    return event
                if keycode != kod:
                    return event
                wcisniety = bool(flagi & maska)
                if wcisniety and not stan["wcisniety"]:
                    stan["wcisniety"] = True
                    if self.wolne_rece:
                        # ponowne wciśnięcie klawisza kończy nagrywanie bez trzymania
                        self.wolne_rece = False
                        self.klawisz_puszczony()
                    else:
                        self.klawisz_wcisniety()
                elif not wcisniety and stan["wcisniety"]:
                    stan["wcisniety"] = False
                    if not self.wolne_rece:
                        self.klawisz_puszczony()
                return None if polknij else event
            except Exception:
                traceback.print_exc()
                return event

        self.tap = Quartz.CGEventTapCreate(
            Quartz.kCGSessionEventTap,
            Quartz.kCGHeadInsertEventTap,
            Quartz.kCGEventTapOptionDefault if polknij else Quartz.kCGEventTapOptionListenOnly,
            Quartz.CGEventMaskBit(Quartz.kCGEventFlagsChanged) | Quartz.CGEventMaskBit(Quartz.kCGEventKeyDown)
            | Quartz.CGEventMaskBit(Quartz.kCGEventOtherMouseDown) | Quartz.CGEventMaskBit(Quartz.kCGEventOtherMouseUp)
            | Quartz.CGEventMaskBit(Quartz.kCGEventOtherMouseDragged),
            cb, None,
        )
        if self.tap is None:
            self.tap = None
            raise RuntimeError(
                "Nie mogę podsłuchać klawiatury. Daj tej aplikacji (Terminal / Python) uprawnienia w:\n"
                "Ustawienia systemowe → Prywatność i bezpieczeństwo → Dostępność oraz Monitorowanie wprowadzania."
            )
        zrodlo = Quartz.CFMachPortCreateRunLoopSource(None, self.tap, 0)
        Quartz.CFRunLoopAddSource(Quartz.CFRunLoopGetMain(), zrodlo, Quartz.kCFRunLoopCommonModes)
        Quartz.CGEventTapEnable(self.tap, True)

    def sprawdz_uprawnienia(self):
        from ApplicationServices import AXIsProcessTrustedWithOptions
        if not AXIsProcessTrustedWithOptions({"AXTrustedCheckOptionPrompt": True}):
            print("Brak uprawnień Dostępności. Włącz je w Ustawieniach systemowych i uruchom ponownie.")

    def uruchom(self):
        import rumps

        for inny in ("Wispr Flow", "superwhisper", "MacWhisper"):
            if subprocess.run(["pgrep", "-x", inny], capture_output=True).returncode == 0:
                print(f"UWAGA: działa też {inny}. Jeśli słucha tego samego klawisza, zareagują oba.")

        from AppKit import NSApplication, NSApplicationActivationPolicyAccessory
        NSApplication.sharedApplication().setActivationPolicy_(NSApplicationActivationPolicyAccessory)

        self.sprawdz_uprawnienia()
        app = rumps.App("DYKTAFON", title="⏳", quit_button=None)
        self.app = app

        klawisz_nazwa = NAZWY_KLAWISZY.get(int(self.ust["klawisz"]), "klawisz " + str(self.ust["klawisz"]))
        mod_nazwa = NAZWY_MODYFIKATOROW.get(str(self.ust.get("bez_trzymania") or "control").lower(), "Control")

        def pozycja(tytul, fn):
            m = rumps.MenuItem(tytul, callback=fn)
            return m

        def wstrzymaj(item):
            self.wstrzymany = not self.wstrzymany
            if self.wstrzymany:
                self.wolne_rece = False
                self.anuluj()
                item.title = "Wznów"
                self.ikona("⏸")
            else:
                item.title = "Wstrzymaj"
                self.ikona("🎙")

        app.menu = [
            pozycja(f"{klawisz_nazwa}: trzymaj i mów", None),
            pozycja(f"{klawisz_nazwa} + {mod_nazwa}: bez trzymania, kończy {klawisz_nazwa} (Esc anuluje)", None),
            None,
            pozycja("Wstrzymaj", wstrzymaj),
            pozycja("Odblokuj klawisz (reset)", lambda _: self.zresetuj_tap()),
            pozycja("Wklej ostatni tekst", lambda _: self.ostatni_tekst and wklej(self.ostatni_tekst, self.ust)),
            pozycja("Otwórz słownik", lambda _: subprocess.run(["open", str(PLIK_SLOWNIKA)])),
            pozycja("Otwórz ustawienia", lambda _: subprocess.run(["open", str(PLIK_USTAWIEN)])),
            pozycja("Otwórz dziennik", lambda _: subprocess.run(["open", str(PLIK_LOGU)])),
            None,
            pozycja(f"Mikrofon: {self.nagrywanie.nazwa_urzadzenia()}", None),
            pozycja("Model: " + self.ust["model"].split("/")[-1], None),
            None,
            pozycja("Zakończ", lambda _: rumps.quit_application()),
        ]

        def rozgrzewka():
            try:
                self.transkrypcja.rozgrzej()
                self.ikona("🎙")
                print("gotowe: przytrzymaj klawisz i mów")
            except Exception:
                traceback.print_exc()
                self.ikona("⚠️")

        threading.Thread(target=rozgrzewka, daemon=True).start()

        self.tap = None

        def sprobuj_tap(_=None):
            if self.tap is not None:
                return
            try:
                self.zaloz_tap()
                print("podsłuch klawisza założony")
            except Exception as e:
                print(e)
                self.ikona("⚠️")

        def dozorca(_=None):
            """Co 5 s: pilnuje, żeby podsłuch klawisza żył i żeby nagrywanie nie wisiało w nieskończoność."""
            import Quartz
            if self.tap is None:
                sprobuj_tap()
                return
            try:
                if not Quartz.CGEventTapIsEnabled(self.tap):
                    Quartz.CGEventTapEnable(self.tap, True)
                    self.zaloguj("podsłuch klawisza był wyłączony przez system, włączam z powrotem")
                    if not self.nagrywa and not self.wstrzymany:
                        self.ikona("🎙")
            except Exception:
                traceback.print_exc()
            if self.audio_od and time.time() - self.audio_od > 8 and not self.zgloszony_zator:
                self.zgloszony_zator = True
                self.zaloguj("wątek mikrofonu nie odpowiada od 8 s — sterownik dźwięku się zaciął")
                self.ikona("⚠️")
            elif not self.audio_od:
                self.zgloszony_zator = False
            # z zakleszczenia w CoreAudio wątek mikrofonu już nie wróci; czekamy tylko,
            # aż bieżące dyktando zostanie wklejone, i startujemy program od nowa
            if self.zgloszony_zator and not self.blokada.locked():
                self.zaloguj("uruchamiam się ponownie")
                time.sleep(1.5)      # przywrócenie schowka po wklejeniu
                if os.environ.get("XPC_SERVICE_NAME", "").endswith("dyktafon"):
                    os._exit(1)      # launchd (KeepAlive) podnosi program sam
                os.execv(sys.executable, [sys.executable, "-u", os.path.abspath(__file__)])
            limit = self.ust.get("max_sekundy", 300)
            if self.nagrywa and limit and time.time() - self.start_czas > limit:
                self.zaloguj(f"nagrywanie przekroczyło {limit}s, kończę je sam")
                self.wolne_rece = False
                self.klawisz_puszczony()

        sprobuj_tap()
        self.timer_tap = rumps.Timer(dozorca, 5)
        self.timer_tap.start()
        app.run()


if __name__ == "__main__":
    if "--sprawdz" in sys.argv:
        # tryb słownikowy: dyktafon.py --sprawdz "tekst jaki usłyszał model"
        ust = wczytaj_ustawienia()
        sl = Slownik()
        tekst = " ".join(sys.argv[sys.argv.index("--sprawdz") + 1:])
        print("surowy :", tekst)
        print("czysty :", wyczysc(tekst, ust, sl))
    elif "--test" in sys.argv:
        # tryb testowy: dyktafon.py --test plik.wav  (transkrypcja + czyszczenie, bez wklejania)
        import soundfile  # noqa  (jeśli brak: uv pip install soundfile)
        ust = wczytaj_ustawienia()
        sl = Slownik()
        tr = Transkrypcja(ust, sl)
        tr.rozgrzej()
        dane, sr = soundfile.read(sys.argv[-1], dtype="float32")
        if dane.ndim > 1:
            dane = dane[:, 0]
        assert sr == 16000, "wav musi mieć 16 kHz"
        t0 = time.time()
        surowy = tr.przetworz(dane)
        print("surowy :", surowy)
        print("czysty :", wyczysc(surowy, ust, sl))
        print(f"czas   : {time.time()-t0:.2f}s dla {len(dane)/sr:.1f}s audio")
    else:
        Dyktafon().uruchom()
````

### start.sh

````bash
#!/bin/zsh
# Uruchamia DYKTAFON w tle (ikona w pasku menu). Konsola w dyktafon.out
cd -P "$(dirname "$0")"
if pgrep -f "dyktafon.py" >/dev/null; then
  echo "DYKTAFON już działa (ikona 🎙 w pasku menu)."; exit 0
fi
if [ -f "$HOME/Library/LaunchAgents/pl.dyktafon.plist" ]; then
  launchctl kickstart gui/$(id -u)/pl.dyktafon 2>/dev/null || launchctl bootstrap gui/$(id -u) "$HOME/Library/LaunchAgents/pl.dyktafon.plist"
else
  nohup .venv/bin/python -u dyktafon.py >> dyktafon.out 2>&1 &
fi
echo "DYKTAFON wystartował. Poczekaj na ikonę 🎙 w pasku menu, potem przytrzymaj klawisz dyktowania i mów."
````

### stop.sh

````bash
#!/bin/zsh
# Zatrzymuje DYKTAFON do najbliższego restartu/startu ręcznego (autostart zostaje).
launchctl kill TERM gui/$(id -u)/pl.dyktafon 2>/dev/null
pkill -f "dyktafon.py" 2>/dev/null
echo "DYKTAFON zatrzymany. Włącz ponownie: ./start.sh (albo wyloguj się i zaloguj, jeśli masz autostart)."
````

### autostart.sh

````bash
#!/bin/zsh
# autostart.sh wlacz  → DYKTAFON startuje po zalogowaniu (launchd) i sam wstaje po padzie
# autostart.sh wylacz → usuwa autostart
# cd -P: zawsze prawdziwa ścieżka (folder nie może być na Biurku — sandbox blokuje launchd)
cd -P "$(dirname "$0")"
case "$PWD" in "$HOME/Desktop/"*) echo "BŁĄD: folder leży na Biurku — przenieś go do $HOME (launchd nie wystartuje z Biurka)"; exit 1;; esac
PLIST="$HOME/Library/LaunchAgents/pl.dyktafon.plist"
case "$1" in
  wlacz)
    cat > "$PLIST" <<PL
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
  <key>Label</key><string>pl.dyktafon</string>
  <key>ProgramArguments</key><array>
    <string>$PWD/.venv/bin/python</string>
    <string>-u</string>
    <string>$PWD/dyktafon.py</string>
  </array>
  <key>WorkingDirectory</key><string>$PWD</string>
  <key>RunAtLoad</key><true/>
  <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
  <key>StandardOutPath</key><string>$PWD/dyktafon.out</string>
  <key>StandardErrorPath</key><string>$PWD/dyktafon.out</string>
</dict></plist>
PL
    pkill -f "dyktafon.py" 2>/dev/null
    launchctl bootout gui/$(id -u)/pl.dyktafon 2>/dev/null
    launchctl bootstrap gui/$(id -u) "$PLIST" && echo "Autostart włączony i DYKTAFON uruchomiony."
    ;;
  wylacz)
    launchctl bootout gui/$(id -u)/pl.dyktafon 2>/dev/null
    rm -f "$PLIST" && echo "Autostart wyłączony."
    ;;
  *) echo "użycie: ./autostart.sh wlacz | wylacz" ;;
esac
````
