Skip to content
Open
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
19 changes: 13 additions & 6 deletions .claude/skills/project-guardrails/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,17 +63,24 @@ genuinely correct, and reaching for it means it's time to stop and ask.
that is about the **upload**: the service starts inferring as the audio
arrives, and still answers with one final transcript. No deltas, no partial
results, no WebSocket — don't read the route name as permission to add them.
There is no socket in the tree: read-aloud's TTS client
(`TTS/AssemblyAISpeechSynthesizer.swift`) is a streaming HTTP response, and
it speaks text, never transcribes, so it is not a door back to streaming STT.
The one socket in the tree is the main window's Record button
(`AssemblyAI/Realtime/RealtimeTranscriber.swift`, the only file the gate
exempts): a meeting or call transcribed live, partials and all. It never
carries a dictation, so it is not a door back to streaming dictation.
Read-aloud's TTS client (`TTS/AssemblyAISpeechSynthesizer.swift`) is a
streaming HTTP response, and it speaks text, never transcribes.
- **No separate LLM cleanup pass.** Cleanup rides in the same dictation request,
as `config.llm_instruction` (`CleanupInstruction`). No LLM Gateway
client for dictation, no `StylerProtocol`, no post-transcription styling
stage. The one gateway client in the tree is the read-aloud
feature's (`TTS/ReadAloudLLM.swift`, the only file `check-invariants.sh`
stage. The first gateway client in the tree is the read-aloud
feature's (`TTS/ReadAloudLLM.swift`, one of two files `check-invariants.sh`
exempts from this rule): the skip-jargon listening rewrite of a selection, and
the spoken answer to a request about one. It never cleans up a dictation, so
it is not a door back to a cleanup pass.
it is not a door back to a cleanup pass. The second is file transcription's
(`STT/TranscriptTransform.swift`, also exempt): summary, key points and the
other stock transforms over a saved transcript. It is never in the
dictation path and nothing it writes is pasted: it runs only on a history
entry the user opens and asks for a task, past dictations included.
- **No local models / model downloads.** Transcription is a remote AssemblyAI
call. No on-device ASR/LLM, no model cache, no download UI.
- **There is no `config.conversation_context`.** `config.stt_prompt`
Expand Down
61 changes: 33 additions & 28 deletions AGENTS.md

Large diffs are not rendered by default.

88 changes: 84 additions & 4 deletions App/Blurt/Blurt.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

