initial commit: native macOS app for transcriptor-ai

SwiftUI app, the one-click way to use the local transcription pipeline —
installs and runs with no separate manual steps. The Xcode build phase
(scripts/embed-binaries.sh) builds audiotee (git submodule, pinned to a
commit on our own Gitea fork) and transcriptor-ai + qwen_asr (sibling
checkout, which itself vendors qwen-asr as a submodule), then embeds all
three binaries in the app bundle. The AI model is the one thing still
fetched at runtime, via ModelManager.swift, since it's multi-GB and
doesn't belong baked into a build.

Also implements: model download with real progress UI, capture source
selection (system/mic/both), a Settings window for the transcript save
location and a glossary file (biases ASR toward proper nouns/product
names via qwen_asr's --prompt), and a live chat-bubble transcript view.

See CLAUDE.md for the full architecture, the reasoning behind each
decision, and a fairly long list of real bugs found via actual testing
(not just code review) — TCC permission escalation through a subprocess
tree, SwiftUI Form/Grid layout quirks, @State vs @AppStorage persistence,
a prompt-leakage bug in the glossary feature, and more.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sttlab-tech
2026-08-09 13:26:29 +02:00
commit 8bd1a6caf8
23 changed files with 2288 additions and 0 deletions
+12
View File
@@ -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/
+3
View File
@@ -0,0 +1,3 @@
[submodule "third_party/audiotee"]
path = third_party/audiotee
url = ssh://git@gitea.sttlab.eu:2222/stt/audiotee.git
+512
View File
@@ -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 (`<c>` 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: <content>"`, 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.
+40
View File
@@ -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"
Vendored Submodule
+1
Submodule third_party/audiotee added at 678557caf7
+631
View File
@@ -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 = "<group>";
};
A95995AD3026FAFD006E5ACA /* transcriptorTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = transcriptorTests;
sourceTree = "<group>";
};
A95995B73026FAFD006E5ACA /* transcriptorUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = transcriptorUITests;
sourceTree = "<group>";
};
/* 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 = "<group>";
};
A959959E3026FAFC006E5ACA /* Products */ = {
isa = PBXGroup;
children = (
A959959D3026FAFC006E5ACA /* transcriptor.app */,
A95995AA3026FAFD006E5ACA /* transcriptorTests.xctest */,
A95995B43026FAFD006E5ACA /* transcriptorUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
/* 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 */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
+22
View File
@@ -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()
}
}
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -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
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+271
View File
@@ -0,0 +1,271 @@
//
// ContentView.swift
// transcriptor
//
// Proves the captureVADASRSSE circuit end to end from a native UI.
// audiotee, transcriptor-ai, and qwen_asr are embedded in the app bundle
// (see scripts/embed-binaries.sh, the "Embed audiotee, transcriptor-ai,
// qwen_asr" Run Script build phase). The AI model is the one thing still
// fetched at runtime (see ModelManager.swift) deliberately, not an
// oversight, see CLAUDE.md "Goal".
//
import SwiftUI
private enum BundledPaths {
static var audioteeBinary: String {
Bundle.main.url(forResource: "audiotee", withExtension: nil)!.path
}
static var transcriptorAiBinary: String {
Bundle.main.url(forResource: "transcriptor-ai", withExtension: nil)!.path
}
static var qwenAsrBinary: String {
Bundle.main.url(forResource: "qwen_asr", withExtension: nil)!.path
}
static let httpAddr = ":8420"
static let sseURL = URL(string: "http://localhost:8420/events")!
}
struct ContentView: View {
// Owned by AppDelegate, not created here, so it can be stopped from
// applicationWillTerminate when the app quits see AppDelegate.swift.
let transcriptorProcess: TranscriptorProcess
@State private var modelManager = ModelManager()
// @AppStorage, not @State: without this, the picker silently reset to
// .large on every app relaunch (its hardcoded default), discarding
// whatever the user had picked last session same persistence
// treatment as transcriptSaveDirectory/glossaryFilePath below.
// ModelChoice is RawRepresentable (String), which @AppStorage supports
// directly, no separate string key needed.
@AppStorage("selectedModel") private var selectedModel: ModelChoice = .large
@State private var sseClient = SSEClient()
@State private var errorMessage: String?
// @AppStorage: same reasoning/bug as selectedModel above these were
// still @State until this fix and reset to both-on every relaunch,
// silently discarding a "mic off" choice from a prior session. Caught
// the same way: a relaunch-for-a-different-test lost this setting
// mid-session and produced a confusing, uncontrolled test run.
@AppStorage("captureSystem") private var captureSystem = true
@AppStorage("captureMic") private var captureMic = true
@AppStorage(TranscriptSaveLocation.defaultsKey)
private var transcriptSaveDirectory = TranscriptSaveLocation.defaultDirectory.path
@AppStorage(GlossaryFileLocation.defaultsKey)
private var glossaryFilePath = GlossaryFileLocation.defaultPath.path
private var controlsDisabled: Bool {
transcriptorProcess.isRunning || modelManager.isDownloading
}
var body: some View {
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 8) {
HStack {
Picker("Model", selection: $selectedModel) {
ForEach(ModelChoice.allCases) { choice in
Text(choice.displayName).tag(choice)
}
}
.frame(maxWidth: 260)
.disabled(controlsDisabled)
Text("Capture:")
.foregroundStyle(.secondary)
Toggle("System audio", isOn: $captureSystem)
.disabled(controlsDisabled)
Toggle("Microphone", isOn: $captureMic)
.disabled(controlsDisabled)
}
HStack {
Button(transcriptorProcess.isRunning ? "Stop" : "Start") {
if transcriptorProcess.isRunning {
stop()
} else {
Task { await start() }
}
}
.disabled(modelManager.isDownloading || (!captureSystem && !captureMic))
Text(statusText)
.foregroundStyle(.secondary)
Spacer()
if let errorMessage {
Text(errorMessage)
.foregroundStyle(.red)
.lineLimit(1)
}
}
}
.padding()
if modelManager.isDownloading {
VStack(alignment: .leading, spacing: 4) {
Text(modelManager.statusText)
.font(.caption)
.foregroundStyle(.secondary)
ProgressView(value: modelManager.fileProgress)
}
.padding([.horizontal, .bottom])
}
Divider()
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 10) {
ForEach(sseClient.messages) { message in
MessageBubble(message: message)
.id(message.id)
}
}
.padding()
}
.onChange(of: sseClient.messages.count) {
if let last = sseClient.messages.last {
withAnimation {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
}
}
}
.frame(minWidth: 480, minHeight: 360)
}
private var statusText: String {
if transcriptorProcess.isRunning { return "Running" }
if modelManager.isDownloading { return "Downloading model…" }
return "Stopped"
}
private func start() async {
errorMessage = nil
sseClient.clear()
let modelDir: URL
do {
// No-ops immediately if already downloaded see
// ModelManager.isDownloaded. This is what makes "Start" a
// single action regardless of whether the model needs
// fetching first.
modelDir = try await modelManager.ensureModel(selectedModel)
} catch {
errorMessage = "Model download failed: \(error)"
return
}
let transcriptFilePath: String?
do {
transcriptFilePath = try makeTranscriptFilePath(startedAt: Date())
} catch {
errorMessage = "Couldn't prepare transcript save location: \(error)"
return
}
let config = TranscriptorProcess.Config(
binaryPath: BundledPaths.transcriptorAiBinary,
audioteeBinaryPath: BundledPaths.audioteeBinary,
asrBinaryPath: BundledPaths.qwenAsrBinary,
asrModelDir: modelDir.path,
httpAddr: BundledPaths.httpAddr,
transcriptFilePath: transcriptFilePath,
captureSystem: captureSystem,
captureMic: captureMic,
glossary: readGlossaryFile()
)
do {
try transcriptorProcess.start(config)
// transcriptor-ai's HTTP server starts early in its own
// startup, but not instantly give it a moment before
// connecting rather than building retry logic for this
// proof-of-circuit step.
Task {
try? await Task.sleep(for: .seconds(1))
sseClient.connect(to: BundledPaths.sseURL)
}
} catch {
errorMessage = "\(error)"
}
}
private func stop() {
transcriptorProcess.stop()
sseClient.disconnect()
}
/// Builds the transcript file path for a session that started at
/// `startedAt`, creating the save directory if it doesn't exist yet.
/// The filename is the start timestamp, not configurable separately
/// per spec, only the directory is a user setting.
private func makeTranscriptFilePath(startedAt: Date) throws -> String {
let directory = URL(fileURLWithPath: transcriptSaveDirectory)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss"
let filename = "\(formatter.string(from: startedAt)).txt"
return directory.appendingPathComponent(filename).path
}
/// Reads the glossary file's content for use as qwen_asr's --prompt.
/// Missing file or read error is not a failure glossary is optional,
/// so this silently returns nil rather than blocking Start.
///
/// Wraps the raw comma-separated terms as "Preserve spelling: ..."
/// qwen_asr's own documented --prompt example format instead of
/// passing them as a bare list. Found via real testing: a bare list
/// occasionally leaked verbatim into the transcript output (the model
/// echoed the prompt instead of transcribing that segment's audio,
/// e.g. a whole segment came back as literally the glossary terms).
/// Framing it as an instruction rather than free-floating content is
/// expected to reduce that risk not proven eliminated, just
/// following the tool's own recommended usage instead of deviating
/// from it. The glossary *file* itself still holds only the bare
/// terms (matches the Settings UI's help text and keeps the file
/// simple to hand-edit) this wrapping is applied here, not stored.
private func readGlossaryFile() -> String? {
guard let content = try? String(contentsOfFile: glossaryFilePath, encoding: .utf8) else {
return nil
}
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return "Preserve spelling: \(trimmed)"
}
}
#Preview {
ContentView(transcriptorProcess: TranscriptorProcess())
}
/// Renders a transcript message as a chat bubble mic ("Me") on the
/// right, system ("Them") on the left, matching the "me vs them"
/// diarization framing the two-track capture design is built around (see
/// transcriptor-ai/CLAUDE.md). Requested explicitly: the plain track-label
/// prefix wasn't distinct enough to tell the two apart at a glance.
private struct MessageBubble: View {
let message: TranscriptMessage
private var isMic: Bool { message.track == "mic" }
var body: some View {
HStack {
if isMic { Spacer(minLength: 40) }
VStack(alignment: isMic ? .trailing : .leading, spacing: 2) {
Text(isMic ? "Me" : "Them")
.font(.caption2)
.foregroundStyle(.secondary)
Text(message.text)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(isMic ? Color.accentColor.opacity(0.85) : Color.secondary.opacity(0.18))
.foregroundStyle(isMic ? Color.white : Color.primary)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.opacity(message.isFinal ? 1 : 0.6)
if !isMic { Spacer(minLength: 40) }
}
}
}
+34
View File
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleDisplayName</key>
<string>Transcriptor</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSHumanReadableCopyright</key>
<string></string>
<key>NSMicrophoneUsageDescription</key>
<string>transcriptor captures microphone audio for local, on-device meeting transcription.</string>
<key>NSAudioCaptureUsageDescription</key>
<string>transcriptor captures system audio for local, on-device meeting transcription.</string>
</dict>
</plist>
+176
View File
@@ -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, Error>) -> 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<Void, Error>) 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))
}
}
+58
View File
@@ -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<Void, Never>?
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))
}
}
+156
View File
@@ -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()
}
+54
View File
@@ -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
}
}
+90
View File
@@ -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()
}
}
+1
View File
@@ -0,0 +1 @@
CFBundleDisplayName = "Transcriptor";
+22
View File
@@ -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()
}
}
}
+18
View File
@@ -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
}
}
@@ -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 its 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()
}
}
}
@@ -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)
}
}