Skip to content

fix(study): the Speak sheet reports why a listen failed instead of closing - #232

Merged
jvsena42 merged 5 commits into
mainfrom
fix/speak-sheet-silent-close
Sep 4, 2026
Merged

fix(study): the Speak sheet reports why a listen failed instead of closing#232
jvsena42 merged 5 commits into
mainfrom
fix/speak-sheet-silent-close

Conversation

@jvsena42

@jvsena42 jvsena42 commented Sep 4, 2026

Copy link
Copy Markdown
Owner

The bug

The Speak sheet sometimes closed without showing a success or an error.

StudySessionViewModel.onSpeechError() mapped every recognition failure to SpeakPhase.Idle, and on Android the common failures are ordinary rather than exceptional — ERROR_NO_MATCH, ERROR_SPEECH_TIMEOUT, and the engine still busy on a fast Try again. From the reader's side a sheet that closes on its own is indistinguishable from the app having missed the tap.

Driving it on the emulator turned up two more silent paths in the same flow:

  • A listen the engine never finishes. The recognition service reports onReadyForSpeech and onBeginningOfSpeech, then returns neither a result nor an error. Nothing else ends the flow, so the sheet sits on "Say the word" for good.
  • Every API 33 error code fell into UNKNOWN — including ERROR_LANGUAGE_NOT_SUPPORTED, which is exactly what a deck declaring a language the device has no model for looks like. It closed the sheet like all the others.

The fix

The reason travels instead of being thrown away. SpeechEvent.Error carries its SpeechError through to a new SpeakPhase.Failed, which says which failure it was and offers Try again — or a Close, for the two nothing can retry (a refused permission, a missing language model).

Around that:

  • A retry joins the previous listen before starting one. The old recognizer is destroyed in the flow's awaitClose, so cancelling without joining let a fast Try again create a second one first — answered with ERROR_RECOGNIZER_BUSY, which closed the sheet.
  • One listen is capped, with a stopListening grace first so a slow-but-working engine still delivers. A pronunciation attempt is a word or a short phrase; the cap cannot truncate a real one.
  • A final result and an error can both arrive on some engines; whichever lands first is now the attempt's outcome, so "didn't catch that" can't overwrite a graded result.
  • A late error is dropped exactly as a late transcript is, so nothing reopens a sheet the reader has moved on from.
  • A refused microphone permission is a dismissal, not a failed listen: nothing was started.

iOS had the same silent close, and additionally collapsed every recognition error into .noMatch — so its alert for permission and unavailability never fired for the case that actually happens. It now reports through the same shared phase, which retires the separate alert: one place a Speak outcome is shown, on both platforms.

SpeechError gains LanguageUnavailable, and its entries become PascalCase like every other enum that crosses to Swift here — Kotlin exports them lowercased with the separators dropped, so a SCREAMING_SNAKE entry would cross under a name nothing in the repo can predict.

Verification

./gradlew :shared:jvmTest detektAll :composeApp:assembleDebug — green. Four new StudySessionViewModelTest cases cover the reported phase, the retry target, the non-retryable failure and the late-error guard.

Driven on emulator-5554 against the real homeserver ("Spanish basics", es-ES → en-US, Speak on) — journeys/09-speak-study.xml re-run, with the outcome and three new steps recorded in journeys/RESULTS.md:

Step Result
Speak → rationale → RECORD_AUDIO grant PASSED — unchanged
Say nothing, wait PASSED — "Didn't catch that" with the reason and Try again. It used to hang on "Say the word" for good
Try again from the failure sheet PASSED — straight back to Listening, no ERROR_RECOGNIZER_BUSY
Dismiss PASSED — back to the card

Still manual on a device with Google speech: the Correct/Wrong outcome and which language each engine uses. The iOS half is written to match but is unverified — no macOS in this session, and xcodebuildmcp failed to connect.

🤖 Generated with Claude Code

https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye

jvsena42 and others added 5 commits September 4, 2026 11:47
The Speak sheet sometimes vanished with neither a success nor an error.
`onSpeechError` mapped every recognition failure to `SpeakPhase.Idle`,
and the common Android ones are ordinary rather than exceptional:
ERROR_NO_MATCH, ERROR_SPEECH_TIMEOUT, and the engine still busy on a
fast Try again. From the reader's side a sheet that closes on its own
is indistinguishable from the app having missed the tap.

The reason now travels: `SpeechEvent.Error` carries its `SpeechError`
into a new `SpeakPhase.Failed`, which says which failure it was and
whether another go is worth offering — a refused permission and a
missing language model do not change by trying again. Late errors are
dropped exactly as late transcripts are, so nothing reopens a sheet the
reader has moved on from.

`SpeechError` gains `LanguageUnavailable` (a deck declares its language,
so this is the one failure the reader can act on) and its entries become
PascalCase, like every other enum that crosses to Swift here — Kotlin
exports them lowercased with the separators dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye
…ery error

Three gaps in `AndroidSpeechRecognizer`, all of which left the Speak
sheet saying nothing.

A listen could hang forever: the emulator's recognition service reports
`onReadyForSpeech` and `onBeginningOfSpeech` and then returns neither a
result nor an error, and nothing else ends the flow — the sheet sits on
"Say the word" for good. One listen is now capped, with a
`stopListening` grace first so a working-but-slow engine still gets to
deliver. A pronunciation attempt is a word or a short phrase, so the cap
cannot truncate a real one.

A final result and an error can both arrive on some engines, which let
"didn't catch that" overwrite a graded result; whichever lands first is
now the attempt's outcome.

And the API 33 codes were all falling into UNKNOWN — including
ERROR_LANGUAGE_NOT_SUPPORTED, which is what a deck declaring a language
the device has no model for looks like.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye
The sheet gains a failure body: an icon, the reason in the reader's
words, and Try again — or Close, for the two failures nothing can retry.
The unavailable case moves out of a Toast and into the sheet that is
already on screen, so there is one place a Speak outcome is reported
rather than two.

Starting a listen now waits for the previous recognizer to be destroyed
before creating the next. It is destroyed in the flow's `awaitClose`, so
cancelling without joining let a fast Try again create a second one
first — answered with ERROR_RECOGNIZER_BUSY, which used to close the
sheet.

A refused microphone permission is a dismissal rather than a failed
listen: nothing was started, and the rationale dialog has already said
why the microphone is needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye
iOS had the same silent close as Android — `onFailure` ended at
`onSpeechError()`, which dismissed the sheet — and it collapsed every
recognition error into `.noMatch`, so the alert covering permission and
unavailability never fired for the case that actually happens.

The reason now crosses to the shared ViewModel and the sheet renders it,
which retires the separate alert: one place a Speak outcome is reported,
the same as Android. A nil `SFSpeechRecognizer` is told apart from an
unavailable one — the first says iOS has no model for the deck's
language, which the reader can install.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0116NZ92Pe7aGQpqtiRkP4Ye
@jvsena42
jvsena42 merged commit deb8b3f into main Sep 4, 2026
4 checks passed
@jvsena42
jvsena42 deleted the fix/speak-sheet-silent-close branch September 4, 2026 14:54
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