From b604153b8d287118decde9aad416ebff806e0891 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 01:37:46 +0000 Subject: [PATCH 01/10] refactor: simplify the code landed since 2026-10-02 Cleanups from a /simplify pass over a3aab4c..HEAD. Intended behaviour is unchanged, except that the keyboard's release and cancel commands no longer carry the field's text, which the app only reads on a press. iPhone app and keyboard: - One phase mapping. VoiceState.Phase is now PhaseSnapshot.State, the OverlayUIState -> state/message mapping lives once in BlurtiOSCore (it was written out in both DictationCoordinator and VoiceState), and isSettled is defined once on the state. - keyTerms() calls a new KeyTerms.normalized(_:) (split out of parse) instead of repeating its trim-and-dedupe loop. - TermPack.termCap removed in favour of KeyTerms.termCap; staleness constants derive from SyncSTTLimits.maxAudioSeconds and the notice dwell instead of restating 120 s and 2 s. - UtteranceFeed.level(of:) uses MicCapture.linearLevel instead of a copy of the Mac meter's -50 dB mapping. - trimmedNonEmpty() instead of hand-rolled trim + isEmpty; Duration ratios instead of rebuilding seconds from attoseconds; one endTermField() for the four term-field resets; one raw-value choice accessor in SharedStore and a shared defaultThemeID. - The capture delegate skips resampling while no utterance is open (the window holds the mic open for minutes between utterances). - Release and cancel commands no longer copy the field's text across the App Group; only a press reads it. Scripts: ios_require_device and IOS_DEFAULT_DEVICE in ios-lib.sh replace the device-picking prologue copied into seven scripts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X84Vo9pry7SANcbu86Jjrh --- .../BlurtiOS/Sources/AudioSource.swift | 7 ++ .../Sources/DictationCoordinator.swift | 38 +------- .../BlurtiOS/Sources/ImportTermsView.swift | 3 +- .../BlurtiOS/Sources/ListeningSource.swift | 14 +-- .../BlurtiOS/Sources/SettingsView.swift | 2 +- App/BlurtiOS/BlurtiOS/Sources/SetupView.swift | 2 +- .../BlurtiOSCore/KeyboardModel+Mic.swift | 9 +- .../BlurtiOSCore/KeyboardModel+Typing.swift | 19 ++-- App/BlurtiOS/BlurtiOSCore/KeyboardModel.swift | 12 +-- App/BlurtiOS/BlurtiOSCore/SharedState.swift | 43 ++++++--- App/BlurtiOS/BlurtiOSCore/TermPack.swift | 9 +- App/BlurtiOS/BlurtiOSCore/TypingTest.swift | 2 +- App/BlurtiOS/BlurtiOSCore/VoiceState.swift | 95 +++++++++---------- .../BlurtiOSTests/FeedAndLayoutTests.swift | 3 + Sources/AssemblyAI/Dictation/KeyTerms.swift | 13 ++- .../BlurtEngine/Audio/MicCapture+Meter.swift | 3 +- Tests/BlurtEngineTests/KeyTermsTests.swift | 7 ++ scripts/apple-geometry.sh | 8 +- scripts/ios-check.sh | 2 +- scripts/ios-keyboard-flows.sh | 7 +- scripts/ios-keyboard-shot.sh | 7 +- scripts/ios-lib.sh | 19 ++++ scripts/ios-record.sh | 3 +- scripts/ios-sim.sh | 8 +- scripts/ios-test.sh | 9 +- 25 files changed, 171 insertions(+), 173 deletions(-) diff --git a/App/BlurtiOS/BlurtiOS/Sources/AudioSource.swift b/App/BlurtiOS/BlurtiOS/Sources/AudioSource.swift index d2f01c07..17f1a7aa 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/AudioSource.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/AudioSource.swift @@ -131,6 +131,13 @@ extension WindowedAudioSource: nonisolated AVCaptureAudioDataOutputSampleBufferD func captureOutput( _ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection ) { + // The window keeps the mic open for minutes between utterances; resampling + // buffers nobody will read is wasted. The converter is dropped with them, so + // the next utterance doesn't start on the old audio's resampler tail. + guard feed.isCapturing else { + converter = nil + return + } if converter == nil { converter = PCMConverter() } guard let converter, let chunk = converter.convert(sampleBuffer) else { return } feed.deliver(chunk) diff --git a/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift b/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift index fd24ecda..13a2a28c 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift @@ -1,4 +1,5 @@ import AVFoundation +import AssemblyAI import BlurtEngine import BlurtiOSCore import Foundation @@ -221,14 +222,7 @@ final class DictationCoordinator { nonisolated static func keyTerms() -> [String] { // Not through the comma-separated form: a contact called "Smith, John" // is one name, and the engine takes an array. - var seen = Set() - var terms: [String] = [] - for term in SharedStore.keyTerms + lexiconNames.withLock({ $0 }) { - let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, seen.insert(trimmed.lowercased()).inserted else { continue } - terms.append(trimmed) - } - return terms + KeyTerms.normalized(SharedStore.keyTerms + lexiconNames.withLock { $0 }) } /// What the keyboard saw around the cursor on the press in flight, once. @@ -281,7 +275,7 @@ final class DictationCoordinator { if recordingSince == nil { recordingSince = .now } } else if let since = recordingSince { let elapsed = since.duration(to: .now) - lastSpokenSeconds = Double(elapsed.components.seconds) + Double(elapsed.components.attoseconds) / 1e18 + lastSpokenSeconds = elapsed / .seconds(1) recordingSince = nil } } @@ -317,31 +311,9 @@ final class DictationCoordinator { private func publish(_ state: OverlayUIState) { let snapshot = PhaseSnapshot( - state: Self.snapshotState(state), message: Self.message(state), level: Double(level), at: Date(), - command: lastCommandID) + state: PhaseSnapshot.State(state), message: PhaseSnapshot.message(for: state), level: Double(level), + at: Date(), command: lastCommandID) SharedStore.write(snapshot, forKey: BlurtShared.Key.phase) SharedStore.post(BlurtShared.Signal.phase) } - - private static func snapshotState(_ state: OverlayUIState) -> PhaseSnapshot.State { - switch state { - case .idle: .idle - case .connecting: .connecting - case .recording: .recording - case .processing: .processing - case .pasted: .pasted - case .noTarget: .copied - case .error, .inputSilent: .error - } - } - - private static func message(_ state: OverlayUIState) -> String? { - switch state { - case .error(let message): message - // No wide pill on the iPhone: the silent mic reads as an error, its sentence - // (with the device name) as the message. - case .inputSilent: state.accessibilityLabel - case .idle, .connecting, .recording, .processing, .pasted, .noTarget: nil - } - } } diff --git a/App/BlurtiOS/BlurtiOS/Sources/ImportTermsView.swift b/App/BlurtiOS/BlurtiOS/Sources/ImportTermsView.swift index f5557456..fd32978f 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/ImportTermsView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/ImportTermsView.swift @@ -1,3 +1,4 @@ +import AssemblyAI import BlurtDesign import BlurtiOSCore import SwiftUI @@ -24,7 +25,7 @@ struct ImportTermsView: View { } footer: { Text( "They'll ride along with every dictation so Blurt spells them right. Terms you already have are skipped; " - + "the first \(TermPack.termCap) terms ride each request, so keep the list to what matters.") + + "the first \(KeyTerms.termCap) terms ride each request, so keep the list to what matters.") } } .brandForm() diff --git a/App/BlurtiOS/BlurtiOS/Sources/ListeningSource.swift b/App/BlurtiOS/BlurtiOS/Sources/ListeningSource.swift index 6c61333b..30688d1e 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/ListeningSource.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/ListeningSource.swift @@ -75,6 +75,10 @@ nonisolated final class UtteranceFeed: Sendable { } } + /// Whether an utterance is open. A microphone checks this before converting + /// a buffer that `deliver` would only drop. + var isCapturing: Bool { state.withLock { $0.sink != nil } } + /// A chunk of 16 kHz mono 16-bit PCM from the microphone, on whatever /// thread the microphone delivers on. func deliver(_ chunk: Data) { @@ -90,8 +94,8 @@ nonisolated final class UtteranceFeed: Sendable { if delivered { levelsContinuation.yield(Self.level(of: chunk)) } } - /// dBFS of a chunk of 16-bit PCM, mapped to 0…1 with the Mac meter's -50 dB - /// floor so room ambient reads as empty bars. + /// dBFS of a chunk of 16-bit PCM, mapped to 0…1 by the Mac meter's own + /// mapping, so room ambient reads as empty bars on both. static func level(of pcm: Data) -> Float { let count = pcm.count / MemoryLayout.size guard count > 0 else { return 0 } @@ -102,10 +106,6 @@ nonisolated final class UtteranceFeed: Sendable { } } let rms = (energy / Double(count)).squareRoot() / Double(Int16.max) - guard rms > 0 else { return 0 } - let floor = -50.0 - let db = 20 * log10(rms) - guard db > floor else { return 0 } - return Float(min(1, (db - floor) / -floor)) + return MicCapture.linearLevel(fromPowerDB: Float(20 * log10(rms))) } } diff --git a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift index a4b89913..3b4d639e 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift @@ -15,7 +15,7 @@ struct SettingsView: View { @State private var micAlignment = SharedStore.micAlignment @State private var autoDictate = SharedStore.autoDictate @State private var windowMinutes = SharedStore.windowMinutes - @AppStorage(BlurtShared.Key.theme, store: SharedStore.defaults) private var themeID = "system" + @AppStorage(BlurtShared.Key.theme, store: SharedStore.defaults) private var themeID = SharedStore.defaultThemeID /// TEMPORARY: which mic concept to try. Goes with the losers. @AppStorage(BlurtShared.Key.voiceElement, store: SharedStore.defaults) private var voiceRaw = VoiceElementKind.shipped.rawValue diff --git a/App/BlurtiOS/BlurtiOS/Sources/SetupView.swift b/App/BlurtiOS/BlurtiOS/Sources/SetupView.swift index 541a2db7..3ff21e18 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SetupView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SetupView.swift @@ -94,7 +94,7 @@ private struct KeyStep: View { VStack(spacing: DesignTokens.Metrics.appChipGap) { Button(isChecking ? "Checking…" : "Continue") { Task { await submit() } } .buttonStyle(BrandButtonStyle()) - .disabled(isChecking || draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .disabled(isChecking || draft.trimmedNonEmpty() == nil) Button("Get a free key") { openURL(APIKeyStore.dashboardURL) } .buttonStyle(BrandButtonStyle(role: .secondary)) } diff --git a/App/BlurtiOS/BlurtiOSCore/KeyboardModel+Mic.swift b/App/BlurtiOS/BlurtiOSCore/KeyboardModel+Mic.swift index ddbb4e1c..f7680732 100644 --- a/App/BlurtiOS/BlurtiOSCore/KeyboardModel+Mic.swift +++ b/App/BlurtiOS/BlurtiOSCore/KeyboardModel+Mic.swift @@ -64,11 +64,14 @@ extension KeyboardModel { } package func send(_ kind: KeyboardCommand.Kind) { + // Only a press primes the request with the field's text; a release or a + // cancel carries none of it across to the app. + let isPress = kind == .press let command = KeyboardCommand( - id: UUID(), kind: kind, priorText: proxy?.documentContextBeforeInput, - selectedText: proxy?.selectedText, sentAt: Date(), keyboard: instanceID) + id: UUID(), kind: kind, priorText: isPress ? proxy?.documentContextBeforeInput : nil, + selectedText: isPress ? proxy?.selectedText : nil, sentAt: Date(), keyboard: instanceID) transport(command) - unansweredPressCommand = kind == .press ? command : nil + unansweredPressCommand = isPress ? command : nil // A press the app never answers would leave the gate latched over // nothing. Either notification can be missed: first catch up with a phase // the app may have published unheard; if the picture is still as it was, diff --git a/App/BlurtiOS/BlurtiOSCore/KeyboardModel+Typing.swift b/App/BlurtiOS/BlurtiOSCore/KeyboardModel+Typing.swift index 9050773a..90bf6b9a 100644 --- a/App/BlurtiOS/BlurtiOSCore/KeyboardModel+Typing.swift +++ b/App/BlurtiOS/BlurtiOSCore/KeyboardModel+Typing.swift @@ -54,8 +54,8 @@ extension KeyboardModel { /// A selection as a key term: trimmed, on one line, short enough to be a /// name or a phrase. Nil when it is none of those, or nothing is selected. package static func termCandidate(from selected: String?) -> String? { - guard let trimmed = selected?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty, - trimmed.count <= termLengthCap, !trimmed.contains(where: \.isNewline) + guard let trimmed = selected.trimmedNonEmpty(), trimmed.count <= termLengthCap, + !trimmed.contains(where: \.isNewline) else { return nil } return trimmed } @@ -144,11 +144,17 @@ extension KeyboardModel { onLayoutChange?() } - package func cancelAddingTerm() { + /// Closes the term field: the draft and the baselines it was typed against + /// only mean something together, so they are cleared together. + package func endTermField() { termDraft = nil termDraftFromSelection = nil termHostBaseline = nil termHostBaselineAfter = nil + } + + package func cancelAddingTerm() { + endTermField() // The highlight may still stand: the chip comes back as it is now. readSelection() updateShift() @@ -159,7 +165,7 @@ extension KeyboardModel { /// and, when it began as a selection, puts it into the text in place of the /// misheard word. package func saveTerm() { - guard let draft = termDraft?.trimmingCharacters(in: .whitespacesAndNewlines), !draft.isEmpty else { + guard let draft = termDraft.trimmedNonEmpty() else { cancelAddingTerm() return } @@ -168,10 +174,7 @@ extension KeyboardModel { // notification can't read the replacement as typing to claim. let original = termDraftFromSelection let baseline = termHostBaseline - termDraft = nil - termDraftFromSelection = nil - termHostBaseline = nil - termHostBaselineAfter = nil + endTermField() // Only while the highlight the field opened over still stands where it // was: the host may report a selection a moment after the user moved on, // and inserting then would put the word in twice. diff --git a/App/BlurtiOS/BlurtiOSCore/KeyboardModel.swift b/App/BlurtiOS/BlurtiOSCore/KeyboardModel.swift index 690e9819..db10ce0d 100644 --- a/App/BlurtiOS/BlurtiOSCore/KeyboardModel.swift +++ b/App/BlurtiOS/BlurtiOSCore/KeyboardModel.swift @@ -164,12 +164,7 @@ package final class KeyboardModel { /// Whether the phase leaves nothing in flight — the moments the gate has to /// be reset, since a dictation can end with no finger event to close it. - package var isSettled: Bool { - switch snapshot.state { - case .idle, .pasted, .copied, .error: true - case .connecting, .recording, .processing: false - } - } + package var isSettled: Bool { snapshot.state.isSettled } package init() {} @@ -195,10 +190,7 @@ package final class KeyboardModel { // Every appearance starts on the mic page with no term half-typed, and on // the page the field asks for: symbols for a number, letters otherwise. panelShowsKeys = false - termDraft = nil - termDraftFromSelection = nil - termHostBaseline = nil - termHostBaselineAfter = nil + endTermField() dismissedSelection = nil termNotice?.cancel() termSavedAt = nil diff --git a/App/BlurtiOS/BlurtiOSCore/SharedState.swift b/App/BlurtiOS/BlurtiOSCore/SharedState.swift index 667d94e7..cdf8c587 100644 --- a/App/BlurtiOS/BlurtiOSCore/SharedState.swift +++ b/App/BlurtiOS/BlurtiOSCore/SharedState.swift @@ -1,4 +1,5 @@ import AssemblyAI +import BlurtEngine import Foundation // MARK: - The App Group contract @@ -143,6 +144,14 @@ package nonisolated struct PhaseSnapshot: Codable, Sendable, Equatable { case pasted case copied case error + + /// Nothing in flight: a notice, or rest. + package var isSettled: Bool { + switch self { + case .idle, .pasted, .copied, .error: true + case .connecting, .recording, .processing: false + } + } } package let state: State @@ -166,13 +175,15 @@ package nonisolated struct PhaseSnapshot: Codable, Sendable, Equatable { package static let idle = PhaseSnapshot(state: .idle, message: nil, level: 0, at: .distantPast) + private static let longestNoticeDwell = 2.0 + /// How long a notice stays on the orb before it settles back to idle — the /// Mac's 0.8 s / 1.6 s dwell, a little longer since a phone has no hover to /// reveal more. Nil for the states that end on their own. package var noticeDwellSeconds: Double? { switch state { case .pasted: 1.2 - case .copied, .error: 2.0 + case .copied, .error: Self.longestNoticeDwell case .idle, .connecting, .recording, .processing: nil } } @@ -194,11 +205,11 @@ package nonisolated struct PhaseSnapshot: Codable, Sendable, Equatable { } } - /// The longest notice dwell (2 s) plus a second of slack. - package static let noticeStaleAfter: TimeInterval = 3 - /// The engine's 120 s cap on one dictation, plus ten seconds for the - /// transcript to come back. - package static let inFlightStaleAfter: TimeInterval = 130 + /// The longest notice dwell plus a second of slack. + package static let noticeStaleAfter: TimeInterval = longestNoticeDwell + 1 + /// The engine's cap on one dictation, plus ten seconds for the transcript + /// to come back. + package static let inFlightStaleAfter: TimeInterval = SyncSTTLimits.maxAudioSeconds + 10 } /// One entry of the phone's own word list (`UILexicon`): contact names and the @@ -243,15 +254,23 @@ package nonisolated enum SharedStore { defaults.removeObject(forKey: key) } + /// A choice stored by its raw value, or `fallback` when none (or one this + /// build doesn't know) is stored. + private static func choice(_ key: String, default fallback: C) -> C where C.RawValue == String { + C(rawValue: defaults.string(forKey: key) ?? "") ?? fallback + } + package static var layout: KeyboardLayout { - get { KeyboardLayout(rawValue: defaults.string(forKey: BlurtShared.Key.layout) ?? "") ?? .panel } + get { choice(BlurtShared.Key.layout, default: .panel) } set { defaults.set(newValue.rawValue, forKey: BlurtShared.Key.layout) } } - /// Which keyboard theme (`KeyboardPalette.id`); the iPhone's own until one - /// is picked. + /// The theme until one is picked: the iPhone's own. + package static let defaultThemeID = "system" + + /// Which keyboard theme (`KeyboardPalette.id`). package static var themeID: String { - get { defaults.string(forKey: BlurtShared.Key.theme) ?? "system" } + get { defaults.string(forKey: BlurtShared.Key.theme) ?? defaultThemeID } set { defaults.set(newValue, forKey: BlurtShared.Key.theme) } } @@ -259,13 +278,13 @@ package nonisolated enum SharedStore { /// shipped one until Settings says otherwise. Read by the keyboard on every /// appearance and by the home screen live. Goes with the losers. package static var voiceElementKind: VoiceElementKind { - get { VoiceElementKind(rawValue: defaults.string(forKey: BlurtShared.Key.voiceElement) ?? "") ?? .shipped } + get { choice(BlurtShared.Key.voiceElement, default: .shipped) } set { defaults.set(newValue.rawValue, forKey: BlurtShared.Key.voiceElement) } } /// Where the mic key sits across the keyboard; the middle until chosen. package static var micAlignment: MicAlignment { - get { MicAlignment(rawValue: defaults.string(forKey: BlurtShared.Key.micAlignment) ?? "") ?? .center } + get { choice(BlurtShared.Key.micAlignment, default: .center) } set { defaults.set(newValue.rawValue, forKey: BlurtShared.Key.micAlignment) } } diff --git a/App/BlurtiOS/BlurtiOSCore/TermPack.swift b/App/BlurtiOS/BlurtiOSCore/TermPack.swift index f5dc3b43..860253f3 100644 --- a/App/BlurtiOS/BlurtiOSCore/TermPack.swift +++ b/App/BlurtiOS/BlurtiOSCore/TermPack.swift @@ -26,10 +26,9 @@ package nonisolated struct TermPack: Codable, Identifiable, Transferable { package var id: String { "\(name)|\(from ?? "")|\(terms.joined(separator: ","))" } - /// The request's own cap on key terms (`keyterms_prompt`, 100), which is - /// also as many as a list is worth; a file over this size, or a term, name - /// or sender longer than a line, is refused. - package static let termCap = 100 + /// A file over this size, or a term, name or sender longer than a line, is + /// refused. The term count is held to the request's own cap, + /// `KeyTerms.termCap`, which is also as many as a list is worth. package static let fileCap = 64 * 1024 package static let lengthCap = 80 @@ -90,7 +89,7 @@ package nonisolated struct TermPack: Codable, Identifiable, Transferable { /// The pack within the caps, or nil when nothing usable is left. private func capped() -> TermPack? { let kept = KeyTerms.parse(KeyTerms.join(terms)).filter { $0.count <= Self.lengthCap } - let terms = Array(kept.prefix(Self.termCap)) + let terms = Array(kept.prefix(KeyTerms.termCap)) guard !terms.isEmpty else { return nil } return TermPack( name: String(name.prefix(Self.lengthCap)), from: from.map { String($0.prefix(Self.lengthCap)) }, terms: terms) diff --git a/App/BlurtiOS/BlurtiOSCore/TypingTest.swift b/App/BlurtiOS/BlurtiOSCore/TypingTest.swift index 5bd9e3fc..5db3a168 100644 --- a/App/BlurtiOS/BlurtiOSCore/TypingTest.swift +++ b/App/BlurtiOS/BlurtiOSCore/TypingTest.swift @@ -42,7 +42,7 @@ package nonisolated struct TypingTest: Sendable { package func isOnTrack(_ partial: String) -> Bool { currentWord.hasPrefix(partial) } package func wordsPerMinute(after elapsed: Duration) -> Double { - let minutes = Double(elapsed.components.seconds) / 60 + Double(elapsed.components.attoseconds) / 6e19 + let minutes = elapsed / .seconds(60) guard minutes > 0 else { return 0 } return Double(correctCharacters) / 5 / minutes } diff --git a/App/BlurtiOS/BlurtiOSCore/VoiceState.swift b/App/BlurtiOS/BlurtiOSCore/VoiceState.swift index dc589efc..97c0fa1a 100644 --- a/App/BlurtiOS/BlurtiOSCore/VoiceState.swift +++ b/App/BlurtiOS/BlurtiOSCore/VoiceState.swift @@ -7,15 +7,9 @@ import BlurtEngine /// — the ring, a glyph, dimming, whether the meter is up — is a property here /// and tested, not a branch in a view. package nonisolated struct VoiceState: Equatable { - package enum Phase: Equatable { - case idle - case connecting - case recording - case processing - case pasted - case copied - case error - } + /// The same states the keyboard reads from the App Group, so the keyboard's + /// and the app's phases can't drift apart. + package typealias Phase = PhaseSnapshot.State package enum Ring: Equatable { /// At rest, or while recording (the meter says it all). @@ -54,61 +48,28 @@ package nonisolated struct VoiceState: Equatable { /// The keyboard's: from the phase the app published. package init(snapshot: PhaseSnapshot, isReady: Bool) { - let phase: Phase = - switch snapshot.state { - case .idle: .idle - case .connecting: .connecting - case .recording: .recording - case .processing: .processing - case .pasted: .pasted - case .copied: .copied - case .error: .error - } - self.init(phase: phase, isReady: isReady, level: Float(snapshot.level), message: snapshot.message) + self.init(phase: snapshot.state, isReady: isReady, level: Float(snapshot.level), message: snapshot.message) } - /// The app's: from the engine's overlay state. "No target" is the words - /// going to the clipboard — the keyboard's "copied". + /// The app's: from the engine's overlay state, mapped as the app publishes + /// it to the keyboard. package init(overlay: OverlayUIState, windowOpen: Bool, level: Float) { - switch overlay { - case .idle: self.init(phase: .idle, isReady: windowOpen) - case .connecting: self.init(phase: .connecting, isReady: windowOpen) - case .recording: self.init(phase: .recording, isReady: windowOpen, level: level) - case .processing: self.init(phase: .processing, isReady: windowOpen) - case .pasted: self.init(phase: .pasted, isReady: windowOpen) - case .noTarget: self.init(phase: .copied, isReady: windowOpen) - case .error(let message): self.init(phase: .error, isReady: windowOpen, message: message) - case .inputSilent: - self.init(phase: .error, isReady: windowOpen, message: overlay.accessibilityLabel) - } + let phase = Phase(overlay) + self.init( + phase: phase, isReady: windowOpen, level: phase == .recording ? level : 0, + message: PhaseSnapshot.message(for: overlay)) } /// The meter is up and is the key. package var isRecording: Bool { isReady && phase == .recording } /// Something is in flight: the mic coming up, the voice, the words being made. - package var isWorking: Bool { - guard isReady else { return false } - switch phase { - case .connecting, .recording, .processing: return true - case .idle, .pasted, .copied, .error: return false - } - } + package var isWorking: Bool { isReady && !isSettled } - package var isNotice: Bool { - switch phase { - case .pasted, .copied, .error: true - case .idle, .connecting, .recording, .processing: false - } - } + package var isNotice: Bool { isSettled && phase != .idle } /// Nothing in flight — the moments the cancel control has nothing to cancel. - package var isSettled: Bool { - switch phase { - case .idle, .pasted, .copied, .error: true - case .connecting, .recording, .processing: false - } - } + package var isSettled: Bool { phase.isSettled } /// Something is in flight and Blurt is there to stop it: no × beside a /// dimmed mic whose app is gone. @@ -145,3 +106,33 @@ package nonisolated struct VoiceState: Equatable { phase == .error ? message ?? "Dictation failed." : "" } } + +extension PhaseSnapshot.State { + /// The engine's overlay state as the orb shows it. "No target" is the words + /// going to the clipboard — the keyboard's "copied" — and a silent mic is an + /// error: the iPhone has no wide pill for it. + package init(_ overlay: OverlayUIState) { + self = + switch overlay { + case .idle: .idle + case .connecting: .connecting + case .recording: .recording + case .processing: .processing + case .pasted: .pasted + case .noTarget: .copied + case .error, .inputSilent: .error + } + } +} + +extension PhaseSnapshot { + /// The words an overlay state carries for VoiceOver: an error's own, or the + /// silent mic's sentence (with the device name). + package static func message(for overlay: OverlayUIState) -> String? { + switch overlay { + case .error(let message): message + case .inputSilent: overlay.accessibilityLabel + case .idle, .connecting, .recording, .processing, .pasted, .noTarget: nil + } + } +} diff --git a/App/BlurtiOS/BlurtiOSTests/FeedAndLayoutTests.swift b/App/BlurtiOS/BlurtiOSTests/FeedAndLayoutTests.swift index 50f314c9..a59199e5 100644 --- a/App/BlurtiOS/BlurtiOSTests/FeedAndLayoutTests.swift +++ b/App/BlurtiOS/BlurtiOSTests/FeedAndLayoutTests.swift @@ -25,11 +25,14 @@ struct UtteranceFeedTests { func tally() async { let feed = UtteranceFeed() feed.deliver(pcm([1000, -1000])) + #expect(!feed.isCapturing) let stream = feed.begin() + #expect(feed.isCapturing) feed.deliver(pcm([1000, -1000])) feed.deliver(pcm([1000, -1000, 1000])) let bytes = feed.end() #expect(bytes == 10) + #expect(!feed.isCapturing) var received = 0 for await chunk in stream { received += chunk.count } #expect(received == 10) diff --git a/Sources/AssemblyAI/Dictation/KeyTerms.swift b/Sources/AssemblyAI/Dictation/KeyTerms.swift index c7eef65b..765c0298 100644 --- a/Sources/AssemblyAI/Dictation/KeyTerms.swift +++ b/Sources/AssemblyAI/Dictation/KeyTerms.swift @@ -30,10 +30,19 @@ public enum KeyTerms { /// order. `fitted` assumes terms arrive this way. public static func parse(_ text: String?) -> [String] { guard let text else { return [] } + return normalized(text.split(separator: ",").map(String.init)) + } + + // periphery:ignore - public for the iOS app (App/BlurtiOS), which the Mac scan doesn't index. + /// Terms already apart as clean terms, by `parse`'s rules: trimmed, blanks + /// dropped, duplicates removed case-insensitively keeping the first spelling. + /// For lists that can't go through the comma form, such as a contact called + /// "Smith, John". + public static func normalized(_ terms: [String]) -> [String] { var seen = Set() var result: [String] = [] - for piece in text.split(separator: ",") { - guard let term = String(piece).trimmedNonEmpty() else { continue } + for piece in terms { + guard let term = piece.trimmedNonEmpty() else { continue } guard seen.insert(term.lowercased()).inserted else { continue } result.append(term) } diff --git a/Sources/BlurtEngine/Audio/MicCapture+Meter.swift b/Sources/BlurtEngine/Audio/MicCapture+Meter.swift index 4ffba37c..4f8e3847 100644 --- a/Sources/BlurtEngine/Audio/MicCapture+Meter.swift +++ b/Sources/BlurtEngine/Audio/MicCapture+Meter.swift @@ -8,11 +8,12 @@ extension MicCapture { /// the overlay's voice bars read as empty at rest and only move for speech. static let meterFloorDB: Float = -50 + // periphery:ignore - public for the iOS app (App/BlurtiOS), which the Mac scan doesn't index. /// Convert the capture meter's dBFS power into the `0...1` the overlay /// expects, mapped linearly across `[meterFloorDB, 0]`. (A raw `pow(10, db/20)` /// amplitude leaves ambient noise well above zero, so the bars never rested /// at empty.) - static func linearLevel(fromPowerDB db: Float) -> Float { + public static func linearLevel(fromPowerDB db: Float) -> Float { guard db.isFinite, db > meterFloorDB else { return 0 } return min(1, (db - meterFloorDB) / -meterFloorDB) } diff --git a/Tests/BlurtEngineTests/KeyTermsTests.swift b/Tests/BlurtEngineTests/KeyTermsTests.swift index 96cd83bc..593eb437 100644 --- a/Tests/BlurtEngineTests/KeyTermsTests.swift +++ b/Tests/BlurtEngineTests/KeyTermsTests.swift @@ -129,4 +129,11 @@ struct KeyTermsParseTests { #expect(KeyTerms.join(terms) == "AssemblyAI, San Francisco, LeMUR") #expect(KeyTerms.parse(KeyTerms.join(terms)) == terms) } + + @Test("normalized cleans a list without splitting its terms on commas") + func normalizedKeepsCommas() { + // A contact called "Smith, John" is one name; the iPhone app merges such + // names with the user's terms through this rather than through `parse`. + #expect(KeyTerms.normalized([" Smith, John ", "", "smith, john", "Blurt"]) == ["Smith, John", "Blurt"]) + } } diff --git a/scripts/apple-geometry.sh b/scripts/apple-geometry.sh index 59740bb4..b6cb6fdd 100755 --- a/scripts/apple-geometry.sh +++ b/scripts/apple-geometry.sh @@ -34,13 +34,7 @@ while [ $# -gt 0 ]; do esac done -PICKED="$(ios_pick_device "${BLURT_SIM_DEVICE:-iPhone 18 Pro}")" -[ -n "$PICKED" ] || { - echo "apple-geometry: no iPhone simulator available" >&2 - exit 1 -} -UDID="${PICKED%% *}" -NAME="${PICKED#* }" +ios_require_device apple-geometry ios_wait_booted "$UDID" rm -rf "$WORK" mkdir -p "$WORK" diff --git a/scripts/ios-check.sh b/scripts/ios-check.sh index cbb796c6..17d8c7a5 100755 --- a/scripts/ios-check.sh +++ b/scripts/ios-check.sh @@ -61,7 +61,7 @@ echo "==> build-for-testing (BlurtiOSProbe)" # The tests' destination, flags and derived data, so the build above is # reused and only the probe's own files compile (a generic destination would # rebuild everything for a second architecture). -UDID="$(ios_pick_device "${BLURT_SIM_DEVICE:-iPhone 18 Pro}" | cut -f1)" +UDID="$(ios_pick_device "${BLURT_SIM_DEVICE:-$IOS_DEFAULT_DEVICE}" | cut -f1)" if [ -n "$UDID" ]; then xcodebuild build-for-testing -project App/BlurtiOS/BlurtiOS.xcodeproj -scheme BlurtiOSProbe \ -destination "platform=iOS Simulator,id=$UDID" -derivedDataPath "$IOS_DERIVED" \ diff --git a/scripts/ios-keyboard-flows.sh b/scripts/ios-keyboard-flows.sh index 076e3410..0029b694 100755 --- a/scripts/ios-keyboard-flows.sh +++ b/scripts/ios-keyboard-flows.sh @@ -62,12 +62,7 @@ done # screenshots are still worth having — and the script exits non-zero at the end. FAILED_RUNS=() -PICKED="$(ios_pick_device "${BLURT_SIM_DEVICE:-iPhone 18 Pro}")" -[ -n "$PICKED" ] || { - echo "ios-keyboard-flows: no iPhone simulator available" >&2 - exit 1 -} -UDID="${PICKED%% *}" +ios_require_device ios-keyboard-flows ios_wait_booted "$UDID" (cd "$REPO_ROOT/App/BlurtiOS" && xcodegen generate --quiet) diff --git a/scripts/ios-keyboard-shot.sh b/scripts/ios-keyboard-shot.sh index b29dad4a..46d614d6 100755 --- a/scripts/ios-keyboard-shot.sh +++ b/scripts/ios-keyboard-shot.sh @@ -50,12 +50,7 @@ done [ -n "$OUT" ] || OUT="$REPO_ROOT/.build/design/ext/keyboard-$FACE${VOICE:+-$VOICE}${LAYOUT:+-$LAYOUT}.png" mkdir -p "$(dirname "$OUT")" -PICKED="$(ios_pick_device "${BLURT_SIM_DEVICE:-iPhone 18 Pro}")" -[ -n "$PICKED" ] || { - echo "ios-keyboard-shot: no iPhone simulator available" >&2 - exit 1 -} -UDID="${PICKED%% *}" +ios_require_device ios-keyboard-shot ios_wait_booted "$UDID" WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT diff --git a/scripts/ios-lib.sh b/scripts/ios-lib.sh index 726d584f..efee8a34 100755 --- a/scripts/ios-lib.sh +++ b/scripts/ios-lib.sh @@ -40,6 +40,25 @@ print(best[3] + "\t" + best[2]) ' "$1" } +# The simulator every script picks unless BLURT_SIM_DEVICE names another. +IOS_DEFAULT_DEVICE="iPhone 18 Pro" + +# ios_require_device SCRIPT → sets UDID and NAME to the simulator to use +# (BLURT_SIM_DEVICE, else IOS_DEFAULT_DEVICE, else any iPhone), or exits +# with an error naming SCRIPT when this Mac has none. +ios_require_device() { + local picked + picked="$(ios_pick_device "${BLURT_SIM_DEVICE:-$IOS_DEFAULT_DEVICE}")" + if [ -z "$picked" ]; then + echo "$1: no iPhone simulator available (xcrun simctl list devices)" >&2 + exit 1 + fi + # shellcheck disable=SC2034 # used by the scripts that source this file + UDID="${picked%% *}" + # shellcheck disable=SC2034 # used by the scripts that source this file + NAME="${picked#* }" +} + # ios_wait_booted UDID → boots the simulator if needed and waits for it. ios_wait_booted() { xcrun simctl boot "$1" 2>/dev/null || true diff --git a/scripts/ios-record.sh b/scripts/ios-record.sh index 2dc45c33..2112bbb8 100755 --- a/scripts/ios-record.sh +++ b/scripts/ios-record.sh @@ -74,8 +74,7 @@ read -r CROP_W CROP_H CROP_X CROP_Y < <(swift scripts/design-diff.swift crop "$r exit 1 } -PICKED="$(ios_pick_device "${BLURT_SIM_DEVICE:-iPhone 18 Pro}")" -UDID="${PICKED%% *}" +ios_require_device ios-record BUNDLE_ID=com.assemblyai.blurt.ios echo "==> recording $name (two cycles)" diff --git a/scripts/ios-sim.sh b/scripts/ios-sim.sh index d5464146..807aa250 100755 --- a/scripts/ios-sim.sh +++ b/scripts/ios-sim.sh @@ -55,13 +55,7 @@ elif [ ! -d "$APP" ]; then exit 1 fi -PICKED="$(ios_pick_device "${BLURT_SIM_DEVICE:-iPhone 18 Pro}")" -if [ -z "$PICKED" ]; then - echo "ios-sim: no iPhone simulator available (xcrun simctl list devices)" >&2 - exit 1 -fi -UDID="${PICKED%% *}" -NAME="${PICKED#* }" +ios_require_device ios-sim echo "boot $NAME ($UDID)" ios_wait_booted "$UDID" diff --git a/scripts/ios-test.sh b/scripts/ios-test.sh index 53c9ea9c..4a13e8b3 100755 --- a/scripts/ios-test.sh +++ b/scripts/ios-test.sh @@ -53,13 +53,8 @@ case "$SANITIZER" in esac DERIVED="$IOS_DERIVED${SANITIZER:+-$SANITIZER}" -PICKED="$(ios_pick_device "${BLURT_SIM_DEVICE:-iPhone 18 Pro}")" -if [ -z "$PICKED" ]; then - echo "ios-test: no iPhone simulator available (xcrun simctl list devices)" >&2 - exit 1 -fi -UDID="${PICKED%% *}" -echo "testing on ${PICKED#* }${SANITIZER:+ under the $SANITIZER sanitizer}" +ios_require_device ios-test +echo "testing on $NAME${SANITIZER:+ under the $SANITIZER sanitizer}" ios_wait_booted "$UDID" (cd "$REPO_ROOT/App/BlurtiOS" && xcodegen generate --quiet) From 1be44bb93f7105ece9d11b8264ffc2120de6fecf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 01:47:04 +0000 Subject: [PATCH 02/10] fix: address the code review of the commits since 2026-10-02 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hold-to-ask: SelectionSpeechRouting now tracks whether the session is busy (sessionPhaseChanged, fed every phase). A hold while an earlier dictation is still finishing waits for it and then opens the mic, instead of reading that dictation's .pasted as the request failing and resetting the gate mid-hold. Letting go before the mic opened reads the selection and cancels nothing. - SelectionCopy: a ⌘C answered after the 100 ms timeout no longer leaves the selection on the user's clipboard. The first change within a further second is restored, behind the press. - Hold-to-ask is off by default, like skip-jargon: it sends the selection and the request to the LLM Gateway, so it is its own opt-in. - Overlay: a fade-out's completion only dismisses the panel if no later setVisible superseded it (a generation, not the alpha), so the resize path's alpha = 0 can't let a stale fade order out the silent-mic notice. - iPhone: a keyboard press parks its keyboard and field text, and the session's frontmost hook claims them only for a press it accepts. A refused press can no longer redirect the dictation in flight. - AssemblyAITranscriber reads the enhanced-transcripts setting once per request, so rewrite_mode and the pasted transcript always agree. - The developer log records rewrite_mode (fast/smart; absent with the rewrite off). - ListeningWindow's heartbeat no longer holds self across its sleep. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X84Vo9pry7SANcbu86Jjrh --- AGENTS.md | 2 +- App/Blurt/Blurt/AppCoordinator.swift | 8 +- .../Blurt/Hotkey/SelectionSpeechRouter.swift | 4 +- .../Overlay/OverlayWindowController.swift | 13 +- .../Sources/DictationCoordinator.swift | 40 ++++-- .../BlurtiOS/Sources/ListeningWindow.swift | 6 +- .../Dictation/DictationWireTypes.swift | 4 +- .../Config/EnhancedTranscriptsStore.swift | 2 +- .../BlurtEngine/Config/RewriteModeStore.swift | 2 +- .../Config/SelectionAskStore.swift | 11 +- .../Config/StyleProfileStore.swift | 2 +- .../Hotkey/SelectionSpeechRouting.swift | 121 +++++++++++------- .../BlurtEngine/Pipeline/DictationLog.swift | 32 ++++- Sources/BlurtEngine/README.md | 4 +- .../STT/AssemblyAITranscriber.swift | 22 ++-- .../BlurtEngine/STT/CleanupInstruction.swift | 2 +- Sources/BlurtEngine/TTS/SelectionCopy.swift | 38 +++++- .../AssemblyAITranscriberTests.swift | 22 +++- .../BlurtEngineTests/DictationLogTests.swift | 14 +- .../BlurtEngineTests/KeytermsWireTests.swift | 2 +- .../SelectionAskStoreTests.swift | 8 +- .../BlurtEngineTests/SelectionCopyTests.swift | 15 +++ .../SelectionSpeechRoutingTests.swift | 52 ++++++-- 23 files changed, 312 insertions(+), 114 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5954b353..db52c285 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -647,7 +647,7 @@ verbatim transcript, all inside the same request, and it rides **every** request **The switch is applied to the response, not the request** (since 2026-09-11). The response carries both `text` (the verbatim transcript, "never altered by the LLM") and `llm_response` -(the rewrite), so `AssemblyAITranscriber.transcript(from:)` simply picks: with **enhanced +(the rewrite), so `AssemblyAITranscriber.transcript(from:enhanced:)` simply picks: with **enhanced transcripts** on (`EnhancedTranscriptsStore`, on by default, read per request via the transcriber's injected `enhancedTranscripts` closure) it prefers the rewrite and falls back to `text` when `llm_response` is null or blank — the rewrite is best-effort (5 s server-side diff --git a/App/Blurt/Blurt/AppCoordinator.swift b/App/Blurt/Blurt/AppCoordinator.swift index 4a179263..aa323d39 100644 --- a/App/Blurt/Blurt/AppCoordinator.swift +++ b/App/Blurt/Blurt/AppCoordinator.swift @@ -291,9 +291,9 @@ final class AppCoordinator { // normal flow. Asked through the router, because while a read-aloud owns the // gate the reset isn't this dictation's to make (see `SelectionSpeechRouting`). // It gets the phase itself because an ask about a selection ends in one, - // `.handedBack`, carrying the transcript the router answers. - if phase.isTerminal { - speechRouter?.sessionReachedTerminalPhase(phase) - } + // `.handedBack`, carrying the transcript the router answers — and every + // phase, not just the terminal ones, so it can tell an earlier dictation + // still finishing from the end of a request it is waiting on. + speechRouter?.sessionPhaseChanged(phase) } } diff --git a/App/Blurt/Blurt/Hotkey/SelectionSpeechRouter.swift b/App/Blurt/Blurt/Hotkey/SelectionSpeechRouter.swift index 4563e4db..8b74a181 100644 --- a/App/Blurt/Blurt/Hotkey/SelectionSpeechRouter.swift +++ b/App/Blurt/Blurt/Hotkey/SelectionSpeechRouter.swift @@ -46,8 +46,8 @@ final class SelectionSpeechRouter { perform(routing.keyLatched()) } - func sessionReachedTerminalPhase(_ phase: PipelinePhase) { - perform(routing.sessionReachedTerminalPhase(phase)) + func sessionPhaseChanged(_ phase: PipelinePhase) { + perform(routing.sessionPhaseChanged(phase)) } private func perform(_ actions: [SelectionSpeechRouting.Action]) { diff --git a/App/Blurt/Blurt/Overlay/OverlayWindowController.swift b/App/Blurt/Blurt/Overlay/OverlayWindowController.swift index e8fafc42..46dc362b 100644 --- a/App/Blurt/Blurt/Overlay/OverlayWindowController.swift +++ b/App/Blurt/Blurt/Overlay/OverlayWindowController.swift @@ -76,6 +76,8 @@ final class OverlayWindowController { // The revert timer for a transient notice; its dwell comes from the engine // (`OverlayUIState.noticeDwellSeconds`, unit-tested there). private var errorRevertTask: Task? + /// Bumped by every `setVisible`, so a fade-out's completion can tell it was superseded. + private var fadeGeneration = 0 // The pill fades in fast — the appear is tied to the user's keypress, so a snappy // ramp reads as instant response — but fades out gently. Asymmetric on purpose. @@ -254,9 +256,14 @@ final class OverlayWindowController { /// Drives the pill on/off screen, fading unless Reduce Motion is on. Idempotent /// and re-entrant: a key-down during the fade-out re-targets alpha back to 1, - /// and the in-flight fade's completion only orders the panel out if it actually - /// reached transparent — so a quick hide→show never strands a hidden panel. + /// and the in-flight fade's completion only orders the panel out if no later + /// call has superseded it — so a quick hide→show never strands a hidden panel. + /// A generation, not the alpha it ended at: `show` zeroes alpha itself to + /// resize under a transparent panel, and that must not read as the fade-out + /// having finished. private func setVisible(_ visible: Bool) { + fadeGeneration &+= 1 + let generation = fadeGeneration let reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion if visible { // Already up and fully opaque: nothing to ramp. Without this, each phase @@ -292,7 +299,7 @@ final class OverlayWindowController { completionHandler: { [weak self] in // NSAnimationContext completion handlers run on the main thread. MainActor.assumeIsolated { - guard let self, self.panel.alphaValue < 0.01 else { return } + guard let self, self.fadeGeneration == generation, self.panel.alphaValue < 0.01 else { return } self.dismissPanel() } }) diff --git a/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift b/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift index 13a2a28c..1ba0c04d 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift @@ -45,11 +45,20 @@ final class DictationCoordinator { @ObservationIgnored private var lastCommandID: UUID? @ObservationIgnored private var lastLevelPublish = ContinuousClock.now + /// Who pressed last and what their field held, until the session accepts + /// the press (`claimPress`). A press the session refuses — one arriving while + /// a dictation is still in flight — must not take over that dictation's words. + nonisolated private struct Press: Sendable { + var keyboard: String? + var context = FocusedFieldContext.empty + } + nonisolated private static let pendingPress = Mutex(Press()) /// What the keyboard saw around the cursor on the press that started the /// dictation in flight, read by the engine's context seam — and cleared once /// used, so nothing from another app's field primes a later request. nonisolated private static let pressContext = Mutex(.empty) - /// The keyboard process that pressed, for the words to go back to it. + /// The keyboard process whose press started the dictation in flight, for the + /// words to go back to it. nonisolated private static let pressKeyboard = Mutex(nil) /// Contact names, parsed once per lexicon refresh rather than on the press. nonisolated private static let lexiconNames = Mutex<[String]>([]) @@ -82,8 +91,10 @@ final class DictationCoordinator { // The keyboard is the only thing that can see the field: the press // command carries the text before the cursor, and that is what primes // the request — the same signal the Mac reads through Accessibility. + // `frontmost` runs only for a press the session accepts, first thing, so + // that is where the press's keyboard and field text become the dictation's. hostFocusCapture: DictationSession.HostFocusCapture( - frontmost: { nil }, field: { Self.fieldContext() })) + frontmost: { Self.claimPress() }, field: { Self.fieldContext() })) window.onClosingOnItsOwn = { [weak self] in await self?.windowClosingOnItsOwn() } } @@ -176,8 +187,7 @@ final class DictationCoordinator { /// set up, and the way to test the pipeline without one. No field, so no /// prior text primes it. func toggleDictation() { - Self.pressContext.withLock { $0 = .empty } - Self.pressKeyboard.withLock { $0 = nil } + Self.pendingPress.withLock { $0 = Press() } session.submit(phase.isCapturing ? .release : .press) } @@ -196,11 +206,12 @@ final class DictationCoordinator { guard command.isFresh() else { return } switch command.kind { case .press: - Self.pressContext.withLock { - $0 = FocusedFieldContext( - priorText: command.priorText, selectedText: command.selectedText, windowTitle: nil, fieldLabel: nil) + Self.pendingPress.withLock { + $0 = Press( + keyboard: command.keyboard, + context: FocusedFieldContext( + priorText: command.priorText, selectedText: command.selectedText, windowTitle: nil, fieldLabel: nil)) } - Self.pressKeyboard.withLock { $0 = command.keyboard } session.submit(.press) case .release: session.submit(.release) case .cancel: session.submit(.cancel) @@ -225,6 +236,19 @@ final class DictationCoordinator { KeyTerms.normalized(SharedStore.keyTerms + lexiconNames.withLock { $0 }) } + /// The session accepted a press: the last one asked for becomes the + /// dictation's, and is no longer pending. Nil, as the frontmost app: there is + /// none to paste into on the iPhone; the words go back to the keyboard. + nonisolated private static func claimPress() -> CapturedFocus? { + let press = pendingPress.withLock { pending in + defer { pending = Press() } + return pending + } + pressKeyboard.withLock { $0 = press.keyboard } + pressContext.withLock { $0 = press.context } + return nil + } + /// What the keyboard saw around the cursor on the press in flight, once. nonisolated static func fieldContext() -> FocusedFieldContext { pressContext.withLock { context in diff --git a/App/BlurtiOS/BlurtiOS/Sources/ListeningWindow.swift b/App/BlurtiOS/BlurtiOS/Sources/ListeningWindow.swift index 9ac7ec81..9a1e43a0 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/ListeningWindow.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/ListeningWindow.swift @@ -83,8 +83,10 @@ final class ListeningWindow { heartbeat?.cancel() heartbeat = Task { [weak self] in while !Task.isCancelled { - guard let self else { return } - SharedStore.appSeenAt = source.isOpen ? Date() : nil + // Re-checked each beat and not held across the sleep, so a window + // nobody closed can still go away and the beat stops with it. + guard let isOpen = self?.source.isOpen else { return } + SharedStore.appSeenAt = isOpen ? Date() : nil try? await Task.sleep(for: .seconds(SharedStore.appHeartbeatInterval)) } } diff --git a/Sources/AssemblyAI/Dictation/DictationWireTypes.swift b/Sources/AssemblyAI/Dictation/DictationWireTypes.swift index 3184f355..bc4afd6d 100644 --- a/Sources/AssemblyAI/Dictation/DictationWireTypes.swift +++ b/Sources/AssemblyAI/Dictation/DictationWireTypes.swift @@ -74,7 +74,7 @@ public struct DictationConfig: Encodable, Sendable { /// `text`, which is "always the verbatim transcript". So the config carries /// no off switch, and the *response* is where the enhanced-transcripts /// setting chooses between the two transcripts - /// (`AssemblyAITranscriber.transcript(from:)`). + /// (`AssemblyAITranscriber.transcript(from:enhanced:)`). /// /// An explicit null `llm` does suppress the rewrite — measured 2026-09-10 and /// re-confirmed 2026-09-11 (200, with `llm_response` and `llm_error` both @@ -91,7 +91,7 @@ public struct DictationConfig: Encodable, Sendable { /// users who turned cleanup off. That is the failure this whole path already /// shipped once, by a different route. Blurt sent the null until 2026-09-11. /// Adding an `llm` case back would reintroduce it *and* leave the response's - /// choice unreachable — read `transcript(from:)` first. + /// choice unreachable — read `transcript(from:enhanced:)` first. public let llmInstruction: String? /// Which model the cleanup rewrite runs on: `"smart"` (a larger model) or /// `"fast"`, or nil to omit the key and leave the route on its default. diff --git a/Sources/BlurtEngine/Config/EnhancedTranscriptsStore.swift b/Sources/BlurtEngine/Config/EnhancedTranscriptsStore.swift index 4b385401..5029eec8 100644 --- a/Sources/BlurtEngine/Config/EnhancedTranscriptsStore.swift +++ b/Sources/BlurtEngine/Config/EnhancedTranscriptsStore.swift @@ -7,7 +7,7 @@ import Foundation /// carries `llm_instruction` regardless, so the response always holds both the /// verbatim transcript and the cleanup rewrite (disfluencies removed, /// punctuation fixed); this flag decides which one gets pasted — see -/// `AssemblyAITranscriber.transcript(from:)`, which also records what that +/// `AssemblyAITranscriber.transcript(from:enhanced:)`, which also records what that /// costs a user who has it off. `AssemblyAITranscriber` reads this at each /// request, so a change applies to the very next dictation. /// Same shape as `DeveloperModeStore` / `SoundPackStore`. diff --git a/Sources/BlurtEngine/Config/RewriteModeStore.swift b/Sources/BlurtEngine/Config/RewriteModeStore.swift index 8f8eeb66..ad11504f 100644 --- a/Sources/BlurtEngine/Config/RewriteModeStore.swift +++ b/Sources/BlurtEngine/Config/RewriteModeStore.swift @@ -10,7 +10,7 @@ public enum RewriteMode: String, CaseIterable, Sendable { case fast = "Fast" /// The larger rewrite model. It has a longer server-side path, and a rewrite /// that misses the route's budget comes back as `llm_error` and degrades to the - /// verbatim transcript (`AssemblyAITranscriber.transcript(from:)`). + /// verbatim transcript (`AssemblyAITranscriber.transcript(from:enhanced:)`). case smart = "Smart" /// Decodes a persisted raw value, falling back to Fast for unset ("") or diff --git a/Sources/BlurtEngine/Config/SelectionAskStore.swift b/Sources/BlurtEngine/Config/SelectionAskStore.swift index 28cb9a5b..ca075d5e 100644 --- a/Sources/BlurtEngine/Config/SelectionAskStore.swift +++ b/Sources/BlurtEngine/Config/SelectionAskStore.swift @@ -6,9 +6,10 @@ import Foundation /// answer is read aloud (`SelectionSpeechRouting`, `SelectionSpeaker.answer`). /// A tap over a selection still reads it. /// -/// **On by default**, unlike read-aloud itself: it only does anything once the -/// user has opted in to read-aloud, and it is the half of the feature a hold -/// exists for. The Settings window's Advanced pane writes the slot through +/// **Off by default**, like skip-jargon (`ReadAloudStyleStore`) and for the same +/// reason: asking sends the selection and the spoken request to the LLM +/// Gateway, a second service, so it is its own opt-in rather than riding +/// read-aloud's. The Settings window's Advanced pane writes the slot through /// `@AppStorage`, so like `EnhancedTranscriptsStore` this store is read-only. public struct SelectionAskStore { /// UserDefaults key holding the switch. Public so SwiftUI views can observe @@ -17,7 +18,7 @@ public struct SelectionAskStore { /// The value an unset key reads as. Public so the Settings toggle's /// `@AppStorage` default comes from here and can't disagree with the router. - public static let defaultValue = true + public static let defaultValue = false private let defaults: UserDefaults @@ -29,7 +30,7 @@ public struct SelectionAskStore { self.defaults = defaults } - /// Unset means on, hence the presence check rather than `bool(forKey:)`. + /// Unset reads as `defaultValue`. public var isEnabled: Bool { defaults.object(forKey: Self.defaultsKey) as? Bool ?? Self.defaultValue } diff --git a/Sources/BlurtEngine/Config/StyleProfileStore.swift b/Sources/BlurtEngine/Config/StyleProfileStore.swift index 294c3a47..c06e34a1 100644 --- a/Sources/BlurtEngine/Config/StyleProfileStore.swift +++ b/Sources/BlurtEngine/Config/StyleProfileStore.swift @@ -35,7 +35,7 @@ public struct StyleProfile: Codable, Identifiable, Hashable, Sendable { /// set at all. Inert while enhanced transcripts are off — but not because the /// request changes: it still carries `llm_instruction` with the active profile /// appended, and the rewrite that profile shaped is what the response-side -/// switch then discards (`AssemblyAITranscriber.transcript(from:)`). So the +/// switch then discards (`AssemblyAITranscriber.transcript(from:enhanced:)`). So the /// profile is on the wire and off the screen. `AssemblyAITranscriber` reads /// `activeInstructions` at each request, so an edit — or a click on the main /// window's switcher — applies to the very next dictation. diff --git a/Sources/BlurtEngine/Hotkey/SelectionSpeechRouting.swift b/Sources/BlurtEngine/Hotkey/SelectionSpeechRouting.swift index 858e81f9..e286f455 100644 --- a/Sources/BlurtEngine/Hotkey/SelectionSpeechRouting.swift +++ b/Sources/BlurtEngine/Hotkey/SelectionSpeechRouting.swift @@ -62,14 +62,20 @@ public struct SelectionSpeechRouting: Sendable { case deciding(pending: [DictationSession.Command], asks: Bool, latched: Bool, held: Bool) /// A selection is in hand and the key is still down: a tap reads it, a hold asks. case selected(String) - /// Recording the spoken request about the selection. - case asking(String) - /// The request was let go of and is being transcribed. - case transcribing(String) + /// Recording the spoken request about the selection. Not `started` while + /// an earlier dictation is still finishing: the hand-back press waits for + /// it, since the session refuses a press until it is back at rest. + case asking(String, started: Bool) + /// The request was let go of and is being transcribed (or, not `started`, + /// was let go of before the mic ever opened). + case transcribing(String, started: Bool) case speaking } private var mode = Mode.passthrough + /// Whether the session's last phase was in flight. A terminal phase is then + /// that dictation's end, never a hand-back press that hasn't started. + private var sessionBusy = false public init() {} @@ -90,10 +96,10 @@ public struct SelectionSpeechRouting: Sendable { // combo over the selection. Neither asked for anything. mode = .passthrough return command == .cancelRecording ? [.forward(command)] : [] - case .asking(let selection): - return asking(selection, command) - case .transcribing: - return transcribing(command) + case .asking(let selection, let started): + return asking(selection, started: started, command) + case .transcribing(_, let started): + return transcribing(started: started, command) case .speaking: mode = .passthrough switch command { @@ -110,30 +116,34 @@ public struct SelectionSpeechRouting: Sendable { } } - private mutating func asking(_ selection: String, _ command: DictationSession.Command) -> [Action] { + private mutating func asking( + _ selection: String, started: Bool, _ command: DictationSession.Command + ) -> [Action] { switch command { case .release: - mode = .transcribing(selection) - return [.forward(.release)] + mode = .transcribing(selection, started: started) + return started ? [.forward(.release)] : [] case .cancel, .cancelRecording: mode = .passthrough - return [.forward(command)] + // Unstarted, the session is still busy with the earlier dictation, which + // a cancel would throw away. A recovery reset still goes through. + return started || command == .cancelRecording ? [.forward(command)] : [] case .press, .pressHandingBack: // The key is down for the whole request, so the gate can't press again. return [] } } - private mutating func transcribing(_ command: DictationSession.Command) -> [Action] { + private mutating func transcribing(started: Bool, _ command: DictationSession.Command) -> [Action] { switch command { case .press, .pressHandingBack: // A press while the request is transcribing drops it. The gate is armed // for this press, and its key-up would latch a recording nobody started. mode = .passthrough - return [.forward(.cancel), .resetGate] + return started ? [.forward(.cancel), .resetGate] : [.resetGate] case .cancel: mode = .passthrough - return [.forward(.cancel)] + return started ? [.forward(.cancel)] : [] case .release, .cancelRecording: // Nothing is recording, so the session treats either as a no-op. return [.forward(command)] @@ -149,12 +159,12 @@ public struct SelectionSpeechRouting: Sendable { case .selected(let selection): mode = .speaking return [.speak(selection)] - case .asking(let selection): + case .asking(let selection, let started): // Let go before the gate's own hold threshold, so it latched. For a // request, letting go is the end of it, and the latch holds no recording // the user knows about. - mode = .transcribing(selection) - return [.forward(.release), .resetGate] + mode = .transcribing(selection, started: started) + return started ? [.forward(.release), .resetGate] : [.resetGate] case .passthrough, .transcribing, .speaking: return [] } @@ -168,8 +178,7 @@ public struct SelectionSpeechRouting: Sendable { mode = .deciding(pending: pending, asks: true, latched: latched, held: true) return [] case .selected(let selection): - mode = .asking(selection) - return [.forward(.pressHandingBack)] + return beginAsking(selection) case .passthrough, .deciding, .asking, .transcribing, .speaking: return [] } @@ -194,14 +203,18 @@ public struct SelectionSpeechRouting: Sendable { mode = .speaking return [.speak(selection)] } - if held { - mode = .asking(selection) - return [.forward(.pressHandingBack)] - } + if held { return beginAsking(selection) } mode = .selected(selection) return [] } + /// Opens the mic for a request, through the session's hand-back press — or, + /// while an earlier dictation is still finishing, once it has. + private mutating func beginAsking(_ selection: String) -> [Action] { + mode = .asking(selection, started: !sessionBusy) + return sessionBusy ? [] : [.forward(.pressHandingBack)] + } + /// The read ended on its own, or failed. The caller must not report a read it /// stopped itself: a key stop has already moved on, and a newer read may be /// playing. @@ -211,33 +224,49 @@ public struct SelectionSpeechRouting: Sendable { return [.resetGate] } - /// A dictation reached a terminal phase. While commands are going to the - /// session, the gate is this router's to sync. While a read or a decision - /// owns it, an earlier dictation's end must not reset it. A request's end is - /// the one this router waits for: its transcript (`.handedBack`), nothing - /// said (`.idle`), or a failure. - public mutating func sessionReachedTerminalPhase(_ phase: PipelinePhase) -> [Action] { + /// The session's phase changed. Only a terminal phase can need an action: + /// while commands are going to the session, the gate is this router's to + /// sync. While a read or a decision owns it, an earlier dictation's end must + /// not reset it. A request's end is the one this router waits for: its + /// transcript (`.handedBack`), nothing said (`.idle`), or a failure — and + /// only once its own press has started; before that, a terminal phase is the + /// earlier dictation's end, which lets the request start. + public mutating func sessionPhaseChanged(_ phase: PipelinePhase) -> [Action] { + sessionBusy = !phase.isTerminal + guard phase.isTerminal else { return [] } switch mode { case .passthrough: return [.syncGateAfterSession] - case .asking(let selection), .transcribing(let selection): - let stillDown = mode == .asking(selection) - if case .handedBack(let request) = phase { - mode = .speaking - // Handed back with the key still down means the session's recording cap - // released it. Clear the gate, or the eventual key-up would stop the answer. - return [.answer(request: request, selection: selection)] + (stillDown ? [.resetGate] : []) - } - if phase == .idle, !stillDown { - mode = .speaking - return [.speak(selection)] - } - // The request failed or was cancelled: a dictation ending without a key - // event, like any other. - mode = .passthrough - return [.syncGateAfterSession] + case .asking(let selection, started: false): + mode = .asking(selection, started: true) + return [.forward(.pressHandingBack)] + case .transcribing(let selection, started: false): + // Let go before the mic ever opened: a request with nothing said. + mode = .speaking + return [.speak(selection)] + case .asking(let selection, started: true): + return requestEnded(phase, selection: selection, stillDown: true) + case .transcribing(let selection, started: true): + return requestEnded(phase, selection: selection, stillDown: false) case .deciding, .selected, .speaking: return [] } } + + private mutating func requestEnded(_ phase: PipelinePhase, selection: String, stillDown: Bool) -> [Action] { + if case .handedBack(let request) = phase { + mode = .speaking + // Handed back with the key still down means the session's recording cap + // released it. Clear the gate, or the eventual key-up would stop the answer. + return [.answer(request: request, selection: selection)] + (stillDown ? [.resetGate] : []) + } + if phase == .idle, !stillDown { + mode = .speaking + return [.speak(selection)] + } + // The request failed or was cancelled: a dictation ending without a key + // event, like any other. + mode = .passthrough + return [.syncGateAfterSession] + } } diff --git a/Sources/BlurtEngine/Pipeline/DictationLog.swift b/Sources/BlurtEngine/Pipeline/DictationLog.swift index 0f66ad6d..188c27f4 100644 --- a/Sources/BlurtEngine/Pipeline/DictationLog.swift +++ b/Sources/BlurtEngine/Pipeline/DictationLog.swift @@ -57,6 +57,13 @@ public enum DictationLog { /// when none were sent, and `encode(to:)` then omits the key, matching how /// every absent field above is left out rather than written as `null`. let keyterms: [String] + /// Which rewrite model the dictation asked for (`RewriteMode`: "fast" or + /// "smart"), so the corpus can be split by mode: Smart takes a different + /// server path and latency budget. Nil when enhanced transcripts were off and + /// the verbatim text was pasted. Read from the settings when the dictation + /// ends, not threaded from the request. Unlike `config.rewrite_mode`, which + /// leaves Fast out, Fast is written. + let rewriteMode: String? /// Spelled out because a hand-written `encode(to:)` means the compiler no /// longer derives this — the key names are the on-disk contract for anything @@ -65,6 +72,7 @@ public enum DictationLog { enum CodingKeys: String, CodingKey { case transcript, ts, app, window, field, prior, selected, keyterms case sttPrompt = "stt_prompt" + case rewriteMode = "rewrite_mode" } /// Hand-written for two fields: `sttPrompt` is a plain string and @@ -90,6 +98,7 @@ public enum DictationLog { if !keyterms.isEmpty { try container.encode(keyterms, forKey: .keyterms) } + try container.encodeIfPresent(rewriteMode, forKey: .rewriteMode) } } @@ -191,10 +200,12 @@ public enum DictationLog { transcript: String, context: TranscriptionContext? = nil, store: DeveloperModeStore = DeveloperModeStore(), + rewriteMode: @escaping @Sendable () -> RewriteMode? = { requestedRewriteMode() }, to url: URL = defaultURL ) { + // The settings are read behind the gate, like everything else the entry carries. gated(store: store) { now in - write(transcript: transcript, context: context, to: url, now: now) + write(transcript: transcript, context: context, rewriteMode: rewriteMode(), to: url, now: now) } } @@ -225,23 +236,34 @@ public enum DictationLog { /// so all of that is assertable directly rather than through a temp file and a /// substring search over the encoded line — a search that read the same whether /// a field was correctly absent or the write had failed outright. - static func makeEntry(transcript: String, context: TranscriptionContext?, now: Date) -> Entry { + static func makeEntry( + transcript: String, context: TranscriptionContext?, rewriteMode: RewriteMode? = nil, now: Date + ) -> Entry { Entry( transcript: transcript, ts: now.formatted(timestampFormat), app: context?.appName, window: context?.windowTitle, field: context?.fieldLabel, prior: context?.priorText, selected: context?.selectedText, sttPrompt: STTPrompt.text(context: context), - keyterms: KeyTerms.fitted(context?.keyTerms ?? []) + keyterms: KeyTerms.fitted(context?.keyTerms ?? []), + rewriteMode: rewriteMode.map { $0.rawValue.lowercased() } ) } + /// The rewrite the settings ask for: the chosen mode while enhanced + /// transcripts are on, nil while the verbatim text is pasted. The same two + /// settings `AssemblyAITranscriber` reads for the request. + static func requestedRewriteMode() -> RewriteMode? { + EnhancedTranscriptsStore().isEnabled ? RewriteModeStore().mode : nil + } + /// The unconditional writer: formats one entry and appends it. Distinct name /// rather than an `append` overload so the gated entry point above can't be /// bypassed by accidentally satisfying a different signature. static func write( - transcript: String, context: TranscriptionContext? = nil, to url: URL, now: Date + transcript: String, context: TranscriptionContext? = nil, rewriteMode: RewriteMode? = nil, to url: URL, + now: Date ) { - appendLine(makeEntry(transcript: transcript, context: context, now: now), to: url) + appendLine(makeEntry(transcript: transcript, context: context, rewriteMode: rewriteMode, now: now), to: url) } /// Encodes one entry and appends it as a JSONL line, creating the file (and diff --git a/Sources/BlurtEngine/README.md b/Sources/BlurtEngine/README.md index 3edba9ee..dee83fa9 100644 --- a/Sources/BlurtEngine/README.md +++ b/Sources/BlurtEngine/README.md @@ -77,7 +77,7 @@ press() ──▶ MicCapture.start() → frames release() ──▶ MicCapture Key properties of the design, which your integration can rely on: - **One request per utterance, chunked upload, whole response.** The dictation API returns the complete transcript — and its LLM-rewritten form — in the response body: no job polling, no incremental deltas, no second request for the cleanup. `TranscriberProtocol.transcribe` is a single `async throws -> String`, so UIs should still show a "transcribing…" state and then the whole result; there is nothing to stream _back_. What is streamed is the request: `transcribe(frames:sampleRate:context:)` is called at press and uploads the recording in chunks as it is captured, so only inference on the last frames lands on the wait the user sits through. There is deliberately no buffered fallback — a body that has already been streamed cannot be replayed, and the transcriber refuses `URLSession`'s replay request rather than silently re-sending an empty one. -- **Cleanup happens server-side, and the opt-out is on the response.** The request's `config.llm_instruction` asks the service to apply our own cleanup instruction (`CleanupInstruction.text` — delete disfluencies, change nothing else) to the verbatim transcript inside the same call. It is one of three steering fields on the request: `config.stt_prompt` primes the _transcription_ with the text that came before — the user's recent dictations, then the text before the cursor (`STTPrompt`, omitted when there is neither) — and `config.keyterms_prompt` boosts the user's key terms (`KeyTerms`, omitted when there are none). `prompt` is `stt_prompt`'s alias and `conversation_context` the array-of-turns field it replaced; a request carries exactly one of the three. The instruction rides **every** request; the **enhanced transcripts** setting (`EnhancedTranscriptsStore`, on by default) then decides which of the two transcripts in the response gets pasted (`AssemblyAITranscriber.transcript(from:)`). That ordering is the route's own advice: it rewrites by default, omitting `llm_instruction` only selects the service's wording, and the reference documents no way to decline — the escape it names is to ignore `llm_response` and use `text`. The undocumented `"llm": null` does suppress it (measured 2026-09-10) and Blurt sent it until 2026-09-11; the trade for dropping it is that "off" still waits out and still pays for a rewrite nobody reads. The **active style profile**'s instructions (`StyleProfileStore`, none by default) are appended to that instruction via `CleanupInstruction.sendable(appending:)`, trimmed to the headroom the API's 2048 instruction cap leaves (measured in UTF-8 bytes, the conservative bound — the cap's own unit is unmeasured); no profile, or blank instructions, means the base instruction goes out unchanged. Only the **active** profile's text is ever sent — never a join of the user's profiles, which would blow that cap and 400 the whole request rather than degrading. With the setting on 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; with it off it pastes `text` and discards the rewrite unread. There is no client-side LLM pass, no styling stage, and deliberately no hook for one. +- **Cleanup happens server-side, and the opt-out is on the response.** The request's `config.llm_instruction` asks the service to apply our own cleanup instruction (`CleanupInstruction.text` — delete disfluencies, change nothing else) to the verbatim transcript inside the same call. It is one of three steering fields on the request: `config.stt_prompt` primes the _transcription_ with the text that came before — the user's recent dictations, then the text before the cursor (`STTPrompt`, omitted when there is neither) — and `config.keyterms_prompt` boosts the user's key terms (`KeyTerms`, omitted when there are none). `prompt` is `stt_prompt`'s alias and `conversation_context` the array-of-turns field it replaced; a request carries exactly one of the three. The instruction rides **every** request; the **enhanced transcripts** setting (`EnhancedTranscriptsStore`, on by default) then decides which of the two transcripts in the response gets pasted (`AssemblyAITranscriber.transcript(from:enhanced:)`). That ordering is the route's own advice: it rewrites by default, omitting `llm_instruction` only selects the service's wording, and the reference documents no way to decline — the escape it names is to ignore `llm_response` and use `text`. The undocumented `"llm": null` does suppress it (measured 2026-09-10) and Blurt sent it until 2026-09-11; the trade for dropping it is that "off" still waits out and still pays for a rewrite nobody reads. The **active style profile**'s instructions (`StyleProfileStore`, none by default) are appended to that instruction via `CleanupInstruction.sendable(appending:)`, trimmed to the headroom the API's 2048 instruction cap leaves (measured in UTF-8 bytes, the conservative bound — the cap's own unit is unmeasured); no profile, or blank instructions, means the base instruction goes out unchanged. Only the **active** profile's text is ever sent — never a join of the user's profiles, which would blow that cap and 400 the whole request rather than degrading. With the setting on 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; with it off it pastes `text` and discards the rewrite unread. There is no client-side LLM pass, no styling stage, and deliberately no hook for one. - **Latency is pre-paid where possible, and never faked.** `press()` claims `.connecting` before it touches the mic, so a host's pill answers the keypress — but `MicCapture.start()` deliberately holds until the input device is actually delivering frames, and the _start cue_ waits for `.recording`. On a Bluetooth route those are ~1–2 s apart and the OS captures nothing in between, so cueing at the press loses the first words. Route activation itself cannot be pre-paid — measured, not assumed: nothing opens the device before `record()`, so the connecting window is where that cost lives and the gate is what makes it honest. `press()` fires a detached `warmUp()` at the transcriber (pre-opening the HTTPS connection so the request it opens moments later streams from its first frame, ~170 ms cold — measured to coalesce with that request rather than race it) and kicks off the cross-process accessibility read of the focused field without awaiting it — the read is then consumed when the _upload_ opens, with a bounded wait (`DictationSession.contextWaitBudget`, 500 ms), because the streaming route needs the `config` part before any audio — so an unresponsive frontmost app costs the transcript its priming, never a multi-second stall, and that wait is spent while the user is still speaking rather than while they wait for a transcript. On the way out, `release()` flips the phase to `.transcribing` _before_ stopping the mic, so a host's stop cue fires at key-up rather than after the Bluetooth tail linger. - **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 paying for a request that transcribes nothing (measured: the route answers a sub-floor clip with 200 and an empty transcript, not a 400). @@ -191,7 +191,7 @@ func transcribe( func warmUp() async // optional; no-op default ``` -`AssemblyAITranscriber` is a stateless `Sendable` struct. One `POST https://dictation.assemblyai.com/v1/transcribe/live` per utterance, **uploaded in chunks while the recording is still happening**: called at press, it writes the whole `config` part and the `audio` part's headers immediately and then each captured frame as `frames` delivers it, so finishing that stream is what signals end-of-audio. `URLRequest.httpBodyStream` is fed through a `CFStream` bound pair (`ChunkedRequestBody`, in the `AssemblyAI` library with `HTTPTransport`, the wire types and `APIKeyValidator`) because no `URLSession` API takes an `AsyncSequence` body; leaving `Content-Length` unset is what makes the transfer chunked. The body is a JSON `config` part (`sample_rate`, `channels`, and `llm_instruction` carrying `CleanupInstruction.text` with any custom style instructions appended — `CleanupInstruction.sendable(appending:)` — on every request, whatever the enhanced-transcripts setting says) followed by the raw S16LE PCM byte-for-byte, with the API key in `Authorization` and `Blurt/ (macOS .)` in `User-Agent` — `; dev` appended off a release build, so local installs stay out of the service-side baseline (see `UserAgent`; no model header — the service pins the STT model server-side). That `config` part is written **first**, and this route requires it — it rejects an audio-first body with a 400 (see `AssemblyAITranscriber.streamedBody` for the service's exact wording). That is where the latency comes from — the service opens its upstream STT call as soon as the config lands, so inference overlaps the recording and nothing sits between the last frame and the response. The cost is that the press-time focused-field read has to be settled _before_ the request opens, so `DictationSession.startUpload` awaits it (bounded by `contextWaitBudget`) and hands it in as a value; there is no longer a `context` channel for the body producer to wait on. The response carries the verbatim `text`, the rewritten `llm_response`, and the documented request metadata — `session_id` (quote it when reporting a problem), `audio_duration_ms`, `request_time_ms` and `sync_time_ms`, all logged by `logServerMetrics` and all decoded optionally so a dropped diagnostic can never turn a good dictation into a `malformedResponse`. `transcript(from:)` picks between the two transcripts: with enhanced transcripts on it returns the rewrite and falls back to `text` when that is null or blank (the rewrite is best-effort — `llm_error` is logged, never surfaced as a failure); with it off it returns `text`. 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 — an `enhancedTranscripts` closure deciding, per request, which of the response's two transcripts to paste (nil, the default, reads `EnhancedTranscriptsStore`), a `customStyle` closure supplying the style instructions appended to the cleanup instruction (nil, the default, reads the active profile from `StyleProfileStore`), and a `rewriteMode` closure choosing the rewrite's model (nil, the default, reads `RewriteModeStore`; Smart sends `config.rewrite_mode: "smart"`, Fast omits it, and nothing is sent while enhanced transcripts are off). `warmUp()` (in `DictationWarmUp.swift`) fires a GET at the route's own `/warm` endpoint — "an unauthenticated no-op" published for exactly this — to pre-pool the connection; it hit the bare host root until 2026-09-11, which pooled the same connection but asked for a page the service never documented answering. It never throws, and any failure just means the real request pays connection setup as before. The reference's two conditions for the pooled connection to still be there are structural here: same client (the injected `transport`, which is the one `transcribe` uses) and same host (`baseURL`, so a data-zone override warms the zone it will call). +`AssemblyAITranscriber` is a stateless `Sendable` struct. One `POST https://dictation.assemblyai.com/v1/transcribe/live` per utterance, **uploaded in chunks while the recording is still happening**: called at press, it writes the whole `config` part and the `audio` part's headers immediately and then each captured frame as `frames` delivers it, so finishing that stream is what signals end-of-audio. `URLRequest.httpBodyStream` is fed through a `CFStream` bound pair (`ChunkedRequestBody`, in the `AssemblyAI` library with `HTTPTransport`, the wire types and `APIKeyValidator`) because no `URLSession` API takes an `AsyncSequence` body; leaving `Content-Length` unset is what makes the transfer chunked. The body is a JSON `config` part (`sample_rate`, `channels`, and `llm_instruction` carrying `CleanupInstruction.text` with any custom style instructions appended — `CleanupInstruction.sendable(appending:)` — on every request, whatever the enhanced-transcripts setting says) followed by the raw S16LE PCM byte-for-byte, with the API key in `Authorization` and `Blurt/ (macOS .)` in `User-Agent` — `; dev` appended off a release build, so local installs stay out of the service-side baseline (see `UserAgent`; no model header — the service pins the STT model server-side). That `config` part is written **first**, and this route requires it — it rejects an audio-first body with a 400 (see `AssemblyAITranscriber.streamedBody` for the service's exact wording). That is where the latency comes from — the service opens its upstream STT call as soon as the config lands, so inference overlaps the recording and nothing sits between the last frame and the response. The cost is that the press-time focused-field read has to be settled _before_ the request opens, so `DictationSession.startUpload` awaits it (bounded by `contextWaitBudget`) and hands it in as a value; there is no longer a `context` channel for the body producer to wait on. The response carries the verbatim `text`, the rewritten `llm_response`, and the documented request metadata — `session_id` (quote it when reporting a problem), `audio_duration_ms`, `request_time_ms` and `sync_time_ms`, all logged by `logServerMetrics` and all decoded optionally so a dropped diagnostic can never turn a good dictation into a `malformedResponse`. `transcript(from:enhanced:)` picks between the two transcripts: with enhanced transcripts on it returns the rewrite and falls back to `text` when that is null or blank (the rewrite is best-effort — `llm_error` is logged, never surfaced as a failure); with it off it returns `text`. 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 — an `enhancedTranscripts` closure deciding, per request, which of the response's two transcripts to paste (nil, the default, reads `EnhancedTranscriptsStore`), a `customStyle` closure supplying the style instructions appended to the cleanup instruction (nil, the default, reads the active profile from `StyleProfileStore`), and a `rewriteMode` closure choosing the rewrite's model (nil, the default, reads `RewriteModeStore`; Smart sends `config.rewrite_mode: "smart"`, Fast omits it, and nothing is sent while enhanced transcripts are off). `warmUp()` (in `DictationWarmUp.swift`) fires a GET at the route's own `/warm` endpoint — "an unauthenticated no-op" published for exactly this — to pre-pool the connection; it hit the bare host root until 2026-09-11, which pooled the same connection but asked for a page the service never documented answering. It never throws, and any failure just means the real request pays connection setup as before. The reference's two conditions for the pooled connection to still be there are structural here: same client (the injected `transport`, which is the one `transcribe` uses) and same host (`baseURL`, so a data-zone override warms the zone it will call). 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. diff --git a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift index 5964f04d..b45ccc20 100644 --- a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift +++ b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift @@ -13,7 +13,7 @@ import os /// **Every request asks for that rewrite** — `llm_instruction` rides the config /// unconditionally, and the config carries no on/off switch at all. Which of the /// two transcripts gets pasted is decided on the *response*, by the "enhanced -/// transcripts" setting (on by default): see `transcript(from:)`, which also +/// transcripts" setting (on by default): see `transcript(from:enhanced:)`, which also /// states what that costs a user who has it off. /// No upload step, no job submission, no polling — one /// request per utterance covers transcription *and* cleanup. `config` leads the @@ -116,10 +116,14 @@ public struct AssemblyAITranscriber: TranscriberProtocol { // text before the cursor) and the key terms as keyterms prompting. App name, // window title, field label and selected text stay on the machine — // `STTPrompt` draws that line, so nothing is filtered here. + // Read once: the request's rewrite model and which transcript gets pasted + // must answer to the same setting, even if it is flipped mid-recording. + let enhanced = enhancedTranscriptsEnabled() let config = try makeConfigData( sampleRate: sampleRate, sttPrompt: STTPrompt.text(context: context), - keytermsPrompt: KeyTerms.fitted(context?.keyTerms ?? [])) + keytermsPrompt: KeyTerms.fitted(context?.keyTerms ?? []), + enhanced: enhanced) var request = URLRequest(url: baseURL.appending(path: Self.transcribePath)) request.httpMethod = "POST" @@ -146,7 +150,7 @@ public struct AssemblyAITranscriber: TranscriberProtocol { throw AssemblyAIError.malformedResponse } Self.logServerMetrics(response) - return transcript(from: response) + return transcript(from: response, enhanced: enhanced) } /// Which of the two transcripts in the response to paste. @@ -171,8 +175,8 @@ public struct AssemblyAITranscriber: TranscriberProtocol { /// slipping through would strand the utterance — the pipeline drops a /// whitespace-only transcript to `.idle` without pasting or reporting, wasting /// the good verbatim `text` beside it. - private func transcript(from response: DictationResponse) -> String { - guard enhancedTranscriptsEnabled() else { return response.text } + private func transcript(from response: DictationResponse, enhanced: Bool) -> String { + guard enhanced else { return response.text } if let rewrite = response.llmResponse.trimmedNonEmpty() { return rewrite } if let error = response.llmError { Self.log.warning( @@ -250,14 +254,16 @@ public struct AssemblyAITranscriber: TranscriberProtocol { /// alongside it — see `STTPrompt`. The cleanup instruction is /// `cleanupInstruction()`'s and rides every request, whatever the /// enhanced-transcripts setting says — that switch is applied to the response, - /// in `transcript(from:)`. Internal so tests can assert the + /// in `transcript(from:enhanced:)`. Internal so tests can assert the /// config wiring without inspecting the multipart upload body (which /// `URLProtocol` mocks can't observe reliably for `upload(from:)`). /// Neither steering field is defaulted: every caller states both, so what a /// given request does and does not steer with is readable at the call site /// rather than inferred from which argument was left off. + /// `enhanced` is the request's reading of the enhanced-transcripts setting; + /// nil reads it now (the tests' form). func makeConfigData( - sampleRate: Int, sttPrompt: String, keytermsPrompt: [String] + sampleRate: Int, sttPrompt: String, keytermsPrompt: [String], enhanced: Bool? = nil ) throws -> Data { try JSONEncoder().encode( DictationConfig( @@ -268,7 +274,7 @@ public struct AssemblyAITranscriber: TranscriberProtocol { llmInstruction: cleanupInstruction(), // Only while the rewrite is what gets pasted: with enhanced transcripts // off it is discarded unread, so a slower model would only add latency. - rewriteMode: enhancedTranscriptsEnabled() ? rewriteMode().wireValue : nil + rewriteMode: (enhanced ?? enhancedTranscriptsEnabled()) ? rewriteMode().wireValue : nil ) ) } diff --git a/Sources/BlurtEngine/STT/CleanupInstruction.swift b/Sources/BlurtEngine/STT/CleanupInstruction.swift index 3197c0da..5413e147 100644 --- a/Sources/BlurtEngine/STT/CleanupInstruction.swift +++ b/Sources/BlurtEngine/STT/CleanupInstruction.swift @@ -100,7 +100,7 @@ enum CleanupInstruction { /// service's own default cleanup: worse than our instruction, and immeasurably /// better than an outage. It does *not* turn the rewrite off — nothing on the /// request does, and the enhanced-transcripts switch is applied to the - /// response instead (`AssemblyAITranscriber.transcript(from:)`). + /// response instead (`AssemblyAITranscriber.transcript(from:enhanced:)`). /// /// The tests make this unreachable, which is the point — this is the belt to /// their braces, for the edit that lands when nobody runs them. diff --git a/Sources/BlurtEngine/TTS/SelectionCopy.swift b/Sources/BlurtEngine/TTS/SelectionCopy.swift index 3ab4e1cf..533e974d 100644 --- a/Sources/BlurtEngine/TTS/SelectionCopy.swift +++ b/Sources/BlurtEngine/TTS/SelectionCopy.swift @@ -27,15 +27,22 @@ /// tens of milliseconds, and nothing landing means nothing was selected. Kept /// short because a dictation press pays all of it. private let timeout: Duration + /// How long after `timeout` a late answer to the ⌘C is still watched for, + /// so the user's clipboard is put back even when the app copied too slowly + /// to be read (a busy app can take past `timeout`). The press doesn't wait + /// for this; it runs behind it. + private let lateCopyWindow: Duration init( pasteboard: any CopyPasteboard = SystemClipboard(), postCopy: @escaping @Sendable () -> Bool = { KeyInjector.postCmdC() }, - timeout: Duration = .milliseconds(100) + timeout: Duration = .milliseconds(100), + lateCopyWindow: Duration = .seconds(1) ) { self.pasteboard = pasteboard self.postCopy = postCopy self.timeout = timeout + self.lateCopyWindow = lateCopyWindow } /// The copied text, up to `maxCharacters`, or nil when the copy produced @@ -61,12 +68,37 @@ return pasteboard.changeCount == count ? (count, string) : (count, nil) } } while copied == nil && ContinuousClock.now < deadline - guard seen != before else { return nil } let copyCount = seen - await DictationSession.offPool { pasteboard.restore(saved, ifChangeCountIs: copyCount) } + let settled = await DictationSession.offPool { + if copyCount != before { pasteboard.restore(saved, ifChangeCountIs: copyCount) } + return pasteboard.changeCount + } + // Nothing readable in time, but the app may still answer. Nothing else + // would put the user's clipboard back then. + if copied == nil { restoreLateCopy(saved, after: settled) } // The same cleanup as the AX read, so a zero-width-only copy isn't spoken. return FocusCapture.visibleTextOrNil(FocusCapture.clip(copied?.trimmedNonEmpty(), to: maxCharacters)) } + + /// Restores `saved` over the first change after `baseline`, if one comes + /// within `lateCopyWindow`: the ⌘C's answer arriving after the press gave up + /// on it. + private func restoreLateCopy(_ saved: PasteboardSnapshot, after baseline: Int) { + let pasteboard = pasteboard + let deadline = ContinuousClock.now + lateCopyWindow + Task { + while ContinuousClock.now < deadline { + try? await Task.sleep(for: .milliseconds(20)) + let restored = await DictationSession.offPool { () -> Bool in + let count = pasteboard.changeCount + guard count != baseline else { return false } + pasteboard.restore(saved, ifChangeCountIs: count) + return true + } + if restored { return } + } + } + } } /// The pasteboard operations `SelectionCopy` needs, so tests never touch the real diff --git a/Tests/BlurtEngineTests/AssemblyAITranscriberTests.swift b/Tests/BlurtEngineTests/AssemblyAITranscriberTests.swift index 9d19f410..f24b19e5 100644 --- a/Tests/BlurtEngineTests/AssemblyAITranscriberTests.swift +++ b/Tests/BlurtEngineTests/AssemblyAITranscriberTests.swift @@ -1,4 +1,5 @@ import Foundation +import Synchronization import Testing @testable import AssemblyAI @@ -205,7 +206,7 @@ struct HTTPClientTests { // // And sent with the enhanced-transcripts switch in **both** positions, // which is the change of 2026-09-11: the request no longer varies with that - // setting at all. `transcript(from:)` applies it to the response instead. + // setting at all. `transcript(from:enhanced:)` applies it to the response instead. let object = try configObject(prompt: prompt, enhancedTranscripts: enhancedTranscripts) #expect(object["llm_instruction"] as? String == CleanupInstruction.text) // Two keys that must never appear. `llm` is the nested block this replaced — @@ -260,6 +261,25 @@ struct HTTPClientTests { #expect(result == "um hello") } + @Test("the setting is read once per request, so a mid-recording flip can't split request from paste") + func enhancedReadOncePerRequest() async throws { + // On when the request opened, off by the time it answered: the request asked + // for the rewrite, so the rewrite is what gets pasted. + let reads = Mutex(0) + let transport = FakeHTTPTransport { _ in (200, Data(#"{"text":"um hello","llm_response":"Hello."}"#.utf8)) } + let transcriber = AssemblyAITranscriber( + apiKeyProvider: { "test-key" }, transport: transport, + enhancedTranscripts: { + reads.withLock { + $0 += 1 + return $0 == 1 + } + }, + customStyle: { nil }, rewriteMode: { .smart }) + #expect(try await collectTranscript(transcriber) == "Hello.") + #expect(reads.withLock { $0 } == 1) + } + @Test("transcriber HTTP error carries the decoded server message") func transcribeHTTPErrorMessage() async throws { // A documented status carrying a documented shape: 413 with diff --git a/Tests/BlurtEngineTests/DictationLogTests.swift b/Tests/BlurtEngineTests/DictationLogTests.swift index 4b03d095..76d7d3a9 100644 --- a/Tests/BlurtEngineTests/DictationLogTests.swift +++ b/Tests/BlurtEngineTests/DictationLogTests.swift @@ -150,6 +150,16 @@ struct DictationLogEntryTests { #expect(entry.keyterms == ["AssemblyAI", "LeMUR"]) } + @Test("records which rewrite model was asked for, Fast included, and none with the rewrite off") + func logsTheRewriteMode() { + func logged(_ mode: RewriteMode?) -> String? { + DictationLog.makeEntry(transcript: "p", context: nil, rewriteMode: mode, now: Date()).rewriteMode + } + #expect(logged(.smart) == "smart") + #expect(logged(.fast) == "fast") + #expect(logged(nil) == nil) + } + @Test("leaves the key terms empty when the context has none") func noKeytermsLeavesTheFieldEmpty() { #expect(DictationLog.makeEntry(transcript: "p", context: context, now: Date()).keyterms.isEmpty) @@ -217,6 +227,7 @@ struct DictationLogTests { #expect(!line.contains("selected")) #expect(!line.contains("stt_prompt")) #expect(!line.contains("keyterms")) + #expect(!line.contains("rewrite_mode")) } @Test("a non-empty prompt/keyterms pair is written, with its values intact") @@ -228,11 +239,12 @@ struct DictationLogTests { let url = makeTempLogURL() let context = TranscriptionContext( appName: "Mail", priorText: "Hi Sam,", keyTerms: ["AssemblyAI", "LeMUR"]) - DictationLog.write(transcript: "p", context: context, to: url, now: Date()) + DictationLog.write(transcript: "p", context: context, rewriteMode: .smart, to: url, now: Date()) let entry = try #require(firstEntry(in: url)) #expect(entry.keyterms == ["AssemblyAI", "LeMUR"]) #expect(entry.sttPrompt == "Hi Sam,") + #expect(readLog(url).contains(#""rewrite_mode":"smart""#)) } } diff --git a/Tests/BlurtEngineTests/KeytermsWireTests.swift b/Tests/BlurtEngineTests/KeytermsWireTests.swift index e7a9cf2d..dba170c8 100644 --- a/Tests/BlurtEngineTests/KeytermsWireTests.swift +++ b/Tests/BlurtEngineTests/KeytermsWireTests.swift @@ -124,7 +124,7 @@ extension HTTPClientTests { // `llm_instruction` nor `llm` still comes back with `llm_response` set to a // default-cleaned transcript. So this row is "the service's wording", not // "no rewrite" — which is fine, because the config no longer tries to - // decline (`AssemblyAITranscriber.transcript(from:)` chooses instead). + // decline (`AssemblyAITranscriber.transcript(from:enhanced:)` chooses instead). #expect(try rewriteKeys(nil) == []) } diff --git a/Tests/BlurtEngineTests/SelectionAskStoreTests.swift b/Tests/BlurtEngineTests/SelectionAskStoreTests.swift index 9613a8ac..b92e29d4 100644 --- a/Tests/BlurtEngineTests/SelectionAskStoreTests.swift +++ b/Tests/BlurtEngineTests/SelectionAskStoreTests.swift @@ -5,10 +5,10 @@ import Testing @Suite("SelectionAskStore") struct SelectionAskStoreTests { - @Test("defaults to on when unset — it only acts once read-aloud is opted in to") - func defaultsToOn() { - #expect(SelectionAskStore(defaults: freshDefaults()).isEnabled) - #expect(SelectionAskStore.defaultValue) + @Test("defaults to off when unset — asking sends the selection to the LLM Gateway, so it is its own opt-in") + func defaultsToOff() { + #expect(!SelectionAskStore(defaults: freshDefaults()).isEnabled) + #expect(!SelectionAskStore.defaultValue) } @Test("reads back the switch the Settings toggle writes") diff --git a/Tests/BlurtEngineTests/SelectionCopyTests.swift b/Tests/BlurtEngineTests/SelectionCopyTests.swift index 79136962..1d88fa4c 100644 --- a/Tests/BlurtEngineTests/SelectionCopyTests.swift +++ b/Tests/BlurtEngineTests/SelectionCopyTests.swift @@ -125,6 +125,21 @@ struct SelectionCopyTests { #expect(pasteboard.string == "user's clipboard") } + @Test("a copy that lands after the timeout still gets the user's clipboard back") + func lateCopyRestored() async throws { + let pasteboard = FakeCopyPasteboard() + // The app answers nothing in time; its copy lands once the press has given up. + let copy = SelectionCopy( + pasteboard: pasteboard, postCopy: { true }, timeout: .milliseconds(20), lateCopyWindow: .seconds(5)) + #expect(await copy.copySelection(maxCharacters: 100) == nil) + _ = pasteboard.answerCopy(with: "the highlighted text")() + let deadline = ContinuousClock.now + .seconds(5) + while pasteboard.restoreCount == 0, ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + #expect(pasteboard.string == "user's clipboard") + } + @Test("the fallback runs only when AX was unreadable, never on a bare caret") func resolveRule() async { let pasteboard = FakeCopyPasteboard() diff --git a/Tests/BlurtEngineTests/SelectionSpeechRoutingTests.swift b/Tests/BlurtEngineTests/SelectionSpeechRoutingTests.swift index 5b638834..096efa63 100644 --- a/Tests/BlurtEngineTests/SelectionSpeechRoutingTests.swift +++ b/Tests/BlurtEngineTests/SelectionSpeechRoutingTests.swift @@ -63,12 +63,12 @@ struct SelectionSpeechRoutingTests { @Test("a dictation's terminal phase syncs the gate only while commands go to the session") func terminalPhaseOwnership() { var routing = SelectionSpeechRouting() - #expect(routing.sessionReachedTerminalPhase(.pasted) == [.syncGateAfterSession]) + #expect(routing.sessionPhaseChanged(.pasted) == [.syncGateAfterSession]) _ = routing.submit(.press, readAloudEnabled: true) - #expect(routing.sessionReachedTerminalPhase(.pasted).isEmpty) + #expect(routing.sessionPhaseChanged(.pasted).isEmpty) _ = routing.selectionResolved("Hello there.") // The earlier dictation finishing mid-read must not reset the read's gate. - #expect(routing.sessionReachedTerminalPhase(.pasted).isEmpty) + #expect(routing.sessionPhaseChanged(.pasted).isEmpty) } @Test("a press that lands during a slow read and is dropped clears the gate it latched") @@ -104,8 +104,8 @@ struct SelectionSpeechRoutingAskTests { /// A routing that has pressed over `selection` with asking on, and resolved it /// while the key is still down. - private func selected() -> SelectionSpeechRouting { - var routing = SelectionSpeechRouting() + private func selected(_ start: SelectionSpeechRouting = SelectionSpeechRouting()) -> SelectionSpeechRouting { + var routing = start _ = routing.submit(.press, readAloudEnabled: true, askEnabled: true) _ = routing.selectionResolved(selection) return routing @@ -171,7 +171,7 @@ struct SelectionSpeechRoutingAskTests { var routing = asking() _ = routing.submit(.release, readAloudEnabled: true, askEnabled: true) #expect( - routing.sessionReachedTerminalPhase(.handedBack("Summarize this.")) + routing.sessionPhaseChanged(.handedBack("Summarize this.")) == [.answer(request: "Summarize this.", selection: selection)]) // The answer is speech like any read: a key command stops it, and a natural // end clears the gate. @@ -182,7 +182,7 @@ struct SelectionSpeechRoutingAskTests { func stopAnswerWithTap() { var routing = asking() _ = routing.submit(.release, readAloudEnabled: true, askEnabled: true) - _ = routing.sessionReachedTerminalPhase(.handedBack("Summarize this.")) + _ = routing.sessionPhaseChanged(.handedBack("Summarize this.")) // The gate was idle under the answer, so the stopping tap arrives as a press. #expect(routing.submit(.press, readAloudEnabled: true, askEnabled: true) == [.stopSpeech, .resetGate]) // Its key-up latch then finds nothing to do, and the next press is a press. @@ -194,7 +194,7 @@ struct SelectionSpeechRoutingAskTests { func nothingSaid() { var routing = asking() _ = routing.submit(.release, readAloudEnabled: true, askEnabled: true) - #expect(routing.sessionReachedTerminalPhase(.idle) == [.speak(selection)]) + #expect(routing.sessionPhaseChanged(.idle) == [.speak(selection)]) } @Test("a request that fails is a dictation ending without a key event") @@ -202,7 +202,7 @@ struct SelectionSpeechRoutingAskTests { var routing = asking() _ = routing.submit(.release, readAloudEnabled: true, askEnabled: true) let failed = PipelinePhase.failed(.sttFailed(underlying: URLError(.timedOut))) - #expect(routing.sessionReachedTerminalPhase(failed) == [.syncGateAfterSession]) + #expect(routing.sessionPhaseChanged(failed) == [.syncGateAfterSession]) // Back to passthrough: the next press dictates or reads as usual. #expect(routing.submit(.press, readAloudEnabled: false) == [.forward(.press)]) } @@ -211,14 +211,14 @@ struct SelectionSpeechRoutingAskTests { func failureWhileHeld() { var routing = asking() let failed = PipelinePhase.failed(.audioCaptureFailed(underlying: URLError(.unknown))) - #expect(routing.sessionReachedTerminalPhase(failed) == [.syncGateAfterSession]) + #expect(routing.sessionPhaseChanged(failed) == [.syncGateAfterSession]) } @Test("the recording cap handing back with the key still down answers and clears the gate") func handedBackWhileHeld() { var routing = asking() #expect( - routing.sessionReachedTerminalPhase(.handedBack("What is this?")) + routing.sessionPhaseChanged(.handedBack("What is this?")) == [.answer(request: "What is this?", selection: selection), .resetGate]) } @@ -234,7 +234,35 @@ struct SelectionSpeechRoutingAskTests { _ = routing.submit(.release, readAloudEnabled: true, askEnabled: true) #expect(routing.submit(.press, readAloudEnabled: true, askEnabled: true) == [.forward(.cancel), .resetGate]) // A transcript arriving after that is someone else's now, and ignored. - #expect(routing.sessionReachedTerminalPhase(.cancelled) == [.syncGateAfterSession]) + #expect(routing.sessionPhaseChanged(.cancelled) == [.syncGateAfterSession]) + } + + @Test("a hold while an earlier dictation finishes waits for it, then asks") + func holdWaitsForEarlierDictation() { + var routing = SelectionSpeechRouting() + // The earlier dictation was let go of and is still being transcribed. + _ = routing.sessionPhaseChanged(.transcribing) + var held = selected(routing) + // The session would refuse a press now, so none is sent yet... + #expect(held.holdElapsed().isEmpty) + // ...and the earlier dictation's end is not the request's: the mic opens now. + #expect(held.sessionPhaseChanged(.pasted) == [.forward(.pressHandingBack)]) + #expect(held.sessionPhaseChanged(.recording).isEmpty) + #expect(held.submit(.release, readAloudEnabled: true, askEnabled: true) == [.forward(.release)]) + #expect( + held.sessionPhaseChanged(.handedBack("Summarize this.")) + == [.answer(request: "Summarize this.", selection: selection)]) + } + + @Test("a request let go of before the earlier dictation finished reads the selection, cancelling nothing") + func releaseBeforeEarlierDictationEnds() { + var routing = SelectionSpeechRouting() + _ = routing.sessionPhaseChanged(.transcribing) + var held = selected(routing) + _ = held.holdElapsed() + // Nothing goes to the session: it is still busy with the earlier dictation. + #expect(held.submit(.release, readAloudEnabled: true, askEnabled: true).isEmpty) + #expect(held.sessionPhaseChanged(.pasted) == [.speak(selection)]) } @Test("a quick release before the hold delay, under a hold-only trigger, does nothing") From 58d273246e89fb5bf876aed8715379257cbd35d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 01:50:41 +0000 Subject: [PATCH 03/10] refactor: paste where Accessibility can't tell, instead of naming Sublime and Zed The editability check gets a third answer, FocusedEditability.cannotTell: a system-wide focus query that fails with cannotComplete, or a bare AXWindow with no text signal - what editors that draw their own text view report. hasEditableFocusedElement pastes on it, so the custom-drawn editor bundle-ID list, its wrappers and its tests go. Electron and browsers keep their app-identity exemption, since they answer AX with a definite but wrong no. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X84Vo9pry7SANcbu86Jjrh --- .../FocusCapture+Editability.swift | 114 ++++++++++-------- .../FocusCapture/FocusCapture.swift | 17 ++- Sources/BlurtEngine/README.md | 2 +- .../BrowserBundleIDTests.swift | 39 ------ .../EditableTargetTests.swift | 36 ++++++ 5 files changed, 111 insertions(+), 97 deletions(-) diff --git a/Sources/BlurtEngine/FocusCapture/FocusCapture+Editability.swift b/Sources/BlurtEngine/FocusCapture/FocusCapture+Editability.swift index 2c09c3d5..740ff0d6 100644 --- a/Sources/BlurtEngine/FocusCapture/FocusCapture+Editability.swift +++ b/Sources/BlurtEngine/FocusCapture/FocusCapture+Editability.swift @@ -101,65 +101,78 @@ isBrowserBundleID(app?.bundleIdentifier) } - /// Bundle-identifier prefixes of native editors that draw their own text view - /// and expose no text element to Accessibility at all: with the caret in a - /// buffer, the focused element is the bare `AXWindow` — no value, no selection - /// range — or the system-wide query fails outright (`kAXErrorCannotComplete`). - /// Measured on Sublime Text 4 (build 4215), Sublime Merge (build 2132, the - /// commit-message box) and Zed 1.23.2; ⌘V lands in all three. Prefix-matched - /// so channel variants classify with their stable siblings. - private static let customDrawnEditorBundleIDPrefixes: [String] = [ - "com.sublimetext.", // Sublime Text 3 + 4 - "com.sublimemerge", - "dev.zed.Zed", // Zed + Preview/Nightly - ] - - /// Pure decision behind `isCustomDrawnEditorApp`, split out for the same reason - /// as `isBrowserBundleID`. - static func isCustomDrawnEditorBundleID(_ bundleID: String?) -> Bool { - guard let bundleID else { return false } - return customDrawnEditorBundleIDPrefixes.contains { bundleID.hasPrefix($0) } - } - - /// Whether `app` is a known custom-drawn editor (Sublime Text, Sublime Merge, - /// Zed). Neither a browser nor Electron, yet just as invisible to Accessibility - /// — so without this it would read as "nothing editable focused" and every - /// dictation would land on the clipboard instead of in the buffer. - static func isCustomDrawnEditorApp(_ app: NSRunningApplication?) -> Bool { - isCustomDrawnEditorBundleID(app?.bundleIdentifier) - } - - /// Whether `app` is AX-opaque — an Electron editor, a web browser, or a - /// custom-drawn editor — where a focused text field can expose no editable AX - /// signal at all. These are the one case the injector still pastes into on no - /// signal: dropping the user's words into a copy-only fallback there would be + /// Whether `app` is AX-opaque — an Electron editor or a web browser — where a + /// focused text field can expose no editable AX signal at all. These are the + /// one case the injector still pastes into on a definite no-signal answer: + /// dropping the user's words into a copy-only fallback there would be /// the worse mistake. The accepted trade-off is a rare ⌘V beep when such an /// app truly has nothing editable focused. static func isAXOpaqueApp(_ app: NSRunningApplication?) -> Bool { // String prefix checks first, whereas isElectronApp probes the disk // (FileManager.fileExists) — skip that I/O for the common case. - isBrowserApp(app) || isCustomDrawnEditorApp(app) || isElectronApp(app) + isBrowserApp(app) || isElectronApp(app) + } + + /// What Accessibility says about pasting into the focused element. Three + /// answers, not two: an app can draw its own text view and show AX nothing at + /// all, and that is "can't tell", not "nothing to paste into". + enum FocusedEditability: Equatable { + /// A text role, a settable value, or an insertion point. + case editable + /// AX answered, and what it shows takes no text: ⌘V would only beep. + case notEditable + /// AX couldn't answer. Measured on editors that draw their own text view + /// (Sublime Text 4, Sublime Merge, Zed 1.23.2, where ⌘V lands): with the + /// caret in a buffer the system-wide focus query fails with + /// `kAXErrorCannotComplete`, or the focused element is the bare `AXWindow` + /// with no value and no selection range. + case cannotTell + } + + /// Pure decision for a focus query that resolved no element: a query that + /// couldn't complete can't tell; one that answered "nothing focused" (a native + /// app with no focused control, the desktop) means nothing takes text. + static func editability(ofFailedFocusQuery error: AXError) -> FocusedEditability { + error == .cannotComplete ? .cannotTell : .notEditable + } + + /// Pure decision for a focused element's signals: `isEditableTarget`'s + /// positive signals, else a bare window — the element a custom-drawn editor + /// reports — can't tell, and anything else takes no text. + static func editability( + role: String?, valueSettable: Bool, hasInsertionPoint: Bool + ) -> FocusedEditability { + if isEditableTarget(role: role, valueSettable: valueSettable, hasInsertionPoint: hasInsertionPoint) { + return .editable + } + return role == windowRole ? .cannotTell : .notEditable } - /// Whether the system-wide focused element can accept pasted text right now. - /// Read by `KeyInjector` (off the main actor, after it has activated the target - /// app) just before pasting — the Accessibility *client* read APIs are - /// thread-safe. Returns `true` whenever AX can't be consulted (process not - /// trusted) or can't resolve a focused element, so an unknowable state still - /// attempts the paste — the injector's own trust check then handles the - /// missing-permission case. + private static let windowRole = "AXWindow" + + /// Whether the system-wide focused element may accept pasted text right now: + /// anything but a definite `.notEditable`. Read by `KeyInjector` (off the main + /// actor, after it has activated the target app) just before pasting — the + /// Accessibility *client* read APIs are thread-safe. Returns `true` whenever AX + /// can't be consulted (process not trusted) or can't tell, so an unknowable + /// state still attempts the paste — the injector's own trust check then + /// handles the missing-permission case. nonisolated static func hasEditableFocusedElement() -> Bool { guard AXIsProcessTrusted() else { return true } + return focusedEditability() != .notEditable + } - guard let element = systemFocusedElement() else { - // AX is trusted but reports no focused element — e.g. a native app frontmost - // with nothing editable focused (Finder, the desktop, a button-only window). - // Posting ⌘V there only beeps, so treat it as non-editable and copy instead. - // AX-opaque apps (Electron editors like VS Code/Slack, and browsers before - // Chromium's lazy accessibility tree is built) also expose no focused - // element here, but the injector's app-identity check still pastes into - // those (see `KeyInjector.insert` / `isAXOpaqueApp`). - return false + nonisolated private static func focusedEditability() -> FocusedEditability { + let query = queryFocusedElement() + guard let element = query.element else { + // AX is trusted but resolved no focused element. Usually a native app with + // nothing editable focused (Finder, the desktop, a button-only window), + // where ⌘V only beeps; but a query that couldn't complete is AX failing, + // not an answer. AX-opaque apps (Electron editors like VS Code/Slack, and + // browsers before Chromium's lazy accessibility tree is built) also land + // here, and the injector's app-identity check still pastes into those (see + // `KeyInjector.insert` / `isAXOpaqueApp`). + return editability(ofFailedFocusQuery: query.error) } // Same checked reader `captureFieldContext` uses for this attribute, so the @@ -183,8 +196,7 @@ element, kAXSelectedTextRangeAttribute as CFString, &rangeRef) == .success && rangeRef.flatMap(axRange) != nil - return isEditableTarget( - role: role, valueSettable: valueSettable, hasInsertionPoint: hasInsertionPoint) + return editability(role: role, valueSettable: valueSettable, hasInsertionPoint: hasInsertionPoint) } } #endif diff --git a/Sources/BlurtEngine/FocusCapture/FocusCapture.swift b/Sources/BlurtEngine/FocusCapture/FocusCapture.swift index 447e0b34..85997f49 100644 --- a/Sources/BlurtEngine/FocusCapture/FocusCapture.swift +++ b/Sources/BlurtEngine/FocusCapture/FocusCapture.swift @@ -126,6 +126,14 @@ /// APIs are thread-safe, so this serves both the off-main context capture /// and the injector's off-main editability check. nonisolated static func systemFocusedElement() -> AXUIElement? { + queryFocusedElement().element + } + + /// `systemFocusedElement` with the query's own answer kept: the editability + /// check tells a query that couldn't complete (`.cannotComplete`) apart from + /// one that found nothing focused. `.success` with a nil element is a + /// misbehaving app's mistyped reply. + nonisolated static func queryFocusedElement() -> (element: AXUIElement?, error: AXError) { let system = AXUIElementCreateSystemWide() // Setting the timeout on the system-wide element applies it process-wide // (per AXUIElement.h), bounding this focused-element lookup AND every later @@ -133,12 +141,9 @@ // miss (the window element behind `windowTitle`, the editability probes). AXUIElementSetMessagingTimeout(system, axMessagingTimeoutSeconds) var focusedRef: CFTypeRef? - guard - AXUIElementCopyAttributeValue(system, kAXFocusedUIElementAttribute as CFString, &focusedRef) - == .success, - let focusedRef - else { return nil } - return axElement(focusedRef) + let error = AXUIElementCopyAttributeValue(system, kAXFocusedUIElementAttribute as CFString, &focusedRef) + guard error == .success, let focusedRef else { return (nil, error) } + return (axElement(focusedRef), .success) } // MARK: - Checked CF downcasts diff --git a/Sources/BlurtEngine/README.md b/Sources/BlurtEngine/README.md index dee83fa9..71c5b006 100644 --- a/Sources/BlurtEngine/README.md +++ b/Sources/BlurtEngine/README.md @@ -204,7 +204,7 @@ func insert(_ text: String, after priorText: String?, windowTitle: String?) asyn `CapturedFocus` is a pid and a display name rather than an `NSRunningApplication`, so the seam is the same on every platform the engine builds for: `KeyInjector` resolves the process handle from the pid on macOS, and a host with no notion of a target app — an iPhone keyboard inserts into the field it is already attached to — receives `nil` and ignores it. -`KeyInjector.insert` **always** pastes: it saves the current pasteboard, writes the transcript, activates the captured target app, posts a synthesized ⌘V, waits for the target to read the clipboard (`pasteSettleDuration`, default 400 ms, tunable in the initializer), then restores the prior pasteboard contents. That restore never destroys what it can't put back: an unreadable pasteboard snapshots as `nil` (not as empty) and is skipped, and the replacement items are built before `clearContents()`, with the plain-string flavor as a floor when promised representations can't be materialized. There is no keystroke-by-keystroke typing path and no length threshold. If the target app is gone or nothing editable is focused it leaves the text on the clipboard and throws `.targetAppLost` / `.noEditableTarget` — which the session turns into the quiet `.noTarget` outcome. AX-opaque targets — Electron editors, web browsers and custom-drawn editors like Sublime Text and Zed, per `FocusCapture.isAXOpaqueApp` — are exempt from the editability gate and are pasted into even with no editable AX signal: web content is routinely invisible to Accessibility (Chromium builds its tree lazily; `contenteditable` composers expose no editable role), so "no signal" there usually means "AX can't see the field," not "no field," and the accepted trade-off is a rare beep over dropping the user's words to copy-only. `priorText` (the text before the caret, captured at press time) drives `InsertionSeparator.withLeadingSeparator`, which joins consecutive dictations with a space so they don't run together — a platform-free type, because a host that inserts text itself faces the same decision. When `priorText` is unreadable (an Accessibility-opaque editor, or a browser tab like Google Docs whose canvas-rendered body exposes no AX text), `InsertionSeparator.basis` falls back to what was last pasted — but only when both the target app **and** `windowTitle` match the last successful insert, so the fallback tracks "the same window," not just "the same process" (a browser hosts many unrelated tabs/documents under one PID). +`KeyInjector.insert` **always** pastes: it saves the current pasteboard, writes the transcript, activates the captured target app, posts a synthesized ⌘V, waits for the target to read the clipboard (`pasteSettleDuration`, default 400 ms, tunable in the initializer), then restores the prior pasteboard contents. That restore never destroys what it can't put back: an unreadable pasteboard snapshots as `nil` (not as empty) and is skipped, and the replacement items are built before `clearContents()`, with the plain-string flavor as a floor when promised representations can't be materialized. There is no keystroke-by-keystroke typing path and no length threshold. If the target app is gone or nothing editable is focused it leaves the text on the clipboard and throws `.targetAppLost` / `.noEditableTarget` — which the session turns into the quiet `.noTarget` outcome. Where Accessibility can't tell — the focus query fails with `cannotComplete`, or the focused element is a bare `AXWindow`, which is what editors that draw their own text view (Sublime Text, Zed) report — the paste goes ahead (`FocusCapture.FocusedEditability.cannotTell`). AX-opaque targets — Electron editors and web browsers, per `FocusCapture.isAXOpaqueApp` — are exempt from the editability gate and are pasted into even with no editable AX signal: web content is routinely invisible to Accessibility (Chromium builds its tree lazily; `contenteditable` composers expose no editable role), so "no signal" there usually means "AX can't see the field," not "no field," and the accepted trade-off is a rare beep over dropping the user's words to copy-only. `priorText` (the text before the caret, captured at press time) drives `InsertionSeparator.withLeadingSeparator`, which joins consecutive dictations with a space so they don't run together — a platform-free type, because a host that inserts text itself faces the same decision. When `priorText` is unreadable (an Accessibility-opaque editor, or a browser tab like Google Docs whose canvas-rendered body exposes no AX text), `InsertionSeparator.basis` falls back to what was last pasted — but only when both the target app **and** `windowTitle` match the last successful insert, so the fallback tracks "the same window," not just "the same process" (a browser hosts many unrelated tabs/documents under one PID). The session calls `setTarget` at press time with the app that was frontmost when recording started — so the paste lands where the user was, even if focus moved during transcription. diff --git a/Tests/BlurtEngineTests/BrowserBundleIDTests.swift b/Tests/BlurtEngineTests/BrowserBundleIDTests.swift index 9ec172f5..0e59e4e7 100644 --- a/Tests/BlurtEngineTests/BrowserBundleIDTests.swift +++ b/Tests/BlurtEngineTests/BrowserBundleIDTests.swift @@ -64,43 +64,6 @@ } } - /// The custom-drawn-editor arm of the AX-opaque exemption: native editors like - /// Sublime Text and Zed that render their own text view and expose no AX text - /// element, so they paste on no signal just as browsers and Electron apps do. - @Suite("FocusCapture.isCustomDrawnEditorBundleID") - struct CustomDrawnEditorBundleIDTests { - @Test( - "known custom-drawn editors classify, channel variants included", - arguments: [ - "com.sublimetext.4", - "com.sublimetext.3", - "com.sublimemerge", - "dev.zed.Zed", - "dev.zed.Zed-Preview", - "dev.zed.Zed-Nightly", - ]) - func knownEditors(bundleID: String) { - #expect(FocusCapture.isCustomDrawnEditorBundleID(bundleID)) - } - - @Test( - "other apps keep the copy-don't-beep fallback", - arguments: [ - "com.apple.TextEdit", - "com.apple.finder", - "com.sublimetext", // the prefix carries the trailing dot - "com.microsoft.VSCode", - ]) - func others(bundleID: String) { - #expect(!FocusCapture.isCustomDrawnEditorBundleID(bundleID)) - } - - @Test("a nil bundle ID is not a custom-drawn editor") - func nilBundleID() { - #expect(!FocusCapture.isCustomDrawnEditorBundleID(nil)) - } - } - /// The other half of the AX-opaque exemption: Electron detection, and the /// `isAXOpaqueApp` disjunction the injector actually calls. /// @@ -187,7 +150,6 @@ let current = NSRunningApplication.current #expect(!FocusCapture.isBrowserApp(current)) #expect(!FocusCapture.isElectronApp(current)) - #expect(!FocusCapture.isCustomDrawnEditorApp(current)) #expect(!FocusCapture.isAXOpaqueApp(current)) } @@ -198,7 +160,6 @@ // target would be attempted anyway. #expect(!FocusCapture.isBrowserApp(nil)) #expect(!FocusCapture.isElectronApp(nil)) - #expect(!FocusCapture.isCustomDrawnEditorApp(nil)) #expect(!FocusCapture.isAXOpaqueApp(nil)) } } diff --git a/Tests/BlurtEngineTests/EditableTargetTests.swift b/Tests/BlurtEngineTests/EditableTargetTests.swift index 48297d38..39a11199 100644 --- a/Tests/BlurtEngineTests/EditableTargetTests.swift +++ b/Tests/BlurtEngineTests/EditableTargetTests.swift @@ -1,4 +1,5 @@ #if os(macOS) + import ApplicationServices import Testing @testable import BlurtEngine @@ -63,6 +64,41 @@ } } + /// The third answer: AX that can't tell pastes rather than copies, which is + /// what lets editors that draw their own text view (Sublime Text, Zed) take a + /// dictation without being named. + @Suite("FocusCapture.editability") + struct FocusedEditabilityTests { + @Test("a focus query that couldn't complete can't tell") + func cannotComplete() { + #expect(FocusCapture.editability(ofFailedFocusQuery: .cannotComplete) == .cannotTell) + } + + @Test("a focus query that found nothing focused means nothing takes text") + func nothingFocused() { + #expect(FocusCapture.editability(ofFailedFocusQuery: .noValue) == .notEditable) + #expect(FocusCapture.editability(ofFailedFocusQuery: .failure) == .notEditable) + } + + @Test("a bare window with no text signal can't tell") + func bareWindow() { + #expect( + FocusCapture.editability(role: "AXWindow", valueSettable: false, hasInsertionPoint: false) == .cannotTell) + } + + @Test("a text signal is editable, a window's included") + func editableSignals() { + #expect( + FocusCapture.editability(role: "AXTextArea", valueSettable: false, hasInsertionPoint: false) == .editable) + #expect(FocusCapture.editability(role: "AXWindow", valueSettable: false, hasInsertionPoint: true) == .editable) + } + + @Test("any other element without a text signal takes no text", arguments: ["AXButton", "AXGroup", nil]) + func otherElements(role: String?) { + #expect(FocusCapture.editability(role: role, valueSettable: false, hasInsertionPoint: false) == .notEditable) + } + } + @Suite("noTarget phase + overlay mapping") struct NoTargetPhaseTests { @Test("noTarget is terminal") From b42991115f6c01e5c98f75e3f4da57adac1fc633 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 01:51:52 +0000 Subject: [PATCH 04/10] refactor: fold OverlayUIState.inputSilent into .error with a pill notice The silent mic is an error that writes its reason on the pill, not a separate state: .error(message:notice:) with an optional PillNotice. The notice's words are MicCaptureError's, and its errorDescription is built from the same pair, so the sentence and the pill can't drift apart. Hosts with no pill (the iPhone) go back to matching .error alone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X84Vo9pry7SANcbu86Jjrh --- App/Blurt/Blurt/Overlay/OverlayView.swift | 32 ++++----- App/BlurtiOS/BlurtiOSCore/HomeStatus.swift | 3 +- App/BlurtiOS/BlurtiOSCore/VoiceState.swift | 11 ++- .../BlurtEngine/Audio/MicCaptureError.swift | 14 +++- .../BlurtEngine/Pipeline/OverlayUIState.swift | 68 +++++++++++-------- .../OverlayUIStateTests.swift | 13 ++-- 6 files changed, 80 insertions(+), 61 deletions(-) diff --git a/App/Blurt/Blurt/Overlay/OverlayView.swift b/App/Blurt/Blurt/Overlay/OverlayView.swift index 1158f572..ae58efb0 100644 --- a/App/Blurt/Blurt/Overlay/OverlayView.swift +++ b/App/Blurt/Blurt/Overlay/OverlayView.swift @@ -122,7 +122,7 @@ struct OverlayView: View { // surface progressing rather than three unrelated states. waitingContent("Transcribing") .transition(.opacity) - case .error(let message): + case .error(let message, nil): // "Try again" tells the user what to do; the full failure reason is too // long for the pill, so expose it on hover. The VoiceOver announcement // (OverlayWindowController) speaks the same message for non-sighted users. @@ -136,27 +136,25 @@ struct OverlayView: View { StatusLineText("Pasted") .transition(.opacity) .help(state.accessibilityLabel) - case .inputSilent: - // The silent-mic notice: the device name is the diagnosis, so it goes on - // the pill itself — the controller has widened the capsule to fit — in the - // same orange as the "Error" word. + case .error(_, let notice?): + // A failure that spells out its reason (the silent mic: the device name is + // the diagnosis), so it goes on the pill itself — the controller has + // widened the capsule to fit — in the same orange as the "Error" word. // // Two runs, so a device name too long for the capped pill is cut in its // middle while "isn't sending any audio" — the part that says what's wrong — // always shows in full, and neither shrinks. - if let notice = state.pillNotice { - HStack(spacing: 0) { - StatusLineText( - notice.subject + " ", color: BlurtBrand.errorOrange, - truncation: .middle, shrinksToFit: false) - StatusLineText(notice.predicate, color: BlurtBrand.errorOrange, shrinksToFit: false) - .fixedSize() - .layoutPriority(1) - } - .padding(.horizontal, Self.errorTextInset) - .transition(.opacity) - .help(state.accessibilityLabel) + HStack(spacing: 0) { + StatusLineText( + notice.subject + " ", color: BlurtBrand.errorOrange, + truncation: .middle, shrinksToFit: false) + StatusLineText(notice.predicate, color: BlurtBrand.errorOrange, shrinksToFit: false) + .fixedSize() + .layoutPriority(1) } + .padding(.horizontal, Self.errorTextInset) + .transition(.opacity) + .help(state.accessibilityLabel) case .noTarget: // Quiet, informational notice: there was no text field to type into, so // the transcript went to the clipboard. Styled exactly like "Pasted" diff --git a/App/BlurtiOS/BlurtiOSCore/HomeStatus.swift b/App/BlurtiOS/BlurtiOSCore/HomeStatus.swift index 40fd24d3..fb72202d 100644 --- a/App/BlurtiOS/BlurtiOSCore/HomeStatus.swift +++ b/App/BlurtiOS/BlurtiOSCore/HomeStatus.swift @@ -37,8 +37,7 @@ package nonisolated struct HomeStatus: Equatable { case .processing: "Transcribing…" case .pasted: "Pasted" case .noTarget: "Copied" - case .error(let message): message - case .inputSilent: overlay.accessibilityLabel + case .error(let message, _): message case .idle: windowOpen ? "Ready to dictate" : "Not listening" } } diff --git a/App/BlurtiOS/BlurtiOSCore/VoiceState.swift b/App/BlurtiOS/BlurtiOSCore/VoiceState.swift index 97c0fa1a..eab4e931 100644 --- a/App/BlurtiOS/BlurtiOSCore/VoiceState.swift +++ b/App/BlurtiOS/BlurtiOSCore/VoiceState.swift @@ -110,7 +110,7 @@ package nonisolated struct VoiceState: Equatable { extension PhaseSnapshot.State { /// The engine's overlay state as the orb shows it. "No target" is the words /// going to the clipboard — the keyboard's "copied" — and a silent mic is an - /// error: the iPhone has no wide pill for it. + /// error like any other: the iPhone has no wide pill for its notice. package init(_ overlay: OverlayUIState) { self = switch overlay { @@ -120,18 +120,17 @@ extension PhaseSnapshot.State { case .processing: .processing case .pasted: .pasted case .noTarget: .copied - case .error, .inputSilent: .error + case .error: .error } } } extension PhaseSnapshot { - /// The words an overlay state carries for VoiceOver: an error's own, or the - /// silent mic's sentence (with the device name). + /// The words an overlay state carries for VoiceOver: an error's own (the + /// silent mic's sentence, with the device name, included). package static func message(for overlay: OverlayUIState) -> String? { switch overlay { - case .error(let message): message - case .inputSilent: overlay.accessibilityLabel + case .error(let message, _): message case .idle, .connecting, .recording, .processing, .pasted, .noTarget: nil } } diff --git a/Sources/BlurtEngine/Audio/MicCaptureError.swift b/Sources/BlurtEngine/Audio/MicCaptureError.swift index a85a2b70..3fb3e94b 100644 --- a/Sources/BlurtEngine/Audio/MicCaptureError.swift +++ b/Sources/BlurtEngine/Audio/MicCaptureError.swift @@ -30,8 +30,18 @@ enum MicCaptureError: LocalizedError { var errorDescription: String? { switch self { case .noInputDevice: "No microphone is available." - case .inputNeverDelivered(let deviceName?): "\(deviceName) isn't sending any audio." - case .inputNeverDelivered(nil): "The microphone isn't sending any audio." + case .inputNeverDelivered: pillNotice.map { "\($0.text)." } + } + } + + /// What the overlay writes on the pill for this fault (`OverlayUIState.error`'s + /// notice), or nil for one the compact "Error" pill covers. The sentence above + /// is built from the same words, so the two can't drift apart. + var pillNotice: PillNotice? { + switch self { + case .noInputDevice: nil + case .inputNeverDelivered(let deviceName): + PillNotice(subject: deviceName ?? "The microphone", predicate: "isn't sending any audio") } } } diff --git a/Sources/BlurtEngine/Pipeline/OverlayUIState.swift b/Sources/BlurtEngine/Pipeline/OverlayUIState.swift index 25d6fe6a..02aacc4a 100644 --- a/Sources/BlurtEngine/Pipeline/OverlayUIState.swift +++ b/Sources/BlurtEngine/Pipeline/OverlayUIState.swift @@ -1,3 +1,19 @@ +/// A failure's reason written out on the pill, in two parts so the shell can +/// truncate the subject (a device name of any length) and never the predicate, +/// which is what says what went wrong. A status line, so no closing period. +public struct PillNotice: Equatable, Sendable { + public let subject: String + public let predicate: String + + public init(subject: String, predicate: String) { + self.subject = subject + self.predicate = predicate + } + + /// The one line it reads as — what the shell measures. + public var text: String { "\(subject) \(predicate)" } +} + /// The visual state of the dictation overlay pill. It's a pure projection of /// `PipelinePhase` — owned here (rather than in the AppKit shell) so the mapping /// is unit-testable; the shell just renders whatever this resolves to. @@ -18,14 +34,14 @@ public enum OverlayUIState: Equatable, Sendable { /// reason (e.g. "AssemblyAI error 401…"), surfaced via the pill's hover /// tooltip and VoiceOver announcement so the failure isn't an unexplained /// red dot. - case error(message: String) - /// The press failed because the input device delivered only silence (the - /// liveness gate's fail-closed outcome). Split from `.error` because the - /// device name is the whole diagnosis — an aggregate whose real interface is - /// unplugged, or a loopback like BlackHole set as the default input — and a - /// hover tooltip hides it. The shell widens the pill to show `pillText` for - /// this notice alone; every other failure keeps the compact "Error" pill. - case inputSilent(deviceName: String?) + /// + /// `notice`, when set, is written on the pill instead of the compact "Error": + /// today only for a mic that delivered only silence (the liveness gate's + /// fail-closed outcome), where the device name is the whole diagnosis — an + /// aggregate whose real interface is unplugged, or a loopback like BlackHole + /// set as the default input — and a hover tooltip would hide it. The shell + /// widens the pill to fit; a host with no pill (the iPhone) reads `message`. + case error(message: String, notice: PillNotice? = nil) /// Transcription succeeded and the text was pasted into the focused field. The /// shell shows this as a brief, neutral "Pasted" notice before settling to /// `.idle` — the mirror of the `.noTarget` "Copied" notice for the paste path. @@ -45,9 +61,7 @@ public enum OverlayUIState: Equatable, Sendable { case .connecting: "Connecting to the microphone." case .recording: "Recording." case .processing: "Processing." - case .error(let message): message - case .inputSilent(let deviceName): - MicCaptureError.inputNeverDelivered(deviceName: deviceName).errorDescription ?? "" + case .error(let message, _): message case .pasted: "Your dictation was pasted." case .noTarget: "No text field focused. Your dictation was copied to the clipboard." } @@ -63,29 +77,23 @@ public enum OverlayUIState: Equatable, Sendable { public var noticeDwellSeconds: Double? { switch self { case .pasted: 0.8 - case .error, .noTarget: 1.6 // A sentence, not a word: long enough to read the device name. - case .inputSilent: 3.0 + case .error(_, _?): 3.0 + case .error, .noTarget: 1.6 case .idle, .connecting, .recording, .processing: nil } } - /// The words written on the pill for a notice that spells out its reason, or - /// nil for the states the pill shows as a status word or meter. Split so the - /// shell can truncate the subject (a device name of any length) and never the - /// predicate, which is what says what went wrong. A status line, so no closing - /// period — the sentence form is `accessibilityLabel`. - public var pillNotice: (subject: String, predicate: String)? { - switch self { - case .inputSilent(let deviceName): (deviceName ?? "The microphone", "isn't sending any audio") - case .idle, .connecting, .recording, .processing, .error, .pasted, .noTarget: nil - } + /// The words written on the pill for a failure that spells out its reason, or + /// nil for the states the pill shows as a status word or meter. The sentence + /// form is `accessibilityLabel`. + public var pillNotice: PillNotice? { + guard case .error(_, let notice) = self else { return nil } + return notice } /// `pillNotice` as the one line it reads as — what the shell measures. - public var pillText: String? { - pillNotice.map { "\($0.subject) \($0.predicate)" } - } + public var pillText: String? { pillNotice?.text } } extension PipelinePhase { @@ -117,10 +125,10 @@ extension PipelinePhase { // navigation can't disagree about which failures are setup states. case .failed(let error) where error.isSetupBlocker: .idle case .failed(let error): - if case .audioCaptureFailed(let underlying as MicCaptureError) = error, - case .inputNeverDelivered(let deviceName) = underlying - { - .inputSilent(deviceName: deviceName) + // A capture fault with its own pill notice (the silent mic) speaks in its + // own sentence, not the "Audio capture failed: …" wrapper. + if case .audioCaptureFailed(let underlying as MicCaptureError) = error, let notice = underlying.pillNotice { + .error(message: underlying.errorDescription ?? notice.text, notice: notice) } else { .error(message: error.errorDescription ?? "Dictation failed.") } diff --git a/Tests/BlurtEngineTests/OverlayUIStateTests.swift b/Tests/BlurtEngineTests/OverlayUIStateTests.swift index a5f7b811..e58cadd0 100644 --- a/Tests/BlurtEngineTests/OverlayUIStateTests.swift +++ b/Tests/BlurtEngineTests/OverlayUIStateTests.swift @@ -58,11 +58,15 @@ struct OverlayUIStateTests { } @Test func silentMicFailureNamesTheDeviceOnThePill() { - // Its own state rather than `.error`, so the shell can widen the pill and - // show the device name — the whole diagnosis — instead of hiding it on hover. + // An error carrying a pill notice, so the shell can widen the pill and show + // the device name — the whole diagnosis — instead of hiding it on hover. let error = MicCaptureError.inputNeverDelivered(deviceName: "Aggregate Device") let state = PipelinePhase.failed(.audioCaptureFailed(underlying: error)).overlayState - #expect(state == .inputSilent(deviceName: "Aggregate Device")) + #expect( + state + == .error( + message: "Aggregate Device isn't sending any audio.", + notice: PillNotice(subject: "Aggregate Device", predicate: "isn't sending any audio"))) #expect(state.pillText == "Aggregate Device isn't sending any audio") #expect(state.pillNotice?.subject == "Aggregate Device") #expect(state.pillNotice?.predicate == "isn't sending any audio") @@ -71,7 +75,8 @@ struct OverlayUIStateTests { } @Test func silentMicFailureWithoutANameStillSaysWhatHappened() { - let state = OverlayUIState.inputSilent(deviceName: nil) + let error = MicCaptureError.inputNeverDelivered(deviceName: nil) + let state = PipelinePhase.failed(.audioCaptureFailed(underlying: error)).overlayState #expect(state.pillText == "The microphone isn't sending any audio") #expect(state.accessibilityLabel == "The microphone isn't sending any audio.") } From 3fc1d1952ed16031ccc68d5d3d7d2dbab84e4f2b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 01:55:01 +0000 Subject: [PATCH 05/10] refactor: move TextShortcuts from the AssemblyAI SDK to the engine On-device phrase expansion runs on the text the API returned and puts nothing on the wire, so it is Blurt's text processing, not part of the API client. TextShortcut and TextShortcutExpander move to Sources/BlurtEngine/Pipeline; imports of AssemblyAI that existed only for them go, files that use them on the iPhone import BlurtEngine, and the mutation-test target, READMEs and AGENTS.md follow. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X84Vo9pry7SANcbu86Jjrh --- AGENTS.md | 11 ++++++----- App/Blurt/Blurt/Wizard/TextShortcutsSection.swift | 1 - App/BlurtiOS/BlurtiOSCore/WordsEdit.swift | 1 + App/BlurtiOS/BlurtiOSTests/WordsEditTests.swift | 1 + Sources/AssemblyAI/README.md | 6 +++--- Sources/BlurtEngine/Config/TextShortcutStore.swift | 3 +-- .../Pipeline/DictationSession+Pipeline.swift | 1 - .../Pipeline}/TextShortcuts.swift | 0 .../DictationSessionHandBackTests.swift | 1 - Tests/BlurtEngineTests/PersistedSettingsTests.swift | 1 - .../Stubs/DictationSessionTestSupport.swift | 1 - Tests/BlurtEngineTests/TextShortcutTests.swift | 1 - scripts/mutate.sh | 2 +- 13 files changed, 13 insertions(+), 17 deletions(-) rename Sources/{AssemblyAI/Dictation => BlurtEngine/Pipeline}/TextShortcuts.swift (100%) diff --git a/AGENTS.md b/AGENTS.md index db52c285..5b372a32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,9 +42,9 @@ Four reflexes before you touch anything: The package's second library, **`AssemblyAI`** (`Sources/AssemblyAI/`), is the AssemblyAI API client the engine is built on: the `HTTPTransport` seam and its streamed upload body, the dictation wire types and multipart framing, `APIKeyValidator`, and dictation vocabulary — `KeyTerms` (parsing a list and fitting -it to `keyterms_prompt`'s caps) and `TextShortcut` with its on-device `TextShortcutExpander`. It holds no Blurt policy (no prompts, styles, key storage or -`User-Agent`; `APIKeyValidator.blurt()` is where Blurt's agent joins it), so it can become a standalone -Swift SDK. It is part of the same local package, so the dependency-free rule is unchanged. +it to `keyterms_prompt`'s caps). It holds no Blurt policy (no prompts, styles, key storage, +`User-Agent` or on-device text processing; `APIKeyValidator.blurt()` is where Blurt's agent joins it, +and text shortcuts live in the engine), so it can become a standalone Swift SDK. It is part of the same local package, so the dependency-free rule is unchanged. The engine's dependency-free rule is a **rule**. The app carries one remote package — Sparkle, the in-place updater (see [Updates](#updates)) — allowlisted by URL in `check.sh`'s dependency guard, and @@ -60,7 +60,7 @@ workflow and the _why_ behind the design; the engine's README covers the _what_ ```text Sources/AssemblyAI/ the AssemblyAI API client library (README.md there): HTTPTransport + ChunkedRequestBody, Dictation/ (wire types, multipart framing, - KeyTerms, TextShortcuts), APIKeyValidator. No Blurt policy; + KeyTerms), APIKeyValidator. No Blurt policy; BlurtEngine depends on it Sources/BlurtEngine/ the engine (dependency-free Swift package) README.md the engine's developer guide (quick start, seams, error table) @@ -86,7 +86,8 @@ Sources/BlurtEngine/ the engine (dependency-free Swift package) Permissions/ PermissionStatus + SetupReadiness (every platform); PermissionsChecker (mic + Accessibility), PermissionsReset, SigningIdentity (macOS only) - Pipeline/ DictationSession (actor) + phases, UI projections, geometry, log + Pipeline/ DictationSession (actor) + phases, UI projections, geometry, log, + TextShortcuts (the on-device phrase expansion) STT/ AssemblyAITranscriber, Conversation/TranscriptionContext, CleanupInstruction, SyncSTTLimits TTS/ experimental read-aloud: SelectionSpeaker, AssemblyAISpeechSynthesizer diff --git a/App/Blurt/Blurt/Wizard/TextShortcutsSection.swift b/App/Blurt/Blurt/Wizard/TextShortcutsSection.swift index 6a19e9c4..47bce690 100644 --- a/App/Blurt/Blurt/Wizard/TextShortcutsSection.swift +++ b/App/Blurt/Blurt/Wizard/TextShortcutsSection.swift @@ -1,4 +1,3 @@ -import AssemblyAI import BlurtEngine import SwiftUI diff --git a/App/BlurtiOS/BlurtiOSCore/WordsEdit.swift b/App/BlurtiOS/BlurtiOSCore/WordsEdit.swift index 1f1d403c..ce11c99d 100644 --- a/App/BlurtiOS/BlurtiOSCore/WordsEdit.swift +++ b/App/BlurtiOS/BlurtiOSCore/WordsEdit.swift @@ -1,4 +1,5 @@ import AssemblyAI +import BlurtEngine import Foundation /// The Words tab's edits, apart from its views: what adding a key term or a diff --git a/App/BlurtiOS/BlurtiOSTests/WordsEditTests.swift b/App/BlurtiOS/BlurtiOSTests/WordsEditTests.swift index 95a290d3..178fbe2e 100644 --- a/App/BlurtiOS/BlurtiOSTests/WordsEditTests.swift +++ b/App/BlurtiOS/BlurtiOSTests/WordsEditTests.swift @@ -1,4 +1,5 @@ import AssemblyAI +import BlurtEngine import Testing @testable import BlurtiOSCore diff --git a/Sources/AssemblyAI/README.md b/Sources/AssemblyAI/README.md index 881be173..d3c5ec8f 100644 --- a/Sources/AssemblyAI/README.md +++ b/Sources/AssemblyAI/README.md @@ -11,10 +11,10 @@ a standalone Swift SDK. Foundation only; macOS 15 and iOS 18. | `DictationMultipart` | The upload's multipart framing: `config` first, then the PCM audio. | | `APIKeyValidator` | A cheap authenticated check that tells a rejected key from one it couldn't verify. | | `KeyTerms` | Key terms for `keyterms_prompt`: parse a comma-separated list, fit it to the caps. | -| `TextShortcut`, `TextShortcutExpander` | Say a phrase, get saved text: expanded on the device, never sent. | What it deliberately doesn't hold: which prompt, cleanup instruction or key terms to send, where the -key, the key terms or the shortcuts are stored, or the `User-Agent`. Those are the app's choices and live in `BlurtEngine` -(`AssemblyAITranscriber`, `KeyTermsStore`, `TextShortcutStore`, `APIKeyValidator.blurt()`). +key or the key terms are stored, the `User-Agent`, or anything done to the text on the device (text +shortcuts, `TextShortcutExpander`). Those are the app's choices and live in `BlurtEngine` +(`AssemblyAITranscriber`, `KeyTermsStore`, `TextShortcuts.swift`, `APIKeyValidator.blurt()`). Tests run with the engine's: `swift test` from the repo root. diff --git a/Sources/BlurtEngine/Config/TextShortcutStore.swift b/Sources/BlurtEngine/Config/TextShortcutStore.swift index 29141389..777b8021 100644 --- a/Sources/BlurtEngine/Config/TextShortcutStore.swift +++ b/Sources/BlurtEngine/Config/TextShortcutStore.swift @@ -1,7 +1,6 @@ -import AssemblyAI import Foundation -/// Storage for the user's text shortcuts (the AssemblyAI SDK's `TextShortcut`), +/// Storage for the user's text shortcuts (`TextShortcut`), /// as one JSON list in `UserDefaults` — Blurt's key and Blurt's limits. /// /// Has a setter for the same encoded-on-write reason as `StyleProfileStore`: diff --git a/Sources/BlurtEngine/Pipeline/DictationSession+Pipeline.swift b/Sources/BlurtEngine/Pipeline/DictationSession+Pipeline.swift index 4f14b5c9..32bda486 100644 --- a/Sources/BlurtEngine/Pipeline/DictationSession+Pipeline.swift +++ b/Sources/BlurtEngine/Pipeline/DictationSession+Pipeline.swift @@ -1,4 +1,3 @@ -import AssemblyAI import Foundation import os diff --git a/Sources/AssemblyAI/Dictation/TextShortcuts.swift b/Sources/BlurtEngine/Pipeline/TextShortcuts.swift similarity index 100% rename from Sources/AssemblyAI/Dictation/TextShortcuts.swift rename to Sources/BlurtEngine/Pipeline/TextShortcuts.swift diff --git a/Tests/BlurtEngineTests/DictationSessionHandBackTests.swift b/Tests/BlurtEngineTests/DictationSessionHandBackTests.swift index 734b8c77..79611a66 100644 --- a/Tests/BlurtEngineTests/DictationSessionHandBackTests.swift +++ b/Tests/BlurtEngineTests/DictationSessionHandBackTests.swift @@ -1,7 +1,6 @@ import Foundation import Testing -@testable import AssemblyAI @testable import BlurtEngine /// A hand-back press (`press(handingBack: true)`, read-aloud's "ask about the diff --git a/Tests/BlurtEngineTests/PersistedSettingsTests.swift b/Tests/BlurtEngineTests/PersistedSettingsTests.swift index de758701..14d509b1 100644 --- a/Tests/BlurtEngineTests/PersistedSettingsTests.swift +++ b/Tests/BlurtEngineTests/PersistedSettingsTests.swift @@ -1,7 +1,6 @@ import Foundation import Testing -@testable import AssemblyAI @testable import BlurtEngine /// `PersistedSettings.allDefaultsKeys` exists so "add a store" and "add it to diff --git a/Tests/BlurtEngineTests/Stubs/DictationSessionTestSupport.swift b/Tests/BlurtEngineTests/Stubs/DictationSessionTestSupport.swift index 760a2108..71d4407e 100644 --- a/Tests/BlurtEngineTests/Stubs/DictationSessionTestSupport.swift +++ b/Tests/BlurtEngineTests/Stubs/DictationSessionTestSupport.swift @@ -1,7 +1,6 @@ import Foundation import Synchronization -@testable import AssemblyAI @testable import BlurtEngine /// A `DictationSession` plus the doubles a test configures or asserts against diff --git a/Tests/BlurtEngineTests/TextShortcutTests.swift b/Tests/BlurtEngineTests/TextShortcutTests.swift index 35dce5f8..b72015e4 100644 --- a/Tests/BlurtEngineTests/TextShortcutTests.swift +++ b/Tests/BlurtEngineTests/TextShortcutTests.swift @@ -1,7 +1,6 @@ import Foundation import Testing -@testable import AssemblyAI @testable import BlurtEngine @Suite("TextShortcutExpander") diff --git a/scripts/mutate.sh b/scripts/mutate.sh index 9425d073..b0dc0bd9 100755 --- a/scripts/mutate.sh +++ b/scripts/mutate.sh @@ -48,7 +48,7 @@ DEFAULT_TARGETS=( "Sources/BlurtEngine/Config/APIKeyDisplay.swift" "Sources/AssemblyAI/APIKeyValidator.swift" "Sources/AssemblyAI/Dictation/KeyTerms.swift" - "Sources/AssemblyAI/Dictation/TextShortcuts.swift" + "Sources/BlurtEngine/Pipeline/TextShortcuts.swift" "Sources/BlurtEngine/FocusCapture/FocusCapture+Pure.swift" "Sources/BlurtEngine/Hotkey/DictationKeyGate.swift" "Sources/BlurtEngine/Hotkey/DictationKeyRouter.swift" From 3c0958fd8f0a56b73e327dacdc6f3dd09e57f9a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 01:55:51 +0000 Subject: [PATCH 06/10] refactor: move the off-pool queue out of DictationSession into BlockingIPC DictationSession.contextQueue and offPool were the engine's one home for blocking cross-process work, but read-aloud's selection read and clipboard copy reached into the dictation actor for them. BlockingIPC.queue and BlockingIPC.run hold them now, named for what they run, and the press path, SelectionSpeaker and SelectionCopy all use it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X84Vo9pry7SANcbu86Jjrh --- Sources/BlurtEngine/BlockingIPC.swift | 26 +++++++++++++++++++ .../Pipeline/DictationSession+Press.swift | 15 ++--------- .../Pipeline/DictationSession.swift | 13 ++-------- Sources/BlurtEngine/TTS/SelectionCopy.swift | 8 +++--- .../BlurtEngine/TTS/SelectionSpeaker.swift | 4 +-- 5 files changed, 36 insertions(+), 30 deletions(-) create mode 100644 Sources/BlurtEngine/BlockingIPC.swift diff --git a/Sources/BlurtEngine/BlockingIPC.swift b/Sources/BlurtEngine/BlockingIPC.swift new file mode 100644 index 00000000..6de86762 --- /dev/null +++ b/Sources/BlurtEngine/BlockingIPC.swift @@ -0,0 +1,26 @@ +import Dispatch + +/// Where blocking cross-process work runs: an Accessibility read, or a +/// pasteboard read that can make another app produce promised data. Each can +/// block its thread for seconds against a beachballing app (the AX messaging +/// timeout bounds each round trip, not the whole read), so none of it may run +/// on the Swift cooperative pool, which is sized to the core count and does +/// not overcommit: a few such calls could park every cooperative thread and +/// stall every actor. Dispatch overcommits, so a blocked call costs a thread +/// instead of the pool. Concurrent, so a hung read can't delay the next one. +/// +/// Its own type rather than a member of `DictationSession`, because the press +/// pipeline is one of several callers: read-aloud's selection read and its +/// clipboard copy (`SelectionSpeaker`, `SelectionCopy`) run here too. +enum BlockingIPC { + static let queue = DispatchQueue( + label: HostIdentity.current.queueLabel("BlockingIPC"), qos: .userInitiated, attributes: .concurrent) + + /// Runs `work` on `queue` and awaits it: the awaitable form of + /// `queue.async`, for a caller that needs the result. + static func run(_ work: @escaping @Sendable () -> T) async -> T { + await withCheckedContinuation { continuation in + queue.async { continuation.resume(returning: work()) } + } + } +} diff --git a/Sources/BlurtEngine/Pipeline/DictationSession+Press.swift b/Sources/BlurtEngine/Pipeline/DictationSession+Press.swift index e6acbf56..674210de 100644 --- a/Sources/BlurtEngine/Pipeline/DictationSession+Press.swift +++ b/Sources/BlurtEngine/Pipeline/DictationSession+Press.swift @@ -6,7 +6,7 @@ import Dispatch // file-length budget, mirroring `+Pipeline` (the release half). Members it // reaches are internal, not private: file-scoped access can't cross the split. // `Dispatch`, not `Foundation`: the only thing here from outside the module is -// `contextQueue.async` (see `performPress` for why that read is off-pool). +// `BlockingIPC.queue.async` (see `BlockingIPC` for why that read is off-pool). extension DictationSession { func performPress(handingBack: Bool) async { guard phase.isTerminal else { return } @@ -189,7 +189,7 @@ extension DictationSession { // values, so it needs no task context. let captureFieldContext = seams.captureFieldContext let textShortcutsProvider = textShortcutsProvider - Self.contextQueue.async { + BlockingIPC.queue.async { let field = captureFieldContext() let context = TranscriptionContext( appName: captured?.processName, @@ -209,14 +209,3 @@ extension DictationSession { } } } - -extension DictationSession { - /// Runs blocking cross-process work on `contextQueue` and awaits it: the - /// awaitable form of the press-time capture's hop, for an AX read or a - /// pasteboard read that can make another app produce promised data. - static func offPool(_ work: @escaping @Sendable () -> T) async -> T { - await withCheckedContinuation { continuation in - contextQueue.async { continuation.resume(returning: work()) } - } - } -} diff --git a/Sources/BlurtEngine/Pipeline/DictationSession.swift b/Sources/BlurtEngine/Pipeline/DictationSession.swift index fe61d4b3..359010ea 100644 --- a/Sources/BlurtEngine/Pipeline/DictationSession.swift +++ b/Sources/BlurtEngine/Pipeline/DictationSession.swift @@ -1,18 +1,9 @@ import AssemblyAI -// `Dispatch`, not `Foundation`: the only thing here from outside the module is -// `contextQueue`'s and `commandQueue`'s `DispatchQueue`, the same choice -// `+Press.swift` documents. Foundation's last use here went with `mic.stop()` -// returning a byte count instead of a `Data` blob. -import Dispatch +// No `Foundation`: its last use here went with `mic.stop()` returning a byte +// count instead of a `Data` blob, and the off-pool queue moved to `BlockingIPC`. import Synchronization public actor DictationSession { - /// Off-pool home for the press-time AX field read — see its use in - /// `performPress` for why blocking IPC must not run on the cooperative pool. - static let contextQueue = DispatchQueue( - label: HostIdentity.current.queueLabel("FieldContext"), qos: .userInitiated, - attributes: .concurrent) - /// `internal(set)`, not `private(set)`: `private` is file-scoped, and the one /// writer — `setPhase` — lives in `+Observation` (see the split note below). /// Hosts outside the module still can't assign it. **`setPhase` remains the diff --git a/Sources/BlurtEngine/TTS/SelectionCopy.swift b/Sources/BlurtEngine/TTS/SelectionCopy.swift index 533e974d..3ffdc7a8 100644 --- a/Sources/BlurtEngine/TTS/SelectionCopy.swift +++ b/Sources/BlurtEngine/TTS/SelectionCopy.swift @@ -50,7 +50,7 @@ func copySelection(maxCharacters: Int) async -> String? { let pasteboard = pasteboard // An unreadable pasteboard can't be restored, so don't disturb it. - let (saved, before) = await DictationSession.offPool { (pasteboard.snapshot(), pasteboard.changeCount) } + let (saved, before) = await BlockingIPC.run { (pasteboard.snapshot(), pasteboard.changeCount) } guard let saved, postCopy() else { return nil } let deadline = ContinuousClock.now + timeout // Wait for a string, not just a change: an app may clear the clipboard and @@ -59,7 +59,7 @@ var copied: String? repeat { try? await Task.sleep(for: .milliseconds(10)) - (seen, copied) = await DictationSession.offPool { + (seen, copied) = await BlockingIPC.run { let count = pasteboard.changeCount guard count != before else { return (count, nil) } let string = pasteboard.currentString() @@ -69,7 +69,7 @@ } } while copied == nil && ContinuousClock.now < deadline let copyCount = seen - let settled = await DictationSession.offPool { + let settled = await BlockingIPC.run { if copyCount != before { pasteboard.restore(saved, ifChangeCountIs: copyCount) } return pasteboard.changeCount } @@ -89,7 +89,7 @@ Task { while ContinuousClock.now < deadline { try? await Task.sleep(for: .milliseconds(20)) - let restored = await DictationSession.offPool { () -> Bool in + let restored = await BlockingIPC.run { () -> Bool in let count = pasteboard.changeCount guard count != baseline else { return false } pasteboard.restore(saved, ifChangeCountIs: count) diff --git a/Sources/BlurtEngine/TTS/SelectionSpeaker.swift b/Sources/BlurtEngine/TTS/SelectionSpeaker.swift index 832f596d..9338af82 100644 --- a/Sources/BlurtEngine/TTS/SelectionSpeaker.swift +++ b/Sources/BlurtEngine/TTS/SelectionSpeaker.swift @@ -40,11 +40,11 @@ /// /// Accessibility first. The read is synchronous cross-process IPC that can /// block a thread for the AX timeout against a hung app, so it runs off the - /// cooperative pool (`DictationSession.offPool`). When AX can't reach a + /// cooperative pool (`BlockingIPC.run`). When AX can't reach a /// focused element at all, it falls back to copying the selection /// (`SelectionCopy`). public static func focusedSelection() async -> String? { - let read = await DictationSession.offPool { FocusCapture.captureSelectedText(maxChars: maxCharacters) } + let read = await BlockingIPC.run { FocusCapture.captureSelectedText(maxChars: maxCharacters) } return await resolve(read, copy: SelectionCopy()) } From e0637b4e96a9054970049d1561e9628644d96120 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 01:56:20 +0000 Subject: [PATCH 07/10] refactor: type rewrite_mode in the AssemblyAI SDK DictationConfig.rewriteMode was a String?, so the SDK accepted any word and the documented values lived in BlurtEngine. DictationRewriteMode (fast, smart) is the SDK's now; the engine's RewriteMode maps to it in wireValue, still omitting the key for Fast. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X84Vo9pry7SANcbu86Jjrh --- .../Dictation/DictationWireTypes.swift | 21 +++++++++++++------ .../BlurtEngine/Config/RewriteModeStore.swift | 7 ++++--- Tests/BlurtEngineTests/RewriteModeTests.swift | 14 +++++++++++++ 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/Sources/AssemblyAI/Dictation/DictationWireTypes.swift b/Sources/AssemblyAI/Dictation/DictationWireTypes.swift index bc4afd6d..a90a82e4 100644 --- a/Sources/AssemblyAI/Dictation/DictationWireTypes.swift +++ b/Sources/AssemblyAI/Dictation/DictationWireTypes.swift @@ -25,6 +25,16 @@ // evidence a field is supported, only that it has not been removed yet. There // has been a lot of renaming on this route. Treat any name absent from the docs // as deprecated and don't send it, however well it works today. +/// The models the cleanup rewrite can run on — `config.rewrite_mode`'s +/// documented values. Typed so a misspelling can't type-check its way onto the +/// wire. +public enum DictationRewriteMode: String, Encodable, Sendable { + /// The route's default. + case fast + /// A larger model: better rewrites, a longer server-side path. + case smart +} + public struct DictationConfig: Encodable, Sendable { public let sampleRate: Int public let channels: Int @@ -93,15 +103,14 @@ public struct DictationConfig: Encodable, Sendable { /// Adding an `llm` case back would reintroduce it *and* leave the response's /// choice unreachable — read `transcript(from:enhanced:)` first. public let llmInstruction: String? - /// Which model the cleanup rewrite runs on: `"smart"` (a larger model) or - /// `"fast"`, or nil to omit the key and leave the route on its default. - /// A top-level sibling of `llm_instruction`, which is where the rest of the - /// rewrite request already lives. - public let rewriteMode: String? + /// Which model the cleanup rewrite runs on, or nil to omit the key and leave + /// the route on its default. A top-level sibling of `llm_instruction`, which + /// is where the rest of the rewrite request already lives. + public let rewriteMode: DictationRewriteMode? public init( sampleRate: Int, channels: Int, sttPrompt: String, keytermsPrompt: [String], llmInstruction: String?, - rewriteMode: String? = nil + rewriteMode: DictationRewriteMode? = nil ) { self.sampleRate = sampleRate self.channels = channels diff --git a/Sources/BlurtEngine/Config/RewriteModeStore.swift b/Sources/BlurtEngine/Config/RewriteModeStore.swift index ad11504f..0d5ab496 100644 --- a/Sources/BlurtEngine/Config/RewriteModeStore.swift +++ b/Sources/BlurtEngine/Config/RewriteModeStore.swift @@ -1,3 +1,4 @@ +import AssemblyAI import Foundation /// Which model the dictation API's server-side cleanup rewrite runs on — the @@ -32,12 +33,12 @@ public enum RewriteMode: String, CaseIterable, Sendable { public static let guidance = "Smart uses a larger model for better rewrites; Fast is quicker." /// What goes on the wire as `rewrite_mode`, or nil to omit the key. Fast - /// **omits** it rather than sending `"fast"`, so the default request stays + /// **omits** it rather than sending `.fast`, so the default request stays /// byte-identical to the one that shipped before this setting existed. - var wireValue: String? { + var wireValue: DictationRewriteMode? { switch self { case .fast: return nil - case .smart: return "smart" + case .smart: return .smart } } } diff --git a/Tests/BlurtEngineTests/RewriteModeTests.swift b/Tests/BlurtEngineTests/RewriteModeTests.swift index 29d2cf28..7d176a05 100644 --- a/Tests/BlurtEngineTests/RewriteModeTests.swift +++ b/Tests/BlurtEngineTests/RewriteModeTests.swift @@ -1,3 +1,4 @@ +import AssemblyAI import Foundation import Testing @@ -59,6 +60,19 @@ struct RewriteModeTests { #expect(object.keys.contains("rewrite_mode") == false) } + @Test("the SDK writes each typed mode as its documented spelling") + func sdkSpellsEachMode() throws { + for mode in [DictationRewriteMode.fast, .smart] { + let data = try JSONEncoder().encode( + DictationConfig( + sampleRate: 16_000, channels: 1, sttPrompt: "", keytermsPrompt: [], llmInstruction: nil, + rewriteMode: mode)) + let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(object["rewrite_mode"] as? String == mode.rawValue) + } + #expect(DictationRewriteMode.smart.rawValue == "smart") + } + // MARK: - helpers private func config(mode: RewriteMode, enhancedTranscripts: Bool = true) throws -> [String: Any] { From 1a5e46af83be7d6aa40bb3bddbdf8a35784715a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 02:41:31 +0000 Subject: [PATCH 08/10] =?UTF-8?q?fix:=20get=20CI=20green=20=E2=80=94=20act?= =?UTF-8?q?or=20isolation,=20a=20two-value=20pattern,=20lint=20lengths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PhaseSnapshot.State's init(_:) and message(for:) are nonisolated: the iPhone core defaults to the main actor, and VoiceState calls them from a nonisolated init. isSettled moves beside them, which also brings SharedState.swift back under the 400-line limit. - The last OverlayUIState test matching .error with one binding matches both values. - The enhanced-transcripts read-once test moves to RewriteModeTests, and two comments shorten, so AssemblyAITranscriber.swift and its test suite are back within file_length / type_body_length. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X84Vo9pry7SANcbu86Jjrh --- App/BlurtiOS/BlurtiOSCore/SharedState.swift | 11 +--------- App/BlurtiOS/BlurtiOSCore/VoiceState.swift | 12 +++++++++-- .../STT/AssemblyAITranscriber.swift | 9 +++------ .../AssemblyAITranscriberTests.swift | 20 ------------------- .../OverlayUIStateTests.swift | 2 +- Tests/BlurtEngineTests/RewriteModeTests.swift | 20 +++++++++++++++++++ 6 files changed, 35 insertions(+), 39 deletions(-) diff --git a/App/BlurtiOS/BlurtiOSCore/SharedState.swift b/App/BlurtiOS/BlurtiOSCore/SharedState.swift index cdf8c587..4cc64740 100644 --- a/App/BlurtiOS/BlurtiOSCore/SharedState.swift +++ b/App/BlurtiOS/BlurtiOSCore/SharedState.swift @@ -144,14 +144,6 @@ package nonisolated struct PhaseSnapshot: Codable, Sendable, Equatable { case pasted case copied case error - - /// Nothing in flight: a notice, or rest. - package var isSettled: Bool { - switch self { - case .idle, .pasted, .copied, .error: true - case .connecting, .recording, .processing: false - } - } } package let state: State @@ -254,8 +246,7 @@ package nonisolated enum SharedStore { defaults.removeObject(forKey: key) } - /// A choice stored by its raw value, or `fallback` when none (or one this - /// build doesn't know) is stored. + /// A choice stored by its raw value, or `fallback` for none or an unknown one. private static func choice(_ key: String, default fallback: C) -> C where C.RawValue == String { C(rawValue: defaults.string(forKey: key) ?? "") ?? fallback } diff --git a/App/BlurtiOS/BlurtiOSCore/VoiceState.swift b/App/BlurtiOS/BlurtiOSCore/VoiceState.swift index eab4e931..9f7f86d3 100644 --- a/App/BlurtiOS/BlurtiOSCore/VoiceState.swift +++ b/App/BlurtiOS/BlurtiOSCore/VoiceState.swift @@ -108,10 +108,18 @@ package nonisolated struct VoiceState: Equatable { } extension PhaseSnapshot.State { + /// Nothing in flight: a notice, or rest. + nonisolated package var isSettled: Bool { + switch self { + case .idle, .pasted, .copied, .error: true + case .connecting, .recording, .processing: false + } + } + /// The engine's overlay state as the orb shows it. "No target" is the words /// going to the clipboard — the keyboard's "copied" — and a silent mic is an /// error like any other: the iPhone has no wide pill for its notice. - package init(_ overlay: OverlayUIState) { + nonisolated package init(_ overlay: OverlayUIState) { self = switch overlay { case .idle: .idle @@ -128,7 +136,7 @@ extension PhaseSnapshot.State { extension PhaseSnapshot { /// The words an overlay state carries for VoiceOver: an error's own (the /// silent mic's sentence, with the device name, included). - package static func message(for overlay: OverlayUIState) -> String? { + nonisolated package static func message(for overlay: OverlayUIState) -> String? { switch overlay { case .error(let message, _): message case .idle, .connecting, .recording, .processing, .pasted, .noTarget: nil diff --git a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift index b45ccc20..902c86c7 100644 --- a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift +++ b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift @@ -116,8 +116,7 @@ public struct AssemblyAITranscriber: TranscriberProtocol { // text before the cursor) and the key terms as keyterms prompting. App name, // window title, field label and selected text stay on the machine — // `STTPrompt` draws that line, so nothing is filtered here. - // Read once: the request's rewrite model and which transcript gets pasted - // must answer to the same setting, even if it is flipped mid-recording. + // Read once, so a mid-recording flip can't split the request from the paste. let enhanced = enhancedTranscriptsEnabled() let config = try makeConfigData( sampleRate: sampleRate, @@ -260,8 +259,7 @@ public struct AssemblyAITranscriber: TranscriberProtocol { /// Neither steering field is defaulted: every caller states both, so what a /// given request does and does not steer with is readable at the call site /// rather than inferred from which argument was left off. - /// `enhanced` is the request's reading of the enhanced-transcripts setting; - /// nil reads it now (the tests' form). + /// `enhanced` is the request's reading of the setting; nil reads it now. func makeConfigData( sampleRate: Int, sttPrompt: String, keytermsPrompt: [String], enhanced: Bool? = nil ) throws -> Data { @@ -272,8 +270,7 @@ public struct AssemblyAITranscriber: TranscriberProtocol { sttPrompt: sttPrompt, keytermsPrompt: keytermsPrompt, llmInstruction: cleanupInstruction(), - // Only while the rewrite is what gets pasted: with enhanced transcripts - // off it is discarded unread, so a slower model would only add latency. + // Only while the rewrite is pasted: off, a slower model only adds latency. rewriteMode: (enhanced ?? enhancedTranscriptsEnabled()) ? rewriteMode().wireValue : nil ) ) diff --git a/Tests/BlurtEngineTests/AssemblyAITranscriberTests.swift b/Tests/BlurtEngineTests/AssemblyAITranscriberTests.swift index f24b19e5..3094fc82 100644 --- a/Tests/BlurtEngineTests/AssemblyAITranscriberTests.swift +++ b/Tests/BlurtEngineTests/AssemblyAITranscriberTests.swift @@ -1,5 +1,4 @@ import Foundation -import Synchronization import Testing @testable import AssemblyAI @@ -261,25 +260,6 @@ struct HTTPClientTests { #expect(result == "um hello") } - @Test("the setting is read once per request, so a mid-recording flip can't split request from paste") - func enhancedReadOncePerRequest() async throws { - // On when the request opened, off by the time it answered: the request asked - // for the rewrite, so the rewrite is what gets pasted. - let reads = Mutex(0) - let transport = FakeHTTPTransport { _ in (200, Data(#"{"text":"um hello","llm_response":"Hello."}"#.utf8)) } - let transcriber = AssemblyAITranscriber( - apiKeyProvider: { "test-key" }, transport: transport, - enhancedTranscripts: { - reads.withLock { - $0 += 1 - return $0 == 1 - } - }, - customStyle: { nil }, rewriteMode: { .smart }) - #expect(try await collectTranscript(transcriber) == "Hello.") - #expect(reads.withLock { $0 } == 1) - } - @Test("transcriber HTTP error carries the decoded server message") func transcribeHTTPErrorMessage() async throws { // A documented status carrying a documented shape: 413 with diff --git a/Tests/BlurtEngineTests/OverlayUIStateTests.swift b/Tests/BlurtEngineTests/OverlayUIStateTests.swift index e58cadd0..f5c50e22 100644 --- a/Tests/BlurtEngineTests/OverlayUIStateTests.swift +++ b/Tests/BlurtEngineTests/OverlayUIStateTests.swift @@ -128,7 +128,7 @@ struct OverlayUIStateTests { @Test func failedFallsBackWhenNoDescription() { // Defensive: every BlurtError supplies an errorDescription today, but the // mapping must still produce a non-empty message if one ever returns nil. - if case .error(let message) = PipelinePhase.failed(.targetAppLost).overlayState { + if case .error(let message, _) = PipelinePhase.failed(.targetAppLost).overlayState { #expect(!message.isEmpty) } else { Issue.record("expected .error") diff --git a/Tests/BlurtEngineTests/RewriteModeTests.swift b/Tests/BlurtEngineTests/RewriteModeTests.swift index 7d176a05..49416af7 100644 --- a/Tests/BlurtEngineTests/RewriteModeTests.swift +++ b/Tests/BlurtEngineTests/RewriteModeTests.swift @@ -1,5 +1,6 @@ import AssemblyAI import Foundation +import Synchronization import Testing @testable import BlurtEngine @@ -73,6 +74,25 @@ struct RewriteModeTests { #expect(DictationRewriteMode.smart.rawValue == "smart") } + @Test("the setting is read once per request, so a mid-recording flip can't split request from paste") + func enhancedReadOncePerRequest() async throws { + // On when the request opened, off by the time it answered: the request asked + // for the rewrite, so the rewrite is what gets pasted. + let reads = Mutex(0) + let transport = FakeHTTPTransport { _ in (200, Data(#"{"text":"um hello","llm_response":"Hello."}"#.utf8)) } + let transcriber = AssemblyAITranscriber( + apiKeyProvider: { "test-key" }, transport: transport, + enhancedTranscripts: { + reads.withLock { + $0 += 1 + return $0 == 1 + } + }, + customStyle: { nil }, rewriteMode: { .smart }) + #expect(try await collectTranscript(transcriber) == "Hello.") + #expect(reads.withLock { $0 } == 1) + } + // MARK: - helpers private func config(mode: RewriteMode, enhancedTranscripts: Bool = true) throws -> [String: Any] { From df5d968168977e44f5eca7ce746cad2d5ed7556e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 01:57:50 +0000 Subject: [PATCH 09/10] refactor: one APIKeyModel, in the engine, for both shells The Mac and iPhone each carried a copy of the same @Observable model, kept in step by a comment. It moves to Sources/BlurtEngine/Config as a public @MainActor type with the Mac's full surface (current, storage, save), and its tests move to the engine suite. Observation is a toolchain module, like Synchronization, so the dependency-free rule holds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X84Vo9pry7SANcbu86Jjrh --- AGENTS.md | 10 +-- App/Blurt/Blurt.xcodeproj/project.pbxproj | 4 - App/BlurtiOS/BlurtiOSCore/APIKeyModel.swift | 82 ------------------- .../BlurtEngine/Config}/APIKeyModel.swift | 31 ++++--- .../BlurtEngineTests}/APIKeyModelTests.swift | 15 +++- 5 files changed, 35 insertions(+), 107 deletions(-) delete mode 100644 App/BlurtiOS/BlurtiOSCore/APIKeyModel.swift rename {App/Blurt/Blurt => Sources/BlurtEngine/Config}/APIKeyModel.swift (83%) rename {App/BlurtiOS/BlurtiOSTests => Tests/BlurtEngineTests}/APIKeyModelTests.swift (70%) diff --git a/AGENTS.md b/AGENTS.md index 5b372a32..9ed286f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,11 +33,11 @@ Four reflexes before you touch anything: ### The two layers -| Layer | What it is | -| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Sources/BlurtEngine/` | Swift package (`swift-tools-version:6.2`, `platforms: [.macOS(.v15), .iOS(.v18)]`) owning the pipeline. Pure logic behind protocol seams, no AppKit-shell deps, **no external SPM deps** — Foundation/Security/AVFoundation/CoreAudio plus toolchain modules like Synchronization, with AppKit types only at the seams. The macOS-only files (Accessibility reads, clipboard paste, CoreAudio route reads, TCC and code-signing helpers) are fenced behind `#if os(macOS)`; `check.yml`'s `ios-build` job builds the library for the iOS simulator so a symbol that escapes its fence fails the PR. | -| `App/Blurt/` | AppKit/SwiftUI shell (Xcode project generated by XcodeGen) that wires the engine to an overlay window, the main window, a Settings scene, a menu bar item, and the trigger key. Its only package is the local `BlurtEngine` (declared in `App/Blurt/project.yml`). | -| `App/BlurtiOS/` | The iPhone app (SwiftUI) and the keyboard it embeds, generated by XcodeGen from its own `project.yml` and built by `check.yml`'s `ios-build` job. The app owns the engine's pipeline over an always-on capture; the keyboard is a remote control that inserts the words. Their logic is `BlurtiOSCore`, a framework apart from the UI the way the engine is apart from the Mac shell. `App/BlurtiOS/README.md` is its guide. | +| Layer | What it is | +| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Sources/BlurtEngine/` | Swift package (`swift-tools-version:6.2`, `platforms: [.macOS(.v15), .iOS(.v18)]`) owning the pipeline. Pure logic behind protocol seams, no AppKit-shell deps, **no external SPM deps** — Foundation/Security/AVFoundation/CoreAudio plus toolchain modules like Synchronization and Observation (`APIKeyModel`, the one `@Observable` type both shells share), with AppKit types only at the seams. The macOS-only files (Accessibility reads, clipboard paste, CoreAudio route reads, TCC and code-signing helpers) are fenced behind `#if os(macOS)`; `check.yml`'s `ios-build` job builds the library for the iOS simulator so a symbol that escapes its fence fails the PR. | +| `App/Blurt/` | AppKit/SwiftUI shell (Xcode project generated by XcodeGen) that wires the engine to an overlay window, the main window, a Settings scene, a menu bar item, and the trigger key. Its only package is the local `BlurtEngine` (declared in `App/Blurt/project.yml`). | +| `App/BlurtiOS/` | The iPhone app (SwiftUI) and the keyboard it embeds, generated by XcodeGen from its own `project.yml` and built by `check.yml`'s `ios-build` job. The app owns the engine's pipeline over an always-on capture; the keyboard is a remote control that inserts the words. Their logic is `BlurtiOSCore`, a framework apart from the UI the way the engine is apart from the Mac shell. `App/BlurtiOS/README.md` is its guide. | The package's second library, **`AssemblyAI`** (`Sources/AssemblyAI/`), is the AssemblyAI API client the engine is built on: the `HTTPTransport` seam and its streamed upload body, the dictation wire types and diff --git a/App/Blurt/Blurt.xcodeproj/project.pbxproj b/App/Blurt/Blurt.xcodeproj/project.pbxproj index f6533e64..41e22e90 100644 --- a/App/Blurt/Blurt.xcodeproj/project.pbxproj +++ b/App/Blurt/Blurt.xcodeproj/project.pbxproj @@ -55,7 +55,6 @@ 19A470448F7B22B3DCC66B80 /* rom1b-8-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = FBC56D4B9D0ABF6D4541EDAC /* rom1b-8-start.m4a */; }; 1A41CBCC3C2F561F41CD1589 /* juno-57-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = D848AB9AC79E40A76CFA7123 /* juno-57-stop.m4a */; }; 1AA267C68388794394E6652C /* juno-7-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = FE25AF745B85611516B16FDA /* juno-7-start.m4a */; }; - 1B61DD5EE4CFDA81889A0A6E /* APIKeyModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9610DB6F24068F89C9F49EA /* APIKeyModel.swift */; }; 1BA6EB5B7B96489923E9FEDF /* juno-124-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 2187024BF79E6953ECEFD04C /* juno-124-stop.m4a */; }; 1BAF2D90C8E3CFCF70C6DC2E /* juno-54-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = E90698CCD83D2CDAF043877A /* juno-54-stop.m4a */; }; 1BC2EB8B211378DD842647EC /* rom1a-6-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = BBF8A81BBD03B233A8C2B845 /* rom1a-6-stop.m4a */; }; @@ -895,7 +894,6 @@ F8A0A285719AC6179D039DD9 /* juno-10-stop.m4a */ = {isa = PBXFileReference; path = "juno-10-stop.m4a"; sourceTree = ""; }; F8A43C600FA98A4316D02150 /* juno-85-stop.m4a */ = {isa = PBXFileReference; path = "juno-85-stop.m4a"; sourceTree = ""; }; F935C3E491692470C97EAA48 /* juno-113-start.m4a */ = {isa = PBXFileReference; path = "juno-113-start.m4a"; sourceTree = ""; }; - F9610DB6F24068F89C9F49EA /* APIKeyModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeyModel.swift; sourceTree = ""; }; F967C98CF7C0A6A736B91627 /* rom1a-24-stop.m4a */ = {isa = PBXFileReference; path = "rom1a-24-stop.m4a"; sourceTree = ""; }; F96D128F79D585621827FF6A /* rom1a-3-start.m4a */ = {isa = PBXFileReference; path = "rom1a-3-start.m4a"; sourceTree = ""; }; FB6A990540604C4EE8DC425E /* juno-119-stop.m4a */ = {isa = PBXFileReference; path = "juno-119-stop.m4a"; sourceTree = ""; }; @@ -933,7 +931,6 @@ 1560402FBA5911463036085F /* Blurt */ = { isa = PBXGroup; children = ( - F9610DB6F24068F89C9F49EA /* APIKeyModel.swift */, 7C151FD5B49E5D7E61BDDEF0 /* App.swift */, 240939D503F3CE03A0697A5F /* AppCoordinator.swift */, 3611A8FA0547B780D0804F52 /* AppCoordinator+UITesting.swift */, @@ -2036,7 +2033,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 1B61DD5EE4CFDA81889A0A6E /* APIKeyModel.swift in Sources */, 0A259385C40D25FC87E377D6 /* APIKeyStepView.swift in Sources */, 73FA46BD466212E9AE09C847 /* App.swift in Sources */, B391EA5F093506B3DF318F10 /* AppCoordinator+UITesting.swift in Sources */, diff --git a/App/BlurtiOS/BlurtiOSCore/APIKeyModel.swift b/App/BlurtiOS/BlurtiOSCore/APIKeyModel.swift deleted file mode 100644 index 2a3c9c52..00000000 --- a/App/BlurtiOS/BlurtiOSCore/APIKeyModel.swift +++ /dev/null @@ -1,82 +0,0 @@ -import AssemblyAI -import BlurtEngine -import Observation - -/// A copy of the Mac shell's `APIKeyModel` (App/Blurt/Blurt/APIKeyModel.swift): -/// nothing in it is platform-specific, and the two apps are separate targets -/// that cannot share a file without a third package. Keep the two in step. -/// -/// The app shell's API-key surface, extracted from `AppCoordinator` so the -/// coordinator stays focused on wiring the pipeline to the UI. Owns the storage -/// seam (`APIKeyGateway`), the validate-then-save flow (`APIKeySubmission`), and -/// the observable `hasAPIKey` flag the wizard/Settings react to. -/// -/// `@Observable` so views that only care about the key (the API-key step, the -/// wizard's readiness gate) observe *this* directly instead of reaching through -/// the coordinator. The validate-and-never-persist-an-unverified-key rules -/// themselves live in the engine's `APIKeySubmission`, where `swift test` covers -/// them; this type just forwards and mirrors the result into `hasAPIKey`. -@Observable -package final class APIKeyModel { - /// Storage for the API key. Production hits the Keychain via `APIKeyStore`; - /// UI tests inject an in-memory store so the real key is never touched. - @ObservationIgnored private let keyStore: any APIKeyGateway - /// The validate-then-save flow over `keyStore` (engine-owned + unit-tested). - /// Carries the injected validator: AssemblyAI's real network check in - /// production, an offline stub under UI testing — both through the one submit - /// path, so there's no test-only branch here. - @ObservationIgnored private let submission: APIKeySubmission - - /// Whether an AssemblyAI API key is currently saved. Drives the wizard (which - /// gates dictation on having a key) and the Settings UI. - package private(set) var hasAPIKey: Bool - - /// `validateKey` defaults to the engine's real AssemblyAI check; UI tests - /// inject an offline validator so the settings flow needs no network. - /// - /// The `hasKey` read below also loads the Keychain memo (`APIKeyStore.current`), - /// so every later readiness check on the press→recording latency path is - /// served from memory instead of paying a cold `SecItemCopyMatching`. - package init( - keyStore: any APIKeyGateway = ProductionAPIKeyStore(), - validateKey: @escaping @Sendable (String) async -> APIKeyValidator.Result = { - await APIKeyValidator.blurt().validate($0) - } - ) { - self.keyStore = keyStore - self.submission = APIKeySubmission(keyStore: keyStore, validate: validateKey) - self.hasAPIKey = keyStore.hasKey - } - - /// A `@Sendable` snapshot of the readiness gate for `DictationSession`: a press - /// with no key saved fails fast as `.failed(.apiKeyMissing)` before any capture. - /// Captures the (Sendable) store, not this main-actor model, so it can cross - /// into the session's `@Sendable` closure. - package func readinessCheck() -> @Sendable () -> BlurtError? { - let keyStore = keyStore - return { keyStore.hasKey ? nil : .apiKeyMissing } - } - - /// Re-reads the Keychain and updates `hasAPIKey`. The flag is otherwise only - /// set at `init` and after `submit`, so it can drift out of sync with what's - /// actually stored — e.g. an early-launch Keychain read fails (the item's ACL - /// needs re-approval after a re-sign) before a later read succeeds. Calling this - /// when the API-key UI appears keeps the readiness gate honest: a key that's - /// already present flips `hasAPIKey` true, so the wizard advances to the ready - /// screen instead of stranding the user on a setup step whose only control (a - /// *changed*-key "Update") is disabled. - package func refreshStatus() { - let hasKey = keyStore.hasKey - if hasKey != hasAPIKey { hasAPIKey = hasKey } - } - - /// Verifies `key` against AssemblyAI and saves it only when AssemblyAI - /// actively accepts it (`.valid`) — the engine's `APIKeySubmission` owns - /// (and unit-tests) that never-persist-an-unverified-key rule. Mirrors the - /// outcome into `hasAPIKey` so the wizard/Settings UI reacts. - package func submit(_ key: String) async -> APIKeySubmission.Outcome { - let outcome = await submission.submit(key) - refreshStatus() - return outcome - } -} diff --git a/App/Blurt/Blurt/APIKeyModel.swift b/Sources/BlurtEngine/Config/APIKeyModel.swift similarity index 83% rename from App/Blurt/Blurt/APIKeyModel.swift rename to Sources/BlurtEngine/Config/APIKeyModel.swift index ad8253a1..11c13e6d 100644 --- a/App/Blurt/Blurt/APIKeyModel.swift +++ b/Sources/BlurtEngine/Config/APIKeyModel.swift @@ -1,19 +1,24 @@ import AssemblyAI -import BlurtEngine import Observation -/// The app shell's API-key surface, extracted from `AppCoordinator` so the -/// coordinator stays focused on wiring the pipeline to the UI. Owns the storage +/// The app shells' API-key surface, kept out of their coordinators so those +/// stay focused on wiring the pipeline to the UI. Owns the storage /// seam (`APIKeyGateway`), the validate-then-save flow (`APIKeySubmission`), and /// the observable `hasAPIKey` flag the wizard/Settings react to. /// /// `@Observable` so views that only care about the key (the API-key step, the /// wizard's readiness gate) observe *this* directly instead of reaching through /// the coordinator. The validate-and-never-persist-an-unverified-key rules -/// themselves live in the engine's `APIKeySubmission`, where `swift test` covers +/// themselves live in `APIKeySubmission`, where `swift test` covers /// them; this type just forwards and mirrors the result into `hasAPIKey`. +/// +/// One type for both shells — the Mac's wizard and Settings, the iPhone's setup +/// and key entry — in the engine rather than copied into each app: nothing in +/// it is platform-specific. `Observation` is a toolchain module, like +/// `Synchronization`, not a package dependency. +@MainActor @Observable -final class APIKeyModel { +public final class APIKeyModel { /// Storage for the API key. Production hits the Keychain via `APIKeyStore`; /// UI tests inject an in-memory store so the real key is never touched. @ObservationIgnored private let keyStore: any APIKeyGateway @@ -25,7 +30,7 @@ final class APIKeyModel { /// Whether an AssemblyAI API key is currently saved. Drives the wizard (which /// gates dictation on having a key) and the Settings UI. - private(set) var hasAPIKey: Bool + public private(set) var hasAPIKey: Bool /// `validateKey` defaults to the engine's real AssemblyAI check; UI tests /// inject an offline validator so the settings flow needs no network. @@ -33,7 +38,7 @@ final class APIKeyModel { /// The `hasKey` read below also loads the Keychain memo (`APIKeyStore.current`), /// so every later readiness check on the press→recording latency path is /// served from memory instead of paying a cold `SecItemCopyMatching`. - init( + public init( keyStore: any APIKeyGateway = ProductionAPIKeyStore(), validateKey: @escaping @Sendable (String) async -> APIKeyValidator.Result = { await APIKeyValidator.blurt().validate($0) @@ -47,7 +52,7 @@ final class APIKeyModel { /// The key currently stored, read through the gateway. The setup/settings /// API-key view reads this (rather than `APIKeyStore` directly) so a UI-test /// run sees the injected in-memory store instead of the real Keychain. - var current: String? { keyStore.current } + public var current: String? { keyStore.current } /// The storage seam itself, handed to the Settings window's full-install reset /// (`InstallReset`) so the sweep deletes the key from the *injected* store — @@ -55,13 +60,13 @@ final class APIKeyModel { /// to the production Keychain item. Exposed rather than wrapped in a `clear()` /// here because the reset owns the sweep; what this model owns is /// `refreshStatus()`, which is how `hasAPIKey` catches up afterwards. - var storage: any APIKeyGateway { keyStore } + public var storage: any APIKeyGateway { keyStore } /// A `@Sendable` snapshot of the readiness gate for `DictationSession`: a press /// with no key saved fails fast as `.failed(.apiKeyMissing)` before any capture. /// Captures the (Sendable) store, not this main-actor model, so it can cross /// into the session's `@Sendable` closure. - func readinessCheck() -> @Sendable () -> BlurtError? { + public func readinessCheck() -> @Sendable () -> BlurtError? { let keyStore = keyStore return { keyStore.hasKey ? nil : .apiKeyMissing } } @@ -71,7 +76,7 @@ final class APIKeyModel { /// actually readable from Keychain after the write (the engine's /// `APIKeySubmission.save` owns that read-back rule). @discardableResult - func save(_ key: String) -> Bool { + public func save(_ key: String) -> Bool { let saved = submission.save(key) refreshStatus() return saved @@ -85,7 +90,7 @@ final class APIKeyModel { /// already present flips `hasAPIKey` true, so the wizard advances to the ready /// screen instead of stranding the user on a setup step whose only control (a /// *changed*-key "Update") is disabled. - func refreshStatus() { + public func refreshStatus() { let hasKey = keyStore.hasKey if hasKey != hasAPIKey { hasAPIKey = hasKey } } @@ -94,7 +99,7 @@ final class APIKeyModel { /// actively accepts it (`.valid`) — the engine's `APIKeySubmission` owns /// (and unit-tests) that never-persist-an-unverified-key rule. Mirrors the /// outcome into `hasAPIKey` so the wizard/Settings UI reacts. - func submit(_ key: String) async -> APIKeySubmission.Outcome { + public func submit(_ key: String) async -> APIKeySubmission.Outcome { let outcome = await submission.submit(key) refreshStatus() return outcome diff --git a/App/BlurtiOS/BlurtiOSTests/APIKeyModelTests.swift b/Tests/BlurtEngineTests/APIKeyModelTests.swift similarity index 70% rename from App/BlurtiOS/BlurtiOSTests/APIKeyModelTests.swift rename to Tests/BlurtEngineTests/APIKeyModelTests.swift index 87405376..c9d0a09b 100644 --- a/App/BlurtiOS/BlurtiOSTests/APIKeyModelTests.swift +++ b/Tests/BlurtEngineTests/APIKeyModelTests.swift @@ -1,9 +1,8 @@ -import BlurtEngine import Testing -@testable import BlurtiOSCore +@testable import BlurtEngine -/// The iPhone's API-key model over an in-memory store — never the real +/// The shells' API-key model over an in-memory store — never the real /// Keychain — and a validator that answers without the network. @Suite("API key model") @MainActor @@ -19,6 +18,7 @@ struct APIKeyModelTests { #expect(await model.submit("good") == .valid) #expect(model.hasAPIKey) #expect(store.current == "good") + #expect(model.current == "good") } @Test("the readiness gate reads the store as it is now; a refresh catches the flag up") @@ -33,4 +33,13 @@ struct APIKeyModelTests { model.refreshStatus() #expect(model.hasAPIKey) } + + @Test("save writes through the injected store, unvalidated, and flips the flag") + func save() { + let store = InMemoryAPIKeyStore() + let model = APIKeyModel(keyStore: store) { _ in .invalid } + #expect(model.save("seeded")) + #expect(model.hasAPIKey) + #expect(model.storage.current == "seeded") + } } From 49be1fbdfd081b5116f880ad3667b252bb8392cb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 03:01:46 +0000 Subject: [PATCH 10/10] fix: drop imports and public access the TextShortcuts move left unused STTPrompt, TranscriptionContext and DictationSession imported AssemblyAI only for TextShortcut; TextShortcutExpander has no caller outside the engine, so it is internal (periphery's redundant-public check). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X84Vo9pry7SANcbu86Jjrh --- Sources/BlurtEngine/Pipeline/DictationSession.swift | 1 - Sources/BlurtEngine/Pipeline/TextShortcuts.swift | 6 +++--- Sources/BlurtEngine/STT/STTPrompt.swift | 2 -- Sources/BlurtEngine/STT/TranscriptionContext.swift | 2 -- 4 files changed, 3 insertions(+), 8 deletions(-) diff --git a/Sources/BlurtEngine/Pipeline/DictationSession.swift b/Sources/BlurtEngine/Pipeline/DictationSession.swift index 359010ea..30df8ba0 100644 --- a/Sources/BlurtEngine/Pipeline/DictationSession.swift +++ b/Sources/BlurtEngine/Pipeline/DictationSession.swift @@ -1,4 +1,3 @@ -import AssemblyAI // No `Foundation`: its last use here went with `mic.stop()` returning a byte // count instead of a `Data` blob, and the off-pool queue moved to `BlockingIPC`. import Synchronization diff --git a/Sources/BlurtEngine/Pipeline/TextShortcuts.swift b/Sources/BlurtEngine/Pipeline/TextShortcuts.swift index 4f2202fe..5188cbf6 100644 --- a/Sources/BlurtEngine/Pipeline/TextShortcuts.swift +++ b/Sources/BlurtEngine/Pipeline/TextShortcuts.swift @@ -57,7 +57,7 @@ public struct TextShortcut: Codable, Identifiable, Hashable, Sendable { /// /// One pass over the text, longest trigger first, so an expansion is never /// itself re-expanded and "personal email work" wins over "personal email". -public enum TextShortcutExpander { +enum TextShortcutExpander { /// What counts as part of a word — the regex spelling of /// `CharacterSet.alphanumerics` (letters, marks, digits), which `words(in:)` /// splits on. @@ -73,7 +73,7 @@ public enum TextShortcutExpander { private static let separatorRun = "(?:[^\(wordClass)\(sentencePunctuation)]|[\(sentencePunctuation)](?!\\s))*" - public static func expand(_ text: String, using shortcuts: [TextShortcut]) -> String { + static func expand(_ text: String, using shortcuts: [TextShortcut]) -> String { // Longest first, so a trigger that extends another claims the text before // its prefix can. Group i+1 of `alternation` is `ordered[i]`. let ordered = shortcuts.compactMap { shortcut in pattern(for: shortcut.trigger).map { (shortcut, $0) } } @@ -130,7 +130,7 @@ public enum TextShortcutExpander { /// `minimumClippedOverlap` long) at the very start of `text` counts too. /// Replaced with the trigger rather than nothing, so the prompt still reads as /// the continuous passage it is. - public static func redactingExpansions(in text: String, using shortcuts: [TextShortcut]) -> String { + static func redactingExpansions(in text: String, using shortcuts: [TextShortcut]) -> String { var result = text for shortcut in shortcuts.sorted(by: { $0.expansion.count > $1.expansion.count }) { let expansion = shortcut.expansion diff --git a/Sources/BlurtEngine/STT/STTPrompt.swift b/Sources/BlurtEngine/STT/STTPrompt.swift index e5b3e346..0111e671 100644 --- a/Sources/BlurtEngine/STT/STTPrompt.swift +++ b/Sources/BlurtEngine/STT/STTPrompt.swift @@ -1,5 +1,3 @@ -import AssemblyAI - /// Builds the dictation request's `config.stt_prompt` — the contextual prompt /// that primes the STT model with what came *before* this utterance. The model /// reads it as continuity: vocabulary, spelling, capitalization, and mid-sentence diff --git a/Sources/BlurtEngine/STT/TranscriptionContext.swift b/Sources/BlurtEngine/STT/TranscriptionContext.swift index d7e600b3..c1a98a7c 100644 --- a/Sources/BlurtEngine/STT/TranscriptionContext.swift +++ b/Sources/BlurtEngine/STT/TranscriptionContext.swift @@ -1,5 +1,3 @@ -import AssemblyAI - /// Everything captured at dictation start about where the user is typing: /// the frontmost app, the focused window and field, the text around the cursor, /// the user's recent dictations, and the user's key terms.