Dictation

Overview

Dictation turns speech into text using a model the user picks, and hands that
text to the app rather than sending it. It has two surfaces: a Dictation
page under Settings that records, transcribes and shows an editable
transcript, and a microphone button in the chat composer that transcribes
while the user speaks and grows the draft as the words arrive. The provider
credential never reaches the browser: the recording — or each segment of it — is
posted to POST /api/ai/transcribe and the server performs the upstream call.

Usage

The Dictation page

  1. Open Settings → App defaults → Dictation (or #/settings/dictation).

  2. Pick a dictation model (a model from the active project, or one the
    connected providers offer) under Dictation model; the dialect it will be
    sent in is shown underneath, read-only. There is usually nothing to pick: a
    remembered model, a lone candidate, or a lone row whose name says it
    transcribes is selected for you.

  3. In the Test card below, tap Record. The timer and the level meter
    confirm the microphone is live. Recording stops on the second tap, or
    automatically at 2:00.

  4. Tap Transcribe. The transcript appears in an editable field, and the
    line under it reports the run — model, size, duration and what it cost.

  5. Choose what happens to it: Copy, Insert in chat (fills the newest
    chat's draft), Send to chat (same, labelled for a send), or Clear.

    Optional per-run hints sit under the picker, as a plain item list — one row
    each, no disclosure, so every setting is on the page at once:

The composer microphone

Inside a chat, the microphone button dictates as you speak. The take is cut
into 3-second segments, each one a complete recording, and every finished
segment is posted as it arrives — so the transcript appears in the draft at the
caret while the take is still running. Nothing is sent: dictation produces a
draft, and sending stays a user decision.
The chat's status line under the composer is the live indicator (12 words so
far — tap the mic to stop.
), the button's tooltip carries the running clock
(Stop dictation (0:14)), and the second tap closes the take and reports it
(dictation added, plus the run's cost when it is priced).

Live transcription is on by default and is switched off in the dictation
page's Live transcription row (Settings → App defaults → Dictation). With it off the
composer mic behaves the way it always did: one recording, one request, one
transcript when the button is tapped a second time. Turn it off for a model
that bills per minute or that rejects a short recording on its own.

The model is resolved before the microphone opens, so a chat with no
dictation model configured reports the reason in that same status line —
No dictation model yet — Open Settings → App defaults → Dictation to pick a
dictation model.
, marked as an error and with nothing recorded. Everything the
button does or fails to do (recording, transcribing, a provider rejection) is
written there too: the button's own title is a hover affordance, and a phone
has none.
The whole button can be kept out of the composer row with Settings → App
defaults → Chat defaults → Dictation microphone in the composer
. That switch
(dictationButton, on by default) only decides whether the row draws the
button: the dictation page, the remembered model and POST /api/ai/transcribe
are untouched, so nothing is disabled. See
Composer tool buttons.

How a live take is put together

A live take is a series of complete recordings, not one recording sliced up,
and each one is transcribed as if it were a take from the dictation page:

What a run cost

A transcription is billed work, so the page and the composer both account for
it — with one deliberate exception:

The layout of the page

One column, mobile first, and the sections are ordered settings first, test
below
: Dictation model (with the options list and the catalog
note), then Test — the record button, timer and level meter — then
Transcript. The model has to be answered before a take can be transcribed at
all, so it is picked before the microphone is opened; a phone user chooses a
model once and records many times, and keeping the recorder at the top pushed
the transcript, which is what the page is for, off the fold. The Test group
title says what the card under it does; the record button keeps its own
Record / Stop / Record again caption. The group title names the control
(Dictation model) rather
than labelling it a second time under itself, and the note beside it appears only
when it has something to say: the page adopts the first registered project on a
cold start or a PWA launch, the models then come from that project, and naming
it is the difference between "where did these come from" and a named source. When
the active project's catalog is already the one on offer, the note is empty rather
than a "this project" that restates the obvious.

What the surfaces show while they work

Dictation is a chain of waits — two catalog reads before the microphone opens, a
transcription after it closes, a hand-off that writes a chat's draft — and each
one leaves the user looking at a screen that has not changed yet. So every wait
is reported, on the control that was tapped and next to the thing that is
being waited on, and the report is the same one a screen reader gets:

// What the read-out says, and whether it is a spinner or a sentence.
catalogNote({ catalogBusy, liveBusy, projectCount, hasLive })
// -> { text: 'Loading models…', loading: true }
// Which wait is in flight; a long wait (the catalog) wins over a short one.
busyPhase({ catalogBusy, liveBusy, transcribing, handoff })
// -> '' | 'catalog' | 'live' | 'transcribe' | 'handoff'
// The composer mic's loading state: a transcription request in flight, in
// either of its two shapes — a one-request take (`transcribing`) or a live
// take still transcribing its last segment (`finishing`) — and nothing else
// (never the model resolve).
micWaitPhase({ transcribing, finishing })
// -> '' | 'transcribe'
// The sentence the live take's settle writes to the chat's status row.
MIC_TRANSCRIBE_NOTE // -> 'Transcribing…'
// What a tap says while it resolves the model, once that wait is worth a word.
micResolveNote({ preparing, delayMs })
// -> '' | 'Preparing dictation…'

micWaitPhase and micResolveNote are the two halves of one rule: a spinner
belongs to the request the button is actually processing, and a wait that has no
request behind it (and no audio) gets words instead. micWaitPhase reads the
request itself, not the moment the user tapped: a live take's requests outlive
the stop that ends it, so finishing — "stopped, and a segment has not
answered" — is the same loading state as transcribing.

Every spinner is decoration over a sentence or an aria-busy, and it is a
currentColor ring so it inherits the accent inside a primary button and the
muted tone inside the mic button. Under prefers-reduced-motion: reduce the
animation stops but the ring stays — a static ring with its coloured top arc
still reads as "working", and the words beside it are unaffected.

Configuring a model

The Dictation page lists two kinds of model, and you do not have to
configure anything for the first one:

  1. Models from your providers. Every connected provider in Settings →
    Providers is asked for its current catalog — the speech-to-text slice of it
    where the provider publishes one (OpenRouter), and the chat slice for
    OpenRouter, because that is where its Google models live — and the entries
    that can be dictated with are listed. Refresh re-reads them (the lists are
    cached server-side for an hour). This is what makes a fresh install work with
    no setup: connect a Gemini key and gemini-2.5-flash is offered; connect an
    OpenRouter key and google/gemini-3.5-flash is offered alongside
    openai/whisper-large-v3.

  2. Models from the project. A model id in .mouaif.json is the way to
    describe something the provider's catalog cannot: a self-hosted endpoint, a
    per-model language default, or a specific OpenRouter slug. A project record
    for an id wins over the live entry for the same id.

{
  "models": [
    { "id": "gemini-2.5-flash", "provider": "gemini" },
    {
      "id": "my-self-hosted-asr",
      "provider": "openai-compatible",
      "transcription": {
        "kind": "openai-compatible",
        "path": "/v1/audio/transcriptions",
        "language": "fr",
        "prompt": "mouaif, SSE, MediaRecorder"
      }
    }
  ]
}

Five signals decide whether a model is offered, most trustworthy first:

SignalExample
transcription is set"transcription": { "kind": "gemini" }
the provider reports transcription outputopenai/whisper-1 in OpenRouter's transcription catalog
the model can be sent audio over the chat route (see below)google/gemini-3.5-flash, openai/gpt-audio on OpenRouter
the id looks like speech-to-textwhisper-1, mistralai/voxtral-…, parakeet
it resolves to the Gemini familygemini-2.5-flash
the provider reports audio input, and no output reportmeta/muse-spark-1.3 on a provider that reports inputs only

The provider's output report settles the question in both directions. A row
whose outputs include transcription is a transcriber whatever its name says
(google/chirp-3, deepgram/nova-3); a row whose outputs are reported and
exclude it is a transcriber only if the chat route can carry its audio (next
section) — otherwise it is not one, however much audio it accepts. Only when
there is no output report at all does the filter fall back to names and
capabilities.

When none of them matches, everything is offered rather than nothing — a
self-hosted my-asr is exactly the case nothing can infer. A catalog the
provider did classify, where no row can be dictated with, is a different
answer rather than a gap: the picker offers none of them instead of all.

Two OpenAI-shaped routes, and which one a row takes

A model that can hear has two ways in, and picking the wrong one is a provider
error rather than a preference:

RouteEndpointFor
openai-compatiblePOST /audio/transcriptions (multipart)every model that has an entry there: openai/whisper-large-v3, google/chirp-3, mistralai/voxtral-mini-transcribe
openai-audioPOST /chat/completions (inline input_audio)a model with no such entry that the provider reports as taking audio: google/gemini-3.5-flash, ~google/gemini-flash-latest, openai/gpt-audio
geminiPOST /v1beta/models/{model}:generateContentthe native Gemini connection

The second route exists because of OpenRouter's Google models, and that is
the bug it fixes. OpenRouter slices GET /api/v1/models by output modality, and
a Google Gemini row is filed under output_modalities: ["text"] — so
google/gemini-3.5-flash is in the chat catalog, is absent from the
transcription slice (?output_modalities=transcription, whose one Google row is
google/chirp-3), and is answered by /audio/transcriptions with
400 Model google/gemini-3.5-flash does not exist. Reading only the
transcription slice therefore left the picker with no Google chat model at all,
which reads from the phone as "Google models are not available on OpenRouter".
The model was always there and always able to transcribe; the catalog was asking
the wrong endpoint. The same audio, sent as an input_audio part on a chat
completion, comes back with the transcript and a billed usage report.

A row takes the chat route when all of these hold:

Gemini's two transcript shapes

The native Gemini connection has two ways to answer a transcription, and which
one you get depends on the model:

Choosing from the list

A connected provider's catalog is a chat catalog: hundreds of rows, rendered
provider by provider and alphabetically within each — except on a provider that
publishes a separate transcription catalog (OpenRouter's 21 speech-to-text
models), where the list is the transcription slice. Two things keep the
dictation list usable:

Behavior