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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1017,6 +1017,10 @@ Engine-side stores, all `UserDefaults`-backed value types with the same shape:
**`EnhancedTranscriptsStore`** (`BlurtEnhancedTranscripts`, **on** by default — unset reads as
enabled; picks the cleanup rewrite over the verbatim transcript in the dictation _response_,
re-read at every request — it shapes no part of the request),
**`RewriteModeStore`** (`BlurtRewriteMode`, `RewriteMode.fast` by default — unset or unknown
reads as Fast; Smart sends `config.rewrite_mode: "smart"` for the larger rewrite model, Fast
omits the key so the default request is unchanged, and nothing is sent while enhanced
transcripts are off; re-read at every request),
**`StyleProfileStore`** (three keys: `BlurtStyleProfiles`, the JSON list of up to
`profileLimit` (4) named `StyleProfile`s; `BlurtActiveStyleProfile`, the id of the one in
effect, or the `defaultStyleID` sentinel when the user has selected **Default** — base
Expand Down
23 changes: 23 additions & 0 deletions App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,16 +109,39 @@ private struct TranscriptionSection: View {
@AppStorage(EnhancedTranscriptsStore.defaultsKey)
private var enhancedTranscripts = EnhancedTranscriptsStore.defaultValue

// "" is "nothing persisted"; the unset default belongs to
// `RewriteMode.fromPersisted`, as `HotkeyStepView`'s activation slot does.
@AppStorage(RewriteModeStore.defaultsKey) private var rewriteModeRaw = ""

private var rewriteMode: Binding<RewriteMode> {
Binding(
get: { RewriteMode.fromPersisted(rewriteModeRaw) },
// Through the store, which owns the encoding (see `HotkeyStepView`).
set: { RewriteModeStore().mode = $0 })
}

var body: some View {
Section {
Toggle(isOn: $enhancedTranscripts) {
SettingLabel(title: "Enhanced transcripts", systemImage: "wand.and.stars")
}
.accessibilityIdentifier(UITestIdentifiers.enhancedTranscriptsToggle)
// Picks the model for the rewrite the switch above pastes, so it is
// disabled with it, as the styles below are.
PickerSettingRow(
title: "Dictation mode", systemImage: "speedometer",
accessibilityID: UITestIdentifiers.rewriteModePicker, selection: rewriteMode
) {
ForEach(RewriteMode.allCases, id: \.self) { mode in
Text(mode.label).tag(mode)
}
}
.disabled(!enhancedTranscripts)
} footer: {
Text(
"Polishes each dictation before pasting — removing filler words and fixing punctuation. "
+ "Turn off to paste your words exactly as spoken.")
Text(RewriteMode.guidance)
}
}
}
Expand Down
1 change: 1 addition & 0 deletions App/Blurt/Shared/UITestIdentifiers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ enum UITestIdentifiers {
static let developerToggle = "settings.developer.toggle"
static let developerShowLogs = "settings.developer.showLogs"
static let enhancedTranscriptsToggle = "settings.enhancedTranscripts.toggle"
static let rewriteModePicker = "settings.rewriteMode.picker"
static let selectionSpeechToggle = "settings.selectionSpeech.toggle"
static let selectionAskToggle = "settings.selectionAsk.toggle"
static let readAloudSpeedPicker = "settings.readAloud.speed"
Expand Down
11 changes: 10 additions & 1 deletion Sources/AssemblyAI/Dictation/DictationWireTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,22 +93,30 @@ public struct DictationConfig: Encodable, Sendable {
/// Adding an `llm` case back would reintroduce it *and* leave the response's
/// choice unreachable — read `transcript(from:)` 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?

public init(
sampleRate: Int, channels: Int, sttPrompt: String, keytermsPrompt: [String], llmInstruction: String?
sampleRate: Int, channels: Int, sttPrompt: String, keytermsPrompt: [String], llmInstruction: String?,
rewriteMode: String? = nil
) {
self.sampleRate = sampleRate
self.channels = channels
self.sttPrompt = sttPrompt
self.keytermsPrompt = keytermsPrompt
self.llmInstruction = llmInstruction
self.rewriteMode = rewriteMode
}
enum CodingKeys: String, CodingKey {
case sampleRate = "sample_rate"
case channels
case sttPrompt = "stt_prompt"
case keytermsPrompt = "keyterms_prompt"
case llmInstruction = "llm_instruction"
case rewriteMode = "rewrite_mode"
}

/// Hand-written for the one thing synthesis can't express: an empty
Expand All @@ -129,6 +137,7 @@ public struct DictationConfig: Encodable, Sendable {
try container.encode(keytermsPrompt, forKey: .keytermsPrompt)
}
try container.encodeIfPresent(llmInstruction, forKey: .llmInstruction)
try container.encodeIfPresent(rewriteMode, forKey: .rewriteMode)
}
}

Expand Down
2 changes: 2 additions & 0 deletions Sources/BlurtEngine/Config/DefaultsKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ enum DefaultsKey: String, CaseIterable {
case keyTerms = "KeyTerms"
case developerMode = "DeveloperMode"
case enhancedTranscripts = "EnhancedTranscripts"
/// Which model the cleanup rewrite runs on (`RewriteModeStore`).
case rewriteMode = "RewriteMode"
/// The pre-profiles single custom-style field. `StyleProfileStore` still
/// *reads* it — an install that predates profiles has its text here and no
/// list — so the case stays, and its raw value with it.
Expand Down
63 changes: 63 additions & 0 deletions Sources/BlurtEngine/Config/RewriteModeStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Foundation

/// Which model the dictation API's server-side cleanup rewrite runs on — the
/// request's `rewrite_mode` option. Fast is the model every request has used so
/// far and the unset default; Smart opts in to a larger one that rewrites better
/// and takes longer. The raw value is what `RewriteModeStore` persists, so it
/// follows `DefaultsKey`'s rule: rename a case freely, its raw value never.
public enum RewriteMode: String, CaseIterable, Sendable {
/// The shipped behavior, and the unset default (`fromPersisted`).
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:)`).
case smart = "Smart"

/// Decodes a persisted raw value, falling back to Fast for unset ("") or
/// unknown values. The single decode-with-default rule shared by
/// `RewriteModeStore` and the `@AppStorage` view that reads the raw slot.
public static func fromPersisted(_ raw: String) -> RewriteMode {
RewriteMode(rawValue: raw) ?? .fast
}

/// Menu-picker title.
public var label: String {
switch self {
case .fast: return "Fast"
case .smart: return "Smart"
}
}

/// The Settings footer sentence under the picker.
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
/// byte-identical to the one that shipped before this setting existed.
var wireValue: String? {
switch self {
case .fast: return nil
case .smart: return "smart"
}
}
}

/// Persists the chosen `RewriteMode` as its raw value in `UserDefaults`. Reads
/// as Fast when unset or when the stored value isn't a known mode.
/// `AssemblyAITranscriber` reads it at each request, so a change applies to the
/// very next dictation. Same shape as `TriggerActivationStore`.
public struct RewriteModeStore {
/// UserDefaults key holding the mode. Public so SwiftUI views can observe it
/// directly (e.g. `@AppStorage`) and re-render on change.
public static var defaultsKey: String { DefaultsKey.rewriteMode.key }
private let defaults: UserDefaults

public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}

public var mode: RewriteMode {
get { RewriteMode.fromPersisted(defaults.string(forKey: Self.defaultsKey) ?? "") }
nonmutating set { defaults.set(newValue.rawValue, forKey: Self.defaultsKey) }
}
}
2 changes: 1 addition & 1 deletion Sources/BlurtEngine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<short version> (macOS <major>.<minor>)` 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`), and a `customStyle` closure supplying the style instructions appended to the cleanup instruction (nil, the default, reads the active profile from `StyleProfileStore`). `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/<short version> (macOS <major>.<minor>)` 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).

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.

Expand Down
13 changes: 10 additions & 3 deletions Sources/BlurtEngine/STT/AssemblyAITranscriber.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ public struct AssemblyAITranscriber: TranscriberProtocol {
let transport: any HTTPTransport
private let enhancedTranscriptsEnabled: @Sendable () -> Bool
private let customStyle: @Sendable () -> String?
private let rewriteMode: @Sendable () -> RewriteMode

/// Idle timeout for the transcribe round trip — `URLRequest.timeoutInterval` is
/// reset each time data moves, so this bounds *stalls*, not total elapsed time.
Expand All @@ -75,7 +76,8 @@ public struct AssemblyAITranscriber: TranscriberProtocol {
/// the route returns gets pasted — it no longer shapes the request, which
/// always asks for the rewrite; `customStyle` supplies the *active* style
/// profile's instructions, appended to the cleanup instruction — one
/// profile's text, never a join of several (see `StyleProfileStore`). Both are
/// profile's text, never a join of several (see `StyleProfileStore`);
/// `rewriteMode` picks the rewrite's model (`RewriteModeStore`). All three are
/// read at every `transcribe` so a settings change applies to the next
/// dictation without rebuilding the transcriber. `nil` (the default) reads
/// the corresponding store — spelled as optionals rather than default
Expand All @@ -86,13 +88,15 @@ public struct AssemblyAITranscriber: TranscriberProtocol {
baseURL: URL = URL(staticString: "https://dictation.assemblyai.com"),
transport: any HTTPTransport = URLSession.shared,
enhancedTranscripts: (@Sendable () -> Bool)? = nil,
customStyle: (@Sendable () -> String?)? = nil
customStyle: (@Sendable () -> String?)? = nil,
rewriteMode: (@Sendable () -> RewriteMode)? = nil
) {
self.apiKeyProvider = apiKeyProvider
self.baseURL = baseURL
self.transport = transport
self.enhancedTranscriptsEnabled = enhancedTranscripts ?? { EnhancedTranscriptsStore().isEnabled }
self.customStyle = customStyle ?? { StyleProfileStore().activeInstructions }
self.rewriteMode = rewriteMode ?? { RewriteModeStore().mode }
}

// MARK: - Dictation request
Expand Down Expand Up @@ -261,7 +265,10 @@ public struct AssemblyAITranscriber: TranscriberProtocol {
channels: 1,
sttPrompt: sttPrompt,
keytermsPrompt: keytermsPrompt,
llmInstruction: cleanupInstruction()
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
)
)
}
Expand Down
3 changes: 2 additions & 1 deletion Tests/BlurtEngineTests/KeytermsWireTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,8 @@ extension HTTPClientTests {
apiKeyProvider: { "test-key" },
transport: FakeHTTPTransport { _ in (500, Data()) },
enhancedTranscripts: { true },
customStyle: { nil }
customStyle: { nil },
rewriteMode: { .fast }
)
.makeConfigData(sampleRate: 16_000, sttPrompt: prompt, keytermsPrompt: keyterms)
return try #require(JSONSerialization.jsonObject(with: config) as? [String: Any])
Expand Down
Loading
Loading