# STT troubleshooting

> Diagnose Cartesia transcription failures: STTFailure cases (offline, serverError, rateLimited, outOfCredits, invalidKey, unknown), notch vs Home card surfacing, graceful collapse on empty or benign disconnect, and STTFailureRoutingTests regression contracts.

- Repository: cartesia-ai/InkIt
- GitHub: https://github.com/cartesia-ai/InkIt
- Human docs: https://grok-wiki.com/public/docs/cartesia-ai-inkit-18975554254b
- Complete Markdown: https://grok-wiki.com/public/docs/cartesia-ai-inkit-18975554254b/llms-full.txt

## Source Files

- `InkIt/CartesiaStreamingClient.swift`
- `InkIt/AppCoordinator.swift`
- `InkItTests/STTFailureRoutingTests.swift`
- `InkIt/SettingsStore.swift`
- `InkIt/NotchHUD.swift`

---

---
title: "STT troubleshooting"
description: "Diagnose Cartesia transcription failures: STTFailure cases (offline, serverError, rateLimited, outOfCredits, invalidKey, unknown), notch vs Home card surfacing, graceful collapse on empty or benign disconnect, and STTFailureRoutingTests regression contracts."
---

InkIt classifies every Cartesia STT session failure through `STTFailure` in `CartesiaStreamingClient`, routes named failures to the notch HUD via `AppCoordinator.handleSTTFailure`, and persists only key and credit problems to the Home banner. Transient blips stay momentary; account problems stay sticky until the next successful dictation.

## Failure taxonomy

`STTFailure` is the single enum that drives both the notch copy (`notchMessage`) and, for two cases, persisted Home state. Classification happens in two paths: Cartesia `{"type":"error"}` events (`status_code` + optional `error_code`) and transport-level errors (HTTP upgrade response or `URLError`).

| Case | Typical signals | Notch message | Home persistence |
|------|-----------------|---------------|------------------|
| `offline` | `URLError`: `.notConnectedToInternet`, `.networkConnectionLost`, `.cannotConnectToHost`, `.cannotFindHost`, `.dnsLookupFailed`, `.dataNotAllowed` | No internet | None |
| `serverError` | HTTP 5xx; `URLError.timedOut` | Server error | None |
| `rateLimited` | HTTP 429; `error_code` `concurrency_limited` | Too many requests | None |
| `outOfCredits` | HTTP 402; `error_code` `quota_exceeded` or `plan_upgrade_required` | Out of credits | `cartesiaOutOfCredits` |
| `invalidKey` | HTTP 401 or 403 | Invalid API key | `cartesiaKeyInvalid` |
| `unknown` | Unclassified 400; POSIX `ENOTCONN`/`EPIPE`/`ECONNRESET`; other unmapped errors | Couldn't transcribe | None |

<ParamField body="error_code" type="string">
Cartesia error-event field. `quota_exceeded` and `plan_upgrade_required` map to `outOfCredits`; `concurrency_limited` maps to `rateLimited`. Checked before HTTP status.
</ParamField>

<ParamField body="status_code" type="int">
HTTP status on Cartesia error events or a failed WebSocket upgrade (`urlSession(_:task:didCompleteWithError:)`). Used when `error_code` does not match a known bucket.
</ParamField>

Rejected WebSocket upgrades (invalid key, rate limit, out of credits before the receive loop starts) are handled in `urlSession(_:task:didCompleteWithError:)` so they are not swallowed silently.

## Where failures surface

InkIt uses two UI channels with different lifetimes.

### Notch HUD (momentary)

When `CartesiaStreamingClient.onError` fires, `AppCoordinator.handleSTTFailure` calls `setError(failure.notchMessage)`, which:

- Sets `DictationState` to `.error(message)`
- Stops audio and cancels the WebSocket (`client.cancel()`)
- Renders `NotchHUD` as `.errorNotice` — `InkIt ⚠ <notchMessage>` with a red warning glyph
- Self-clears to `.idle` after **1.5 s** post-release (`armErrorDismiss`); while the hotkey is held (`isHotkeyHeld`), the error stays visible until release

<Warning>
On the error path, `finishClose` runs with `reportClosed: false` so an empty `onClosed` callback does not reset state to `.idle` and wipe the notch notice before the user reads it.
</Warning>

### Home banner (persistent)

Only `invalidKey` and `outOfCredits` set persisted flags:

<ParamField body="cartesiaKeyInvalid" type="bool">
UserDefaults key `cartesiaKeyInvalid`. Set on `STTFailure.invalidKey`. Cleared when `onClosed` delivers a non-empty transcript.
</ParamField>

<ParamField body="cartesiaOutOfCredits" type="bool">
UserDefaults key `cartesiaOutOfCredits`. Set on `STTFailure.outOfCredits`. Cleared on the next successful transcription.
</ParamField>

`SettingsStore.transcriptionIssue` derives from these flags (suppressed when no Cartesia key is set — that is onboarding, not a fault). When non-nil, Home shows a full-width **Dictation is paused** banner with CTA:

| `ServiceIssue` | Message | CTA action |
|----------------|---------|------------|
| `keyInvalid` | Your Cartesia API key is invalid… | Opens Settings → Dictation pane |
| `outOfCredits` | You're out of Cartesia credits… | Opens `https://play.cartesia.ai/subscription` |

Transient failures (`offline`, `serverError`, `rateLimited`, `unknown` when surfaced) appear only in the notch. They do not set Home flags.

```mermaid
stateDiagram-v2
    [*] --> Classify: error or transport failure
    Classify --> NamedFailure: offline, serverError, rateLimited, outOfCredits, invalidKey
    Classify --> Unknown: unclassified / benign disconnect

    NamedFailure --> SurfaceNotch: onError → setError(notchMessage)
    SurfaceNotch --> PersistHome: invalidKey or outOfCredits only
    PersistHome --> IdleAfterDismiss: armErrorDismiss 1.5s

    Unknown --> HasContent: transcript in hand
    Unknown --> NoContent: empty session
    HasContent --> Deliver: onClosed with words, no error
    NoContent --> Collapse: onClosed empty → idle, no notch error
```

## Graceful collapse

`reportFailureOrCollapse` is the core decision function. Only **named** failures show an error; `.unknown` never does.

**Priority order:**

1. **Named failure** → `onError` + `finishClose(reportClosed: false)`. User sees the notch message.
2. **`.unknown` with transcript content** → `onClosed` delivers words (graceful goodbye after a normal server close or benign POSIX disconnect).
3. **`.unknown` with no content** → silent collapse: `onClosed("")` → `AppCoordinator` sets `.idle` with no error.

**Post-close 500 carve-out:** If `failure == .serverError`, `awaitingClose == true`, and there is no transcript content, the session collapses silently (rapid tap-and-release where the server returns 500 instead of an empty turn). A mid-hold 5xx with no content still surfaces; a post-close 5xx with words in hand still surfaces.

**Empty transcript at coordinator:** When `onClosed` delivers an empty string after trimming, the coordinator resets to `.idle` immediately — no polish, no paste, no error state.

<Note>
A too-short or silent press can make Cartesia reject the session with HTTP 400 (→ `.unknown`, collapse) or, on instant close, HTTP 500 (→ post-close carve-out, collapse). Neither should alarm the user.
</Note>

## Symptom → cause → fix

<Steps>
<Step title="Notch shows No internet">
Check network connectivity. `STTFailure.offline` maps from `URLError` codes that indicate no route to `api.cartesia.ai`. Retry dictation when online. No Home flag is set.
</Step>

<Step title="Notch shows Server error or Too many requests">
Transient Cartesia or network issue. `serverError` covers 5xx and timeouts; `rateLimited` covers 429 and `concurrency_limited`. Wait and retry. If persistent, check Cartesia status. No Home persistence.
</Step>

<Step title="Notch shows Invalid API key and Home shows Dictation is paused">
`cartesiaKeyInvalid` is set. Open Settings → Dictation, re-enter the key (Keychain account `cartesiaAPIKey`). See [Configure Cartesia API key](/configure-cartesia-api-key). Flag clears on the next successful dictation.
</Step>

<Step title="Notch shows Out of credits and Home banner persists">
`cartesiaOutOfCredits` is set. Review billing at Cartesia subscription. Flag clears when a non-empty transcript returns.
</Step>

<Step title="Notch shows Couldn't transcribe">
Named `unknown` failure with no graceful-collapse path — e.g. unclassified 400 **after** partial content arrived. Check `~/Library/Logs/InkIt-debug.log` with `debugLoggingEnabled` on for `STT error event:` and `reportFailureOrCollapse:` lines.
</Step>

<Step title="Release hotkey and nothing happens (no error)">
Expected for silent / too-short press: `.unknown` with no content, or post-close 500 carve-out. `onClosed` fires with `""` and state returns to `.idle`. Not a bug unless speech was clearly captured (check live transcript during hold).
</Step>
</Steps>

## Debug trace

Enable **Debug logging** in Settings (`debugLoggingEnabled` → `~/Library/Logs/InkIt-debug.log`). Relevant log prefixes:

| Log pattern | Meaning |
|-------------|---------|
| `STT error event: status=… code=…` | Cartesia `{"type":"error"}` before classification |
| `reportFailureOrCollapse: failure=… decision=…` | Collapse vs surface decision |
| `receive failure: domain=… code=… http=…` | Transport error in receive loop |
| `WS close: reason=… elapsed=…` | `SessionMetrics` close reason (`CloseReason`) |
| `setError: …` | Notch error message applied |

`SessionMetrics` stores up to 500 `CloseMetric` entries in UserDefaults (`CartesiaCloseMetrics`). Close reasons include `finalTurnReceived`, `serverClosed`, `graceTimerExpired`, `serverError`, `silentNoAudio`, `receiveFailed`, and `externalCancel`.

## Regression contracts (`STTFailureRoutingTests`)

The `STTFailureRouting` test target locks behavior that must not regress:

| Test | Contract |
|------|----------|
| `testUnknownWithContentDeliversTranscriptAndNeverErrors` | Benign disconnect with words → `onClosed` delivers transcript, `onError` nil |
| `testUnknownWithNoContentCollapsesSilently` | Silent press → `onClosed("")`, no `onError` |
| `testNamedFailureSurfacesError` | All named cases surface via `onError`; `onClosed` must **not** fire (would wipe notice) |
| `testServerErrorAfterCloseWithNoContentCollapsesSilently` | Post-close 500 on empty session → silent collapse |
| `testServerErrorMidHoldWithNoContentStillSurfaces` | 5xx while still holding → `serverError` reaches user |
| `testServerErrorAfterCloseWithContentStillSurfaces` | Post-close 500 with partial transcript → still surfaces |
| `testBenignSocketDisconnectsClassifyAsUnknown` | POSIX 57/32/54 → `.unknown` (forgiveness path) |
| `testRealTransportErrorsStayNamed` | Real `URLError` offline/timeout stay named |

Run locally:

```sh
xcodegen generate
xcodebuild -project InkIt.xcodeproj -scheme InkIt -configuration Debug \
  -destination 'platform=macOS' -only-testing:InkItTests/STTFailureRoutingTests test
```

<Check>
CI runs the full `InkItTests` target including `STTFailureRoutingTests` on every PR.
</Check>

## End-to-end failure flow

```mermaid
sequenceDiagram
    participant User
    participant Coordinator as AppCoordinator
    participant Client as CartesiaStreamingClient
    participant Cartesia as Cartesia STT API
    participant Notch as NotchHUD
    participant Home as Home banner

    User->>Coordinator: Release hotkey
    Coordinator->>Client: finalizeAndClose()
    Cartesia-->>Client: error event or disconnect
    Client->>Client: reportFailureOrCollapse(STTFailure)

    alt Named failure
        Client->>Coordinator: onError(failure)
        Coordinator->>Coordinator: handleSTTFailure
        Coordinator->>Home: Set cartesiaKeyInvalid / cartesiaOutOfCredits if applicable
        Coordinator->>Notch: setError(notchMessage)
        Note over Client: finishClose(reportClosed: false)
    else Unknown, no content
        Client->>Coordinator: onClosed("")
        Coordinator->>Coordinator: state = .idle
    else Unknown, has content
        Client->>Coordinator: onClosed(transcript)
        Coordinator->>Coordinator: polish → paste pipeline
        Coordinator->>Home: Clear cartesiaKeyInvalid / cartesiaOutOfCredits
    end
```

## Related pages

<CardGroup>
<Card title="Cartesia STT reference" href="/cartesia-stt-reference">
WebSocket contract, server events, and the full `STTFailure` classification table with `notchMessage` strings.
</Card>
<Card title="Configure Cartesia API key" href="/configure-cartesia-api-key">
Keychain storage, validation, and how `cartesiaKeyInvalid` / `cartesiaOutOfCredits` drive Home cards.
</Card>
<Card title="Dictation state machine" href="/dictation-state-machine">
`DictationState` transitions including `.error`, `.finalizing`, and empty-transcript collapse to `.idle`.
</Card>
<Card title="Settings reference" href="/settings-reference">
`transcriptionIssue`, persisted service-issue flags, and `debugLoggingEnabled`.
</Card>
<Card title="Testing and CI" href="/testing-and-ci">
Running `STTFailureRoutingTests` and the full CI test workflow.
</Card>
<Card title="Runtime troubleshooting" href="/runtime-troubleshooting">
Non-API issues: mic delay, duplicate app instances, debug log location, notch on non-notch displays.
</Card>
</CardGroup>
