arrow_back Wszystkie wpisy
6 min czytania

Zbudowaliśmy aplikację do dyktowania, która wpisuje tekst w dowolne okno

Jak zbudowaliśmy desktopową aplikację speech-to-text w Pythonie, która działa w czasie rzeczywistym, może pracować offline i wstrzykuje klawisze bezpośrednio w okno, w którym pracujesz.

R
Revolt Bots Team
Tacet main window showing live transcription preview while dictating

Większość narzędzi do dyktowania wrzuca tekst do własnego okna. Transkrybujesz, potem kopiujesz i wklejasz gdzieś indziej. To łamie twój flow. Tacet słucha mikrofonu i pisze bezpośrednio w aplikację, która jest aktywna. VS Code, Slack, przeglądarka, terminal. Bez schowka, bez pluginów, bez przełączania okien.

Istniejące rozwiązania albo zamykają cię w jednym ekosystemie (Apple Dictation, Google Voice Typing), albo wymagają integracji z konkretnymi aplikacjami. Chcieliśmy czegoś, co działa wszędzie, może działać offline, jeśli zależy ci na prywatności, i nie potrzebuje połączenia z internetem ani klucza API. Nic na rynku nie robiło tych trzech rzeczy naraz.

Kto tego faktycznie używa

Ludzie, którzy dużo piszą i wolą mówić. Pisarze tworzący długie teksty. Developerzy, którzy chcą dyktować komentarze albo dokumentację bez wychodzenia z edytora. Każdy z RSI albo potrzebami dostępności, kto nie może klepać na klawiaturze cały dzień. Ludzie, którym zależy na prywatności i nie chcą, żeby ich dane głosowe trafiały na serwer.

Stos technologiczny

Python 3.9+ z CustomTkinter do desktopowego GUI. faster-whisper do lokalnej transkrypcji (działa na CTranslate2). sounddevice do przechwytywania mikrofonu w czasie rzeczywistym. pynput do globalnych skrótów klawiszowych i wstrzykiwania klawiszy. numpy do przetwarzania sygnału audio. pystray do ikony w zasobniku systemowym. OpenAI API i Deepgram API jako opcjonalni dostawcy chmurowi.

Nic wymyślnego. Wybraliśmy Pythona, bo ekosystem AI/ML tam jest. faster-whisper to implementacja Whispera w C++ z bindingami dla Pythona, więc szybkość transkrypcji to nie jest problem Pythona. GUI to jedyna część, która jest “wolnym Pythonem”, i nie musi być szybka.

Najtrudniejsza część: podgląd na żywo

Kiedy mówisz, Tacet pokazuje podgląd w czasie rzeczywistym tego, co myśli, że mówisz. Whisper ponownie transkrybuje to samo okno audio co 900ms. Naiwne podejście: wycofaj stary podgląd, wpisz nowy. To powoduje widoczne migotanie, bo usuwasz i wpisujesz ponownie 40+ znaków kilka razy na sekundę.

Rozwiązaliśmy to przez diffing stabilnych prefiksów. Przy każdej aktualizacji znajdujemy najdłuższy wspólny prefiks między starym a nowym tekstem. Potem cofamy tylko sufiks, który się zmienił, i wpisujemy nowy sufiks. Jeśli Whisper przechodzi z “I want to” na “I want to talk about”, nie ruszamy pierwszych 10 znaków. Po prostu dopisujemy ” talk about”.

# Instead of backspacing the entire preview and retyping,
# we find what's already correct and only fix the tail.

common_len = 0
limit = min(len(old_text), len(new_text))
while common_len < limit and old_text[common_len] == new_text[common_len]:
    common_len += 1

# Only delete what changed
old_suffix_len = len(old_text) - common_len
backspace(self.kb, old_suffix_len)

# Only type what's new
new_suffix = new_text[common_len:]
if new_suffix:
    self._safe_type(new_suffix)

