arrow_back Tutti gli articoli
7 min di lettura

Come abbiamo costruito un'app di dettatura vocale che scrive in qualsiasi finestra

Come abbiamo costruito un'app desktop di speech-to-text in tempo reale, funzionante offline, in Python, che inietta i tasti direttamente nella finestra in cui stai lavorando.

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

La maggior parte degli strumenti di dettatura inserisce il testo nella propria finestra. Trascrivi, poi copi e incolli da qualche altra parte. Questo interrompe il tuo flusso di lavoro. Tacet ascolta il microfono e scrive direttamente nell’app attiva. VS Code, Slack, il browser, un terminale. Niente clipboard, niente plugin, niente cambio di finestra.

Le alternative esistenti ti legano a un ecosistema (Apple Dictation, Google Voice Typing) oppure richiedono integrazioni specifiche per ogni app. Volevamo qualcosa che funzionasse ovunque, che girasse offline per la privacy, e che non avesse bisogno di connessione internet o API key. Niente sul mercato faceva tutte e tre le cose.

Chi lo usa davvero

Persone che scrivono molto e preferiscono parlare. Scrittori che lavorano su testi lunghi. Sviluppatori che vogliono dettare commenti o documentazione senza uscire dall’editor. Chiunque abbia problemi di RSI o esigenze di accessibilità e non possa usare la tastiera tutto il giorno. Persone attente alla privacy che non vogliono che i loro dati vocali finiscano su un server.

Lo stack tecnologico

Python 3.9+ con CustomTkinter per la GUI desktop. faster-whisper per la trascrizione locale (gira su CTranslate2). sounddevice per la cattura audio dal microfono in tempo reale. pynput per hotkey globali e iniezione di tasti. numpy per l’elaborazione del segnale audio. pystray per l’icona nella system tray. OpenAI API e Deepgram API come provider cloud opzionali.

Niente di complicato. Abbiamo scelto Python perché l’ecosistema AI/ML è lì. faster-whisper è un’implementazione C++ di Whisper con binding Python, quindi la velocità di trascrizione non è un problema di Python. La GUI è l’unica parte che è “Python lento”, e non ha bisogno di essere veloce.

La parte più difficile: l’anteprima live

Quando parli, Tacet mostra un’anteprima in tempo reale di ciò che pensa tu stia dicendo. Whisper ri-trascrive la stessa finestra audio ogni 900ms. L’approccio banale è: cancella la vecchia anteprima, scrivi quella nuova. Questo causa sfarfallio visibile perché stai cancellando e riscrivendo più di 40 caratteri più volte al secondo.

Abbiamo risolto con il diffing a prefisso stabile. Ad ogni aggiornamento, troviamo il prefisso comune più lungo tra il vecchio testo e il nuovo. Poi cancelliamo solo il suffisso che è cambiato e scriviamo il nuovo suffisso. Se Whisper passa da “I want to” a “I want to talk about”, non tocchiamo i primi 10 caratteri. Aggiungiamo solo ” 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)

Questo ha ridotto lo sfarfallio dei tasti di circa l’80% e ha reso l’anteprima fluida invece che tremolante.

L’altro problema era il rilevamento dell’attività vocale. Una soglia di energia fissa non funziona perché ogni microfono ha un rumore di fondo diverso. Alla fine abbiamo tracciato un livello di rumore di fondo usando una media mobile esponenziale, poi impostato le soglie di parlato/silenzio come moltiplicatori sopra quel livello. Una stanza silenziosa e un bar rumoroso funzionano entrambi senza che l’utente tocchi alcuna impostazione.

# 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

Come fluiscono i dati

Tre thread. Il thread principale gestisce la GUI. Un thread in background cattura l’audio dal microfono ed esegue il rilevamento dell’attività vocale, dividendo l’audio in blocchi separati dalle pause di silenzio. Quando un blocco è pronto, finisce in una coda. Due thread worker di trascrizione pescano dalla coda e processano l’audio attraverso il motore selezionato (Whisper locale, OpenAI API o Deepgram API).

Il testo trascritto passa attraverso una pipeline di elaborazione a cinque stadi: comandi vocali (“period” diventa ”.”), timestamp, espansione dei template, auto-capitalizzazione e sostituzione di parole. Il testo finale viene iniettato come tasti tramite pynput nella finestra attiva.

Il motore e la GUI sono disaccoppiati tramite callback. Il motore non importa nulla dalla GUI. Chiama semplicemente on_status_change, on_preview_update, on_final_text. La GUI collega queste callback alla sua coda di aggiornamento. Potresti far girare il motore senza interfaccia grafica senza cambiare una riga.

Cosa può fare davvero

Funziona con qualsiasi applicazione

