Files
transcriptor/transcriptor/ContentView.swift
T
sttlab-tech 8bd1a6caf8 initial commit: native macOS app for transcriptor-ai
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>
2026-08-09 13:26:29 +02:00

272 lines
11 KiB
Swift

//
// ContentView.swift
// transcriptor
//
// Proves the captureVADASRSSE 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) }
}
}
}