To zmniejszyło widoczny churn klawiszy o około 80% i sprawiło, że podgląd działa płynnie, a nie szarpie.

Drugi ból głowy to detekcja aktywności głosowej. Stały próg energii nie działa, bo każdy mikrofon ma inny szum tła. Skończyliśmy z śledzeniem poziomu szumu za pomocą wykładniczej średniej kroczącej, a potem ustawianiem progów mowy/ciszy jako mnożników ponad ten poziom. Cichy pokój i hałaśliwa kawiarnia działają bez dotykania ustawień przez użytkownika.

# Fixed thresholds break in noisy rooms. Instead, we track
# the ambient noise level and set thresholds relative to it.

if noise_floor < 1e-6:
    noise_floor = rms
else:
    noise_floor = noise_alpha * rms + (1 - noise_alpha) * noise_floor

speech_threshold = max(self.energy_threshold, noise_floor * noise_speech_mult)
silence_threshold = max(self.energy_threshold, noise_floor * noise_silence_mult)

if rms >= speech_threshold:
    self._speaking = True

Jak przepływają dane

Trzy wątki. Główny wątek obsługuje GUI. Wątek w tle przechwytuje audio z mikrofonu i uruchamia detekcję aktywności głosowej, dzieląc audio według przerw ciszy. Kiedy chunk jest gotowy, trafia do kolejki. Dwa wątki robocze do transkrypcji pobierają z tej kolejki i przepuszczają audio przez wybrany silnik (lokalny Whisper, OpenAI API albo Deepgram API).

Transkrybowany tekst przechodzi przez pięciostopniowy pipeline procesorów: komendy głosowe (“period” zamienia się w ”.”), znaczniki czasu, rozwijanie szablonów, automatyczna kapitalizacja i zamienniki słów. Finalny tekst jest wstrzykiwany jako klawisze przez pynput do aktywnego okna.

Silnik i GUI są rozdzielone przez callbacki. Silnik nie importuje niczego z GUI. Po prostu wywołuje on_status_change, on_preview_update, on_final_text. GUI podpina je do swojej kolejki aktualizacji. Mógłbyś uruchomić silnik headless bez zmiany ani jednej linii.

Co faktycznie potrafi

Działa z każdą aplikacją

Tacet pisze bezpośrednio w aktywne okno. Używa pynput do wstrzykiwania klawiszy na poziomie systemu operacyjnego, więc działa z każdą aplikacją, która przyjmuje klawiaturę. Bez rozszerzeń do przeglądarki, bez integracji API, bez pluginów. Otwórz maila, zacznij dyktować, słowa się pojawiają. Przełącz się na Slacka, mów dalej. Obsługuje znaki specjalne jak nowe linie i tabulatory, naciskając odpowiednie klawisze (Enter, Tab) zamiast próbować wpisywać sekwencje escape.

Adaptacyjna detekcja aktywności głosowej

Aplikacja automatycznie wykrywa, kiedy zaczynasz i przestajesz mówić. Śledzi poziom szumu otoczenia za pomocą wykładniczej średniej kroczącej, a potem ustawia progi detekcji mowy jako mnożniki ponad ten poziom szumu. Działa w cichym pokoju i hałaśliwym otoczeniu bez ręcznej kalibracji. Buforuje też 300ms audio przed wykryciem mowy (preroll), więc nigdy nie tracisz pierwszej sylaby zdania.

Najpierw offline, chmura jako zapasowa opcja

Domyślny silnik to faster-whisper działający lokalnie na twoim CPU. Bez kluczy API, bez internetu, bez danych opuszczających twój komputer. Przy pierwszym uruchomieniu Tacet testuje szybkość wybranego modelu. Jeśli transkrypcja 1 sekundy audio zajmuje więcej niż 5 sekund, automatycznie przełącza się na model “tiny” i zapisuje tę preferencję. Jeśli chcesz wyższą dokładność i nie przeszkadza ci chmura, możesz przełączyć się na OpenAI albo Deepgram jedną zmianą w konfiguracji.

Konfigurowalne komendy głosowe i przetwarzanie tekstu

Powiedz “period”, a wpisuje ”.”. Powiedz “new line”, a naciska Enter. Powiedz “delete that”, a cofa ostatni chunk. Wszystko jest konfigurowalne. Możesz dodać własne komendy, wyłączyć wbudowane albo ustawić zamienniki słów (“btw” zamienia się w “by the way”). Tekst przechodzi przez pięciostopniowy pipeline: komendy głosowe, znaczniki czasu, szablony, automatyczna kapitalizacja i zamienniki. Każdy etap jest niezależny i można go włączyć lub wyłączyć.

Kompromisy, na które poszliśmy

Wybraliśmy CustomTkinter zamiast Electrona. Electron dałby nam ładniejszy UI mniejszym nakładem pracy, ale dostarcza całą przeglądarkę Chromium. Dla aplikacji, która siedzi cicho w zasobniku systemowym i zużywa minimum zasobów, to nie pasowało. CustomTkinter jest lżejszy, startuje szybciej i utrzymuje mały rozmiar instalacji. Kompromis to mniej komponentów UI i mniej wizualnej finezji.

Poszliśmy z przetwarzaniem tekstu opartym na regułach zamiast używać LLM do post-processingu. LLM mógłby poprawiać gramatykę, dodawać mądrzejszą interpunkcję, może nawet restrukturyzować zdania. Ale dodałoby to opóźnienie, wymagałoby albo wywołania do chmury, albo drugiego lokalnego modelu, i sprawiłoby, że wynik byłby nieprzewidywalny. Kiedy ktoś dyktuje do okna czatu, chce, żeby jego słowa pojawiły się teraz, a nie zostały poprawione dwie sekundy później. Reguły są szybkie, przewidywalne i darmowe.

Odpuściliśmy sobie transkrypcję strumieniową w czasie rzeczywistym dla lokalnego silnika. Whisper działa na kompletnych chunkach audio, nie na strumieniach. Mogliśmy zbudować pipeline strumieniowy z nakładającymi się oknami, ale złożoność nie była tego warta. Zamiast tego dostroiliśmy chunking (detekcja ciszy, buforowanie preroll, minimalna długość chunka), żeby działał wystarczająco responsywnie. Większość wypowiedzi przetwarza się w mniej niż sekundę na przyzwoitym CPU.

Wybraliśmy też wstrzykiwanie klawiszy zamiast schowka. Wstrzykiwanie przez schowek byłoby prostsze i bardziej niezawodne między platformami, ale nadpisuje to, co użytkownik ostatnio skopiował. To dyskwalifikacja dla większości przepływów pracy.

Co zrobilibyśmy inaczej

System konfiguracji zaczął się jako płaski plik JSON i rósł organicznie. Skończyliśmy z kodem migracji starych formatów dla zamienników słów, bo GUI potrzebowało innej struktury niż oryginalna konfiguracja. Gdybyśmy zaczynali od nowa, zdefiniowalibyśmy porządny schemat z wersjonowaniem od pierwszego dnia. Zamiast dobudowywać logikę migracji po fakcie.

Model wątków działa, ale jest ręcznie napisany z lockami, eventami i ID wypowiedzi do wykrywania nieaktualnych danych. Biblioteka jak concurrent.futures albo podejście async z asyncio byłyby czystsze. Rozważylibyśmy też separację silnika do osobnego procesu, żeby całkowicie uniknąć GIL, bo przechwytywanie audio i transkrypcja są CPU-bound.

Zainwestowalibyśmy też w automatyczne testowanie wcześniej. Architektura oparta na callbackach sprawia, że silnik jest testowalny w teorii, ale nie mamy zestawu testów, który ćwiczy cały pipeline. To dług techniczny, który niesiemy.

Tagi

tacet desktop ai open-source python

Wiecej z bloga