diff --git a/.claude/skills/project-guardrails/SKILL.md b/.claude/skills/project-guardrails/SKILL.md index 8dca901..739ca08 100644 --- a/.claude/skills/project-guardrails/SKILL.md +++ b/.claude/skills/project-guardrails/SKILL.md @@ -23,9 +23,13 @@ one, stop and ask the user first. This is the fast "don't" list; AGENTS.md's - **No streaming STT.** The AssemblyAI Sync API returns the full transcript in one response. Overlay goes "Transcribing…" → full text. -- **No separate LLM cleanup pass.** Cleanup rides in the Sync STT request's - `config.prompt` (`TranscriptionPrompt`). No LLM Gateway client, no - `StylerProtocol`, no post-transcription styling stage. +- **No separate/client-side LLM cleanup pass.** Cleanup is the dictation API's + server-side rewrite, requested by the `llm` block on the same `/transcribe` + call — one request, no second round-trip. No LLM Gateway client, no + `StylerProtocol`, no post-transcription styling stage. The block's + `instruction` is now user-editable (`CleanupPromptStore`, sent as + `llm.instruction`; blank = the service default) and requested per-press by the + cleaned trigger, but it is still one server-side request. - **No local models / model downloads.** Transcription is a remote AssemblyAI call. No on-device ASR/LLM, no model cache, no download UI. - Don't reintroduce a "remove filler words (um, uh, like)" directive in the @@ -45,9 +49,10 @@ one, stop and ask the user first. This is the fast "don't" list; AGENTS.md's menu bar, so nothing may depend on it being visible. A menu-bar-_only_ variant (no Dock icon) was tried and reverted twice for that reason — don't drop the Dock icon or add `LSUIElement`. -- The dictation trigger is a **single lone modifier** (right ⌘ default), home- - grown via `CGEventTap` + `DictationKeyGate`. No `KeyboardShortcuts` package, no - key+modifier chord. +- The dictation trigger is **two lone modifiers** — a cleaned key (right ⌘ + default) and a raw key (right ⌥ default) — home-grown via `CGEventTap` + + `DictationKeyGate` (routed by `DualTriggerRouter`). Two lone-modifier keys are + fine; no `KeyboardShortcuts` package, no key+modifier chord. - **Updates are download-only** — check → open the DMG in the browser → the user installs it. The `mxcl/AppUpdater` dependency and its in-place self-updater were removed; don't reintroduce a self-replacing install path, a timer-driven diff --git a/AGENTS.md b/AGENTS.md index 8345b99..edf4195 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,12 +6,14 @@ the Claude-Code-specific tooling under `.claude/` (hooks, skills, subagents). ## Start here Blurt is a macOS dictation app powered by [AssemblyAI](https://www.assemblyai.com). Tap or hold a -trigger key, speak, and polished text is pasted into the focused app. Transcription is **one remote -AssemblyAI dictation API call**: a per-utterance `prompt` (a transcription directive plus contextual -priming built from the focused app/window/field and the user's key terms) rides along with the -request, and the same request asks the service for its server-side LLM cleanup rewrite -(`config.llm`), so the text that comes back is already polished. The user supplies their own API -key. +trigger key, speak, and text is pasted into the focused app. There are **two** trigger keys: a +_cleaned_ key that pastes the server-side LLM cleanup rewrite and a _raw_ key that pastes the +verbatim transcript. Transcription is **one remote AssemblyAI dictation API call**: a per-utterance +`prompt` (a transcription directive plus contextual priming built from the focused app/window/field +and the user's key terms) rides along with the request, and — only for a cleaned dictation — the +same request asks the service for its server-side LLM cleanup rewrite (`config.llm`, carrying the +user's editable instruction), so the text that comes back is already polished. The user supplies +their own API key. Four reflexes before you touch anything: @@ -49,7 +51,8 @@ Sources/BlurtEngine/ the engine (dependency-free Swift package) Audio/ MicCapture (+meter), SoundPack/Catalog/Store — record cues Config/ Keychain-backed API key, key terms, developer mode, PersistedSettings FocusCapture/ Accessibility reads of the frontmost app / focused field - Hotkey/ TriggerKey(+Store), DictationKeyGate, DictationKeyRouter + Hotkey/ TriggerKey(+Store), RawTriggerKeyStore, DictationMode/TriggerPair, + DictationKeyGate, DualTriggerRouter Injection/ KeyInjector (clipboard paste), SystemClipboard Permissions/ PermissionsChecker (mic + Accessibility) Pipeline/ DictationSession (actor) + phases, UI projections, geometry, log @@ -159,23 +162,23 @@ In Claude Code on the web, a `SessionStart` hook installs the portable linters a Each was tried the other way and reverted. If a task seems to require one, stop and ask first. (`.claude/skills/project-guardrails` is the compressed version of this list.) -| Don't | Because | -| --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Add an external SPM dependency to the engine | Dependency-free by rule (biggest supply-chain risk); a `check.sh` guard fails on `.package(` in `Package.swift` or a `url:`/`github:` package in `project.yml`. Extend `BlurtEngine` instead. | -| Use `AVAudioEngine` / `installTap` for capture | A long-lived engine bound its input graph to one device and went stale on a mic↔built-in switch — `-10868` (`kAudioUnitErr_FormatNotSupported`) or all-zero buffers. `MicCapture` uses a fresh `AVAudioRecorder` per session. | -| Add streaming STT | The dictation API returns the full (already rewritten) text in one response; the overlay shows "Transcribing…" then the full text. | -| Add a client-side LLM cleanup pass | Cleanup is the dictation API's server-side rewrite, requested by the `llm` block on the same `/transcribe` call. No LLM Gateway client, no `StylerProtocol`, no styling stage, no second request — transcription steering belongs in `TranscriptionPrompt`. | -| Add local models or model downloads | Transcription is a remote AssemblyAI call: no on-device ASR/LLM, no model cache, no download UI. | -| Pin the prompt to English | Hurt non-English transcription; language is left to the model's own detection. | -| Add a "remove filler words (um, uh, like)" clause | Not in the STT model's trained instruction set — a no-op, deliberately dropped; disfluency removal is the server-side LLM rewrite's job. | -| Add a keystroke-typing paste path or a length threshold | Injection is **always** clipboard paste (save → write → ⌘V → settle → restore), with the copied-to-clipboard degradation when the target is lost. | -| Add `LSUIElement` or a menu-bar-**only** mode | Blurt is a Dock app first. The `MenuBarExtra` status item is convenience layered on the Dock icon; the notch can hide a status item, so nothing may depend on it. A menu-bar-only variant was reverted twice. | -| Add a `KeyboardShortcuts` package or a key+modifier chord | The trigger is a single lone modifier, home-grown (`CGEventTap` + `DictationKeyGate`), and swallows nothing. | -| Add a self-replacing install or background auto-updater | Updates are download-only; `mxcl/AppUpdater` and its in-place updater were removed. The once-a-day launch _check_ (`AutomaticUpdateCheck`) is fine; installing for the user, or polling, is not. Extend `UpdateCheckModel`. | -| Hand-edit `Blurt.xcodeproj/project.pbxproj` | Generated from `project.yml`; `check.sh`'s drift check fails on any manual edit (a Claude PreToolUse hook also blocks it). | -| Redirect the post-build install away from `/Applications` | TCC won't register apps in DerivedData/`/tmp`, so permission toggles never appear. | -| Touch the real Keychain in tests | `APIKeyStore` is the production item — a test that writes it triggers Keychain prompts and corrupts the real item's ACL. Use an isolated service (see `KeychainStoreTests`) or `InMemoryAPIKeyStore`. | -| Add backwards-compat shims for removed types | Deleted types stay deleted — no deprecated re-exports. | +| Don't | Because | +| --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Add an external SPM dependency to the engine | Dependency-free by rule (biggest supply-chain risk); a `check.sh` guard fails on `.package(` in `Package.swift` or a `url:`/`github:` package in `project.yml`. Extend `BlurtEngine` instead. | +| Use `AVAudioEngine` / `installTap` for capture | A long-lived engine bound its input graph to one device and went stale on a mic↔built-in switch — `-10868` (`kAudioUnitErr_FormatNotSupported`) or all-zero buffers. `MicCapture` uses a fresh `AVAudioRecorder` per session. | +| Add streaming STT | The dictation API returns the full (already rewritten) text in one response; the overlay shows "Transcribing…" then the full text. | +| Add a client-side LLM cleanup pass | Cleanup is the dictation API's server-side rewrite, requested by the `llm` block on the same `/transcribe` call. No LLM Gateway client, no `StylerProtocol`, no styling stage, no second request. The block's `instruction` is now user-editable (`CleanupPromptStore`, sent as `llm.instruction`; blank = the service default) and requested per-press by the _cleaned_ trigger — but it is still one server-side request, never a client-side LLM. | +| Add local models or model downloads | Transcription is a remote AssemblyAI call: no on-device ASR/LLM, no model cache, no download UI. | +| Pin the prompt to English | Hurt non-English transcription; language is left to the model's own detection. | +| Add a "remove filler words (um, uh, like)" clause | Not in the STT model's trained instruction set — a no-op, deliberately dropped; disfluency removal is the server-side LLM rewrite's job. | +| Add a keystroke-typing paste path or a length threshold | Injection is **always** clipboard paste (save → write → ⌘V → settle → restore), with the copied-to-clipboard degradation when the target is lost. | +| Add `LSUIElement` or a menu-bar-**only** mode | Blurt is a Dock app first. The `MenuBarExtra` status item is convenience layered on the Dock icon; the notch can hide a status item, so nothing may depend on it. A menu-bar-only variant was reverted twice. | +| Add a `KeyboardShortcuts` package or a key+modifier chord | The triggers are **two lone modifiers** (raw + cleaned), home-grown (`CGEventTap` + `DictationKeyGate` via `DualTriggerRouter`), and swallow nothing. Two lone-modifier keys are supported; chords and the `KeyboardShortcuts` package are still out. | +| Add a self-replacing install or background auto-updater | Updates are download-only; `mxcl/AppUpdater` and its in-place updater were removed. The once-a-day launch _check_ (`AutomaticUpdateCheck`) is fine; installing for the user, or polling, is not. Extend `UpdateCheckModel`. | +| Hand-edit `Blurt.xcodeproj/project.pbxproj` | Generated from `project.yml`; `check.sh`'s drift check fails on any manual edit (a Claude PreToolUse hook also blocks it). | +| Redirect the post-build install away from `/Applications` | TCC won't register apps in DerivedData/`/tmp`, so permission toggles never appear. | +| Touch the real Keychain in tests | `APIKeyStore` is the production item — a test that writes it triggers Keychain prompts and corrupts the real item's ACL. Use an isolated service (see `KeychainStoreTests`) or `InMemoryAPIKeyStore`. | +| Add backwards-compat shims for removed types | Deleted types stay deleted — no deprecated re-exports. | Release-side invariants (hardened runtime and a secure timestamp on every nested mach-o and embedded framework, or notarization rejects the build; roll-forward-only for a bad release) live in @@ -242,22 +245,24 @@ the seam they inject. Implements `TranscriberProtocol` against AssemblyAI's **dictation** API: a single `POST https://dictation.assemblyai.com/transcribe` with the captured audio as a raw S16LE PCM blob -in the `audio` multipart part plus a JSON `config` part (`sample_rate`, `channels`, `prompt`, and an -empty `llm` block). No model header — the service pins the STT model server-side. The `prompt` -(built per utterance by `TranscriptionPrompt`) steers _transcription_; the `llm` block asks the -service to run its default LLM cleanup rewrite (remove disfluencies, fix punctuation) over the -verbatim transcript, all inside the same request. The block rides along while **enhanced -transcripts** are enabled (`EnhancedTranscriptsStore`, on by default, read per request via the -transcriber's injected `enhancedTranscripts` closure); with the setting off the config omits `llm` -entirely, the service skips the rewrite, and the verbatim transcript is pasted as spoken. The -response carries both `text` (verbatim) and +in the `audio` multipart part plus a JSON `config` part (`sample_rate`, `channels`, `prompt`, and — +for a cleaned dictation — an `llm` block). No model header — the service pins the STT model +server-side. The `prompt` (built per utterance by `TranscriptionPrompt`) steers _transcription_; the +`llm` block asks the service to run its LLM cleanup rewrite (remove disfluencies, fix punctuation) +over the verbatim transcript, all inside the same request. Whether the block rides along is decided +**per press** by `transcribe`'s `cleanup` flag (from the `DictationMode` the trigger key selected): +the _cleaned_ key includes it, the _raw_ key omits it entirely, so the service skips the rewrite and +the verbatim transcript is pasted as spoken. When included, the block carries the user's editable +cleanup instruction (`CleanupPromptStore`, read per request via the transcriber's injected +`cleanupInstruction` closure) as `llm.instruction`; a blank instruction encodes an empty block +(`{}`) and selects the service's own default rewrite. The response carries both `text` (verbatim) and `llm_response` (the rewrite); the transcriber returns the rewrite and falls back to `text` when `llm_response` is null — the rewrite is best-effort (5 s server-side budget), so a rewrite failure (`llm_error`) is a logged degradation, never a user-facing error. The finished text arrives in the response body — no `/v2/upload`, no job submission, no polling. -Truly synchronous: `transcribe(pcm:sampleRate:context:)` is a single `async throws -> String` -returning the whole polished text at once (no streaming, no deltas). The underlying sync STT model +Truly synchronous: `transcribe(pcm:sampleRate:context:cleanup:)` is a single `async throws -> String` +returning the whole text at once (no streaming, no deltas). The underlying sync STT model handles audio from ~80 ms up to 120 s (server-side ~30 s inference deadline); those limits live in `SyncSTTLimits` and back `DictationSession`'s auto-release timeout, so a held hotkey stops before the cap. @@ -321,7 +326,8 @@ process" (a browser hosts many unrelated tabs under one PID). ### `AppCoordinator` — `App/Blurt/Blurt/AppCoordinator.swift` The only place the engine is composed for the real app. It builds the concrete instances and owns a -`DictationKeyTap` whose `onStart` → `session.submit(.press)`, `onStop` → `submit(.release)`, +`DictationKeyTap` whose `onStart` → `session.submit(.press(mode))` (the mode the triggering key +selected), `onStop` → `submit(.release)`, `onCancel` → `submit(.cancel)`, and `onRecordingDiscarded` → `submit(.cancelRecording)`, then observes `session.phaseStream()` to drive the overlay. @@ -336,15 +342,22 @@ user picks a different trigger key. ## Hotkey -The dictation trigger is a **single lone modifier key** (tap-to-toggle or hold-to-talk), implemented -in-house. Four pieces, three of them pure engine logic: +The dictation trigger is **two lone modifier keys** (each tap-to-toggle or hold-to-talk) — a +_cleaned_ key (LLM rewrite) and a _raw_ key (verbatim) sharing one gate, so only one dictation runs +at a time and the key that started it picks the `DictationMode`. Implemented in-house: +- **`DictationMode`** (`Hotkey/DictationMode.swift`) — `raw` / `cleaned`; `cleansUp` is what the + transcriber reads to decide the `llm` block's presence. It rides from the triggering key through + `DictationSession` into the request, so the two keys can't disagree with what they ask for. - **`TriggerKey`** (`Hotkey/TriggerKey.swift`) — enum of the curated lone momentary modifiers usable - as the trigger (right ⌘, right ⌥, `fn`), `rawValue` = the macOS virtual keycode, plus `label` + as a trigger (right ⌘, right ⌥, `fn`), `rawValue` = the macOS virtual keycode, plus `label` ("right ⌘") and the device-modifier masks the event source needs. Right-side modifiers are chosen because a solo press rarely collides with app shortcuts. -- **`TriggerKeyStore`** — persists the chosen keycode in `UserDefaults` (`BlurtTriggerKeyCode`), - defaulting to **right ⌘**. +- **`TriggerKeyStore`** / **`RawTriggerKeyStore`** — persist the two chosen keycodes in `UserDefaults` + (`BlurtTriggerKeyCode` for the cleaned key, defaulting to **right ⌘**; `BlurtRawTriggerKeyCode` for + the raw key, defaulting to **right ⌥**), so the two start out distinct. +- **`DictationTriggerPair`** — a pure value type holding both keys; `assigning(_:to:)` swaps on a + collision so the two keys stay distinct, the one rule the Settings pickers apply. - **`DictationKeyGate`** — pure, clock-free state machine (`idle`/`armed`/`latched`) turning modifier-down/up and other-key-down into `start`/`stop`/`cancel`/`none`. Recording starts the instant the modifier goes down; on key-up a release ≥ `holdThreshold` (default 1 s) is a **hold** @@ -352,25 +365,28 @@ in-house. Four pieces, three of them pure engine logic: (modifier + another key, e.g. ⌘C) from idle cancels the fresh capture; over a latched recording it passes through as a normal shortcut. Callers pass monotonic timestamps, so every decision is deterministic and unit-tested. -- **`DictationKeyRouter`** — the event-routing layer over the gate: only the bound keycode's flag - changes drive the modifier, and only genuine down/up **edges** reach the gate (`flagsChanged` - deliveries re-report the bit whether or not it changed, so a repeat must not double-fire). - `reset()`/`rebind(triggerKeyCode:)` report whether they discarded a live recording the host must - cancel upstream. +- **`DualTriggerRouter`** — the event-routing layer over one gate for both keys: only a bound + keycode's flag changes drive the modifier, only genuine down/up **edges** reach the gate + (`flagsChanged` deliveries re-report the bit whether or not it changed, so a repeat must not + double-fire), and the first key to open the idle gate _owns_ it — the other key's flag changes are + ignored until the gate goes idle, so no chord across the two. Its `Outcome` reports the gate action + plus, on `.start`, which mode owns the session. `reset()`/`rebind(rawKeyCode:cleanedKeyCode:)` + report whether they discarded a live recording the host must cancel upstream. The app side, **`DictationKeyTap`** (`App/Blurt/Blurt/Hotkey/DictationKeyTap.swift`), reduces each -`CGEventTap` delivery (watching `flagsChanged` for the bound modifier and `keyDown` for any other -key) to a `DictationKeyRouter.Event` and owns the tap lifecycle. `AppCoordinator` calls its -`syncAfterTerminalPhase()` on every terminal phase: a dictation can end with no key event to close -the gate (the auto-release cap, or a refused/failed press), which would leave the gate `.latched` -and silently swallow the user's next press — a latched `modifierDown` returns `.none`, and the -`modifierUp` after it returns `.stop`, which no-ops on an already-terminal session. The tap -**swallows nothing**: a lone modifier types nothing, and combos pass through so normal shortcuts keep -working. - -The trigger is editable in the Shortcut section of the setup/settings UI (`HotkeyStepView`) — a -`Picker` over `TriggerKey.allCases` that writes `TriggerKeyStore`, after which -`AppCoordinator.dictationBindingChanged()` re-reads it into the tap. For display strings, use +`CGEventTap` delivery (watching `flagsChanged` for either bound modifier and `keyDown` for any other +key) to a `DualTriggerRouter.Event` and owns the tap lifecycle, calling `onStart(mode)` with the +owning mode. `AppCoordinator` calls its `syncAfterTerminalPhase()` on every terminal phase: a +dictation can end with no key event to close the gate (the auto-release cap, or a refused/failed +press), which would leave the gate `.latched` and silently swallow the user's next press — a latched +`modifierDown` returns `.none`, and the `modifierUp` after it returns `.stop`, which no-ops on an +already-terminal session. The tap **swallows nothing**: a lone modifier types nothing, and combos +pass through so normal shortcuts keep working. + +Both triggers are editable in the Shortcut section of the setup/settings UI (`HotkeyStepView`) — two +`Picker`s over `TriggerKey.allCases` that write `TriggerKeyStore` (cleaned) and `RawTriggerKeyStore` +(raw) through `DictationTriggerPair.assigning` (keeping the two distinct), after which +`AppCoordinator.dictationBindingChanged()` re-reads both into the tap. For display strings, use `TriggerKeyStore().triggerKey.label` for one-shot reads; in views that must re-render live on a Settings change, use **`@BoundTriggerKey`** — a `DynamicProperty` in `Wizard/BoundTriggerKey.swift` wrapping the `@AppStorage(TriggerKeyStore.defaultsKey)` + `TriggerKey.fromPersisted` pair — rather than @@ -406,11 +422,14 @@ regression-tested — no language directive and no filler-word clause; see Engine-side stores, all `UserDefaults`-backed value types with the same shape: -- **`TriggerKeyStore`** (`BlurtTriggerKeyCode`), **`SoundPackStore`** (`BlurtSoundPack`), +- **`TriggerKeyStore`** (`BlurtTriggerKeyCode`, the cleaned key, default right ⌘), + **`RawTriggerKeyStore`** (`BlurtRawTriggerKeyCode`, the raw key, default right ⌥), + **`SoundPackStore`** (`BlurtSoundPack`), **`KeyTermsStore`** (the user's domain vocabulary, re-read at every press via the session's `keyTermsProvider`), **`DeveloperModeStore`** (`BlurtDeveloperMode`, off by default), - **`EnhancedTranscriptsStore`** (`BlurtEnhancedTranscripts`, **on** by default — unset reads as - enabled; gates the dictation request's `llm` cleanup-rewrite block, re-read at every request), + **`CleanupPromptStore`** (`BlurtCleanupPrompt`, the user's editable cleanup instruction sent as + `llm.instruction` on a cleaned dictation; blank/unset selects the service default, re-read at every + request), **`OverlayOriginStore`** (the pill's dragged origin, x/y), **`LastUpdateCheckStore`** (`BlurtLastUpdateCheck`, the stamp throttling the automatic launch update check). - **`PersistedSettings.allDefaultsKeys`** is the roster of every key those stores write, and diff --git a/App/Blurt/Blurt/AppCoordinator.swift b/App/Blurt/Blurt/AppCoordinator.swift index a04a37b..09bfded 100644 --- a/App/Blurt/Blurt/AppCoordinator.swift +++ b/App/Blurt/Blurt/AppCoordinator.swift @@ -115,7 +115,7 @@ final class AppCoordinator { private func startDictationDriver() { let session = session keyTap = DictationKeyTap( - onStart: { session.submit(.press) }, + onStart: { mode in session.submit(.press(mode)) }, onStop: { session.submit(.release) }, onCancel: { session.submit(.cancel) }, // Recovery-only teardown; `cancelRecording()`'s doc owns the rationale. diff --git a/App/Blurt/Blurt/Hotkey/DictationKeyTap.swift b/App/Blurt/Blurt/Hotkey/DictationKeyTap.swift index 99881aa..984b4ae 100644 --- a/App/Blurt/Blurt/Hotkey/DictationKeyTap.swift +++ b/App/Blurt/Blurt/Hotkey/DictationKeyTap.swift @@ -2,18 +2,22 @@ import BlurtEngine import CoreGraphics import os -/// Drives the single lone-modifier dictation trigger from a `CGEventTap`. +/// Drives the **two** lone-modifier dictation triggers from a `CGEventTap`. /// -/// Watches `flagsChanged` for the bound modifier (e.g. right ⌘, keycode 54) to -/// detect down/up, and `keyDown` for any *other* key to spot a modifier combo -/// (⌘C, ⌘V…). The per-event decision lives in the engine — `DictationKeyRouter` -/// (keycode relevance + down/up edge dedup) over `DictationKeyGate` (tap/hold -/// semantics) — so this type only reduces each `CGEvent` to a router event and -/// owns the tap lifecycle. +/// One key produces the raw verbatim transcript, the other the server-side +/// cleanup rewrite; they share one gate, so only one dictation runs at a time +/// and the key that started it decides the `DictationMode` handed to `onStart`. +/// Watches `flagsChanged` for either bound modifier (e.g. right ⌥ raw, right ⌘ +/// cleaned) to detect down/up, and `keyDown` for any *other* key to spot a +/// modifier combo (⌘C, ⌘V…). The per-event decision lives in the engine — +/// `DualTriggerRouter` (keycode relevance, per-key edge dedup, and single-gate +/// ownership) over `DictationKeyGate` (tap/hold semantics) — so this type only +/// reduces each `CGEvent` to a router event and owns the tap lifecycle. /// -/// Unlike the old chord trigger, this **swallows nothing**: a lone modifier -/// types nothing into the focused app, and combos must pass through so normal -/// shortcuts keep working. The tap is therefore created `.listenOnly` — an +/// Like the old single trigger and unlike a chord, this **swallows nothing**: a +/// lone modifier types nothing into the focused app, and combos must pass +/// through so normal shortcuts keep working. The tap is therefore created +/// `.listenOnly` — an /// active (`.defaultTap`) tap would make macOS synchronously wait on this /// process before delivering every keystroke system-wide, so any main-thread /// stall in Blurt would add typing latency in *other* apps. @@ -28,7 +32,7 @@ final class DictationKeyTap { private static let logger = Logger( subsystem: BlurtIdentity.subsystem, category: "DictationKeyTap") - private let onStart: @Sendable () -> Void + private let onStart: @Sendable (DictationMode) -> Void private let onStop: @Sendable () -> Void private let onCancel: @Sendable () -> Void /// Fired when a *state-recovery* reset (disabled-tap recovery, trigger @@ -40,12 +44,17 @@ final class DictationKeyTap { /// a transcript already in flight — see `DictationSession.cancelRecording`. private let onRecordingDiscarded: @Sendable () -> Void - /// The engine-side event router (keycode relevance, down/up edge dedup, and - /// the gate's tap/hold state machine — all unit-tested in BlurtEngine). - private var router = DictationKeyRouter(triggerKeyCode: TriggerKey.rightCommand.keyCode) - /// The bound key's device-dependent `CGEventFlags` bit — the one CoreGraphics- + /// The engine-side event router (keycode relevance, per-key down/up edge + /// dedup, single-gate ownership, and the gate's tap/hold state machine — all + /// unit-tested in BlurtEngine). `refreshBinding()` syncs the keycodes to the + /// persisted raw/cleaned stores. + private var router = DualTriggerRouter( + rawKeyCode: TriggerKey.rightOption.keyCode, + cleanedKeyCode: TriggerKey.rightCommand.keyCode) + /// Each bound key's device-dependent `CGEventFlags` bit — the CoreGraphics- /// typed piece of the binding, so it stays here rather than in the router. - private var triggerFlag = DictationKeyTap.flag(for: .rightCommand) + private var rawFlag = DictationKeyTap.flag(for: .rightOption) + private var cleanedFlag = DictationKeyTap.flag(for: .rightCommand) /// Monotonic reference; per-event timestamps are `reference.duration(to: now)`. private let reference = ContinuousClock.now @@ -57,7 +66,7 @@ final class DictationKeyTap { nonisolated(unsafe) private var tap: CFMachPort? init( - onStart: @escaping @Sendable () -> Void, + onStart: @escaping @Sendable (DictationMode) -> Void, onStop: @escaping @Sendable () -> Void, onCancel: @escaping @Sendable () -> Void, onRecordingDiscarded: @escaping @Sendable () -> Void @@ -149,14 +158,18 @@ final class DictationKeyTap { if router.reset() { onRecordingDiscarded() } } - /// Re-read the bound trigger key into the router. Call after the user - /// rebinds. The router's reset reports a discarded live recording: rebinding - /// mid-dictation means the old key's up-event will never match, so the capture - /// must be cancelled, not left to run out the auto-release cap. + /// Re-read both bound trigger keys into the router. Call after the user + /// rebinds either. The router's reset reports a discarded live recording: + /// rebinding mid-dictation means the old key's up-event will never match, so + /// the capture must be cancelled, not left to run out the auto-release cap. func refreshBinding() { - let key = TriggerKeyStore().triggerKey - triggerFlag = Self.flag(for: key) - if router.rebind(triggerKeyCode: key.keyCode) { onRecordingDiscarded() } + let rawKey = RawTriggerKeyStore().triggerKey + let cleanedKey = TriggerKeyStore().triggerKey + rawFlag = Self.flag(for: rawKey) + cleanedFlag = Self.flag(for: cleanedKey) + if router.rebind(rawKeyCode: rawKey.keyCode, cleanedKeyCode: cleanedKey.keyCode) { + onRecordingDiscarded() + } } /// Callback entry point (always on the main thread — the tap's source lives on @@ -171,8 +184,10 @@ final class DictationKeyTap { // here would discard speech the user is mid-sentence on. Otherwise the // trigger's key-up may have been missed; reset, and cancel a recording // the reset discards rather than leaving the session in .recording until - // the auto-release cap fires and pastes an unprompted transcript. - if CGEventSource.flagsState(.combinedSessionState).contains(triggerFlag) { return } + // the auto-release cap fires and pastes an unprompted transcript. Either + // trigger being held means a live capture whose key-up is still coming. + let held = CGEventSource.flagsState(.combinedSessionState) + if held.contains(rawFlag) || held.contains(cleanedFlag) { return } if router.reset() { onRecordingDiscarded() } return } @@ -183,12 +198,17 @@ final class DictationKeyTap { } /// Reduces a `CGEvent` to the router's CoreGraphics-free event shape, or nil - /// for event types the trigger doesn't care about. - private func routerEvent(type: CGEventType, event: CGEvent) -> DictationKeyRouter.Event? { + /// for event types the trigger doesn't care about. A `flagsChanged` carries + /// both keys' device-bit states; the router picks the one matching the + /// event's keycode. + private func routerEvent(type: CGEventType, event: CGEvent) -> DualTriggerRouter.Event? { let keyCode = Int(event.getIntegerValueField(.keyboardEventKeycode)) switch type { case .flagsChanged: - return .flagsChanged(keyCode: keyCode, triggerFlagIsOn: event.flags.contains(triggerFlag)) + return .flagsChanged( + keyCode: keyCode, + rawFlagIsOn: event.flags.contains(rawFlag), + cleanedFlagIsOn: event.flags.contains(cleanedFlag)) case .keyDown: return .keyDown(keyCode: keyCode) default: @@ -196,9 +216,11 @@ final class DictationKeyTap { } } - private func dispatch(_ action: DictationKeyGate.Action) { - switch action { - case .start: onStart() + private func dispatch(_ outcome: DualTriggerRouter.Outcome) { + switch outcome.action { + // `.start` carries the owning mode (raw vs cleaned); default to `.cleaned` + // for the impossible nil so the call site stays total. + case .start: onStart(outcome.mode ?? .cleaned) case .stop: onStop() case .cancel: onCancel() case .none: break @@ -226,7 +248,9 @@ final class DictationKeyTap { _ = router.reset() dispatch( router.handle( - .flagsChanged(keyCode: router.triggerKeyCode, triggerFlagIsOn: true), at: .seconds(0))) + .flagsChanged( + keyCode: router.cleanedKeyCode, rawFlagIsOn: false, cleanedFlagIsOn: true), + at: .seconds(0))) } /// Completes the synthetic cycle as a hold (past the threshold), so the gate @@ -234,7 +258,9 @@ final class DictationKeyTap { func simulateReleaseForTesting() { dispatch( router.handle( - .flagsChanged(keyCode: router.triggerKeyCode, triggerFlagIsOn: false), at: .seconds(2))) + .flagsChanged( + keyCode: router.cleanedKeyCode, rawFlagIsOn: false, cleanedFlagIsOn: false), + at: .seconds(2))) } #endif } diff --git a/App/Blurt/Blurt/UITestSupport.swift b/App/Blurt/Blurt/UITestSupport.swift index 57d407d..2f74980 100644 --- a/App/Blurt/Blurt/UITestSupport.swift +++ b/App/Blurt/Blurt/UITestSupport.swift @@ -83,7 +83,9 @@ /// Stub transcriber: returns the harness's canned transcript, so the "spoken" /// text is whatever the test set — no network, fully deterministic. nonisolated struct UITestTranscriber: TranscriberProtocol { - func transcribe(pcm: Data, sampleRate: Int, context: TranscriptionContext?) async throws -> String { + func transcribe( + pcm: Data, sampleRate: Int, context: TranscriptionContext?, cleanup: Bool + ) async throws -> String { await MainActor.run { UITestState.shared.cannedTranscript } } } diff --git a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift index af08fae..b4fb64c 100644 --- a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift +++ b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift @@ -67,7 +67,7 @@ private struct GeneralSettingsTab: View { } } -/// The occasional stuff: the enhanced-transcripts switch, checking for an +/// The occasional stuff: the cleanup-instruction editor, checking for an /// update, and the developer-mode log toggle. Kept out of General so the /// common pane stays short. private struct AdvancedSettingsTab: View { @@ -82,29 +82,51 @@ private struct AdvancedSettingsTab: View { } } -/// The Transcription section of the Settings window: the enhanced-transcripts -/// switch. While on (the default), every dictation request asks AssemblyAI's -/// dictation API for its server-side cleanup rewrite, so the pasted text is -/// the polished version; turned off, the request omits the rewrite and the -/// verbatim transcript is pasted exactly as spoken. The transcriber reads the -/// same default this toggle writes at every request, so a change applies to -/// the next dictation. Settings-only — not a wizard step, since it never -/// gates setup. +/// The Transcription section of the Settings window: the editable cleanup +/// instruction. Whatever is typed here is sent as the dictation request's +/// `llm.instruction` on a *cleaned* dictation (the cleaned-up trigger key), so +/// the user can steer the server-side rewrite. Leaving it blank sends an empty +/// `llm` block, which selects AssemblyAI's own default cleanup rewrite. The +/// transcriber reads the same default this field writes at every request, so a +/// change applies to the next dictation. Settings-only — not a wizard step, +/// since it never gates setup. private struct TranscriptionSection: View { - @AppStorage(EnhancedTranscriptsStore.defaultsKey) private var enhancedTranscripts = true + // Observe-only: `@AppStorage` re-renders the view when the slot changes (default + // "" so an unset prompt reads as empty). The field writes *through* + // `CleanupPromptStore`, not this slot directly — the store owns the cap and the + // blank → remove-key rule, and is the setter's only production caller (so a + // change to how the prompt is persisted can't leave the setter untested), the + // same pattern `HotkeyStepView` uses for the trigger keys. + @AppStorage(CleanupPromptStore.defaultsKey) private var storedPrompt = "" + + private var prompt: Binding { + Binding( + get: { storedPrompt }, + set: { CleanupPromptStore().instruction = $0 }) + } var body: some View { Section { - Toggle(isOn: $enhancedTranscripts) { - Label("Enhanced transcripts", systemImage: "wand.and.stars") + // A vertical-axis TextField grows with content up to `lineLimit`; a + // `@ViewBuilder` label + `labelsHidden()` renders it full-width (like the + // Key Terms field) rather than squeezed into a leading label column. + TextField( + text: prompt, + prompt: Text("Leave blank to use the default cleanup"), + axis: .vertical + ) { + Text("Rewrite instruction") } - .accessibilityIdentifier(UITestIdentifiers.enhancedTranscriptsToggle) + .labelsHidden() + .lineLimit(2...6) + .accessibilityIdentifier(UITestIdentifiers.cleanupPromptField) } header: { - Text("Transcription") + Text("Cleanup") } footer: { Text( - "Polishes each dictation before pasting — removing filler words and fixing punctuation. " - + "Turn off to paste your words exactly as spoken.") + "The instruction sent to the cleanup rewrite for the cleaned-up dictation key — for " + + "example, \"Fix punctuation and remove filler words.\" Leave it blank to use " + + "AssemblyAI's default cleanup.") } } } diff --git a/App/Blurt/Blurt/Wizard/Steps/HotkeyStepView.swift b/App/Blurt/Blurt/Wizard/Steps/HotkeyStepView.swift index b450dec..96d58c9 100644 --- a/App/Blurt/Blurt/Wizard/Steps/HotkeyStepView.swift +++ b/App/Blurt/Blurt/Wizard/Steps/HotkeyStepView.swift @@ -1,34 +1,47 @@ import BlurtEngine import SwiftUI -/// The dictation-key section of the setup/settings screen. A menu picker lets -/// the user choose which lone modifier triggers dictation; changes are persisted -/// and pushed to the event tap immediately. +/// The dictation-key section of the setup/settings screen. Two menu pickers let +/// the user choose which lone modifier triggers each dictation mode — one for +/// the cleaned-up (LLM rewrite) transcript, one for the raw verbatim transcript. +/// Changes are persisted and pushed to the event tap immediately, and the two +/// keys are kept distinct: picking one key for a mode that the other already +/// holds swaps them (`DictationTriggerPair.assigning`). struct HotkeyStepView: View { var coordinator: AppCoordinator - // `0` is "no keycode persisted", not a default binding: the unset default belongs - // to `TriggerKey.fromPersisted` (below), which maps any unknown keycode to right - // ⌘. Restating `TriggerKey.rightCommand.rawValue` here would give the empty slot - // two answers, and this one would win for an unset key — so a change to the - // engine's default would leave this picker showing the old binding while the - // ready screen and menu bar showed the new one. Matches `@BoundTriggerKey`. - @AppStorage(TriggerKeyStore.defaultsKey) private var triggerKeyCode = 0 + // `0` is "no keycode persisted", not a default binding: the unset default + // belongs to each store's decode-with-fallback (cleaned → right ⌘ via + // `TriggerKey.fromPersisted`, raw → right ⌥ via `RawTriggerKeyStore`). + // Restating those defaults here would give the empty slot two answers. The + // `@AppStorage` slots are here to *observe* the keys so this view re-renders + // when the store writes; the pickers write through the stores. + @AppStorage(TriggerKeyStore.defaultsKey) private var cleanedKeyCode = 0 + @AppStorage(RawTriggerKeyStore.defaultsKey) private var rawKeyCode = 0 - private var selection: Binding { + /// The current pair, decoded from both slots through each store's own + /// fallback so an unset key resolves to that store's default. + private var pair: DictationTriggerPair { + DictationTriggerPair( + raw: TriggerKey(rawValue: rawKeyCode) ?? .rightOption, + cleaned: TriggerKey.fromPersisted(cleanedKeyCode)) + } + + /// A binding for `mode`'s key that, on write, resolves the pair through + /// `assigning` (swapping on a collision so the two stay distinct), persists + /// **both** keys through their stores, then re-reads them into the tap. + private func selection(for mode: DictationMode) -> Binding { Binding( - get: { - TriggerKey.fromPersisted(triggerKeyCode) - }, + get: { mode == .cleaned ? pair.cleaned : pair.raw }, set: { newValue in - // Write through the store, not the raw `@AppStorage` slot: the store owns - // how a `TriggerKey` is encoded, and `@AppStorage` is here to *observe* the - // key so this view re-renders (it picks up the store's external write). - // Assigning `triggerKeyCode` directly left `TriggerKeyStore`'s setter with - // no production caller, so a change to the encoding — versioning the key, - // storing the case name, a migration — would keep `swift test` green while - // the picker silently kept writing the old form. - TriggerKeyStore().triggerKey = newValue + let updated = pair.assigning(mode, to: newValue) + // Write through the stores, not the raw `@AppStorage` slots: the stores + // own how a `TriggerKey` is encoded (and are the setters' only + // production callers). `@AppStorage` here just observes the external + // write so this view re-renders. Write both, since `assigning` may have + // swapped the other mode's key too. + TriggerKeyStore().triggerKey = updated.cleaned + RawTriggerKeyStore().triggerKey = updated.raw coordinator.dictationBindingChanged() }) } @@ -36,17 +49,28 @@ struct HotkeyStepView: View { var body: some View { Section { PickerSettingRow( - title: "Dictation key", systemImage: "keyboard", - accessibilityID: UITestIdentifiers.hotkeyPicker, selection: selection + title: "Cleaned-up dictation key", systemImage: "wand.and.stars", + accessibilityID: UITestIdentifiers.hotkeyPicker, selection: selection(for: .cleaned) + ) { + ForEach(TriggerKey.allCases, id: \.self) { key in + Text(key.label).tag(key) + } + } + PickerSettingRow( + title: "Raw dictation key", systemImage: "text.quote", + accessibilityID: UITestIdentifiers.rawHotkeyPicker, selection: selection(for: .raw) ) { ForEach(TriggerKey.allCases, id: \.self) { key in Text(key.label).tag(key) } } } header: { - Text("Shortcut") + Text("Shortcuts") } footer: { - Text("Tap to start and tap again to stop, or hold the key and release to dictate.") + Text( + "Two keys, each tap-to-toggle or hold-to-talk. The cleaned-up key pastes a polished " + + "transcript (filler words removed, punctuation fixed); the raw key pastes your words " + + "exactly as spoken. Choosing a key already used by the other mode swaps them.") } } } diff --git a/App/Blurt/Shared/UITestIdentifiers.swift b/App/Blurt/Shared/UITestIdentifiers.swift index f7b0523..b5b11ee 100644 --- a/App/Blurt/Shared/UITestIdentifiers.swift +++ b/App/Blurt/Shared/UITestIdentifiers.swift @@ -60,10 +60,15 @@ enum UITestIdentifiers { static let apiKeyCancel = "settings.apiKey.cancel" static let apiKeyError = "settings.apiKey.error" static let keyTermsField = "settings.keyTerms.field" + /// The cleaned-up (LLM rewrite) dictation-key picker. static let hotkeyPicker = "settings.hotkey.picker" + /// The raw (verbatim) dictation-key picker. + static let rawHotkeyPicker = "settings.hotkey.rawPicker" static let soundPicker = "settings.sound.picker" static let developerToggle = "settings.developer.toggle" - static let enhancedTranscriptsToggle = "settings.enhancedTranscripts.toggle" + /// The editable cleanup-instruction field (sent as the request's + /// `llm.instruction`). + static let cleanupPromptField = "settings.cleanupPrompt.field" static let updateCheck = "settings.update.check" /// The dictation overlay pill (`OverlayView`). diff --git a/BLURTENGINE.md b/BLURTENGINE.md index 0da531c..a2311e4 100644 --- a/BLURTENGINE.md +++ b/BLURTENGINE.md @@ -1,6 +1,6 @@ # Building on BlurtEngine -BlurtEngine is the Swift package that powers [Blurt](README.md)'s dictation pipeline: capture speech from the microphone, transcribe it in a single AssemblyAI dictation API call (transcription plus a server-side LLM cleanup rewrite, contextually primed by a per-utterance prompt), and paste the polished text into the focused app. This guide is for developers embedding the engine in their own macOS app or extending it inside this repository. For repo-wide conventions and agent workflow, see [AGENTS.md](AGENTS.md). +BlurtEngine is the Swift package that powers [Blurt](README.md)'s dictation pipeline: capture speech from the microphone, transcribe it in a single AssemblyAI dictation API call (transcription plus an optional server-side LLM cleanup rewrite, contextually primed by a per-utterance prompt), and paste the text into the focused app. This guide is for developers embedding the engine in their own macOS app or extending it inside this repository. For repo-wide conventions and agent workflow, see [AGENTS.md](AGENTS.md). ## What you get @@ -37,7 +37,7 @@ await session.cancel() // abort, whatever the pipeline is doing // Or, from a callback that can't await (an event tap, a UI action), // use the synchronous fire-and-forget feed — same commands, same order: -session.submit(.press) +session.submit(.press(.cleaned)) ``` Before the first dictation can succeed the host must have: @@ -50,8 +50,8 @@ Before the first dictation can succeed the host must have: ```text press() ──▶ MicCapture.start() release() ──▶ MicCapture.stop() → Data (raw S16LE PCM) - (16 kHz mono 16-bit PCM) AssemblyAITranscriber.transcribe(pcm:sampleRate:context:) - + focus/context capture (one POST dictation.assemblyai.com/transcribe: STT + LLM rewrite) + (16 kHz mono 16-bit PCM) AssemblyAITranscriber.transcribe(pcm:sampleRate:context:cleanup:) + + focus/context capture (one POST dictation.assemblyai.com/transcribe: STT + optional LLM rewrite) + connection warm-up KeyInjector.insert(text, after: priorText) (clipboard paste via synthesized ⌘V) ``` @@ -59,7 +59,7 @@ press() ──▶ MicCapture.start() release() ──▶ MicCapture.s Key properties of the design, which your integration can rely on: - **One request per utterance, no streaming.** The dictation API returns the complete transcript — and its LLM-rewritten form — in the response body: no upload step, no job polling, no incremental deltas, no second request for the cleanup. `TranscriberProtocol.transcribe` is a single `async throws -> String`. UIs should show a "transcribing…" state and then the whole result; there is nothing to stream. -- **Cleanup happens server-side, and it's optional.** The request's empty `llm` block asks the service for its default cleanup rewrite (remove disfluencies, fix punctuation), applied to the verbatim transcript inside the same call; the per-utterance `config.prompt` (built by `TranscriptionPrompt` from the captured context) primes the _transcription_. The block is gated by the **enhanced transcripts** setting (`EnhancedTranscriptsStore`, on by default): turned off, the config omits `llm` and the verbatim transcript is pasted as spoken. The engine pastes `llm_response`, falling back to the verbatim `text` when the best-effort rewrite failed (`llm_error`) — a degradation, never a user-facing error. There is no client-side LLM pass, no styling stage, and deliberately no hook for one. +- **Cleanup happens server-side, and it's per-press.** For a _cleaned_ dictation the request's `llm` block asks the service for its cleanup rewrite (remove disfluencies, fix punctuation), applied to the verbatim transcript inside the same call; the per-utterance `config.prompt` (built by `TranscriptionPrompt` from the captured context) primes the _transcription_. The block is chosen by `transcribe`'s `cleanup` flag (from the `DictationMode` the trigger key selected): a _raw_ dictation omits `llm` and the verbatim transcript is pasted as spoken. When present, the block carries the user's editable instruction (`CleanupPromptStore`) as `llm.instruction`; blank selects the service default (an empty `{}` block). The engine pastes `llm_response`, falling back to the verbatim `text` when the best-effort rewrite failed (`llm_error`) — a degradation, never a user-facing error. There is no client-side LLM pass, no styling stage, and deliberately no hook for one. - **Latency is pre-paid where possible.** `press()` fires a detached `warmUp()` at the transcriber (pre-opening the HTTPS connection while the user speaks, ~170 ms saved cold) and kicks off the cross-process accessibility read of the focused field without awaiting it — the read is then consumed at transcribe time with a bounded wait (`DictationSession.contextWaitBudget`, 500 ms), so an unresponsive frontmost app costs the transcript its priming, never a multi-second stall — and never delays the recording indicator. On the way out, `release()` flips the phase to `.transcribing` _before_ reading the recorded audio back, so a host's stop cue fires at key-up rather than after the disk read. - **A held trigger auto-releases.** `DictationSession` stops recording after `maxRecordingSeconds` (default `SyncSTTLimits.autoReleaseSeconds`, 115 s) so audio never exceeds what the endpoint accepts, and transcribes what it has. Clips shorter than `SyncSTTLimits.minPCMBytes` (~100 ms of audio — an accidental tap) are dropped as a silent no-op rather than sent to earn a 400. @@ -129,11 +129,11 @@ Only `start()`/`stop()` must be implemented — `levels` and `warmUp()` have def ### `TranscriberProtocol` → `AssemblyAITranscriber` ```swift -func transcribe(pcm: Data, sampleRate: Int, context: TranscriptionContext?) async throws -> String +func transcribe(pcm: Data, sampleRate: Int, context: TranscriptionContext?, cleanup: Bool) async throws -> String func warmUp() async // optional; no-op default ``` -`AssemblyAITranscriber` is a stateless `Sendable` struct. One `POST https://dictation.assemblyai.com/transcribe` per utterance: the audio as raw S16LE PCM (the `pcm` blob, byte-for-byte) in the `audio` multipart part, plus a JSON `config` part (`sample_rate`, `channels`, the rendered `prompt`, and — while enhanced transcripts are enabled, the default — an empty `llm` block requesting the service's default cleanup rewrite), with the API key in `Authorization` (no model header — the service pins the STT model server-side). The response carries the verbatim `text` and the rewritten `llm_response`; the transcriber returns the rewrite and falls back to `text` when it is null (the rewrite is best-effort — `llm_error` is logged, never surfaced as a failure). Its initializer takes an `apiKeyProvider` closure (defaults to `APIKeyStore.current`), a `baseURL`, an `HTTPTransport` — inject a fake transport (see `Tests/BlurtEngineTests/Stubs/FakeHTTPTransport.swift`) to test against canned responses — and an `enhancedTranscripts` closure deciding, per request, whether the `llm` block is sent (nil, the default, reads `EnhancedTranscriptsStore`). `warmUp()` fires a throwaway GET at the host root to pre-pool the connection; it never throws and any failure just means the real request pays connection setup as before. +`AssemblyAITranscriber` is a stateless `Sendable` struct. One `POST https://dictation.assemblyai.com/transcribe` per utterance: the audio as raw S16LE PCM (the `pcm` blob, byte-for-byte) in the `audio` multipart part, plus a JSON `config` part (`sample_rate`, `channels`, the rendered `prompt`, and — when the caller's `cleanup` flag is set — an `llm` block requesting the cleanup rewrite), with the API key in `Authorization` (no model header — the service pins the STT model server-side). The response carries the verbatim `text` and the rewritten `llm_response`; the transcriber returns the rewrite and falls back to `text` when it is null (the rewrite is best-effort — `llm_error` is logged, never surfaced as a failure). Its initializer takes an `apiKeyProvider` closure (defaults to `APIKeyStore.current`), a `baseURL`, an `HTTPTransport` — inject a fake transport (see `Tests/BlurtEngineTests/Stubs/FakeHTTPTransport.swift`) to test against canned responses — and a `cleanupInstruction` closure supplying the user's custom `llm.instruction` (nil, the default, reads `CleanupPromptStore`; a blank instruction encodes an empty `llm` block and selects the service default). `warmUp()` fires a throwaway GET at the host root to pre-pool the connection; it never throws and any failure just means the real request pays connection setup as before. The model's limits live in `SyncSTTLimits` (16 kHz sample rate, ~0.1 s–120 s audio, and the auto-release math — the sync STT model behind the dictation service) — the single source shared by the mic, the session, and the request so recorded and declared geometry can't drift. @@ -164,14 +164,15 @@ Each completed dictation is appended to **`DictationLog`** (a local JSONL histor ## Hotkey building blocks -The engine ships the _decision logic_ for a lone-modifier trigger; the host supplies the event source (in Blurt, a `CGEventTap` — see `App/Blurt/Blurt/Hotkey/DictationKeyTap.swift` for the reference wiring). +The engine ships the _decision logic_ for **two** lone-modifier triggers — a _raw_ (verbatim) key and a _cleaned_ (LLM-rewrite) key sharing one gate — the host supplies the event source (in Blurt, a `CGEventTap` — see `App/Blurt/Blurt/Hotkey/DictationKeyTap.swift` for the reference wiring). - **`TriggerKey`** — the curated lone modifiers usable as a trigger (right ⌘, right ⌥, `fn`), with keycodes, display labels, and the device-modifier masks the event source needs. -- **`TriggerKeyStore`** — persists the chosen key in `UserDefaults` (`BlurtTriggerKeyCode`), defaulting to right ⌘. +- **`DictationMode`** — `.raw` / `.cleaned`; `cleansUp` selects whether the request asks for the server-side cleanup rewrite. The `DualTriggerRouter` reports it on `.start` so the host presses the session in that mode. +- **`TriggerKeyStore`** / **`RawTriggerKeyStore`** — persist the two chosen keys in `UserDefaults` (`BlurtTriggerKeyCode`, default right ⌘, for the cleaned key; `BlurtRawTriggerKeyCode`, default right ⌥, for the raw key). **`DictationTriggerPair`** holds both and `assigning(_:to:)` swaps on a collision so the two stay distinct. - **`DictationKeyGate`** — a pure, clock-free state machine that turns `modifierDown(at:)` / `modifierUp(at:)` / `otherKeyDown()` into `.start` / `.stop` / `.cancel` / `.none`. Recording starts the instant the modifier goes down; on key-up, a release held ≥ `holdThreshold` (default 1 s) is push-to-talk (stop), a shorter release latches tap-to-toggle (next tap stops). A modifier+key combo from idle cancels the fresh capture; over a latched recording it passes through as a normal shortcut. Callers pass monotonic timestamps, so every decision is deterministic and unit-tested (`DictationKeyGateTests`, `HotkeyRaceTests`). -- **`DictationKeyRouter`** — the recommended layer over the gate: reduce each raw event to `.flagsChanged(keyCode:triggerFlagIsOn:)` / `.keyDown(keyCode:)` and `handle(_:at:)` applies the filters every event source needs — only the bound keycode's flag changes count, and only genuine down/up _edges_ reach the gate (`flagsChanged` deliveries re-report the bit whether or not it changed, so a repeat must not double-start a dictation). `reset()` / `rebind(triggerKeyCode:)` clear state that can no longer be trusted (dropped events, a rebound trigger) and return whether they discarded a live recording. Unit-tested (`DictationKeyRouterTests`). +- **`DualTriggerRouter`** — the recommended layer over the gate for both keys: reduce each raw event to `.flagsChanged(keyCode:rawFlagIsOn:cleanedFlagIsOn:)` / `.keyDown(keyCode:)` and `handle(_:at:)` applies the filters every event source needs — only a bound keycode's flag changes count, only genuine down/up _edges_ reach the gate (`flagsChanged` deliveries re-report the bit whether or not it changed, so a repeat must not double-start a dictation), and the first key to open the idle gate owns it (the other key is ignored until idle — no chord across the two). Its `Outcome` carries the gate action plus, on `.start`, the owning `DictationMode`. `reset()` / `rebind(rawKeyCode:cleanedKeyCode:)` clear state that can no longer be trusted (dropped events, a rebound trigger) and return whether they discarded a live recording. Unit-tested (`DualTriggerRouterTests`). -Map the router's actions onto the session with `submit`: `.start` → `submit(.press)`, `.stop` → `submit(.release)`, `.cancel` → `submit(.cancel)` — event-tap callbacks can't `await`, and `submit` preserves their emit order where per-callback `Task` spawning wouldn't. If your event source can lose key-ups (a disabled tap, a rebind), call the router's `reset()`/`rebind(triggerKeyCode:)` and recover a discarded recording with `submit(.cancelRecording)`. +Map the router's outcome onto the session with `submit`: `.start` → `submit(.press(outcome.mode ?? .cleaned))`, `.stop` → `submit(.release)`, `.cancel` → `submit(.cancel)` — event-tap callbacks can't `await`, and `submit` preserves their emit order where per-callback `Task` spawning wouldn't. If your event source can lose key-ups (a disabled tap, a rebind), call the router's `reset()`/`rebind(rawKeyCode:cleanedKeyCode:)` and recover a discarded recording with `submit(.cancelRecording)`. ## Testing your integration diff --git a/Sources/BlurtEngine/Config/CleanupPromptStore.swift b/Sources/BlurtEngine/Config/CleanupPromptStore.swift new file mode 100644 index 0000000..2aade8e --- /dev/null +++ b/Sources/BlurtEngine/Config/CleanupPromptStore.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Persists the user's custom **cleanup instruction** in `UserDefaults` — the +/// text sent as the dictation request's `llm.instruction` when a *cleaned* +/// dictation runs (`DictationMode.cleaned`). Blank or unset means "use the +/// service's default cleanup instruction", so the request carries an empty `llm` +/// block and the server-owned default rewrite applies. `AssemblyAITranscriber` +/// reads this at each request, so an edit in Settings applies to the very next +/// dictation. Same shape as `SoundPackStore` / `TriggerKeyStore`. +public struct CleanupPromptStore { + /// UserDefaults key holding the instruction. Public so SwiftUI views can + /// observe it directly (e.g. `@AppStorage`) and re-render on change. + public static let defaultsKey = "BlurtCleanupPrompt" + /// Defensive upper bound on the persisted instruction, so a runaway paste + /// into the Settings editor can't store an unbounded blob that then rides + /// every request. Generous for a cleanup directive; the service enforces its + /// own request limits regardless. + static let characterCap = 4096 + private let defaults: UserDefaults + + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + /// The stored instruction. The **getter** trims to non-empty — nil when unset + /// or blank — so the transcriber can treat "no custom prompt" as one case. The + /// **setter** removes the key when the value is blank (after trimming) and + /// otherwise stores the value *raw* (untrimmed), capped at `characterCap`. + /// Storing raw rather than trimmed matters for the Settings editor's binding: + /// a normalizing write would bounce a trimmed string back through + /// `@AppStorage` and eat a trailing space the moment the user typed it — the + /// same reason `KeyTermsStore` normalizes on read, not write. + public var instruction: String? { + get { defaults.string(forKey: Self.defaultsKey).trimmedNonEmpty() } + nonmutating set { + guard newValue.trimmedNonEmpty() != nil, let value = newValue else { + defaults.removeObject(forKey: Self.defaultsKey) + return + } + defaults.set(String(value.prefix(Self.characterCap)), forKey: Self.defaultsKey) + } + } +} diff --git a/Sources/BlurtEngine/Config/EnhancedTranscriptsStore.swift b/Sources/BlurtEngine/Config/EnhancedTranscriptsStore.swift deleted file mode 100644 index 8caa19c..0000000 --- a/Sources/BlurtEngine/Config/EnhancedTranscriptsStore.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Foundation - -/// Persists the "enhanced transcripts" switch in `UserDefaults`. On by -/// default; the Settings window's Transcription section flips it. While on, -/// every dictation request carries the `llm` block asking the dictation API -/// for its server-side cleanup rewrite (remove disfluencies, fix punctuation); -/// turned off, the request omits the block and the verbatim transcript is -/// pasted exactly as spoken. `AssemblyAITranscriber` reads this at each -/// request, so a change applies to the very next dictation. -/// Same shape as `DeveloperModeStore` / `SoundPackStore`. -public struct EnhancedTranscriptsStore { - /// UserDefaults key holding the switch. Public so SwiftUI views can observe - /// it directly (e.g. `@AppStorage`) and re-render on change. - public static let defaultsKey = "BlurtEnhancedTranscripts" - private let defaults: UserDefaults - - init(defaults: UserDefaults = .standard) { - self.defaults = defaults - } - - /// Unset means **on** — the cleanup rewrite is the product's default - /// behavior, so only an explicit opt-out disables it. That inverts the - /// usual `bool(forKey:)` shape (which reads a missing key as false), hence - /// the presence check. - var isEnabled: Bool { - get { defaults.object(forKey: Self.defaultsKey) as? Bool ?? true } - nonmutating set { defaults.set(newValue, forKey: Self.defaultsKey) } - } -} diff --git a/Sources/BlurtEngine/Config/PersistedSettings.swift b/Sources/BlurtEngine/Config/PersistedSettings.swift index e3560a7..0ae69b5 100644 --- a/Sources/BlurtEngine/Config/PersistedSettings.swift +++ b/Sources/BlurtEngine/Config/PersistedSettings.swift @@ -1,12 +1,12 @@ import Foundation /// The roster of `UserDefaults` keys the engine's settings stores persist: -/// trigger key, sound pack, key terms, developer mode, enhanced transcripts, -/// overlay origin, and the -/// timestamp throttling the automatic update check. Owned -/// here — next to the stores — so adding a store and adding it to every "reset -/// to a clean state" sweep (e.g. the app's UI-test launch reset) are the same -/// edit, instead of a hand-maintained list in the app shell that goes stale. +/// the cleaned and raw trigger keys, sound pack, key terms, developer mode, +/// cleanup prompt, overlay origin, and the timestamp throttling the automatic +/// update check. Owned here — next to the stores — so adding a store and adding +/// it to every "reset to a clean state" sweep (e.g. the app's UI-test launch +/// reset) are the same edit, instead of a hand-maintained list in the app shell +/// that goes stale. public enum PersistedSettings { /// Every defaults key an engine store writes. Keep in sync by adding the new /// store's key here in the same change that introduces the store. @@ -17,10 +17,11 @@ public enum PersistedSettings { /// periphery's redundant-public check besides. static let allDefaultsKeys: [String] = [ TriggerKeyStore.defaultsKey, + RawTriggerKeyStore.defaultsKey, SoundPackStore.defaultsKey, KeyTermsStore.defaultsKey, DeveloperModeStore.defaultsKey, - EnhancedTranscriptsStore.defaultsKey, + CleanupPromptStore.defaultsKey, OverlayOriginStore.xDefaultsKey, OverlayOriginStore.yDefaultsKey, LastUpdateCheckStore.defaultsKey, diff --git a/Sources/BlurtEngine/Hotkey/DictationKeyRouter.swift b/Sources/BlurtEngine/Hotkey/DictationKeyRouter.swift deleted file mode 100644 index db249c3..0000000 --- a/Sources/BlurtEngine/Hotkey/DictationKeyRouter.swift +++ /dev/null @@ -1,83 +0,0 @@ -/// Routes raw trigger-key events into `DictationKeyGate` and owns the two -/// decisions that would otherwise sit untested in the app's event-tap shim: -/// -/// - **Edge dedup.** `flagsChanged` deliveries re-report the bound key's flag -/// bit whether or not it changed, so the router tracks the modifier's current -/// physical state and only a genuine down/up *edge* reaches the gate — -/// repeated same-state deliveries must not double-fire a dictation. -/// - **Relevance.** Only the bound keycode's flag changes drive the modifier; -/// a `keyDown` for any *other* key marks a combo (e.g. ⌘C over the held -/// trigger), and the trigger's own keycode never counts as a combo. -/// -/// Like the gate, the router reads no clock — callers pass monotonic timestamps -/// — so every decision is deterministic and unit-testable. The app-side -/// `DictationKeyTap` reduces each `CGEvent` to an `Event` and forwards it here. -public struct DictationKeyRouter: Sendable { - /// A keyboard event reduced to exactly what the routing decision needs, so - /// the router never touches `CGEvent`/`CGEventFlags` types. - public enum Event: Sendable, Equatable { - /// A `flagsChanged` delivery: the keycode it reports and whether the bound - /// trigger's device-dependent flag bit is set in the event's flags (see - /// `TriggerKey.deviceModifierMask`). - case flagsChanged(keyCode: Int, triggerFlagIsOn: Bool) - /// A `keyDown` for `keyCode`. - case keyDown(keyCode: Int) - } - - /// The virtual keycode of the bound trigger modifier (`TriggerKey.keyCode`). - public private(set) var triggerKeyCode: Int - - private var gate: DictationKeyGate - /// The bound modifier's current physical state, so repeated `flagsChanged` - /// deliveries with an unchanged bit don't re-fire the gate. - private var modifierIsDown = false - - public init(triggerKeyCode: Int, holdThreshold: Duration = .seconds(1)) { - self.triggerKeyCode = triggerKeyCode - self.gate = DictationKeyGate(holdThreshold: holdThreshold) - } - - /// Feeds one event through the relevance/edge filters into the gate and - /// returns its decision. - public mutating func handle(_ event: Event, at now: Duration) -> DictationKeyGate.Action { - switch event { - case .flagsChanged(let keyCode, let triggerFlagIsOn): - guard keyCode == triggerKeyCode else { return .none } - if triggerFlagIsOn, !modifierIsDown { - modifierIsDown = true - return gate.modifierDown(at: now) - } - if !triggerFlagIsOn, modifierIsDown { - modifierIsDown = false - return gate.modifierUp(at: now) - } - return .none - case .keyDown(let keyCode): - return keyCode == triggerKeyCode ? .none : gate.otherKeyDown() - } - } - - /// Rebinds the trigger and resets: events already tracked belong to the old - /// key, whose up-event can no longer match. Returns whether the reset - /// discarded a live recording (see `reset()`). - @discardableResult - public mutating func rebind(triggerKeyCode: Int) -> Bool { - self.triggerKeyCode = triggerKeyCode - return reset() - } - - /// Clears the gate (and the modifier-down tracker) because the events it was - /// tracking can no longer be trusted — the binding changed, or the host's - /// event tap was disabled and events were dropped. Returns true when the - /// reset discarded a live gate state (armed or latched): no future key event - /// can end that dictation, so the caller must cancel the recording upstream - /// — otherwise the session sits in `.recording` until the auto-release cap - /// pastes an unprompted transcript. - @discardableResult - public mutating func reset() -> Bool { - let discardedRecording = !gate.isIdle - gate.reset() - modifierIsDown = false - return discardedRecording - } -} diff --git a/Sources/BlurtEngine/Hotkey/DictationMode.swift b/Sources/BlurtEngine/Hotkey/DictationMode.swift new file mode 100644 index 0000000..d2559cc --- /dev/null +++ b/Sources/BlurtEngine/Hotkey/DictationMode.swift @@ -0,0 +1,28 @@ +/// Which of the two dictation triggers started a session, and therefore what +/// the pasted text should be. +/// +/// Blurt binds two lone-modifier keys (see `DictationTriggerPair`): one for the +/// **raw** verbatim transcript and one for the **cleaned-up** server-side LLM +/// rewrite. The mode rides from the key that fired (`DualTriggerRouter`) through +/// `DictationSession` into the transcribe request, where `cleansUp` decides +/// whether the dictation API's `llm` cleanup-rewrite block is included — raw +/// omits it (the verbatim `text` is pasted), cleaned includes it (the rewrite +/// is pasted). It is the pipeline's single source of "which transcript did the +/// user ask for", so the two keys can't disagree with the request they build. +public enum DictationMode: Sendable, Hashable, CaseIterable { + /// Verbatim transcript — the request omits the `llm` block, so the service + /// skips its cleanup rewrite and the words are pasted exactly as spoken. + case raw + /// Cleaned-up transcript — the request carries the `llm` block, so the + /// service runs its cleanup rewrite (disfluencies removed, punctuation fixed) + /// and that polished text is pasted. + case cleaned + + /// Whether this mode asks the dictation API for its server-side cleanup + /// rewrite. Read at request-build time to decide the `llm` block's presence. + /// Internal (not public): only the engine's pipeline reads it — the app just + /// forwards the opaque `DictationMode` — so exposing it would trip periphery's + /// redundant-public check, the same reason the former enhanced-transcripts + /// switch kept its accessor internal. + var cleansUp: Bool { self == .cleaned } +} diff --git a/Sources/BlurtEngine/Hotkey/DictationTriggerPair.swift b/Sources/BlurtEngine/Hotkey/DictationTriggerPair.swift new file mode 100644 index 0000000..61e7441 --- /dev/null +++ b/Sources/BlurtEngine/Hotkey/DictationTriggerPair.swift @@ -0,0 +1,35 @@ +/// The two lone-modifier keys that trigger dictation — one per `DictationMode` +/// — kept as a value type so the "the two keys must stay distinct" rule lives +/// in one tested place rather than being re-derived at each Settings picker. +/// +/// A picker changing one key can collide with the other; `assigning` resolves +/// that by *swapping* rather than rejecting, so the user always ends up with two +/// working, different triggers instead of a silently dropped edit. +public struct DictationTriggerPair: Sendable, Equatable { + /// The key that produces the verbatim transcript. + public var raw: TriggerKey + /// The key that produces the server-side cleanup rewrite. + public var cleaned: TriggerKey + + public init(raw: TriggerKey, cleaned: TriggerKey) { + self.raw = raw + self.cleaned = cleaned + } + + /// Returns a new pair with `mode`'s key set to `key`, preserving distinctness: + /// if `key` collides with the other mode's key, the other mode takes this + /// pair's current key for `mode` (a swap), so the two are never equal. Setting + /// a mode to the key it already holds is a no-op. + public func assigning(_ mode: DictationMode, to key: TriggerKey) -> DictationTriggerPair { + switch mode { + case .raw: + // A collision hands `cleaned` the key `raw` is vacating — a swap — so the + // pair stays distinct instead of both keys landing on `key`. + let cleanedKey = key == cleaned ? raw : cleaned + return DictationTriggerPair(raw: key, cleaned: cleanedKey) + case .cleaned: + let rawKey = key == raw ? cleaned : raw + return DictationTriggerPair(raw: rawKey, cleaned: key) + } + } +} diff --git a/Sources/BlurtEngine/Hotkey/DualTriggerRouter.swift b/Sources/BlurtEngine/Hotkey/DualTriggerRouter.swift new file mode 100644 index 0000000..0b556c4 --- /dev/null +++ b/Sources/BlurtEngine/Hotkey/DualTriggerRouter.swift @@ -0,0 +1,167 @@ +/// Routes raw trigger-key events for **two** bound modifiers into a single +/// `DictationKeyGate`, reporting which `DictationMode` started the session. +/// +/// Drives the two lone-modifier triggers (`DictationTriggerPair`) — a *raw* key +/// and a *cleaned* key — over one shared `DictationKeyGate`, so only one +/// dictation runs at a time no matter which key fires. It owns three decisions +/// the app's event-tap shim would otherwise carry untested: +/// +/// - **Edge dedup**, per key. `flagsChanged` deliveries re-report each key's +/// flag bit whether or not it changed, so the router tracks both keys' +/// physical state and only a genuine down/up *edge* reaches the gate. +/// - **Ownership.** The first key to open the idle gate becomes its `owner` for +/// the life of that session; the *other* key's flag changes are ignored until +/// the gate returns to idle, so pressing the second trigger mid-dictation +/// can't hijack or double-fire the run. Ownership clears whenever the gate +/// goes idle (`postUpdate`). +/// - **Relevance.** Only the two bound keycodes' flag changes drive the +/// modifier; a `keyDown` for any *other* key marks a combo, and neither +/// trigger's own keycode counts as a combo. +/// +/// Like the gate, the router reads no clock — callers pass monotonic timestamps +/// — so every decision is deterministic and unit-testable. The app-side +/// `DictationKeyTap` reduces each `CGEvent` to an `Event` and forwards it here. +public struct DualTriggerRouter: Sendable { + /// A keyboard event reduced to exactly what the routing decision needs, so + /// the router never touches `CGEvent`/`CGEventFlags` types. `flagsChanged` + /// carries *both* triggers' device-dependent flag bits (see + /// `TriggerKey.deviceModifierMask`); the router picks the one matching the + /// event's keycode. + public enum Event: Sendable, Equatable { + case flagsChanged(keyCode: Int, rawFlagIsOn: Bool, cleanedFlagIsOn: Bool) + case keyDown(keyCode: Int) + } + + /// The gate's decision plus which mode owns it. `mode` is non-nil **only** + /// when `action == .start`, naming the key that opened the session so the host + /// can paste the matching (raw vs cleaned) transcript; every other action + /// carries `nil`. + public struct Outcome: Sendable, Equatable { + public let action: DictationKeyGate.Action + public let mode: DictationMode? + } + + /// The verbatim trigger's virtual keycode. Internal (not public): only the + /// engine and tests read the property — the app passes it in via `init`/ + /// `rebind` — so a public getter would trip periphery's redundant-public + /// check. `cleanedKeyCode` stays public because the UITEST harness reads it. + private(set) var rawKeyCode: Int + /// The cleanup-rewrite trigger's virtual keycode. + public private(set) var cleanedKeyCode: Int + + private var gate: DictationKeyGate + /// Each key's current physical state, so repeated `flagsChanged` deliveries + /// with an unchanged bit don't re-fire the gate. + private var rawDown = false + private var cleanedDown = false + /// Which key currently owns the live gate, or nil when idle. Set when a key + /// opens the idle gate; cleared by `postUpdate` once the gate returns to idle. + private var owner: DictationMode? + + public init(rawKeyCode: Int, cleanedKeyCode: Int, holdThreshold: Duration = .seconds(1)) { + self.rawKeyCode = rawKeyCode + self.cleanedKeyCode = cleanedKeyCode + self.gate = DictationKeyGate(holdThreshold: holdThreshold) + } + + /// Feeds one event through the relevance/edge/ownership filters into the gate + /// and returns its decision plus the owning mode on a start. + public mutating func handle(_ event: Event, at now: Duration) -> Outcome { + switch event { + case .flagsChanged(let keyCode, let rawFlagIsOn, let cleanedFlagIsOn): + let role: DictationMode + let bit: Bool + if keyCode == rawKeyCode { + role = .raw + bit = rawFlagIsOn + } else if keyCode == cleanedKeyCode { + role = .cleaned + bit = cleanedFlagIsOn + } else { + return Outcome(action: .none, mode: nil) + } + // Edge dedup against this role's tracked physical state. + let wasDown = (role == .raw) ? rawDown : cleanedDown + guard bit != wasDown else { return Outcome(action: .none, mode: nil) } + setDown(role, bit) + return bit ? handleDown(role, at: now) : handleUp(role, at: now) + case .keyDown(let keyCode): + // A trigger's own keyDown isn't a combo; any other key is. + if keyCode == rawKeyCode || keyCode == cleanedKeyCode { + return Outcome(action: .none, mode: nil) + } + let action = gate.otherKeyDown() + postUpdate() + return Outcome(action: action, mode: nil) + } + } + + /// A down *edge* for `role`. Opens the idle gate (claiming ownership and + /// reporting the mode on a start), passes through when `role` already owns the + /// gate, and is ignored when the *other* key holds a live session. + private mutating func handleDown(_ role: DictationMode, at now: Duration) -> Outcome { + if gate.isIdle { + owner = role + let action = gate.modifierDown(at: now) + postUpdate() + return Outcome(action: action, mode: action == .start ? role : nil) + } + if owner == role { + let action = gate.modifierDown(at: now) + postUpdate() + return Outcome(action: action, mode: nil) + } + // The other trigger while a session is active — ignored (no chord across + // the two keys), though its physical down-state is still tracked above. + return Outcome(action: .none, mode: nil) + } + + /// An up *edge* for `role`. Only the owning key can release the gate; the + /// other key's release (tracked, but never having driven the gate) is inert. + private mutating func handleUp(_ role: DictationMode, at now: Duration) -> Outcome { + guard owner == role else { return Outcome(action: .none, mode: nil) } + let action = gate.modifierUp(at: now) + postUpdate() + return Outcome(action: action, mode: nil) + } + + private mutating func setDown(_ role: DictationMode, _ isDown: Bool) { + switch role { + case .raw: rawDown = isDown + case .cleaned: cleanedDown = isDown + } + } + + /// Clears ownership whenever the gate returns to idle, so the next key to + /// press opens a fresh session (and can be a different mode). + private mutating func postUpdate() { + if gate.isIdle { owner = nil } + } + + /// Rebinds both triggers and resets: events already tracked belong to the old + /// keys, whose up-events can no longer match. Returns whether the reset + /// discarded a live recording (see `reset()`). + @discardableResult + public mutating func rebind(rawKeyCode: Int, cleanedKeyCode: Int) -> Bool { + self.rawKeyCode = rawKeyCode + self.cleanedKeyCode = cleanedKeyCode + return reset() + } + + /// Clears the gate (and both physical-state trackers plus ownership) because + /// the events it was tracking can no longer be trusted — a binding changed, or + /// the host's event tap was disabled and events were dropped. Returns true + /// when the reset discarded a live gate state (armed or latched): no future + /// key event can end that dictation, so the caller must cancel the recording + /// upstream — otherwise the session sits in `.recording` until the + /// auto-release cap pastes an unprompted transcript. + @discardableResult + public mutating func reset() -> Bool { + let discardedRecording = !gate.isIdle + gate.reset() + rawDown = false + cleanedDown = false + owner = nil + return discardedRecording + } +} diff --git a/Sources/BlurtEngine/Hotkey/RawTriggerKeyStore.swift b/Sources/BlurtEngine/Hotkey/RawTriggerKeyStore.swift new file mode 100644 index 0000000..8778599 --- /dev/null +++ b/Sources/BlurtEngine/Hotkey/RawTriggerKeyStore.swift @@ -0,0 +1,36 @@ +import Foundation + +/// Persists the **raw** dictation `TriggerKey` as its keycode in `UserDefaults`. +/// The companion to `TriggerKeyStore` (which holds the *cleaned* key): the two +/// stores back the two lone-modifier triggers (`DictationTriggerPair`), one +/// pasting the verbatim transcript and one the server-side cleanup rewrite. +/// +/// Defaults to right ⌥ when unset or when the stored code isn't one of the +/// curated options. That default differs from `TriggerKeyStore`'s right ⌘, so +/// the two triggers start out distinct; unlike `TriggerKeyStore` it can't lean +/// on `TriggerKey.fromPersisted` (whose fallback is right ⌘), so the read +/// applies the right-⌥ fallback here — leaving `fromPersisted`'s existing +/// right-⌘ default untouched for the cleaned store and the `@AppStorage` views. +public struct RawTriggerKeyStore { + /// UserDefaults key holding the raw trigger keycode. Public so SwiftUI views + /// can observe it directly (e.g. `@AppStorage`) and re-render on change. + public static let defaultsKey = "BlurtRawTriggerKeyCode" + private let defaults: UserDefaults + + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + public var triggerKey: TriggerKey { + get { + // Unset reads as 0 (not a curated keycode); an unknown code likewise + // isn't curated. Both fall back to right ⌥ — the raw trigger's default, + // distinct from the cleaned trigger's right ⌘. + let code = defaults.integer(forKey: Self.defaultsKey) + return TriggerKey(rawValue: code) ?? .rightOption + } + nonmutating set { + defaults.set(newValue.rawValue, forKey: Self.defaultsKey) + } + } +} diff --git a/Sources/BlurtEngine/Pipeline/DictationSession+Commands.swift b/Sources/BlurtEngine/Pipeline/DictationSession+Commands.swift index e99934b..c85c6e8 100644 --- a/Sources/BlurtEngine/Pipeline/DictationSession+Commands.swift +++ b/Sources/BlurtEngine/Pipeline/DictationSession+Commands.swift @@ -1,8 +1,9 @@ extension DictationSession { /// One host-initiated pipeline command, for `submit(_:)`. Mirrors the four - /// async methods one-to-one; see each method's doc for semantics. + /// async methods one-to-one; see each method's doc for semantics. `press` + /// carries the `DictationMode` its trigger key selected (raw vs cleaned). public enum Command: Sendable { - case press + case press(DictationMode) case release case cancel case cancelRecording @@ -25,7 +26,7 @@ extension DictationSession { /// mirrors, so `submit` and direct calls share every guard and race rule. func run(_ command: Command) async { switch command { - case .press: await press() + case .press(let mode): await press(mode: mode) case .release: await release() case .cancel: await cancel() case .cancelRecording: await cancelRecording() diff --git a/Sources/BlurtEngine/Pipeline/DictationSession+Pipeline.swift b/Sources/BlurtEngine/Pipeline/DictationSession+Pipeline.swift index eb830eb..c78a81c 100644 --- a/Sources/BlurtEngine/Pipeline/DictationSession+Pipeline.swift +++ b/Sources/BlurtEngine/Pipeline/DictationSession+Pipeline.swift @@ -52,9 +52,10 @@ extension DictationSession { capturedContext = nil } - // The dictation API runs the cleanup rewrite server-side, so the text it - // returns is already the final, polished text — there is no client-side - // styling pass. + // When the active trigger asked for cleanup, the dictation API runs the + // rewrite server-side, so the text it returns is already the final, polished + // text; a raw trigger gets the verbatim transcript. Either way there is no + // client-side styling pass — the choice is one flag on the same request. guard let text = await transcribe(pcm: pcm) else { return } // A cancel() that landed while transcribe was in flight already set @@ -102,7 +103,8 @@ extension DictationSession { private func transcribe(pcm: Data) async -> String? { do { return try await transcriber.transcribe( - pcm: pcm, sampleRate: SyncSTTLimits.sampleRate, context: capturedContext) + pcm: pcm, sampleRate: SyncSTTLimits.sampleRate, context: capturedContext, + cleanup: activeMode.cleansUp) } catch { // A cancel() that landed mid-request already tore this task down and set // .cancelled; the transport then surfaces a cancellation-shaped error diff --git a/Sources/BlurtEngine/Pipeline/DictationSession.swift b/Sources/BlurtEngine/Pipeline/DictationSession.swift index b3f278a..8e7147c 100644 --- a/Sources/BlurtEngine/Pipeline/DictationSession.swift +++ b/Sources/BlurtEngine/Pipeline/DictationSession.swift @@ -59,6 +59,13 @@ public actor DictationSession { /// transcriber, `inject`'s separator decision, and the log share one snapshot. var capturedContext: TranscriptionContext? + /// Which trigger started the in-flight dictation, set at the top of + /// `performPress` and read in `+Pipeline` to decide whether the request asks + /// for the server-side cleanup rewrite (`DictationMode.cleansUp`). Defaults to + /// `.cleaned` so a direct `press()` with no mode keeps the historical + /// cleaned-transcript behavior. + var activeMode: DictationMode = .cleaned + /// The in-flight AX field-context read, started by `press()` — that's when /// the target field still holds focus — but consumed only in /// `runTranscribeInject`, bounded by `contextWaitBudget`. Deliberately not @@ -149,16 +156,25 @@ public actor DictationSession { await task.value } - public func press() async { - await enqueue { await self.performPress() } + /// Starts a dictation in `mode` — `.raw` pastes the verbatim transcript, + /// `.cleaned` (the default) the server-side cleanup rewrite. The mode is + /// captured for the whole run at press time and read again in `+Pipeline`. + public func press(mode: DictationMode = .cleaned) async { + await enqueue { await self.performPress(mode: mode) } } public func release() async { await enqueue { await self.performRelease() } } - private func performPress() async { + private func performPress(mode: DictationMode) async { guard phase.isTerminal else { return } + // Claim the mode only once the guard confirms this press is starting a + // session. A press rejected here — e.g. the *other* key tapped while the + // prior dictation is still `.transcribing` — must not clobber the in-flight + // session's mode, which `runTranscribeInject` reads (via `activeMode`) only + // after the up-to-500 ms context-wait suspension. + activeMode = mode // Refuse the press before any capture begins when the host reports a // blocker (e.g. no API key saved): recording an utterance that can only // fail at transcribe time would discard the user's words after the fact. diff --git a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift index 55da320..9595a40 100644 --- a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift +++ b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift @@ -12,19 +12,26 @@ private let transcriberLog = Logger(subsystem: BlurtIdentity.subsystem, category /// A single `POST dictation.assemblyai.com/transcribe` carries the captured /// audio (raw S16LE PCM, exactly the bytes the mic recorded — there is no /// re-encoding pass) plus a JSON `config` part, and the response body carries -/// both the verbatim transcript and — when the config requests one via its -/// `llm` block (the "enhanced transcripts" setting, on by default) — an -/// LLM-rewritten version with disfluencies removed and punctuation fixed. -/// No upload step, no job submission, no polling — one -/// request per utterance covers transcription *and* cleanup. The service picks -/// the STT model server-side and handles audio from ~80 ms up to 120 s; the -/// rewrite is best-effort with a ~5 s server-side deadline, so a rewrite -/// failure still returns the verbatim transcript (`llm_response` null). +/// both the verbatim transcript and — when the caller's `cleanup` flag requests +/// one via the config's `llm` block — an LLM-rewritten version with +/// disfluencies removed and punctuation fixed. No upload step, no job +/// submission, no polling — one request per utterance covers transcription +/// *and* cleanup. The service picks the STT model server-side and handles audio +/// from ~80 ms up to 120 s; the rewrite is best-effort with a ~5 s server-side +/// deadline, so a rewrite failure still returns the verbatim transcript +/// (`llm_response` null). +/// +/// Whether to request the rewrite is decided **per call** by `transcribe`'s +/// `cleanup` flag (from the `DictationMode` that fired): the *cleaned* trigger +/// asks for it, the *raw* trigger omits the `llm` block entirely. When it is +/// requested, the block carries the user's editable cleanup instruction +/// (`CleanupPromptStore`) as `llm.instruction`; an empty instruction selects the +/// service's own default cleanup rewrite (the block encodes as `{}`). public struct AssemblyAITranscriber: TranscriberProtocol { private let apiKeyProvider: @Sendable () -> String? private let baseURL: URL private let transport: any HTTPTransport - private let enhancedTranscriptsEnabled: @Sendable () -> Bool + private let cleanupInstruction: @Sendable () -> String? /// Idle timeout for the transcribe round trip — `URLRequest.timeoutInterval` is /// reset each time data moves, so this bounds *stalls*, not total elapsed time. @@ -35,34 +42,34 @@ public struct AssemblyAITranscriber: TranscriberProtocol { /// stuck on "Transcribing…" indefinitely. private static let requestTimeoutSeconds: TimeInterval = 90 - /// `enhancedTranscripts` decides, per request, whether the config carries - /// the `llm` cleanup-rewrite block. Read at every `transcribe` so a settings - /// change applies to the next dictation without rebuilding the transcriber. - /// `nil` (the default) reads `EnhancedTranscriptsStore` — spelled as an - /// optional rather than a default closure because a public default argument - /// can't reference the store's internal `isEnabled`. + /// `cleanupInstruction` supplies the user's custom cleanup directive, read at + /// every *cleaned* `transcribe` so a Settings edit applies to the next + /// dictation without rebuilding the transcriber; nil/blank asks the service + /// for its default rewrite. `nil` (the default) reads `CleanupPromptStore` — + /// spelled as an optional rather than a default closure because a public + /// default argument can't reference the store's internal `instruction`. public init( apiKeyProvider: @escaping @Sendable () -> String? = { APIKeyStore.current }, baseURL: URL = URL(staticString: "https://dictation.assemblyai.com"), transport: any HTTPTransport = URLSession.shared, - enhancedTranscripts: (@Sendable () -> Bool)? = nil + cleanupInstruction: (@Sendable () -> String?)? = nil ) { self.apiKeyProvider = apiKeyProvider self.baseURL = baseURL self.transport = transport - self.enhancedTranscriptsEnabled = enhancedTranscripts ?? { EnhancedTranscriptsStore().isEnabled } + self.cleanupInstruction = cleanupInstruction ?? { CleanupPromptStore().instruction } } // MARK: - Dictation request public func transcribe( - pcm: Data, sampleRate: Int, context: TranscriptionContext? + pcm: Data, sampleRate: Int, context: TranscriptionContext?, cleanup: Bool ) async throws -> String { guard let apiKey = apiKeyProvider(), !apiKey.isEmpty else { throw BlurtError.apiKeyMissing } let prompt = TranscriptionPrompt.build(context: context) - let config = try makeConfigData(sampleRate: sampleRate, prompt: prompt) + let config = try makeConfigData(sampleRate: sampleRate, prompt: prompt, cleanup: cleanup) let boundary = "blurt-\(UUID().uuidString)" var request = URLRequest(url: baseURL.appendingPathComponent("transcribe")) @@ -118,19 +125,20 @@ public struct AssemblyAITranscriber: TranscriberProtocol { /// Builds the JSON `config` part sent alongside the audio. The context /// `prompt` is included only when non-empty; a nil or blank prompt omits the /// field so the server applies its default prompt. The `llm` block rides - /// along while enhanced transcripts are enabled (the default) and is omitted - /// entirely when the user has turned them off, so the service skips the - /// rewrite and the verbatim transcript is what gets pasted — see - /// `DictationConfig.llm`. Internal so tests can assert the - /// prompt wiring without inspecting the multipart upload body (which - /// `URLProtocol` mocks can't observe reliably for `upload(from:)`). - func makeConfigData(sampleRate: Int, prompt: String?) throws -> Data { + /// along only when `cleanup` is true (a *cleaned* dictation) and is omitted + /// entirely for a *raw* one, so the service skips the rewrite and the verbatim + /// transcript is what gets pasted — see `DictationConfig.llm`. When present it + /// carries the user's custom cleanup instruction (or none, encoding as `{}`, + /// to select the service default). Internal so tests can assert the prompt + /// wiring without inspecting the multipart upload body (which `URLProtocol` + /// mocks can't observe reliably for `upload(from:)`). + func makeConfigData(sampleRate: Int, prompt: String?, cleanup: Bool) throws -> Data { try JSONEncoder().encode( DictationConfig( sampleRate: sampleRate, channels: 1, prompt: prompt.trimmedNonEmpty(), - llm: enhancedTranscriptsEnabled() ? LLMRewrite() : nil + llm: cleanup ? LLMRewrite(instruction: cleanupInstruction().trimmedNonEmpty()) : nil ) ) } @@ -216,13 +224,13 @@ public struct AssemblyAITranscriber: TranscriberProtocol { /// it falls back to the server's default prompt. Steers *transcription*; /// the cleanup rewrite is the `llm` block's job. let prompt: String? - /// The rewrite request, present only while enhanced transcripts are - /// enabled (nil — the synthesized `encode` omits it — asks for no rewrite, - /// so the response's `llm_response` is null and the verbatim `text` is - /// used). An empty object selects the service's default - /// cleanup instruction; per the API's `instruction`-mode rules, output - /// format and don't-answer-the-text safeguards are enforced server-side, - /// so nothing rides along here. + /// The rewrite request, present only for a *cleaned* dictation (nil — the + /// synthesized `encode` omits it — asks for no rewrite, so the response's + /// `llm_response` is null and the verbatim `text` is used). When present it + /// carries the user's custom `instruction` if they set one; an empty object + /// (`{}`) selects the service's default cleanup instruction. Per the API's + /// `instruction`-mode rules, output format and don't-answer-the-text + /// safeguards are enforced server-side. let llm: LLMRewrite? enum CodingKeys: String, CodingKey { case sampleRate = "sample_rate" @@ -232,7 +240,14 @@ public struct AssemblyAITranscriber: TranscriberProtocol { } } - private struct LLMRewrite: Encodable {} + /// The `llm` config block. `instruction` is the user's editable cleanup + /// directive; nil (the synthesized `encode` uses `encodeIfPresent` for + /// optionals) omits the field, so the block encodes as `{}` and the service + /// applies its own default cleanup rewrite — byte-identical to the historical + /// empty-block request. + private struct LLMRewrite: Encodable { + let instruction: String? + } private struct DictationResponse: Decodable { /// The verbatim transcript — always present, never altered by the LLM. diff --git a/Sources/BlurtEngine/STT/TranscriberProtocol.swift b/Sources/BlurtEngine/STT/TranscriberProtocol.swift index c13ca5a..6f67f1a 100644 --- a/Sources/BlurtEngine/STT/TranscriberProtocol.swift +++ b/Sources/BlurtEngine/STT/TranscriberProtocol.swift @@ -8,7 +8,15 @@ public protocol TranscriberProtocol: Sendable { /// /// `context` carries per-utterance priming (focused app + text before the /// cursor) rendered into the request prompt; pass `nil` for none. - func transcribe(pcm: Data, sampleRate: Int, context: TranscriptionContext?) async throws -> String + /// + /// `cleanup` selects which transcript the caller wants: `true` asks the + /// dictation API for its server-side LLM cleanup rewrite (the `llm` block + /// rides along and the polished text comes back), `false` omits it so the + /// verbatim transcript is returned as spoken. It maps straight from the + /// `DictationMode` that started the session (`DictationMode.cleansUp`). + func transcribe( + pcm: Data, sampleRate: Int, context: TranscriptionContext?, cleanup: Bool + ) async throws -> String /// Optionally pre-open the transcription connection so the next `transcribe` /// doesn't pay connection setup (DNS/TCP/TLS) on the latency-sensitive hot diff --git a/Tests/BlurtEngineTests/AssemblyAITranscriberTests.swift b/Tests/BlurtEngineTests/AssemblyAITranscriberTests.swift index 9744a61..0830255 100644 --- a/Tests/BlurtEngineTests/AssemblyAITranscriberTests.swift +++ b/Tests/BlurtEngineTests/AssemblyAITranscriberTests.swift @@ -67,7 +67,8 @@ struct HTTPClientTests { .transcribe( pcm: Self.testPCM, sampleRate: 16_000, - context: TranscriptionContext(appName: "Slack", priorText: "Dear Sam,")) + context: TranscriptionContext(appName: "Slack", priorText: "Dear Sam,"), + cleanup: true) #expect(result == "hello world") } @@ -164,23 +165,43 @@ struct HTTPClientTests { } @Test( - "config part requests the default cleanup rewrite while enhanced transcripts are on", + "config part requests the default cleanup rewrite for a cleaned dictation with no custom prompt", arguments: ["CONTEXT. Transcribe.", nil]) func configRequestsDefaultRewrite(prompt: String?) throws { - // `llm` must be present and empty on every enhanced request: present so the - // service runs the rewrite at all, empty so the server-owned default cleanup - // instruction (and its guardrails) applies rather than a client-side copy. + // `llm` must be present and empty on a cleaned request with no custom + // instruction: present so the service runs the rewrite at all, empty (`{}`) + // so the server-owned default cleanup instruction (and its guardrails) + // applies — byte-identical to the historical enhanced-transcripts request. // `isEmpty == true` also covers presence — it is false for a missing `llm`. #expect((try configObject(prompt: prompt)["llm"] as? [String: Any])?.isEmpty == true) } - @Test("config part omits the llm block when enhanced transcripts are off") - func configOmitsRewriteWhenDisabled() throws { + @Test("config part carries the user's custom cleanup instruction on a cleaned dictation") + func configCarriesCustomInstruction() throws { + // A non-blank instruction rides as `llm.instruction`, steering the rewrite; + // the rest of the config is unaffected. + let object = try configObject(prompt: "CONTEXT. Transcribe.", cleanupInstruction: "Be terse.") + let llm = try #require(object["llm"] as? [String: Any]) + #expect(llm["instruction"] as? String == "Be terse.") + #expect(object["prompt"] as? String == "CONTEXT. Transcribe.") + } + + @Test("config part drops a blank custom instruction to the default rewrite") + func configBlankInstructionSelectsDefault() throws { + // A whitespace-only instruction is treated as "no custom prompt" — the block + // encodes as `{}`, selecting the service default rather than sending blanks. + #expect((try configObject(prompt: nil, cleanupInstruction: " \n")["llm"] as? [String: Any])?.isEmpty == true) + } + + @Test("config part omits the llm block for a raw dictation") + func configOmitsRewriteForRaw() throws { // Omission — not an empty or null `llm` — is what tells the service to skip // the rewrite, so the user gets the verbatim transcript pasted as spoken. - let object = try configObject(prompt: "CONTEXT. Transcribe.", enhancedTranscripts: false) + // A custom instruction set in Settings is ignored for a raw dictation. + let object = try configObject( + prompt: "CONTEXT. Transcribe.", cleanup: false, cleanupInstruction: "Be terse.") #expect(object.keys.contains("llm") == false) - // The rest of the config is unaffected by the switch. + // The rest of the config is unaffected by the mode. #expect(object["sample_rate"] as? Int == 16_000) #expect(object["prompt"] as? String == "CONTEXT. Transcribe.") } @@ -304,30 +325,35 @@ struct HTTPClientTests { /// Builds a transcriber wired to `transport`. The default transport answers /// every request with a 500, for the cases that must never reach the wire. - /// Enhanced transcripts are pinned (on unless a test opts out) rather than + /// The cleanup instruction is pinned (none unless a test sets one) rather than /// left to the production default, which reads the process's real /// `UserDefaults`. private func makeTranscriber( apiKey: String?, transport: any HTTPTransport = FakeHTTPTransport { _ in (500, Data()) }, - enhancedTranscripts: Bool = true + cleanupInstruction: String? = nil ) -> AssemblyAITranscriber { AssemblyAITranscriber( apiKeyProvider: { apiKey }, transport: transport, - enhancedTranscripts: { enhancedTranscripts }) + cleanupInstruction: { cleanupInstruction }) } - private func collectTranscript(_ transcriber: AssemblyAITranscriber) async throws -> String { - try await transcriber.transcribe(pcm: Self.testPCM, sampleRate: 16_000, context: nil) + private func collectTranscript( + _ transcriber: AssemblyAITranscriber, cleanup: Bool = true + ) async throws -> String { + try await transcriber.transcribe( + pcm: Self.testPCM, sampleRate: 16_000, context: nil, cleanup: cleanup) } /// The encoded `config` part re-parsed as a dictionary — the shape every /// config assertion below wants, since `makeConfigData` returns raw JSON. /// A part that isn't a JSON object at all fails here rather than turning every /// downstream assertion into a silent nil-compare. - private func configObject(prompt: String?, enhancedTranscripts: Bool = true) throws -> [String: Any] { - let config = try makeTranscriber(apiKey: "test-key", enhancedTranscripts: enhancedTranscripts) - .makeConfigData(sampleRate: 16_000, prompt: prompt) + private func configObject( + prompt: String?, cleanup: Bool = true, cleanupInstruction: String? = nil + ) throws -> [String: Any] { + let config = try makeTranscriber(apiKey: "test-key", cleanupInstruction: cleanupInstruction) + .makeConfigData(sampleRate: 16_000, prompt: prompt, cleanup: cleanup) return try #require(JSONSerialization.jsonObject(with: config) as? [String: Any]) } diff --git a/Tests/BlurtEngineTests/CancelRaceTests.swift b/Tests/BlurtEngineTests/CancelRaceTests.swift index 356c5ab..3e033ac 100644 --- a/Tests/BlurtEngineTests/CancelRaceTests.swift +++ b/Tests/BlurtEngineTests/CancelRaceTests.swift @@ -262,7 +262,7 @@ private actor GatedTranscriber: TranscriberProtocol { self.throwsWhenCancelled = throwsWhenCancelled } - func transcribe(pcm: Data, sampleRate: Int, context: TranscriptionContext?) + func transcribe(pcm: Data, sampleRate: Int, context: TranscriptionContext?, cleanup: Bool) async throws -> String { await gate.enter() diff --git a/Tests/BlurtEngineTests/CleanupPromptStoreTests.swift b/Tests/BlurtEngineTests/CleanupPromptStoreTests.swift new file mode 100644 index 0000000..ef0cd5f --- /dev/null +++ b/Tests/BlurtEngineTests/CleanupPromptStoreTests.swift @@ -0,0 +1,58 @@ +import Foundation +import Testing + +@testable import BlurtEngine + +@Suite("CleanupPromptStore") +struct CleanupPromptStoreTests { + @Test("reads nil when unset") + func nilWhenUnset() { + #expect(CleanupPromptStore(defaults: freshDefaults()).instruction == nil) + } + + @Test("persists and reads back an instruction") + func roundTrips() { + let defaults = freshDefaults() + let store = CleanupPromptStore(defaults: defaults) + store.instruction = "Fix punctuation." + #expect(CleanupPromptStore(defaults: defaults).instruction == "Fix punctuation.") + } + + @Test("a blank instruction reads back as nil and removes the key") + func blankIsNil() { + let defaults = freshDefaults() + let store = CleanupPromptStore(defaults: defaults) + store.instruction = "Be terse." + store.instruction = " \n" + #expect(CleanupPromptStore(defaults: defaults).instruction == nil) + // Blank clears the slot entirely rather than persisting whitespace. + #expect(defaults.object(forKey: CleanupPromptStore.defaultsKey) == nil) + } + + @Test("the setter stores the raw value so trailing whitespace survives") + func setterPreservesRawWhitespace() { + let defaults = freshDefaults() + let store = CleanupPromptStore(defaults: defaults) + store.instruction = "Be terse. " + // Stored raw (untrimmed), so a trailing space the user is mid-typing isn't + // eaten by a normalizing write bouncing back through `@AppStorage`. + #expect(defaults.string(forKey: CleanupPromptStore.defaultsKey) == "Be terse. ") + // The getter still trims for the transcriber. + #expect(store.instruction == "Be terse.") + } + + @Test("the getter trims surrounding whitespace") + func getterTrims() { + let defaults = freshDefaults() + defaults.set(" Be terse. ", forKey: CleanupPromptStore.defaultsKey) + #expect(CleanupPromptStore(defaults: defaults).instruction == "Be terse.") + } + + @Test("the setter truncates to the character cap") + func setterTruncates() { + let defaults = freshDefaults() + let store = CleanupPromptStore(defaults: defaults) + store.instruction = String(repeating: "x", count: CleanupPromptStore.characterCap + 50) + #expect(store.instruction?.count == CleanupPromptStore.characterCap) + } +} diff --git a/Tests/BlurtEngineTests/DictationKeyRouterTests.swift b/Tests/BlurtEngineTests/DictationKeyRouterTests.swift deleted file mode 100644 index 7c92ae2..0000000 --- a/Tests/BlurtEngineTests/DictationKeyRouterTests.swift +++ /dev/null @@ -1,168 +0,0 @@ -import Testing - -@testable import BlurtEngine - -/// The router's two jobs on top of `DictationKeyGate` (whose tap/hold semantics -/// have their own suites): only the bound keycode's flag *edges* reach the gate -/// — `flagsChanged` deliveries re-report the bit whether or not it changed, so -/// a repeat must not double-fire — and reset/rebind report whether they -/// discarded a live recording the host has to cancel upstream. -@Suite("DictationKeyRouter") -struct DictationKeyRouterTests { - private let trigger = TriggerKey.rightCommand.keyCode - private let otherModifier = TriggerKey.rightOption.keyCode - - private func downEvent(_ keyCode: Int) -> DictationKeyRouter.Event { - .flagsChanged(keyCode: keyCode, triggerFlagIsOn: true) - } - - private func upEvent(_ keyCode: Int) -> DictationKeyRouter.Event { - .flagsChanged(keyCode: keyCode, triggerFlagIsOn: false) - } - - @Test("a held press is start → stop") - func holdIsStartStop() { - var router = DictationKeyRouter(triggerKeyCode: trigger) - #expect(router.handle(downEvent(trigger), at: .zero) == .start) - #expect(router.handle(upEvent(trigger), at: .seconds(2)) == .stop) - } - - @Test("a repeated down-state delivery doesn't re-fire the gate") - func repeatedDownStateIsDeduped() { - // While the trigger is held, another flags delivery can re-report its bit - // still set; re-arming the gate on it would corrupt the tap/hold timing. - var router = DictationKeyRouter(triggerKeyCode: trigger) - #expect(router.handle(downEvent(trigger), at: .zero) == .start) - #expect(router.handle(downEvent(trigger), at: .milliseconds(50)) == .none) - // The eventual release still stops the (single) dictation. - #expect(router.handle(upEvent(trigger), at: .seconds(2)) == .stop) - } - - @Test("an up-state delivery with no tracked down is ignored") - func upWithoutDownIsIgnored() { - var router = DictationKeyRouter(triggerKeyCode: trigger) - #expect(router.handle(upEvent(trigger), at: .zero) == .none) - } - - @Test("flag changes reported for another keycode never reach the gate") - func otherKeycodeFlagsAreIgnored() { - // E.g. right ⌥ going down while right ⌘ is bound: the delivery's flags may - // even carry the trigger's bit, but the event isn't about the bound key. - var router = DictationKeyRouter(triggerKeyCode: trigger) - #expect(router.handle(downEvent(otherModifier), at: .zero) == .none) - #expect(router.handle(upEvent(otherModifier), at: .seconds(2)) == .none) - } - - @Test("another key over a fresh press is a combo and cancels") - func comboCancelsFreshCapture() { - var router = DictationKeyRouter(triggerKeyCode: trigger) - #expect(router.handle(downEvent(trigger), at: .zero) == .start) - #expect(router.handle(.keyDown(keyCode: 8), at: .milliseconds(100)) == .cancel) // ⌘C - } - - @Test("the trigger's own keyDown is not a combo") - func triggerKeyDownIsNotACombo() { - var router = DictationKeyRouter(triggerKeyCode: trigger) - #expect(router.handle(downEvent(trigger), at: .zero) == .start) - #expect(router.handle(.keyDown(keyCode: trigger), at: .milliseconds(100)) == .none) - #expect(router.handle(upEvent(trigger), at: .seconds(2)) == .stop) - } - - @Test("a short tap latches; the next tap stops") - func tapToToggle() { - var router = DictationKeyRouter(triggerKeyCode: trigger) - #expect(router.handle(downEvent(trigger), at: .zero) == .start) - #expect(router.handle(upEvent(trigger), at: .milliseconds(200)) == .none) // latched - #expect(router.handle(downEvent(trigger), at: .seconds(5)) == .none) - #expect(router.handle(upEvent(trigger), at: .seconds(5) + .milliseconds(200)) == .stop) - } - - // Note: `reset()`/`rebind(_:)` results are hoisted into locals below because - // #expect can't invoke a mutating method directly (its expansion captures the - // receiver in an immutable closure). - - @Test("reset while idle reports nothing discarded") - func resetWhileIdle() { - var router = DictationKeyRouter(triggerKeyCode: trigger) - let discarded = router.reset() - #expect(!discarded) - } - - @Test("reset mid-recording reports the discarded recording") - func resetMidRecordingReportsDiscard() { - var router = DictationKeyRouter(triggerKeyCode: trigger) - #expect(router.handle(downEvent(trigger), at: .zero) == .start) - let discarded = router.reset() - #expect(discarded) - // The tracker cleared too: the stale key-up is ignored, a new press starts. - #expect(router.handle(upEvent(trigger), at: .seconds(2)) == .none) - #expect(router.handle(downEvent(trigger), at: .seconds(3)) == .start) - } - - @Test("reset over a latched recording reports the discarded recording") - func resetOverLatchedReportsDiscard() { - var router = DictationKeyRouter(triggerKeyCode: trigger) - #expect(router.handle(downEvent(trigger), at: .zero) == .start) - #expect(router.handle(upEvent(trigger), at: .milliseconds(200)) == .none) // latched - let discarded = router.reset() - #expect(discarded) - } - - @Test("a latch left behind by a keyless dictation end doesn't swallow the next press") - func resetClearsLatchSoNextPressStarts() { - // The bug this pins: when a dictation ends WITHOUT a key event — the - // auto-release cap fires, or the press is refused/failed — the gate stays - // `.latched`. A latched `modifierDown` returns `.none` and the `modifierUp` - // after it returns `.stop`, which no-ops on an already-terminal session, so - // the user's whole next press does nothing. `DictationKeyTap`'s - // `syncAfterTerminalPhase()` calls `reset()` to clear it; this pins that a - // reset genuinely restores the next press. - var router = DictationKeyRouter(triggerKeyCode: trigger) - #expect(router.handle(downEvent(trigger), at: .zero) == .start) - #expect(router.handle(upEvent(trigger), at: .milliseconds(200)) == .none) // latched - - // Without the reset, this next tap is swallowed — the exact dead press. - var swallowed = router - #expect(swallowed.handle(downEvent(trigger), at: .seconds(5)) == .none) - - router.reset() - #expect(router.handle(downEvent(trigger), at: .seconds(5)) == .start) - } - - @Test("reset after a keyless end ignores a stale key-up, then starts cleanly") - func resetWhileHeldThenReleaseIsInert() { - // The auto-release/failed-press case where the trigger is still physically - // held when the phase goes terminal. `syncAfterTerminalPhase` resets anyway - // (the dictation is over), which clears the modifier tracker — so the release - // that follows must route to `.none` rather than emitting a spurious `.stop`, - // and the press after that must start normally. - var router = DictationKeyRouter(triggerKeyCode: trigger) - #expect(router.handle(downEvent(trigger), at: .zero) == .start) - - router.reset() // terminal phase arrived while the key is still down - - #expect(router.handle(upEvent(trigger), at: .milliseconds(300)) == .none) - #expect(router.handle(downEvent(trigger), at: .seconds(2)) == .start) - } - - @Test("rebind mid-recording discards it and switches keycodes") - func rebindMidRecording() { - var router = DictationKeyRouter(triggerKeyCode: trigger) - #expect(router.handle(downEvent(trigger), at: .zero) == .start) - // Rebinding means the old key's up-event can never match — the caller must - // cancel the capture rather than let the auto-release cap paste it. - let discarded = router.rebind(triggerKeyCode: otherModifier) - #expect(discarded) - #expect(router.triggerKeyCode == otherModifier) - // The old key is now irrelevant; the new one drives dictation. - #expect(router.handle(downEvent(trigger), at: .seconds(1)) == .none) - #expect(router.handle(downEvent(otherModifier), at: .seconds(2)) == .start) - } - - @Test("rebind while idle reports nothing discarded") - func rebindWhileIdle() { - var router = DictationKeyRouter(triggerKeyCode: trigger) - let discarded = router.rebind(triggerKeyCode: otherModifier) - #expect(!discarded) - } -} diff --git a/Tests/BlurtEngineTests/DictationModeTests.swift b/Tests/BlurtEngineTests/DictationModeTests.swift new file mode 100644 index 0000000..6e98d6d --- /dev/null +++ b/Tests/BlurtEngineTests/DictationModeTests.swift @@ -0,0 +1,14 @@ +import Testing + +@testable import BlurtEngine + +@Suite("DictationMode") +struct DictationModeTests { + @Test("only the cleaned mode asks for the server-side cleanup rewrite") + func cleansUp() { + // `cleansUp` is what `makeConfigData` reads to decide the `llm` block's + // presence, so pin the mapping: raw omits it, cleaned includes it. + #expect(DictationMode.raw.cleansUp == false) + #expect(DictationMode.cleaned.cleansUp == true) + } +} diff --git a/Tests/BlurtEngineTests/DictationSessionModeTests.swift b/Tests/BlurtEngineTests/DictationSessionModeTests.swift new file mode 100644 index 0000000..068205e --- /dev/null +++ b/Tests/BlurtEngineTests/DictationSessionModeTests.swift @@ -0,0 +1,114 @@ +import Foundation +import Testing + +@testable import BlurtEngine + +/// The dictation mode chosen at press time must reach the transcriber's +/// `cleanup` flag unchanged, so the raw key gets a verbatim request and the +/// cleaned key an LLM-rewrite request. Pins that threading through the pipeline. +@Suite("DictationSession mode", .timeLimit(.minutes(1))) +struct DictationSessionModeTests { + @Test( + "press(mode:) threads the cleanup flag to the transcriber", + arguments: [(DictationMode.raw, false), (DictationMode.cleaned, true)]) + func modeThreadsCleanup(mode: DictationMode, expectedCleanup: Bool) async { + let transcriber = ModeRecordingTranscriber() + let session = DictationSession( + mic: StubMicCapture(), transcriber: transcriber, injector: StubInjector()) + + await session.press(mode: mode) + await session.release() + await session.awaitPipeline() + + #expect(await transcriber.lastCleanup == expectedCleanup) + } + + @Test("a mode-less press defaults to the cleaned (rewrite) request") + func defaultPressCleansUp() async { + let transcriber = ModeRecordingTranscriber() + let session = DictationSession( + mic: StubMicCapture(), transcriber: transcriber, injector: StubInjector()) + + await session.press() + await session.release() + await session.awaitPipeline() + + #expect(await transcriber.lastCleanup == true) + } + + @Test("a press the terminal-phase guard rejects doesn't clobber the in-flight mode") + func rejectedPressLeavesInFlightModeUntouched() async { + // The reachable race the guard-ordering fix closes: after a release the gate + // is idle, so the *other* mode's key can fire a fresh `.start` while the + // prior dictation is still `.transcribing`. That press is rejected by + // `guard phase.isTerminal`, but must not overwrite `activeMode` first — the + // in-flight run reads it after its context-wait. + let transcriber = GatedModeTranscriber() + let session = DictationSession( + mic: StubMicCapture(), transcriber: transcriber, injector: StubInjector()) + + await session.press(mode: .raw) + await session.release() + // Park deterministically inside the (gated) transcribe, so the actor is free + // and the injected press below runs to its guard before the pipeline resumes. + await transcriber.waitUntilStarted() + + // The other key taps mid-transcribe: `.transcribing` is non-terminal, so the + // guard rejects it. With the bug it would first set `activeMode = .cleaned`. + await session.press(mode: .cleaned) + #expect(await session.activeMode == .raw) + + await transcriber.allowToFinish() + await session.awaitPipeline() + // The delivered dictation transcribed as raw, never re-tagged to cleaned. + #expect(await transcriber.recordedCleanup == false) + } +} + +/// A transcriber that parks inside `transcribe` until released, so a test can +/// land a second press while the first dictation is deterministically in-flight. +/// Records the `cleanup` flag it was actually called with. +private actor GatedModeTranscriber: TranscriberProtocol { + private(set) var recordedCleanup: Bool? + private var startedFlag = false + private var startedWaiter: CheckedContinuation? + private var releaseFlag = false + private var releaseWaiter: CheckedContinuation? + + func transcribe( + pcm: Data, sampleRate: Int, context: TranscriptionContext?, cleanup: Bool + ) async throws -> String { + recordedCleanup = cleanup + startedFlag = true + startedWaiter?.resume() + startedWaiter = nil + if !releaseFlag { + await withCheckedContinuation { releaseWaiter = $0 } + } + return "Hello world." + } + + func waitUntilStarted() async { + if startedFlag { return } + await withCheckedContinuation { startedWaiter = $0 } + } + + func allowToFinish() { + releaseFlag = true + releaseWaiter?.resume() + releaseWaiter = nil + } +} + +/// Records the `cleanup` flag of the most recent request so a test can assert +/// the mode reached the wire boundary. +private actor ModeRecordingTranscriber: TranscriberProtocol { + private(set) var lastCleanup: Bool? + + func transcribe( + pcm: Data, sampleRate: Int, context: TranscriptionContext?, cleanup: Bool + ) async throws -> String { + lastCleanup = cleanup + return "Hello world." + } +} diff --git a/Tests/BlurtEngineTests/DictationSessionSubmitTests.swift b/Tests/BlurtEngineTests/DictationSessionSubmitTests.swift index 01214e2..e5172b1 100644 --- a/Tests/BlurtEngineTests/DictationSessionSubmitTests.swift +++ b/Tests/BlurtEngineTests/DictationSessionSubmitTests.swift @@ -15,7 +15,7 @@ struct DictationSessionSubmitTests { let fixture = makeSession() let stream = await fixture.session.phaseStream() - fixture.session.submit(.press) + fixture.session.submit(.press(.cleaned)) fixture.session.submit(.release) var seen: [PipelinePhase] = [] @@ -39,7 +39,7 @@ struct DictationSessionSubmitTests { // The exact shape of the race `submit` exists to prevent: were each of // these a separately spawned Task, the cancel could overtake the press, // no-op on a still-idle session, and strand the recording. - fixture.session.submit(.press) + fixture.session.submit(.press(.cleaned)) fixture.session.submit(.cancel) for await phase in stream where phase == .cancelled { break } @@ -54,7 +54,7 @@ struct DictationSessionSubmitTests { let fixture = makeSession(mode: .transcript("never")) let stream = await fixture.session.phaseStream() - fixture.session.submit(.press) + fixture.session.submit(.press(.raw)) fixture.session.submit(.cancelRecording) for await phase in stream where phase == .cancelled { break } diff --git a/Tests/BlurtEngineTests/DictationTriggerPairTests.swift b/Tests/BlurtEngineTests/DictationTriggerPairTests.swift new file mode 100644 index 0000000..ea6320e --- /dev/null +++ b/Tests/BlurtEngineTests/DictationTriggerPairTests.swift @@ -0,0 +1,47 @@ +import Testing + +@testable import BlurtEngine + +/// `assigning` is the one place the "the two dictation keys must stay distinct" +/// rule lives, so pin its three cases: a clean assign, a colliding assign that +/// swaps, and a no-op assign to the key a mode already holds. +@Suite("DictationTriggerPair") +struct DictationTriggerPairTests { + @Test("assigning a free key to a mode leaves the other mode untouched") + func assignWithoutCollision() { + let pair = DictationTriggerPair(raw: .rightOption, cleaned: .rightCommand) + let updated = pair.assigning(.raw, to: .function) + #expect(updated == DictationTriggerPair(raw: .function, cleaned: .rightCommand)) + } + + @Test("assigning a free key to the cleaned mode leaves the other mode untouched") + func assignCleanedWithoutCollision() { + let pair = DictationTriggerPair(raw: .rightOption, cleaned: .rightCommand) + let updated = pair.assigning(.cleaned, to: .function) + #expect(updated == DictationTriggerPair(raw: .rightOption, cleaned: .function)) + } + + @Test("assigning a mode the other mode's key swaps them, preserving distinctness") + func assignWithCollisionSwaps() { + let pair = DictationTriggerPair(raw: .rightOption, cleaned: .rightCommand) + // Ask raw to take the cleaned key: cleaned takes raw's vacated key (a swap) + // rather than both landing on right ⌘. + let updated = pair.assigning(.raw, to: .rightCommand) + #expect(updated == DictationTriggerPair(raw: .rightCommand, cleaned: .rightOption)) + #expect(updated.raw != updated.cleaned) + } + + @Test("the swap works symmetrically from the cleaned side") + func assignCleanedWithCollisionSwaps() { + let pair = DictationTriggerPair(raw: .rightOption, cleaned: .rightCommand) + let updated = pair.assigning(.cleaned, to: .rightOption) + #expect(updated == DictationTriggerPair(raw: .rightCommand, cleaned: .rightOption)) + } + + @Test("assigning a mode the key it already holds is a no-op") + func assignSelfIsNoOp() { + let pair = DictationTriggerPair(raw: .rightOption, cleaned: .rightCommand) + #expect(pair.assigning(.raw, to: .rightOption) == pair) + #expect(pair.assigning(.cleaned, to: .rightCommand) == pair) + } +} diff --git a/Tests/BlurtEngineTests/DualTriggerRouterTests.swift b/Tests/BlurtEngineTests/DualTriggerRouterTests.swift new file mode 100644 index 0000000..18a78d3 --- /dev/null +++ b/Tests/BlurtEngineTests/DualTriggerRouterTests.swift @@ -0,0 +1,158 @@ +import Testing + +@testable import BlurtEngine + +/// The dual router's jobs on top of `DictationKeyGate` (whose tap/hold semantics +/// have their own suites): route two keys' flag *edges* into one shared gate, +/// report which mode opened a session on `.start`, ignore the non-owning key +/// while a session is live, dedup repeated flag deliveries, and report a +/// discarded recording on reset/rebind. +@Suite("DualTriggerRouter") +struct DualTriggerRouterTests { + private let raw = TriggerKey.rightOption.keyCode + private let cleaned = TriggerKey.rightCommand.keyCode + + private func rawDown() -> DualTriggerRouter.Event { + .flagsChanged(keyCode: raw, rawFlagIsOn: true, cleanedFlagIsOn: false) + } + private func rawUp() -> DualTriggerRouter.Event { + .flagsChanged(keyCode: raw, rawFlagIsOn: false, cleanedFlagIsOn: false) + } + private func cleanedDown() -> DualTriggerRouter.Event { + .flagsChanged(keyCode: cleaned, rawFlagIsOn: false, cleanedFlagIsOn: true) + } + private func cleanedUp() -> DualTriggerRouter.Event { + .flagsChanged(keyCode: cleaned, rawFlagIsOn: false, cleanedFlagIsOn: false) + } + + private func makeRouter() -> DualTriggerRouter { + DualTriggerRouter(rawKeyCode: raw, cleanedKeyCode: cleaned) + } + + private func start(_ mode: DictationMode) -> DualTriggerRouter.Outcome { + .init(action: .start, mode: mode) + } + private func plain(_ action: DictationKeyGate.Action) -> DualTriggerRouter.Outcome { + .init(action: action, mode: nil) + } + + @Test("a held raw press is start(.raw) → stop") + func rawHoldIsStartStop() { + var router = makeRouter() + #expect(router.handle(rawDown(), at: .zero) == start(.raw)) + // A start reports the owning mode; the stop does not. + #expect(router.handle(rawUp(), at: .seconds(2)) == plain(.stop)) + } + + @Test("a cleaned tap latches; the next tap stops, and start reports .cleaned") + func cleanedTapToToggle() { + var router = makeRouter() + #expect(router.handle(cleanedDown(), at: .zero) == start(.cleaned)) + #expect(router.handle(cleanedUp(), at: .milliseconds(200)) == plain(.none)) // latched + #expect(router.handle(cleanedDown(), at: .seconds(5)) == plain(.none)) + #expect(router.handle(cleanedUp(), at: .seconds(5) + .milliseconds(200)) == plain(.stop)) + } + + @Test("another key over a fresh press is a combo and cancels") + func comboCancelsFreshCapture() { + var router = makeRouter() + #expect(router.handle(rawDown(), at: .zero) == start(.raw)) + #expect(router.handle(.keyDown(keyCode: 8), at: .milliseconds(100)) == plain(.cancel)) // ⌘C + } + + @Test("a trigger's own keyDown is not a combo") + func triggerKeyDownIsNotACombo() { + var router = makeRouter() + #expect(router.handle(rawDown(), at: .zero) == start(.raw)) + #expect(router.handle(.keyDown(keyCode: raw), at: .milliseconds(100)) == plain(.none)) + #expect(router.handle(.keyDown(keyCode: cleaned), at: .milliseconds(150)) == plain(.none)) + #expect(router.handle(rawUp(), at: .seconds(2)) == plain(.stop)) + } + + @Test("a repeated down-state delivery doesn't re-fire the gate") + func repeatedDownStateIsDeduped() { + var router = makeRouter() + #expect(router.handle(rawDown(), at: .zero) == start(.raw)) + // A re-reported still-down bit must not re-arm the gate (corrupting timing). + #expect(router.handle(rawDown(), at: .milliseconds(50)) == plain(.none)) + #expect(router.handle(rawUp(), at: .seconds(2)) == plain(.stop)) + } + + @Test("an up-state delivery with no tracked down is ignored") + func upWithoutDownIsIgnored() { + var router = makeRouter() + #expect(router.handle(rawUp(), at: .zero) == plain(.none)) + #expect(router.handle(cleanedUp(), at: .zero) == plain(.none)) + } + + @Test("flag changes for an unbound keycode never reach the gate") + func unboundKeycodeIsIgnored() { + var router = makeRouter() + // keycode 99 is neither trigger; the bits are irrelevant. + #expect( + router.handle(.flagsChanged(keyCode: 99, rawFlagIsOn: true, cleanedFlagIsOn: true), at: .zero) + == plain(.none)) + } + + @Test("the other key's flag changes are ignored while a session is active") + func otherKeyIgnoredWhileActive() { + var router = makeRouter() + #expect(router.handle(cleanedDown(), at: .zero) == start(.cleaned)) + // Raw goes down and up while the cleaned session owns the gate: both inert, + // and neither disturbs the running dictation. + #expect(router.handle(rawDown(), at: .milliseconds(100)) == plain(.none)) + #expect(router.handle(rawUp(), at: .milliseconds(200)) == plain(.none)) + // The cleaned key still stops its own (held) session. + #expect(router.handle(cleanedUp(), at: .seconds(2)) == plain(.stop)) + } + + @Test("ownership clears on stop, so the other key can start the next session") + func ownershipClearsBetweenSessions() { + var router = makeRouter() + #expect(router.handle(rawDown(), at: .zero) == start(.raw)) + #expect(router.handle(rawUp(), at: .seconds(2)) == plain(.stop)) + // A fresh session from the other key reports its own mode. + #expect(router.handle(cleanedDown(), at: .seconds(3)) == start(.cleaned)) + #expect(router.handle(cleanedUp(), at: .seconds(5)) == plain(.stop)) + } + + @Test("reset while idle reports nothing discarded") + func resetWhileIdle() { + var router = makeRouter() + let discarded = router.reset() + #expect(!discarded) + } + + @Test("reset mid-recording discards the live state and clears trackers") + func resetMidRecordingReportsDiscard() { + var router = makeRouter() + #expect(router.handle(rawDown(), at: .zero) == start(.raw)) + let discarded = router.reset() + #expect(discarded) + // Trackers and ownership cleared: the stale key-up is inert, a new press + // (on either key) starts cleanly. + #expect(router.handle(rawUp(), at: .seconds(2)) == plain(.none)) + #expect(router.handle(cleanedDown(), at: .seconds(3)) == start(.cleaned)) + } + + @Test("rebind mid-recording discards it and switches keycodes") + func rebindMidRecording() { + var router = makeRouter() + #expect(router.handle(cleanedDown(), at: .zero) == start(.cleaned)) + let discarded = router.rebind(rawKeyCode: cleaned, cleanedKeyCode: raw) // swap the two + #expect(discarded) + #expect(router.rawKeyCode == cleaned) + #expect(router.cleanedKeyCode == raw) + // The keycode that was "cleaned" now drives the raw mode. + #expect( + router.handle(.flagsChanged(keyCode: cleaned, rawFlagIsOn: true, cleanedFlagIsOn: false), at: .seconds(1)) + == start(.raw)) + } + + @Test("rebind while idle reports nothing discarded") + func rebindWhileIdle() { + var router = makeRouter() + let discarded = router.rebind(rawKeyCode: cleaned, cleanedKeyCode: raw) + #expect(!discarded) + } +} diff --git a/Tests/BlurtEngineTests/EnhancedTranscriptsStoreTests.swift b/Tests/BlurtEngineTests/EnhancedTranscriptsStoreTests.swift deleted file mode 100644 index 4c60f35..0000000 --- a/Tests/BlurtEngineTests/EnhancedTranscriptsStoreTests.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Foundation -import Testing - -@testable import BlurtEngine - -@Suite("EnhancedTranscriptsStore") -struct EnhancedTranscriptsStoreTests { - @Test("defaults to on when unset") - func defaultsToOn() { - // The cleanup rewrite is the product's default behavior — an unset key - // must read as enabled, unlike the bool stores that default to off. - #expect(EnhancedTranscriptsStore(defaults: freshDefaults()).isEnabled) - } - - @Test("persists and reads back the switch") - func roundTrips() { - let defaults = freshDefaults() - let store = EnhancedTranscriptsStore(defaults: defaults) - store.isEnabled = false - #expect(!EnhancedTranscriptsStore(defaults: defaults).isEnabled) - store.isEnabled = true - #expect(EnhancedTranscriptsStore(defaults: defaults).isEnabled) - } -} diff --git a/Tests/BlurtEngineTests/PersistedSettingsTests.swift b/Tests/BlurtEngineTests/PersistedSettingsTests.swift index 34ee2d3..3492c41 100644 --- a/Tests/BlurtEngineTests/PersistedSettingsTests.swift +++ b/Tests/BlurtEngineTests/PersistedSettingsTests.swift @@ -11,13 +11,15 @@ struct PersistedSettingsTests { @Test("the reset roster names every engine store's defaults key") func rosterCoversEveryStore() { #expect(PersistedSettings.allDefaultsKeys.contains(TriggerKeyStore.defaultsKey)) + // The raw trigger's keycode — the companion to the cleaned trigger, so a + // reset returns both dictation keys to their (distinct) defaults. + #expect(PersistedSettings.allDefaultsKeys.contains(RawTriggerKeyStore.defaultsKey)) #expect(PersistedSettings.allDefaultsKeys.contains(SoundPackStore.defaultsKey)) #expect(PersistedSettings.allDefaultsKeys.contains(KeyTermsStore.defaultsKey)) #expect(PersistedSettings.allDefaultsKeys.contains(DeveloperModeStore.defaultsKey)) - // Enhanced transcripts default to ON, so its key matters to the sweep in - // the other direction too: a stray `false` surviving a reset would leave a - // "clean" install pasting verbatim transcripts. - #expect(PersistedSettings.allDefaultsKeys.contains(EnhancedTranscriptsStore.defaultsKey)) + // The editable cleanup instruction: a stray custom prompt surviving a reset + // would steer a "clean" install's rewrites in ways the user never set. + #expect(PersistedSettings.allDefaultsKeys.contains(CleanupPromptStore.defaultsKey)) // OverlayOriginStore persists a point, so it contributes two keys rather // than one. Both belong to the sweep: while they were private to // `OverlayWindowController`, no reset knew about them and a pill dragged @@ -31,10 +33,10 @@ struct PersistedSettingsTests { @Test("the roster carries no stale or duplicate keys") func rosterHasNoStrays() { - // Exactly the seven known stores' keys (OverlayOriginStore contributes two): + // Exactly the eight known stores' keys (OverlayOriginStore contributes two): // a removed store must leave the roster in the same change, and a key listed // twice would hint at a copy-paste slip. - #expect(PersistedSettings.allDefaultsKeys.count == 8) + #expect(PersistedSettings.allDefaultsKeys.count == 9) #expect(Set(PersistedSettings.allDefaultsKeys).count == PersistedSettings.allDefaultsKeys.count) } diff --git a/Tests/BlurtEngineTests/RawTriggerKeyStoreTests.swift b/Tests/BlurtEngineTests/RawTriggerKeyStoreTests.swift new file mode 100644 index 0000000..42f3d87 --- /dev/null +++ b/Tests/BlurtEngineTests/RawTriggerKeyStoreTests.swift @@ -0,0 +1,32 @@ +import Foundation +import Testing + +@testable import BlurtEngine + +@Suite("RawTriggerKeyStore") +struct RawTriggerKeyStoreTests { + @Test("defaults to right option when unset") + func defaultsToRightOption() { + // The raw trigger's default is right ⌥, distinct from the cleaned trigger's + // right ⌘ (`TriggerKeyStore`), so the two keys start out different. + let store = RawTriggerKeyStore(defaults: freshDefaults()) + #expect(store.triggerKey == .rightOption) + } + + @Test("persists and reads back a chosen key") + func roundTrips() { + let defaults = freshDefaults() + let store = RawTriggerKeyStore(defaults: defaults) + store.triggerKey = .function + #expect(RawTriggerKeyStore(defaults: defaults).triggerKey == .function) + } + + @Test("an unknown stored code falls back to right option") + func unknownFallsBack() { + // Unlike TriggerKeyStore (which falls back to right ⌘ via `fromPersisted`), + // the raw store applies its own right-⌥ fallback for an unknown or 0 code. + let defaults = freshDefaults() + defaults.set(123, forKey: RawTriggerKeyStore.defaultsKey) + #expect(RawTriggerKeyStore(defaults: defaults).triggerKey == .rightOption) + } +} diff --git a/Tests/BlurtEngineTests/Stubs/StubTranscriber.swift b/Tests/BlurtEngineTests/Stubs/StubTranscriber.swift index 64ae583..e8fb8fb 100644 --- a/Tests/BlurtEngineTests/Stubs/StubTranscriber.swift +++ b/Tests/BlurtEngineTests/Stubs/StubTranscriber.swift @@ -11,7 +11,9 @@ actor StubTranscriber: TranscriberProtocol { init(mode: Mode) { self.mode = mode } - func transcribe(pcm: Data, sampleRate: Int, context: TranscriptionContext?) async throws -> String { + func transcribe( + pcm: Data, sampleRate: Int, context: TranscriptionContext?, cleanup: Bool + ) async throws -> String { switch mode { case .transcript(let transcript): return transcript