// // ContentView.swift // transcriptor // // Proves the capture→VAD→ASR→SSE circuit end to end from a native UI. // audiotee, transcriptor-ai, and qwen_asr are embedded in the app bundle // (see scripts/embed-binaries.sh, the "Embed audiotee, transcriptor-ai, // qwen_asr" Run Script build phase). The AI model is the one thing still // fetched at runtime (see ModelManager.swift) — deliberately, not an // oversight, see CLAUDE.md "Goal". // import SwiftUI private enum BundledPaths { static var audioteeBinary: String { Bundle.main.url(forResource: "audiotee", withExtension: nil)!.path } static var transcriptorAiBinary: String { Bundle.main.url(forResource: "transcriptor-ai", withExtension: nil)!.path } static var qwenAsrBinary: String { Bundle.main.url(forResource: "qwen_asr", withExtension: nil)!.path } static let httpAddr = ":8420" static let sseURL = URL(string: "http://localhost:8420/events")! } struct ContentView: View { // Owned by AppDelegate, not created here, so it can be stopped from // applicationWillTerminate when the app quits — see AppDelegate.swift. let transcriptorProcess: TranscriptorProcess @State private var modelManager = ModelManager() // @AppStorage, not @State: without this, the picker silently reset to // .large on every app relaunch (its hardcoded default), discarding // whatever the user had picked last session — same persistence // treatment as transcriptSaveDirectory/glossaryFilePath below. // ModelChoice is RawRepresentable (String), which @AppStorage supports // directly, no separate string key needed. @AppStorage("selectedModel") private var selectedModel: ModelChoice = .large @State private var sseClient = SSEClient() @State private var errorMessage: String? // @AppStorage: same reasoning/bug as selectedModel above — these were // still @State until this fix and reset to both-on every relaunch, // silently discarding a "mic off" choice from a prior session. Caught // the same way: a relaunch-for-a-different-test lost this setting // mid-session and produced a confusing, uncontrolled test run. @AppStorage("captureSystem") private var captureSystem = true @AppStorage("captureMic") private var captureMic = true @AppStorage(TranscriptSaveLocation.defaultsKey) private var transcriptSaveDirectory = TranscriptSaveLocation.defaultDirectory.path @AppStorage(GlossaryFileLocation.defaultsKey) private var glossaryFilePath = GlossaryFileLocation.defaultPath.path private var controlsDisabled: Bool { transcriptorProcess.isRunning || modelManager.isDownloading } var body: some View { VStack(spacing: 0) { VStack(alignment: .leading, spacing: 8) { HStack { Picker("Model", selection: $selectedModel) { ForEach(ModelChoice.allCases) { choice in Text(choice.displayName).tag(choice) } } .frame(maxWidth: 260) .disabled(controlsDisabled) Text("Capture:") .foregroundStyle(.secondary) Toggle("System audio", isOn: $captureSystem) .disabled(controlsDisabled) Toggle("Microphone", isOn: $captureMic) .disabled(controlsDisabled) } HStack { Button(transcriptorProcess.isRunning ? "Stop" : "Start") { if transcriptorProcess.isRunning { stop() } else { Task { await start() } } } .disabled(modelManager.isDownloading || (!captureSystem && !captureMic)) Text(statusText) .foregroundStyle(.secondary) Spacer() if let errorMessage { Text(errorMessage) .foregroundStyle(.red) .lineLimit(1) } } } .padding() if modelManager.isDownloading { VStack(alignment: .leading, spacing: 4) { Text(modelManager.statusText) .font(.caption) .foregroundStyle(.secondary) ProgressView(value: modelManager.fileProgress) } .padding([.horizontal, .bottom]) } Divider() ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 10) { ForEach(sseClient.messages) { message in MessageBubble(message: message) .id(message.id) } } .padding() } .onChange(of: sseClient.messages.count) { if let last = sseClient.messages.last { withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } } } } } .frame(minWidth: 480, minHeight: 360) } private var statusText: String { if transcriptorProcess.isRunning { return "Running" } if modelManager.isDownloading { return "Downloading model…" } return "Stopped" } private func start() async { errorMessage = nil sseClient.clear() let modelDir: URL do { // No-ops immediately if already downloaded — see // ModelManager.isDownloaded. This is what makes "Start" a // single action regardless of whether the model needs // fetching first. modelDir = try await modelManager.ensureModel(selectedModel) } catch { errorMessage = "Model download failed: \(error)" return } let transcriptFilePath: String? do { transcriptFilePath = try makeTranscriptFilePath(startedAt: Date()) } catch { errorMessage = "Couldn't prepare transcript save location: \(error)" return } let config = TranscriptorProcess.Config( binaryPath: BundledPaths.transcriptorAiBinary, audioteeBinaryPath: BundledPaths.audioteeBinary, asrBinaryPath: BundledPaths.qwenAsrBinary, asrModelDir: modelDir.path, httpAddr: BundledPaths.httpAddr, transcriptFilePath: transcriptFilePath, captureSystem: captureSystem, captureMic: captureMic, glossary: readGlossaryFile() ) do { try transcriptorProcess.start(config) // transcriptor-ai's HTTP server starts early in its own // startup, but not instantly — give it a moment before // connecting rather than building retry logic for this // proof-of-circuit step. Task { try? await Task.sleep(for: .seconds(1)) sseClient.connect(to: BundledPaths.sseURL) } } catch { errorMessage = "\(error)" } } private func stop() { transcriptorProcess.stop() sseClient.disconnect() } /// Builds the transcript file path for a session that started at /// `startedAt`, creating the save directory if it doesn't exist yet. /// The filename is the start timestamp, not configurable separately — /// per spec, only the directory is a user setting. private func makeTranscriptFilePath(startedAt: Date) throws -> String { let directory = URL(fileURLWithPath: transcriptSaveDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" let filename = "\(formatter.string(from: startedAt)).txt" return directory.appendingPathComponent(filename).path } /// Reads the glossary file's content for use as qwen_asr's --prompt. /// Missing file or read error is not a failure — glossary is optional, /// so this silently returns nil rather than blocking Start. /// /// Wraps the raw comma-separated terms as "Preserve spelling: ..." — /// qwen_asr's own documented --prompt example format — instead of /// passing them as a bare list. Found via real testing: a bare list /// occasionally leaked verbatim into the transcript output (the model /// echoed the prompt instead of transcribing that segment's audio, /// e.g. a whole segment came back as literally the glossary terms). /// Framing it as an instruction rather than free-floating content is /// expected to reduce that risk — not proven eliminated, just /// following the tool's own recommended usage instead of deviating /// from it. The glossary *file* itself still holds only the bare /// terms (matches the Settings UI's help text and keeps the file /// simple to hand-edit) — this wrapping is applied here, not stored. private func readGlossaryFile() -> String? { guard let content = try? String(contentsOfFile: glossaryFilePath, encoding: .utf8) else { return nil } let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } return "Preserve spelling: \(trimmed)" } } #Preview { ContentView(transcriptorProcess: TranscriptorProcess()) } /// Renders a transcript message as a chat bubble — mic ("Me") on the /// right, system ("Them") on the left, matching the "me vs them" /// diarization framing the two-track capture design is built around (see /// transcriptor-ai/CLAUDE.md). Requested explicitly: the plain track-label /// prefix wasn't distinct enough to tell the two apart at a glance. private struct MessageBubble: View { let message: TranscriptMessage private var isMic: Bool { message.track == "mic" } var body: some View { HStack { if isMic { Spacer(minLength: 40) } VStack(alignment: isMic ? .trailing : .leading, spacing: 2) { Text(isMic ? "Me" : "Them") .font(.caption2) .foregroundStyle(.secondary) Text(message.text) .padding(.horizontal, 12) .padding(.vertical, 8) .background(isMic ? Color.accentColor.opacity(0.85) : Color.secondary.opacity(0.18)) .foregroundStyle(isMic ? Color.white : Color.primary) .clipShape(RoundedRectangle(cornerRadius: 14)) } .opacity(message.isFinal ? 1 : 0.6) if !isMic { Spacer(minLength: 40) } } } }