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:
+12
@@ -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/
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "third_party/audiotee"]
|
||||||
|
path = third_party/audiotee
|
||||||
|
url = ssh://git@gitea.sttlab.eu:2222/stt/audiotee.git
|
||||||
@@ -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.
|
||||||
Executable
+40
@@ -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"
|
||||||
+1
Submodule third_party/audiotee added at 678557caf7
@@ -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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
//
|
||||||
|
// ContentView.swift
|
||||||
|
// transcriptor
|
||||||
|
//
|
||||||
|
// Proves the capture→VAD→ASR→SSE circuit end to end from a native UI.
|
||||||
|
// audiotee, transcriptor-ai, and qwen_asr are embedded in the app bundle
|
||||||
|
// (see scripts/embed-binaries.sh, the "Embed audiotee, transcriptor-ai,
|
||||||
|
// qwen_asr" Run Script build phase). The AI model is the one thing still
|
||||||
|
// fetched at runtime (see ModelManager.swift) — deliberately, not an
|
||||||
|
// oversight, see CLAUDE.md "Goal".
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
private enum BundledPaths {
|
||||||
|
static var audioteeBinary: String {
|
||||||
|
Bundle.main.url(forResource: "audiotee", withExtension: nil)!.path
|
||||||
|
}
|
||||||
|
static var transcriptorAiBinary: String {
|
||||||
|
Bundle.main.url(forResource: "transcriptor-ai", withExtension: nil)!.path
|
||||||
|
}
|
||||||
|
static var qwenAsrBinary: String {
|
||||||
|
Bundle.main.url(forResource: "qwen_asr", withExtension: nil)!.path
|
||||||
|
}
|
||||||
|
static let httpAddr = ":8420"
|
||||||
|
static let sseURL = URL(string: "http://localhost:8420/events")!
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
// Owned by AppDelegate, not created here, so it can be stopped from
|
||||||
|
// applicationWillTerminate when the app quits — see AppDelegate.swift.
|
||||||
|
let transcriptorProcess: TranscriptorProcess
|
||||||
|
@State private var modelManager = ModelManager()
|
||||||
|
// @AppStorage, not @State: without this, the picker silently reset to
|
||||||
|
// .large on every app relaunch (its hardcoded default), discarding
|
||||||
|
// whatever the user had picked last session — same persistence
|
||||||
|
// treatment as transcriptSaveDirectory/glossaryFilePath below.
|
||||||
|
// ModelChoice is RawRepresentable (String), which @AppStorage supports
|
||||||
|
// directly, no separate string key needed.
|
||||||
|
@AppStorage("selectedModel") private var selectedModel: ModelChoice = .large
|
||||||
|
@State private var sseClient = SSEClient()
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
// @AppStorage: same reasoning/bug as selectedModel above — these were
|
||||||
|
// still @State until this fix and reset to both-on every relaunch,
|
||||||
|
// silently discarding a "mic off" choice from a prior session. Caught
|
||||||
|
// the same way: a relaunch-for-a-different-test lost this setting
|
||||||
|
// mid-session and produced a confusing, uncontrolled test run.
|
||||||
|
@AppStorage("captureSystem") private var captureSystem = true
|
||||||
|
@AppStorage("captureMic") private var captureMic = true
|
||||||
|
@AppStorage(TranscriptSaveLocation.defaultsKey)
|
||||||
|
private var transcriptSaveDirectory = TranscriptSaveLocation.defaultDirectory.path
|
||||||
|
@AppStorage(GlossaryFileLocation.defaultsKey)
|
||||||
|
private var glossaryFilePath = GlossaryFileLocation.defaultPath.path
|
||||||
|
|
||||||
|
private var controlsDisabled: Bool {
|
||||||
|
transcriptorProcess.isRunning || modelManager.isDownloading
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Picker("Model", selection: $selectedModel) {
|
||||||
|
ForEach(ModelChoice.allCases) { choice in
|
||||||
|
Text(choice.displayName).tag(choice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 260)
|
||||||
|
.disabled(controlsDisabled)
|
||||||
|
|
||||||
|
Text("Capture:")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Toggle("System audio", isOn: $captureSystem)
|
||||||
|
.disabled(controlsDisabled)
|
||||||
|
Toggle("Microphone", isOn: $captureMic)
|
||||||
|
.disabled(controlsDisabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button(transcriptorProcess.isRunning ? "Stop" : "Start") {
|
||||||
|
if transcriptorProcess.isRunning {
|
||||||
|
stop()
|
||||||
|
} else {
|
||||||
|
Task { await start() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(modelManager.isDownloading || (!captureSystem && !captureMic))
|
||||||
|
|
||||||
|
Text(statusText)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
if let errorMessage {
|
||||||
|
Text(errorMessage)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
if modelManager.isDownloading {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(modelManager.statusText)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
ProgressView(value: modelManager.fileProgress)
|
||||||
|
}
|
||||||
|
.padding([.horizontal, .bottom])
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 10) {
|
||||||
|
ForEach(sseClient.messages) { message in
|
||||||
|
MessageBubble(message: message)
|
||||||
|
.id(message.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.onChange(of: sseClient.messages.count) {
|
||||||
|
if let last = sseClient.messages.last {
|
||||||
|
withAnimation {
|
||||||
|
proxy.scrollTo(last.id, anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 480, minHeight: 360)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusText: String {
|
||||||
|
if transcriptorProcess.isRunning { return "Running" }
|
||||||
|
if modelManager.isDownloading { return "Downloading model…" }
|
||||||
|
return "Stopped"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func start() async {
|
||||||
|
errorMessage = nil
|
||||||
|
sseClient.clear()
|
||||||
|
|
||||||
|
let modelDir: URL
|
||||||
|
do {
|
||||||
|
// No-ops immediately if already downloaded — see
|
||||||
|
// ModelManager.isDownloaded. This is what makes "Start" a
|
||||||
|
// single action regardless of whether the model needs
|
||||||
|
// fetching first.
|
||||||
|
modelDir = try await modelManager.ensureModel(selectedModel)
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Model download failed: \(error)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let transcriptFilePath: String?
|
||||||
|
do {
|
||||||
|
transcriptFilePath = try makeTranscriptFilePath(startedAt: Date())
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Couldn't prepare transcript save location: \(error)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = TranscriptorProcess.Config(
|
||||||
|
binaryPath: BundledPaths.transcriptorAiBinary,
|
||||||
|
audioteeBinaryPath: BundledPaths.audioteeBinary,
|
||||||
|
asrBinaryPath: BundledPaths.qwenAsrBinary,
|
||||||
|
asrModelDir: modelDir.path,
|
||||||
|
httpAddr: BundledPaths.httpAddr,
|
||||||
|
transcriptFilePath: transcriptFilePath,
|
||||||
|
captureSystem: captureSystem,
|
||||||
|
captureMic: captureMic,
|
||||||
|
glossary: readGlossaryFile()
|
||||||
|
)
|
||||||
|
do {
|
||||||
|
try transcriptorProcess.start(config)
|
||||||
|
// transcriptor-ai's HTTP server starts early in its own
|
||||||
|
// startup, but not instantly — give it a moment before
|
||||||
|
// connecting rather than building retry logic for this
|
||||||
|
// proof-of-circuit step.
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
sseClient.connect(to: BundledPaths.sseURL)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errorMessage = "\(error)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stop() {
|
||||||
|
transcriptorProcess.stop()
|
||||||
|
sseClient.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the transcript file path for a session that started at
|
||||||
|
/// `startedAt`, creating the save directory if it doesn't exist yet.
|
||||||
|
/// The filename is the start timestamp, not configurable separately —
|
||||||
|
/// per spec, only the directory is a user setting.
|
||||||
|
private func makeTranscriptFilePath(startedAt: Date) throws -> String {
|
||||||
|
let directory = URL(fileURLWithPath: transcriptSaveDirectory)
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss"
|
||||||
|
let filename = "\(formatter.string(from: startedAt)).txt"
|
||||||
|
|
||||||
|
return directory.appendingPathComponent(filename).path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the glossary file's content for use as qwen_asr's --prompt.
|
||||||
|
/// Missing file or read error is not a failure — glossary is optional,
|
||||||
|
/// so this silently returns nil rather than blocking Start.
|
||||||
|
///
|
||||||
|
/// Wraps the raw comma-separated terms as "Preserve spelling: ..." —
|
||||||
|
/// qwen_asr's own documented --prompt example format — instead of
|
||||||
|
/// passing them as a bare list. Found via real testing: a bare list
|
||||||
|
/// occasionally leaked verbatim into the transcript output (the model
|
||||||
|
/// echoed the prompt instead of transcribing that segment's audio,
|
||||||
|
/// e.g. a whole segment came back as literally the glossary terms).
|
||||||
|
/// Framing it as an instruction rather than free-floating content is
|
||||||
|
/// expected to reduce that risk — not proven eliminated, just
|
||||||
|
/// following the tool's own recommended usage instead of deviating
|
||||||
|
/// from it. The glossary *file* itself still holds only the bare
|
||||||
|
/// terms (matches the Settings UI's help text and keeps the file
|
||||||
|
/// simple to hand-edit) — this wrapping is applied here, not stored.
|
||||||
|
private func readGlossaryFile() -> String? {
|
||||||
|
guard let content = try? String(contentsOfFile: glossaryFilePath, encoding: .utf8) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
return "Preserve spelling: \(trimmed)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ContentView(transcriptorProcess: TranscriptorProcess())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a transcript message as a chat bubble — mic ("Me") on the
|
||||||
|
/// right, system ("Them") on the left, matching the "me vs them"
|
||||||
|
/// diarization framing the two-track capture design is built around (see
|
||||||
|
/// transcriptor-ai/CLAUDE.md). Requested explicitly: the plain track-label
|
||||||
|
/// prefix wasn't distinct enough to tell the two apart at a glance.
|
||||||
|
private struct MessageBubble: View {
|
||||||
|
let message: TranscriptMessage
|
||||||
|
|
||||||
|
private var isMic: Bool { message.track == "mic" }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
if isMic { Spacer(minLength: 40) }
|
||||||
|
|
||||||
|
VStack(alignment: isMic ? .trailing : .leading, spacing: 2) {
|
||||||
|
Text(isMic ? "Me" : "Them")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(message.text)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(isMic ? Color.accentColor.opacity(0.85) : Color.secondary.opacity(0.18))
|
||||||
|
.foregroundStyle(isMic ? Color.white : Color.primary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
.opacity(message.isFinal ? 1 : 0.6)
|
||||||
|
|
||||||
|
if !isMic { Spacer(minLength: 40) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
CFBundleDisplayName = "Transcriptor";
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDownWithError() throws {
|
||||||
|
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testExample() throws {
|
||||||
|
// UI tests must launch the application that they test.
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launch()
|
||||||
|
|
||||||
|
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
||||||
|
// XCUIAutomation Documentation
|
||||||
|
// https://developer.apple.com/documentation/xcuiautomation
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testLaunchPerformance() throws {
|
||||||
|
// This measures how long it takes to launch your application.
|
||||||
|
measure(metrics: [XCTApplicationLaunchMetric()]) {
|
||||||
|
XCUIApplication().launch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user