From 8bd1a6caf8aa6d066b4b148c9faf4131b9bd6236 Mon Sep 17 00:00:00 2001 From: sttlab-tech Date: Sun, 9 Aug 2026 13:26:29 +0200 Subject: [PATCH] initial commit: native macOS app for transcriptor-ai MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 12 + .gitmodules | 3 + CLAUDE.md | 512 ++++++++++++++ scripts/embed-binaries.sh | 40 ++ third_party/audiotee | 1 + transcriptor.xcodeproj/project.pbxproj | 631 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + transcriptor/AppDelegate.swift | 22 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 85 +++ transcriptor/Assets.xcassets/Contents.json | 6 + transcriptor/ContentView.swift | 271 ++++++++ transcriptor/Info.plist | 34 + transcriptor/ModelManager.swift | 176 +++++ transcriptor/SSEClient.swift | 58 ++ transcriptor/SettingsView.swift | 156 +++++ transcriptor/TranscriptMessage.swift | 54 ++ transcriptor/TranscriptorProcess.swift | 90 +++ transcriptor/en.lproj/InfoPlist.strings | 1 + transcriptor/transcriptorApp.swift | 22 + transcriptorTests/transcriptorTests.swift | 18 + transcriptorUITests/transcriptorUITests.swift | 43 ++ .../transcriptorUITestsLaunchTests.swift | 35 + 23 files changed, 2288 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 CLAUDE.md create mode 100755 scripts/embed-binaries.sh create mode 160000 third_party/audiotee create mode 100644 transcriptor.xcodeproj/project.pbxproj create mode 100644 transcriptor.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 transcriptor/AppDelegate.swift create mode 100644 transcriptor/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 transcriptor/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 transcriptor/Assets.xcassets/Contents.json create mode 100644 transcriptor/ContentView.swift create mode 100644 transcriptor/Info.plist create mode 100644 transcriptor/ModelManager.swift create mode 100644 transcriptor/SSEClient.swift create mode 100644 transcriptor/SettingsView.swift create mode 100644 transcriptor/TranscriptMessage.swift create mode 100644 transcriptor/TranscriptorProcess.swift create mode 100644 transcriptor/en.lproj/InfoPlist.strings create mode 100644 transcriptor/transcriptorApp.swift create mode 100644 transcriptorTests/transcriptorTests.swift create mode 100644 transcriptorUITests/transcriptorUITests.swift create mode 100644 transcriptorUITests/transcriptorUITestsLaunchTests.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d74a45 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.DS_Store +xcuserdata/ +*.xcuserstate +DerivedData/ +/build/ +*.dSYM +*.dSYM.zip + +# Manual ASR quality test fixtures (downloaded video + subtitles used to +# compute WER against transcriptor-ai) — large binary media, not source. +# See CLAUDE.md for what's in here and the results obtained from it. +/test/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..ce3d7e5 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "third_party/audiotee"] + path = third_party/audiotee + url = ssh://git@gitea.sttlab.eu:2222/stt/audiotee.git diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f02d8e0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,512 @@ +# transcriptor-ui + +Native macOS SwiftUI app — the "one-click" way to actually use the local meeting +transcription project. This file is for working on the code: decisions, reasoning, +conventions, current status. User-facing install/usage docs belong in `README.md` once the +app exists (see `../transcriptor-ai`'s and `../audiotee`'s READMEs for the pattern this repo +should follow — don't mix the two kinds of doc in this file). + +Design/decision history lives in a long chat transcript, not in this repo yet. If something +here seems arbitrary, assume there was a reason discussed elsewhere rather than +re-litigating it from scratch — but flag it if it looks wrong given the current code. + +## Goal + +**"One-click" means installation too, not just usage** — this was an explicit correction +from the user, not an assumption to relax. The end state: someone downloads/builds this one +`.app`, double-clicks it, and it works — no separate manual steps to build or locate +`audiotee`, `qwen_asr`, or `transcriptor-ai` first. The **only** thing fetched at runtime is +the AI model (multi-GB, deliberately not bundled — same reasoning as why Ollama/LM Studio +don't ship models inside their app bundle either). + +Feature scope for the UI itself: +- Configure a few options at launch — which ASR model to use, to start. +- Display the live transcript (SSE client, same protocol `transcript-tail` already uses). +- Save the transcript to a text file, live, so a crash loses as little as possible. + **Already implemented on the `transcriptor-ai` side**, not here — see "Architecture" below, + this was a deliberate decision, don't revisit without reason. +- Future (not now, don't build preemptively): configuring a per-meeting glossary; a + post-processing UI for LLM-based features the user described — detecting a + user-supplied list of questions being asked/answered during the meeting and logging the + answers, and generating a meeting summary/CR. These are real intended features, not + speculative — but out of scope until the core "one-click live transcript" app works. + +## Target hardware + +Same as `transcriptor-ai`: **MacBook (base) M3, 24 GB unified memory** is the deployment +target, not the M4 Max/128GB dev machine. Carries over here mainly for any future +model-size/UI-responsiveness decisions (e.g. don't assume headroom for running something +heavy inside the UI process itself). + +## Architecture + +``` +transcriptor-ui.app (SwiftUI) + ├─ Contents/Resources/audiotee (embedded, prebuilt+signed — see below) + ├─ Contents/Resources/qwen_asr (embedded, prebuilt — see below) + ├─ Contents/Resources/transcriptor-ai (embedded, prebuilt — see below) + │ + ├─ spawns transcriptor-ai as a subprocess (same pattern as + │ transcriptor-ai → audiotee: each layer manages the one below it via a + │ subprocess + a clean protocol, never merged into one project/toolchain) + │ + ├─ SSE client of transcriptor-ai's live transcript endpoint (same protocol + │ cmd/transcript-tail in transcriptor-ai already implements — port from + │ that Go code, don't redesign the parsing from scratch) + │ + └─ on first run (or whenever a selected model is missing locally): downloads + the model, with progress UI — the only runtime fetch, everything else is + bundled at build time +``` + +**Deliberately not a monorepo** with `transcriptor-ai` or `audiotee` — each stays its own +repo with its own native toolchain (Go, Swift-CLI, Swift-app respectively). Mixing Go and +Swift build steps into one Xcode project would undermine the reason Go was chosen for +`transcriptor-ai` in the first place (see that repo's CLAUDE.md, "Why these choices"). Xcode +build phases (Run Script) reach into the sibling repos instead — a standard pattern, not +exotic, for embedding externally-built dependencies. + +### Why these choices (don't re-decide without reason) + +- **Transcript file writing lives in `transcriptor-ai`, not here.** Decided explicitly by the + user: `transcriptor-ai` is more crash-resistant than a UI process (simpler control flow), + and the whole point of that file is surviving a crash — so the more crash-resistant process + should own it. `transcriptor-ai` already implements this via `-transcript-file` (see its + `internal/transcript/writer.go`); this app just needs to pass that flag with a path when it + spawns the subprocess, not write the file itself. +- **SSE, not WebSocket, for the live transcript** — inherited from `transcriptor-ai`'s + decision (one-directional flow). A native app connects to SSE the same way a browser's + `EventSource` or `transcript-tail`'s manual line-parsing does — no special client library + needed in Swift either (a `URLSession` data task reading line-by-line works, same parsing + logic as `transcript-tail`). +- **Binaries embedded at build time, not referenced by a fixed external path.** Rejected the + simpler "just spawn `~/bin/audiotee` and a hardcoded `qwen_asr` path" approach — that's not + "one-click install," it requires the user to have separately built and placed those first. + Real cost of the embedded approach: build phases in Xcode need to invoke `go build`, `make`, + and Swift's own build across three different toolchains, and the resulting `.app` needs + correct nested code-signing (see "Known open risk"). +- **Models are the one runtime dependency, deliberately.** Multi-GB downloads don't belong + baked into a build artifact — same reasoning `transcriptor-ai`'s own README already + documents for why it doesn't try to bundle them either. + +### Nested code-signing — resolved (2026-08-08), verified empirically + +Was an open risk; now answered by actually building and inspecting the result, not just +reasoning about it. Xcode's default app-signing pass (no `--deep`) does **not** touch nested +pre-signed executables sitting in `Contents/Resources/` — copying `audiotee` in with a plain +`cp` and letting Xcode sign the outer `.app` normally works cleanly: +- `codesign -dvvv transcriptor.app/Contents/Resources/audiotee` still shows + `Authority=sttlab-apps` after the outer app is built and signed — untouched. +- `codesign --verify --verbose transcriptor.app` → "valid on disk", "satisfies its Designated + Requirement". +- `qwen_asr` and `transcriptor-ai` (ad-hoc signed by their own toolchains, Go/clang) sit in + Resources/ too and run fine — they never call Core Audio themselves, so they don't need a + real identity, just to be executable at all, which ad-hoc signing satisfies locally. + +No `--deep`, no re-signing step, no special handling needed. If this ever needs revisiting +(e.g. after adding Hardened Runtime, or targeting notarization/distribution — neither planned +now), re-verify rather than assume this still holds. + +## Dependencies (what this repo's build reaches into) + +Implemented: `scripts/embed-binaries.sh`, invoked by a "Run Script" build phase named "Embed +audiotee, transcriptor-ai, qwen_asr" (last phase on the `transcriptor` target, after +Resources). It: +- `../audiotee` — runs `scripts/build-signed.sh` there, then copies the resulting + `~/bin/audiotee` in. Doesn't reimplement audiotee's signing logic, just invokes it. +- `../transcriptor-ai` — runs `scripts/build-dist.sh` there (builds both `transcriptor-ai` + and `qwen_asr`, the latter from a pinned git submodule, `third_party/qwen-asr`, inside that + repo, into `dist/`), then copies `dist/*` in. This repo still doesn't need to know about + qwen-asr directly — that stays `transcriptor-ai`'s dependency to manage. +- Copies all three into `Contents/Resources/` in the built app, `chmod +x`'d. +- Prepends `/opt/homebrew/bin` to `PATH` at the top of the script — Xcode Run Script phases + don't inherit a normal shell's PATH, so `go` (used by `transcriptor-ai`'s build) isn't found + otherwise. Bit us once already during manual testing this session; same fix here. +- Testable standalone outside Xcode — see the script's own header comment for the exact env + vars to set (`SRCROOT`, `BUILT_PRODUCTS_DIR`, `UNLOCALIZED_RESOURCES_FOLDER_PATH`). Do this + before debugging inside Xcode if the phase ever fails — much faster iteration loop. + +Two Xcode project settings had to be disabled for this to work, both build-setting keys (not +files) — `ENABLE_APP_SANDBOX = NO` (a sandboxed app cannot spawn arbitrary external +processes, fundamentally incompatible with what this app does) and +`ENABLE_USER_SCRIPT_SANDBOXING = NO` (a separate, newer Xcode feature that sandboxes Run +Script build phases themselves — blocked the script from even reading its own file otherwise, +independent of the app's runtime sandbox). Both are the right call for a personal, +non-App-Store tool, same reasoning as `audiotee` itself not being sandboxed. + +`transcriptor-ai` needed a new flag to make this fully self-contained: `-audiotee-binary` +(otherwise it only found `audiotee` via `PATH` then `~/bin/audiotee` — fine for CLI use, not +for a bundled app that shouldn't depend on anything pre-installed outside itself). Verified +by moving `~/bin/audiotee` aside entirely before testing — capture still worked, proving the +embedded copy was actually being used, not a leftover fallback. + +## Conventions + +- Swift/SwiftUI idioms, standard Xcode project conventions once the project exists. +- Code and comments in English. Conversation with the user in French (matches the sibling + repos' convention). +- User-facing install/usage docs belong in `README.md` once one exists, not here. +- Don't reintroduce a monorepo, WebSocket, or reimplement audiotee/qwen-asr build logic + locally instead of invoking the sibling repos' own scripts — see "Why these choices." + +## Project facts (don't regenerate the Xcode project — it exists, edit it) + +- Xcode project lives at **`transcriptor.xcodeproj`, directly at the repo root** — + `transcriptor/`, `transcriptorTests/`, `transcriptorUITests/` sit as flat sibling folders + next to it. Xcode's project-creation flow initially nests everything one level deeper + (inside a `transcriptor/` wrapper folder named after the product, i.e. + `transcriptor-ui/transcriptor/transcriptor.xcodeproj`) — that nesting was deliberately + removed (moved everything up one level, verified with a clean build afterward that nothing + broke — Xcode's internal paths are relative to the `.xcodeproj`'s own location, so this is + safe). If you ever regenerate or re-import this project, redo that flattening. +- Product name: **`transcriptor`** (the built app is `transcriptor.app`; the repo is + `transcriptor-ui`, deliberately different — don't rename to match, that's already decided). +- Bundle identifier: **`eu.sttlab.transcriptor`** (organization identifier `eu.sttlab`, not + `com.stephanetailland` — matches the `sttlab-apps` code-signing certificate already created + for `audiotee`; this is the namespace for the user's personal apps going forward). +- Targets: `transcriptor` (the app), `transcriptorTests` (Swift Testing, chosen over XCTest — + Apple's current default), `transcriptorUITests`. Storage: None (no Core Data/SwiftData — + config is small enough for `@AppStorage`, the transcript file is owned by + `transcriptor-ai`, not this app — see "Why these choices"). +- Built and launch-tested once already (`xcodebuild ... build`, then `open` the resulting + `.app`, confirmed the process actually runs) — this is the stock SwiftUI template + (`ContentView.swift` / `transcriptorApp.swift`), no project-specific code yet. +- `.gitignore` added after project creation (Xcode's "create project" flow does not add one + automatically when you opt out of "Create Git repository on my Mac", which the user did + since the repo already existed) — `xcuserdata/`, `*.xcuserstate`, `.DS_Store`, etc. Verify + new xcuserdata files don't creep back into `git status` before committing anything. + +## Status + +**The capture→VAD→ASR→SSE circuit is proven end to end from the native UI**, verified with +real audio (not just "it compiles") — `transcriptor.app` spawns `transcriptor-ai`, which +spawns `audiotee`, real system audio gets captured (confirmed via `ffmpeg volumedetect`, +-21dB/-2.8dB, not silence), transcribed, and delivered live over the SSE endpoint to a Swift +client rendering it in a scrolling list. (Historical note: this paragraph originally described +a dev-hardcoded-paths version, before embedding and model management existed — see "Resume +point" below for current, accurate status. Left the bug narrative below as-is since it's still +useful history, just be aware "Paths are still dev-time hardcoded" no longer applies.) + +New files: `TranscriptMessage.swift` (mirrors `transcriptor-ai`'s `internal/transcript.Message` +JSON — kept in sync by hand, no shared schema), `TranscriptorProcess.swift` (subprocess +spawn/stop), `SSEClient.swift` (Swift port of `cmd/transcript-tail`'s parsing logic), +`AppDelegate.swift` (see bug below), `Info.plist` (see bug below). `ContentView.swift` +rewritten with a Start/Stop button and a live-scrolling transcript list. + +### Two real bugs found via actual testing, not code review + +- **App Sandbox blocked everything.** The macOS App template enables + `ENABLE_APP_SANDBOX = YES` by default. A sandboxed app cannot spawn arbitrary external + processes — fundamentally incompatible with what this app does (spawn `transcriptor-ai`, + which spawns `audiotee`/`qwen_asr`). Disabled (`ENABLE_APP_SANDBOX = NO` in both Debug and + Release). Correct call for a personal, non-App-Store tool — same reasoning as why `audiotee` + itself isn't sandboxed. +- **TCC "responsible process" escalation silently blocked mic + system audio capture**, with + **no visible permission prompt** — the most subtle/important finding so far. `audiotee` + already has a valid, working signed identity (`sttlab-apps`) and its own embedded + `Info.plist` with the usage-description keys. That was NOT enough once `audiotee` runs as a + grandchild of a brand-new GUI app: macOS's TCC appears to attribute the permission request + to the top-level "responsible" app in the process tree (`transcriptor.app`), not just the + leaf binary that actually calls the Core Audio APIs — and since `transcriptor.app`'s + Info.plist had no usage-description keys of its own, the request failed outright instead of + prompting. Confirmed via `log show --predicate 'process == "audiotee"'` + (`/usr/bin/log`, not the zsh builtin `log` — that shadows it and errors with "too many + arguments") — a `TCCAccessRequest() IPC` immediately followed by a CoreAudio HAL error whose + code decodes as ASCII "nope" (`0x6E6F7065` / `1852797029`), with no dialog ever shown. + **Fix:** `transcriptor.app` needs its own `NSMicrophoneUsageDescription` AND + `NSAudioCaptureUsageDescription`, same as `audiotee` has. `NSMicrophoneUsageDescription` is + a recognized `INFOPLIST_KEY_*` build setting Xcode synthesizes automatically, but + `NSAudioCaptureUsageDescription` is not (same gap `audiotee`'s own CONTEXT.md already + documented for its Xcode dropdown) — silently dropped if you try `INFOPLIST_KEY_*` for it. + Switched the target to a real, physical `Info.plist` (`GENERATE_INFOPLIST_FILE = NO`, + `INFOPLIST_FILE = transcriptor/Info.plist`) with both keys set explicitly, using + `$(VARIABLE)` placeholders for the usual build-setting-derived values + (`CFBundleIdentifier`, etc.) so they stay in sync. Had to also add a + `PBXFileSystemSynchronizedBuildFileExceptionSet` excluding `Info.plist` from "Copy Bundle + Resources" (the synchronized-group auto-membership otherwise tries to bundle it as a + resource too, alongside using it as the actual Info.plist — build warning, not fatal, but + worth doing cleanly). After a `tccutil reset Microphone eu.sttlab.transcriptor` (needed + because the earlier silent failure may have already recorded a denial) and a rebuild, real + permission prompts appeared and — once granted — real audio was captured. **Implication for + later:** the eventual embedded/bundled version needs this too; don't assume `audiotee`'s own + Info.plist is sufficient just because it worked standalone via CLI. +- **Quitting the app orphaned `transcriptor-ai`/`audiotee` as background processes** — normal + `Cmd+Q`/Quit did not stop them, confirmed by quitting and checking `pgrep` afterward. Fixed + by moving `TranscriptorProcess` ownership to a proper `NSApplicationDelegate` + (`AppDelegate.swift`) so `applicationWillTerminate` can call `.stop()` (sends SIGINT, same + graceful shutdown `transcriptor-ai` already has). `ContentView` now receives the shared + instance via init instead of creating its own `@State`. Verified: start capture, quit via + the app's own Quit (tested via `osascript ... quit`, which triggers the same termination + path as the user doing it), `pgrep` shows nothing left running. Known gap, not fixed (can't + be, at this layer): a force-kill (Activity Monitor, SIGKILL) skips + `applicationWillTerminate` entirely and would still orphan the children. + +## ASR quality benchmark (2026-08-09) — real-world WER against YouTube subtitles + +Ad hoc, not a built tool (deliberately — a one-off validation exercise, don't build a +permanent benchmark harness unless asked): tested the app against real spoken content by +playing 5 minutes of two YouTube videos (`test/test-en.webm`, English tutorial; `test/test-fr.mkv`, +French tutorial) through system audio while `transcriptor.app` captured with mic disabled, +then compared the saved transcript against each video's auto-generated YouTube subtitles as +reference (`test/test-en.vtt`, `test/test-fr.vtt` — gitignored, large media, not committed). + +**Reference-text extraction from YouTube's auto-caption VTT format**: these files use +word-by-word rolling reveal (`` inline tags) with the growing line repeated across +consecutive cues — naively joining cue text produces massive duplication. The correct +reconstruction (validated by inspection): take only the **last** text line of each cue block, +strip `<...>` tags, and skip consecutive duplicate lines. This is a general fact about +YouTube's auto-caption format, not project-specific — worth remembering for any future +subtitle-scraping task, not just this one. + +**Results**: English WER 6.6%, French WER 8.9% (Levenshtein word-edit-distance / reference +word count, computed with a throwaway Python script, not committed to the repo). Both +"quite good" for real, unscripted speech. +- English: the dominant error was **"Claude" consistently mistranscribed as "cloud"/"claw"/ + "clawed"/"clause"** — a single recurring word accounting for a meaningful share of the + errors, and exactly the kind of thing lexical biasing (`--prompt`) exists to fix. +- French: no single dominant error class — singular/plural agreement (often genuinely + inaudible in spoken French: "agent"/"agents"), proper nouns garbled on *both* sides + (including in YouTube's own reference — e.g. "SWOT" was itself mistranscribed as "swat" in + the YouTube reference, while our ASR got it right), and one real content-loss segment + ("être honnête avec vous" → "un intagou") worth keeping an eye on if it recurs. +- Pipeline mechanics (capture → VAD → ASR → file save) held up correctly through both runs, + no crashes or dropped audio. + +## Glossary (2026-08-09) — implemented + +Directly motivated by the "Claude" mishearing above. **Design: a plain text file, not a +Settings text field** — the user explicitly rejected an in-app text field after seeing it +(see the two SwiftUI layout bugs below) in favor of something editable in any text editor. +- `SettingsView.swift`'s `GlossaryFileLocation` enum: default path + `~/Library/Application Support/eu.sttlab.transcriptor/glossary.txt`, location persisted via + `@AppStorage`. Settings UI has "Choose…" (`NSSavePanel`, not `NSOpenPanel` — lets picking a + location for a file that doesn't exist yet, the way "Save As…" does; `NSOpenPanel` can only + select existing files) and "Open" (`NSWorkspace.shared.open`, launches the user's default + text editor for `.txt`). +- `ContentView.readGlossaryFile()` reads the file's content fresh at every Start (not cached), + trims whitespace, wraps it as `"Preserve spelling: "`, and passes that as + `TranscriptorProcess.Config.glossary` → `-prompt` to `transcriptor-ai` → `--prompt` to + `qwen_asr`. Missing/empty file → `nil`, silently no `-prompt` passed — not a startup error, + glossary is optional. **The file itself holds only the bare comma-separated terms** (matches + the Settings UI help text) — the `"Preserve spelling: "` wrapping is applied in code, not + stored, so the file stays simple to hand-edit. + +### Glossary prompt-leakage bug (2026-08-09) — found via real testing, mitigated + +Ran a real WER benchmark (5 min, English test video, qwen3-asr-0.6b + the glossary from +`--prompt` above) to check whether the glossary actually fixed the earlier "Claude" → +"cloud"/"claw"/"clause" mishearing. It did — 100% of "Claude" occurrences correct in that run +— but surfaced a different, real problem: **one transcript segment came back as the glossary +terms verbatim** (`"claude claude code mcp context7 supabase stripe vercel github typescript +playwright ui"`), replacing what should have been actual transcribed speech +(`"assistants are only as good as their training data enforcing it to use web search to +fetch"`), and that hallucinated segment was marked `is_final: true` like a normal one — the +model echoed the biasing prompt instead of transcribing the audio for that segment. This is a +known general failure mode of prompt-conditioned generation ("prompt leakage"), not a +qwen_asr-specific bug and not something fixable from `transcriptor-ai`'s side (it's the +underlying model's behavior, not something the Go orchestration layer controls). + +**Mitigation applied**: the glossary was being passed as a bare comma list +(`"Claude, Claude Code, MCP, ..."`). `qwen_asr`'s own documented `--prompt` example is +`"Preserve spelling: CPU, CUDA, PostgreSQL, Redis"` — an instruction-framed prompt, not a bare +list — and we'd deviated from that. Fixed to match (see `readGlossaryFile()` above). **Not +proven to eliminate the leak, just: follow the tool's own recommended format instead of an +untested deviation from it** — re-verify if a future test surfaces leakage again, don't assume +this fully solved it. + +**The prefix mitigation alone was NOT sufficient** — re-ran the same French 5-min test with +the "Preserve spelling: " framing active, and the leak recurred: one segment came back as +almost the entire glossary verbatim (`"Mistral, IA Studio, SWOT, PESTEL, JSON, TypeScript, +Playwright, UI/UX, Claude, Claude Code, MCP, Context7, Stripe, Vercel, GitHub"` — 15 of 16 +glossary terms, in the file's exact order), contributing measurably to that run's WER (13.5% +vs. the original 8.9% 1.7B baseline — though as always, confounded by the model-size +difference too, see below). + +**Real fix implemented: `internal/asr/leak.go`, `looksLikePromptLeak(text, prompt string) bool`** +in `transcriptor-ai`. Called from `Transcriber.Transcribe` right after getting qwen_asr's +output — if a segment looks like a leak, it's discarded (returns `""`, which the existing +`if text == "" { continue }` caller logic in `main.go` already treats as "nothing to publish +this round" — no separate wiring needed there) and a diagnostic line is written to stderr so +it's visible for debugging without polluting the live transcript or the saved file. + +Heuristic: normalize both the segment text and the prompt into lowercase word lists (unicode +letter/digit aware, so accented French words don't get mis-split), and if ≥80% of the +segment's words also appear among the prompt's words AND the segment has ≥4 words, treat it +as a leak. The ≥4-word floor exists specifically so a short, legitimate mention of one +glossary term ("Use Mistral", "in Claude Code") is never flagged — real leaks observed were +near-total reproductions of the term list, giving a lot of margin between real leaks and +normal usage. Verified against both real captured leaks (English and French) plus several +normal-sentence-mentioning-a-glossary-term cases with a throwaway Go test program before +wiring it in — all classified correctly, not just "it compiled." + +**Re-verified end to end with a real capture**: reran the same French 5-min benchmark with the +filter active — the saved transcript file no longer contains the leak pattern, and WER +improved to 12.5% (from 13.5% with the leak present), consistent with removing the ~15-word +leaked segment's contribution to the edit distance. Model was still 0.6B in this run (see +"Model quality" note below — same confound applies, this number isn't a clean 1.7B comparison +either), but the *qualitative* result — no more verbatim glossary dumps in the output — is the +actual point of this fix and was directly confirmed, not inferred from the WER delta alone. + +### Model quality: not actually compared apples-to-apples (2026-08-09) + +Have three WER data points now, but **still no clean isolated comparison** — every run has +at least two variables differing at once (model size, glossary presence, and/or the leak bug +above): 1.7B/no-glossary: 6.6% (EN) / 8.9% (FR); 0.6B/with-glossary/leak-present: 7.3% (EN) / +13.5% (FR); 0.6B/with-glossary/leak-fixed: 12.5% (FR). None of these isolate the model-size +variable — an isolated test (0.6B, no glossary, same 5-min clips) was proposed twice this +session to get a clean comparison but still not run. Do that first if model-size quality is +ever actually decided; don't reuse any of these numbers as if they isolate the model variable. + +### Settings persistence bugs (2026-08-09) — `@State` vs `@AppStorage`, two found and fixed + +Both found the same way: mid-session, the app had to be relaunched (to test a different +config), and a setting the user had explicitly picked reverted to its hardcoded default +without warning — read as the app ignoring input, caused real, justified frustration both +times before the actual cause (not persisted at all, silently) was identified. + +- `selectedModel` (the ASR model picker) was `@State`, defaulting to `.large` (1.7B) — every + relaunch silently discarded a `.small` (0.6B) selection back to 1.7B. Fixed: + `@AppStorage("selectedModel")` — `ModelChoice` is `RawRepresentable` (`String`), which + `@AppStorage` supports directly, no separate string-backed key needed. +- `captureSystem`/`captureMic` (the two capture-source toggles) had the identical bug, + defaulting back to both-on every relaunch. Fixed the same way: + `@AppStorage("captureSystem")` / `@AppStorage("captureMic")`. + +**Verified for real, not just "it compiled"**: for `selectedModel`, set the picker to small, +fully quit the app (not just Stop), relaunched, and confirmed via +`/usr/libexec/PlistBuddy -c "Print" ~/Library/Preferences/eu.sttlab.transcriptor.plist` that +`selectedModel = small` persisted on disk and the picker showed 0.6B on the next launch — not +just eyeballing the UI once. Note: `defaults read eu.sttlab.transcriptor ...` resolves to the +wrong (sandboxed-style `~/Library/Containers/...`) path for this non-sandboxed app and reports +"does not exist" even though the real prefs file is fine at the standard +`~/Library/Preferences/eu.sttlab.transcriptor.plist` location — use `PlistBuddy` directly +against that path instead of trusting `defaults read`'s domain resolution here. + +**Check `@State` vs `@AppStorage` deliberately for any new setting added to this view** — this +was the same bug twice in a row because it's an easy default to reach for; anything the user +configures and would expect to survive a relaunch needs `@AppStorage`, not `@State`. + +**Two real SwiftUI layout bugs hit and fixed while building the Settings UI for this** — both +found by the user looking at actual screenshots, not caught by a successful build: +1. A multi-line `TextField(..., axis: .vertical)` inside `LabeledContent` inside `Form` + rendered the placeholder floating above a large empty box instead of inside it — visually + broken. This was the earlier "text field in Settings" design, since abandoned for the + plain-file approach above, so this specific bug is moot now — but the general lesson + (verify below) isn't. +2. **`Form`'s automatic label-column width calculation broke badly** once a row's content grew + taller than one line (the glossary row's caption text) — the "Glossary file:" label + rendered wrapped one letter per line ("G" / "lo" / "s" / "s" / "a" / "ry" / "fil" / "e:"), + stacked vertically. Fixed by abandoning `Form`/`LabeledContent` entirely for this view in + favor of a hand-rolled `VStack` with a fixed-width label column + (`.frame(width: labelWidth, alignment: .trailing)`) — simpler and predictable, not worth + fighting Form's automatic sizing further. **If this view needs new rows in the future, + keep using the hand-rolled layout, don't reintroduce `Form`/`LabeledContent` here** — it's + already demonstrated to break under multi-line content in this exact view. + +## Resume point + +**The one-click install goal is fully met, verified end to end with a real cold start.** +`transcriptor.app` is completely self-contained: +- Building it (one Xcode build phase) builds and embeds `audiotee`, `transcriptor-ai`, and + `qwen_asr` — no separate manual step. Verified by moving `~/bin/audiotee` aside and + confirming capture still worked from the embedded copy alone. +- Nested code-signing, the biggest open unknown, resolved cleanly with no special handling + needed (see above). +- **Model management is implemented** (`ModelManager.swift`): downloads from HuggingFace + natively (`URLSessionDownloadDelegate`, real per-file progress, resumable — already-present + files are skipped), stores under `~/Library/Application Support/eu.sttlab.transcriptor/models/`, + lets the user pick 0.6B vs 1.7B via a `Picker`. Clicking "Start" downloads-then-starts as one + action if the model isn't there yet — no separate "download" step to remember. **Verified + with a genuine cold start**: emptied the target directory, clicked Start, watched the 1.7B + model (~4.7GB, 7 files) download for real, confirmed it auto-started capture immediately + after, and confirmed real transcription worked against the freshly-downloaded model. Not + simulated or assumed — this is as close to "what a new user's first launch looks like" as + testing on the dev machine allows. +- Two `@Observable`/Swift gotchas hit along the way: the macro doesn't support `lazy var` + (init-accessor synthesis conflict — fixed with `@ObservationIgnored` on the one lazy + property, `URLSession`, which isn't UI-relevant state anyway), and a ternary expression + can't mix a `Void`-returning branch with a `Task { }`-returning branch (fixed with a plain + `if/else` in the button action instead). + +**Transcript save location is implemented** (`SettingsView.swift`): a real macOS Settings +window (`Cmd+,`, the `Settings { }` scene in `transcriptorApp.swift` — not a control bolted +onto the main window), lets the user pick the save *directory* via `NSOpenPanel` +(`@AppStorage`-persisted, defaults to `~/Documents/Transcriptor`). The filename itself is not +user-configurable, per explicit spec — `ContentView.makeTranscriptFilePath` derives it from +the transcription's start timestamp (`yyyy-MM-dd_HH-mm-ss.txt`), computed fresh each time +"Start" is pressed. Verified with a real session end to end: file created at the expected +path with the expected name, content matched the live SSE transcript exactly. + +Two things worth remembering from getting this verified: +- **Terminal (and this Bash tool) can't read `~/Documents` on this machine** — a separate, + per-app macOS "Files and Folders" TCC grant that Terminal doesn't have, unrelated to + whether `transcriptor.app` itself can write there (it can — the file got created and + written to correctly). Don't mistake "my shell can't read X" for "the app failed to write + X" — verify via Finder/the app's own behavior instead when this comes up again. +- **A "0 bytes" or "no SSE messages" observation immediately after clicking Start is not + necessarily a bug** — it can simply mean no segment has been transcribed *yet* (the first + segment takes several seconds: VAD needs speech, then up to ~4s to force-cut, then the ASR + call itself). This tripped up verification here more than once this session already (see + the "Two real bugs" section above for the first two times) — when a live-audio test comes + back empty, redo it with `curl` started *before* the audio plays and a generous timeout, + rather than assuming something regressed. + +**Capture source selection and message visual distinction are implemented**, per explicit +request: +- Two `Toggle`s ("System audio", "Microphone") in the main window, `@State`-backed (not + persisted — resets to both-on each launch, unlike the transcript directory setting; that + wasn't specified either way, revisit if the user wants it remembered). Start is disabled if + both are off. Wired to two new `transcriptor-ai` flags, `-capture-system`/`-capture-mic` + (both default `true`) — passed as `-flag=value`, not `-flag value`, because **Go's `flag` + package does not accept a space-separated value for boolean flags**: `-flag value` leaves + the flag at its default and the value as a stray positional argument. Verified empirically + with a throwaway Go program before relying on it, not assumed — see + `TranscriptorProcess.swift`'s comment at the call site. +- Important asymmetry, also documented in `transcriptor-ai/CLAUDE.md`: `-capture-mic=false` + skips mic capture at the `audiotee` level entirely (no permission requested). `-capture-system=false` + **cannot** do the same — audiotee has no flag to skip its system tap, so it keeps running; + disabling it just stops `transcriptor-ai` from transcribing that track. Verified via CLI + (not just via the app) with system audio playing while `-capture-system=false`: zero + `track: system` messages reached the SSE stream, confirming the drop actually works. +- `MessageBubble` (new private view in `ContentView.swift`) replaced the plain + track-label-prefix row with a Messages.app-style layout — mic ("Me") right-aligned in accent + color, system ("Them") left-aligned in a neutral gray, matching the "me vs them" framing the + two-track design has used since `audiotee`. Verified functionally (the underlying toggle + logic, via SSE content) but the visual layout itself was not independently re-confirmed by + the user after a code change late in this session — worth a quick visual glance next time + the app is open, not assumed broken, just not re-verified. + +**Glossary is now implemented** — see "Glossary (2026-08-09)" above, no longer future work. +Its Settings UI layout was last verified visually to have fixed the label-wrapping bug (build +succeeded, app relaunched with the hand-rolled VStack layout) but **the user had not yet +confirmed the fix on screen when this session paused** — do a quick visual check (open +Settings, `Cmd+,`) before assuming it's resolved, don't just trust the earlier "build +succeeded." + +Not done yet — everything under "Future" in Goal (question-detection/CR-generation +post-processing) — still explicitly out of scope until asked for. + +## Installing to /Applications (not automated — do this manually when needed) + +There's no install script; this was done by hand once (2026-08-08) and documented here so it +doesn't need re-deriving: +```bash +xcodebuild -project transcriptor.xcodeproj -scheme transcriptor -configuration Release build +ditto "$(xcodebuild -project transcriptor.xcodeproj -scheme transcriptor -configuration Release -showBuildSettings | awk -F'= ' '/ CONFIGURATION_BUILD_DIR /{print $2; exit}')/transcriptor.app" /Applications/transcriptor.app +/System/Library/Frameworks/CoreServices.framework/Versions/Current/Frameworks/LaunchServices.framework/Versions/Current/Support/lsregister -f /Applications/transcriptor.app +``` +(The exact `CONFIGURATION_BUILD_DIR` used in practice was the DerivedData path directly — +the `awk`/`-showBuildSettings` version above is untested, just a cleaner-looking equivalent; +verify it before trusting it blindly.) + +**Known cosmetic issue, not fixed**: every Xcode build (Debug and Release) registers that +build's `transcriptor.app` with Launch Services, so Spotlight's raw filename search lists +multiple entries (DerivedData Debug/Release + the `/Applications` copy) with generic blank +icons (no `AppIcon` image set in `Assets.xcassets` yet — that's a separate, unaddressed gap). +**This is cosmetic only** — `open -a transcriptor` (what actually fires when you hit Enter on +a Spotlight result, or use the Dock/Launchpad) correctly resolves to `/Applications/transcriptor.app` +regardless, verified by killing all instances and confirming `open -a transcriptor` launched +the `/Applications` one specifically. Can't be fully cleaned up without deleting the +DerivedData build products, which would break `⌘R` in Xcode — not worth doing. If it bothers +the user again, the practical fix is dragging `/Applications/transcriptor.app` to the Dock, +not chasing the Spotlight listing further. diff --git a/scripts/embed-binaries.sh b/scripts/embed-binaries.sh new file mode 100755 index 0000000..8709f9f --- /dev/null +++ b/scripts/embed-binaries.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# Xcode "Run Script" build phase: builds audiotee, transcriptor-ai, and +# qwen_asr from the sibling repos and embeds them in this app's bundle — +# the "one-click install" requirement (see CLAUDE.md, "Goal"). Only the AI +# model is left to fetch at runtime; everything else is baked in here. +# +# Expects Xcode's build environment variables (SRCROOT, BUILT_PRODUCTS_DIR, +# UNLOCALIZED_RESOURCES_FOLDER_PATH). Run manually for debugging via: +# SRCROOT=$(pwd) BUILT_PRODUCTS_DIR=/tmp/embed-test \ +# UNLOCALIZED_RESOURCES_FOLDER_PATH=transcriptor.app/Contents/Resources \ +# scripts/embed-binaries.sh + +set -euo pipefail + +# Xcode Run Script phases don't inherit a normal interactive-shell PATH — +# Homebrew's go/etc. aren't found without this (bit us during manual +# testing throughout this session too). +export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" + +SRCROOT="${SRCROOT:?SRCROOT must be set (run via Xcode, or set it manually — see header comment)}" +REPO_ROOT="$(cd "$SRCROOT" && pwd)" +AUDIOTEE_REPO="$(cd "$REPO_ROOT/../audiotee" && pwd)" +TRANSCRIPTOR_AI_REPO="$(cd "$REPO_ROOT/../transcriptor-ai" && pwd)" + +echo "Building audiotee..." +"$AUDIOTEE_REPO/scripts/build-signed.sh" + +echo "Building transcriptor-ai + qwen_asr..." +"$TRANSCRIPTOR_AI_REPO/scripts/build-dist.sh" + +RESOURCES_DIR="${BUILT_PRODUCTS_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}" +mkdir -p "$RESOURCES_DIR" + +cp "$HOME/bin/audiotee" "$RESOURCES_DIR/audiotee" +cp "$TRANSCRIPTOR_AI_REPO/dist/transcriptor-ai" "$RESOURCES_DIR/transcriptor-ai" +cp "$TRANSCRIPTOR_AI_REPO/dist/qwen_asr" "$RESOURCES_DIR/qwen_asr" +chmod +x "$RESOURCES_DIR/audiotee" "$RESOURCES_DIR/transcriptor-ai" "$RESOURCES_DIR/qwen_asr" + +echo "Embedded binaries in $RESOURCES_DIR:" +ls -la "$RESOURCES_DIR/audiotee" "$RESOURCES_DIR/transcriptor-ai" "$RESOURCES_DIR/qwen_asr" diff --git a/third_party/audiotee b/third_party/audiotee new file mode 160000 index 0000000..678557c --- /dev/null +++ b/third_party/audiotee @@ -0,0 +1 @@ +Subproject commit 678557caf71981d288a1d67a166b8ec806b9f79e diff --git a/transcriptor.xcodeproj/project.pbxproj b/transcriptor.xcodeproj/project.pbxproj new file mode 100644 index 0000000..03dbee4 --- /dev/null +++ b/transcriptor.xcodeproj/project.pbxproj @@ -0,0 +1,631 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXContainerItemProxy section */ + A95995AB3026FAFD006E5ACA /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A95995953026FAFC006E5ACA /* Project object */; + proxyType = 1; + remoteGlobalIDString = A959959C3026FAFC006E5ACA; + remoteInfo = transcriptor; + }; + A95995B53026FAFD006E5ACA /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A95995953026FAFC006E5ACA /* Project object */; + proxyType = 1; + remoteGlobalIDString = A959959C3026FAFC006E5ACA; + remoteInfo = transcriptor; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + A959959D3026FAFC006E5ACA /* transcriptor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = transcriptor.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A95995AA3026FAFD006E5ACA /* transcriptorTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = transcriptorTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + A95995B43026FAFD006E5ACA /* transcriptorUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = transcriptorUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + A959959F3026FAFC006E5ACA /* transcriptor */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + A959970000000000000001AA /* Exceptions for "transcriptor" folder in "transcriptor" target */, + ); + path = transcriptor; + sourceTree = ""; + }; + A95995AD3026FAFD006E5ACA /* transcriptorTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = transcriptorTests; + sourceTree = ""; + }; + A95995B73026FAFD006E5ACA /* transcriptorUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = transcriptorUITests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + A959970000000000000001AA /* Exceptions for "transcriptor" folder in "transcriptor" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = A959959C3026FAFC006E5ACA /* transcriptor */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFrameworksBuildPhase section */ + A959959A3026FAFC006E5ACA /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A95995A73026FAFD006E5ACA /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A95995B13026FAFD006E5ACA /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A95995943026FAFC006E5ACA = { + isa = PBXGroup; + children = ( + A959959F3026FAFC006E5ACA /* transcriptor */, + A95995AD3026FAFD006E5ACA /* transcriptorTests */, + A95995B73026FAFD006E5ACA /* transcriptorUITests */, + A959959E3026FAFC006E5ACA /* Products */, + ); + sourceTree = ""; + }; + A959959E3026FAFC006E5ACA /* Products */ = { + isa = PBXGroup; + children = ( + A959959D3026FAFC006E5ACA /* transcriptor.app */, + A95995AA3026FAFD006E5ACA /* transcriptorTests.xctest */, + A95995B43026FAFD006E5ACA /* transcriptorUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A959959C3026FAFC006E5ACA /* transcriptor */ = { + isa = PBXNativeTarget; + buildConfigurationList = A95995BE3026FAFD006E5ACA /* Build configuration list for PBXNativeTarget "transcriptor" */; + buildPhases = ( + A95995993026FAFC006E5ACA /* Sources */, + A959959A3026FAFC006E5ACA /* Frameworks */, + A959959B3026FAFC006E5ACA /* Resources */, + A959970000000000000002BB /* Embed audiotee, transcriptor-ai, qwen_asr */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + A959959F3026FAFC006E5ACA /* transcriptor */, + ); + name = transcriptor; + packageProductDependencies = ( + ); + productName = transcriptor; + productReference = A959959D3026FAFC006E5ACA /* transcriptor.app */; + productType = "com.apple.product-type.application"; + }; + A95995A93026FAFD006E5ACA /* transcriptorTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A95995C13026FAFD006E5ACA /* Build configuration list for PBXNativeTarget "transcriptorTests" */; + buildPhases = ( + A95995A63026FAFD006E5ACA /* Sources */, + A95995A73026FAFD006E5ACA /* Frameworks */, + A95995A83026FAFD006E5ACA /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + A95995AC3026FAFD006E5ACA /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + A95995AD3026FAFD006E5ACA /* transcriptorTests */, + ); + name = transcriptorTests; + packageProductDependencies = ( + ); + productName = transcriptorTests; + productReference = A95995AA3026FAFD006E5ACA /* transcriptorTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + A95995B33026FAFD006E5ACA /* transcriptorUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A95995C43026FAFD006E5ACA /* Build configuration list for PBXNativeTarget "transcriptorUITests" */; + buildPhases = ( + A95995B03026FAFD006E5ACA /* Sources */, + A95995B13026FAFD006E5ACA /* Frameworks */, + A95995B23026FAFD006E5ACA /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + A95995B63026FAFD006E5ACA /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + A95995B73026FAFD006E5ACA /* transcriptorUITests */, + ); + name = transcriptorUITests; + packageProductDependencies = ( + ); + productName = transcriptorUITests; + productReference = A95995B43026FAFD006E5ACA /* transcriptorUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A95995953026FAFC006E5ACA /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2660; + LastUpgradeCheck = 2660; + TargetAttributes = { + A959959C3026FAFC006E5ACA = { + CreatedOnToolsVersion = 26.6; + }; + A95995A93026FAFD006E5ACA = { + CreatedOnToolsVersion = 26.6; + TestTargetID = A959959C3026FAFC006E5ACA; + }; + A95995B33026FAFD006E5ACA = { + CreatedOnToolsVersion = 26.6; + TestTargetID = A959959C3026FAFC006E5ACA; + }; + }; + }; + buildConfigurationList = A95995983026FAFC006E5ACA /* Build configuration list for PBXProject "transcriptor" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = A95995943026FAFC006E5ACA; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = A959959E3026FAFC006E5ACA /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A959959C3026FAFC006E5ACA /* transcriptor */, + A95995A93026FAFD006E5ACA /* transcriptorTests */, + A95995B33026FAFD006E5ACA /* transcriptorUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A959959B3026FAFC006E5ACA /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A95995A83026FAFD006E5ACA /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A95995B23026FAFD006E5ACA /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + A959970000000000000002BB /* Embed audiotee, transcriptor-ai, qwen_asr */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Embed audiotee, transcriptor-ai, qwen_asr"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$SRCROOT/scripts/embed-binaries.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A95995993026FAFC006E5ACA /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A95995A63026FAFD006E5ACA /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A95995B03026FAFD006E5ACA /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + A95995AC3026FAFD006E5ACA /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A959959C3026FAFC006E5ACA /* transcriptor */; + targetProxy = A95995AB3026FAFD006E5ACA /* PBXContainerItemProxy */; + }; + A95995B63026FAFD006E5ACA /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A959959C3026FAFC006E5ACA /* transcriptor */; + targetProxy = A95995B53026FAFD006E5ACA /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + A95995BC3026FAFD006E5ACA /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + A95995BD3026FAFD006E5ACA /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + A95995BF3026FAFD006E5ACA /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_APP_SANDBOX = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = transcriptor/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 26.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = eu.sttlab.transcriptor; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + XROS_DEPLOYMENT_TARGET = 26.5; + }; + name = Debug; + }; + A95995C03026FAFD006E5ACA /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_APP_SANDBOX = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = transcriptor/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 26.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = eu.sttlab.transcriptor; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + XROS_DEPLOYMENT_TARGET = 26.5; + }; + name = Release; + }; + A95995C23026FAFD006E5ACA /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + MACOSX_DEPLOYMENT_TARGET = 26.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = eu.sttlab.transcriptorTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/transcriptor.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/transcriptor"; + XROS_DEPLOYMENT_TARGET = 26.5; + }; + name = Debug; + }; + A95995C33026FAFD006E5ACA /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + MACOSX_DEPLOYMENT_TARGET = 26.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = eu.sttlab.transcriptorTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/transcriptor.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/transcriptor"; + XROS_DEPLOYMENT_TARGET = 26.5; + }; + name = Release; + }; + A95995C53026FAFD006E5ACA /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + MACOSX_DEPLOYMENT_TARGET = 26.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = eu.sttlab.transcriptorUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_TARGET_NAME = transcriptor; + XROS_DEPLOYMENT_TARGET = 26.5; + }; + name = Debug; + }; + A95995C63026FAFD006E5ACA /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + MACOSX_DEPLOYMENT_TARGET = 26.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = eu.sttlab.transcriptorUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_TARGET_NAME = transcriptor; + XROS_DEPLOYMENT_TARGET = 26.5; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A95995983026FAFC006E5ACA /* Build configuration list for PBXProject "transcriptor" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A95995BC3026FAFD006E5ACA /* Debug */, + A95995BD3026FAFD006E5ACA /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A95995BE3026FAFD006E5ACA /* Build configuration list for PBXNativeTarget "transcriptor" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A95995BF3026FAFD006E5ACA /* Debug */, + A95995C03026FAFD006E5ACA /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A95995C13026FAFD006E5ACA /* Build configuration list for PBXNativeTarget "transcriptorTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A95995C23026FAFD006E5ACA /* Debug */, + A95995C33026FAFD006E5ACA /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A95995C43026FAFD006E5ACA /* Build configuration list for PBXNativeTarget "transcriptorUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A95995C53026FAFD006E5ACA /* Debug */, + A95995C63026FAFD006E5ACA /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = A95995953026FAFC006E5ACA /* Project object */; +} diff --git a/transcriptor.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/transcriptor.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/transcriptor.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/transcriptor/AppDelegate.swift b/transcriptor/AppDelegate.swift new file mode 100644 index 0000000..e4ef88d --- /dev/null +++ b/transcriptor/AppDelegate.swift @@ -0,0 +1,22 @@ +// +// AppDelegate.swift +// transcriptor +// +// Owns the shared TranscriptorProcess so it can be stopped when the app +// quits normally — without this, quitting the app orphaned the +// transcriptor-ai/audiotee subprocesses, which kept running invisibly in +// the background (confirmed with a real quit + `pgrep` check). Doesn't +// cover a force-kill (e.g. via Activity Monitor, SIGKILL) — no +// applicationWillTerminate callback fires for that, nothing to do about it +// at this layer. +// + +import AppKit + +final class AppDelegate: NSObject, NSApplicationDelegate { + let transcriptorProcess = TranscriptorProcess() + + func applicationWillTerminate(_ notification: Notification) { + transcriptorProcess.stop() + } +} diff --git a/transcriptor/Assets.xcassets/AccentColor.colorset/Contents.json b/transcriptor/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/transcriptor/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/transcriptor/Assets.xcassets/AppIcon.appiconset/Contents.json b/transcriptor/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..ffdfe15 --- /dev/null +++ b/transcriptor/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,85 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/transcriptor/Assets.xcassets/Contents.json b/transcriptor/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/transcriptor/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/transcriptor/ContentView.swift b/transcriptor/ContentView.swift new file mode 100644 index 0000000..3ebbd7e --- /dev/null +++ b/transcriptor/ContentView.swift @@ -0,0 +1,271 @@ +// +// 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) } + } + } +} diff --git a/transcriptor/Info.plist b/transcriptor/Info.plist new file mode 100644 index 0000000..45ec014 --- /dev/null +++ b/transcriptor/Info.plist @@ -0,0 +1,34 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundleDisplayName + Transcriptor + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSPrincipalClass + NSApplication + NSHumanReadableCopyright + + NSMicrophoneUsageDescription + transcriptor captures microphone audio for local, on-device meeting transcription. + NSAudioCaptureUsageDescription + transcriptor captures system audio for local, on-device meeting transcription. + + diff --git a/transcriptor/ModelManager.swift b/transcriptor/ModelManager.swift new file mode 100644 index 0000000..21ed44a --- /dev/null +++ b/transcriptor/ModelManager.swift @@ -0,0 +1,176 @@ +// +// ModelManager.swift +// transcriptor +// +// Downloads and locates qwen-asr model files — the one thing "one-click +// install" deliberately leaves at runtime (see CLAUDE.md, "Goal": models +// are multi-GB and don't belong baked into the app bundle, same reasoning +// Ollama/LM Studio don't ship them inside their own app either). +// +// File list and HuggingFace URL pattern mirror +// transcriptor-ai/third_party/qwen-asr/download_model.sh exactly — keep in +// sync by hand if that script's file list ever changes. +// + +import Foundation + +enum ModelChoice: String, CaseIterable, Identifiable { + case small + case large + + var id: String { rawValue } + + var displayName: String { + switch self { + case .small: "Qwen3-ASR 0.6B (faster, lower quality)" + case .large: "Qwen3-ASR 1.7B (recommended)" + } + } + + fileprivate var modelID: String { + switch self { + case .small: "Qwen/Qwen3-ASR-0.6B" + case .large: "Qwen/Qwen3-ASR-1.7B" + } + } + + var directoryName: String { + switch self { + case .small: "qwen3-asr-0.6b" + case .large: "qwen3-asr-1.7b" + } + } + + fileprivate var files: [String] { + switch self { + case .small: + ["config.json", "generation_config.json", "model.safetensors", "vocab.json", "merges.txt"] + case .large: + [ + "config.json", "generation_config.json", "model.safetensors.index.json", + "model-00001-of-00002.safetensors", "model-00002-of-00002.safetensors", + "vocab.json", "merges.txt", + ] + } + } +} + +enum ModelManagerError: Error { + case invalidDownloadedFile +} + +@Observable +final class ModelManager: NSObject { + private(set) var isDownloading = false + private(set) var statusText = "" + private(set) var fileProgress: Double = 0 // 0...1, current file only + private(set) var currentFileIndex = 0 + private(set) var totalFiles = 0 + + private struct DownloadContext { + let destination: URL + let completion: (Result) -> Void + } + private var contexts: [Int: DownloadContext] = [:] + // delegateQueue: .main so delegate callbacks land on the main actor + // directly — this class's @Observable properties are read by SwiftUI, + // which needs that. + // @Observable's macro doesn't support `lazy var` (init-accessor + // synthesis conflict) — this is an internal implementation detail + // anyway, not UI-relevant state, so excluding it from observation + // tracking is the right fix, not a workaround. + @ObservationIgnored + private lazy var session = URLSession(configuration: .default, delegate: self, delegateQueue: .main) + + static var modelsRootDirectory: URL { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + return appSupport.appendingPathComponent("eu.sttlab.transcriptor/models", isDirectory: true) + } + + func localDirectory(for choice: ModelChoice) -> URL { + Self.modelsRootDirectory.appendingPathComponent(choice.directoryName, isDirectory: true) + } + + func isDownloaded(_ choice: ModelChoice) -> Bool { + let dir = localDirectory(for: choice) + return choice.files.allSatisfy { + FileManager.default.fileExists(atPath: dir.appendingPathComponent($0).path) + } + } + + /// Downloads whatever files are missing (already-present files are left + /// alone, so an interrupted download resumes rather than restarting) + /// and returns the model's local directory. + func ensureModel(_ choice: ModelChoice) async throws -> URL { + let dir = localDirectory(for: choice) + if isDownloaded(choice) { return dir } + + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + isDownloading = true + defer { isDownloading = false } + + totalFiles = choice.files.count + for (index, file) in choice.files.enumerated() { + currentFileIndex = index + 1 + let destination = dir.appendingPathComponent(file) + if FileManager.default.fileExists(atPath: destination.path) { + continue + } + statusText = "Downloading \(file) (\(currentFileIndex)/\(totalFiles))..." + fileProgress = 0 + let url = URL(string: "https://huggingface.co/\(choice.modelID)/resolve/main/\(file)")! + try await downloadFile(from: url, to: destination) + } + statusText = "Model ready." + return dir + } + + private func downloadFile(from url: URL, to destination: URL) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let task = session.downloadTask(with: url) + contexts[task.taskIdentifier] = DownloadContext(destination: destination) { result in + continuation.resume(with: result) + } + task.resume() + } + } +} + +extension ModelManager: URLSessionDownloadDelegate { + func urlSession( + _ session: URLSession, downloadTask: URLSessionDownloadTask, + didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64 + ) { + guard totalBytesExpectedToWrite > 0 else { return } + fileProgress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) + } + + func urlSession( + _ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL + ) { + // The file at `location` only exists for the duration of this + // callback — the move must happen synchronously here, not be + // deferred to later (e.g. inside the completion closure), or the + // system may have already deleted it. + guard let context = contexts.removeValue(forKey: downloadTask.taskIdentifier) else { return } + do { + if FileManager.default.fileExists(atPath: context.destination.path) { + try FileManager.default.removeItem(at: context.destination) + } + try FileManager.default.moveItem(at: location, to: context.destination) + context.completion(.success(())) + } catch { + context.completion(.failure(error)) + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + // A successful download is already resolved in + // didFinishDownloadingTo (which removes the context) — this only + // has work to do for a task that failed before reaching that point + // (network error, etc). + guard let error, let context = contexts.removeValue(forKey: task.taskIdentifier) else { return } + context.completion(.failure(error)) + } +} diff --git a/transcriptor/SSEClient.swift b/transcriptor/SSEClient.swift new file mode 100644 index 0000000..852145d --- /dev/null +++ b/transcriptor/SSEClient.swift @@ -0,0 +1,58 @@ +// +// SSEClient.swift +// transcriptor +// +// Minimal Server-Sent Events client for transcriptor-ai's live transcript +// endpoint — the Swift equivalent of transcriptor-ai's own +// cmd/transcript-tail (same line-parsing logic, same protocol). See +// transcriptor-ai/CLAUDE.md for why SSE, not WebSocket. +// + +import Foundation + +@Observable +final class SSEClient { + private(set) var messages: [TranscriptMessage] = [] + private var task: Task? + + func connect(to url: URL) { + disconnect() + task = Task { + do { + let (bytes, response) = try await URLSession.shared.bytes(for: URLRequest(url: url)) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + return + } + let decoder = JSONDecoder.transcriptDecoder + for try await line in bytes.lines { + guard Task.isCancelled == false else { return } + guard let jsonString = line.dropPrefix("data: ") else { continue } + guard let data = jsonString.data(using: .utf8) else { continue } + guard let message = try? decoder.decode(TranscriptMessage.self, from: data) else { continue } + await MainActor.run { self.messages.append(message) } + } + } catch { + // Connection ending here — cleanly or as an error — almost + // always just means transcriptor-ai stopped. Same reasoning + // as transcript-tail's calm handling of this: not worth + // treating as a real failure. + } + } + } + + func disconnect() { + task?.cancel() + task = nil + } + + func clear() { + messages.removeAll() + } +} + +extension String { + fileprivate func dropPrefix(_ prefix: String) -> String? { + guard hasPrefix(prefix) else { return nil } + return String(dropFirst(prefix.count)) + } +} diff --git a/transcriptor/SettingsView.swift b/transcriptor/SettingsView.swift new file mode 100644 index 0000000..a0b5646 --- /dev/null +++ b/transcriptor/SettingsView.swift @@ -0,0 +1,156 @@ +// +// SettingsView.swift +// transcriptor +// +// Lets the user pick the directory transcripts are saved to, and configure +// the ASR glossary (Cmd+, — the standard macOS Settings window, not +// controls crammed into the main window). The transcript filename itself +// isn't configurable here: it's derived from the transcription's start +// timestamp in ContentView.start(), per the user's explicit spec. +// + +import AppKit +import SwiftUI + +enum TranscriptSaveLocation { + static let defaultsKey = "transcriptSaveDirectory" + + static var defaultDirectory: URL { + let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + return documents.appendingPathComponent("Transcriptor", isDirectory: true) + } +} + +/// The glossary lives in a plain text file the user edits directly (in +/// whatever app they like), not a text field in this Settings window — +/// simpler for anything longer than a few words, and avoids SwiftUI +/// multi-line TextField layout quirks inside a Form. transcriptor-ai reads +/// its contents at Start time and passes them as-is to qwen_asr's --prompt. +enum GlossaryFileLocation { + static let defaultsKey = "glossaryFilePath" + + static var defaultPath: URL { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + return appSupport.appendingPathComponent("eu.sttlab.transcriptor/glossary.txt") + } +} + +struct SettingsView: View { + @AppStorage(TranscriptSaveLocation.defaultsKey) + private var transcriptSaveDirectory = TranscriptSaveLocation.defaultDirectory.path + @AppStorage(GlossaryFileLocation.defaultsKey) + private var glossaryFilePath = GlossaryFileLocation.defaultPath.path + + // Grid, not Form/LabeledContent: Form sizes each row's trailing content + // independently, so "Choose…" buttons on different rows land at + // different x-positions whenever the path text between them differs in + // length — that's the button-misalignment the user flagged. Grid + // aligns by column across all rows, which is what's actually needed + // here. The glossary caption is its own GridRow (empty first cell, + // text spanning the rest) rather than a sibling view outside the grid, + // so it shares the grid's tight row spacing and reads as attached to + // the row above it instead of floating with a mismatched gap — that + // mismatch (24pt to the caption vs. ~14pt between the two field rows) + // was the second thing flagged. + // Path text gets a generous minWidth so the two rows' paths (different + // lengths) don't squeeze the shared value column down to whatever the + // shorter one needs — plenty of window width is available, use it + // instead of truncating more than necessary. + private let pathMinWidth: CGFloat = 380 + + var body: some View { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 10, verticalSpacing: 14) { + GridRow { + Text("Save transcripts to") + .gridColumnAlignment(.trailing) + Text(transcriptSaveDirectory) + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(.secondary) + .frame(minWidth: pathMinWidth, alignment: .leading) + Button("Choose…") { + chooseDirectory() + } + } + + GridRow { + Text("Glossary file") + Text(glossaryFilePath) + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(.secondary) + .frame(minWidth: pathMinWidth, alignment: .leading) + Button("Choose…") { + chooseGlossaryFile() + } + Button("Open") { + openGlossaryFile() + } + } + + GridRow { + Color.clear.gridCellUnsizedAxes([.horizontal, .vertical]) + // No .gridColumnAlignment here: setting it on a cell that + // also spans multiple columns (gridCellColumns) confused + // Grid's column-width computation and pushed the "Open" + // button on the row above away from "Choose…" with a large + // gap — the Grid-level `alignment:` above already handles + // leading alignment correctly without it. + Text("Comma-separated terms (e.g. \"Claude, Claude Code, Mistral, MCP\") to bias transcription toward — proper nouns, product names, acronyms. Probabilistic, not guaranteed: keep it short and specific to what you expect to hear, not an exhaustive dictionary.") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .gridCellColumns(3) + } + } + .padding(28) + .frame(width: 760, alignment: .leading) + } + + private func chooseDirectory() { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.canCreateDirectories = true + panel.prompt = "Choose" + panel.directoryURL = URL(fileURLWithPath: transcriptSaveDirectory) + + if panel.runModal() == .OK, let url = panel.url { + transcriptSaveDirectory = url.path + } + } + + /// NSSavePanel, not NSOpenPanel: lets the user pick a location for a + /// file that may not exist yet (a fresh glossary), the same way a + /// "Save As…" dialog would — NSOpenPanel can only select files that + /// already exist. + private func chooseGlossaryFile() { + let panel = NSSavePanel() + panel.canCreateDirectories = true + panel.prompt = "Choose" + panel.nameFieldStringValue = (glossaryFilePath as NSString).lastPathComponent + panel.directoryURL = URL(fileURLWithPath: glossaryFilePath).deletingLastPathComponent() + + if panel.runModal() == .OK, let url = panel.url { + glossaryFilePath = url.path + ensureGlossaryFileExists() + } + } + + private func openGlossaryFile() { + ensureGlossaryFileExists() + NSWorkspace.shared.open(URL(fileURLWithPath: glossaryFilePath)) + } + + private func ensureGlossaryFileExists() { + let url = URL(fileURLWithPath: glossaryFilePath) + guard !FileManager.default.fileExists(atPath: url.path) else { return } + try? FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + FileManager.default.createFile(atPath: url.path, contents: Data()) + } +} + +#Preview { + SettingsView() +} diff --git a/transcriptor/TranscriptMessage.swift b/transcriptor/TranscriptMessage.swift new file mode 100644 index 0000000..fe9aadd --- /dev/null +++ b/transcriptor/TranscriptMessage.swift @@ -0,0 +1,54 @@ +// +// TranscriptMessage.swift +// transcriptor +// +// Mirrors transcriptor-ai's internal/transcript.Message JSON format +// (see transcriptor-ai/CLAUDE.md, "Message format (SSE)"). Keep in sync by +// hand — there's no shared schema between the Go and Swift sides. +// + +import Foundation + +struct TranscriptMessage: Codable, Identifiable { + var id = UUID() + let track: String + let text: String + let isFinal: Bool + let timestamp: Date + + enum CodingKeys: String, CodingKey { + case track, text, timestamp + case isFinal = "is_final" + } +} + +extension JSONDecoder { + /// Go's encoding/json emits RFC3339 timestamps with fractional seconds + /// (e.g. "2026-08-07T15:37:57.140302+02:00"). Foundation's built-in + /// .iso8601 strategy doesn't parse fractional seconds unless configured + /// — mirrors the formatter audiotee's own Swift code uses for the same + /// reason (see audiotee/Sources/AudioTeeCore/Utils/Logger.swift). + static var transcriptDecoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let string = try container.decode(String.self) + + let withFractional = ISO8601DateFormatter() + withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = withFractional.date(from: string) { + return date + } + + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + if let date = plain.date(from: string) { + return date + } + + throw DecodingError.dataCorruptedError( + in: container, debugDescription: "Unrecognized date format: \(string)") + } + return decoder + } +} diff --git a/transcriptor/TranscriptorProcess.swift b/transcriptor/TranscriptorProcess.swift new file mode 100644 index 0000000..a01d661 --- /dev/null +++ b/transcriptor/TranscriptorProcess.swift @@ -0,0 +1,90 @@ +// +// TranscriptorProcess.swift +// transcriptor +// +// Spawns transcriptor-ai as a subprocess — same relationship +// transcriptor-ai has to audiotee (each layer manages the one below it via +// a subprocess, never imported as a library). +// + +import Foundation + +enum TranscriptorProcessError: Error { + case alreadyRunning + case binaryNotFound(String) +} + +@Observable +final class TranscriptorProcess { + private(set) var isRunning = false + private var process: Process? + + struct Config { + var binaryPath: String + var audioteeBinaryPath: String + var asrBinaryPath: String + var asrModelDir: String + var httpAddr: String = ":8420" + var transcriptFilePath: String? + var captureSystem = true + var captureMic = true + var glossary: String? + } + + func start(_ config: Config) throws { + guard process == nil else { throw TranscriptorProcessError.alreadyRunning } + for path in [config.binaryPath, config.audioteeBinaryPath, config.asrBinaryPath] { + guard FileManager.default.fileExists(atPath: path) else { + throw TranscriptorProcessError.binaryNotFound(path) + } + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: config.binaryPath) + + // Note: Go's flag package does NOT accept "-flag value" (separate + // argv elements) for boolean flags — "-flag" alone sets it true + // and the next element is left as a stray positional argument. + // Must be "-flag=value" as one element. Verified empirically with + // a throwaway Go program before relying on this, not assumed. + var args = [ + "-audiotee-binary", config.audioteeBinaryPath, + "-asr-binary", config.asrBinaryPath, + "-asr-model-dir", config.asrModelDir, + "-http-addr", config.httpAddr, + "-capture-system=\(config.captureSystem)", + "-capture-mic=\(config.captureMic)", + ] + if let transcriptFilePath = config.transcriptFilePath { + args += ["-transcript-file", transcriptFilePath] + } + if let glossary = config.glossary, !glossary.isEmpty { + args += ["-prompt", glossary] + } + process.arguments = args + + // Forward transcriptor-ai's stderr (its own JSON logs plus + // audiotee's, which it forwards) to Console output for now, useful + // while proving the circuit. Revisit once there's real UI for this. + process.standardError = FileHandle.standardError + + process.terminationHandler = { [weak self] _ in + Task { @MainActor in + self?.isRunning = false + self?.process = nil + } + } + + try process.run() + self.process = process + self.isRunning = true + } + + /// Sends SIGINT (same as a terminal Ctrl+C) so transcriptor-ai runs its + /// own graceful shutdown — draining in-flight transcriptions, flushing + /// the transcript file, closing the SSE server — rather than being + /// killed outright. + func stop() { + process?.interrupt() + } +} diff --git a/transcriptor/en.lproj/InfoPlist.strings b/transcriptor/en.lproj/InfoPlist.strings new file mode 100644 index 0000000..c8343ba --- /dev/null +++ b/transcriptor/en.lproj/InfoPlist.strings @@ -0,0 +1 @@ +CFBundleDisplayName = "Transcriptor"; diff --git a/transcriptor/transcriptorApp.swift b/transcriptor/transcriptorApp.swift new file mode 100644 index 0000000..c61812b --- /dev/null +++ b/transcriptor/transcriptorApp.swift @@ -0,0 +1,22 @@ +// +// transcriptorApp.swift +// transcriptor +// +// Created by Stéphane Tailland on 08/08/2026. +// + +import SwiftUI + +@main +struct transcriptorApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + + var body: some Scene { + WindowGroup { + ContentView(transcriptorProcess: appDelegate.transcriptorProcess) + } + Settings { + SettingsView() + } + } +} diff --git a/transcriptorTests/transcriptorTests.swift b/transcriptorTests/transcriptorTests.swift new file mode 100644 index 0000000..33e845f --- /dev/null +++ b/transcriptorTests/transcriptorTests.swift @@ -0,0 +1,18 @@ +// +// transcriptorTests.swift +// transcriptorTests +// +// Created by Stéphane Tailland on 08/08/2026. +// + +import Testing + +struct transcriptorTests { + + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + // Swift Testing Documentation + // https://developer.apple.com/documentation/testing + } + +} diff --git a/transcriptorUITests/transcriptorUITests.swift b/transcriptorUITests/transcriptorUITests.swift new file mode 100644 index 0000000..c21f2ce --- /dev/null +++ b/transcriptorUITests/transcriptorUITests.swift @@ -0,0 +1,43 @@ +// +// transcriptorUITests.swift +// transcriptorUITests +// +// Created by Stéphane Tailland on 08/08/2026. +// + +import XCTest + +final class transcriptorUITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + @MainActor + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + // XCUIAutomation Documentation + // https://developer.apple.com/documentation/xcuiautomation + } + + @MainActor + func testLaunchPerformance() throws { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } +} diff --git a/transcriptorUITests/transcriptorUITestsLaunchTests.swift b/transcriptorUITests/transcriptorUITestsLaunchTests.swift new file mode 100644 index 0000000..347abe5 --- /dev/null +++ b/transcriptorUITests/transcriptorUITestsLaunchTests.swift @@ -0,0 +1,35 @@ +// +// transcriptorUITestsLaunchTests.swift +// transcriptorUITests +// +// Created by Stéphane Tailland on 08/08/2026. +// + +import XCTest + +final class transcriptorUITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + // XCUIAutomation Documentation + // https://developer.apple.com/documentation/xcuiautomation + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +}