
Cisza była nośna
Dwa ostatnie wydania GeekBye v1 mówią o tej samej niewygodnej prawdzie: transkrypcja w czasie rzeczywistym przez prawdziwą sieć nie jest bezstratna, a uczciwym ruchem jest przestać udawać, że jest. v1.8.20 zachowywało kopię każdego fragmentu dźwięku na dysku, zanim porzuciło go podczas ponownego połączenia, i zaczęło głośno oznaczać luki w transkrypcji. v1.9.0 przestało wysyłać ciszę, by oszczędzić pasmo — i odkryło, że cisza była dokładnie tym sygnałem, po którym transkryber poznawał, że zdanie się skończyło. Dwa wydania o koszcie wyrzucania rzeczy.
Dwa ostatnie wydania GeekBye v1 — v1.8.20 i v1.9.0, te tuż przed kamieniem milowym wersji drugiej — łączy motyw, który staje się oczywisty dopiero, gdy przeczytasz je razem. Oba dotyczą rzeczy, które aplikacja po cichu wyrzucała, i oba dochodzą do tego samego wniosku: rzecz, którą porzucasz dla wydajności, wykonywała więcej pracy, niż ci się zdawało. Jedno wydanie wyrzucało dźwięk podczas ponownych połączeń sieciowych. Drugie wyrzucało ciszę, by oszczędzić pasmo. W obu przypadkach porzucona rzecz okazała się nośna.
Transkrypcja w czasie rzeczywistym nie jest bezstratna, a v1.8.20 to przyznaje
Oto fakt o strumieniowej zamianie mowy na tekst przez WebSocket, od którego łatwo odwrócić wzrok: gdy połączenie się zrywa i nawiązuje na nowo — albo gdy aplikacja przełącza się awaryjnie z jednego dostawcy transkrypcji na drugiego, z Deepgram na ElevenLabs — klient celowo wyrzuca fragmenty dźwięku przechwycone w czasie tej przerwy. Musi. Świeżo podłączony dostawca, który nagle dostaje zaległy stos nieświeżego dźwięku, zakrztusi się albo, co gorsza, zaczepi swój detektor aktywności głosowej o niewłaściwą rzecz. Więc klient wyrzuca te fragmenty.
Problem nie leży w wyrzucaniu. Problem w tym, co wyrzucanie kiedyś znaczyło: te słowa po prostu nigdy nie pojawiały się w transkrypcji, a nic ci nie mówiło, że ich brakuje. Nagranie wyglądało na kompletne. Nie było.
v1.8.20 atakuje to na dwóch frontach i żaden z nich nie brzmi „spraw, by ponowne połączenia nigdy nie gubiły dźwięku" — bo się nie da, nie w czasie rzeczywistym. Zamiast tego czyni stratę odzyskiwalną i uczciwą.
Odzyskiwalna połowa to trwały odczep PCM. W handlerze przechwytu w procesie głównym każdy przechwycony fragment jest teraz zapisywany do pliku na dysku zanim dotrze do bramek porzucania przy ponownym połączeniu i w okresie karencji:
every captured chunk is written to
<userData>/sessions/session-<ts>.wavbefore realtime drop gates so reconnect-lost audio is preserved for a future batch-STT reconciliation pass
Mechanizm jest przyjemnie prosty technicznie: teeChunkToDisk otwiera WriteStream, rezerwuje z góry 44-bajtowy nagłówek WAV, dopisuje surowe 16-bitowe próbki mono w miarę ich napływania i łata nagłówek finalnymi rozmiarami przy zatrzymaniu (fs.openSync(..., 'r+'), zapis 44 bajtów pod offsetem 0). Wynikiem jest odtwarzalny plik WAV z kompletnym dźwiękiem — wliczając części, których transkrypcja w czasie rzeczywistym nigdy nie zobaczyła. Gdy nagrywanie się kończy, emituje zdarzenie session-audio-saved ze ścieżką pliku i liczbą bajtów.
Dwie uwagi na temat uczciwości mają tu znaczenie, bo łatwo to przechwalić. Po pierwsze: odczep nie wypełnia luki w tych wydaniach. Zachowuje surowy dźwięk na późniejszy przebieg uzgadniający — wsadową retranskrypcję, która uzgadnia plik z transkrypcją na żywo — a to zaczep, który te wydania zostawiają, nie funkcja, którą dostarczają. Kolejny commit wręcz opatruje session-audio-saved komentarzem TODO wskazującym na zgłoszenia dotyczące uzgadniania, by nikt nie wziął tego za martwy kod. Po drugie: to kosztuje dysk. Zespół oszacował z grubsza 86 MB na 30-minutowe nagranie, więc to samo wydanie dodaje StartupCleanupService, który usuwa zapisane sesje starsze niż 7 dni. Siatka bezpieczeństwa, która rośnie bez ograniczeń, to wyciek; to sprzątanie sprawia, że odczep nadaje się do wydania.
Uczciwa połowa jest jeszcze prostsza. Przy pierwszej próbie ponownego połączenia klient zapisuje prawdziwy, widoczny wpis w transkrypcji:
[reconnecting — audio may be missing]
Ta jedna linijka to cała filozofia wydania w miniaturze. Aplikacja nie może zagwarantować, że usłyszała wszystko, więc przestaje sugerować, że tak było. Lukę, którą widzisz, da się obejść; luka, której nie widzisz, to czekające na swój moment zgłoszenie błędu.
v1.9.0: cisza, którą zoptymalizowałeś, była sygnałem
v1.9.0 to ostatnie wydanie v1 i to właśnie w nim backend przekazał klientowi zestaw obowiązków związanych z transkrypcją na żywo — porzucanie ciszy, przypisywanie każdej linii transkrypcji do mikrofonu lub dźwięku systemu na podstawie lokalnej osi czasu aktywności głosowej, władanie pętlą ponownego łączenia, egzekwowanie limitów nagrywania. Większość tego to fundament pod architekturę wersji drugiej i była opowiedziana gdzie indziej. Ale jeden wątek w tym wydaniu to najlepsza mała historia z obu, bo to poprawka, która musiała cofnąć szkody wyrządzone przez optymalizację całkowicie poprawną w izolacji.
Zaczyna się od zmiany oczywiście dobrej: przestań wysyłać ciszę. Klient zaczął odfiltrowywać fragmenty, których dominującym źródłem była cisza, zanim wyśle je przez gniazdo — mniej pasma, mniej bezużytecznego dźwięku do przetworzenia po stronie backendu, i honorował jawny kontrakt klienta z backendem mówiący „nie wysyłaj ciszy". Każdy powód, by tak zrobić, był słuszny.
I całkowicie zepsuł finalizację wypowiedzi. Commit, który to naprawia, wykłada łańcuch przyczynowy z niezwykłą szczerością:
After the client began filtering silence chunks … ElevenLabs Scribe's VAD-based commit strategy no longer fires — EL needs ~1s of silence in the audio stream to commit a partial, and that silence never reaches it. Result: partials accumulate forever, UI shows trailing "...", utterances never finalize.
To platoniczna postać nieszczelnej abstrakcji, która się odgryza. Detektor aktywności głosowej w transkryberze nie ma osobnego wejścia „mówiący skończył" — wnioskuje koniec zdania z usłyszenia około sekundy ciszy w dźwięku. Cisza nie była szumem na łączu; była komunikatem. Odfiltrowując ją, by oszczędzić pasmo, klient przypadkiem przestał wysyłać ten jeden sygnał, który mówił transkryberowi, że zdanie się skończyło. Więc każda częściowa transkrypcja po prostu rosła dalej, ogonem uciekając w ..., które nigdy się nie rozstrzygało.
Można to naprawić, wstawiając ciszę z powrotem — ale wtedy cofasz optymalizację. Właściwa poprawka jest lepsza: wyślij znaczenie ciszy, nie wysyłając ciszy. Klient śledzi teraz lokalnie, jak długo dźwięk jest cichy, a gdy przekroczy SILENCE_COMMIT_THRESHOLD_MS = 1000 — celowo dopasowane do własnego progu ciszy VAD backendu — wysyła jawny sygnał commit poza pasmem: sendMixedChunk zauważa utrzymującą się ciszę, wysyła realtime-audio:commit przez IPC, proces główny przekazuje { type: 'commit' } przez gniazdo, a backend każe ElevenLabs sfinalizować część. Dwa małe elementy stanu, silenceStartedAt i silenceCommitFired, nie pozwalają mu odpalać wielokrotnie. Zysk na paśmie przetrwał; finalizacja znów działa; kończące ... się rozstrzyga.
Do tego wydania jest też mały, uczciwy przypis: nieograniczona logika ponownego łączenia, którą wprowadziło v1.9.0, powstała najpierw w rendererze jako WsReconnectController, a potem została usunięta w obrębie tego samego wydania, gdy prawdziwa implementacja wylądowała w handlerze procesu głównego, gdzie było jej miejsce. Sama historia ponownego łączenia — nieograniczony backoff, naprzemienni dostawcy co pięć niepowodzeń — jest opowiedziana we własnym rozdziale; to, co dokłada v1.9.0, to fakt, że właśnie tu narodziła się ta architektura i że klient po raz pierwszy wziął ją na własność.
Trzy rzeczy, których nauczyły te wydania
- Gdy nie możesz zapobiec stracie, uczyń ją odzyskiwalną i uczyń ją widoczną. v1.8.20 nie powstrzymuje ponownych połączeń przed gubieniem dźwięku — to fizycznie niemożliwe w czasie rzeczywistym. Zachowuje kopię surowego dźwięku na później i oznacza lukę w transkrypcji. Żadne z tych dwóch nie jest efektowne; razem zamieniają cichą utratę danych w znany, ograniczony, odzyskiwalny stan.
- Siatka bezpieczeństwa potrzebuje własnych granic. Trwały odczep po cichu zapełniłby dysk, gdyby zostawić go samemu sobie — poprawka, która staje się błędem w zwolnionym tempie. Siedmiodniowe sprzątanie dostarczone w tym samym wydaniu to nie dodatek po namyśle; to właśnie ono sprawia, że odczep w ogóle nadaje się do wydania. Jeśli dodajesz mechanizm, który się kumuluje, dodaj tym samym tchem mechanizm, który go przycina.
- Rzecz, którą porzucasz dla wydajności, może być sygnałem. Najostrzejsza lekcja z obu wydań: odfiltrowanie ciszy było poprawne na każdej osi, którą normalnie byś sprawdził — pasmo, obciążenie, zgodność z kontraktem — a i tak zepsuło produkt, bo system w dół strumienia odczytywał znaczenie z ciszy. Gdy usuwasz dane dla wydajności, zapytaj, co wnioskowało coś z tych danych. Czasem musisz wysłać znaczenie jawnie właśnie dlatego, że przestałeś wysyłać rzecz, z której było ono dawniej wnioskowane.
I to koniec historii wersji pierwszej — ostatnie utwardzanie przed przepisaniem. Po poprzedni rozdział — trzy czasowniki, które utrzymują Web Audio przy życiu (v1.8.16 + v2.0.2); po to, dokąd to wszystko zmierzało — ile naprawdę kosztuje wersja druga: 206 commitów (v2.0.0); a po cały łuk — anatomię dostarczania oprogramowania do perfekcji.