Tacet scrive direttamente nella finestra attiva. Usa pynput per iniettare tasti a livello di sistema operativo, quindi funziona con qualsiasi app che accetti input da tastiera. Niente estensioni del browser, niente integrazioni API, niente plugin. Apri la tua email, inizia a dettare, e le parole appaiono. Passa a Slack, continua a parlare. Gestisce i caratteri speciali come le nuove righe e le tabulazioni premendo i tasti reali (Enter, Tab) invece di provare a scrivere sequenze di escape.

Rilevamento adattivo dell’attività vocale

L’app rileva automaticamente quando inizi e smetti di parlare. Traccia i livelli di rumore ambientale usando una media mobile esponenziale, poi imposta le soglie di rilevamento come moltiplicatori sopra quel livello di rumore. Funziona in una stanza silenziosa o in un ambiente rumoroso senza calibrazione manuale. Inoltre memorizza 300ms di audio prima che il parlato venga rilevato (preroll), così non perdi mai la prima sillaba di una frase.

Offline-first con fallback cloud

Il motore predefinito è faster-whisper che gira localmente sulla tua CPU. Niente API key, niente internet, niente dati che lasciano la tua macchina. Al primo avvio, Tacet fa uno speed-test del modello selezionato. Se impiega più di 5 secondi per trascrivere 1 secondo di audio, passa automaticamente al modello “tiny” e salva quella preferenza. Se vuoi maggiore precisione e non ti preoccupa il cloud, puoi passare a OpenAI o Deepgram con una singola modifica alla configurazione.

Comandi vocali e elaborazione del testo personalizzabili

Dì “period” e scrive ”.”. Dì “new line” e preme Enter. Dì “delete that” e cancella l’ultimo blocco. Tutto questo è configurabile. Puoi aggiungere i tuoi comandi, disabilitare quelli predefiniti, o impostare sostituzioni di parole (“btw” diventa “by the way”). Il testo passa attraverso una pipeline a cinque stadi: comandi vocali, timestamp, template, auto-capitalizzazione e sostituzioni. Ogni stadio è indipendente e può essere attivato o disattivato.

Compromessi che abbiamo fatto

Abbiamo scelto CustomTkinter al posto di Electron. Electron ci avrebbe dato un’interfaccia più bella con meno sforzo, ma include un intero browser Chromium. Per un’app che sta tranquilla nella system tray e usa risorse minime, ci sembrava sbagliato. CustomTkinter è più leggero, si avvia più velocemente e mantiene le dimensioni dell’installazione ridotte. Il compromesso è avere meno componenti UI e meno cura visiva.

Abbiamo scelto l’elaborazione del testo basata su regole invece di usare un LLM per il post-processing. Un LLM potrebbe correggere la grammatica, aggiungere punteggiatura più intelligente, forse anche ristrutturare le frasi. Ma aggiungerebbe latenza, richiederebbe una chiamata cloud o un secondo modello locale, e renderebbe l’output imprevedibile. Quando qualcuno detta in una finestra di chat, vuole che le parole vengano scritte subito, non corrette due secondi dopo. Le regole sono veloci, prevedibili e gratuite.

Abbiamo saltato la trascrizione in streaming in tempo reale per il motore locale. Whisper lavora su blocchi audio completi, non su stream. Avremmo potuto costruire una pipeline di streaming con finestre sovrapposte, ma la complessità non ne valeva la pena. Invece, abbiamo ottimizzato il chunking (rilevamento del silenzio, preroll buffering, durata minima del blocco) per sembrare abbastanza reattivo. La maggior parte delle frasi viene elaborata in meno di un secondo su una CPU decente.

Abbiamo anche scelto di iniettare i tasti invece di usare la clipboard. L’iniezione via clipboard sarebbe più semplice e affidabile tra le piattaforme, ma sovrascrive qualsiasi cosa l’utente abbia copiato per ultimo. Questo è inaccettabile per la maggior parte dei flussi di lavoro.

Cosa faremmo diversamente

Il sistema di configurazione è partito come un file JSON piatto ed è cresciuto organicamente. Ci siamo ritrovati con codice di migrazione per le sostituzioni di parole perché la GUI aveva bisogno di una struttura diversa dalla configurazione originale. Se ricominciassimo da zero, definiremmo uno schema con versionamento dal primo giorno, invece di aggiungere logica di migrazione dopo.

Il modello di threading funziona ma è costruito a mano con lock, eventi e ID per il rilevamento di dati obsoleti. Una libreria come concurrent.futures o un approccio asincrono con asyncio sarebbe stato più pulito. Prenderemmo anche in considerazione di separare il motore in un processo a sé per evitare del tutto il GIL, dato che sia la cattura audio che la trascrizione sono CPU-bound.

Avremmo anche investito nei test automatizzati prima. L’architettura basata su callback rende il motore testabile in teoria, ma non abbiamo una suite di test che eserciti l’intera pipeline. Questo è debito tecnico che ci portiamo dietro.

Tag

tacet desktop ai open-source python

Altro dal blog