Skip to content

fix: recognition resilience — keep task alive across jumps, retry transient recognizer unavailability#67

Merged
f merged 3 commits into
f:masterfrom
samgutentag:fix/recognition-resilience
Jul 9, 2026
Merged

fix: recognition resilience — keep task alive across jumps, retry transient recognizer unavailability#67
f merged 3 commits into
f:masterfrom
samgutentag:fix/recognition-resilience

Conversation

@samgutentag

Copy link
Copy Markdown
Contributor

Summary

Two resilience fixes for the speech recognition session, both hit during real daily use of word-tracking mode.

1. Jumps restarted the recognition task and dropped the words spoken right after

jumpTo(charOffset:) (word taps) tore down the recognition task and started a fresh one. A fresh SFSpeechRecognitionTask takes up to ~1s to warm, and audio in that window is dropped — which is exactly when the user re-speaks the words they jumped back for. The highlight appeared to stop following after any jump.

Jumps now keep the task alive and record a transcript anchor: matchCharacters ignores everything transcribed before the jump and matches from the new offset, so tracking resumes on the first word with zero dropped audio. The anchor resets whenever a genuinely new transcript starts (soft restart, new session), so the ~55s preemptive restarts are unaffected.

2. Transient recognizer unavailability made the app permanently deaf

beginRecognition and restartTask both gave up when SFSpeechRecognizer.isAvailable was false. That state is usually transient — the recognition service churns briefly after a task cancellation or an audio device config change. Worse, beginRecognition's guard didn't set isListening = false, so nothing surfaced: the audio engine had already been stopped by the preceding cleanup, no retry was scheduled, and the app stayed deaf until relaunch.

Observed live with a USB mic (Elgato Wave): the device renegotiates its sample rate ~100ms after engine start → AVAudioEngineConfigurationChangerestartTask → the cancelled task's error callback schedules a fresh beginRecognition → it lands on the unavailable guard mid-churn and returns. System log showed the engine stop with no restart and no error.

Both guards now retry with the same 0.5s backoff (bounded by the existing maxRetries) that the invalid-format guard a few lines below already uses.

Relation to #64

Independent changes — this touches jumpTo/session lifecycle, #64 touches scroll anchoring and match arbitration. Happy to rebase whichever lands second.

Test plan

  • Builds clean (arm64, Xcode 26.6)
  • Word tracking: tap/jump back mid-read, immediately re-speak — highlight resumes on the first re-spoken word (previously ~1s of dropped words)
  • Reproduced the deaf state via USB-mic config change; with the fix the session recovers within one retry
  • Long reads still work across the ~55s preemptive task restarts

🤖 Generated with Claude Code

samgutentag and others added 3 commits July 7, 2026 21:39
jumpTo tore down the recognition task and started a fresh one, dropping
the audio spoken during the restart window (~1s to warm) — exactly the
words the user re-speaks right after tapping a word to jump, so the
highlight appeared to stop following.

Jumps now keep the task running and record a transcript anchor:
matching ignores everything transcribed before the jump and continues
from the new offset with zero dropped audio. The anchor resets whenever
a new task starts a fresh transcript (soft restart, new session).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
beginRecognition and restartTask both gave up permanently when
SFSpeechRecognizer reported unavailable — a state that is usually
transient (the recognition service churns briefly after a task
cancellation or an audio device config change). beginRecognition's
guard didn't even set isListening = false, so the app went silently
deaf: engine stopped by the preceding cleanup, no retry, no error
surfaced, until a full app relaunch.

Observed live: a USB mic (Elgato Wave) sample-rate renegotiation ~100ms
after engine start triggered restartTask, the cancelled task's error
callback scheduled a fresh beginRecognition, and that hit the
unavailable guard and returned. Both guards now retry with the same
backoff the invalid-format guard already uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Findings from an adversarial self-review of the first two commits:

- restartTask/beginRecognition now clear lastSpokenText along with the
  anchor: a jump taken after a task restart but before its first result
  anchored on the old transcript and froze matching until the next
  ~55s restart.
- The anchor is now the transcript prefix string, trimmed by surviving
  common prefix (with bounded slack) instead of a raw char count, so
  the recognizer revising earlier text no longer swallows post-jump
  speech or leaks the pre-jump transcript.
- Results delivered within 300ms of a jump are ignored — they were
  computed against the pre-jump position.
- jumpTo no longer resets retryCount (tapping words could keep a
  failing availability-retry loop alive forever) and falls back to a
  full restart for far jumps (>500 chars, refreshing contextualStrings
  for the new section) or when the engine died silently.
- A nil SFSpeechRecognizer (unsupported locale) fails fast instead of
  silently retrying a permanent condition; exhausted retries in
  restartTask stop the audio engine instead of leaving the mic hot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@f f merged commit b5c040d into f:master Jul 9, 2026
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.

2 participants