The initial commit's embed-binaries.sh still had the pre-submodule logic (../audiotee sibling checkout) even though third_party/audiotee was already added and referenced in CLAUDE.md — the edit to actually switch the script over got made but never re-staged before the first commit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
38 KiB
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-tailalready uses). - Save the transcript to a text file, live, so a crash loses as little as possible.
Already implemented on the
transcriptor-aiside, 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-aiis 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-aialready implements this via-transcript-file(see itsinternal/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'sEventSourceortranscript-tail's manual line-parsing does — no special client library needed in Swift either (aURLSessiondata task reading line-by-line works, same parsing logic astranscript-tail). - Binaries embedded at build time, not referenced by a fixed external path. Rejected the
simpler "just spawn
~/bin/audioteeand a hardcodedqwen_asrpath" 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 invokego build,make, and Swift's own build across three different toolchains, and the resulting.appneeds 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/audioteestill showsAuthority=sttlab-appsafter the outer app is built and signed — untouched.codesign --verify --verbose transcriptor.app→ "valid on disk", "satisfies its Designated Requirement".qwen_asrandtranscriptor-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:
third_party/audiotee— a git submodule (2026-08-09), pinned to a commit on our own Gitea fork (ssh://git@gitea.sttlab.eu:2222/stt/audiotee.git, migrated there fromgithub.com/makeusabrew/audioteesince we don't have push access upstream — that GitHub remote is nowupstreamin the audiotee repo,originis the Gitea fork). Was a sibling directory (../audiotee) before this — converted for the same reasontranscriptor-aialready vendorsqwen-asras a submodule: this script shouldn't depend on a checkout existing at some assumed sibling path on whatever machine builds this. The script runsgit submodule update --init --recursive third_party/audiotee, thenthird_party/audiotee/scripts/build-signed.sh, then copies the resulting~/bin/audioteein (build-signed.shalways installs there regardless of where its own source checkout lives, so nothing else needed to change). Verified end to end after the conversion: ran the script standalone, confirmed (viacodesign -dvvvandmd5) the embedded binary was freshly built from the submodule checkout and correctly signed, not a stale leftover.../transcriptor-ai— still a sibling directory, not a submodule (not converted — wasn't asked for, and the audiotee fragility this session's change addressed doesn't apply to it the same way yet). Runsscripts/build-dist.shthere (builds bothtranscriptor-aiandqwen_asr, the latter from a pinned git submodule,third_party/qwen-asr, inside that repo, intodist/), then copiesdist/*in. This repo still doesn't need to know about qwen-asr directly — that staystranscriptor-ai's dependency to manage.- Copies all three into
Contents/Resources/in the built app,chmod +x'd. - Prepends
/opt/homebrew/bintoPATHat the top of the script — Xcode Run Script phases don't inherit a normal shell's PATH, sogo(used bytranscriptor-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.mdonce 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 atranscriptor/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 istranscriptor.app; the repo istranscriptor-ui, deliberately different — don't rename to match, that's already decided). - Bundle identifier:
eu.sttlab.transcriptor(organization identifiereu.sttlab, notcom.stephanetailland— matches thesttlab-appscode-signing certificate already created foraudiotee; 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 bytranscriptor-ai, not this app — see "Why these choices"). - Built and launch-tested once already (
xcodebuild ... build, thenopenthe resulting.app, confirmed the process actually runs) — this is the stock SwiftUI template (ContentView.swift/transcriptorApp.swift), no project-specific code yet. .gitignoreadded 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 intogit statusbefore 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 = YESby default. A sandboxed app cannot spawn arbitrary external processes — fundamentally incompatible with what this app does (spawntranscriptor-ai, which spawnsaudiotee/qwen_asr). Disabled (ENABLE_APP_SANDBOX = NOin both Debug and Release). Correct call for a personal, non-App-Store tool — same reasoning as whyaudioteeitself 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.
audioteealready has a valid, working signed identity (sttlab-apps) and its own embeddedInfo.plistwith the usage-description keys. That was NOT enough onceaudioteeruns 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 sincetranscriptor.app's Info.plist had no usage-description keys of its own, the request failed outright instead of prompting. Confirmed vialog show --predicate 'process == "audiotee"'(/usr/bin/log, not the zsh builtinlog— that shadows it and errors with "too many arguments") — aTCCAccessRequest() IPCimmediately followed by a CoreAudio HAL error whose code decodes as ASCII "nope" (0x6E6F7065/1852797029), with no dialog ever shown. Fix:transcriptor.appneeds its ownNSMicrophoneUsageDescriptionANDNSAudioCaptureUsageDescription, same asaudioteehas.NSMicrophoneUsageDescriptionis a recognizedINFOPLIST_KEY_*build setting Xcode synthesizes automatically, butNSAudioCaptureUsageDescriptionis not (same gapaudiotee's own CONTEXT.md already documented for its Xcode dropdown) — silently dropped if you tryINFOPLIST_KEY_*for it. Switched the target to a real, physicalInfo.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 aPBXFileSystemSynchronizedBuildFileExceptionSetexcludingInfo.plistfrom "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 atccutil 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 assumeaudiotee's own Info.plist is sufficient just because it worked standalone via CLI. - Quitting the app orphaned
transcriptor-ai/audioteeas background processes — normalCmd+Q/Quit did not stop them, confirmed by quitting and checkingpgrepafterward. Fixed by movingTranscriptorProcessownership to a properNSApplicationDelegate(AppDelegate.swift) soapplicationWillTerminatecan call.stop()(sends SIGINT, same graceful shutdowntranscriptor-aialready has).ContentViewnow receives the shared instance via init instead of creating its own@State. Verified: start capture, quit via the app's own Quit (tested viaosascript ... quit, which triggers the same termination path as the user doing it),pgrepshows nothing left running. Known gap, not fixed (can't be, at this layer): a force-kill (Activity Monitor, SIGKILL) skipsapplicationWillTerminateentirely 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'sGlossaryFileLocationenum: default path~/Library/Application Support/eu.sttlab.transcriptor/glossary.txt, location persisted via@AppStorage. Settings UI has "Choose…" (NSSavePanel, notNSOpenPanel— lets picking a location for a file that doesn't exist yet, the way "Save As…" does;NSOpenPanelcan 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 asTranscriptorProcess.Config.glossary→-prompttotranscriptor-ai→--prompttoqwen_asr. Missing/empty file →nil, silently no-promptpassed — 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")—ModelChoiceisRawRepresentable(String), which@AppStoragesupports 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:
- A multi-line
TextField(..., axis: .vertical)insideLabeledContentinsideFormrendered 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. 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 abandoningForm/LabeledContententirely for this view in favor of a hand-rolledVStackwith 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 reintroduceForm/LabeledContenthere — 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, andqwen_asr— no separate manual step. Verified by moving~/bin/audioteeaside 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 aPicker. 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 supportlazy var(init-accessor synthesis conflict — fixed with@ObservationIgnoredon the one lazy property,URLSession, which isn't UI-relevant state anyway), and a ternary expression can't mix aVoid-returning branch with aTask { }-returning branch (fixed with a plainif/elsein 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
~/Documentson this machine — a separate, per-app macOS "Files and Folders" TCC grant that Terminal doesn't have, unrelated to whethertranscriptor.appitself 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
curlstarted 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
Toggles ("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 newtranscriptor-aiflags,-capture-system/-capture-mic(both defaulttrue) — passed as-flag=value, not-flag value, because Go'sflagpackage does not accept a space-separated value for boolean flags:-flag valueleaves 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 — seeTranscriptorProcess.swift's comment at the call site. - Important asymmetry, also documented in
transcriptor-ai/CLAUDE.md:-capture-mic=falseskips mic capture at theaudioteelevel entirely (no permission requested).-capture-system=falsecannot do the same — audiotee has no flag to skip its system tap, so it keeps running; disabling it just stopstranscriptor-aifrom transcribing that track. Verified via CLI (not just via the app) with system audio playing while-capture-system=false: zerotrack: systemmessages reached the SSE stream, confirming the drop actually works. MessageBubble(new private view inContentView.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 sinceaudiotee. 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:
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.