Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions .claude/skills/project-guardrails/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,13 @@ genuinely correct, and reaching for it means it's time to stop and ask.
that is about the **upload**: the service starts inferring as the audio
arrives, and still answers with one final transcript. No deltas, no partial
results, no WebSocket — don't read the route name as permission to add them.
The one socket in the tree is the experimental read-selection-aloud feature's
AssemblyAI streaming **TTS** client (`TTS/AssemblyAISpeechSynthesizer.swift`,
the only file `check-invariants.sh` exempts); it speaks text, never
transcribes, so it is not a door back to streaming STT.
There is no socket in the tree: read-aloud's TTS client
(`TTS/AssemblyAISpeechSynthesizer.swift`) is a streaming HTTP response, and
it speaks text, never transcribes, so it is not a door back to streaming STT.
- **No separate LLM cleanup pass.** Cleanup rides in the same dictation request,
as `config.llm_instruction` (`CleanupInstruction`). No LLM Gateway
client for dictation, no `StylerProtocol`, no post-transcription styling
stage. The one gateway client in the tree is the experimental read-aloud
stage. The one gateway client in the tree is the read-aloud
feature's (`TTS/ReadAloudLLM.swift`, the only file `check-invariants.sh`
exempts from this rule): the skip-jargon listening rewrite of a selection, and
the spoken answer to a request about one. It never cleans up a dictation, so
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,10 @@ jobs:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: macos-26
timeout-minutes: 30
# The UI suite alone is ~17 minutes (47 tests, driving every trigger key and
# read-aloud end to end) on top of the engine suite, the leak run and the
# lint passes; 30 cut a passing run off in its last lint step.
timeout-minutes: 45

steps:
- name: Checkout blurt
Expand Down
128 changes: 85 additions & 43 deletions AGENTS.md

Large diffs are not rendered by default.

58 changes: 42 additions & 16 deletions App/Blurt/Blurt.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

41 changes: 27 additions & 14 deletions App/Blurt/Blurt/AppCoordinator+UITesting.swift
Original file line number Diff line number Diff line change
@@ -1,20 +1,33 @@
#if UITEST_HOOKS
import BlurtEngine

