Skip to content

Translation: speak one language, type another - #25

Merged
bojieli merged 6 commits into
mainfrom
feat/translation
Aug 30, 2026
Merged

Translation: speak one language, type another#25
bojieli merged 6 commits into
mainfrom
feat/translation

Conversation

@bojieli

@bojieli bojieli commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Stacked on #24#23#22. Review those first; this PR's diff is only its own changes.

What this changes, and why

Requested: dictate in one language, get another at the cursor. A target language in Settings, off
by default, on all four clients and from dnt transcribe --mode translate:English.

This is the one setting in the product that makes the main control deliver something other than
what was said, so it is worth being exact about what it does not change. The verbatim
transcript is still produced first, still stored first, and still recoverable — ⌘⌥Z,
Ctrl+Alt+Z, the History row on both phones. That is the actual promise; "the main key stays
verbatim"
was how it had been kept so far, not the thing being kept.

Three decisions worth the review time:

It replaces the rewrite stage rather than joining it. One request doing two jobs is the
combination already measured at twice the substitution rate, and "formal French" is a feature
request rather than a fix for what was asked for. Every client says so where the rewrite control
is, through a new RewriteAvailability.translating case — that type exists precisely so a control
is never greyed out without a sentence.

It folds into the request that transcribes, wherever the backend can answer the wider schema.
Same argument as the rewrite: that request is the only one holding the audio, and a translator
working from text alone applies world knowledge and "corrects" a version number it believes is
stale. Recognisers, split recordings and the live pipeline keep the second pass.

StyledRequest replaces the bare style-clause parameter for this. A rewrite and a translation are
different jobs asked for through the same styled field, and two optional parameters would have
made "both at once" a state somebody has to remember not to construct.

The language is free text with a shape check, exactly like the Model field and for the same
reason: "Traditional Chinese", "Brazilian Portuguese" and "plain English" are all things a model
can do and none is a row in an enum anybody would have thought to add. TranslationTarget checks
shape and never existence; the suggestion list is a shortcut, and the suites assert it is not a
whitelist.

prompt/translate.md is its own part rather than a rewrite style, because the rewrite block's
first rule is keep the speaker's language — the same reasoning that keeps the summary separate,
where the rule at stake is "never remove a fact".

Numbers, if this touches transcription quality

The new part is a second-stage instruction. It is never appended to the transcription
instruction, so the request carrying the audio is byte-identical whether or not a language is set.
The suite measures that request, so this ran as a control:

before (the typography control, #24) after
matched 38 36
improved 7 9
regressed 2 3

swift run dnt-eval suite eval/nearmiss --repeat-count 3, gemini-3.5-flash, 48 runs.

The regressed count moved from 2 to 3 and the honest reading is that it did not move. The
runner prints its own per-pass range beside the figure — REGRESSED 3 (0–2 per pass) — and named
real-version-number and benefit-novel-repo as giving different answers across passes in that
same run
. real-version-number is the third regression and is one of the two. Nothing in the
request changed, so there is nothing for a change to have caused; what this records is the size of
the suite's noise. benefit-novel-repo remains the standing failure it has been for weeks.

Platforms

  • macOS
  • Windows
  • Android
  • iOS

The mode-grammar parity table gains eight translate rows in all three suites, including the two
that must be rejected (translate and translate: — every other stage has an obvious default and
"into what?" has none). The setting crosses devices in the transfer profile. On both phones the
mode chip says Translate and stops being a toggle while a language is set, because clearing it
belongs to the settings screen and a chip reading "Dictate" over a dictation coming back in another
language would be a lie.

swift test, dotnet test and ./gradlew test pass. iOS builds and its simulator tests pass,
with the same pre-existing testAPIKeySurvivesLeavingSettings failure noted in #23.

Updated after review: the series has since been run through
./gradlew connectedDebugAndroidTest on an API-35 emulator, so Android is no longer untested on a
device. A review pass over this branch also found the tray building its "what is happening" label
from TranscriptMode.Rewrite(SecondaryStyle) in two places — a second answer to a question the
controller had already answered, so a translation showed "Loosening…" while a translation was in
flight. AppSettings.SecondStageFor is now the one definition. The dotnet format failure the
Windows job reported here is fixed on #24, which this branch is rebased onto.

Checks

  • swift test, dotnet test and ./gradlew test pass
  • Comments explain why where a decision looks arbitrary
  • Nothing new is logged that could contain a transcript or a key

Cancelling an active dictation worked on every client already. On three of them
nothing said so, which for a feature reachable only by a key or a gesture is
the same as not having it: the desktops bound Escape and never printed it, and
both phones cancelled capture by dragging off the talk button before letting
go — undiscoverable, and with nowhere on a keyboard to be documented. What was
left in practice was to let a recording you did not mean finish, pay for it,
and delete what it typed.

Phones get a control, shown only while there is something to abandon and named
for which half of the dictation that is: Discard recording while the microphone
is open, Cancel transcription once the request has gone. Two names rather than
one because the two lose different things. On Android it is a cross beside the
talk button — deliberately not on the utility row, which is always there and
already has Backspace on it — and on iOS the keyboard's existing Cancel button
simply stops hiding itself during recording. Both hosts route through one new
DictationController.cancelActive(), because the state changes under the finger:
a request starts the moment a recording ends, and a control that went inert at
exactly that boundary would fail when it is most wanted.

The desktops name the key instead of growing a button. Their overlay ignores
the mouse on purpose — it hangs over whatever is being typed into, and a pill
that swallowed clicks would take them from the application underneath — so the
fix is that the pill says "Esc to cancel", in the recording phase and in the
transcribing phase, where the wait is longer and the way out matters more.
RecordingHint composes that row in the core of both desktops, with the same four
cases asserted in Swift and C#: the key's *name* stays with the client, because
macOS says Return where Windows says Enter, but the order and the join do not,
or the row reads differently on the two platforms.

swift test, dotnet test and ./gradlew test pass. The iOS UI tests pass with one
pre-existing failure (testAPIKeySurvivesLeavingSettings), which reproduces on
main; a new UI test covers discarding a recording. Android compiles and unit
tests; untested on a device.
The space between Chinese and Latin was whatever the model felt like on the
request. The same sentence came back spaced once and tight the next time, and
sometimes with a stray space after a full-width full stop, which no convention
allows anywhere. Every individual output was defensible; the set of them was
not, and inconsistency is what was reported.

The fix is split along the line between what can be decided locally and what
cannot.

Spacing is arithmetic, so it happens on the device. Typography runs in
TranscriptionService after both audio guards and before the result leaves —
the same choke point and the same argument as HallucinationGuard: dictation,
file transcription, retry, redo and both CLIs all come through it, so history
and the cursor cannot disagree, and a caller cannot forget it. One space at the
boundary is the default, with "no space" and "leave it as the model wrote it"
beside it; space next to a full-width mark is removed under both rules, because
that one is not a preference.

Three constraints keep it safe to run on every transcript. It may only add or
remove horizontal space — all three suites assert the input and output are
identical once whitespace is dropped. Newlines are not horizontal space, so it
cannot join two lines. And Hangul is outside the CJK class while kana is inside
it: Korean separates its own words, so "no space" would break the language,
whereas excluding kana would space Web開発 and not Webかいはつ, which is the
inconsistency the whole thing exists to remove. The 22-row table is repeated
verbatim in Swift, C# and Kotlin, like the mode grammar.

Script and punctuation are not arithmetic, so they are asked of the model.
prompt/typography.md carries a chosen Chinese script — Simplified against
Traditional was drifting inside single dictations — plus the request to
separate clauses with full-width punctuation rather than spaces. prompt/sample.md
carries the user's own formatting example, framed three times as an example and
not as speech, the way screen context is. Replacing a space with a comma stays a
request rather than a transform, because a comma the speaker did not say is a
content change and this project does not make those locally.

Both blocks are absent from a default request, and that is load-bearing rather
than tidy: every measured number in docs/PROMPT.md describes the default
request, and a clause added to it unconditionally would invalidate the whole
table at once. The equality is a test, not a claim.

Measured as a control rather than a before/after, since nothing sent a new
block: 48 runs, 38 matched, 7 improved, 2 regressed, against the 2026-08-19
baseline's 37/11/2 — inside the per-pass range the runner prints for itself.
The two regressions are the standing benefit-novel-repo failures.

The measurement harness turns typography off explicitly, which is the second
deliberate divergence in ARCHITECTURE.md: a suite scoring against ground truth
transcribed from the backend's output should not be scoring this app's own
transform.

swift test (577), dotnet test (588) and ./gradlew test pass; iOS builds and its
simulator tests pass. Android and Windows compile and unit-test; untested on a
device.
CI's `dotnet format whitespace --verify-no-changes` rejected an attribute left
on the same line as a property whose initialiser wraps. Caught by the Windows
source-validation job, which is the one part of the matrix a Mac cannot run.
A target language in Settings, off by default, on all four clients and from
`dnt transcribe --mode translate:English`.

This is the one setting in the product that makes the main control deliver
something other than what was said, so it is worth being exact about what it
does not change. The verbatim transcript is still produced first, stored first,
and recoverable — ⌘⌥Z, Ctrl+Alt+Z, the History row on both phones. That is the
actual promise; "the main key stays verbatim" was how it had been kept so far,
not the thing being kept. A translation you cannot expand back into the words
that produced it is the failure this project was built against. One you can is
a convenience sitting beside them.

Three decisions worth the review time:

It replaces the rewrite stage rather than joining it. One request doing two
jobs is the combination already measured at twice the substitution rate, and
"formal French" is a feature request rather than a fix for what was asked for.
Every client says so where the rewrite control is, through a new
RewriteAvailability.translating case, rather than leaving a picker that quietly
does nothing — the same reason that type exists at all.

It folds into the request that transcribes, wherever the backend can answer the
wider schema. Same argument as the rewrite: that request is the only one holding
the audio, and a translator working from text alone applies world knowledge and
"corrects" a version number it believes is stale. Recognisers, split recordings
and the live pipeline keep the second pass. StyledRequest replaces the bare
style-clause parameter for this: a rewrite and a translation are different jobs
asked for through the same field, and two optional parameters would have made
"both at once" a state somebody has to remember not to construct.

The language is free text with a shape check, exactly like the Model field and
for the same reason. "Traditional Chinese", "Brazilian Portuguese" and "plain
English" are all things a model can do and none is a row in an enum anybody
would have thought to add. TranslationTarget checks shape, never existence; the
suggestions are a shortcut and the suites assert they are not a whitelist.

prompt/translate.md is its own part rather than a rewrite style, because the
rewrite block's first rule is *keep the speaker's language* — the same
reasoning that keeps the summary separate, where the rule at stake is "never
remove a fact".

Measured as a control, since the transcription request is untouched: 48 runs,
36 matched, 9 improved, 3 regressed. The regressed count moved from 2 and the
honest reading is that it did not move — the runner printed `0–2 per pass` beside
it and named real-version-number, the third regression, as one of two cases
giving different answers across passes in that same run.

swift test (582), dotnet test (607) and ./gradlew test pass; the mode grammar
table gains eight translate rows in all three suites. iOS builds and its
simulator tests pass, with the same pre-existing testAPIKeySurvivesLeavingSettings
failure that reproduces on main. Android and Windows compile and unit-test;
untested on a device.
`prompt show --section system` printed the bare contract while the app appends
two more paragraphs to it, which makes the command useless for the one job it
has — telling somebody what is being sent. It now reads the same two settings
the app does, and gains a `translate` section beside `rewrite` and `summary`.

Verified end to end against a real code-switched recording: with the spacing
setting on `tight`, `一个retrieval pipeline,` and `这个index已经` come back
tightened while the space inside "retrieval pipeline" is left alone.
Found reading the branch back. The Windows tray built its "what is happening"
label as TranscriptMode.Rewrite(SecondaryStyle).ProgressLabel in two places,
which is a second answer to a question the controller had already answered — so
a translation showed "Loosening…" in the overlay and in the menu while a
translation was in flight. AppSettings.SecondStageFor is now the one definition
and both callers use it.

The Kotlin PromptPart carried two consecutive KDoc blocks on isClause, because
the new note was inserted ahead of the old one instead of into it.
@bojieli
bojieli changed the base branch from feat/typography to main August 30, 2026 14:27
@bojieli
bojieli merged commit 1e84ac8 into main Aug 30, 2026
8 checks passed
@bojieli
bojieli deleted the feat/translation branch August 30, 2026 14:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant