Skip to content

feat(stt): Whisper output language is per-transcription — core 1.8.0, speech 0.5.0 - #501

Merged
DenisovAV merged 6 commits into
mainfrom
feat/stt-language
Sep 10, 2026
Merged

feat(stt): Whisper output language is per-transcription — core 1.8.0, speech 0.5.0#501
DenisovAV merged 6 commits into
mainfrom
feat/stt-language

Conversation

@DenisovAV

@DenisovAV DenisovAV commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Closes the promise made on #500: getActiveStt(language: 'de') now selects Whisper's output language. Defaults to 'en', which is what the profile hardcoded, so nothing moves unless a caller asks.

The capability was already shipped and unreachable

The package ships litert-community/whisper-tiny / whisper-base with the openai/whisper-tiny tokenizer — no .en suffix, i.e. the multilingual weights. What pinned it to English was one line in SttModelProfile.whisper's decoder prompt.

Measured on one German clip, same build, one token apart:

<|en|>   " This weather is very beautiful and the sun is shining."
<|de|>   " Das Wetter ist heute sehr schön und die Sonne scheint."

The first line is the informative one: with <|en|> the model does not garble non-English speech — it translates it. Comprehension was never the limit; the token only ever decided the output language.

The first attempt compiled and did nothing

Threading language touches four signatures. On the first pass it reached each shell's createSttModel and was dropped before RuntimeConfig, so getActiveStt(language: 'de') still returned English.

That failure mode is indistinguishable from success — transcription runs, text comes back, just always English. It surfaced only because the check goes through the public API rather than patching the profile, which is how the gap was found in the first place.

Versions

core 1.8.0 / speech 0.5.0. This started as a patch, and the patch call was
right for what it described: widening createSttModel on FlutterGemmaPlugin,
a PlatformInterface with a private token that nothing outside the repo can
subclass. That is the same shape as createTtsModel gaining language in core
1.5.2, which shipped as a patch.

The fix commits changed the situation. Making the language per-transcription
widened the abstract SpeechRecognizertranscribe gained language:, and
the type gained a language field — and flutter_gemma_speech extends that
type. Published speech 0.4.3 declares flutter_gemma: ^1.5.6, so it
co-resolves with the new core:

app: flutter_gemma: ^1.7.0, flutter_gemma_speech: 0.4.3
pub upgrade flutter_gemma  ->  core 1.8.0, speech stays 0.4.3
build  ->  INVALID_OVERRIDE: LiteRtSpeechRecognizer.transcribe

The build breaks in a package the user never touched. It is a loud compile
error rather than silent wrong output, and it only bites someone upgrading core
on its own or pinning speech exactly — pub get and a full pub upgrade are
both fine. So a minor plus an explicit CHANGELOG line is the honest label; a
major would only be about excluding old speech, which is not worth it for this.

flutter_gemma_speech's floor moves to ^1.8.0: it reads config.language and
overrides the widened members, and only core 1.8.0 has them.

Publish order: core 1.8.0 first, then speech 0.5.0.

Docs

models.md said "Whisper (tiny, English)", which undersold what already shipped. Now states the 99-language support, the getActiveStt(language:) call, and the caveat that asking for 'en' on non-English audio returns a translation rather than an error.

Parakeet CTC 0.6B is genuinely English-only — that part of the answer on #500 stands. Moonshine and Parakeet ignore the parameter.

Testing

  • speech 263 ✓, core 701 ✓, analyze 0 errors/warnings
  • End-to-end on macOS through the public API: getActiveStt(language: 'de') → verbatim German, umlaut included

…en|>

`getActiveStt(language: 'de')` now selects Whisper's output language. It
defaults to 'en', which is what the profile hardcoded before, so nothing moves
unless a caller asks.

The capability was already there and nobody could reach it. The shipped
checkpoints are `litert-community/whisper-tiny` / `whisper-base` with the
`openai/whisper-tiny` tokenizer — no `.en` suffix, i.e. the multilingual weights
— while `SttModelProfile.whisper`'s decoder prompt pinned `<|en|>`. Measured on
one German clip, same build, one token apart:

    <|en|>   " This weather is very beautiful and the sun is shining."
    <|de|>   " Das Wetter ist heute sehr schön und die Sonne scheint."

The first is the telling one: with `<|en|>` the model does not garble non-English
speech, it TRANSLATES it. Comprehension was never the limit; the token only ever
decided the output language.

Threading it took four signatures and, on the first attempt, silently did
nothing: `language` reached each shell's `createSttModel` and was dropped before
`RuntimeConfig`, so `getActiveStt(language: 'de')` still returned English. That
failure is indistinguishable from working — transcription succeeds, text comes
back, just always English — so it is verified through the public API rather than
by patching the profile, which is how the gap surfaced.

Both packages take a minor: this is new public API on core, and speech's floor
goes to ^1.8.0 because it READS `config.language` while only core 1.8.0 can SET
it. On an older core it would resolve, compile and transcribe in English with the
feature advertised and inert. Core publishes first.

models.md said "Whisper (tiny, English)", which undersold what already shipped.
Corrected, with the 99-language note and the caveat that asking for 'en' on
non-English audio returns a translation rather than an error. Parakeet CTC 0.6B
is genuinely English-only; moonshine and parakeet ignore the parameter.
The change is one optional named parameter threaded through five files
(18 lines), and RuntimeConfig.language already existed on main — this
release only connects a public field to a public call that were never
wired together. SttModelProfile is not exported from the speech barrel,
so its loss of const is invisible outside the package.

A minor here would not match what this project has shipped as one:
1.7.0 was the whole Hugging Face install path, 1.6.0 a new file type
plus engine, 1.5.0 genai_primitives and four groups of facade API.

Speech's floor moves to ^1.7.5 rather than staying at ^1.7.4: it reads
config.language, and only core 1.7.5 sets it — on 1.7.4 the parameter
is dropped before RuntimeConfig and every transcript comes back in
English, which is exactly the bug being fixed.
@DenisovAV DenisovAV changed the title feat(stt): Whisper output language is a parameter — core 1.8.0, speech 0.5.0 feat(stt): Whisper output language is a parameter — core 1.7.5, speech 0.4.4 Sep 9, 2026
Review found getActiveStt(language:) was a no-op after the first call in a
process: all three shells key the STT singleton on the active model's name,
so the second call returned the recognizer built for the FIRST language and
transcribed into it with no error. The manual macOS check missed it because
it made exactly one call on a cold start.

Rather than guard the cache, remove what the guard would protect. The
decoder seed prompt is already copied on every transcription, so the
language token is swapped per call instead of baked at load:

  SpeechRecognizer.language      mutable default, retargeted on reuse
  transcribe(pcm, language:)     per-call override
  SttCore._promptFor             replaces one id in a copy of the seed

Nothing is reloaded to change language, so there is no stale state to
invalidate. SttModelProfile loses its language parameter and is const again;
forType matches TtsModelProfile.forType and no longer silently discards a
language for moonshine and parakeet, which now reject it.

Validation is in two halves: a shape check at create time, so 'de-DE' fails
before an isolate is spawned, and the exact code checked against the
tokenizer's own index — harvested at load from the same map the resolver
builds. That index is also what stops language: 'translate' resolving
cleanly and rewriting the prompt's task slot.

Tests: promptForLanguage extracted as a pure function (matching the file's
other testable helpers) and covered, plus stt_language_singleton_test.dart
modelled on the TTS one. Mutation-checked: dropping the retarget, guarding
it on non-null, an off-by-one slot, a silent fallback on an unknown code,
and removing language from RuntimeConfig each turn the suite red.

Docs: RuntimeConfig.language no longer claims to be TTS-only and spells out
that its two consumers use incompatible vocabularies; language documented on
getActiveStt, createSttModel, transcribe, speech.md and the speech README;
website/docs/speech.md pinned flutter_gemma_speech ^0.4.3 while core moved.
…gumentError's type across the isolate port

The unit suite fakes everything below transcribe, so it cannot show that the
model actually writes a different language. whisper_language_test.dart runs
the real checkpoint. Measured on macOS, one clip, one recognizer:

  en          She had Jedak Su in greasy wash for all year.
  de          Sie hat ja dags zu den Grissie-Waschwarrohrer.
  default-de  Sie hat ja dags zu den Grissie-Waschwarrohrer.

The third line is the regression this PR exists for: getActiveStt(language:)
on an ALREADY-BUILT recognizer, which previously returned the first
language's recognizer and said nothing. identical() holds across it, so the
retarget really is a field write and not a reload.

That run also caught a defect the fakes could not: every worker error
reached the caller as StateError, because an exception object is not
sendable and the reply carried only e.toString(). So transcribe(language:
'zz') threw, correctly, but could not be caught as the ArgumentError it is.
The reply now carries a badArgument flag and the type is reconstructed —
general to the error path, not specific to the language.
Review round two found the fix incomplete in three places and wrong in a
fourth, all on paths the first round had already named:

- the in-flight-completer guard (mobile and desktop) and the legacy
  explicit-paths branch returned an existing recognizer without applying
  the requested language — #500 again, in a window wide enough to hold an
  isolate spawn and a 51k-entry tokenizer parse;
- the retarget branch assigned the language with NO validation, so
  `getActiveStt(language: 'de')` on moonshine returned normally instead of
  throwing as documented, and then every later `transcribe()` failed —
  including calls passing no language. One call broke the recognizer for
  good.

Validation now lives in `LiteRtSpeechRecognizer`'s `language` setter, which
every write goes through, and `create()` applies the same rule before
spawning the worker so a bad code costs no isolate.

Two more from the same round: `badArgument` was `e is ArgumentError`, but
`RangeError` and `IndexError` both EXTEND `ArgumentError`, so any bad index
in the decode path reached the app as a bad `language`; and rebuilding the
error from `toString()` dropped `name`/`invalidValue` and doubled the
prefix. Both fixed, both verified against dart:core rather than assumed.

The test fixture claimed `<|yue|>` at 50325. The real openai/whisper-tiny
tokenizer has `<|yo|>` there and no `<|yue|>` at all; its only 3-letter code
is `<|haw|>`. Downloading the file also showed the regex matches exactly 99
entries on its own, so `_nonLanguageCodes` excluded nothing and is gone.

Version: 1.8.0, not 1.7.5. The patch call was made before this branch
widened the abstract `SpeechRecognizer`, and speech 0.4.3 (`^1.5.6`)
co-resolves with a new core — so `pub upgrade flutter_gemma` alone breaks
the build in a package the user never touched. It is a loud compile error,
not silent wrong output, so a minor plus a CHANGELOG line is the honest
label rather than a major.

Coverage: the desktop shell had none at all (`defaultFlutterGemmaInstance`
returns mobile on the VM), so deleting its `RuntimeConfig` threading left
the suite green. It has a file now — with a recorded KNOWN GAP: desktop
never reuses its recognizer, because an install names the spec after the
basename and the restore path after the full filename, so its cache guard
always sees a changed model. That is pre-existing and separate from #500.

Mutation-checked: dropping either guard's retarget, swallowing instead of
throwing in the setter, and removing desktop's `RuntimeConfig` threading
each turn the suite red. macOS re-verified end to end.
@DenisovAV DenisovAV changed the title feat(stt): Whisper output language is a parameter — core 1.7.5, speech 0.4.4 feat(stt): Whisper output language is per-transcription — core 1.8.0, speech 0.5.0 Sep 10, 2026
CI failed on four warnings my local analyzer did not report — the trap
CLAUDE.md names: CI installs `channel: stable` (latest), a dev box is
whatever was last pulled, and warnings are fatal there while
`--no-fatal-infos` only spares infos.

Three were `unnecessary_cast` on `(e as ArgumentError)` in the worker's
catch. A `bool` flag leaves `e` an `Object`, so each field read needed a
cast, and whether the analyzer calls that cast redundant differs by SDK.
Binding a typed local instead removes the casts entirely, so the code no
longer depends on which flow analysis is in front of it. The fourth was
`SttModelType` left in a `show` clause after the validation that used it
moved into the recognizer's setter.

Local Flutter upgraded 3.47.2 -> 3.47.3 (Dart 3.13.2 -> 3.13.3) so this
stops being discovered by a red CI. Confirmed aligned: local now reports
569 issues, CI reported 573 before the fix, and the difference is exactly
these four warnings.

The upgrade also churned pubspec.lock and the generated plugin
registrants (a new transitive connectivity_plus). Reverted — that is a
dependency change, not this release, and it belongs in its own PR.
@DenisovAV
DenisovAV merged commit 01801ef into main Sep 10, 2026
5 checks passed
@DenisovAV
DenisovAV deleted the feat/stt-language branch September 10, 2026 08:29
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