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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
11 changes: 9 additions & 2 deletions App/Blurt/Blurt.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -1535,6 +1537,7 @@
name = Blurt;
packageProductDependencies = (
371215E93A9C2CE60C6347BB /* BlurtEngine */,
07F3519DF8F73DA30C8E55EB /* AssemblyAI */,
3BE06CE59B6A662701C56CE5 /* Sparkle */,
);
productName = Blurt;
Expand Down Expand Up @@ -2481,6 +2484,10 @@
/* End XCRemoteSwiftPackageReference section */

/* Begin XCSwiftPackageProductDependency section */
07F3519DF8F73DA30C8E55EB /* AssemblyAI */ = {
isa = XCSwiftPackageProductDependency;
productName = AssemblyAI;
};
371215E93A9C2CE60C6347BB /* BlurtEngine */ = {
isa = XCSwiftPackageProductDependency;
productName = BlurtEngine;
Expand Down
3 changes: 2 additions & 1 deletion App/Blurt/Blurt/APIKeyModel.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AssemblyAI
import BlurtEngine
import Observation

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions App/Blurt/Blurt/UITestSupport.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#if UITEST_HOOKS

import AppKit
import AssemblyAI
import BlurtEngine
import Foundation
import Observation
Expand Down
3 changes: 3 additions & 0 deletions App/Blurt/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion App/BlurtiOS/BlurtiOSCore/APIKeyModel.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AssemblyAI
import BlurtEngine
import Observation

Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions App/BlurtiOS/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,28 @@ 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.
exclude: ["README.md"]
),
.testTarget(
name: "BlurtEngineTests",
dependencies: ["BlurtEngine"]
dependencies: ["BlurtEngine", "AssemblyAI"]
),
]
)
Original file line number Diff line number Diff line change
Expand Up @@ -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/<version> (…)`), 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 {
Expand All @@ -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/<version>`, 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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")
}
}
Loading
Loading