Simiyon Vinscent Samuel · Engineering

All posts / Electron

  • Electron
  • Kotlin
  • Android
  • IME
  • Reverse Engineering
  • System Design

Voxa: one dictation pipeline, two clients, no backend

The full design of Voxa, the dictation tool I use daily: an Electron desktop app and a Kotlin Android keyboard that record speech, sign in to chatgpt.com like a browser, call its web transcription endpoint with my own session, and put the text into whatever field has focus. Covers the sign-in flow, the exact request, a focus-safe recording strip, two insertion mechanisms, durable audio and history, and the trade-offs stated plainly.

Voxa is the dictation tool I use every day: press a key or tap a mic, speak, and the text lands in whatever field you were typing into. It runs as an Electron app on the desktop and as a Kotlin keyboard on Android. Neither client talks to a server I own. Both sign in to chatgpt.com the way a browser does and call the web transcription endpoint behind ChatGPT's own dictation button, using my own session. This post walks through the whole design, one requirement at a time, including the parts that were harder than the transcription call: not stealing focus, not losing audio, and what happens when the endpoint misbehaves.

One request path, five numbered hops. Everything inside the orange zone is the Electron app; everything inside the blue zone is chatgpt.com, which Voxa does not control. The Android keyboard follows the same shape with its own recorder and a different last hop.
The Voxa recording strip pinned to the bottom-left corner of a desktop while recording, timer at 00:19
The strip in context. A 240 by 84 pixel transparent window pinned to a screen corner of the primary display, here bottom-left at 00:19. Where it sits is a preference.
The whole product is this loop. The rest of the post explains why each hop is built the way it is. Press play or pick a step.

Understand the problem#

Dictation sounds like one feature, but it is a chain of small ones that each fail differently. I wrote the requirements down before the code and they have barely changed.

Functional requirements#

  1. Start and stop recording from anywhere on the system with a hotkey, and from a mic button on Android.
  2. Sign in once and stay signed in, without Voxa ever seeing a password.
  3. Turn the recorded audio into text.
  4. Put the text into whatever field had focus when I started, on both platforms.
  5. Keep a history of dictations with replayable audio, and let me retry a failed one.

Non-functional requirements#

  1. Focus safety. Starting a dictation must never move focus away from the target app.
  2. No lost takes. A crash, a closed lid or a failed request must not lose recorded speech.
  3. Zero infrastructure. No server to host, no key to rotate, no bill.
  4. Readable failures. Each failure maps to a short message that says what to do.
  5. Privacy on demand. One setting removes successful dictations and their audio when the app quits.
  6. Core path never blocked by bookkeeping. If history cannot be written, dictation still works.
On the board so far
Requirements 1-5  (functional)
Constraints  NFR 1-6  (focus, no lost takes, no infra, ...)
Nothing built yet. One external dependency: chatgpt.com

The set-up#

Core entities#

  • Dictation. One recording and its outcome. Has an id, a start time, a source app label, a status and, once finished, a transcript and a word count.
  • Audio file. <id>.webm under the app's user data directory, written while recording.
  • Session. The saved cookie list, a device id and a per-launch session id. The cookies are the long-lived credential.
  • Preference. Hotkeys, strip position, maximum recording length, retention limits and privacy mode.
Four things revolve around a dictation. The audio file and the session are what the pipeline moves; preferences and the source app label only change how it behaves and how it is shown.

The interface#

There is no public API to design. The contract is the two chatgpt.com calls, plus the IPC channels between the strip, the Hub window and the main process.

Text
GET  https://chatgpt.com/api/auth/session        Cookie: <session-cookies>   -> { accessToken, user, account }
POST https://chatgpt.com/backend-api/transcribe  Authorization: Bearer <access-token>
                                                 multipart: file=recording.webm, model=whisper-1  -> { text }

IPC (strip -> main)     recording-start-meta {id, startedAt}
                        recording-chunk      {id, chunk}      every 250 ms
                        finalize-recording   {id, durationMs, mimeType}
IPC (hub -> main)       history-list, history-retry, history-copy, history-reinsert,
                        login-chatgpt, sign-out, save-preferences

The data model#

Everything is plain files in the user data directory. History is one JSON file written atomically, audio sits beside it as .webm files, and the account cache holds identity for the profile screen but never tokens.
Small constants carry the design. These are the values in overlay.html, chatgptAuth.js, textInserter.js and configStore.js.

Build it one requirement at a time#

Requirement 1: record from anywhere#

The hotkey is registered with Electron's globalShortcut. The start key toggles: pressing it again while recording stops. Escape is registered as a separate stop key whenever it differs from the start key. The recording itself happens in a small hidden-until-needed window, the strip, because only a renderer can call getUserMedia.

Capture is three parts. Glowing nodes are new on this board.
  1. The hotkey calls startRecording(), which checks microphone access (macOS only), creates a pending history row and shows the strip.
  2. The strip asks for a mono microphone stream with echo cancellation and noise suppression off and auto gain on, then starts MediaRecorder on audio/webm;codecs=opus.
  3. Every 250 ms a chunk goes over IPC to main, which appends it to <id>.webm.

Requirement 2: sign in without a password#

Voxa shows the real login page and keeps only what it produces. Passwords, two-factor prompts and captchas stay on the website's side.
  1. A BrowserWindow opens https://chatgpt.com/auth/login in its own persistent partition with nodeIntegration off and contextIsolation on.
  2. After each page load, once the URL has left /auth/, Voxa polls that partition's cookies and saves them only when a cookie containing session-token exists. The window then closes itself.

Requirement 3: turn audio into text#

Two calls per dictation. The token is never stored, so there is nothing to refresh on a schedule.
  1. On stop, main closes the file, patches the WebM duration into its header and reads it back.
  2. It calls GET /api/auth/session with the cookies. A 401 or 403 becomes "Session expired. Please login to ChatGPT again."
  3. It posts the file with model=whisper-1 and reads text from the JSON reply.

Requirement 4: put the text where the cursor was#

The clipboard is both the carrier and the fallback. If the keystroke fails the text is still there to paste by hand.
  1. The inserter writes the transcript to the clipboard and reads it back to confirm.
  2. It waits 200 ms for the foreground window to settle.
  3. It sends the paste keystroke for the platform and returns what happened, never throwing.

Requirement 5: never lose a dictation#

The audio file is written first and the history row second. Retry needs only the file and the row id.
  1. A pending row exists from the moment recording starts, and the audio streams in beside it. A crash leaves a pending row, which the next launch marks failed.
  2. The retention worker deletes failed audio after 7 days and evicts the oldest successful audio when the folder passes the cap.
  3. Retry reads the saved file, runs the same transcription and updates the same row.

Requirement 1 again, on Android#

Same two calls, different capture and a different last hop. The Android client reimplements the HTTP in Kotlin; it shares no code with the desktop app.
  1. Tapping the keyboard's toolbar mic reaches LatinIME.onEvent, which routes VOICE_INPUT to VoxaVoiceController.toggle() instead of handing off to the system voice IME.
  2. The controller starts AudioRecorder, a MediaRecorder writing Opus in WebM at 16 kHz mono.
  3. A second tap stops, and the controller calls TranscriptionApi.transcribeAudio().
  4. The API makes the session call and the upload with OkHttp, 30 s connect and 60 s read timeouts.
  5. The text goes straight into the field with commitText().

Deep dive: the borrowed session#

Capture as soon as the URL leaves the login page

Simple and wrong. The logged-out chatgpt.com home page also lives outside /auth/. An early version captured on the URL alone, saved an empty session and closed the window, which cost two or three login retries.

Ask the user to paste a token

Works, and keeps Voxa out of the login. It is also a chore on every expiry, and a copied bearer token dies in minutes.

Poll the cookie jar for the session cookie

After each navigation outside /auth/, poll every 500 ms, up to 20 times, for a cookie whose name contains session-token. Save only then. The cookie is long-lived and the access token is derived from it per dictation, so nothing needs refreshing.

The poll is the part that makes login reliable. The window only closes after the cookie exists.

On Android, AuthScreen.kt does the same inside a WebView, polling CookieManager every 500 ms up to 30 times for __Secure-next-auth.session-token. It stores the bare token and the full cookie header in private SharedPreferences. The full header is preferred at request time because it also carries the Cloudflare clearance cookie. On desktop, cookies live in a JSON file in the user data directory.

The request#

JavaScript
const formData = new FormData();
formData.append("file", new Blob([buf], { type: mimeType }), `recording.${ext}`);
formData.append("model", "whisper-1");

const response = await net.fetch("https://chatgpt.com/backend-api/transcribe", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${accessToken}`,   // <access-token>
    Cookie: "<session-cookies>",
    "User-Agent": "Mozilla/5.0 ... Chrome/126 ...",
    "oai-device-id": "<device-uuid>",
    "oai-session-id": "<per-launch-uuid>",
    "oai-client-build-number": "<web-build>",
    "x-openai-target-path": "/backend-api/transcribe"
  },
  body: formData
});

The request goes through Electron's net.fetch, which uses Chromium's network stack. A source comment records the reason: its TLS fingerprint passes the checks in front of the site that a plain Node client trips. The reply is parsed leniently, because nothing documents it: text, else transcript, else message, else an empty string.

Deep dive: visible, on top, and never focused#

Show a normal window

show() activates the window. On macOS it activates the owning app, which raises all of the app's windows, Hub included, in front of the app you were dictating into. The paste then lands in Voxa.

Show it, then hand focus back

Race-prone: there is a visible flash where the target app has lost focus, and some apps drop their selection when they lose it.

Never take focus in the first place

The strip is created with focusable: false, alwaysOnTop, skipTaskbar and a transparent background, and shown with showInactive() at the screen-saver level. The target app stays frontmost the entire time.

Close-up of the Voxa recording strip: a paper-style card with a recording dot, the label rec., a timer reading 00:00 and a stop button
The strip, up close. A dot, the label "rec.", a timer and a stop button. While the file is being transcribed the label changes to "transcribing" and the stop button is disabled.
Focus, not visibility, is the thing being protected. The same on-screen result, two very different outcomes for the paste.
The Voxa Shortcuts view: start and toggle hotkey set to F9, stop hotkey set to Escape, with preset chips
Hotkeys are user-configurable. F9 starts and toggles, Escape stops and pastes from anywhere. On macOS the default start key is Option+Space, because F9 collides with Mission Control.

Deep dive: two ways to insert text#

Simulate typing, character by character

Slow for long text, wrong with non-US layouts and input methods, and it fights with autocorrect in the target app.

Use accessibility APIs to set the field value

Precise where it works, and unavailable in many fields, including most Electron and browser content.

Clipboard plus one paste keystroke

Works everywhere a paste works. The clipboard also doubles as the fallback: if the keystroke fails, the transcript is still on it.

The inserter writes the text, reads it back to confirm it landed, waits 200 ms, then sends the keystroke. Windows runs a hidden PowerShell call to SendKeys with ^v. macOS posts Cmd+V as a raw CGEvent through JXA, which needs only Accessibility permission and no second Automation prompt. Linux gets the clipboard and a manual paste. The function never throws; it returns pasted and clipboard separately, so the app can say "copied, press Ctrl+V" instead of failing silently.

On Android an input method already sits inside the focused field, so none of this applies. commitText(text, text.length) on the InputConnection inserts at the caret. The voice-only keyboard, VoxaInputMethod, adds pause and resume, a cancel and a send button and a live waveform polled every 50 ms from the recorder's amplitude. The floating overlay button is different: it has no input connection, so it copies the transcript to the clipboard and broadcasts it.

Desktop is an outside observer; an IME is already inside. That is the one structural advantage of the keyboard.

Deep dive: audio that survives#

Hold the blob in memory until stop

A crash or a failed upload loses everything, and a long take sits in renderer memory.

Write the whole file at stop

Better, but anything that dies before stop still loses the take.

Stream chunks to disk during recording

The file exists from the first 250 ms. A crash leaves a pending row and a partial file. The next launch marks the row failed and keeps whatever audio reached disk, ready for retry.

Streaming created its own bug, found in v0.2.4. The overlay's ondataavailable handler was async: a later chunk's arrayBuffer() could resolve first and be written before the header chunk. The file no longer began with the WebM signature 1A 45 DF A3, and the transcription endpoint answered 500 on that file every time. The fix serializes reads and sends through one promise chain, and the stop path drains the chain before telling main to finalize.

Order is a property of the file format, not of the network. The chain guarantees it.

Finalize then closes the stream and patches the WebM Duration element, because MediaRecorder leaves it empty and the endpoint wants an accurate length. Failures are bucketed so the History view can say something useful.

An empty transcript is not an error. It means the audio was processed and held no speech, usually a muted microphone, so it never appears as a red failure and retry is not offered.
The Voxa Settings view: launch on Windows boot, recording strip position and maximum recording seconds, history and audio retention
Retention and strip behaviour are settings, not constants. Successful audio is kept by default and bounded by the disk cap. Privacy mode deletes successful rows and audio on quit.

Deep dive: long recordings#

Send it and hope

A recorded session of 48.43 minutes was a 44.77 MB file, and the endpoint failed on it with a 500 from its speech service.

Cap the recording length

Voxa does this: 300 s by default, adjustable. It stops the take but also cuts off a genuinely long dictation.

Trim silence before upload

A voice-activity pass cut the same session to 2.34 minutes and 2.44 MB, and it transcribed in under four seconds. This is specified and prototyped in scripts/trim-silence.py and the feature note in the repo; it is not wired into the app yet.

95 percent of that recording was dead air. The benchmark is one real session from the repo's notes, run with the standalone script. Thresholds: sensitivity 0.25, 200 ms padding, cuts only for pauses over 300 ms, 5 ms fades.

The final design#

Six numbered hops, one rebuild of the product. The login window runs once; the rest runs on every dictation.

One request, one rebuild#

  1. The strip appends 250 ms chunks to <id>.webm while the user speaks.
  2. On stop it drains its chunk chain and calls finalize-recording; main closes the file and patches the duration.
  3. Main loads the saved cookies and calls /api/auth/session for a token.
  4. Main uploads the file to /backend-api/transcribe and reads the text.
  5. Main finalizes the history row: ok with a word count, empty for no speech, or failed with an error kind.
  6. The inserter writes the clipboard, waits 200 ms and pastes. Android does hops 3 and 4 in Kotlin and ends in commitText().

Rebuilding from scratch needs only the cookie file and the two URLs. Everything else, recorder, history and retry, is replaceable.

The trade-offs, stated plainly#

What this buys: no server to host or secure, no key to rotate, no bill, and transcription quality that is whatever ChatGPT's dictation gives the account.

What it costs:

  • It is not a supported API. /backend-api/transcribe is an internal route of the ChatGPT web app. No contract, version or rate limit is published for it, and it can change or be blocked at any time. The request headers, such as the client build number, are exactly the details that go stale.
  • It runs on one person's session. The credential is a session cookie in a plain file, and each install acts as one account with that account's limits.
  • It is not an integration the service offers to third-party clients. That is why I use it only on my own account and do not distribute Voxa.
  • The fragility is in two codebases. A change to the endpoint has to be fixed in JavaScript and in Kotlin.

The design is built so these costs stay contained: transcription is a function from audio to a string, and everything else works whichever service sits behind it.

What I'd change#

  • Move to an official speech-to-text API behind a thin relay I own. That removes the session dependency, the cookie at rest and the fingerprint workaround, and puts request building in one place instead of two.
  • Wire in silence trimming. The prototype proves the gain; the app still sends raw captures.
  • Share the protocol. The two HTTP clients should come from one spec, with a test that both build the same request.
  • Use the OS credential store for the session instead of a JSON file and shared preferences.
  • Delete dead guards. The history store is now pure JSON, so the native-module guard in main.js and the WAV-only silence check no longer earn their place.

Test yourself#

Why does the login capture poll for a cookie instead of closing the window as soon as the URL leaves /auth/?

  1. Polling is faster than waiting for navigation.
  2. The logged-out home page also lives outside /auth/, so the URL alone can save an empty session.
  3. Cookies only exist after 10 seconds.
  4. Electron cannot read cookies without polling.
B. Capturing on the URL alone saved an empty session and closed the window. The cookie is the real signal that sign-in finished.

What does showInactive() protect on macOS?

  1. It makes the strip render faster.
  2. It hides the window from screenshots.
  3. It shows the strip without activating Voxa, so the target app stays frontmost and receives the paste.
  4. It lets the strip receive keyboard input.
C. show() activates the owning app and raises all its windows, which would pull focus away from the app being dictated into.

The overlay once produced WebM files that the endpoint rejected every time. What was the cause?

  1. Async chunk handlers could write a later chunk before the header chunk, so the file did not start with the WebM signature.
  2. The sample rate was too high.
  3. The cookies had expired.
  4. The file was larger than 25 MB.
A. Chunks are now read and sent through one promise chain, and the stop path drains it before finalize.

An empty transcript comes back. What does Voxa do?

  1. Marks the dictation failed and offers retry.
  2. Retries automatically three times.
  3. Signs the user out.
  4. Stores it as an empty row with a no-speech message, no retry, and keeps it out of the failed banner.
D. The audio was processed and held no speech, usually a muted microphone, so re-sending it would return the same empty result.

Why is desktop insertion built on clipboard plus a keystroke while Android uses commitText()?

  1. Android has no clipboard.
  2. A desktop app is an outside observer with no API into another window's field, while an input method already sits inside the focused field.
  3. Clipboard paste is faster than commitText.
  4. macOS forbids commitText.
B. The clipboard is also the fallback: if the keystroke fails, the text is still there to paste by hand.

What is the main reason Voxa remains a personal tool?

  1. The Electron app is too large to distribute.
  2. Transcription quality is poor.
  3. It depends on an unsupported internal endpoint and on one person's ChatGPT session, kept in a plain file.
  4. It needs an API key per user.
C. There is no published contract for the endpoint, so it can change or be blocked, and the credential is a session cookie rather than a scoped key.