8bd1a6caf8
SwiftUI app, the one-click way to use the local transcription pipeline — installs and runs with no separate manual steps. The Xcode build phase (scripts/embed-binaries.sh) builds audiotee (git submodule, pinned to a commit on our own Gitea fork) and transcriptor-ai + qwen_asr (sibling checkout, which itself vendors qwen-asr as a submodule), then embeds all three binaries in the app bundle. The AI model is the one thing still fetched at runtime, via ModelManager.swift, since it's multi-GB and doesn't belong baked into a build. Also implements: model download with real progress UI, capture source selection (system/mic/both), a Settings window for the transcript save location and a glossary file (biases ASR toward proper nouns/product names via qwen_asr's --prompt), and a live chat-bubble transcript view. See CLAUDE.md for the full architecture, the reasoning behind each decision, and a fairly long list of real bugs found via actual testing (not just code review) — TCC permission escalation through a subprocess tree, SwiftUI Form/Grid layout quirks, @State vs @AppStorage persistence, a prompt-leakage bug in the glossary feature, and more. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
272 lines
11 KiB
Swift
272 lines
11 KiB
Swift
//
|
|
// 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) }
|
|
}
|
|
}
|
|
}
|