32 changes: 17 additions & 15 deletions App/Blurt/Blurt/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,18 +42,6 @@ final class AppCoordinator {
@ObservationIgnored private let transcriptStream: AsyncStream<RecentDictations>
@ObservationIgnored private var transcriptObserver: Task<Void, Never>?

/// The dictations that produced a transcript — pasted, copied, or even
/// failed-to-paste (the seam fires before injection) — newest first, the first
/// `displayCapacity` of them listed in the ready window's "Recent" section
/// beneath the shortcut readout. In-memory only — starts empty each launch and
/// is never written to disk.
///
/// A **projection**, not a second ring: `DictationSession` owns the history
/// (it builds each request's `conversation_context` from it, inside the actor)
/// and pushes the updated value here, so this can't drift from what was
/// actually sent. Nothing outside the session records into it.
private(set) var recentDictations = RecentDictations()

/// Live dictation status for the menu bar indicator (see `MenuBarLabel`).
/// Updated in `render(_:)` alongside the overlay pill. The menu bar item is a
/// convenience layered on the Dock app and can be hidden behind the notch on a
Expand All @@ -70,6 +58,9 @@ final class AppCoordinator {
/// What reads aloud and how the selection is read, when not the real ones:
/// UI tests run the whole read-aloud path offline. Nil in production.
@ObservationIgnored private let readAloudSeams: ReadAloudSeams?
/// The saved history — every dictation, dropped or browsed-for file and
/// recording — and the transcript sheet's state for whichever is open.
let transcripts: TranscriptsModel

/// `components` defaults to the production pipeline, its transcriber dictating
/// against the account's `dictationOrigin`; `apiKey` defaults to the
Expand All @@ -82,7 +73,8 @@ final class AppCoordinator {
components: DictationComponents? = nil,
apiKey: APIKeyModel = APIKeyModel(),
account: AssemblyAIAccount? = nil,
readAloud: ReadAloudSeams? = nil
readAloud: ReadAloudSeams? = nil,
transcripts: TranscriptsModel? = nil
) {
self.onSetupBlocked = onSetupBlocked
self.readAloudSeams = readAloud
Expand Down Expand Up @@ -110,6 +102,8 @@ final class AppCoordinator {
self.transcriptStream = transcriptStream

self.mic = components.mic
self.transcripts = transcripts ?? TranscriptsModel()
self.transcripts.setAutoRecordMeetings(MeetingAutoRecordStore().isEnabled)
self.session = DictationSession(
mic: components.mic,
transcriber: components.transcriber,
Expand Down Expand Up @@ -170,7 +164,13 @@ final class AppCoordinator {
let router =
readAloudSeams.map {
SelectionSpeechRouter(session: session, speaker: $0.speaker, readSelection: $0.readSelection)
} ?? SelectionSpeechRouter(session: session)
}
?? SelectionSpeechRouter(
session: session,
// Every read is kept, findable and playable from the Recent list.
clips: ReadClipSink(directory: TranscriptStore.readAloudDirectory) { [weak self] clip in
Task { @MainActor in self?.transcripts.saveReadClip(clip) }
})
keyTap = DictationKeyTap(
// The key rides the press, and the session resolves its style for that one
// dictation (`PressStyle`); the main window's selection is never written.
Expand Down Expand Up @@ -219,7 +219,9 @@ final class AppCoordinator {
private func startPipelineObservers() {
phaseObserver = observePhases()
levelsObserver = observe(mic.levels) { $0.overlay?.pushLevel($1) }
transcriptObserver = observe(transcriptStream) { $0.recentDictations = $1 }
// `DictationSession` owns the ring of recent dictations (its request
// context); each entry it gains is saved to the history.
transcriptObserver = observe(transcriptStream) { $0.transcripts.saveDictations(from: $1) }
}

private func observePhases() -> Task<Void, Never> {
Expand Down
9 changes: 8 additions & 1 deletion App/Blurt/Blurt/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// what it's asked to read instead of streaming TTS.
readAloud: ReadAloudSeams(
speaker: UITestReadAloudSpeaker(),
readSelection: { [selection = UITestIdentifiers.cannedSelection] in selection }))
readSelection: { [selection = UITestIdentifiers.cannedSelection] in selection }),
// A fresh history per run — never the user's saved transcripts —
// seeded with known entries when the suite asks, and the canned
// transport behind transcription and the LLM.
transcripts: .uiTest(store: UITestMode.historyStore()))
if ProcessInfo.processInfo.arguments.contains(UITestIdentifiers.transcribeSampleLaunchArgument) {
coord.transcripts.transcribe(UITestHistorySeed.sampleFile())
}
} else {
coord = AppCoordinator(onSetupBlocked: onSetupBlocked)
}
Expand Down
2 changes: 2 additions & 0 deletions App/Blurt/Blurt/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
<string>public.app-category.productivity</string>
<key>LSMinimumSystemVersion</key>
<string>15.0</string>
<key>NSAudioCaptureUsageDescription</key>
<string>Blurt records your Mac's audio alongside your microphone so it can transcribe meetings and calls with AssemblyAI.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Blurt uses your microphone to transcribe your dictation with AssemblyAI.</string>
<key>SUEnableAutomaticChecks</key>
Expand Down
113 changes: 113 additions & 0 deletions App/Blurt/Blurt/UITestSupport+Transcripts.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
#if UITEST_HOOKS

import AssemblyAI
import BlurtEngine
import Foundation

// UI-test doubles for the transcription half of the app: a canned network
// transport standing in for the async API and the LLM Gateway, a seeded
// history, and a sample file to transcribe. Same gate as the rest of the
// hooks (`UITEST_HOOKS`), so none of it ships.

/// Answers the async API and the gateway with fixed results, offline: any
/// file transcribes to a two-speaker exchange, a title request returns
/// `cannedFileTitle`, and any other task returns `cannedTaskResult`.
nonisolated struct UITestTranscriptTransport: HTTPTransport {
// Handed in on the main actor at creation (`uiTest(store:)`): the app
// target's constants are main-actor, and this answers off it.
let speakerA: String
let speakerB: String
let title: String
let taskResult: String

nonisolated func data(for request: URLRequest) async throws -> (Data, URLResponse) {
let path = request.url?.path ?? ""
let body: Any =
switch path {
case "/v2/upload": ["upload_url": "https://uitest.invalid/audio"]
case "/v2/transcript": ["id": "uitest", "status": "queued"]
case let path where path.hasPrefix("/v2/transcript/"):
[
"id": "uitest", "status": "completed",
"text": "\(speakerA) \(speakerB)",
"utterances": [["speaker": "A", "text": speakerA], ["speaker": "B", "text": speakerB]],
]
default:
// The gateway: a title request says so in its instruction.
[
"choices": [
[
"message": [
"content": Self.isTitleRequest(request) ? title : taskResult
],
"finish_reason": "stop",
]
]
]
}
let data = try JSONSerialization.data(withJSONObject: body)
let response = HTTPURLResponse(
url: request.url ?? URL(filePath: "/"), statusCode: 200, httpVersion: nil, headerFields: nil)
return (data, response ?? URLResponse())
}

nonisolated func upload(
for request: URLRequest, streaming body: AsyncThrowingStream<Data, any Error>,
delegate: (any URLSessionTaskDelegate)?
) async throws -> (Data, URLResponse) {
throw URLError(.unsupportedURL)
}

nonisolated private static func isTitleRequest(_ request: URLRequest) -> Bool {
guard let body = request.httpBody, let text = String(data: body, encoding: .utf8) else { return false }
return text.contains("short, specific title")
}
}

extension TranscriptsModel {
/// The model over `store`, with the canned transport behind both the
/// async API and the gateway.
static func uiTest(store: TranscriptStore) -> TranscriptsModel {
let transport = UITestTranscriptTransport(
speakerA: UITestIdentifiers.cannedFileSpeakerA, speakerB: UITestIdentifiers.cannedFileSpeakerB,
title: UITestIdentifiers.cannedFileTitle, taskResult: UITestIdentifiers.cannedTaskResult)
let key = UITestIdentifiers.validAPIKey
return TranscriptsModel(
apiKey: { key },
transcriber: FileTranscriber(
client: AsyncTranscriptionClient(transport: transport, pollInterval: .zero), apiKeyProvider: { key }),
llm: TranscriptLLM(apiKeyProvider: { key }, transport: transport),
store: store)
}
}

/// The known history a seeded launch starts with, newest first.
enum UITestHistorySeed {
static func seed(_ store: TranscriptStore) {
let now = Date.now
let entries = [
SavedTranscript(
kind: .recording, date: now.addingTimeInterval(-60),
text:
"Speaker A: \(UITestIdentifiers.seededRecordingSpeakerA)\n\nSpeaker B: \(UITestIdentifiers.seededRecordingSpeakerB)",
fileURL: sampleFile(),
plainText: "\(UITestIdentifiers.seededRecordingSpeakerA) \(UITestIdentifiers.seededRecordingSpeakerB)",
generatedTitle: UITestIdentifiers.seededRecordingTitle),
SavedTranscript(
kind: .dictation, date: now.addingTimeInterval(-120), text: UITestIdentifiers.seededDictationText),
SavedTranscript(
kind: .readAloud, date: now.addingTimeInterval(-180), text: UITestIdentifiers.seededReadText,
fileURL: sampleFile()),
]
try? store.save(entries)
}

/// A small file to stand for audio: the canned transport never reads it.
static func sampleFile() -> URL {
let url = FileManager.default.temporaryDirectory.appending(path: "blurt-uitest-sample.m4a")
if !FileManager.default.fileExists(atPath: url.path) { try? Data("uitest".utf8).write(to: url) }
return url
}
}

#endif
17 changes: 14 additions & 3 deletions App/Blurt/Blurt/UITestSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@
static var isReadyStateRequested: Bool {
ProcessInfo.processInfo.arguments.contains(UITestIdentifiers.readyLaunchArgument)
}

/// A throwaway history for this run, seeded when the runner asks
/// (`seedHistoryLaunchArgument`).
static func historyStore() -> TranscriptStore {
let store = TranscriptStore(
url: FileManager.default.temporaryDirectory.appending(path: "blurt-uitest-\(UUID().uuidString).json"))
if ProcessInfo.processInfo.arguments.contains(UITestIdentifiers.seedHistoryLaunchArgument) {
UITestHistorySeed.seed(store)
}
return store
}
}

// Accessibility identifiers, the launch argument, and the sentinel API keys
Expand Down Expand Up @@ -298,12 +309,12 @@
.accessibilityIdentifier(UITestIdentifiers.sentStyleLabel)
}

// Mirrors the newest entry of `AppCoordinator.recentDictations` (the
// ready-window "Recent" list) so a test can watch it populate on a
// Mirrors the newest dictation in the saved history (the ready
// window's "Recent" list) so a test can watch it populate on a
// completed dictation. Placeholder "—" stands in for the empty list so
// the read-out is a stable element to assert against.
LabeledContent("Echo") {
Text(coordinator?.recentDictations.entries.first?.text ?? "—")
Text(coordinator?.transcripts.history.first { $0.kind == .dictation }?.text ?? "—")
.accessibilityIdentifier(UITestIdentifiers.transcriptEchoLabel)
}
}
Expand Down
8 changes: 8 additions & 0 deletions App/Blurt/Blurt/Wizard/BlurtCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import SwiftUI
/// `Settings` scene itself (see `BlurtApp`).
struct BlurtCommands: Commands {
var appDelegate: AppDelegate
@FocusedValue(\.focusHistorySearch) private var focusHistorySearch

var body: some Commands {
// "Check for Updates…" sits just below "About Blurt" in the app menu — the
Expand All @@ -22,6 +23,13 @@ struct BlurtCommands: Commands {
// reading as an ad. No ellipses: they open a page and ask nothing more, the
// way Apple's own Help-menu links read. Each omits itself if its URL fails
// to build.
// Edit › Find (⌘F): focuses the Recent list's search field, as any Mac
// window with a search field answers to.
CommandGroup(after: .textEditing) {
Button("Find") { focusHistorySearch?() }
.keyboardShortcut("f")
.disabled(focusHistorySearch == nil)
}
CommandGroup(replacing: .help) {
if let url = BlurtLinks.reportBug {
Link("Report a Bug", destination: url)
Expand Down
45 changes: 45 additions & 0 deletions App/Blurt/Blurt/Wizard/ClipPlayer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import AVFoundation
import Foundation
import Observation

/// Plays one saved clip at a time from the history — a read-aloud clip, a
/// recording, or a file — and says which is playing, so a row and the sheet
/// can show Play or Stop for it. Starting another clip stops the first.
@Observable
final class ClipPlayer: NSObject, AVAudioPlayerDelegate {
/// The history entry whose audio is playing now; nil when nothing is. An
/// id, not the file's URL: a row asking "is that me?" then needn't resolve
/// its bookmark to find out.
private(set) var playingID: UUID?
@ObservationIgnored private var player: AVAudioPlayer?

/// Plays `url` for entry `id`, or stops it if that entry is playing. A file
/// that can't be opened (moved, deleted) simply doesn't play.
func toggle(_ url: URL, id: UUID) {
if playingID == id {
stop()
return
}
stop()
guard let player = try? AVAudioPlayer(contentsOf: url) else { return }
player.delegate = self
guard player.play() else { return }
self.player = player
playingID = id
}

func stop() {
player?.stop()
player = nil
playingID = nil
}

nonisolated func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
// Compared by identity on the main actor; the player itself never crosses.
let finished = ObjectIdentifier(player)
Task { @MainActor in
guard let current = self.player, ObjectIdentifier(current) == finished else { return }
self.stop()
}
}
}
Loading
Loading