diff --git a/AGENTS.md b/AGENTS.md index ad0b6656..44bb6ad0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -576,9 +576,8 @@ 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 -transcripts** on (`EnhancedTranscriptsStore.pastesRewrite`, on by default and off while "speak all -punctuation" is on, read per request via the transcriber's injected `enhancedTranscripts` closure) -it prefers the rewrite and falls back to +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 budget), so a rewrite failure (`llm_error`) is a logged degradation, never a user-facing error. With the setting off it returns `text`, and the rewrite that came back is discarded unread. @@ -956,16 +955,7 @@ Engine-side stores, all `UserDefaults`-backed value types with the same shape: pairs; re-read per transcript via the session's `textShortcutsProvider` and applied locally by `TextShortcutExpander` between the response and the paste — never on the wire. Like `StyleProfileStore` it has a setter, because the value is encoded; the Settings window's Text - Shortcuts pane writes through it), - **`SpokenPunctuationStore`** (`BlurtSpokenPunctuation`, off by default — the "speak all - punctuation" mode, re-read per transcript via the session's `spokenPunctuationProvider`. While - on, `SpokenPunctuationFormatter` strips every mark the service inserted and turns the spoken - ones — "comma", "question mark", "new paragraph" — back into symbols, locally, before - `TextShortcutExpander` runs, so a shortcut's saved text keeps its own punctuation. It formats the - **verbatim** transcript, never the rewrite, since the rewrite is told to fix punctuation and may - already have turned a spoken "comma" into a mark the strip would remove: so it overrides - enhanced transcripts and the active style, through the one rule both read, - `EnhancedTranscriptsStore.pastesRewrite`. The request is unchanged). + Shortcuts pane writes through it). - **`DefaultsKey`** (`Config/DefaultsKey.swift`) defines every key those stores write, one case each, and each store's `defaultsKey` reads its case from there rather than spelling a string literal. **`PersistedSettings.allDefaultsKeys`** is therefore just `DefaultsKey.allCases`, and diff --git a/App/Blurt/Blurt/Wizard/DeveloperSection.swift b/App/Blurt/Blurt/Wizard/DeveloperSection.swift index 472ca8c7..9f26ce54 100644 --- a/App/Blurt/Blurt/Wizard/DeveloperSection.swift +++ b/App/Blurt/Blurt/Wizard/DeveloperSection.swift @@ -3,9 +3,9 @@ import BlurtEngine import OSLog import SwiftUI -// The Advanced pane's three standalone sections: the update check, the -// developer-mode switch and the start-over button. All are Settings-only (none -// gates setup, so none is a wizard step), and they live here rather than in +// The Advanced pane's two standalone sections: the developer-mode switch and +// the start-over button. Both are Settings-only (neither gates setup, so +// neither is a wizard step), and they live here rather than in // `SettingsWindowRoot` because that file is at the repo's file-length limit. /// The Developer section of the Settings window: an opt-in switch for developer @@ -194,37 +194,3 @@ struct ResetSection: View { NSApp.terminate(nil) } } - -/// The Updates section of the Settings window: the running version and a -/// "Check for Updates" button that runs the check and reports the result in a -/// modal (see `UpdateCheckModel`). The same check is reachable from the -/// "Check for Updates…" app-menu command and the menu-bar item; all three share -/// the one `UpdateCheckModel` owned by `AppDelegate`, so a check from any place -/// runs through the same controller. -struct UpdateSection: View { - let model: UpdateCheckModel - - var body: some View { - Section { - // "Blurt 0.1.31" — the label is the engine's (shared with the result - // alerts, so the two can't name the version differently). - SettingRow(title: model.versionLabel, systemImage: "arrow.triangle.2.circlepath") { - HStack(spacing: 8) { - // A user-initiated check that can stall on a slow connection needs - // visible progress, or the button reads as dead until the result - // alert lands. Show a spinner and disable the button while in flight - // (the model already ignores a second check) — the native equivalent - // of Sparkle's "Checking for updates…". - if model.isChecking { - ProgressView().controlSize(.small) - } - Button("Check for Updates") { model.checkForUpdates() } - .disabled(model.isChecking) - .accessibilityIdentifier(UITestIdentifiers.updateCheck) - } - } - } header: { - Text("Updates") - } - } -} diff --git a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift index 7e102ced..14e7ae08 100644 --- a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift +++ b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift @@ -106,46 +106,30 @@ private struct AdvancedSettingsTab: View { /// switch. Every dictation request asks AssemblyAI's dictation API for its /// server-side cleanup rewrite, so the response always holds both versions; /// while this is on (the default) the polished one is pasted, and turned off -/// the verbatim transcript is pasted exactly as spoken. "Speak all punctuation" -/// (on the Text Shortcuts pane) formats the verbatim transcript itself, so it -/// overrides this switch, which is disabled while it is on -/// (`EnhancedTranscriptsStore.pastesRewrite`). The transcriber reads the same -/// default this toggle writes at every request, so a change applies to the next -/// dictation. Settings-only — not a wizard step, since it never gates setup. -/// -/// Nothing here may grow the pane: Advanced is a non-scrolling form, and one -/// extra row here was enough to push its Reset button out of reach on the CI -/// runner's display (`testResetAsksBeforeDoingAnything`, "Not hittable"). +/// the verbatim transcript is pasted exactly as spoken. The transcriber reads +/// the same default this toggle writes at every request, so a change applies to +/// the next dictation — see `AssemblyAITranscriber.transcript(from:)`. +/// Settings-only — not a wizard step, since it never gates setup. private struct TranscriptionSection: View { // The unset default comes from the store, not a literal here: the transcriber // reads the same slot per request, and two spellings of "unset means on" would let // the toggle and the request disagree about an untouched install. @AppStorage(EnhancedTranscriptsStore.defaultsKey) private var enhancedTranscripts = EnhancedTranscriptsStore.defaultValue - @AppStorage(SpokenPunctuationStore.defaultsKey) private var spokenPunctuation = false var body: some View { Section { Toggle(isOn: $enhancedTranscripts) { Label("Enhanced transcripts", systemImage: "wand.and.stars") } - .disabled(spokenPunctuation) - .help(spokenPunctuation ? "Off while Speak all punctuation is on." : "") .accessibilityIdentifier(UITestIdentifiers.enhancedTranscriptsToggle) } header: { Text("Transcription") } footer: { - Text(footer) - } - } - - private var footer: String { - if spokenPunctuation { - return "Off while Speak all punctuation is on, which pastes your words as spoken with only " - + "the punctuation you say." + Text( + "Polishes each dictation before pasting — removing filler words and fixing punctuation. " + + "Turn off to paste your words exactly as spoken.") } - return "Polishes each dictation before pasting — removing filler words and fixing punctuation. " - + "Turn off to paste your words exactly as spoken." } } @@ -155,8 +139,8 @@ private struct TranscriptionSection: View { /// request (see `CleanupInstruction.sendable(appending:)` / `StyleProfileStore`), /// so the enhanced-transcript polish also applies the user's formatting /// preferences. Optional — with none defined the request is exactly what ships -/// today. Disabled while enhanced transcripts are off (or overridden by spoken -/// punctuation), since the rewrite they shape is not pasted then. +/// today. Disabled while enhanced transcripts are off, since the instruction +/// they extend is not sent at all then. /// /// Each row is a name and a way in: all editing happens in the sheet below, for /// the reasons on `APIKeyStepView`'s. Which style is *active* is deliberately @@ -166,10 +150,6 @@ private struct TranscriptionSection: View { private struct StyleProfilesSection: View { @AppStorage(EnhancedTranscriptsStore.defaultsKey) private var enhancedTranscripts = EnhancedTranscriptsStore.defaultValue - @AppStorage(SpokenPunctuationStore.defaultsKey) private var spokenPunctuation = false - - /// The view-side spelling of `EnhancedTranscriptsStore.pastesRewrite`. - private var stylesApply: Bool { enhancedTranscripts && !spokenPunctuation } /// Bound to observe, not to write: the store owns the JSON encoding, so it /// decodes this slot and the sheet writes through it, while `@AppStorage` is @@ -205,13 +185,11 @@ private struct StyleProfilesSection: View { // enhanced transcripts off the rewrite a style shapes is discarded // unread, so describing the limit is the less useful half. Text( - stylesApply + enhancedTranscripts ? "Up to \(StyleProfileStore.profileLimit) styles." - : spokenPunctuation - ? "Styles don’t apply while Speak all punctuation is on." - : "Style preferences need enhanced transcripts turned on.") + : "Style preferences need enhanced transcripts turned on.") } - .disabled(!stylesApply) + .disabled(!enhancedTranscripts) .sheet(item: $editing) { profile in StyleProfileEditorSheet(profile: profile, isExisting: profiles.contains(profile)) } @@ -386,3 +364,37 @@ private struct StyleProfileEditorSheet: View { dismiss() } } + +/// The Updates section of the Settings window: the running version and a +/// "Check for Updates" button that runs the check and reports the result in a +/// modal (see `UpdateCheckModel`). The same check is reachable from the +/// "Check for Updates…" app-menu command and the menu-bar item; all three share +/// the one `UpdateCheckModel` owned by `AppDelegate`, so a check from any place +/// runs through the same controller. +private struct UpdateSection: View { + let model: UpdateCheckModel + + var body: some View { + Section { + // "Blurt 0.1.31" — the label is the engine's (shared with the result + // alerts, so the two can't name the version differently). + SettingRow(title: model.versionLabel, systemImage: "arrow.triangle.2.circlepath") { + HStack(spacing: 8) { + // A user-initiated check that can stall on a slow connection needs + // visible progress, or the button reads as dead until the result + // alert lands. Show a spinner and disable the button while in flight + // (the model already ignores a second check) — the native equivalent + // of Sparkle's "Checking for updates…". + if model.isChecking { + ProgressView().controlSize(.small) + } + Button("Check for Updates") { model.checkForUpdates() } + .disabled(model.isChecking) + .accessibilityIdentifier(UITestIdentifiers.updateCheck) + } + } + } header: { + Text("Updates") + } + } +} diff --git a/App/Blurt/Blurt/Wizard/TextShortcutsSection.swift b/App/Blurt/Blurt/Wizard/TextShortcutsSection.swift index 4783c237..52e1d3c4 100644 --- a/App/Blurt/Blurt/Wizard/TextShortcutsSection.swift +++ b/App/Blurt/Blurt/Wizard/TextShortcutsSection.swift @@ -9,17 +9,10 @@ import SwiftUI /// /// Rows follow the Styles section's shape: each is a read-out plus an "Edit…" /// way in, and all editing happens in the sheet below. -/// -/// The "Speak all punctuation" switch sits above the list: it is the other -/// local rewrite of what was said (`SpokenPunctuationFormatter`, run just -/// before the shortcuts), and this is the one pane that scrolls — the Advanced -/// pane, beside the enhanced-transcripts switch it overrides, hugs its content -/// and had no height to spare (see `TranscriptionSection`). struct TextShortcutsSection: View { /// Bound to observe, not to write — the store owns the JSON encoding, as with /// `StyleProfilesSection`. @AppStorage(TextShortcutStore.defaultsKey) private var rawShortcuts = "" - @AppStorage(SpokenPunctuationStore.defaultsKey) private var spokenPunctuation = false /// The shortcut the sheet is editing, or nil while it's closed. @State private var editing: TextShortcut? @@ -27,18 +20,6 @@ struct TextShortcutsSection: View { var body: some View { let shortcuts = TextShortcutStore().shortcuts(decoding: rawShortcuts) Form { - Section { - Toggle(isOn: $spokenPunctuation) { - Label("Speak all punctuation", systemImage: "quote.opening") - } - .accessibilityIdentifier(UITestIdentifiers.spokenPunctuationToggle) - } header: { - Text("Punctuation") - } footer: { - Text( - "Punctuation appears only where you say it — “comma”, “period”, “question mark”, " - + "“new line”. Overrides Enhanced transcripts.") - } Section { ForEach(Array(shortcuts.enumerated()), id: \.element.id) { index, shortcut in HStack(spacing: 12) { diff --git a/App/Blurt/BlurtUITests/README.md b/App/Blurt/BlurtUITests/README.md index 6cf5a8ec..598d6266 100644 --- a/App/Blurt/BlurtUITests/README.md +++ b/App/Blurt/BlurtUITests/README.md @@ -8,8 +8,7 @@ observable UI rather than mocking views. - **Settings page** — `SettingsUITests`: the real Settings window's API-key flow (save / reject / reveal, and the change → cancel round-trip back to the saved - row), the hotkey picker, the sound picker, and "Speak all punctuation" (on - the Text Shortcuts pane) disabling the enhanced-transcripts switch it overrides. + row), the hotkey picker, and the sound picker. - **Text shortcuts** — `TextShortcutsUITests`: the Settings pane's add / edit / delete round-trip through the editor sheet, and a duplicate phrase (differing only in case and separators) leaving Save disabled. diff --git a/App/Blurt/BlurtUITests/SettingsUITests.swift b/App/Blurt/BlurtUITests/SettingsUITests.swift index 6b9306a0..87540254 100644 --- a/App/Blurt/BlurtUITests/SettingsUITests.swift +++ b/App/Blurt/BlurtUITests/SettingsUITests.swift @@ -122,28 +122,6 @@ final class SettingsUITests: BlurtUITestCase { XCTAssertEqual("\(toggle.value ?? "")", "1", "Clicking should switch developer mode on") } - /// "Speak all punctuation" (Text Shortcuts pane) starts off, and switching it - /// on disables the enhanced-transcripts switch it overrides on the Advanced - /// pane (`EnhancedTranscriptsStore.pastesRewrite`). - func testSpokenPunctuationDisablesEnhancedTranscripts() { - let settings = openSettingsWindow() - let shortcuts = selectSettingsTab(settings, named: UITestIdentifiers.textShortcutsTab) - - let toggle = shortcuts.anyDescendant(identified: UITestIdentifiers.spokenPunctuationToggle) - XCTAssertTrue(toggle.waitForExistence(timeout: 10), "Speak all punctuation toggle not found") - XCTAssertEqual("\(toggle.value ?? "")", "0", "Speak all punctuation should start switched off") - - toggle.click() - XCTAssertEqual("\(toggle.value ?? "")", "1", "Clicking should switch spoken punctuation on") - - // Switched from `shortcuts`, not `settings`: selecting a pane retitles the - // window, so the proxy from before the first switch is stale. - let advanced = selectSettingsTab(shortcuts, named: UITestIdentifiers.advancedSettingsTab) - let enhanced = advanced.anyDescendant(identified: UITestIdentifiers.enhancedTranscriptsToggle) - XCTAssertTrue(enhanced.waitForExistence(timeout: 10), "Enhanced transcripts toggle not found") - XCTAssertFalse(enhanced.isEnabled, "Spoken punctuation overrides enhanced transcripts") - } - /// The Advanced pane's "Check for Updates" button runs the check and reports /// the result in a modal. Under UI testing the check is stubbed offline to /// always report up-to-date, so clicking it surfaces the "You’re up to date" diff --git a/App/Blurt/Shared/UITestIdentifiers.swift b/App/Blurt/Shared/UITestIdentifiers.swift index 58c1847a..b344f551 100644 --- a/App/Blurt/Shared/UITestIdentifiers.swift +++ b/App/Blurt/Shared/UITestIdentifiers.swift @@ -67,7 +67,6 @@ enum UITestIdentifiers { static let soundPicker = "settings.sound.picker" static let developerToggle = "settings.developer.toggle" static let enhancedTranscriptsToggle = "settings.enhancedTranscripts.toggle" - static let spokenPunctuationToggle = "settings.spokenPunctuation.toggle" static let updateCheck = "settings.update.check" /// The Advanced pane's "Reset…" button (`SettingsWindowRoot`'s reset section). /// Only the row button is identified: the confirmation it opens is an alert, diff --git a/Sources/BlurtEngine/Config/DefaultsKey.swift b/Sources/BlurtEngine/Config/DefaultsKey.swift index 3ce2c832..7850d08a 100644 --- a/Sources/BlurtEngine/Config/DefaultsKey.swift +++ b/Sources/BlurtEngine/Config/DefaultsKey.swift @@ -48,8 +48,6 @@ enum DefaultsKey: String, CaseIterable { case micDeviceUID = "MicDeviceUID" /// The JSON list of spoken-phrase → text expansions (`TextShortcutStore`). case textShortcuts = "TextShortcuts" - /// The "speak all punctuation" switch (`SpokenPunctuationStore`). - case spokenPunctuation = "SpokenPunctuation" /// The key this case actually reads and writes, under the configured host /// identity. A computed property rather than a stored string because the diff --git a/Sources/BlurtEngine/Config/EnhancedTranscriptsStore.swift b/Sources/BlurtEngine/Config/EnhancedTranscriptsStore.swift index c86b7b14..4b385401 100644 --- a/Sources/BlurtEngine/Config/EnhancedTranscriptsStore.swift +++ b/Sources/BlurtEngine/Config/EnhancedTranscriptsStore.swift @@ -39,13 +39,4 @@ public struct EnhancedTranscriptsStore { var isEnabled: Bool { defaults.object(forKey: Self.defaultsKey) as? Bool ?? Self.defaultValue } - - /// Whether the rewrite is what gets pasted: this switch, unless "speak all - /// punctuation" is on — that mode formats the verbatim transcript, since the - /// rewrite is told to fix punctuation and may already have turned a spoken - /// "comma" into a mark the formatter would then strip (`SpokenPunctuationStore`). - /// The one rule both the transcriber and the Recent row's style chip read. - var pastesRewrite: Bool { - isEnabled && !SpokenPunctuationStore(defaults: defaults).isEnabled - } } diff --git a/Sources/BlurtEngine/Config/SpokenPunctuationStore.swift b/Sources/BlurtEngine/Config/SpokenPunctuationStore.swift deleted file mode 100644 index fe9b43d0..00000000 --- a/Sources/BlurtEngine/Config/SpokenPunctuationStore.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Foundation - -/// Persists the "speak all punctuation" switch in `UserDefaults`. Off by -/// default; the Settings window's Text Shortcuts pane flips it. -/// -/// While on, the pipeline pastes only the punctuation the user *said*: every -/// mark the service inserted is stripped and spoken ones ("comma", "question -/// mark", "new paragraph") are put back as symbols — see -/// `SpokenPunctuationFormatter`. The verbatim transcript is formatted rather -/// than the cleanup rewrite, which is told to fix punctuation and so may turn a -/// spoken "comma" into a mark the formatter would then strip; so this mode -/// overrides enhanced transcripts, and with them the active style -/// (`EnhancedTranscriptsStore.pastesRewrite`). -/// Read at every dictation, so a change applies to the next one. Same shape as -/// `DeveloperModeStore`. -public struct SpokenPunctuationStore { - /// UserDefaults key holding the switch. Public so SwiftUI views can observe - /// it directly (e.g. `@AppStorage`) and re-render on change. - public static var defaultsKey: String { DefaultsKey.spokenPunctuation.key } - private let defaults: UserDefaults - - init(defaults: UserDefaults = .standard) { - self.defaults = defaults - } - - /// `bool(forKey:)` returns false for a missing key, so unset means off. - /// - /// Read-only for the reason on `DeveloperModeStore.isEnabled`: the Settings - /// toggle writes the slot through `@AppStorage`, so a setter here would have - /// no production caller. Seed the slot to change the switch. - var isEnabled: Bool { - defaults.bool(forKey: Self.defaultsKey) - } -} diff --git a/Sources/BlurtEngine/Injection/KeyInjector+Separator.swift b/Sources/BlurtEngine/Injection/KeyInjector+Separator.swift index 5e691301..79aec915 100644 --- a/Sources/BlurtEngine/Injection/KeyInjector+Separator.swift +++ b/Sources/BlurtEngine/Injection/KeyInjector+Separator.swift @@ -20,21 +20,13 @@ extension KeyInjector { /// chunks where nothing strips it. `priorText` is nil for empty fields and for /// secure/Accessibility-opaque fields — there we can't tell what precedes the /// caret, so we add no separator rather than risk a stray leading space. - /// - /// Nor before text that opens with a mark that hugs the word before it — a - /// spoken-punctuation dictation of "comma and then" pastes ", and then", which - /// belongs flush against the previous word (`SpokenPunctuationFormatter`). public static func withLeadingSeparator(_ text: String, after priorText: String?) -> String { guard !text.isEmpty else { return text } guard let priorText, let last = priorText.last, !last.isWhitespace else { return text } - guard let first = text.first, !first.isWhitespace, !closingMarks.contains(first) else { return text } + guard let first = text.first, !first.isWhitespace else { return text } return " " + text } - /// Marks that attach to the text before them, so a paste opening with one - /// takes no leading separator. - private static let closingMarks: Set = [".", ",", "?", "!", ":", ";", ")", "]", "…"] - /// Chooses what text the separator decision should treat as preceding the /// caret. AX-read `priorText` is authoritative whenever we have it. When it's /// nil — the field is empty *or* Accessibility-opaque (Electron/Monaco, e.g. VS diff --git a/Sources/BlurtEngine/Pipeline/DictationSession+Pipeline.swift b/Sources/BlurtEngine/Pipeline/DictationSession+Pipeline.swift index bac098e7..ab5af52d 100644 --- a/Sources/BlurtEngine/Pipeline/DictationSession+Pipeline.swift +++ b/Sources/BlurtEngine/Pipeline/DictationSession+Pipeline.swift @@ -70,11 +70,9 @@ extension DictationSession { if Task.isCancelled { return } // The log keeps what the service returned; the paste and the Recent list - // get the formatted, expanded text, and the ring also keeps the spoken - // version (see `RecentDictations.Entry.spoken`). Spoken punctuation runs - // first so a shortcut's saved text keeps its own punctuation. - let punctuated = spokenPunctuationProvider() ? SpokenPunctuationFormatter.format(text) : text - let expanded = TextShortcutExpander.expand(punctuated, using: textShortcutsProvider()) + // get the expanded text, and the ring also keeps the spoken version (see + // `RecentDictations.Entry.spoken`). + let expanded = TextShortcutExpander.expand(text, using: textShortcutsProvider()) guard let spoken = text.trimmedNonEmpty(), let trimmed = expanded.trimmedNonEmpty() else { setPhase(.idle) return diff --git a/Sources/BlurtEngine/Pipeline/DictationSession.swift b/Sources/BlurtEngine/Pipeline/DictationSession.swift index 5892e74e..0133e646 100644 --- a/Sources/BlurtEngine/Pipeline/DictationSession.swift +++ b/Sources/BlurtEngine/Pipeline/DictationSession.swift @@ -1,6 +1,7 @@ -// `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. +// `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 import Synchronization @@ -20,12 +21,14 @@ public actor DictationSession { /// the failure from the log. public internal(set) var phase: PipelinePhase = .idle - // Split for the lint file-length budget: `performPress` — the whole press half, including the mic - // bring-up — lives in `+Press`, mirroring the post-release transcribe→inject pipeline in - // `+Pipeline`. `submit(_:)`, both cancel commands and the cancel-intent accessors over `cancelState` - // live in `+Commands`; `phaseStream()`/`setPhase`/os_signpost live in `+Observation`; the - // non-protocol collaborators (focus capture, developer-mode log) live in `+Seams`. Members those - // files reach are internal, not private (file-scoped access can't cross the split), `phase`'s setter too. + // Split for the lint file-length budget: `performPress` — the whole press half, + // including the mic bring-up — lives in `+Press`, mirroring the post-release + // transcribe→inject pipeline in `+Pipeline`. `submit(_:)`, both cancel commands + // and the cancel-intent accessors over `cancelState` live in `+Commands`; + // `phaseStream()`/`setPhase`/os_signpost live in `+Observation`; the + // non-protocol collaborators (focus capture, developer-mode log) live in + // `+Seams`. Members those files reach are internal, not private (file-scoped + // access can't cross the split) — including `phase`'s setter. /// Live feeds of phase changes. Each `phaseStream()` call yields the current /// phase plus every subsequent transition, so the production renderer and @@ -58,8 +61,6 @@ public actor DictationSession { let styleNameProvider: @Sendable () -> String? /// The text shortcuts expanded into each transcript before the paste; live-read. let textShortcutsProvider: @Sendable () -> [TextShortcut] - /// Whether "speak all punctuation" formats each transcript (`SpokenPunctuationStore`); live-read. - let spokenPunctuationProvider: @Sendable () -> Bool /// Auto-releases the hotkey after this long so a held key can't run forever. /// Defaults to just under the dictation API's audio cap (see /// `SyncSTTLimits`) — recording past it would only produce audio the @@ -76,9 +77,10 @@ public actor DictationSession { /// user has spoken a whole utterance. Defaults to always-ready (no Keychain /// read), so tests and keyless hosts are unaffected unless they opt in. let readinessCheck: @Sendable () -> BlurtError? - /// Fired once with the final transcript as soon as it's produced — before injection, so pasted, - /// copied, and failed-to-paste dictations all count. The second argument is `recentDictations` as - /// it stands, pushed from its one owner so the "Recent" list is a projection, not a second ring. + /// Fired once with the final transcript as soon as it's produced — before + /// injection, so pasted, copied, and failed-to-paste dictations all count. The + /// second argument is `recentDictations` as it stands, pushed from its one owner + /// so the "Recent" list is a projection rather than a second ring (see it). let onTranscriptDelivered: (@Sendable (String, RecentDictations) -> Void)? /// The focus capture and the developer-mode log, behind closures rather than @@ -102,7 +104,8 @@ public actor DictationSession { /// copy of that history. Recorded in `runTranscribeInject` (`+Pipeline`) just /// before `onTranscriptDelivered` fires, and read at press time into /// `TranscriptionContext.recentTranscripts`, which sends them as the leading - /// `stt_prompt` lines — so a run of dictations reads as one continuing dialogue. + /// leading `stt_prompt` lines — so a run of dictations reads to the model as + /// one continuing dialogue rather than N unrelated clips. /// /// It lives here, not in the host, because the request is assembled inside this /// actor: a ring held as MainActor UI state couldn't be read at press time @@ -177,15 +180,14 @@ public actor DictationSession { keyTermsProvider: (@Sendable () -> [String])? = nil, styleNameProvider: (@Sendable () -> String?)? = nil, textShortcutsProvider: (@Sendable () -> [TextShortcut])? = nil, - spokenPunctuationProvider: (@Sendable () -> Bool)? = nil, readinessCheck: @escaping @Sendable () -> BlurtError? = { nil }, onTranscriptDelivered: (@Sendable (String, RecentDictations) -> Void)? = nil ) { self.init( mic: mic, transcriber: transcriber, injector: injector, - maxRecordingSeconds: maxRecordingSeconds, clock: clock, keyTermsProvider: keyTermsProvider, - styleNameProvider: styleNameProvider, textShortcutsProvider: textShortcutsProvider, - spokenPunctuationProvider: spokenPunctuationProvider, readinessCheck: readinessCheck, + maxRecordingSeconds: maxRecordingSeconds, clock: clock, + keyTermsProvider: keyTermsProvider, styleNameProvider: styleNameProvider, + textShortcutsProvider: textShortcutsProvider, readinessCheck: readinessCheck, onTranscriptDelivered: onTranscriptDelivered, seams: .production) } @@ -204,7 +206,6 @@ public actor DictationSession { keyTermsProvider: (@Sendable () -> [String])? = nil, styleNameProvider: (@Sendable () -> String?)? = nil, textShortcutsProvider: (@Sendable () -> [TextShortcut])? = nil, - spokenPunctuationProvider: (@Sendable () -> Bool)? = nil, readinessCheck: @escaping @Sendable () -> BlurtError? = { nil }, onTranscriptDelivered: (@Sendable (String, RecentDictations) -> Void)? = nil, seams: Seams @@ -218,15 +219,14 @@ public actor DictationSession { self.styleNameProvider = styleNameProvider ?? { - // No rewrite pasted means no style was applied — matching the - // transcriber's own per-request read of the same rule. `active` is nil - // for the Default sentinel as well as for an empty list, which is exactly + // No rewrite means no style was applied — matching the transcriber's + // own per-request read of the same store. `active` is nil for the + // Default sentinel as well as for an empty list, which is exactly // the rule: only a *custom* style is worth naming on the row. - guard EnhancedTranscriptsStore().pastesRewrite else { return nil } + guard EnhancedTranscriptsStore().isEnabled else { return nil } return StyleProfileStore().active?.name } self.textShortcutsProvider = textShortcutsProvider ?? { TextShortcutStore().shortcuts } - self.spokenPunctuationProvider = spokenPunctuationProvider ?? { SpokenPunctuationStore().isEnabled } self.readinessCheck = readinessCheck self.onTranscriptDelivered = onTranscriptDelivered self.seams = seams diff --git a/Sources/BlurtEngine/Pipeline/SpokenPunctuationFormatter.swift b/Sources/BlurtEngine/Pipeline/SpokenPunctuationFormatter.swift deleted file mode 100644 index f1d4980d..00000000 --- a/Sources/BlurtEngine/Pipeline/SpokenPunctuationFormatter.swift +++ /dev/null @@ -1,212 +0,0 @@ -/// The "speak all punctuation" pass (`SpokenPunctuationStore`): strips every -/// mark the service put in a transcript, then turns the punctuation the user -/// *said* back into symbols — "hello comma how are you question mark" → -/// "hello, how are you?". Pure and local, like `TextShortcutExpander`, and run -/// before it so a shortcut's saved text is pasted with its own punctuation. -/// -/// - **What is stripped**: sentence and phrase punctuation — `. , ? ! : ;`, -/// quotes, brackets, dashes, ellipses, `¿ ¡`. Symbols someone may well have -/// dictated as words (`% $ @ & # /`) stay. A `.` `,` `:`, apostrophe or hyphen -/// *between* two letters or digits is part of a word and stays too: "don't", -/// "well-known", "3.5", "1,000", "10:30", "cal.com". -/// - **What is recognized**: the phrases in `vocabulary`, case-insensitively and -/// as whole words. Deliberately literal — in this mode "period" is always a -/// `.`, the trade every spoken-punctuation mode makes. Bare "quote" and "dot" -/// are left out as too common as ordinary words. -/// - **Spacing** follows the mark: closing marks hug the word before, opening -/// ones the word after, hyphens and dashes both, and a line break takes the -/// spaces on either side. -/// - **Case**: a word after a spoken sentence end or line break is capitalized. -/// A word the service capitalized only because *its* period came first — -/// "Hello comma. How are you" — is lowercased again, unless it doesn't look -/// like a sentence-start capital ("I", "I'm", "NASA", "iPhone"). A name right -/// after a stripped period loses its capital too; that is the price of not -/// pasting "Hello, How are you?". -enum SpokenPunctuationFormatter { - /// How a mark sits against the words either side of it. - enum Spacing { - /// Hugs the word before it: `.` `,` `)`. - case closing - /// Hugs the word after it: `(` and an opening quote. - case opening - /// Hugs both: a hyphen, a dash, an apostrophe. - case joining - /// A line break: no space on either side. - case lineBreak - } - - struct Mark { - let symbol: String - let spacing: Spacing - /// Whether the word after it starts a sentence, and so is capitalized. - let endsSentence: Bool - } - - /// One recognized phrase: its words, spelled as `matchKey` reads them, and - /// the mark it stands for. - struct Entry { - let words: [String] - let mark: Mark - } - - /// A word left once the service's punctuation is stripped. - struct Word { - let text: String - /// Whether a sentence end the *service* inserted came right before it — - /// the case where its capital letter may be an artifact of that period. - let followsStrippedSentenceEnd: Bool - } - - enum Piece { - case word(Word) - case mark(Mark) - } - - /// Every spoken phrase the formatter recognizes, longest first so "question - /// mark" claims its words before any shorter phrase could. - static let vocabulary: [Entry] = { - func entries( - _ phrases: [String], _ symbol: String, _ spacing: Spacing, endsSentence: Bool = false - ) -> [Entry] { - let mark = Mark(symbol: symbol, spacing: spacing, endsSentence: endsSentence) - return phrases.map { Entry(words: $0.split(separator: " ").map(String.init), mark: mark) } - } - let groups = [ - entries(["period", "full stop"], ".", .closing, endsSentence: true), - entries(["comma"], ",", .closing), - entries(["question mark"], "?", .closing, endsSentence: true), - entries(["exclamation point", "exclamation mark"], "!", .closing, endsSentence: true), - entries(["colon"], ":", .closing), - entries(["semicolon", "semi colon"], ";", .closing), - entries(["ellipsis", "dot dot dot"], "…", .closing), - entries(["hyphen"], "-", .joining), - entries(["dash", "em dash"], "—", .joining), - entries(["apostrophe"], "'", .joining), - entries(["open paren", "open parenthesis", "left paren", "left parenthesis"], "(", .opening), - entries(["close paren", "close parenthesis", "right paren", "right parenthesis"], ")", .closing), - entries(["open bracket", "left bracket"], "[", .opening), - entries(["close bracket", "right bracket"], "]", .closing), - entries(["open quote", "begin quote", "start quote"], "\"", .opening), - entries(["close quote", "end quote", "unquote"], "\"", .closing), - entries(["new line", "newline"], "\n", .lineBreak, endsSentence: true), - entries(["new paragraph"], "\n\n", .lineBreak, endsSentence: true), - ] - return groups.flatMap { $0 }.sorted { $0.words.count > $1.words.count } - }() - - /// Stripped wherever they sit — inside a token too, where they split it: - /// "yes—no" is two words. - private static let alwaysStripped: Set = [ - "?", "!", ";", "\"", "“", "”", "„", "‘", "«", "»", "‹", "›", "(", ")", "[", "]", "{", "}", - "…", "—", "–", "¿", "¡", - ] - - /// Stripped from either end of a word, but kept between two of its letters - /// or digits: "don't", "well-known", "3.5", "1,000", "10:30". - private static let wordJoiners: Set = [".", ",", ":", "'", "’", "-", "‐"] - - /// The stripped marks that end a sentence, for `Word.followsStrippedSentenceEnd`. - private static let sentenceEnds: Set = [".", "?", "!", "…"] - - static func format(_ text: String) -> String { - render(pieces(from: strippedWords(in: text))) - } - - /// A token as `vocabulary` spells it: lowercased letters only, so "Comma,", - /// "semi-colon" and "new-line" all read as vocabulary words. - static func matchKey(_ word: String) -> String { - word.lowercased().filter(\.isLetter) - } - - /// `text`'s words with the service's punctuation gone. - static func strippedWords(in text: String) -> [Word] { - var words: [Word] = [] - var afterSentenceEnd = false - func add(_ piece: String) { - let trailing = piece.reversed().prefix(while: wordJoiners.contains) - let core = piece.drop(while: wordJoiners.contains).dropLast(trailing.count) - if !core.isEmpty { - words.append(Word(text: String(core), followsStrippedSentenceEnd: afterSentenceEnd)) - afterSentenceEnd = false - } - if trailing.contains(".") { afterSentenceEnd = true } - } - for token in text.split(whereSeparator: \.isWhitespace) { - var piece = "" - for character in token { - guard alwaysStripped.contains(character) else { - piece.append(character) - continue - } - add(piece) - piece = "" - if sentenceEnds.contains(character) { afterSentenceEnd = true } - } - add(piece) - } - return words - } - - /// `words` with each spoken phrase in `vocabulary` replaced by its mark. - static func pieces(from words: [Word]) -> [Piece] { - let keys = words.map { matchKey($0.text) } - var pieces: [Piece] = [] - var index = 0 - while index < words.count { - if let entry = vocabulary.first(where: { keys[index...].starts(with: $0.words) }) { - pieces.append(.mark(entry.mark)) - index += entry.words.count - } else { - pieces.append(.word(words[index])) - index += 1 - } - } - return pieces - } - - static func render(_ pieces: [Piece]) -> String { - var output = "" - var previous: Piece? - var capitalizeNext = false - for piece in pieces { - if let previous, spaceAfter(previous), spaceBefore(piece) { output += " " } - switch piece { - case .word(let word): - output += cased(word, capitalize: capitalizeNext) - capitalizeNext = false - case .mark(let mark): - output += mark.symbol - capitalizeNext = capitalizeNext || mark.endsSentence - } - previous = piece - } - return output - } - - private static func spaceAfter(_ piece: Piece) -> Bool { - guard case .mark(let mark) = piece else { return true } - return mark.spacing == .closing - } - - private static func spaceBefore(_ piece: Piece) -> Bool { - guard case .mark(let mark) = piece else { return true } - return mark.spacing == .opening - } - - private static func cased(_ word: Word, capitalize: Bool) -> String { - let text = word.text - if capitalize { return text.prefix(1).uppercased() + text.dropFirst() } - guard word.followsStrippedSentenceEnd, isSentenceCapitalized(text) else { return text } - return text.prefix(1).lowercased() + text.dropFirst() - } - - /// "Hello", but not "I", "I'm", "NASA" or "iPhone" — the shape a capital - /// from sentence position takes, as opposed to one the word always carries. - private static func isSentenceCapitalized(_ text: String) -> Bool { - guard let first = text.first, first.isUppercase else { return false } - let rest = text.dropFirst() - if rest.contains(where: \.isUppercase) { return false } - let isPronounI = first == "I" && (rest.isEmpty || rest.first == "'" || rest.first == "’") - return !isPronounI - } -} diff --git a/Sources/BlurtEngine/README.md b/Sources/BlurtEngine/README.md index 185e2afe..4ab3fc86 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 (`KeytermsBoost`, 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:)`) — unless **speak all punctuation** (`SpokenPunctuationStore`, off by default) is on, which always takes the verbatim `text` and formats it locally (`SpokenPunctuationFormatter`: the service's marks stripped, the spoken ones put back), since the rewrite may already have turned a spoken "comma" into a mark the strip would remove (`EnhancedTranscriptsStore.pastesRewrite`). 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 (`KeytermsBoost`, 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. - **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). @@ -243,9 +243,8 @@ Keys are the host identity's `defaultsPrefix` followed by the `DefaultsKey` case | `OverlayOriginStore` | `BlurtOverlayCustomOriginX`/`…Y` | pill never dragged; default place | | `LastUpdateCheckStore` | `BlurtLastUpdateCheck` | never checked | | `TextShortcutStore` | `BlurtTextShortcuts` | no text shortcuts | -| `SpokenPunctuationStore` | `BlurtSpokenPunctuation` | off — punctuation is the model's | -Several are read-only by design (`KeyTermsStore`, `DeveloperModeStore`, `EnhancedTranscriptsStore`, `SpokenPunctuationStore`): the Settings control binds `@AppStorage` to `defaultsKey` and is the sole writer, so normalization lives on the read side — a whitespace-only key-terms field reads back as "no terms" — and a setter here would fight the text field. `StyleProfileStore` is the counter-case: its value is JSON, so the encoding is the store's to own and the views write through it (`profiles`, `activate(_:)`, `activateDefault()` — the last stores a sentinel meaning "base styling, no profile appended", distinct from unset so a migrated legacy profile stays active) while binding `@AppStorage` only to observe. It also still **reads** `BlurtCustomStyle`, the single field that predates profiles, and never writes it — an install with text there and no list reads back as one active profile named "Custom". The six a host constructs itself (`TriggerKeyStore`, `SoundPackStore`, `StyleProfileStore`, `TextShortcutStore`, `OverlayOriginStore`, `LastUpdateCheckStore`) take a `UserDefaults` publicly; the rest keep that injection internal. (`SoundPackStore` also takes the `SoundPackCatalog` it decodes against — see [Record cues](#record-cues).) +Several are read-only by design (`KeyTermsStore`, `DeveloperModeStore`, `EnhancedTranscriptsStore`): the Settings control binds `@AppStorage` to `defaultsKey` and is the sole writer, so normalization lives on the read side — a whitespace-only key-terms field reads back as "no terms" — and a setter here would fight the text field. `StyleProfileStore` is the counter-case: its value is JSON, so the encoding is the store's to own and the views write through it (`profiles`, `activate(_:)`, `activateDefault()` — the last stores a sentinel meaning "base styling, no profile appended", distinct from unset so a migrated legacy profile stays active) while binding `@AppStorage` only to observe. It also still **reads** `BlurtCustomStyle`, the single field that predates profiles, and never writes it — an install with text there and no list reads back as one active profile named "Custom". The six a host constructs itself (`TriggerKeyStore`, `SoundPackStore`, `StyleProfileStore`, `TextShortcutStore`, `OverlayOriginStore`, `LastUpdateCheckStore`) take a `UserDefaults` publicly; the rest keep that injection internal. (`SoundPackStore` also takes the `SoundPackCatalog` it decodes against — see [Record cues](#record-cues).) The keys themselves are one internal `DefaultsKey` enum and each store reads its case from it, so **`PersistedSettings.resetAll(in:)`** sweeps `DefaultsKey.allCases` rather than a hand-maintained list — adding a store and adding it to the reset are not merely the same edit, they're the same line. That's not tidiness: it _was_ a hand-maintained array, and both times a store was copy-edited into existence the second half was forgotten (the overlay origin and the update-check stamp), so a pill dragged during a UI-test run survived `reset-install.sh`'s clean-install path into the next one. Raw values are half the on-disk contract and the identity's `defaultsPrefix` is the other — rename a case freely, never its raw value or your prefix, or every existing user's setting is silently abandoned. diff --git a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift index 04373e17..052de6a8 100644 --- a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift +++ b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift @@ -90,7 +90,7 @@ public struct AssemblyAITranscriber: TranscriberProtocol { self.apiKeyProvider = apiKeyProvider self.baseURL = baseURL self.transport = transport - self.enhancedTranscriptsEnabled = enhancedTranscripts ?? { EnhancedTranscriptsStore().pastesRewrite } + self.enhancedTranscriptsEnabled = enhancedTranscripts ?? { EnhancedTranscriptsStore().isEnabled } self.customStyle = customStyle ?? { StyleProfileStore().activeInstructions } } diff --git a/Tests/BlurtEngineTests/DictationSessionContextTests.swift b/Tests/BlurtEngineTests/DictationSessionContextTests.swift index cb10b644..3f68cf9a 100644 --- a/Tests/BlurtEngineTests/DictationSessionContextTests.swift +++ b/Tests/BlurtEngineTests/DictationSessionContextTests.swift @@ -195,6 +195,11 @@ struct DictationSessionContextTests { // request goes out with no context at all — the case being tested. await clock.waitUntilSleeping(for: DictationSession.contextWaitBudget) clock.advance(by: DictationSession.contextWaitBudget) + // `advance` only resumes the budget's sleeper; the upload's race is not + // decided until that child runs. Wait for the request to actually open + // before unblocking the read, or a fast capture queue can win the race and + // the request goes out primed after all. + while await transcriber.receivedContexts.isEmpty { await Task.yield() } // Only now let the read finish. Waiting on the box rather than sleeping, so // this asserts the recovery instead of racing the capture queue. gate.signal() diff --git a/Tests/BlurtEngineTests/EnhancedTranscriptsStoreTests.swift b/Tests/BlurtEngineTests/EnhancedTranscriptsStoreTests.swift index fc587fd0..7ec04a49 100644 --- a/Tests/BlurtEngineTests/EnhancedTranscriptsStoreTests.swift +++ b/Tests/BlurtEngineTests/EnhancedTranscriptsStoreTests.swift @@ -26,16 +26,4 @@ struct EnhancedTranscriptsStoreTests { defaults.set(true, forKey: EnhancedTranscriptsStore.defaultsKey) #expect(store.isEnabled) } - - /// Spoken punctuation formats the verbatim transcript, so while it is on the - /// rewrite is never what gets pasted, whatever this switch says. - @Test("spoken punctuation overrides the switch") - func spokenPunctuationPastesVerbatim() { - let defaults = freshDefaults() - let store = EnhancedTranscriptsStore(defaults: defaults) - #expect(store.pastesRewrite) - defaults.set(true, forKey: SpokenPunctuationStore.defaultsKey) - #expect(store.isEnabled) - #expect(!store.pastesRewrite) - } } diff --git a/Tests/BlurtEngineTests/KeyInjectorLeadingSeparatorTests.swift b/Tests/BlurtEngineTests/KeyInjectorLeadingSeparatorTests.swift index 4174e0bd..311b9ab5 100644 --- a/Tests/BlurtEngineTests/KeyInjectorLeadingSeparatorTests.swift +++ b/Tests/BlurtEngineTests/KeyInjectorLeadingSeparatorTests.swift @@ -37,13 +37,6 @@ struct KeyInjectorLeadingSeparatorTests { #expect(KeyInjector.withLeadingSeparator(" Second.", after: "First.") == " Second.") } - @Test("no separator before text that opens with a closing mark") - func textStartsWithClosingMark() { - #expect(KeyInjector.withLeadingSeparator(", and then", after: "First") == ", and then") - #expect(KeyInjector.withLeadingSeparator(".", after: "First") == ".") - #expect(KeyInjector.withLeadingSeparator("(aside)", after: "First") == " (aside)") - } - @Test("returns empty text unchanged") func emptyText() { #expect(KeyInjector.withLeadingSeparator("", after: "First.") == "") diff --git a/Tests/BlurtEngineTests/PersistedSettingsTests.swift b/Tests/BlurtEngineTests/PersistedSettingsTests.swift index 6ad38b45..19d1f822 100644 --- a/Tests/BlurtEngineTests/PersistedSettingsTests.swift +++ b/Tests/BlurtEngineTests/PersistedSettingsTests.swift @@ -41,16 +41,14 @@ struct PersistedSettingsTests { // The text shortcuts: left out, a reset would keep expanding the user's // phrases into their old snippets. #expect(PersistedSettings.allDefaultsKeys.contains(TextShortcutStore.defaultsKey)) - // Spoken punctuation: left out, a reset would keep stripping punctuation. - #expect(PersistedSettings.allDefaultsKeys.contains(SpokenPunctuationStore.defaultsKey)) } @Test("the roster carries no stale or duplicate keys") func rosterHasNoStrays() { - // Exactly the twelve known stores' keys (OverlayOriginStore contributes two, + // Exactly the eleven known stores' keys (OverlayOriginStore contributes two, // StyleProfileStore three): a removed store must leave the roster in the same // change, and a key listed twice would hint at a copy-paste slip. - #expect(PersistedSettings.allDefaultsKeys.count == 15) + #expect(PersistedSettings.allDefaultsKeys.count == 14) #expect(Set(PersistedSettings.allDefaultsKeys).count == PersistedSettings.allDefaultsKeys.count) } @@ -77,12 +75,11 @@ struct PersistedSettingsTests { LastUpdateCheckStore.defaultsKey, MicDeviceStore.defaultsKey, TextShortcutStore.defaultsKey, - SpokenPunctuationStore.defaultsKey, ] #expect(storeKeys == Set(DefaultsKey.allCases.map(\.key))) // No two stores sharing a slot — the Set above would have quietly absorbed a // collision, and two stores on one key means each overwrites the other. - #expect(storeKeys.count == 15) + #expect(storeKeys.count == 14) } @Test("resetAll clears every roster key and leaves unrelated ones alone") diff --git a/Tests/BlurtEngineTests/SpokenPunctuationTests.swift b/Tests/BlurtEngineTests/SpokenPunctuationTests.swift deleted file mode 100644 index 30d2a105..00000000 --- a/Tests/BlurtEngineTests/SpokenPunctuationTests.swift +++ /dev/null @@ -1,146 +0,0 @@ -import Foundation -import Testing - -@testable import BlurtEngine - -@Suite("SpokenPunctuationFormatter") -struct SpokenPunctuationFormatterTests { - private func format(_ text: String) -> String { SpokenPunctuationFormatter.format(text) } - - @Test("strips the service's punctuation and puts back only what was said") - func replacesPunctuation() { - #expect(format("Hello comma, how are you question mark?") == "Hello, how are you?") - #expect(format("Hello, how are you?") == "Hello how are you") - } - - /// The service capitalizes after the periods *it* inserts, which the strip - /// removes — so "How" is lowercased again, while a spoken period capitalizes. - @Test("recases around stripped and spoken sentence ends") - func recases() { - #expect(format("Hello comma. How are you.") == "Hello, how are you") - #expect(format("done period thanks") == "done. Thanks") - #expect(format("Fine. I agree. NASA called. iPhone ships.") == "Fine I agree NASA called iPhone ships") - } - - @Test("marks sit against their neighbours by kind") - func spacing() { - #expect(format("he said open quote hello close quote period") == "he said \"hello\".") - #expect(format("call me open paren maybe close paren") == "call me (maybe)") - #expect(format("a well hyphen known fact") == "a well-known fact") - #expect(format("wait dash what") == "wait—what") - #expect(format("list colon eggs semicolon milk") == "list: eggs; milk") - #expect(format("really exclamation point") == "really!") - } - - @Test("line breaks take the spaces around them and start a sentence") - func lineBreaks() { - #expect(format("Dear Sam comma new paragraph thanks for writing") == "Dear Sam,\n\nThanks for writing") - #expect(format("one new line two") == "one\nTwo") - } - - @Test("phrases match case-insensitively through the service's own punctuation") - func loosePhrases() { - #expect(format("Wow, Exclamation. Point!") == "Wow!") - #expect(format("eggs Semi-colon milk") == "eggs; milk") - #expect(format("one New-line two") == "one\nTwo") - } - - @Test("punctuation inside a word stays") - func keepsWordInternalMarks() { - #expect(format("Don't, it's well-known: 3.5% of 1,000 at 10:30.") == "Don't it's well-known 3.5% of 1,000 at 10:30") - #expect(format("See cal.com, \"now\".") == "See cal.com now") - } - - @Test("dashes, quotes and brackets are stripped, even inside a token") - func stripsEverywhere() { - #expect(format("yes—no (maybe) “sure” - fine…") == "yes no maybe sure fine") - } - - @Test("only whole words are punctuation words") - func wholeWords() { - #expect(format("commas and periods") == "commas and periods") - } - - @Test("nothing but punctuation formats to nothing; a lone spoken mark is itself") - func edges() { - #expect(format("") == "") - #expect(format("...") == "") - #expect(format("Period.") == ".") - } -} - -@Suite("SpokenPunctuationStore") -struct SpokenPunctuationStoreTests { - @Test("defaults to off when unset") - func defaultsToOff() { - #expect(!SpokenPunctuationStore(defaults: freshDefaults()).isEnabled) - } - - @Test("reads back the switch the Settings toggle writes") - func readsBackTheToggledSlot() { - let defaults = freshDefaults() - let store = SpokenPunctuationStore(defaults: defaults) - defaults.set(true, forKey: SpokenPunctuationStore.defaultsKey) - #expect(store.isEnabled) - defaults.set(false, forKey: SpokenPunctuationStore.defaultsKey) - #expect(!store.isEnabled) - } -} - -@Suite("DictationSession spoken punctuation", .timeLimit(.minutes(1))) -struct DictationSessionSpokenPunctuationTests { - @Test("with the mode on, the paste is formatted; the log and history keep the service's text") - func formatsBeforePaste() async { - let spy = StringListBox() - let fixture = makeSession( - mode: .transcript("Hello comma, world period."), spokenPunctuation: true, - onTranscriptDelivered: { text, _ in spy.append(text) }) - - await fixture.session.press() - await fixture.session.release() - await fixture.session.waitForIdle() - - #expect(await fixture.injector.inserted == ["Hello, world."]) - #expect(spy.values == ["Hello, world."]) - #expect(fixture.log.transcripts.map(\.text) == ["Hello comma, world period."]) - #expect(await fixture.session.recentDictations.spokenOldestFirst == ["Hello comma, world period."]) - } - - @Test("with the mode off, the transcript is pasted untouched") - func offLeavesTextAlone() async { - let fixture = makeSession(mode: .transcript("Hello comma, world.")) - - await fixture.session.press() - await fixture.session.release() - await fixture.session.waitForIdle() - - #expect(await fixture.injector.inserted == ["Hello comma, world."]) - } - - /// Formatting first means a shortcut's saved text keeps its own punctuation, - /// which the strip would otherwise remove. - @Test("shortcuts expand after formatting, keeping their own punctuation") - func shortcutsAfterFormatting() async { - let fixture = makeSession( - mode: .transcript("Sign off, comma. Signature."), - textShortcuts: [TextShortcut(trigger: "signature", expansion: "Thanks, Sam.")], - spokenPunctuation: true) - - await fixture.session.press() - await fixture.session.release() - await fixture.session.waitForIdle() - - #expect(await fixture.injector.inserted == ["Sign off, Thanks, Sam."]) - } - - @Test("a transcript that is only the service's punctuation pastes nothing") - func punctuationOnlyIdles() async { - let fixture = makeSession(mode: .transcript("..."), spokenPunctuation: true) - - await fixture.session.press() - await fixture.session.release() - await fixture.session.waitForIdle() - - #expect(await fixture.injector.inserted.isEmpty) - } -} diff --git a/Tests/BlurtEngineTests/Stubs/DictationSessionTestSupport.swift b/Tests/BlurtEngineTests/Stubs/DictationSessionTestSupport.swift index f46d7015..aee4b9da 100644 --- a/Tests/BlurtEngineTests/Stubs/DictationSessionTestSupport.swift +++ b/Tests/BlurtEngineTests/Stubs/DictationSessionTestSupport.swift @@ -27,7 +27,6 @@ func makeSession( frontmost: CapturedFocus? = nil, keyTerms: [String] = [], textShortcuts: [TextShortcut] = [], - spokenPunctuation: Bool = false, onTranscriptDelivered: (@Sendable (String, RecentDictations) -> Void)? = nil ) -> SessionFixture { let mic = StubMicCapture() @@ -41,7 +40,6 @@ func makeSession( // developer's own Settings list can't change what a test sends. keyTermsProvider: { keyTerms }, textShortcutsProvider: { textShortcuts }, - spokenPunctuationProvider: { spokenPunctuation }, onTranscriptDelivered: onTranscriptDelivered, seams: testSeams(field: field, frontmost: frontmost, log: log)) return SessionFixture( @@ -100,8 +98,7 @@ func makeSession( ) -> DictationSession { DictationSession( mic: mic, transcriber: transcriber, injector: injector, clock: clock, - keyTermsProvider: { keyTerms }, textShortcutsProvider: { [] }, spokenPunctuationProvider: { false }, - seams: seams) + keyTermsProvider: { keyTerms }, textShortcutsProvider: { [] }, seams: seams) } extension DictationSession.Seams {