// MARK: - UI-test dictation drivers
//
// XCUITest can't synthesize the lone right-modifier `flagsChanged` event the
// real trigger relies on — and the `CGEventTap` that reads it needs the
// process to be Accessibility-trusted, which the CI test host isn't. The test
// harness window instead calls `session.press()`/`release()`/`cancel()`
// directly, driving the *same* `DictationSession` (and its
// press-time missing-key readiness check) the key tap's closures reach via
// `session.submit(_:)`. Compiled only in Debug, so nothing here ships in the
// notarized Release build.
// XCUITest can't synthesize the lone-modifier `flagsChanged` event the real
// triggers rely on — and the `CGEventTap` that reads it needs the process to
// be Accessibility-trusted, which the CI test host isn't. The test harness
// window drives the *real* key tap's gate and callbacks instead, for any
// trigger key, so the whole path — the tap, the read-aloud router, the
// session — runs as a keypress would. Compiled only in Debug, so nothing here
// ships in the notarized Release build.
extension AppCoordinator {
/// Fire a synthetic dictation-key press/release through the *real* key tap
/// (gate + callbacks), not the session directly. Lets the leak exercise
/// (`scripts/leaks.sh`) cover the DictationKeyTap object graph a real
/// keypress would build. No-ops if the tap wasn't created.
func simulateDictationPressForTesting() { keyTap?.simulatePressForTesting() }
func simulateDictationReleaseForTesting() { keyTap?.simulateReleaseForTesting() }
/// Fire a synthetic press/release of `key` (the bound key unless given)
/// through the real key tap. No-ops if the tap wasn't created.
func simulateKeyPressForTesting(_ key: TriggerKey? = nil) { keyTap?.simulatePressForTesting(key) }
func simulateKeyReleaseForTesting(_ key: TriggerKey? = nil, asTap: Bool = false) {
keyTap?.simulateReleaseForTesting(key, asTap: asTap)
}

/// Sets how `key` is pressed, as the Keys tab would, and rebinds the tap.
func setKeyActivationForTesting(_ activation: TriggerActivation, for key: TriggerKey) {
TriggerKeyActivationStore().setActivation(activation, for: key)
dictationBindingChanged()
}

/// Sets what `key` does, as the Keys tab would, and rebinds the tap.
func setKeyChoiceForTesting(_ choice: TriggerKeyChoice, for key: TriggerKey) {
TriggerKeyStyleStore().setChoice(choice, for: key)
dictationBindingChanged()
}
}
#endif
76 changes: 68 additions & 8 deletions App/Blurt/Blurt/AppCoordinator.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppKit
import AssemblyAI
import BlurtEngine
import Foundation
Expand Down Expand Up @@ -62,6 +63,13 @@ final class AppCoordinator {
/// .isCapturing`) — the ready screen disables the style switcher on it, and
/// its `.recording` half (via `menuBarStatus`) drives the listening state.
private(set) var isCapturing = false
/// The last phase rendered, so the pill can go back to it when a read ends.
@ObservationIgnored private var lastPhase = PipelinePhase.idle
/// Whether a read or answer is playing — the pill shows it; UI tests read it.
private(set) var isReadingAloud = false
/// What reads aloud and how the selection is read, when not the real ones:
/// UI tests run the whole read-aloud path offline. Nil in production.
@ObservationIgnored private let readAloudSeams: ReadAloudSeams?

/// `components` defaults to the production pipeline, its transcriber dictating
/// against the account's `dictationOrigin`; `apiKey` defaults to the
Expand All @@ -73,9 +81,11 @@ final class AppCoordinator {
onSetupBlocked: @escaping @MainActor () -> Void,
components: DictationComponents? = nil,
apiKey: APIKeyModel = APIKeyModel(),
account: AssemblyAIAccount? = nil
account: AssemblyAIAccount? = nil,
readAloud: ReadAloudSeams? = nil
) {
self.onSetupBlocked = onSetupBlocked
self.readAloudSeams = readAloud
self.apiKey = apiKey
let info = Bundle.main.infoDictionary ?? [:]
let account =
Expand Down Expand Up @@ -154,14 +164,17 @@ final class AppCoordinator {
/// than a Carbon global hotkey: the latter leaks the trigger's auto-repeat key
/// events into the focused app while held.
private func startDictationDriver() {
// Every trigger command goes through the read-aloud router. With the
// experimental switch off, it forwards each one to `session.submit` exactly
// as before.
let router = SelectionSpeechRouter(session: session)
// Every trigger command goes through the read-aloud router, which forwards
// a dictating key's commands to `session.submit` and handles read and ask
// keys itself.
let router =
readAloudSeams.map {
SelectionSpeechRouter(session: session, speaker: $0.speaker, readSelection: $0.readSelection)
} ?? SelectionSpeechRouter(session: session)
keyTap = DictationKeyTap(
// The key rides the press, and the session resolves its style for that one
// dictation (`PressStyle`); the main window's selection is never written.
onStart: { key in router.submit(.press(key: key)) },
onStart: { key in router.press(key: key) },
onStop: { router.submit(.release) },
onCancel: { router.submit(.cancel) },
onLatch: { router.keyLatched() },
Expand All @@ -170,6 +183,25 @@ final class AppCoordinator {
)
router.syncGateAfterSession = { [weak self] in self?.keyTap?.syncAfterTerminalPhase() }
router.resetGate = { [weak self] in self?.keyTap?.resetGate() }
// A read wears the dictation pill — orb and meter, the meter following the
// voice — between the same start and stop cues. Pill first, then the cue,
// as `render(_:)` orders them.
router.speechStarted = { [weak self] in
self?.isReadingAloud = true
self?.overlay?.show(state: .speaking)
self?.cues.speechStarted()
}
router.speechEnded = { [weak self] in
guard let self else { return }
self.isReadingAloud = false
// Back to what the session is doing — a capture or transcription still
// under way — and never a dictation's old notice (the engine's rule).
self.overlay?.show(state: self.lastPhase.overlayStateAfterSpeech)
self.cues.speechEnded()
}
router.speechLevel = { [weak self] level in self?.overlay?.pushLevel(level) }
// The pill just goes away on a failed read, so say something went wrong.
router.speechFailed = { _ in NSSound.beep() }
speechRouter = router
// Deliberately *not* installed here: `CGEvent.tapCreate` for keystrokes is
// itself what surfaces the system permission prompt, so creating the tap at
Expand Down Expand Up @@ -268,7 +300,12 @@ final class AppCoordinator {
/// otherwise (see `GlobeKeyOverride`). Also run from `start()`, so the relaunch
/// after a settings reset restores the user's setting.
private func syncGlobeKeyOverride() {
GlobeKeyOverride().sync(boundKeys: TriggerKeyStyleStore().enabledKeys)
#if UITEST_HOOKS
// UI tests set fn to every action; the 🌐 setting it writes is the Mac's
// own, one per login, and no test may change it.
if UITestMode.isActive { return }
#endif
GlobeKeyOverride().sync(boundKeys: TriggerKeyBindings().enabledKeys)
}

// MARK: - Dictation render
Expand All @@ -282,6 +319,17 @@ final class AppCoordinator {
cues.packChanged()
}

/// Read-aloud's cue pack changed in Settings: reload and preview it.
func readAloudSoundPackChanged() {
cues.readPackChanged()
}

/// The Read Aloud tab picked a voice or a speed: play a sample in it, as a cue
/// sound plays when it's picked.
func readAloudStyleChanged() {
speechRouter?.previewSample()
}

private func render(_ phase: PipelinePhase) {
// A setup blocker is a state, not a fault: the engine projections below
// render it as calm idle (no red flash) and the menu bar ignores it — the only
Expand All @@ -295,7 +343,12 @@ final class AppCoordinator {
// front of the visual state change. Pure phase→pill mapping lives in the
// engine (unit-tested there); .failed resolves to .error, which the pill
// flashes red then auto-reverts to idle.
overlay?.show(state: phase.overlayState)
// A read wears the pill until it ends (the engine's rule — see
// `overlayState(whileSpeaking:)`).
lastPhase = phase
if let state = phase.overlayState(whileSpeaking: speechRouter?.isSpeaking ?? false) {
overlay?.show(state: state)
}
// Mirror the phase onto the menu bar indicator (mapping lives in the engine,
// unit-tested alongside `overlayState`).
menuBarStatus = phase.menuBarStatus
Expand All @@ -319,3 +372,10 @@ final class AppCoordinator {
speechRouter?.sessionPhaseChanged(phase)
}
}

/// Stand-ins for read-aloud's speaker and selection read, so a host can run the
/// read-aloud path with no network, audio or Accessibility (Blurt's UI tests).
struct ReadAloudSeams {
let speaker: any ReadAloudSpeaker
let readSelection: @Sendable () async -> String?
}
11 changes: 8 additions & 3 deletions App/Blurt/Blurt/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
apiKey: apiKey,
// No configuration: the suite covers the paste-a-key path, and sign-in
// would open a real browser.
account: AssemblyAIAccount(configuration: nil, apiKey: apiKey, store: .inMemory()))
account: AssemblyAIAccount(configuration: nil, apiKey: apiKey, store: .inMemory()),
// Read-aloud offline: a canned selection, and a speaker that records
// what it's asked to read instead of streaming TTS.
readAloud: ReadAloudSeams(
speaker: UITestReadAloudSpeaker(),
readSelection: { [selection = UITestIdentifiers.cannedSelection] in selection }))
} else {
coord = AppCoordinator(onSetupBlocked: onSetupBlocked)
}
Expand Down Expand Up @@ -255,9 +260,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// Accessibility trust, but its gate/callback graph is still exercised.)
coord.showOverlay()
for _ in 0..<5 {
coord.simulateDictationPressForTesting()
coord.simulateKeyPressForTesting()
try? await Task.sleep(for: .milliseconds(120)) // let press() reach .recording
coord.simulateDictationReleaseForTesting()
coord.simulateKeyReleaseForTesting()
try? await Task.sleep(for: .milliseconds(180)) // let transcribe→inject settle
}
coord.hideOverlay()
Expand Down
Loading
Loading