Skip to content

fix(dictation): every dictation ends with a reason code, and phases have deadlines (#243) - #1340

Merged
Juliusolsson05 merged 13 commits into
mainfrom
fix/dictation-reason-deadlines
Sep 27, 2026
Merged

Juliusolsson05 merged 13 commits into
mainfrom
fix/dictation-reason-deadlines

Conversation

@Juliusolsson05

@Juliusolsson05 Juliusolsson05 commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

Refs #243. It delivers reason codes and deadlines for connect, first audio, the drain before stop, final, and terminal insertion. It stays Refs: the issue asks that every session end with a coded outcome, and application quit (for a renderer-side session) and #1299's never-released recording still do not.

Problem (from the owner's 148 recorded dictation-debug journals)

  • 56 of 148 sessions ended with no terminal outcome row at all:
    • 25 told the user "No speech detected" when the microphone had simply opened late. The key was held 1–3.8 s, but getUserMedia took most of that (up to 3,836 ms), so the recorder ran 2–611 ms and captured nothing.
    • 13 were taps discarded without a trace.
    • 7 were renderer-only microphone errors.
  • All 24 recorded errors read "Deepgram transcription failed" or "fetch failed". The provider had actually answered HTTP 400 (bad audio) in 23, and the other was a DNS failure. The renderer showed main's text verbatim.
  • No phase had a deadline; stream-start awaits an unbounded keychain read.

Change

  • One shared code union, DictationOutcomeReason (src/shared/types/dictation.ts), grouped by phase: no-speech.*, cancelled.*, mic.*, recorder.*, config.*, provider.*, network, connect.timeout, final.timeout, delivery.*, unknown. classifyProviderFailure(status) maps 400/401/403/408/429/5xx/none.

  • Exactly one OUTCOME row per session, with code. Main writes it when the provider decided; the renderer writes it for everything it decides (taps, hidden panes, microphone errors, late mic, deadlines, unmount). Main's stream-cancel row is now an IPC row, so a cancel is not a second outcome. Delivery that did not happen after a success is a TRANSCRIPT delivery:skipped row with a code.

  • The user reads the code's sentence, never provider, IPC or browser text (q22/q39). dictationReasonMessage is a fixed, bounded table. Main's error answers gain reason; message stays for diagnostics only. Microphone errors keep dictationAudioInputError's curated sentences, which name the chosen device. Its label is capped at 80 characters, and an unrecognized error now gets a fixed sentence instead of the error's own text.

  • Single terminal owner. A deadline or recorder error that fails a recording while stop() is awaiting owns the ending, and stop() stands down. Unmounting while the microphone is still opening writes cancelled.unmount; the late stream is closed and no recorder is built. A transcription aborted by quit journals cancelled.shutdown.

  • mic.opened-late replaces the false "No speech detected" when nothing reached main and the microphone took 500 ms or more of the press: "Nothing was recorded — the microphone took 3.3 s to open. Hold the key until the indicator shows listening." (wording UNCONFIRMED).

  • Deadlines (DICTATION_DEADLINES_MS), set against the recorded maxima:

    Phase Deadline Recorded max On expiry
    connect 10 s 55 ms connect.timeout: ends the recording now
    first audio 2 s 626 ms recorder.no-audio: a silent mic still encodes silence, so only a capture producing nothing trips it
    drain (release to every chunk pushed) 10 s local IPC final.timeout: ends the recording and cancels main's half (a hung push stranded "stopping")
    final 30 s (2.1× the max) 14.3 s final.timeout: aborts through the controller quit already uses
    terminal insertion 5 s not journaled before delivery.failed
  • Insertion. The composer sink is a synchronous draft write (recorded 3 / 11 / 21 ms) that cannot hang. A terminal sink is an async session write that can answer false or reject. TRANSCRIPT committed is now written only once it answers true; false, rejection, or no answer within 5 s is a delivery.failed row and a sentence. (The first head wrongly said all insertion was synchronous.)

    • What the sentence says about History depends on what is known (q67, q71). Main's History write is not awaited and can fail, so main mints the row id first and returns it as historyId. The renderer then reads History (serialised behind the append) and looks for that id, not for matching text:

      • found: "It is in Settings → Dictation → History";
      • read, but not found: "could not be saved to History either";
      • the read failed: "Check Settings → Dictation → History; it could not be confirmed there."

      An older dictation with the same words can no longer make a failed append look saved.

    • A failure that lands while a newer dictation is starting or recording names "the previous transcript" and leaves the newer attempt's overlay alone.

  • Stream ownership. The stream id main returns is kept from the moment it answers, and every exit (stop, failure, unmount, short press) releases main's session through one helper. A drain that hangs before the id is published still cancels main's session. Unmounting during device enumeration asks for no microphone.

  • The recorded 408 is a timeout, not no-speech. It is excluded from the short-clip downgrade, so it is now provider.timeout.

Plan: docs/plans/2026-09-26-dictation-reason-codes.md.

Tests (31 new: 11 red on main; 8 red on the round-1 head; 5 red on the round-2 head; 5 red on the q67 head; 4 mutation-pinned)

A fixture of six real recorded sessions' lifecycle rows: five shapes plus the one recorded HTTP 408, testing/fixtures/dictation/lifecycle-sessions-2026-09.json. It has no audio samples, chunks or dictated text; device ids are zeroed, and one personal name and the Deepgram request id are replaced at the same length.

  • Main (dictationReasons.test.ts):

    • the recorded 400, 408 and network failures replay to provider.bad-audio / provider.timeout / network, with matching OUTCOME codes and sentences;
    • a transcription aborted by quit journals one cancelled.shutdown row;
    • a missing key gives config.missing-api-key;
    • a hung transcription is aborted at 30 s as final.timeout;
    • a cancel writes no OUTCOME row.
  • Renderer (useComposerDictation.renderer.test.tsx), driven with recorded timings:

    • the 49 ms tap during a 190 ms mic open gives cancelled.short-press and no message;
    • the 3,280 ms mic open gives mic.opened-late and "took 3.3 s", never "No speech detected";
    • the unplugged-EarPods OverconstrainedError gives mic.unavailable;
    • no audio for 2 s gives recorder.no-audio;
    • a stream-start that never answers gives connect.timeout;
    • a provider error shows the code's sentence, not main's text;
    • round 1:
      • an unknown microphone error never shows its own text;
      • unmount during mic open closes the late stream and builds no recorder;
      • unmount mid-recording writes cancelled.unmount;
      • a connect timeout during stop gives one sentence and one row;
      • a stop IPC that throws writes unknown;
      • a hung chunk push ends as final.timeout;
      • a terminal paste answering false or rejecting is delivery.failed, never committed;
    • round 2 and q67:
      • a hung queued drain cancels main's stream;
      • unmount during enumeration requests no microphone;
      • a late paste failure is named as the previous transcript;
      • a successful terminal paste writes bracketed paste and committed;
    • q71 (all red on the q67 head):
      • History holds this append's row: saved;
      • History holds only an older identical transcript: absent;
      • the History read rejects: the unknown hedge;
      • main returns the id it appended;
      • the store keeps a caller id.

    The recorded-timing tests (the 49 ms tap and the 3,280 ms mic open) fake only Date, so they are exact under load.

src/main/ipc/, src/main/dictation*, tile-tree/, features/voice-dictation: 401/401 in one batch at deafa064; the dictation suites are 94/94 at 154bcd68. npx tsc -b is clean.

Out of scope / residuals

  • Review (a, b and c; three rounds including verification; all MERGE-READY at a543abd7, plus the test-only 6620b459). Residuals:
    • the stop-catch single-owner guard (a recorder error while a push hangs, then the drain deadline) has no committed test;
    • removing clearTimeout(finalDeadline), the no-speech branch's hardcoded code, or cleanup()'s timer clears changes nothing observable today;
    • a paste failure landing after its pane unmounted can still paint the global overlay (predates this PR).
  • Dictation can keep the microphone open for hours: no max duration, no silence stop (33 h recorded) #1299 (owner-only): maximum recording duration, silence stop, sleep/blur release. Microphone prewarm, which would reduce mic.opened-late, is a separate change.
  • The deadline values and the mic.opened-late wording are UNCONFIRMED defaults, tuned on one machine's recordings. The terminal-insertion bound (5 s) is a judgment: terminal delivery was never journaled before this. Real microphone and Deepgram behaviour cannot be run here.
  • Of the 25 recorded zero-chunk stops, 14 now get mic.opened-late; 11 (mic open 125–203 ms) are still "No speech detected", which is right for them.
  • A session that ends by application quit writes no renderer OUTCOME row. Main writes cancelled.shutdown only if a transcription was in flight.

🤖 Generated with Claude Code

Juliusolsson05 and others added 2 commits September 26, 2026 18:28
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e has a deadline (#243)

56 of 148 recorded sessions ended with no outcome row, 25 of them telling
the user "No speech detected" after the microphone took seconds to open,
and every provider error read "Deepgram transcription failed". Outcomes
now carry one shared code, the user reads the code's curated sentence,
and connect (10 s), first-audio (2 s) and final (30 s) have deadlines
taken from the recorded maxima.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Juliusolsson05 Juliusolsson05 added type:bug Something works wrong class:C3-silent-failure The app knows it failed and does not say sev:P2 Real bug with a workaround labels Sep 27, 2026
Juliusolsson05 and others added 8 commits September 26, 2026 19:36
…nd terminal delivery (#1340 review)

An unknown getUserMedia error's own text reached the user; a microphone
opening after unmount started an ownerless recorder; a connect timeout
during stop produced two endings; shutdown dropped its OUTCOME; the
recorded 408 was downgraded to no-speech; terminal paste results were
never awaited; a hung chunk push stranded stopping. Each is fixed with a
fail-first case on the recorded sessions (the 408 added to the fixture).

Refs #243

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A fixed 250 ms delay raced the hook's 180 ms accidental-tap timer under
load, ending the recording as too-short before main was ever called.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…unt, attribute late paste failures (#1340 round 2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…en it holds the transcript (#1340 q67)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t cancel and the too-short branch (#1340 review b)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…his append's id (#1340 steering q71)

Main mints the History row id before its un-awaited append and returns it
as historyId. After a failed terminal paste the renderer looks for that id:
found is 'saved', read-but-missing is 'absent', and a failed read is
'unknown' with hedged copy. Matching text let an older identical dictation
claim a failed append as saved, and a failed read was reported as a failed
save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Disposition: steering q71 (fixed in afa19c66; main merged in 03f6ccdc).

  • Unreadable History: a failed read is now unknown, not "could not be saved". The copy is bounded: "Check Settings → Dictation → History; it could not be confirmed there."
  • Older identical transcript: fixed by identity.
    • Main mints the History row id before its un-awaited append and returns it as historyId.
    • The renderer looks for that id only, so a matching older text can no longer claim a failed append as saved.
    • absent is claimed only when History was read and the id is missing. The read is serialised behind the append because appendEntry enqueues synchronously before the stop result is returned.
  • Tests (all 5 red on the q67 head 2abc991e):
    • renderer: this row present → saved; only an older identical transcript → absent; the read rejects → unknown;
    • main: the returned id equals the appended id;
    • store: a caller id is stored verbatim.
  • Gate: tsc is clean, and the dictation suites pass 82/82.
  • The owner's deadline and wording disposition remains separate and open. The PR stays out of batches until a/b/c verify.

…tation counts as newer (#1340 verification a/c)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Disposition: verification pass (a and c at 03f6ccdc, FIX-BEFORE-MERGE; b at 2abc991e, MERGE-READY). Fixed in a543abd7.

  • a (major): unmount or a short press during a stalled queued drain left main's stream alive until quit. Those two exits still used the old id-or-start-promise cancel, which a hung push never settles. All four exits now go through one releaseMainStream(), which uses id ?? startedStreamId. Tests: unmount and short press, both red before.
  • a and c (survivor): the !previous overlay guard was not asserted. The late-failure test now asserts that the overlay stays clear; removing the guard fails it.
  • a and c (survivor): read ordering was unpinned. Their probe is committed as a store test: a read issued before an un-awaited append settles sees the row.
  • c (minor residual): a late paste failure while the next dictation was still starting was named "the transcript" and painted the new attempt's overlay. activeRef is published only after the mic opens, so previous now also counts any non-idle lifecycle. That status cannot be stale for this dictation, because stop() set 'idle' synchronously before the callback. Test: red before.
  • b, residual: the stop-catch single-owner guard has no committed test. It only matters when a recorder error, a hung push and the drain deadline coincide.
  • Gate: tsc clean; dictation suites 93/93. a, b and c now verify a543abd7; the PR stays out of batches until then.

Juliusolsson05 and others added 2 commits September 26, 2026 23:06
…ication 2 a/c survivor)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Disposition, verification 2: a, b and c are all MERGE-READY at a543abd7.

  • Pinned (a and c): deleting releaseMainStream from failRecording survived every committed test. A test-only commit, 6620b459, now covers it: a recorder error after the stream has started must cancel stream-1. The test fails when the call is removed.
  • Residuals (b), stated rather than another round:
    • the stop-catch single-owner guard has no test; it matters only when a recorder error, a hung push and the drain deadline coincide;
    • clearTimeout(finalDeadline) is behaviour-equivalent: its removal leaks a 30 s no-op timer;
    • the no-speech branch hardcoding its code is benign while all three no-speech sentences are identical;
    • cleanup()'s redundant timer clears are equivalent backstops.
  • Declined (b): a paste failure that lands after its pane unmounted can still paint the global overlay. This predates the PR; the message is honest.
  • Main is merged in 154bcd68. tsc is clean and the dictation suites pass 94/94.
  • Owner: the deadline values and wording remain the owner's disposition.

@Juliusolsson05

Copy link
Copy Markdown
Owner Author

OWNER-APPROVED: the dictation deadlines and reason wording are accepted as written.
Owner, 2026-09-27T07:33:05Z: "just make these for me, just keep the fixtures in, they are innocent enough. do not delete stuff often" (manager applied it).

@Juliusolsson05
Juliusolsson05 merged commit 8c9d1d4 into main Sep 27, 2026
2 checks passed
Juliusolsson05 added a commit that referenced this pull request Sep 27, 2026
@Juliusolsson05
Juliusolsson05 deleted the fix/dictation-reason-deadlines branch September 27, 2026 08:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

class:C3-silent-failure The app knows it failed and does not say sev:P2 Real bug with a workaround type:bug Something works wrong

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant