DocumentationDecision recordsADR-0039
Provider-agnostic voice transcription seam
Voice dictation turns a microphone recording into Markdown the writer can insert. Speech-to-text differs from the completion seam in ADR-0033 in shape, not in…
accepted
Context
Voice dictation turns a microphone recording into Markdown the writer can insert. Speech-to-text differs from the completion seam in ADR-0033 in shape, not in trust: the input is a bounded binary recording rather than a prompt, the shipped provider endpoints are request/response rather than streams, and the payload is more sensitive than any selection — it is the writer's voice.
The existing constraints stand unchanged: AI is off by default, nothing runs
on startup or an interaction hot path, credentials resolve only through the
consent-gated native store, and no provider-specific syntax may enter
skriuw-domain or the renderer. The renderer also must not talk to a
provider itself — the webview's CSP has no provider hosts in connect-src,
and keeping it that way is part of the credential boundary.
Widening AiCompletionRequest with an audio field was rejected: the request
is deny_unknown_fields, centrally validated, and recorded in run history;
recordings must never be able to reach any of those paths.
Decision
A second narrow use case, not a wider one
skriuw-domain owns an AiTranscribe trait beside AiComplete:
transcribe(&AiTranscriptionRequest, &AiCancellation) -> AiTranscriptionTerminal.
The request carries an opaque request ID, provider ID, model ID, a container
MIME type from a closed allowlist, an optional bounded language hint, and the
recording bytes. It is deliberately not serializable, so a recording cannot
enter a contract, an event, a log line, or run history by accident. Audio is
bounded by the smallest limit among shipped adapters (25 MB), so a recording
either transcribes everywhere or is refused before any bytes leave.
Transcription is not streamed: the terminal carries the whole bounded transcript, and there is no delta sink. The transcript itself is bounded, and oversized or undocumented response shapes terminalize as malformed rather than being truncated.
Adapters translate syntax behind the seam
skriuw-ai-remote implements AiTranscribe for the same adapters that
implement AiComplete. Groq uploads the recording as multipart/form-data
to its Whisper endpoint; Gemini sends base64 inlineData on an ordinary
generateContent call with a fixed transcription instruction. Both resolve
their key through AiCredentialSource before any socket opens, reuse the
completion seam's status and transport error projections, and drain error
bodies under a bound without forwarding them. A speech-capable local adapter
(for example whisper.cpp) is a future implementation of the same trait, not a
new seam.
The speech-to-text catalogue is a repository-owned list separate from the completion catalogue: transcription is priced per audio minute, so reusing the token-priced completion catalogue would fabricate prices. An entry exists exactly when an adapter mapping ships.
The shell stages bytes; the provider call leaves the main thread
Raw-body IPC commands are synchronous in Tauri, while a provider request must not run on the main thread. The shell therefore accepts the recording in a synchronous raw-bytes command that parks it in a bounded in-memory staging queue, and an async command consumes the staged bytes on a blocking worker. Staged recordings are consumed exactly once whatever the outcome, and the queue evicts its oldest entry rather than growing, so an abandoned recording cannot pin audio in process memory. Recordings never touch SQLite, the blob store, disk, sync, archives, or run history.
On Linux, WebKitGTK's default answer to a getUserMedia permission request
is denial, so the shell answers the webview's permission-request signal and
grants only audio-only capture; camera and combined requests stay denied.
Formatting reuses the completion seam
Turning a transcript into cleaned or structured Markdown is an ordinary
completion: the dictation surface fires the existing AiComplete path with a
built-in prompt (clean-transcript, structure-transcript) and a
voice:<mode> origin, so it inherits streaming, cancellation, model
selection, prompt shadowing, and run history without new machinery — and the
formatting step is provider-agnostic for free. When formatting fails, the raw
transcript is offered instead; dictated words are never lost to a provider
error. The result enters the document through the same preview-then-one-
transaction rule as editor actions.
Consequences
Adding a transcription vendor means one AiTranscribe implementation and a
catalogue entry; nothing else moves. The recording's lifetime is memory-only
and single-use, which recovery and privacy reasoning can rely on, but also
means an interrupted transcription requires re-recording unless the surface
kept the bytes. Because blocking uploads cannot be aborted mid-flight,
cancellation is only observed before the request and before the result is
returned; a cancelled upload finishes on the wire and its result is
discarded. Run history does not yet record transcription runs — extending the
recorder with a duration-priced run shape is deferred until usage justifies
it.
On this page