diff --git a/AGENTS.md b/AGENTS.md index ac4e6913..f5a6c03b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,12 @@ Four reflexes before you touch anything: | `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 +multipart framing, and `APIKeyValidator`. 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. + 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 the rebindable dictation trigger is home-grown (`CGEventTap` over a pure state machine), not a @@ -51,6 +57,9 @@ workflow and the _why_ behind the design; the engine's README covers the _what_ ## Repository map ```text +Sources/AssemblyAI/ the AssemblyAI API client library (README.md there): HTTPTransport + + ChunkedRequestBody, Dictation/ (wire types, multipart framing), + 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) Audio/ MicCapture (+meter) over CaptureSessionRecorder (the diff --git a/App/Blurt/Blurt.xcodeproj/project.pbxproj b/App/Blurt/Blurt.xcodeproj/project.pbxproj index 6bbdfd00..f6533e64 100644 --- a/App/Blurt/Blurt.xcodeproj/project.pbxproj +++ b/App/Blurt/Blurt.xcodeproj/project.pbxproj @@ -288,7 +288,7 @@ A0DEFAB5DF5E5C7A6146A559 /* juno-90-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = DA27B927EB6616D017D910CD /* juno-90-stop.m4a */; }; A13335FE97FA2E60A4F5CABC /* ReadyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 383E03AB60BA21AAD92655D5 /* ReadyView.swift */; }; A1C101543CA3972691DF5367 /* rom1a-30-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 7FC3A25550C39659E3E00A11 /* rom1a-30-start.m4a */; }; - A34A51EDE26BC24EB08A3900 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 3BE06CE59B6A662701C56CE5 /* Sparkle */; }; + A34A51EDE26BC24EB08A3900 /* AssemblyAI in Frameworks */ = {isa = PBXBuildFile; productRef = 07F3519DF8F73DA30C8E55EB /* AssemblyAI */; }; A3D012E25BD52949E2452B7B /* juno-17-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = F37D9EAC78D140EE0CD438F2 /* juno-17-start.m4a */; }; A487D7B801A44C51807E0DCB /* rom1a-28-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = AC2E79FB5ACE247FF90F50E4 /* rom1a-28-stop.m4a */; }; A5D51CD762229A68757463C8 /* CueSoundPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D222ED050CC93DEB330E773 /* CueSoundPlayer.swift */; }; @@ -379,6 +379,7 @@ D4782F83F43693371724011D /* rom1b-23-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = EAB0871FD8CB94A4DCF837CC /* rom1b-23-start.m4a */; }; D5927A391D26CAB212D6FBB4 /* rom1b-3-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 49CD6A6815EDDB16BBBCCCB2 /* rom1b-3-start.m4a */; }; D5A84975628548A076CEB3D7 /* juno-118-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 92387F60666853C0AA1BF0B5 /* juno-118-stop.m4a */; }; + D6DFF322638007525EBA02B2 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 3BE06CE59B6A662701C56CE5 /* Sparkle */; }; D750C1BF16502EEC92182ECD /* PermissionsStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8DE60178ABA2CC7F4EB6401 /* PermissionsStepView.swift */; }; D779D9FD904DA9DDC241DA5F /* juno-70-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = AD5F1BDEB1DB3658DDB96247 /* juno-70-start.m4a */; }; D7CCAEAF551027586E39A032 /* juno-4-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 63189CC345106B1D56960B1E /* juno-4-start.m4a */; }; @@ -912,7 +913,8 @@ buildActionMask = 2147483647; files = ( 0A7FB8A90BB7E7A5110A4F4D /* BlurtEngine in Frameworks */, - A34A51EDE26BC24EB08A3900 /* Sparkle in Frameworks */, + A34A51EDE26BC24EB08A3900 /* AssemblyAI in Frameworks */, + D6DFF322638007525EBA02B2 /* Sparkle in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1535,6 +1537,7 @@ name = Blurt; packageProductDependencies = ( 371215E93A9C2CE60C6347BB /* BlurtEngine */, + 07F3519DF8F73DA30C8E55EB /* AssemblyAI */, 3BE06CE59B6A662701C56CE5 /* Sparkle */, ); productName = Blurt; @@ -2481,6 +2484,10 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 07F3519DF8F73DA30C8E55EB /* AssemblyAI */ = { + isa = XCSwiftPackageProductDependency; + productName = AssemblyAI; + }; 371215E93A9C2CE60C6347BB /* BlurtEngine */ = { isa = XCSwiftPackageProductDependency; productName = BlurtEngine; diff --git a/App/Blurt/Blurt/APIKeyModel.swift b/App/Blurt/Blurt/APIKeyModel.swift index a77c7160..ad8253a1 100644 --- a/App/Blurt/Blurt/APIKeyModel.swift +++ b/App/Blurt/Blurt/APIKeyModel.swift @@ -1,3 +1,4 @@ +import AssemblyAI import BlurtEngine import Observation @@ -35,7 +36,7 @@ final class APIKeyModel { init( keyStore: any APIKeyGateway = ProductionAPIKeyStore(), validateKey: @escaping @Sendable (String) async -> APIKeyValidator.Result = { - await APIKeyValidator().validate($0) + await APIKeyValidator.blurt().validate($0) } ) { self.keyStore = keyStore diff --git a/App/Blurt/Blurt/UITestSupport.swift b/App/Blurt/Blurt/UITestSupport.swift index 22b3e4f4..6c304e2a 100644 --- a/App/Blurt/Blurt/UITestSupport.swift +++ b/App/Blurt/Blurt/UITestSupport.swift @@ -1,6 +1,7 @@ #if UITEST_HOOKS import AppKit + import AssemblyAI import BlurtEngine import Foundation import Observation diff --git a/App/Blurt/project.yml b/App/Blurt/project.yml index 293da363..833f2992 100644 --- a/App/Blurt/project.yml +++ b/App/Blurt/project.yml @@ -180,6 +180,9 @@ targets: dependencies: - package: BlurtEngine product: BlurtEngine + # The AssemblyAI SDK library (the key validator's types live there). + - package: BlurtEngine + product: AssemblyAI - package: Sparkle product: Sparkle postBuildScripts: diff --git a/App/BlurtiOS/BlurtiOSCore/APIKeyModel.swift b/App/BlurtiOS/BlurtiOSCore/APIKeyModel.swift index 0cb42974..2a3c9c52 100644 --- a/App/BlurtiOS/BlurtiOSCore/APIKeyModel.swift +++ b/App/BlurtiOS/BlurtiOSCore/APIKeyModel.swift @@ -1,3 +1,4 @@ +import AssemblyAI import BlurtEngine import Observation @@ -39,7 +40,7 @@ package final class APIKeyModel { package init( keyStore: any APIKeyGateway = ProductionAPIKeyStore(), validateKey: @escaping @Sendable (String) async -> APIKeyValidator.Result = { - await APIKeyValidator().validate($0) + await APIKeyValidator.blurt().validate($0) } ) { self.keyStore = keyStore diff --git a/App/BlurtiOS/project.yml b/App/BlurtiOS/project.yml index ce6162a3..4df8a7d0 100644 --- a/App/BlurtiOS/project.yml +++ b/App/BlurtiOS/project.yml @@ -72,6 +72,9 @@ targets: dependencies: - package: BlurtEngine product: BlurtEngine + # The AssemblyAI SDK library (the key validator's types live there). + - package: BlurtEngine + product: AssemblyAI BlurtiOS: type: application platform: iOS diff --git a/Package.swift b/Package.swift index 69b49e7a..cfdfff1d 100644 --- a/Package.swift +++ b/Package.swift @@ -10,11 +10,20 @@ let package = Package( // for both platforms. CI's `ios-build` job is what keeps that true. platforms: [.macOS(.v15), .iOS(.v18)], products: [ - .library(name: "BlurtEngine", targets: ["BlurtEngine"]) + .library(name: "BlurtEngine", targets: ["BlurtEngine"]), + // The AssemblyAI API client the engine is built on: transport, wire types, + // key validation. Foundation only, no Blurt policy, so it can become a + // standalone SDK. See Sources/AssemblyAI/README.md. + .library(name: "AssemblyAI", targets: ["AssemblyAI"]), ], targets: [ + .target( + name: "AssemblyAI", + exclude: ["README.md"] + ), .target( name: "BlurtEngine", + dependencies: ["AssemblyAI"], // The engine's developer guide lives next to the code it documents. SwiftPM // has no rule for a stray .md inside a target, so declare it excluded rather // than let it land in the target's unhandled-files list. @@ -22,7 +31,7 @@ let package = Package( ), .testTarget( name: "BlurtEngineTests", - dependencies: ["BlurtEngine"] + dependencies: ["BlurtEngine", "AssemblyAI"] ), ] ) diff --git a/Sources/BlurtEngine/Config/APIKeyValidator.swift b/Sources/AssemblyAI/APIKeyValidator.swift similarity index 84% rename from Sources/BlurtEngine/Config/APIKeyValidator.swift rename to Sources/AssemblyAI/APIKeyValidator.swift index 17aba1ed..87d90ab2 100644 --- a/Sources/BlurtEngine/Config/APIKeyValidator.swift +++ b/Sources/AssemblyAI/APIKeyValidator.swift @@ -24,13 +24,19 @@ public struct APIKeyValidator: Sendable { private let baseURL: URL private let transport: any HTTPTransport + private let userAgent: String? + /// - Parameter userAgent: identifies the calling app (Blurt passes + /// `Blurt/ (…)`), so a key this endpoint rejects can be traced to + /// the build that asked. Nil sends URLSession's default. public init( - baseURL: URL = URL(staticString: "https://api.assemblyai.com"), - transport: any HTTPTransport = URLSession.shared + baseURL: URL = URL(literal: "https://api.assemblyai.com"), + transport: any HTTPTransport = URLSession.shared, + userAgent: String? = nil ) { self.baseURL = baseURL self.transport = transport + self.userAgent = userAgent } public func validate(_ key: String) async -> Result { @@ -50,9 +56,9 @@ public struct APIKeyValidator: Sendable { // AssemblyAI expects the raw key in Authorization (no "Bearer" prefix), // matching AssemblyAITranscriber. request.setValue(trimmed, forHTTPHeaderField: "Authorization") - // `Blurt/`, the same agent the dictation requests carry, so a key - // this endpoint rejects can be traced to the build that asked. - request.setUserAgent() + // The caller's agent (Blurt sends the one its dictation requests carry), so + // a key this endpoint rejects can be traced to the build that asked. + if let userAgent { request.setValue(userAgent, forHTTPHeaderField: "User-Agent") } do { let (_, response) = try await transport.data(for: request) diff --git a/Sources/BlurtEngine/ChunkedRequestBody.swift b/Sources/AssemblyAI/ChunkedRequestBody.swift similarity index 97% rename from Sources/BlurtEngine/ChunkedRequestBody.swift rename to Sources/AssemblyAI/ChunkedRequestBody.swift index 410cf834..6b14aea7 100644 --- a/Sources/BlurtEngine/ChunkedRequestBody.swift +++ b/Sources/AssemblyAI/ChunkedRequestBody.swift @@ -45,7 +45,8 @@ final class ChunkedRequestBody: @unchecked Sendable { /// uplink, and it is already the low end and the tightest spread (276-286 ms). /// Nothing to win by retuning it; what it protects against is a *saturated* /// uplink, which that sweep cannot manufacture and so did not test. - private static let bufferSize = SyncSTTLimits.pcmBytes(forSeconds: 1) + /// One second of 16 kHz mono 16-bit PCM, the dictation API's audio format. + private static let bufferSize = 16_000 * 2 /// Backpressure re-check interval: starts here and doubles up to /// `maxSpaceRetry` while the pipe stays full, resetting after every write that @@ -133,7 +134,7 @@ final class ChunkedRequestBody: @unchecked Sendable { // `streamError`, so the fallback was the only value it ever produced. // Logged instead, on the rare chance a future OS does populate it. if let streamError = output.streamError { - AssemblyAITranscriber.log.error( + SDKLog.transport.error( "upload pipe write failed: \(streamError.localizedDescription, privacy: .public)") } throw ChunkedUploadError.bodyStreamClosed @@ -201,7 +202,8 @@ final class ChunkedRequestBody: @unchecked Sendable { } /// Failures specific to feeding a streamed request body. Wrapped in -/// `BlurtError.sttFailed` before reaching the UI, like `AssemblyAIError`. +/// the caller's own error before reaching a UI (Blurt wraps it in +/// `BlurtError.sttFailed`). enum ChunkedUploadError: Error, LocalizedError { /// The pipe closed before the whole body was written — `URLSession` gave up /// on the request while audio was still being produced. diff --git a/Sources/BlurtEngine/STT/DictationMultipart.swift b/Sources/AssemblyAI/Dictation/DictationMultipart.swift similarity index 91% rename from Sources/BlurtEngine/STT/DictationMultipart.swift rename to Sources/AssemblyAI/Dictation/DictationMultipart.swift index c9d0a0e6..d38507a4 100644 --- a/Sources/BlurtEngine/STT/DictationMultipart.swift +++ b/Sources/AssemblyAI/Dictation/DictationMultipart.swift @@ -18,11 +18,11 @@ import Foundation /// The order they go on the wire — config, audio header, frames, closing /// boundary — is `AssemblyAITranscriber.streamedBody`'s to explain, and it is /// the streaming route's requirement rather than a choice. -enum DictationMultipart { +public enum DictationMultipart { /// The `audio` part's framing — everything before the PCM bytes themselves, /// written once, straight after the `config` part, so the frames that follow /// are just audio. - static func audioPartHeader(boundary: String) -> Data { + public static func audioPartHeader(boundary: String) -> Data { framed( "--\(boundary)\r\n", "Content-Disposition: form-data; name=\"audio\"; filename=\"audio.pcm\"\r\n", @@ -40,7 +40,7 @@ enum DictationMultipart { /// headers, the JSON, and the CRLF terminating the part. Written before a /// single audio byte — see `streamedBody` for why, and why the service cannot /// start without it. - static func configHead(config: Data, boundary: String) -> Data { + public static func configHead(config: Data, boundary: String) -> Data { var head = framed( "--\(boundary)\r\n", "Content-Disposition: form-data; name=\"config\"\r\n", @@ -53,7 +53,7 @@ enum DictationMultipart { /// Everything after the last audio frame: the `audio` part's terminating CRLF /// and the closing boundary — all that stands between the last frame and the /// request completing, which is why it carries no work. - static func closingBoundary(boundary: String) -> Data { + public static func closingBoundary(boundary: String) -> Data { framed("\r\n", "--\(boundary)--\r\n") } } diff --git a/Sources/AssemblyAI/Dictation/DictationWireTypes.swift b/Sources/AssemblyAI/Dictation/DictationWireTypes.swift new file mode 100644 index 00000000..d4875504 --- /dev/null +++ b/Sources/AssemblyAI/Dictation/DictationWireTypes.swift @@ -0,0 +1,221 @@ +// The dictation API's JSON contract: the `config` part `AssemblyAITranscriber` +// encodes, and the success/error bodies it decodes. Split from +// `AssemblyAITranscriber.swift` to stay within the lint file-length budget — +// that file is the transport (multipart framing, timeouts, metrics), this one is +// the wire shape. Top-level and public: the +// transcriber that sends them lives in BlurtEngine, a separate module. +// +// Every field name here was swept against the live route on 2026-09-10, because +// the route validates its config **strictly**: an unrecognized key earns +// `400 invalid config part: : Extra inputs are not permitted`, exactly as a +// deliberately bogus one does. The reference says the opposite about this +// endpoint — "Over HTTP, unknown fields are forwarded to the transcription +// engine as-is" — but re-measured 2026-09-11, a config carrying +// `blurt_probe_bogus_key: 1` earns `400 invalid config part: +// blurt_probe_bogus_key: Extra inputs are not permitted`. +// +// **The docs are the source of truth for what to send, and that disagreement +// does not change what this file does**: every key below is one the reference +// documents, so nothing here depends on unknown fields being either forwarded or +// rejected. Keep it that way and the question stays academic. +// +// The corollary is the one that bites. The route *accepts* more names than the +// reference lists — `prompt`, `keyterms`, `word_boost` and `language_code` are +// all live, and the docs call the first three legacy aliases — so a 200 is not +// 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. +public struct DictationConfig: Encodable, Sendable { + public let sampleRate: Int + public let channels: Int + /// The contextual prompt: the text that preceded this utterance, oldest + /// first — the user's recent dictations, then the text before the cursor. + /// Steers *transcription* (continuity, spelling, mid-sentence continuation); + /// the cleanup rewrite is `llmInstruction`'s job. A **string**, and only ever a + /// string: the route rejects an array with + /// `stt_prompt: Input should be a valid string`. Empty means no prior text, + /// and `encode(to:)` then drops the key rather than sending `""`. Assembled + /// by `STTPrompt`, which is also where the 4096-scalar cap and the reason + /// this replaced `conversation_context` live. + /// + /// `prompt` is the same field under its other name — sending both earns + /// `provide only one of stt_prompt or prompt; they are the same field` — so + /// never add one alongside this. + public let sttPrompt: String + /// Keyterms prompting: the user's key terms as a flat array of strings, + /// biasing recognition toward those exact spellings. A sibling of + /// `stt_prompt`, not an alternative — the API takes both, for + /// different jobs (prior text versus a vocabulary list) — fitted by + /// `KeytermsBoost` to its own 2048-byte cap, which is a different number + /// from the 4096 scalars on the prompt. Empty asks for no boosting, and + /// `encode(to:)` then drops the key rather than sending `[]`. + /// + /// **`keyterms_prompt`, and only ever one name for it.** The route accepts + /// `keyterms` and `word_boost` as legacy aliases, and rejects any request + /// carrying two of the three: `400 provide only one of keyterms_prompt, + /// keyterms, or word_boost` (measured, 2026-09-10). So this is a swap, never + /// an addition — a compatibility shim that sent both names would 400 every + /// dictation. + public let keytermsPrompt: [String] + /// The cleanup instruction the server-side rewrite should apply + /// (`CleanupInstruction.sendable`, style profile and all), or nil to leave + /// the wording to the service. `encode(to:)` then drops the key. + /// + /// **Nil is not "no rewrite."** The route rewrites *by default*, which the + /// reference states outright — "Omitting the field, or setting it to + /// `null`, keeps the default cleanup task" — and which was measured the + /// same way on 2026-09-10: a config carrying neither `llm_instruction` nor + /// `llm` comes back with `llm_response` set to a **default-cleaned** + /// transcript. Omission selects the service's own wording; it does not + /// decline. + /// + /// **The route documents no way to decline at all**, and the escape it + /// prescribes is the one Blurt now takes: ignore `llm_response` and use + /// `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:)`). + /// + /// 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 + /// null), and the only off switch there is — but it appears **nowhere in the + /// reference**, and no documented spelling substitutes for it: `llm: {}`, + /// `llm: {"enabled": false}` and `llm_instruction: null` all run the default + /// cleanup, while invented names (`llm_enabled`, `disable_llm`) are rejected + /// as unknown keys. + /// + /// That it works is therefore not a reason to send it. **Blurt calls this + /// API only the way the docs describe it** — an undocumented field carries + /// no compatibility promise, so a switch built on one is a switch that can + /// stop being a switch without notice, silently pasting cleaned-up text to + /// 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. + public let llmInstruction: String? + + public init( + sampleRate: Int, channels: Int, sttPrompt: String, keytermsPrompt: [String], llmInstruction: String? + ) { + self.sampleRate = sampleRate + self.channels = channels + self.sttPrompt = sttPrompt + self.keytermsPrompt = keytermsPrompt + self.llmInstruction = llmInstruction + } + enum CodingKeys: String, CodingKey { + case sampleRate = "sample_rate" + case channels + case sttPrompt = "stt_prompt" + case keytermsPrompt = "keyterms_prompt" + case llmInstruction = "llm_instruction" + } + + /// Hand-written for the one thing synthesis can't express: an empty + /// `stt_prompt` or `keyterms_prompt` must be *absent*, not `""`/`[]`, and a + /// non-optional string or array always encodes. (`llmInstruction` would + /// omit-if-nil on its own; it is spelled out here so the whole wire shape + /// reads in one place.) A property added above and forgotten here never + /// reaches the wire — which is what the config assertions in the tests + /// catch. + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(sampleRate, forKey: .sampleRate) + try container.encode(channels, forKey: .channels) + if !sttPrompt.isEmpty { + try container.encode(sttPrompt, forKey: .sttPrompt) + } + if !keytermsPrompt.isEmpty { + try container.encode(keytermsPrompt, forKey: .keytermsPrompt) + } + try container.encodeIfPresent(llmInstruction, forKey: .llmInstruction) + } +} + +public struct DictationResponse: Decodable, Sendable { + /// The verbatim transcript — always present, never altered by the LLM. What + /// gets pasted with **enhanced transcripts** off, and the fallback when it + /// is on but the rewrite is unusable. + public let text: String + /// The rewritten transcript, or nil when the rewrite failed. Every request + /// asks for one, so — unlike before — nil here means failure rather than + /// possibly a declined rewrite. + public let llmResponse: String? + /// `"timeout"` or `"error"` when the rewrite failed, which is the only + /// reason `llm_response` can be null now that every request asks for one. + public let llmError: String? + /// The request identifier, which the reference says to "include it when + /// reporting problems" — so it is logged on every round trip + /// (`logServerMetrics`). Without it a user's report of a bad dictation + /// cannot be tied to the request that produced it. + public let sessionId: String? + /// The service's own duration for the audio it received. Worth having + /// beside the client's `audioMs`, which is computed from the bytes *sent*: + /// a disagreement means the upload was truncated, which no other signal + /// distinguishes from a user who simply stopped talking. + public let audioDurationMs: Double? + /// Total server-side processing time, and the transcription portion of it. + /// The pair localizes a slow dictation that the client's `postSpeechMs` + /// only reports the total of: `request_time_ms` minus `sync_time_ms` is + /// roughly what the rewrite cost, so a regression can be attributed to the + /// network, the STT upstream, or the LLM rather than guessed at. + public let requestTimeMs: Double? + public let syncTimeMs: Double? + /// **All optional except `text`, and deliberately so**, even though the + /// reference marks `session_id` and `audio_duration_ms` required. A + /// non-optional here turns a field the service stops sending into + /// `AssemblyAIError.malformedResponse` — a failed dictation, for a + /// diagnostic nobody was waiting on. `text` is the only field whose absence + /// means there is nothing to paste. + enum CodingKeys: String, CodingKey { + case text + case llmResponse = "llm_response" + case llmError = "llm_error" + case sessionId = "session_id" + case audioDurationMs = "audio_duration_ms" + case requestTimeMs = "request_time_ms" + case syncTimeMs = "sync_time_ms" + } +} + +/// A dictation API failure body. The reference documents exactly two shapes +/// and says to read both: `{error, error_code}` for most failures (400, 401, +/// 413, 429, 502, 503, 504) and `{status, title, detail}` for the ones relayed +/// from the transcription service — an invalid API key (404, *not* 401) and an +/// unsupported audio format (415). A non-string `detail` (a FastAPI-style +/// validation array) is ignored and the caller falls back to the raw body. +/// +/// **`error` is the field the common failures carry**, and reading it is not +/// optional dressing: 400 is the config-validation status, so a wrong field +/// name lands here as +/// `{"error": "invalid config part: : Extra inputs are not permitted", +/// "error_code": "bad_request"}`. This type consulted `message` and `detail` +/// until 2026-09-11, so every one of those fell through to the raw-body arm +/// and surfaced as JSON. +/// +/// **Exactly these two keys, because they are the two the reference +/// documents.** `message` was the first key read here and is in neither +/// documented shape; it was dropped rather than kept as a harmless fallback, +/// under the same rule that took `llm: null` off the request — Blurt calls +/// this API only the way the docs describe it, on both directions of the +/// wire. A body carrying some third spelling now reaches the user through the +/// raw-body arm below, which is the honest outcome: unrecognized, not silently +/// guessed at. +public struct ErrorResponse: Decodable, Sendable { + public let message: String? + + enum CodingKeys: String, CodingKey { + case error, detail + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + // `try? decode` already yields `String?` for a key that is missing, null, + // or the wrong type — `decodeIfPresent` would return `String??` here and + // need flattening back down. + func string(_ key: CodingKeys) -> String? { + try? container.decode(String.self, forKey: key) + } + message = string(.error) ?? string(.detail) + } +} diff --git a/Sources/BlurtEngine/HTTPTransport.swift b/Sources/AssemblyAI/HTTPTransport.swift similarity index 100% rename from Sources/BlurtEngine/HTTPTransport.swift rename to Sources/AssemblyAI/HTTPTransport.swift diff --git a/Sources/AssemblyAI/README.md b/Sources/AssemblyAI/README.md new file mode 100644 index 00000000..94d39105 --- /dev/null +++ b/Sources/AssemblyAI/README.md @@ -0,0 +1,18 @@ +# AssemblyAI + +The AssemblyAI API client that Blurt's engine is built on, kept free of Blurt policy so it can become +a standalone Swift SDK. Foundation only; macOS 15 and iOS 18. + +| Type | What it is | +| -------------------------------------- | ------------------------------------------------------------------------------------ | +| `HTTPTransport` | The network seam. `URLSession` conforms, including a streamed (chunked) upload body. | +| `DictationConfig`, `DictationResponse` | The dictation API's `config` part and response, with only documented keys. | +| `ErrorResponse` | The dictation API's two documented error shapes. | +| `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. | + +What it deliberately doesn't hold: which prompt, cleanup instruction or key terms to send, where the +key is stored, or the `User-Agent`. Those are the app's choices and live in `BlurtEngine` +(`AssemblyAITranscriber`, `APIKeyValidator.blurt()`). + +Tests run with the engine's: `swift test` from the repo root. diff --git a/Sources/AssemblyAI/Support.swift b/Sources/AssemblyAI/Support.swift new file mode 100644 index 00000000..9c1a7b06 --- /dev/null +++ b/Sources/AssemblyAI/Support.swift @@ -0,0 +1,28 @@ +import Foundation +import os + +/// The SDK's own log, apart from any app's. Only transport faults are logged. +enum SDKLog { + static let transport = Logger(subsystem: "com.assemblyai.sdk", category: "Transport") +} + +extension URL { + /// A URL from a literal known to be valid; the SDK's own copy of BlurtEngine's + /// helper, so neither module reaches into the other. `@usableFromInline` for + /// the reason the engine's copy gives: public inits' default arguments use it. + @usableFromInline + init(literal: StaticString) { + guard let url = URL(string: "\(literal)") else { + preconditionFailure("Invalid URL literal: \(literal)") + } + self = url + } +} + +extension String { + /// The string trimmed of surrounding whitespace, or nil when nothing is left. + func trimmedNonEmpty() -> String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/Sources/BlurtEngine/Config/APIKeySubmission.swift b/Sources/BlurtEngine/Config/APIKeySubmission.swift index a5c36212..c7dbb439 100644 --- a/Sources/BlurtEngine/Config/APIKeySubmission.swift +++ b/Sources/BlurtEngine/Config/APIKeySubmission.swift @@ -1,3 +1,4 @@ +import AssemblyAI /// The validate-then-save flow behind the setup/settings screen's Save/Update /// button. Owned in the engine — rather than in the app coordinator — so its /// central invariant is unit-tested: **an unverified key never persists.** A key diff --git a/Sources/BlurtEngine/Config/APIKeyValidator+Blurt.swift b/Sources/BlurtEngine/Config/APIKeyValidator+Blurt.swift new file mode 100644 index 00000000..9586cb64 --- /dev/null +++ b/Sources/BlurtEngine/Config/APIKeyValidator+Blurt.swift @@ -0,0 +1,12 @@ +import AssemblyAI +import Foundation + +extension APIKeyValidator { + /// AssemblyAI's key check as Blurt sends it: with `UserAgent.current`, the same + /// `Blurt/ (…)` agent the dictation requests carry, so a rejected key + /// can be traced to the build that asked. The validator itself lives in the + /// AssemblyAI SDK and knows nothing of Blurt; this is where the agent joins it. + public static func blurt(transport: any HTTPTransport = URLSession.shared) -> APIKeyValidator { + APIKeyValidator(transport: transport, userAgent: UserAgent.current) + } +} diff --git a/Sources/BlurtEngine/README.md b/Sources/BlurtEngine/README.md index be842a02..a0e6360f 100644 --- a/Sources/BlurtEngine/README.md +++ b/Sources/BlurtEngine/README.md @@ -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`) 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`), 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/ (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`), 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). 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 052de6a8..ebddcf0a 100644 --- a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift +++ b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift @@ -1,3 +1,4 @@ +import AssemblyAI import Foundation import os diff --git a/Sources/BlurtEngine/STT/DictationUploadMetrics.swift b/Sources/BlurtEngine/STT/DictationUploadMetrics.swift index 0a3032ec..2f9c581b 100644 --- a/Sources/BlurtEngine/STT/DictationUploadMetrics.swift +++ b/Sources/BlurtEngine/STT/DictationUploadMetrics.swift @@ -1,3 +1,4 @@ +import AssemblyAI import Foundation import Synchronization import os diff --git a/Sources/BlurtEngine/STT/DictationWireTypes.swift b/Sources/BlurtEngine/STT/DictationWireTypes.swift deleted file mode 100644 index 8f329518..00000000 --- a/Sources/BlurtEngine/STT/DictationWireTypes.swift +++ /dev/null @@ -1,213 +0,0 @@ -// The dictation API's JSON contract: the `config` part `AssemblyAITranscriber` -// encodes, and the success/error bodies it decodes. Split from -// `AssemblyAITranscriber.swift` to stay within the lint file-length budget — -// that file is the transport (multipart framing, timeouts, metrics), this one is -// the wire shape. Nested in the transcriber, and internal rather than private, -// only because Swift's `private` is file-scoped and cannot cross the split. -// -// Every field name here was swept against the live route on 2026-09-10, because -// the route validates its config **strictly**: an unrecognized key earns -// `400 invalid config part: : Extra inputs are not permitted`, exactly as a -// deliberately bogus one does. The reference says the opposite about this -// endpoint — "Over HTTP, unknown fields are forwarded to the transcription -// engine as-is" — but re-measured 2026-09-11, a config carrying -// `blurt_probe_bogus_key: 1` earns `400 invalid config part: -// blurt_probe_bogus_key: Extra inputs are not permitted`. -// -// **The docs are the source of truth for what to send, and that disagreement -// does not change what this file does**: every key below is one the reference -// documents, so nothing here depends on unknown fields being either forwarded or -// rejected. Keep it that way and the question stays academic. -// -// The corollary is the one that bites. The route *accepts* more names than the -// reference lists — `prompt`, `keyterms`, `word_boost` and `language_code` are -// all live, and the docs call the first three legacy aliases — so a 200 is not -// 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. -extension AssemblyAITranscriber { - struct DictationConfig: Encodable { - let sampleRate: Int - let channels: Int - /// The contextual prompt: the text that preceded this utterance, oldest - /// first — the user's recent dictations, then the text before the cursor. - /// Steers *transcription* (continuity, spelling, mid-sentence continuation); - /// the cleanup rewrite is `llmInstruction`'s job. A **string**, and only ever a - /// string: the route rejects an array with - /// `stt_prompt: Input should be a valid string`. Empty means no prior text, - /// and `encode(to:)` then drops the key rather than sending `""`. Assembled - /// by `STTPrompt`, which is also where the 4096-scalar cap and the reason - /// this replaced `conversation_context` live. - /// - /// `prompt` is the same field under its other name — sending both earns - /// `provide only one of stt_prompt or prompt; they are the same field` — so - /// never add one alongside this. - let sttPrompt: String - /// Keyterms prompting: the user's key terms as a flat array of strings, - /// biasing recognition toward those exact spellings. A sibling of - /// `stt_prompt`, not an alternative — the API takes both, for - /// different jobs (prior text versus a vocabulary list) — fitted by - /// `KeytermsBoost` to its own 2048-byte cap, which is a different number - /// from the 4096 scalars on the prompt. Empty asks for no boosting, and - /// `encode(to:)` then drops the key rather than sending `[]`. - /// - /// **`keyterms_prompt`, and only ever one name for it.** The route accepts - /// `keyterms` and `word_boost` as legacy aliases, and rejects any request - /// carrying two of the three: `400 provide only one of keyterms_prompt, - /// keyterms, or word_boost` (measured, 2026-09-10). So this is a swap, never - /// an addition — a compatibility shim that sent both names would 400 every - /// dictation. - let keytermsPrompt: [String] - /// The cleanup instruction the server-side rewrite should apply - /// (`CleanupInstruction.sendable`, style profile and all), or nil to leave - /// the wording to the service. `encode(to:)` then drops the key. - /// - /// **Nil is not "no rewrite."** The route rewrites *by default*, which the - /// reference states outright — "Omitting the field, or setting it to - /// `null`, keeps the default cleanup task" — and which was measured the - /// same way on 2026-09-10: a config carrying neither `llm_instruction` nor - /// `llm` comes back with `llm_response` set to a **default-cleaned** - /// transcript. Omission selects the service's own wording; it does not - /// decline. - /// - /// **The route documents no way to decline at all**, and the escape it - /// prescribes is the one Blurt now takes: ignore `llm_response` and use - /// `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:)`). - /// - /// 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 - /// null), and the only off switch there is — but it appears **nowhere in the - /// reference**, and no documented spelling substitutes for it: `llm: {}`, - /// `llm: {"enabled": false}` and `llm_instruction: null` all run the default - /// cleanup, while invented names (`llm_enabled`, `disable_llm`) are rejected - /// as unknown keys. - /// - /// That it works is therefore not a reason to send it. **Blurt calls this - /// API only the way the docs describe it** — an undocumented field carries - /// no compatibility promise, so a switch built on one is a switch that can - /// stop being a switch without notice, silently pasting cleaned-up text to - /// 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. - let llmInstruction: String? - enum CodingKeys: String, CodingKey { - case sampleRate = "sample_rate" - case channels - case sttPrompt = "stt_prompt" - case keytermsPrompt = "keyterms_prompt" - case llmInstruction = "llm_instruction" - } - - /// Hand-written for the one thing synthesis can't express: an empty - /// `stt_prompt` or `keyterms_prompt` must be *absent*, not `""`/`[]`, and a - /// non-optional string or array always encodes. (`llmInstruction` would - /// omit-if-nil on its own; it is spelled out here so the whole wire shape - /// reads in one place.) A property added above and forgotten here never - /// reaches the wire — which is what the config assertions in the tests - /// catch. - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(sampleRate, forKey: .sampleRate) - try container.encode(channels, forKey: .channels) - if !sttPrompt.isEmpty { - try container.encode(sttPrompt, forKey: .sttPrompt) - } - if !keytermsPrompt.isEmpty { - try container.encode(keytermsPrompt, forKey: .keytermsPrompt) - } - try container.encodeIfPresent(llmInstruction, forKey: .llmInstruction) - } - } - - struct DictationResponse: Decodable { - /// The verbatim transcript — always present, never altered by the LLM. What - /// gets pasted with **enhanced transcripts** off, and the fallback when it - /// is on but the rewrite is unusable. - let text: String - /// The rewritten transcript, or nil when the rewrite failed. Every request - /// asks for one, so — unlike before — nil here means failure rather than - /// possibly a declined rewrite. - let llmResponse: String? - /// `"timeout"` or `"error"` when the rewrite failed, which is the only - /// reason `llm_response` can be null now that every request asks for one. - let llmError: String? - /// The request identifier, which the reference says to "include it when - /// reporting problems" — so it is logged on every round trip - /// (`logServerMetrics`). Without it a user's report of a bad dictation - /// cannot be tied to the request that produced it. - let sessionId: String? - /// The service's own duration for the audio it received. Worth having - /// beside the client's `audioMs`, which is computed from the bytes *sent*: - /// a disagreement means the upload was truncated, which no other signal - /// distinguishes from a user who simply stopped talking. - let audioDurationMs: Double? - /// Total server-side processing time, and the transcription portion of it. - /// The pair localizes a slow dictation that the client's `postSpeechMs` - /// only reports the total of: `request_time_ms` minus `sync_time_ms` is - /// roughly what the rewrite cost, so a regression can be attributed to the - /// network, the STT upstream, or the LLM rather than guessed at. - let requestTimeMs: Double? - let syncTimeMs: Double? - /// **All optional except `text`, and deliberately so**, even though the - /// reference marks `session_id` and `audio_duration_ms` required. A - /// non-optional here turns a field the service stops sending into - /// `AssemblyAIError.malformedResponse` — a failed dictation, for a - /// diagnostic nobody was waiting on. `text` is the only field whose absence - /// means there is nothing to paste. - enum CodingKeys: String, CodingKey { - case text - case llmResponse = "llm_response" - case llmError = "llm_error" - case sessionId = "session_id" - case audioDurationMs = "audio_duration_ms" - case requestTimeMs = "request_time_ms" - case syncTimeMs = "sync_time_ms" - } - } - - /// A dictation API failure body. The reference documents exactly two shapes - /// and says to read both: `{error, error_code}` for most failures (400, 401, - /// 413, 429, 502, 503, 504) and `{status, title, detail}` for the ones relayed - /// from the transcription service — an invalid API key (404, *not* 401) and an - /// unsupported audio format (415). A non-string `detail` (a FastAPI-style - /// validation array) is ignored and the caller falls back to the raw body. - /// - /// **`error` is the field the common failures carry**, and reading it is not - /// optional dressing: 400 is the config-validation status, so a wrong field - /// name lands here as - /// `{"error": "invalid config part: : Extra inputs are not permitted", - /// "error_code": "bad_request"}`. This type consulted `message` and `detail` - /// until 2026-09-11, so every one of those fell through to the raw-body arm - /// and surfaced as JSON. - /// - /// **Exactly these two keys, because they are the two the reference - /// documents.** `message` was the first key read here and is in neither - /// documented shape; it was dropped rather than kept as a harmless fallback, - /// under the same rule that took `llm: null` off the request — Blurt calls - /// this API only the way the docs describe it, on both directions of the - /// wire. A body carrying some third spelling now reaches the user through the - /// raw-body arm below, which is the honest outcome: unrecognized, not silently - /// guessed at. - struct ErrorResponse: Decodable { - let message: String? - - enum CodingKeys: String, CodingKey { - case error, detail - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - // `try? decode` already yields `String?` for a key that is missing, null, - // or the wrong type — `decodeIfPresent` would return `String??` here and - // need flattening back down. - func string(_ key: CodingKeys) -> String? { - try? container.decode(String.self, forKey: key) - } - message = string(.error) ?? string(.detail) - } - } -} diff --git a/Sources/BlurtEngine/STT/SyncSTTLimits.swift b/Sources/BlurtEngine/STT/SyncSTTLimits.swift index a5d6c89f..940604dd 100644 --- a/Sources/BlurtEngine/STT/SyncSTTLimits.swift +++ b/Sources/BlurtEngine/STT/SyncSTTLimits.swift @@ -52,14 +52,6 @@ public enum SyncSTTLimits { /// `DictationSession` applies to the blob `MicCaptureProtocol.stop()` returns. public static let minPCMBytes = minSamples * bytesPerSample - /// Raw S16LE bytes that `seconds` of audio occupies at `sampleRate` — the - /// inverse of `durationMs(ofPCMBytes:)`, and mono for the same reason (see - /// `channelCount`). Here rather than at the call site so the upload pipe's - /// buffer doesn't hand-roll the seconds-to-bytes conversion this type owns. - static func pcmBytes(forSeconds seconds: Double) -> Int { - Int(seconds * Double(sampleRate)) * bytesPerSample - } - /// Milliseconds of audio a raw S16LE byte count represents at `rate`. Both the /// capture side (`MicCapture.stop`) and the upload side /// (`AssemblyAITranscriber.transcribe`) log the clip's duration, and those logs diff --git a/Sources/BlurtEngine/TTS/ReadAloudLLM.swift b/Sources/BlurtEngine/TTS/ReadAloudLLM.swift index bc6eb9bc..fb8d7e40 100644 --- a/Sources/BlurtEngine/TTS/ReadAloudLLM.swift +++ b/Sources/BlurtEngine/TTS/ReadAloudLLM.swift @@ -1,3 +1,4 @@ +import AssemblyAI import Foundation /// Read-aloud's two LLM jobs, each one `POST /v1/chat/completions` to diff --git a/Tests/BlurtEngineTests/APIKeySubmissionTests.swift b/Tests/BlurtEngineTests/APIKeySubmissionTests.swift index 47f9aa4c..9fa98177 100644 --- a/Tests/BlurtEngineTests/APIKeySubmissionTests.swift +++ b/Tests/BlurtEngineTests/APIKeySubmissionTests.swift @@ -1,5 +1,6 @@ import Testing +@testable import AssemblyAI @testable import BlurtEngine /// The validate-then-save flow behind the Save/Update button. The invariant diff --git a/Tests/BlurtEngineTests/APIKeyValidatorTests.swift b/Tests/BlurtEngineTests/APIKeyValidatorTests.swift index 57c00209..8fc55614 100644 --- a/Tests/BlurtEngineTests/APIKeyValidatorTests.swift +++ b/Tests/BlurtEngineTests/APIKeyValidatorTests.swift @@ -1,6 +1,8 @@ import Foundation +import Synchronization import Testing +@testable import AssemblyAI @testable import BlurtEngine // The `APIKeyValidator` half of the `HTTPClientTests` suite. Kept on the same @@ -120,9 +122,21 @@ extension HTTPClientTests { #expect(hits.value == 0) } + @Test("the SDK's validator sends only the agent it's given; Blurt's comes from .blurt()") + func validatorAgentIsTheCallers() async { + let agents = Mutex<[String?]>([]) + let transport = FakeHTTPTransport { request in + agents.withLock { $0.append(request.value(forHTTPHeaderField: "User-Agent")) } + return (200, json(["page_number": "1"])) + } + #expect(await APIKeyValidator(transport: transport).validate("k") == .valid) + #expect(await APIKeyValidator(transport: transport, userAgent: "MyApp/1.0").validate("k") == .valid) + #expect(agents.withLock { $0 } == [nil, "MyApp/1.0"]) + } + // MARK: - helpers private func makeValidator(_ transport: any HTTPTransport) -> APIKeyValidator { - APIKeyValidator(transport: transport) + APIKeyValidator.blurt(transport: transport) } } diff --git a/Tests/BlurtEngineTests/AssemblyAISpeechSynthesizerTests.swift b/Tests/BlurtEngineTests/AssemblyAISpeechSynthesizerTests.swift index 0b38650e..52d5f125 100644 --- a/Tests/BlurtEngineTests/AssemblyAISpeechSynthesizerTests.swift +++ b/Tests/BlurtEngineTests/AssemblyAISpeechSynthesizerTests.swift @@ -2,6 +2,7 @@ import Foundation import Synchronization import Testing +@testable import AssemblyAI @testable import BlurtEngine @Suite("AssemblyAISpeechSynthesizer wire format") diff --git a/Tests/BlurtEngineTests/AssemblyAITranscriberMultipartTests.swift b/Tests/BlurtEngineTests/AssemblyAITranscriberMultipartTests.swift index ceae91e9..000299c1 100644 --- a/Tests/BlurtEngineTests/AssemblyAITranscriberMultipartTests.swift +++ b/Tests/BlurtEngineTests/AssemblyAITranscriberMultipartTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing +@testable import AssemblyAI @testable import BlurtEngine /// The wire framing of the chunked dictation upload: the three pieces of the diff --git a/Tests/BlurtEngineTests/AssemblyAITranscriberTests.swift b/Tests/BlurtEngineTests/AssemblyAITranscriberTests.swift index 858ced63..9d19f410 100644 --- a/Tests/BlurtEngineTests/AssemblyAITranscriberTests.swift +++ b/Tests/BlurtEngineTests/AssemblyAITranscriberTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing +@testable import AssemblyAI @testable import BlurtEngine /// Tests for the HTTP-backed API clients. The `AssemblyAITranscriber` cases live diff --git a/Tests/BlurtEngineTests/ChunkedRequestBodyTests.swift b/Tests/BlurtEngineTests/ChunkedRequestBodyTests.swift index 4d6906a9..8399dc6c 100644 --- a/Tests/BlurtEngineTests/ChunkedRequestBodyTests.swift +++ b/Tests/BlurtEngineTests/ChunkedRequestBodyTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing +@testable import AssemblyAI @testable import BlurtEngine /// The upload's transport half: the bound-pair body pipe, the send policy that diff --git a/Tests/BlurtEngineTests/KeytermsWireTests.swift b/Tests/BlurtEngineTests/KeytermsWireTests.swift index 7df9477a..e8672e41 100644 --- a/Tests/BlurtEngineTests/KeytermsWireTests.swift +++ b/Tests/BlurtEngineTests/KeytermsWireTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing +@testable import AssemblyAI @testable import BlurtEngine /// The two steering fields of the dictation `config`, as they actually encode: @@ -150,7 +151,7 @@ extension HTTPClientTests { /// One config carrying `llmInstruction` and nothing else optional, re-parsed. private func rewriteConfig(_ instruction: String?) throws -> [String: Any] { let data = try JSONEncoder().encode( - AssemblyAITranscriber.DictationConfig( + DictationConfig( sampleRate: 16_000, channels: 1, sttPrompt: "", keytermsPrompt: [], llmInstruction: instruction)) return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) diff --git a/Tests/BlurtEngineTests/ReadAloudLLMTests.swift b/Tests/BlurtEngineTests/ReadAloudLLMTests.swift index 4f9696a8..3805a6d5 100644 --- a/Tests/BlurtEngineTests/ReadAloudLLMTests.swift +++ b/Tests/BlurtEngineTests/ReadAloudLLMTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing +@testable import AssemblyAI @testable import BlurtEngine @Suite("ReadAloudLLM") diff --git a/Tests/BlurtEngineTests/Stubs/AsyncStreamFixtures.swift b/Tests/BlurtEngineTests/Stubs/AsyncStreamFixtures.swift index ae5fdc11..2e129772 100644 --- a/Tests/BlurtEngineTests/Stubs/AsyncStreamFixtures.swift +++ b/Tests/BlurtEngineTests/Stubs/AsyncStreamFixtures.swift @@ -1,5 +1,6 @@ import Foundation +@testable import AssemblyAI @testable import BlurtEngine extension AsyncStream where Element: Sendable { diff --git a/Tests/BlurtEngineTests/Stubs/FakeHTTPTransport.swift b/Tests/BlurtEngineTests/Stubs/FakeHTTPTransport.swift index 1190e81d..fca65623 100644 --- a/Tests/BlurtEngineTests/Stubs/FakeHTTPTransport.swift +++ b/Tests/BlurtEngineTests/Stubs/FakeHTTPTransport.swift @@ -1,6 +1,7 @@ import Foundation import Synchronization +@testable import AssemblyAI @testable import BlurtEngine /// Per-instance `HTTPTransport` fake for the HTTP-client suites: answers each diff --git a/Tests/BlurtEngineTests/Stubs/TranscriberTestSupport.swift b/Tests/BlurtEngineTests/Stubs/TranscriberTestSupport.swift index 5d202071..044fd598 100644 --- a/Tests/BlurtEngineTests/Stubs/TranscriberTestSupport.swift +++ b/Tests/BlurtEngineTests/Stubs/TranscriberTestSupport.swift @@ -1,5 +1,6 @@ import Foundation +@testable import AssemblyAI @testable import BlurtEngine /// Scaffolding shared by the transcriber suites, in `Stubs/` for the same reason diff --git a/scripts/ios-typecheck.sh b/scripts/ios-typecheck.sh index f610c254..e4134c8d 100755 --- a/scripts/ios-typecheck.sh +++ b/scripts/ios-typecheck.sh @@ -34,9 +34,15 @@ COMMON=( -swift-version 6 -parse-as-library ) +# The AssemblyAI SDK first: the engine imports it. +echo "AssemblyAI → $TARGET" +find "$REPO_ROOT/Sources/AssemblyAI" -name '*.swift' -print0 \ + | xargs -0 xcrun swiftc "${COMMON[@]}" -module-name AssemblyAI \ + -emit-module -emit-module-path "$WORK/AssemblyAI.swiftmodule" + echo "BlurtEngine → $TARGET" find "$REPO_ROOT/Sources/BlurtEngine" -name '*.swift' -print0 \ - | xargs -0 xcrun swiftc "${COMMON[@]}" -module-name BlurtEngine \ + | xargs -0 xcrun swiftc "${COMMON[@]}" -I "$WORK" -module-name BlurtEngine \ -emit-module -emit-module-path "$WORK/BlurtEngine.swiftmodule" IOS="$REPO_ROOT/App/BlurtiOS" diff --git a/scripts/mutate.sh b/scripts/mutate.sh index aa203eba..d0f02d8c 100755 --- a/scripts/mutate.sh +++ b/scripts/mutate.sh @@ -46,7 +46,7 @@ cd "$REPO_ROOT" # (nothing exercises those lines) that drown the real signal. DEFAULT_TARGETS=( "Sources/BlurtEngine/Config/APIKeyDisplay.swift" - "Sources/BlurtEngine/Config/APIKeyValidator.swift" + "Sources/AssemblyAI/APIKeyValidator.swift" "Sources/BlurtEngine/FocusCapture/FocusCapture+Pure.swift" "Sources/BlurtEngine/Hotkey/DictationKeyGate.swift" "Sources/BlurtEngine/Hotkey/DictationKeyRouter.swift"