diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f441fec..ca1b126 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,40 +1,85 @@ name: CI +# `paths` with negations rather than `paths-ignore`: GitHub documents the `!` +# re-include only for `paths`, and this is what makes an edit to this file +# run CI while the rest of .github and the docs do not. Later patterns win. on: push: branches: [main] - paths-ignore: - - 'docs/**' - - '.github/**' - - '!.github/**/*.yml' - - 'README.md' - - 'LICENSE' - - '**/*.md' + paths: + - '**' + - '!docs/**' + - '!**/*.md' + - '!LICENSE' + - '!.github/**' + - '.github/workflows/*.yml' pull_request: - paths-ignore: - - 'docs/**' - - '.github/**' - - '!.github/**/*.yml' - - 'README.md' - - 'LICENSE' - - '**/*.md' + paths: + - '**' + - '!docs/**' + - '!**/*.md' + - '!LICENSE' + - '!.github/**' + - '.github/workflows/*.yml' jobs: build-and-test: # Pladder targets macOS 26 and needs Xcode 26. runs-on: macos-26 + timeout-minutes: 45 steps: - uses: actions/checkout@v4 - name: Select Xcode 26 - run: sudo xcode-select -s /Applications/Xcode_26.app || true + # The newest Xcode 26 on the runner. No fallback: building with the + # runner's default Xcode would test a toolchain Pladder does not use. + run: | + XCODE=$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1) + test -n "$XCODE" + sudo xcode-select -s "$XCODE" - name: Swift version run: swift --version - name: Build - run: swift build + # Warnings in Pladder's own sources fail the build; the dependencies' + # are theirs to fix. + run: | + set -o pipefail + swift build 2>&1 | tee build.log + if grep -E "^$PWD/Sources/.*warning:" build.log; then exit 1; fi - name: Test - run: swift test - - name: Bundle (ad-hoc signed) - run: CODESIGN_IDENTITY=- ./scripts/bundle.sh + # The suite takes seconds. If it stalls, the stack of every thread of + # the test process goes into the log before it is stopped, so a hang + # says where it is instead of holding the runner for an hour. + timeout-minutes: 15 + run: | + set +e + swift build --build-tests || exit 1 + swift test --skip-build & + TEST=$! + for _ in $(seq 1 300); do + kill -0 "$TEST" 2>/dev/null || break + sleep 1 + done + if kill -0 "$TEST" 2>/dev/null; then + echo "::error::swift test still running after 5 minutes; sampling its threads" + for pid in $(pgrep -f 'swiftpm-testing-helper|PladderPackageTests'); do + sample "$pid" 3 -mayDie -file "sample-$pid.txt" + cat "sample-$pid.txt" + done + pkill -P "$TEST"; kill "$TEST" + exit 1 + fi + wait "$TEST" + - name: Scripts never stop every Pladder + # The developer dictates with a running copy while scripts run; a + # bare kill by name would stop it mid-recording (see CLAUDE.md). + run: | + # Commands only: a comment may name what not to do. + if grep -rnE '^[^#]*(pkill -x Pladder|killall Pladder)' scripts; then exit 1; fi + - name: Bundle + # No CODESIGN_IDENTITY on purpose: the runner has no certificate, so + # this runs the auto-detection and its ad-hoc fallback, which once + # ended the script silently on a Mac without a certificate. + run: ./scripts/bundle.sh - uses: actions/upload-artifact@v4 with: name: Pladder.app diff --git a/CLAUDE.md b/CLAUDE.md index 5b4e914..054ddd6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Push-to-talk dictation for macOS 26 on Apple Silicon. Hold a key, speak, release 1. **Speed of the release-to-paste path.** This is the metric. The path runs from the hotkey release to the paste: capture stop, engine, processors, output. In code it is everything between the coordinator's `recordingStopped` and `inserted` events. Nothing goes on that path without a before-and-after benchmark. 2. **Minimal UI.** No new settings, windows or overlay elements unless a feature cannot work without them. Prefer removing to adding. -3. **On-device processing and privacy.** Audio and text never leave the Mac. No network calls, no telemetry, no accounts. One-time model downloads from Hugging Face are the only exception and stay the only one: the speech model at first launch, and an S1-mini polish model only when the user picks it. Each is pinned to a commit and checked against its SHA-256. +3. **On-device processing and privacy.** Audio and text never leave the Mac. No network calls, no telemetry, no accounts. One-time model downloads from Hugging Face are the only exception and stay the only one: the speech model at first launch, and an S1-mini polish model only when the user picks it. The S1-mini files are pinned to a commit and checked against their SHA-256; the speech model comes through FluidAudio's own downloader, from its repository's main branch, checked by size only. ## Critical-path rule @@ -45,15 +45,17 @@ swift run -c release pladder-cli polish-set docs/polish-set.json --model s1-mini | Interrupted press | A non-chord key within 1 s of the chord press cancels the recording without transcribing | Anyone who records a lone Command key shares it with Cmd+C, Cmd+V and Cmd+Tab; the overlay waits 150 ms before showing so those never flash it. Option+Space shares no modifier with them | | Secure Event Input | `IsSecureEventInputEnabled()` polled with the grant; sustained 3 s and a chord Carbon can register → Carbon monitor until it clears | A password field or Terminal's Secure Keyboard Entry stops taps receiving key events; modifier-only chords are unaffected and stay on the tap | | Without Accessibility | Carbon `RegisterEventHotKey` plus clipboard-only output | A standard account cannot grant Accessibility without an admin. Carbon needs no permission but wants exactly one regular key and collapses left and right, so modifier-only chords are refused in the recorder; the transcript is left on the clipboard and the overlay says "press ⌘V". A stored chord Carbon cannot register, a lone Right Command say, is stood in for by the default Option+Space and the menu names it; the stored chord returns with the grant. `CopySymbolicHotKeys` only feeds the warning that an enabled macOS shortcut owns the recorded chord. `AppModel` polls the grant every two seconds and swaps the monitor in both directions | -| Send key | Press V (configurable) while the hotkey is held and Return is posted 50 ms after Cmd+V | Sends a chat message or runs a command without a second trip to the keyboard; the Return is posted from a detached task so it stays off the release-to-paste path. V because the index finger of the hand holding Option+Space rests on it, so the other hand stays on the mouse; Right Option, the default before, needed both hands. A letter is fine here: it is swallowed only while the chord is held. Needs Accessibility like the paste: Carbon registers only the chord, so there Option+V reaches the app. Only for a held recording: a latched one ends on the closing press, before any send key could arm | -| Polish toggle | A Processing-tab toggle (Experimental section), off by default, no key of its own. When on, every dictation runs the usual pipeline, then Apple's on-device model (FoundationModels, `PladderRefine`) with a fixed cleanup prompt, then pastes | Self-corrections, spoken punctuation, number words and lists are beyond the deterministic processors, and the model runs on device with nothing to download. It costs one to three seconds and sits on the normal hotkey's path, which is why it is marked experimental and off: the branch is one Bool read; the session is prewarmed at key-down; transcripts under four words skip it; anything the model cannot do (Apple Intelligence off, refusal, the 8 s timeout) pastes the text as dictated. Logged as its own `polished release-to-paste` line | -| Learned corrections | After a paste the field is watched through Accessibility for up to 60 s; a word the user corrects that passes a token diff, a phonetic gate (Soundex or edit distance ≤ 2) and a yes/no review by the on-device model becomes one menu line, "Learned “x” → “y”? Add / Dismiss" | Nothing runs before Cmd+V is posted: the hook is in `AppModel.handle(.inserted)`, the watcher lives on its own thread and reads only the pasted range plus a margin, the review runs on a detached task. Present only with Accessibility and Apple Intelligence, absent otherwise, no setting, no change to the menu bar glyph. Dismissed pairs go to `dismissed-corrections.json`, not settings, so a bug there can never cost the dictionary. Pure case changes are never proposed. Terminals and TUIs expose a screen buffer, not a field, so nothing is learned there | +| Send key | Press V (configurable) while the hotkey is held and Return is posted 50 ms after the target app reads the paste, or 400 ms after Cmd+V if nothing reads it | Sends a chat message or runs a command without a second trip to the keyboard; the Return is posted from a detached task so it stays off the release-to-paste path, and waits for the read so an app that reads late gets the text first (Chromium's early read starts the wait too, so there it is as early as a fixed delay); a Return still owed when the next paste starts goes out ahead of that paste's Cmd+V. V because the index finger of the hand holding Option+Space rests on it, so the other hand stays on the mouse; Right Option, the default before, needed both hands. A letter is fine here: it is swallowed only while the chord is held. Needs Accessibility like the paste: Carbon registers only the chord, so there Option+V reaches the app. Only for a held recording: a latched one ends on the closing press, before any send key could arm | +| Polish toggle | A Processing-tab toggle (Experimental section), off by default, no key of its own. When on, every dictation runs the usual pipeline, then Apple's on-device model (FoundationModels, `PladderRefine`) with a fixed cleanup prompt, then pastes | Self-corrections, spoken punctuation, number words and lists are beyond the deterministic processors, and the model runs on device with nothing to download. It costs one to three seconds and sits on the normal hotkey's path, which is why it is marked experimental and off: the branch is one Bool read; the session is prewarmed at key-down; transcripts under four words skip it; anything the model cannot do (Apple Intelligence off, refusal, an answer cut off at its token limit, the 8 s budget, which covers loading the model and every chunk of a long dictation) pastes the text as dictated. Logged as its own `polished release-to-paste` line | +| Learned corrections | After a paste the field is watched through Accessibility for up to 60 s; a word the user corrects that passes a token diff, a phonetic gate (Soundex or edit distance ≤ 2) and a yes/no review by the on-device model becomes one menu line, "Learned “x” → “y”? Add / Dismiss" | Nothing runs before Cmd+V is posted: the hook is in `AppModel.handle(.inserted)`, the watcher lives on its own thread and reads only the pasted range plus a margin, the review runs on a detached task and waits while a dictation is in flight, since it shares Apple's model with the polish. Only a paste that stayed in its field is watched: not a copy, not a send. Present only with Accessibility and Apple Intelligence, absent otherwise, no setting, no change to the menu bar glyph. Dismissed pairs go to `dismissed-corrections.json`, not settings, so a bug there can never cost the dictionary. Pure case changes are never proposed. Terminals and TUIs expose a screen buffer, not a field, so nothing is learned there | | Toggle key | Another recordable chord, off by default. A chord of its own latches at release however long the press; equal to the push-to-talk chord it makes that key hybrid: a tap under 400 ms latches, a longer hold stops at release. The next press of any chord, Escape or the 10 min cap ends a latched recording. The overlay is the same as for a held recording, in and out; only the menu's status line says which key stops it | Two-minute dictations should not need a key held for two minutes. Handy and VoiceInk default to hybrid on one key; here it is opt-in, because a stray tap would otherwise leave the microphone open until the cap pastes two minutes of room noise. Hold, toggle and hybrid are decided in `HotkeyGestureTracker`, a clockless value type timed by the instant each monitor stamps on its events, so both monitors behave alike and a press that waits for the microphone cannot make the next release look longer. A same-chord press within 50 ms of its release is a bounce (some Bluetooth keyboards do this mid-hold): it never acts, and the first one seen turns on a 50 ms settle before every stopping release for the rest of the run, so only a keyboard that needs it pays for it and the release path is otherwise untouched. No separate press debounce: both monitors already report alternating presses and releases. Without Accessibility the toggle chord registers with Carbon like the key; one Carbon cannot register has no stand-in, except that a hybrid chord follows the key's. With a lone modifier as a hybrid key, the Command of a later Cmd+C ends a latched recording | | Escape | Discards a recording without transcribing and plays the stop sound; taken only while a recording is on | Never taken globally, so Escape keeps closing dialogs. On the tap `HotkeyChordSet` catches it before the chord trackers, so the interrupted-press rule never sees it, and Escape with the chord's own modifiers held still counts; Carbon registers the bare key around each recording, from the main queue so the release path never waits on it. Under Secure Event Input a modifier-only chord stays on the tap, where no key-down arrives, so Escape cannot cancel there | | Output | Clipboard + simulated Cmd+V; the transcript is a pasteboard promise and the old clipboard comes back off the critical path, 400 ms after Cmd+V if the target app has read it by then, otherwise 200 ms after it does, or after 8 s if nothing does | Universal, fast. Waiting for the read as well as the 400 ms: an app busy when Cmd+V arrives reads late, and the timer alone handed it the old clipboard (issue #40). A read never shortens the 400 ms, because Chromium sometimes reads once before the real paste and the pasteboard serves the second read itself. The promise is served on the main thread, so a busy Pladder main thread delays the target's read; the `paste` log line records when it came. It relies on the nspasteboard.org markers: without them Universal Clipboard reads every write within ~15 ms, which would pass for the paste | | Polish model | A picker under the toggle: Apple Intelligence (default), S1-mini by Superwhisper at full precision (f16, 1.5 GB) or 8-bit (Q8_0, 805 MB), the S1-mini files run by llama.cpp on the GPU. `PolishRouter` is the coordinator's one refiner and forwards to the chosen model | On the polish set (docs/BENCHMARKS.md) S1-mini is more accurate than Apple's model and three to five times faster, and translated nothing where Apple's model translated two dictations. It is Qwen3-0.6B fine-tuned for dictation cleanup, trained on English, and needs its own fixed prompt. llama.cpp comes as its prebuilt XCFramework, pinned by release and checksum, because MLX Swift needs Xcode to build its Metal shaders; `bundle.sh` embeds and signs the framework. The GPU leaves the Neural Engine to Parakeet. A file downloads only when polish is on and the model picked, is loaded at the first key-down with the fixed prompt prefix decoded once, and is freed on a switch or with polish off. The licence asks for the name as "S1-mini" by "Superwhisper" | | Post-processing | Filler remover, dictionary replacer, fuzzy custom-word corrector, whitespace normaliser, spoken punctuation, in that order | No latency, no network. Spoken punctuation takes only phrases that are never ordinary words ("period", "Punkt", "punto" stay words), runs last because the whitespace step folds line breaks, and needs Spanish evidence for "coma"; Parakeet already writes numbers as digits. An earlier Apple Intelligence step was removed from this path unmeasured; the model is back behind the experimental polish toggle only | -| Mute while dictating | Off by default; `kAudioDevicePropertyMute` on the default output device 200 ms into a recording, restored off the release path | Music or a call otherwise goes into the microphone. The delay means a tap-and-release never toggles anything; a device the user had already muted is left alone, and the device that was muted is the one unmuted even if the default changed meanwhile | +| Mute while dictating | Off by default; `kAudioDevicePropertyMute` on the default output device 200 ms into a recording, restored off the release path | Music or a call otherwise goes into the microphone. The delay means a tap-and-release never toggles anything; a device or channel the user had already muted is left as it was, and the device that was muted is the one unmuted even if the default changed meanwhile. Start and end carry the recording's number, so an end that overtakes its start still wins | +| Quit | Quit and SIGTERM wait up to 3 s for `DictationCoordinator.shutdown()`: a recording is dropped, a dictation on its way out is pasted, the speakers and the clipboard are given back, the clipboard only once the target app has read the transcript (at most `restoreFloor` plus `readSettle` after Cmd+V). SIGTERM is caught off the main thread and ends the process 5 s later regardless | A latched recording otherwise left the speakers muted, and a quit within 8 s of a paste left the transcript on the clipboard; restoring at once instead would hand a dictation pasted on the way out the user's old clipboard. The hard exit keeps a hung copy stoppable by `pkill` | +| Settings file | Decoded key by key on top of the defaults; a value a build cannot read keeps its default, and the file is copied aside once; that value, until the user changes the setting, and keys a build does not know survive its saves | An older worktree build reading a newer build's file once moved the whole file aside and started over with an empty dictionary | | UI language | Follows the macOS system language; no setting | String Catalogs (`Localizable.xcstrings` in the app, `KeyNames.xcstrings` in `PladderSystem`) are compiled by `swift build`; `bundle.sh` merges their `.lproj` folders into `Pladder.app/Contents/Resources`, so `Bundle.main` serves them and no code names a bundle. `swift run` shows English. Core and Engines emit enum cases; the app turns them into text. German first; more languages are catalog contributions | | Recording cap | 10 min | Keeps the microphone from staying on when a key-up is lost. The cap ends a latched recording the same way | | Benchmark | A script run by hand, not a test | A benchmark that fails on noise gets ignored | @@ -61,14 +63,15 @@ swift run -c release pladder-cli polish-set docs/polish-set.json --model s1-mini ## Pluggability rules - `PladderCore` imports Foundation only. It never imports FluidAudio, AVFoundation or AppKit, so tests compile fast and engines are truly swappable. -- Adding an engine: implement `TranscriptionEngine` in its own file under `PladderEngines`, register it in the `EngineRegistry` built in `AppModel`. One file plus one registry line; the settings picker reads the registry. The engine lifecycle — building, loading, status polling and swapping — lives in `EngineLoader`. -- Adding a processor: implement `TextProcessor` in its own file, append a factory to `StandardProcessors.factories` in `PladderSystem`, which the app and `pladder-cli --process` both build from. The pipeline is rebuilt when settings change, never per dictation. A processor sits on the critical path, so the benchmark rule applies. -- Adding a polish model: a `PolishModel` case in `Settings`, a `ModelFile` pinned to a commit with its SHA-256 if it downloads, a `TranscriptRefiner` in `PladderRefine`, and a branch in `AppModel.applyPolishModel`; `PolishRouter` hands it the calls, the picker and `polish-set` read the enum. Judge it with `pladder-cli polish-set` before it goes in. +- Adding an engine: implement `TranscriptionEngine` in its own file under `PladderEngines`, add an entry to `StandardEngines.entries`. One file plus one catalog line; the app's registry, the settings picker and `pladder-cli` read the catalog. The engine lifecycle — building, loading, status polling and swapping — lives in `EngineLoader`. An engine that transcribes while the key is held also implements `StreamingTranscriptionEngine`: `beginUtterance` returns an `Utterance` handle, every later call names it, and a call naming one that is no longer current does nothing, so a cancelled recording's late feed or abandon cannot reach the next one. `UtteranceSlot` in Core keeps that bookkeeping. +- Adding a processor: implement `TextProcessor` in its own file — synchronous, unable to fail (one that cannot do its job returns its input), and without user-facing text — append an `Entry` to `StandardProcessors.entries` in `PladderSystem`, which the app and `pladder-cli --process` both build from, and word it in `ProcessorText` in `StatusText.swift`. The pipeline is rebuilt when `DictationSettings` change, never per dictation. A processor sits on the critical path, so the benchmark rule applies; `pladder-cli bench-process` measures it. +- Adding a polish model: a `PolishModel` case in `Settings`, a `ModelFile` pinned to a commit with its SHA-256 if it downloads, a `TranscriptRefiner` in `PladderRefine`, and a branch in `PolishModelController.apply`; `PolishRouter` hands it the calls, the picker and `polish-set` read the enum. Judge it with `pladder-cli polish-set` before it goes in. - Adding a prompt: build an `OnDeviceLanguageModel(instructions:)` in `PladderRefine` and call `respond(to:)` or `respond(to:generating:)`; availability, prewarm, timeout and the drain of an abandoned call come with it. The coordinator only ever sees `TranscriptRefiner`. - The correction learner's two seams are protocols in `PladderCore`, `PastedTextObserver` and `CorrectionReviewer`, with fakes in the tests; the Accessibility and Foundation Models implementations live in `PladderSystem` (`AXPasteObserver`) and `PladderRefine` (`FoundationModelsCorrectionReviewer`). - Adding a hotkey role: a case in `HotkeyRole`, a `Hotkey` field in `Settings` with `[]` meaning off, an entry in the chords `startHotkey` hands the monitor and a mode in `startGesture`, a `HotkeyRecorderField` row. The monitors and trackers need nothing. - Adding a language: add a `` localization to both catalogs; nothing else. Adding a *string*: the key is the exact English text, and `PladderCore` never holds one — it emits an enum case and `Sources/Pladder/StatusText.swift` words it. -- Engine and capture are actors. The coordinator is `@MainActor` because it drives UI. It owns the state machine and nothing else; every dependency is injected, so tests run it with in-memory fakes. +- Engine and capture are actors. The coordinator is `@MainActor` because it drives UI. It owns the state machine and nothing else; every dependency is injected, its clock included, so tests run it with in-memory fakes and fire its timers with `ManualClock`. It sees `DictationSettings`, the part of `Settings` it acts on, and the app hands it a new value only when that part changed. +- The app target is split by job: `AppModel` is the composition root and owns the settings; `HotkeyRouter` picks the tap or Carbon (the rule itself is `HotkeySource.choose` in Core); `PermissionMonitor` polls the grants; `PolishModelController` owns the polish model and its file; `CorrectionInbox` the learned corrections; `SettingsLocation` the files. Decisions worth testing go to Core as values (`HotkeyWarning`, `HotkeySource`, the timing line) and the app words them in `StatusText.swift`. Views talk to `AppModel` and its parts, not to the coordinator. ## Risks @@ -76,7 +79,7 @@ swift run -c release pladder-cli polish-set docs/polish-set.json --model s1-mini - **First launch.** About 700 MB of CoreML models download from Hugging Face and compile on first load. The menu shows progress and the hotkey is disabled until the engine is ready. - **Cold latency.** The engine loads at launch and stays resident. The audio engine is prepared at launch and runs only while the key is held, so the system microphone indicator is off when idle. A cold encoder pass costs about 110 ms more than a warm one, which is more than every other stage together, so the coordinator warms the Neural Engine every two seconds while the key is held. A release that lands inside a warm pass waits for it: the signature is `engine` well above `engine-time`. - **Long recordings.** FluidAudio's encoder window is 15 s. Longer audio is split into windows and stitched, and seams can drop or duplicate words. Those windows now run while the key is held rather than at release, so the wait is flat with length, but the seam risk is unchanged: it is the same layout and the same merge. The paced benchmark guards it by requiring the text to be byte-identical to transcribing the whole recording at once, and the 30 s to 10 min fixtures watch the word error rate. -- **Polish latency and quality.** Apple's model takes about 1.5 to 2 seconds warm on an M1 for a typical dictation, S1-mini 0.3 to 0.5, and both are capped at eight; past the cap the text is pasted as dictated. A small model can still rewrite rather than clean; for Apple's the guided `cleanedText` field, greedy sampling, inline examples and naming the transcript's language keep that rare. S1-mini's known slips are in docs/BENCHMARKS.md, the worst a German number word read wrong, which real dictation rarely feeds it since Parakeet writes digits. `pladder-cli polish-set` is where model and prompt changes are judged. +- **Polish latency and quality.** Apple's model takes about 1.5 to 2 seconds warm on an M1 for a typical dictation, S1-mini 0.3 to 0.5, and both are capped at eight, the model's load and every chunk of a long dictation included; past the cap the text is pasted as dictated. A small model can still rewrite rather than clean; for Apple's the guided `cleanedText` field, greedy sampling, inline examples and naming the transcript's language keep that rare. S1-mini's known slips are in docs/BENCHMARKS.md, the worst a German number word read wrong, which real dictation rarely feeds it since Parakeet writes digits. `pladder-cli polish-set` is where model and prompt changes are judged. - **Polish model memory.** S1-mini stays loaded while chosen and polish is on: about 0.8 or 1.5 GB of weights plus a 235 MB key-value cache for its 2,048-token context. Long dictations are polished in chunks of about 250 words to fit. - **Clipboard clobbering.** Output saves the pasteboard, pastes, and restores it once the target app has read the transcript. Nothing read within 8 s leaves the transcript on the clipboard that long, never stale text in the target. - **Permissions.** Accessibility and Microphone grants are keyed to the code signature. `bundle.sh` signs with an Apple Development or Developer ID certificate when one is in the keychain; an ad-hoc signature changes on every build and resets both grants. @@ -86,7 +89,7 @@ swift run -c release pladder-cli polish-set docs/polish-set.json --model s1-mini The developer dictates with a running Pladder all day, often into Claude sessions, and several worktrees may each have their own `dist/Pladder.app`. -- Never `pkill -x Pladder` or `killall Pladder`: that also kills the copy in use, mid-recording, and the text is lost. Stop only the copy you launched: `pkill -f "$PWD/dist/Pladder.app"`. +- Never `pkill -x Pladder` or `killall Pladder`: that also kills the copy in use, mid-recording, and the text is lost. Stop only the copy you launched: `pkill -f "$PWD/dist/Pladder.app"`. SIGTERM quits the normal way, so a copy stopped like this gives back the speakers and the clipboard first. `bundle.sh --install` stops only `/Applications/Pladder.app`, and CI fails on a script that kills every copy. - Do not post synthetic hotkey events unless the user has asked for a live UI test. Every running copy reacts to them, so they start, cut short, or paste the user's recordings. - To see an overlay change, run `scripts/overlay-demo.sh` and read its contact sheets. It drives the real coordinator and pill with stand-ins for the engine, microphone, paste and hotkey, so nothing reaches a running copy. - Do not edit `~/Library/Application Support/Pladder/settings.json`; it is the live configuration. diff --git a/Package.swift b/Package.swift index e17303a..6b22cb4 100644 --- a/Package.swift +++ b/Package.swift @@ -85,10 +85,11 @@ let package = Package( dependencies: ["PladderCore", "PladderEngines", "PladderAudio", "PladderBench", "PladderRefine", "PladderSystem"] ), - .testTarget(name: "PladderCoreTests", dependencies: ["PladderCore"]), + .target(name: "PladderTestSupport", path: "Tests/PladderTestSupport"), + .testTarget(name: "PladderCoreTests", dependencies: ["PladderCore", "PladderTestSupport"]), .testTarget(name: "PladderAudioTests", dependencies: ["PladderAudio"]), .testTarget(name: "PladderBenchTests", dependencies: ["PladderBench"]), - .testTarget(name: "PladderSystemTests", dependencies: ["PladderSystem"]), - .testTarget(name: "PladderRefineTests", dependencies: ["PladderRefine"]), + .testTarget(name: "PladderSystemTests", dependencies: ["PladderSystem", "PladderTestSupport"]), + .testTarget(name: "PladderRefineTests", dependencies: ["PladderRefine", "PladderTestSupport"]), ] ) diff --git a/Sources/Pladder/AppModel.swift b/Sources/Pladder/AppModel.swift index e0fc08a..9f73f68 100644 --- a/Sources/Pladder/AppModel.swift +++ b/Sources/Pladder/AppModel.swift @@ -1,5 +1,4 @@ import AppKit -import AVFoundation import Foundation import Observation import PladderCore @@ -10,173 +9,78 @@ import PladderAudio import PladderEngines import PladderRefine -/// Composition root. Builds the engine registry, the settings store and the -/// coordinator, owns the overlay, and exposes everything the UI needs. +/// Composition root. Builds the engine registry, the settings store, the +/// coordinator and the parts around it, owns the settings, and turns the +/// coordinator's events into sounds, log lines and learning. +/// +/// What each part does lives with it: `HotkeyRouter` picks the hotkey +/// source, `PermissionMonitor` polls the grants, `PolishModelController` +/// owns the polish model, `CorrectionInbox` the learned corrections, and the +/// menu's wording is in `StatusText.swift`. @MainActor @Observable final class AppModel { let registry: EngineRegistry let coordinator: DictationCoordinator + let permissions: PermissionMonitor + let hotkeys: HotkeyRouter + let polish: PolishModelController + let corrections: CorrectionInbox - /// Text processors in pipeline order. The single source of truth for both - /// the settings toggles (ids, display names, details) and the runtime - /// pipeline `makePipeline` builds below. - let processors: [any TextProcessor] - - /// Mirrored permission state, refreshed on a timer so the menu and the - /// settings window stay correct after the user flips a switch in System - /// Settings (neither API offers a change notification). - private(set) var accessibilityTrusted: Bool = Permissions.isAccessibilityTrusted - private(set) var microphoneStatus: AVAuthorizationStatus = Permissions.microphoneStatus - - /// Secure Event Input, polled with the permissions. While it is on an - /// event tap sees no key-downs, so a chord with a regular key would fire - /// for nothing; `refreshPermissions()` hands such a chord to Carbon until - /// it clears. Only a *sustained* reading counts, so the password field the - /// user tabs through does not swap the monitor twice in four seconds. - private var secureInput = SustainedCondition() - private(set) var secureInputSustained = false - - /// The keyboard shortcuts macOS itself handles. Read on explicit triggers - /// only — launch, a permission flip, a hotkey edit, the settings window - /// opening — because `CopySymbolicHotKeys` is main-thread work linear in - /// the number of shortcuts and the answer changes about as often as - /// someone visits System Settings. Never on a key press. - private(set) var systemShortcuts: Set = [] - - /// Whether Apple Intelligence can polish right now, for the polish - /// toggle's row. Polled with the permissions: it can be switched on or - /// off in System Settings while the app runs, and the read is cheap. The - /// coordinator never asks; an unavailable model makes the toggle a - /// no-op that pastes as dictated. - private(set) var polishAvailability: OnDeviceModelAvailability = TranscriptPolisher.availability - - /// Where the chosen polish model's file stands; nil for Apple's, which - /// has none. Shown under the model picker. - private(set) var polishModelStatus: ModelFileStatus? - /// The coordinator's refiner; `applyPolishModel` points it at the model - /// the settings name. - private let polishRouter: PolishRouter - private let applePolisher = TranscriptPolisher() - /// The S1-mini polisher for the chosen file, nil while Apple's is chosen. - /// Only one is ever held, so switching frees the other's memory. - private var s1MiniPolisher: S1MiniPolisher? - private let modelFiles: ModelFiles - private let modelStatusRelay = ModelStatusRelay() - - /// The default chord, standing in for a stored chord Carbon cannot - /// register while Accessibility is missing. The stored chord is never - /// rewritten and returns with the grant. - var standInHotkey: Hotkey? { - accessibilityTrusted ? nil : settings.hotkey.standInWithoutAccessibility - } + /// Set when `setLaunchAtLogin` fails, so settings can show the reason + /// under the toggle. + private(set) var launchAtLoginError: String? + /// The login item's state, which can be changed behind the app's back in + /// System Settings: re-read when the settings window opens. + private(set) var launchAtLoginEnabled = LaunchAtLogin.isEnabled - /// So the first `refreshPermissions()` sets the stand-in even though - /// nothing flipped; `hotkeyUsesTap` already matches the grant at that point. - private var didComputeEffectiveHotkey = false - - private let store: SettingsStore - private let events = EventRelay() - private let overlay: OverlayController - private var permissionTask: Task? - private var didRequestAccessibility = false - - /// Both hotkey sources, kept for the app's life so switching between them - /// costs nothing. The tap swallows the chord's regular key and matches - /// left and right modifiers exactly, but needs Accessibility; Carbon needs - /// no permission at all and is what a standard account gets. - private let tapHotkey = GlobalHotkeyMonitor() - private let carbonHotkey = CarbonHotkeyMonitor() - /// Which of the two the coordinator is currently driven by. - private var hotkeyUsesTap: Bool + @ObservationIgnored private let store: SettingsStore + @ObservationIgnored private let overlay: OverlayController + @ObservationIgnored private let output: PasteboardOutput + @ObservationIgnored private let events = MainActorRelay<(DictationCoordinator.Event, ContinuousClock.Instant)>() /// When the hotkey was released, for the release-to-paste measurement. - private var releaseInstant: ContinuousClock.Instant? + @ObservationIgnored private var releaseInstant: ContinuousClock.Instant? + /// Release-to-paste time per dictation, the number the user feels. Read /// it with: log show --last 1h --predicate 'subsystem == "de.dinooo13.pladder"' - private static let timing = Logger(subsystem: "de.dinooo13.pladder", category: "timing") - /// A processor that throws is skipped by `ProcessorPipeline` rather than - /// losing the dictation; this is where that gets logged. `Logger` is - /// `Sendable`, so this is safe to reach from the `@Sendable` failure - /// closure below without hopping back to the main actor. - private nonisolated static let processorLog = Logger(subsystem: "de.dinooo13.pladder", category: "processors") + private static let timingLog = Logger(subsystem: "de.dinooo13.pladder", category: "timing") /// Every mute and unmute of the output device. A mute that gets stuck — /// the app quitting mid-recording, a device vanishing — is silent and /// baffling otherwise, so both ends of it are logged `.public`. private nonisolated static let muteLog = Logger(subsystem: "de.dinooo13.pladder", category: "mute") - /// The correction learner's stages. They quote the user's words, so the - /// text is `.private`: `log show` prints it only with private data on. - private nonisolated static let learningLog = Logger(subsystem: "de.dinooo13.pladder", category: "learning") - - /// Watches a field after the paste and proposes what the user corrected. - private let learner: CorrectionLearner - private let dismissedCorrections: DismissedCorrections - private let proposalRelay = ProposalRelay() - /// Corrections the model agreed with, newest first, waiting in the menu - /// for Add or Dismiss. Kept for the app's life or until answered. - private(set) var proposals: [CorrectionProposal] = [] - static let maximumProposals = 3 /// Hotkey behaviour worth knowing about after the fact, such as a /// keyboard that bounces. private static let hotkeyLog = Logger(subsystem: "de.dinooo13.pladder", category: "hotkey") - - /// Settings live in the coordinator (it reacts to hotkey/engine changes); - /// this forwards and persists. Applying the appearance covers every - /// window and menu at once, so no view has to care. - /// - /// Each side effect runs only when its own keys changed. Re-assigning - /// `NSApp.appearance` is not free: with a forced Light or Dark it makes - /// AppKit re-theme every window, so doing it on every click of a settings - /// card made the card rows lag behind the click. + private static let settingsLog = Logger(subsystem: "de.dinooo13.pladder", category: "settings") + + /// The settings, persisted on every real change. Assignments that change + /// nothing are ignored, and each side effect runs only when its own keys + /// changed. Re-assigning `NSApp.appearance` is not free: with a forced + /// Light or Dark it makes AppKit re-theme every window, so doing it on + /// every click of a settings card made the card rows lag behind the + /// click. The coordinator sees only `DictationSettings`, and only when + /// that part changed. var settings: Settings { - get { coordinator.settings } + get { storedSettings } set { - let old = coordinator.settings + let old = storedSettings guard newValue != old else { return } - coordinator.settings = newValue - if newValue.appearance != old.appearance { - applyAppearance(newValue.appearance) - } - if newValue.overlayStyle != old.overlayStyle || newValue.overlayGlass != old.overlayGlass { - overlay.applyStyle(newValue.overlayStyle, glass: newValue.overlayGlass) - } - if newValue.overlayAnimationSpeed != old.overlayAnimationSpeed { - overlay.applySpeed(newValue.overlayAnimationSpeed) - } - if newValue.hotkey != old.hotkey { - // A chord with a regular key no longer needs a stand-in, and a - // modifier-only one does. - updateEffectiveHotkey() - } - if newValue.polishModel != old.polishModel || newValue.polishDictations != old.polishDictations { - applyPolishModel() - } - try? store.save(newValue) + storedSettings = newValue + settingsChanged(from: old) } } + private var storedSettings: Settings - private func applyAppearance(_ appearance: Appearance) { - let resolved: NSAppearance? = switch appearance { - case .system: nil - case .light: NSAppearance(named: .aqua) - case .dark: NSAppearance(named: .darkAqua) - } - NSApp.appearance = resolved - overlay.applyAppearance(appearance) - } - - init() { + init(location: SettingsLocation) { // Engines, in the order the settings picker shows them. The first - // entry is the default for new installs. + // entry is the default for new installs. The catalog's detail is the + // English catalog key; it is worded here, where the catalog is. var registry = EngineRegistry() - registry.register( - EngineRegistry.Entry( - id: FluidAudioIncrementalEngine.engineID, - displayName: "Parakeet TDT v3", - detail: String(localized: "NVIDIA Parakeet via FluidAudio, runs on the Neural Engine. ~700 MB download on first use."), - make: { FluidAudioIncrementalEngine() } - ) - ) + for var entry in StandardEngines.entries { + entry.detail = String(localized: String.LocalizationValue(entry.detail)) + registry.register(entry) + } #if DEBUG registry.register( EngineRegistry.Entry( @@ -189,120 +93,58 @@ final class AppModel { #endif self.registry = registry - let store = SettingsStore( - url: Self.settingsURL, - defaults: Settings(engineID: FluidAudioIncrementalEngine.engineID) - ) - // A test copy starts from the defaults, not from an old install. - if Self.settingsPathOverride == nil { - Self.migrateLegacySettings(to: Self.settingsURL) - } - self.store = store - + location.migrateLegacySettingsIfNeeded() + store = SettingsStore(url: location.settingsURL, defaults: Settings(engineID: StandardEngines.defaultEntry.id)) // One read: the store moves an undecodable file aside on load, so a // second read could see different settings than the first. var initial = store.load() - // An engine that was removed in an update leaves a stale ID behind. // `EngineRegistry.make` already falls back to the first entry, so the // app works either way; rewriting the ID keeps the settings picker // showing what is actually running. - if registry.entry(for: initial.engineID) == nil, - let fallback = registry.available.first { + if registry.entry(for: initial.engineID) == nil, let fallback = registry.available.first { initial.engineID = fallback.id } + storedSettings = initial - // Processors, in pipeline order; the list and its reasons are in - // `StandardProcessors`, which the CLI shares. - let processorFactories = StandardProcessors.factories - self.processors = processorFactories.map { $0(initial) } - - let router = PolishRouter(applePolisher) - polishRouter = router - let modelStatusRelay = self.modelStatusRelay - modelFiles = ModelFiles(directory: ModelFiles.defaultDirectory) { file, status in - modelStatusRelay.send(file, status) - } + let permissions = PermissionMonitor() + self.permissions = permissions + let hotkeys = HotkeyRouter(accessibilityTrusted: permissions.accessibilityTrusted, hotkey: initial.hotkey) + self.hotkeys = hotkeys + let polish = PolishModelController() + self.polish = polish + let output = PasteboardOutput() + self.output = output let events = self.events - let trusted = Permissions.isAccessibilityTrusted - hotkeyUsesTap = trusted let coordinator = DictationCoordinator( - settings: initial, + settings: DictationSettings(initial), registry: registry, capture: AVAudioEngineCapture(), - output: PasteboardOutput(), + output: output, outputMuter: OutputMuteController( control: CoreAudioOutputMute(), log: { Self.muteLog.info("\($0, privacy: .public)") }), - refiner: router, - hotkeyMonitor: trusted ? tapHotkey : carbonHotkey, - makePipeline: { s in - ProcessorPipeline(processorFactories.map { $0(s) }, onFailure: { id, error in - Self.processorLog.error("processor \(id, privacy: .public) failed: \(error.localizedDescription, privacy: .public)") - }) - }, - onEvent: { [events] event in events.send(event) } + refiner: polish.refiner, + hotkeyMonitor: hotkeys.initialMonitor, + makePipeline: { StandardProcessors.pipeline(for: $0) }, + // Stamped when the coordinator emits it, not when the main actor + // gets to it, so the release-to-paste measurement does not + // include scheduling delay. + onEvent: { event in events.send((event, .now)) } ) self.coordinator = coordinator - - // Nothing here runs before the paste: `handle(.inserted)` hands the - // pasted text over, and the learner watches and reviews on its own - // thread and task. - let dismissed = DismissedCorrections(url: Self.dismissedCorrectionsURL) - dismissedCorrections = dismissed - let proposalRelay = self.proposalRelay - learner = CorrectionLearner( - observer: AXPasteObserver(), - reviewer: FoundationModelsCorrectionReviewer(), - dismissed: dismissed, - dictionary: { await coordinator.settings.dictionary }, - log: { Self.learningLog.info("\($0, privacy: .private)") }, - onProposal: { proposalRelay.send($0) } - ) - + hotkeys.attach(coordinator) + corrections = CorrectionInbox(dismissedURL: location.dismissedCorrectionsURL) overlay = OverlayController(coordinator: coordinator) - events.handler = { [weak self] event, at in self?.handle(event, at: at) } - proposalRelay.handler = { [weak self] proposal in self?.propose(proposal) } - modelStatusRelay.handler = { [weak self] file, status in - guard let self, ModelFile(for: self.settings.polishModel) == file else { return } - self.polishModelStatus = status - } - } - /// `PLADDER_SETTINGS_PATH` points a copy launched for testing at a file - /// of its own, so it neither reads nor writes the configuration of the - /// copy in daily use; every recorder commit is saved at once. Development - /// only: no UI, and a normal launch never has it set. - static var settingsPathOverride: String? { - guard let path = ProcessInfo.processInfo.environment["PLADDER_SETTINGS_PATH"], - !path.isEmpty else { return nil } - return path - } - - static var settingsURL: URL { - if let path = settingsPathOverride { return URL(filePath: path) } - return FileManager.default - .homeDirectoryForCurrentUser - .appending(path: "Library/Application Support/Pladder/settings.json") - } - - /// The corrections the user dismissed, beside the settings but not in - /// them (see `DismissedCorrections`). - static var dismissedCorrectionsURL: URL { - settingsURL.deletingLastPathComponent().appending(path: "dismissed-corrections.json") - } - - /// One-time migration from the pre-rename location. The dictionary and - /// hotkey settings were kept in `~/Library/Application Support/SpeakUp/` - /// before the app was called Pladder; copy them across exactly once, only - /// when the new file does not exist yet. The old file is left in place. - static func migrateLegacySettings(to destination: URL) { - guard !FileManager.default.fileExists(atPath: destination.path) else { return } - let legacy = FileManager.default - .homeDirectoryForCurrentUser - .appending(path: "Library/Application Support/SpeakUp/settings.json") - try? FileManager.default.copyItem(at: legacy, to: destination) + events.handler = { [weak self] in self?.handle($0.0, at: $0.1) } + corrections.dictionary = { [weak self] in self?.settings.dictionary ?? [] } + corrections.addRules = { [weak self] rules in self?.settings.dictionary.merge(rules) } + corrections.waitUntilQuiet = { [weak coordinator] in + await ObservationLoop.wait { coordinator?.state.isBusy != true } + } + permissions.onRefresh = { [weak self] in self?.permissionsRefreshed() } } // MARK: Lifecycle @@ -311,107 +153,102 @@ final class AppModel { applyAppearance(settings.appearance) overlay.applyStyle(settings.overlayStyle, glass: settings.overlayGlass) overlay.applySpeed(settings.overlayAnimationSpeed) - refreshPermissions() - if !accessibilityTrusted && !didRequestAccessibility { - didRequestAccessibility = true - Permissions.requestAccessibility() - } + permissions.start() overlay.start() coordinator.start() - applyPolishModel() - startPermissionMirroring() + polish.apply(model: settings.polishModel, polishing: settings.polishDictations) } - // MARK: Polish model - - /// Points the coordinator's refiner at the chosen model. An S1-mini file - /// is downloaded only while polish is on and that model is chosen, the - /// one network use besides the speech model's; it is loaded at the first - /// key-down, not here, and freed when polish goes off or another model - /// is picked. - private func applyPolishModel() { - guard let file = ModelFile(for: settings.polishModel) else { - releaseS1Mini() - polishRouter.use(applePolisher) - polishModelStatus = nil - return + /// The app is quitting: everything the coordinator borrowed is given + /// back first. The app delegate waits for this, with a deadline. + func shutdown() async { + permissions.stop() + overlay.stop() + await coordinator.shutdown() + } + + private func permissionsRefreshed() { + hotkeys.update( + accessibilityTrusted: permissions.accessibilityTrusted, + secureInputEnabled: permissions.secureInputEnabled) + polish.refreshAvailability() + } + + /// Re-reads what can change behind the app's back and is about to be + /// shown: the grants, the shortcuts macOS owns, the login item. + func settingsWindowOpened() { + permissions.refresh() + hotkeys.refreshSystemShortcuts() + let enabled = LaunchAtLogin.isEnabled + if enabled != launchAtLoginEnabled { launchAtLoginEnabled = enabled } + } + + // MARK: Settings + + private func settingsChanged(from old: Settings) { + let monitor = settings.hotkey != old.hotkey ? hotkeys.hotkeyChanged(to: settings.hotkey) : nil + // Together, so a new chord and the stand-in and monitor it calls for + // restart the monitor once. + coordinator.update(DictationSettings(settings), hotkeyOverride: hotkeys.standInHotkey, monitor: monitor) + if settings.appearance != old.appearance { + applyAppearance(settings.appearance) } - if s1MiniPolisher?.file != file { - releaseS1Mini() - let polisher = S1MiniPolisher(file: file, location: modelFiles.location(of: file)) - s1MiniPolisher = polisher - polishRouter.use(polisher) + if settings.overlayStyle != old.overlayStyle || settings.overlayGlass != old.overlayGlass { + overlay.applyStyle(settings.overlayStyle, glass: settings.overlayGlass) } - let polishing = settings.polishDictations - if !polishing, let polisher = s1MiniPolisher { - Task { await polisher.unload() } + if settings.overlayAnimationSpeed != old.overlayAnimationSpeed { + overlay.applySpeed(settings.overlayAnimationSpeed) } - if polishing { - Task.detached(priority: .utility) { await S1MiniPolisher.warmUpRuntime() } + if settings.polishModel != old.polishModel || settings.polishDictations != old.polishDictations { + polish.apply(model: settings.polishModel, polishing: settings.polishDictations) } - let files = modelFiles - Task { [weak self] in - if polishing { await files.ensure(file) } - let status = await files.status(of: file) - guard let self, ModelFile(for: self.settings.polishModel) == file else { return } - self.polishModelStatus = status + do { + try store.save(settings) + } catch { + Self.settingsLog.error("could not save settings: \(error.localizedDescription, privacy: .public)") } } - /// Tries a failed download again; the picker's "Try Again". - func retryPolishModelDownload() { - guard let file = ModelFile(for: settings.polishModel) else { return } - let files = modelFiles - Task { await files.ensure(file) } + /// Applying the appearance to `NSApp` covers every window and menu at + /// once, so no view has to care; the overlay's panel is told on its own. + private func applyAppearance(_ appearance: Appearance) { + NSApp.appearance = appearance.nsAppearance + overlay.applyAppearance(appearance) } - private func releaseS1Mini() { - guard let polisher = s1MiniPolisher else { return } - s1MiniPolisher = nil - Task { await polisher.unload() } + func setLaunchAtLogin(_ enabled: Bool) { + do { + try LaunchAtLogin.setEnabled(enabled) + launchAtLoginError = nil + } catch { + launchAtLoginError = error.localizedDescription + } + launchAtLoginEnabled = LaunchAtLogin.isEnabled } - func stop() { - permissionTask?.cancel() - overlay.stop() - coordinator.stop() - } + // MARK: Events private func handle(_ event: DictationCoordinator.Event, at instant: ContinuousClock.Instant) { - func fmt(_ duration: Duration) -> String { - let d = Self.seconds(duration) - return String(format: "%.3f", d) - } switch event { case .recordingStarted: if settings.playSounds { SoundPlayer.playStart() } case .recordingStopped: releaseInstant = instant if settings.playSounds { SoundPlayer.playStop() } - case .inserted(let transcript, let timing): + case .inserted(let insertion): + // Strictly after the paste and off the measured window, which + // ended when the coordinator emitted this event. Only a paste + // that stayed in its field can be corrected: a copy was never + // pasted, and a send emptied the field. defer { - // Strictly after the paste and off the measured window, which - // ended when the coordinator emitted this event: the learner - // spawns its own task and reads the field on its own thread. - // Without the grant nothing was pasted, only copied. - if accessibilityTrusted { learner.pasted(transcript.text) } + if insertion.result == .pasted, !insertion.submitted { + corrections.pasted(insertion.transcript.text) + } } guard let released = releaseInstant else { return } releaseInstant = nil - let total = Self.seconds(instant - released) - // A polish cycle gets its own line, so the plain one stays the - // number the benchmark rule watches; one predicate finds both. - let polish = timing.polish.map { "polish \(fmt($0)) (\(settings.polishModel.rawValue)), " } ?? "" - let label = timing.polish == nil ? "release-to-paste" : "polished release-to-paste" - let stages = "stop \(fmt(timing.captureStop)), engine \(fmt(timing.engine)), " + - "process \(fmt(timing.processing)), \(polish)paste \(fmt(timing.insert))" - Self.timing.log( - """ - \(label, privacy: .public) \(total, format: .fixed(precision: 3), privacy: .public) s: \(stages, privacy: .public); \ - audio \(transcript.audioDuration, format: .fixed(precision: 1), privacy: .public) s, \ - engine-time \(transcript.processingTime, format: .fixed(precision: 3), privacy: .public) s - """ - ) + let line = insertion.timingLine(total: instant - released, polishModel: settings.polishModel.rawValue) + Self.timingLog.log("\(line, privacy: .public)") case .failed: releaseInstant = nil case .recordingDiscarded: @@ -422,247 +259,59 @@ final class AppModel { case .keyboardBounceObserved: // That wait comes before `recordingStopped`, so the timing line // cannot show it; this line is what explains a felt delay. - Self.hotkeyLog.notice("keyboard bounce observed: releases now settle for 50 ms before stopping") - } - } - - // MARK: Learned corrections - - private func propose(_ proposal: CorrectionProposal) { - let key = proposal.pair.key - guard !proposals.contains(where: { $0.pair.key == key }), - !Self.dictionary(settings.dictionary, has: proposal.pair.heard) else { return } - proposals = Array(([proposal] + proposals).prefix(Self.maximumProposals)) - } - - /// Adds `heard → corrected` to the dictionary, overwriting a rule with the - /// same `from` the way the Dictionary tab's import does. Through the - /// settings setter, so it is saved and the next dictation uses it. - func acceptProposal(_ proposal: CorrectionProposal) { - proposals.removeAll { $0.id == proposal.id } - let from = proposal.pair.heard.lowercased() - var dictionary = settings.dictionary - let entry = DictionaryEntry(from: proposal.pair.heard, to: proposal.pair.corrected) - if let index = dictionary.firstIndex(where: { - $0.from.trimmingCharacters(in: .whitespaces).lowercased() == from - }) { - dictionary[index].to = entry.to - dictionary[index].matchCase = false - } else { - dictionary.append(entry) - } - settings.dictionary = dictionary - } - - /// Drops the line and remembers the pair so it is never proposed again. - func dismissProposal(_ proposal: CorrectionProposal) { - proposals.removeAll { $0.id == proposal.id } - let dismissed = dismissedCorrections - Task { await dismissed.dismiss(proposal.pair) } - } - - private static func dictionary(_ entries: [DictionaryEntry], has heard: String) -> Bool { - let key = heard.lowercased() - return entries.contains { $0.from.trimmingCharacters(in: .whitespaces).lowercased() == key } - } - - private static func seconds(_ duration: Duration) -> Double { - let parts = duration.components - return Double(parts.seconds) + Double(parts.attoseconds) / 1e18 - } - - // MARK: Permissions - - func refreshPermissions() { - accessibilityTrusted = Permissions.isAccessibilityTrusted - microphoneStatus = Permissions.microphoneStatus - let polish = TranscriptPolisher.availability - if polish != polishAvailability { polishAvailability = polish } - let sustained = secureInput.observe(SecureInput.isEnabled) - if sustained != secureInputSustained { secureInputSustained = sustained } - // Granting Accessibility upgrades the hotkey to the tap; revoking it - // drops back to Carbon. Either way a recording in progress is dropped - // by the coordinator, since the old monitor's release can no longer - // arrive. - // - // Secure input is the second reason to leave the tap: it stops taps - // seeing key-downs, so a chord with a regular key is dead there. - // Carbon can take over only for a chord it can register, and a - // modifier-only chord is unaffected by secure input anyway, so both - // stay on the tap and nothing swaps. The push-to-talk chord alone - // decides; the toggle chord follows whichever monitor is up. - let wantsTap = accessibilityTrusted - && !(sustained && settings.hotkey.canBeRegisteredWithoutAccessibility) - let flipped = wantsTap != hotkeyUsesTap - if flipped { - hotkeyUsesTap = wantsTap - coordinator.replaceHotkeyMonitor(wantsTap ? tapHotkey : carbonHotkey) - } - // After the swap: the new monitor is started with the old stand-in and - // then, if it changed, once more with the new one. The other order - // would make the Carbon monitor log a failure for a modifier-only - // chord on the way to being replaced by the tap. - if flipped || !didComputeEffectiveHotkey { - didComputeEffectiveHotkey = true - updateEffectiveHotkey() - refreshSystemShortcuts() + let window = coordinator.bounceWindow.timeInterval * 1000 + Self.hotkeyLog.notice("keyboard bounce observed: releases now settle for \(window, format: .fixed(precision: 0)) ms before stopping") } } - /// Re-reads the shortcuts macOS owns, which feed the warnings shown in the - /// settings window. Called when that window opens, the moment the answer - /// is about to be shown to the user and the recorder about to be handed it. - func refreshSystemShortcuts() { - let shortcuts = SystemShortcuts.enabled() - if shortcuts != systemShortcuts { systemShortcuts = shortcuts } - } - - /// With the grant the tap matches anything; without it a chord Carbon - /// cannot register listens for nothing, so the default takes its place. - private func updateEffectiveHotkey() { - coordinator.hotkeyOverride = standInHotkey - } - - /// The enabled macOS shortcut that swallows `hotkey`, if any. Shown as a - /// warning in both modes: the tap sees such a chord, but the system - /// shortcut fires as well. - func systemShortcutConflict(for hotkey: Hotkey) -> Hotkey? { - hotkey.systemShortcutConflict(in: systemShortcuts) - } - - var needsAccessibility: Bool { !accessibilityTrusted } - /// Without Accessibility the chord has to contain a regular key, so the - /// recorder refuses modifier-only chords and settings says why. - var hotkeyNeedsRegularKey: Bool { !accessibilityTrusted } - var needsMicrophone: Bool { microphoneStatus != .authorized } - var needsAnyPermission: Bool { needsAccessibility || needsMicrophone } - - /// Polls permission status every 2 s for the app's life. Neither API - /// offers a change notification, so this is how the menu and the settings - /// window notice a permission flipped in System Settings — and, since - /// `refreshPermissions()` swaps the hotkey source, how the app moves - /// between the event tap and Carbon in both directions. It used to stop - /// once both permissions were granted; a later revoke has to be picked up - /// too, and two cheap status reads every two seconds cost nothing. - private func startPermissionMirroring() { - refreshPermissions() - permissionTask?.cancel() - permissionTask = Task { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(for: .seconds(2)) - guard let self else { return } - self.refreshPermissions() - } - } - } - - func grantAccessibility() { - Permissions.requestAccessibility() - Permissions.openAccessibilitySettings() - startPermissionMirroring() - } - - func grantMicrophone() { - Task { [weak self] in - if Permissions.microphoneStatus == .notDetermined { - _ = await Permissions.requestMicrophone() - } else { - Permissions.openMicrophoneSettings() - } - self?.refreshPermissions() - self?.startPermissionMirroring() - } - } - - /// Set when `setLaunchAtLogin` fails, so settings can show the reason - /// under the toggle. `LaunchAtLogin.isEnabled` is the source of truth for - /// the toggle itself, since it can be changed behind the app's back in - /// System Settings. - private(set) var launchAtLoginError: String? - - func setLaunchAtLogin(_ enabled: Bool) { - do { - try LaunchAtLogin.setEnabled(enabled) - launchAtLoginError = nil - settings.launchAtLogin = enabled - } catch { - launchAtLoginError = error.localizedDescription - settings.launchAtLogin = LaunchAtLogin.isEnabled - } - } - - // MARK: Menu presentation + // MARK: Menu /// The app icon's waveform glyph, varied by state (see `MenuBarIcon`). var menuBarImage: NSImage { - MenuBarIcon.image(for: coordinator.state) + MenuBarIcon.image(for: coordinator.state, level: coordinator.inputLevel) } /// One line describing what the app is doing right now. var statusLine: String { - switch coordinator.state { - case .recording: - // The Menu style has no pill, so this line is its latched cue. - guard coordinator.isLatched else { return String(localized: "Recording…") } - return String(localized: "Recording — press \(stopKeyName) to stop") - case .transcribing: return String(localized: "Transcribing…") - case .polishing: return String(localized: "Polishing…") - case .inserting: return String(localized: "Inserting…") - case .error(let failure): return String(localized: "Error: \(failure.text)") - case .copied: return String(localized: "Copied — press ⌘V") - case .idle: - return readyLine - case .unavailable: - switch coordinator.engineStatus { - case .downloading(let progress): - if let progress { - return String(localized: "Model: downloading \(Int((progress * 100).rounded()))%") - } - return String(localized: "Model: downloading…") - case .loading: return String(localized: "Model: loading…") - case .unloaded: return String(localized: "Model: not loaded") - case .failed(let failure): return String(localized: "Model failed: \(failure.text)") - case .ready: return readyLine - } - } + MenuStatus.line( + state: coordinator.state, + engineStatus: coordinator.engineStatus, + isLatched: coordinator.isLatched, + chords: menuChords) + } + + /// The chords the menu names. Without Accessibility the stored chord may + /// be listening for nothing, so the stand-in is named instead; "hold + /// Right Command" would be a lie. Any chord ends a latched recording; + /// the line names the one that latched it: the toggle key when it is a + /// chord of its own, otherwise the key, or what stands in for it. + private var menuChords: MenuStatus.Chords { + let standIn = hotkeys.standInHotkey?.sideAgnosticDisplayName + let hold = standIn ?? hotkeys.displayName(of: settings.hotkey) + let toggle = settings.toggleHotkey + let stop = !toggle.isEmpty && toggle.canonical != settings.hotkey.canonical + ? hotkeys.displayName(of: toggle) : hold + return MenuStatus.Chords( + hold: hold, stop: stop, + accessibilityOff: standIn != nil, + secureKeyboardEntry: hotkeys.usesCarbonForSecureInput) } - /// The chord to hold, named the way the monitor that matches it sees the - /// keys: the tap tells Left from Right, Carbon's mask cannot. - var effectiveHotkeyName: String { - guard !hotkeyUsesTap else { return settings.hotkey.displayName } - return settings.hotkey.sideAgnosticDisplayName + /// While a recorder field records a new chord, the monitor is down so + /// the keys being pressed cannot fire the old one. + func setHotkeySuspended(_ suspended: Bool) { + coordinator.isHotkeySuspended = suspended } - /// What ends a latched recording. Any chord does; this names the one that - /// latched it: the toggle key when it is a chord of its own, otherwise - /// the key, or what stands in for it. - private var stopKeyName: String { - let toggle = settings.toggleHotkey - if !toggle.isEmpty, toggle.canonical != settings.hotkey.canonical { - return hotkeyUsesTap ? toggle.displayName : toggle.sideAgnosticDisplayName - } - return standInHotkey?.sideAgnosticDisplayName ?? effectiveHotkeyName + /// The menu's "Retry Model Download". + func retryEngine() { + coordinator.reloadEngine() } - /// True while a working Accessibility grant is being ignored because - /// Secure Event Input has the tap deaf and Carbon is standing in. - var usesCarbonForSecureInput: Bool { accessibilityTrusted && !hotkeyUsesTap } - - /// What to say when nothing is happening. Without Accessibility the stored - /// chord may be listening for nothing, in which case the stand-in is named - /// instead; "hold Right Command" would be a lie. - private var readyLine: String { - if let standIn = standInHotkey { - return String(localized: "Ready — hold \(standIn.sideAgnosticDisplayName) (Accessibility is off)") - } - // Say why the send key and the swallowing stopped: both are the tap's, - // and the tap is deaf until Secure Keyboard Entry goes off again. A - // whole sentence either way: a suffix glued on cannot be translated. - if usesCarbonForSecureInput { - return String(localized: "Ready — hold \(effectiveHotkeyName) (Secure Keyboard Entry is on)") - } - return String(localized: "Ready — hold \(effectiveHotkeyName)") + /// The menu's "Last: …" line puts the whole transcript on the clipboard. + func copyLastTranscript() { + guard let text = coordinator.lastTranscript?.text else { return } + Task { await output.copy(text) } } var canRetryEngine: Bool { @@ -670,55 +319,18 @@ final class AppModel { return false } - /// A short, word-boundary-aware summary of the most recent transcript for - /// the menu; longer previews make the menu bar menu comically wide. var lastTranscriptSummary: String? { - guard let text = coordinator.lastTranscript?.text, !text.isEmpty else { return nil } - let limit = 32 - guard text.count > limit else { return text } - let head = String(text.prefix(limit)) - if let space = head.lastIndex(of: " "), head.distance(from: head.startIndex, to: space) > 20 { - return String(head[.. Void)? - - nonisolated func send(_ file: ModelFile, _ status: ModelFileStatus) { - Task { @MainActor in self.handler?(file, status) } - } -} - -/// Bridges the learner's proposals onto the main actor, and lets the learner -/// be built before `self` exists, as `EventRelay` does for the coordinator. -@MainActor -final class ProposalRelay { - var handler: ((CorrectionProposal) -> Void)? - - nonisolated func send(_ proposal: CorrectionProposal) { - Task { @MainActor in self.handler?(proposal) } - } -} - -/// Bridges the coordinator's nonisolated `onEvent` callback back onto the main -/// actor, and lets us hand the coordinator a callback before `self` exists. -/// -/// Each event is stamped when the coordinator emits it, not when the main -/// actor gets around to handling it, so the release-to-paste measurement does -/// not include scheduling delay. -@MainActor -final class EventRelay { - var handler: ((DictationCoordinator.Event, ContinuousClock.Instant) -> Void)? - - nonisolated func send(_ event: DictationCoordinator.Event) { - let at = ContinuousClock.now - Task { @MainActor in self.handler?(event, at) } +extension Appearance { + /// `nil` follows the system. + var nsAppearance: NSAppearance? { + switch self { + case .system: nil + case .light: NSAppearance(named: .aqua) + case .dark: NSAppearance(named: .darkAqua) + } } } diff --git a/Sources/Pladder/CorrectionInbox.swift b/Sources/Pladder/CorrectionInbox.swift new file mode 100644 index 0000000..7b9a856 --- /dev/null +++ b/Sources/Pladder/CorrectionInbox.swift @@ -0,0 +1,84 @@ +import Foundation +import Observation +import os +import PladderCore +import PladderRefine +import PladderSystem + +/// The learned-corrections menu lines: the learner watches a field after +/// each paste and proposes what the user corrected; this keeps the proposals +/// until the user answers Add or Dismiss. +/// +/// Nothing here runs before the paste: `pasted` hands the text over and the +/// learner watches and reviews on its own thread and task. +@MainActor +@Observable +final class CorrectionInbox { + /// Corrections the model agreed with, newest first. Kept for the app's + /// life or until answered. + private(set) var proposals: [CorrectionProposal] = [] + static let maximumProposals = 3 + + /// The dictionary as it is now, and how a rule gets into it. Set by the + /// app once it exists. + @ObservationIgnored var dictionary: () -> [DictionaryEntry] = { [] } + @ObservationIgnored var addRules: ([DictionaryEntry]) -> Void = { _ in } + /// Returns once no dictation is being recorded or is on its way out. + @ObservationIgnored var waitUntilQuiet: @MainActor () async -> Void = {} + + @ObservationIgnored private let dismissed: DismissedCorrections + @ObservationIgnored private let relay = MainActorRelay() + @ObservationIgnored private var learner: CorrectionLearner? + + /// The learner's stages. They quote the user's words, so the text is + /// `.private`: `log show` prints it only with private data on. + private nonisolated static let log = Logger(subsystem: "de.dinooo13.pladder", category: "learning") + + init(dismissedURL: URL) { + dismissed = DismissedCorrections(url: dismissedURL) + let relay = self.relay + learner = CorrectionLearner( + observer: AXPasteObserver(), + reviewer: FoundationModelsCorrectionReviewer(), + dismissed: dismissed, + dictionary: { [weak self] in await self?.currentDictionary() ?? [] }, + log: { Self.log.info("\($0, privacy: .private)") }, + // The review and Apple's polish share the system model, which + // answers one request at a time, so a review is held while a + // dictation is recorded or on its way out: one running at the + // release would eat into that dictation's polish budget. + waitUntilQuiet: { [weak self] in await self?.waitForQuiet() }, + onProposal: { relay.send($0) } + ) + relay.handler = { [weak self] in self?.propose($0) } + } + + private func currentDictionary() -> [DictionaryEntry] { dictionary() } + private func waitForQuiet() async { await waitUntilQuiet() } + + /// The text that was pasted, strictly after the paste. + func pasted(_ text: String) { + learner?.pasted(text) + } + + /// Adds `heard → corrected` to the dictionary, overwriting a rule with the + /// same `from` the way the Dictionary tab's import does. + func accept(_ proposal: CorrectionProposal) { + proposals.removeAll { $0.id == proposal.id } + addRules([DictionaryEntry(from: proposal.pair.heard, to: proposal.pair.corrected)]) + } + + /// Drops the line and remembers the pair so it is never proposed again. + func dismiss(_ proposal: CorrectionProposal) { + proposals.removeAll { $0.id == proposal.id } + let dismissed = self.dismissed + Task { await dismissed.dismiss(proposal.pair) } + } + + private func propose(_ proposal: CorrectionProposal) { + let key = proposal.pair.key + guard !proposals.contains(where: { $0.pair.key == key }), + !dictionary().hasRule(for: proposal.pair.heard) else { return } + proposals = Array(([proposal] + proposals).prefix(Self.maximumProposals)) + } +} diff --git a/Sources/Pladder/HotkeyRouter.swift b/Sources/Pladder/HotkeyRouter.swift new file mode 100644 index 0000000..79a2752 --- /dev/null +++ b/Sources/Pladder/HotkeyRouter.swift @@ -0,0 +1,129 @@ +import Foundation +import Observation +import PladderCore +import PladderSystem + +/// Decides which hotkey monitor drives the coordinator and which chord it +/// listens for. +/// +/// Both monitors are kept for the app's life so switching costs nothing. The +/// tap swallows the chord's regular key and matches left and right modifiers +/// exactly, but needs Accessibility; Carbon needs no permission at all and is +/// what a standard account gets. Granting Accessibility upgrades to the tap, +/// revoking it drops back to Carbon, and a sustained Secure Event Input, +/// which stops taps seeing key-downs, hands a chord with a regular key to +/// Carbon until it clears. A modifier-only chord is unaffected by secure +/// input and Carbon cannot register it, so it stays on the tap. The +/// push-to-talk chord alone decides; the toggle chord follows whichever +/// monitor is up. +/// +/// Without Accessibility a stored chord Carbon cannot register listens for +/// nothing, so the default stands in for it. The stored chord is never +/// rewritten and returns with the grant. +@MainActor +@Observable +final class HotkeyRouter { + /// Which of the two the coordinator is currently driven by. + private(set) var usesTap: Bool + private(set) var accessibilityTrusted: Bool + /// The keyboard shortcuts macOS itself handles. Read on explicit + /// triggers only — launch, a permission flip, the settings window + /// opening — because `CopySymbolicHotKeys` is main-thread work linear in + /// the number of shortcuts and the answer changes about as often as + /// someone visits System Settings. Never on a key press. + private(set) var systemShortcuts: Set = [] + /// The stored push-to-talk chord, as the app last reported it. + private(set) var hotkey: Hotkey + + @ObservationIgnored private let tap = GlobalHotkeyMonitor() + @ObservationIgnored private let carbon = CarbonHotkeyMonitor() + /// Only a *sustained* reading counts, so the password field the user + /// tabs through does not swap the monitor twice in four seconds. + @ObservationIgnored private var secureInput = SustainedCondition() + /// The last poll's answer, so a chord change between polls decides with it. + @ObservationIgnored private var secureInputSustained = false + @ObservationIgnored private weak var coordinator: DictationCoordinator? + /// So the first `update` applies the stand-in even though nothing + /// flipped; `usesTap` already matches the grant at that point. + @ObservationIgnored private var didApplyStandIn = false + + init(accessibilityTrusted: Bool, hotkey: Hotkey) { + self.accessibilityTrusted = accessibilityTrusted + self.hotkey = hotkey + usesTap = accessibilityTrusted + } + + /// The monitor the coordinator starts with. + var initialMonitor: any HotkeyMonitor { usesTap ? tap : carbon } + + func attach(_ coordinator: DictationCoordinator) { + self.coordinator = coordinator + } + + /// Called after every permission poll. + func update(accessibilityTrusted trusted: Bool, secureInputEnabled: Bool) { + if trusted != accessibilityTrusted { accessibilityTrusted = trusted } + secureInputSustained = secureInput.observe(secureInputEnabled) + let flipped = chooseMonitor() + guard flipped != nil || !didApplyStandIn else { return } + didApplyStandIn = true + // One update: set one after the other, the new monitor would first be + // started with the old stand-in, which Carbon may refuse. A recording + // in progress is dropped, since the old monitor's release can no + // longer arrive. + if let coordinator { + coordinator.update(coordinator.settings, hotkeyOverride: standInHotkey, monitor: flipped) + } + refreshSystemShortcuts() + } + + /// A chord with a regular key no longer needs a stand-in, and a + /// modifier-only one does; under Secure Event Input it may also need the + /// other monitor, which Carbon cannot register it on. Returns that + /// monitor when it changed, and the app hands the coordinator it, the new + /// `standInHotkey` and the new chord in one restart, rather than leaving + /// the chord on the wrong monitor until the next permission poll. + func hotkeyChanged(to hotkey: Hotkey) -> (any HotkeyMonitor)? { + guard hotkey != self.hotkey else { return nil } + self.hotkey = hotkey + return chooseMonitor() + } + + /// Re-decides between the tap and Carbon; returns the monitor to switch + /// to, or nil when it stays. + private func chooseMonitor() -> (any HotkeyMonitor)? { + let wantsTap = HotkeySource.choose( + accessibilityTrusted: accessibilityTrusted, secureInputSustained: secureInputSustained, + hotkey: hotkey) == .tap + guard wantsTap != usesTap else { return nil } + usesTap = wantsTap + return wantsTap ? tap : carbon + } + + /// The default chord, standing in for a stored chord Carbon cannot + /// register while Accessibility is missing. + var standInHotkey: Hotkey? { + accessibilityTrusted ? nil : hotkey.standInWithoutAccessibility + } + + /// True while a working Accessibility grant is being ignored because + /// Secure Event Input has the tap deaf and Carbon is standing in. + var usesCarbonForSecureInput: Bool { accessibilityTrusted && !usesTap } + + /// Without Accessibility the chord has to contain a regular key, so the + /// recorder refuses modifier-only chords and settings says why. + var hotkeyNeedsRegularKey: Bool { !accessibilityTrusted } + + /// A chord named the way the monitor that matches it sees the keys: the + /// tap tells Left from Right, Carbon's mask cannot. + func displayName(of chord: Hotkey) -> String { + usesTap ? chord.displayName : chord.sideAgnosticDisplayName + } + + /// Re-reads the shortcuts macOS owns, which feed the warnings shown in + /// the settings window. + func refreshSystemShortcuts() { + let shortcuts = SystemShortcuts.enabled() + if shortcuts != systemShortcuts { systemShortcuts = shortcuts } + } +} diff --git a/Sources/Pladder/MainActorRelay.swift b/Sources/Pladder/MainActorRelay.swift new file mode 100644 index 0000000..c7e33f0 --- /dev/null +++ b/Sources/Pladder/MainActorRelay.swift @@ -0,0 +1,12 @@ +/// Carries values from a nonisolated callback onto the main actor, and lets +/// the callback be handed to something built before its handler exists: the +/// coordinator's events, the learner's proposals and the model files' status +/// all start out that way in `AppModel.init`. +@MainActor +final class MainActorRelay { + var handler: ((Value) -> Void)? + + nonisolated func send(_ value: Value) { + Task { @MainActor in self.handler?(value) } + } +} diff --git a/Sources/Pladder/MenuBarIcon.swift b/Sources/Pladder/MenuBarIcon.swift index c24b837..27c23e1 100644 --- a/Sources/Pladder/MenuBarIcon.swift +++ b/Sources/Pladder/MenuBarIcon.swift @@ -21,12 +21,12 @@ enum MenuBarIcon { static let levelSteps = 6 - init(state: DictationState) { + init(state: DictationState, level: Float) { switch state { // The clipboard hint is not a failure and not work in progress; // the glyph reads as ready while the overlay carries the message. case .idle, .copied: self = .idle - case .recording(let level): + case .recording: // Quantise the same shared amplitude curve the overlay's // meter uses, so glyph and overlay wave agree on what the // input level means. @@ -51,8 +51,8 @@ enum MenuBarIcon { @MainActor private static var cache: [Variant: NSImage] = [:] @MainActor - static func image(for state: DictationState) -> NSImage { - image(for: Variant(state: state)) + static func image(for state: DictationState, level: Float = 0) -> NSImage { + image(for: Variant(state: state, level: level)) } @MainActor diff --git a/Sources/Pladder/Overlay/LiveTranscriptMetrics.swift b/Sources/Pladder/Overlay/LiveTranscriptMetrics.swift deleted file mode 100644 index 8a4791c..0000000 --- a/Sources/Pladder/Overlay/LiveTranscriptMetrics.swift +++ /dev/null @@ -1,75 +0,0 @@ -import AppKit -import SwiftUI - -/// How much of the live transcript fits in the pill. -/// -/// The pill shows at most three lines, and the words worth showing are the -/// last ones. SwiftUI cannot do that on its own: `lineLimit(3)` with -/// `truncationMode(.head)` keeps the *first* two lines and ellipsises the -/// third, so the reader gets the oldest words, the newest ones, and a hole in -/// between. Cutting the string here instead gives a plain, continuous tail. -/// -/// The measurement uses the same font the pill draws in, so the cut lands -/// where the text really wraps. It runs while the key is held, never on the -/// release-to-paste path. -@MainActor -enum LiveTranscriptMetrics { - /// The 13 pt rounded medium the live row draws in, as an `NSFont` so it - /// can be measured. - static let font: NSFont = { - let base = NSFont.systemFont(ofSize: 13, weight: .medium) - guard let descriptor = base.fontDescriptor.withDesign(.rounded), - let rounded = NSFont(descriptor: descriptor, size: 13) - else { return base } - return rounded - }() - - /// Height of one line of that font, laid out the way `boundingRect` lays - /// the text out below. - static let lineHeight: CGFloat = height(of: "Mg", width: .greatestFiniteMagnitude) - - /// The longest tail of `text`, starting at a word, that lays out in at - /// most `lines` lines when wrapped at `width`. The whole text when it - /// already fits. - static func tail(of text: String, fittingLines lines: Int, width: CGFloat) -> String { - guard !text.isEmpty, width > 0, lines > 0 else { return text } - let limit = lineHeight * CGFloat(lines) + 0.5 - guard height(of: text, width: width) > limit else { return text } - - let starts = wordStarts(in: text) - guard starts.count > 1 else { return text } - // Fits is monotonic: the later a tail starts, the shorter it is, so - // the first start that fits is the one to use. - var low = 0 - var high = starts.count - 1 - while low < high { - let middle = (low + high) / 2 - if height(of: String(text[starts[middle]...]), width: width) <= limit { - high = middle - } else { - low = middle + 1 - } - } - return String(text[starts[low]...]) - } - - /// Index of the first character of every word. - private static func wordStarts(in text: String) -> [String.Index] { - var starts: [String.Index] = [] - var afterSpace = true - for index in text.indices { - let character = text[index] - if afterSpace && !character.isWhitespace { starts.append(index) } - afterSpace = character.isWhitespace - } - return starts - } - - private static func height(of string: String, width: CGFloat) -> CGFloat { - let attributed = NSAttributedString(string: string, attributes: [.font: font]) - return attributed.boundingRect( - with: CGSize(width: width, height: .greatestFiniteMagnitude), - options: [.usesLineFragmentOrigin, .usesFontLeading] - ).height - } -} diff --git a/Sources/Pladder/Overlay/LiveTranscriptText.swift b/Sources/Pladder/Overlay/LiveTranscriptText.swift new file mode 100644 index 0000000..5a20ebc --- /dev/null +++ b/Sources/Pladder/Overlay/LiveTranscriptText.swift @@ -0,0 +1,144 @@ +import AppKit +import SwiftUI +import PladderCore + +/// The words so far, with the part that has not settled yet in secondary. +/// +/// Every live pass re-decodes the audio and returns a whole new string, so the +/// layout can change anywhere in it. Nothing here animates: a cross-fade +/// between two layouts draws both at once, which is unreadable at two passes a +/// second. Each pass simply replaces the text. The only thing carried between +/// passes is the common prefix with the previous string, which is the part the +/// engine has stopped changing; what came after it is drawn in secondary, so +/// the eye can see where the settled words end. +struct LiveTranscriptText: View { + let text: String + /// A replica in a settings card has no row to fill, so it hugs its sample + /// text, wrapped at `width` onto at most two lines and never trimmed. + var hugs: Bool = false + /// How wide the words may run before they wrap, so the view can work out + /// how much of the tail fits. + var width: CGFloat? + + /// Three lines of 13 pt is as much as the pill can show without turning + /// into a window. + static let maximumLines = 3 + + + /// The text of the previous pass, for the settled/unsettled split. It is + /// only a colour, so lagging one pass behind costs nothing. + @State private var previous = "" + + /// The part of the text that is shown: the whole of it while it fits in + /// three lines, and its tail once it does not. SwiftUI's own head + /// truncation is no use here — with a line limit it keeps the first lines + /// and ellipsises the last one, which shows the oldest words and the + /// newest ones with the middle missing — so the cut is made here, at a + /// word boundary, against the font's real metrics. + private var shown: String { + guard let width, !hugs else { return text } + return LiveTranscriptMetrics.tail(of: text, fittingLines: Self.maximumLines, width: width) + } + + /// One `AttributedString` rather than two concatenated `Text`s, which + /// macOS 26 deprecates. + private var attributed: AttributedString { + let shown = shown + // The previous pass may have been trimmed to a different tail, so + // compare like with like from the end. + var stable = shown.commonPrefix(with: previous.suffix(shown.count)) + // A prefix that stops inside a word would paint half of it grey, so + // back up to the end of the last whole word. + if stable.count < shown.count, let lastSpace = stable.lastIndex(where: \.isWhitespace) { + stable = String(stable[.. String { + guard !text.isEmpty, width > 0, lines > 0 else { return text } + let limit = lineHeight * CGFloat(lines) + 0.5 + guard height(of: text, width: width) > limit else { return text } + + let starts = wordStarts(in: text) + guard starts.count > 1 else { return text } + // Fits is monotonic: the later a tail starts, the shorter it is, so + // the first start that fits is the one to use. + var low = 0 + var high = starts.count - 1 + while low < high { + let middle = (low + high) / 2 + if height(of: String(text[starts[middle]...]), width: width) <= limit { + high = middle + } else { + low = middle + 1 + } + } + return String(text[starts[low]...]) + } + + /// Index of the first character of every word. + private static func wordStarts(in text: String) -> [String.Index] { + var starts: [String.Index] = [] + var afterSpace = true + for index in text.indices { + let character = text[index] + if afterSpace && !character.isWhitespace { starts.append(index) } + afterSpace = character.isWhitespace + } + return starts + } + + private static func height(of string: String, width: CGFloat) -> CGFloat { + let attributed = NSAttributedString(string: string, attributes: [.font: font]) + return attributed.boundingRect( + with: CGSize(width: width, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading] + ).height + } +} diff --git a/Sources/Pladder/Overlay/OverlayController.swift b/Sources/Pladder/Overlay/OverlayController.swift index 29c002d..3da2cc6 100644 --- a/Sources/Pladder/Overlay/OverlayController.swift +++ b/Sources/Pladder/Overlay/OverlayController.swift @@ -1,7 +1,7 @@ import AppKit import Foundation -import Observation import PladderCore +import PladderSystem /// Mirrors the coordinator's state onto the overlay panel and decides when the /// pill appears and disappears. @@ -14,6 +14,8 @@ final class OverlayController { private var hideTask: Task? private var presentTask: Task? private var running = false + private let stateLoop = ObservationLoop() + private let levelLoop = ObservationLoop() private var visible = false /// The pill is being held up across a polish pass. It leaves by the dive /// like a pasted dictation, not by the fade, and that has to hold even @@ -41,11 +43,14 @@ final class OverlayController { guard !running else { return } running = true observe() + observeLevel() apply(coordinator.state) } func stop() { running = false + stateLoop.stop() + levelLoop.stop() cancelPresent() hideTask?.cancel() panel.orderOut(nil) @@ -53,12 +58,11 @@ final class OverlayController { holdingDisc = false } - /// Pushes the app's appearance onto the panel. Borderless panels that are - /// never key or main do not reliably follow an `NSApp.appearance` change - /// after creation, so it is set explicitly. + /// Pushes the app's appearance onto the pill's SwiftUI scheme. The panel + /// itself takes it at every `show`: a borderless panel that is never key + /// or main does not reliably follow an `NSApp.appearance` change. func applyAppearance(_ appearance: Appearance) { model.appearance = appearance - panel.appearance = appearance == .system ? NSApp.appearance : NSAppearance(named: appearance == .dark ? .darkAqua : .aqua) } /// Pushes the overlay style and background choice onto the model, which @@ -74,20 +78,31 @@ final class OverlayController { model.speed = speed } - /// `withObservationTracking` fires once per change, so we re-arm it every - /// time. The callback runs *before* the new value is stored, hence the hop - /// onto a task to read it. private func observe() { - withObservationTracking { + stateLoop.start { [coordinator] in _ = coordinator.state // Read so a new partial re-arms this too: between two passes the // state stays `.recording` and nothing else would fire. _ = coordinator.partialTranscript } onChange: { [weak self] in - Task { @MainActor [weak self] in - guard let self, self.running else { return } - self.apply(self.coordinator.state) - self.observe() + guard let self else { return } + self.apply(self.coordinator.state) + } + } + + /// The meter's level, on its own loop: it changes twenty times a second + /// while recording, and only the bars need to hear about it, not the + /// presentation logic `apply` runs. + private func observeLevel() { + levelLoop.start { [coordinator] in + _ = coordinator.inputLevel + } onChange: { [weak self] in + guard let self else { return } + // Only while recording: at the release the pill gathers from + // the row it was showing, last level included, and the level + // dropping to zero would flatten the bars mid-gather. + if self.coordinator.state.isRecording { + self.model.level = self.coordinator.inputLevel } } } diff --git a/Sources/Pladder/Overlay/OverlayModel.swift b/Sources/Pladder/Overlay/OverlayModel.swift new file mode 100644 index 0000000..95dbd54 --- /dev/null +++ b/Sources/Pladder/Overlay/OverlayModel.swift @@ -0,0 +1,120 @@ +import SwiftUI +import PladderCore + +/// The state the overlay renders. Kept separate from the coordinator so the +/// panel can be driven independently (and shown while fading out after the +/// coordinator has already returned to `.idle`). +@MainActor +@Observable +final class OverlayModel { + var state: DictationState = .idle + /// The input level while recording, mirrored on its own so a level tick + /// redraws the meter and nothing else. + var level: Float = 0 + /// Appearance forced by the Appearance setting; `.system` means follow. + /// AppKit's window propagation reaches a borderless panel inconsistently, + /// so the color scheme is set in SwiftUI directly. + var appearance: Appearance = .system + /// Which pill the user picked. `.menuBar` never presents except for + /// errors; the controller decides that, not the view. + var style: OverlayStyle = .compact + /// Liquid Glass behind the pill, or a flat window-background fill. + var glass: Bool = true + /// How fast the pill flies in and out. + var speed: OverlayAnimationSpeed = .quick + /// Where the pill is in the fly-in/fly-out presentation. The panel does + /// the sliding below the screen edge; this phase keeps the pill as the + /// Minimal disc whenever it is not settled, so it flies as the disc and + /// morphs to its style's shape once it has arrived. + var presentation: OverlayPresentation = .hidden + /// What the engine has heard so far, for the Live Transcript style. Nil in + /// every other style, and nil again the moment the key is released. + var partialTranscript: String? + init() {} +} + +enum OverlayPresentation: Equatable { + /// Off screen, parked as the disc so the next flight starts from it. + case hidden + /// Rising from the bottom edge as the disc; content forced to Minimal. + case flyingIn + /// At rest, in the style's own shape. + case settled + /// Collapsing to the disc, about to dive; content forced to Minimal. + case flyingOut +} + +/// The timing the animation setting maps to. Panel and view read the same +/// values so the slide and the morph stay in sync; the mapping lives here +/// because PladderCore never imports SwiftUI. +extension OverlayAnimationSpeed { + /// How long the panel takes to slide up from (or back down behind) the + /// bottom edge of the screen. + var flightDuration: TimeInterval { + switch self { + case .instant: 0.06 + case .quick: 0.20 + case .expressive: 0.35 + } + } + + /// How long the disc↔row morph takes, in both directions. Arrival is + /// slide-up then expand; departure is collapse then slide-down. The + /// controller waits exactly this long between the collapse and the dive, + /// so the two directions are mirror images, and the content crossfade + /// runs over the same span so what is inside the pill never outruns the + /// pill. + var morphDuration: TimeInterval { + switch self { + case .instant: 0.10 + case .quick: 0.26 + case .expressive: 0.5 + } + } + + /// The spring the disc↔row geometry uses. A spring's duration is + /// perceptual, so the bounce is kept small enough that the settle stays + /// inside `morphDuration`. + var morphAnimation: Animation { + switch self { + case .instant: .smooth(duration: morphDuration) + case .quick: .spring(duration: morphDuration, bounce: 0.12) + case .expressive: .spring(duration: morphDuration, bounce: 0.22) + } + } + + /// How the content arriving with a morph fades in: over the same span as + /// the geometry, but no bounce, since a spring on opacity would dip past + /// zero and flash the content back. What is leaving goes quickly instead + /// (`OverlayPill.contentHandover`), so the eye never sees two dots. + var contentFade: Animation { .easeInOut(duration: morphDuration) } +} + +/// What the pill looks like, with the live audio level projected out. +/// +/// `DictationState.recording` carries a level that changes many times a second. +/// Driving the capsule's morph animation off the state itself would restart +/// that animation on every meter update, so the morph animates on this +/// level-free phase instead while the bars animate on their own. +enum OverlayPhase: Equatable { + case empty + case recording + case transcribing + case polishing + case copied + case error(DictationFailure) + + init(_ state: DictationState) { + switch state { + case .recording: self = .recording + case .transcribing: self = .transcribing + case .polishing: self = .polishing + case .copied: self = .copied + // The controller never mirrors `.inserting` onto the model, so this + // is only reached by a preview, and it draws nothing. + case .inserting: self = .empty + case .error(let failure): self = .error(failure) + case .idle, .unavailable: self = .empty + } + } +} diff --git a/Sources/Pladder/Overlay/OverlayPill.swift b/Sources/Pladder/Overlay/OverlayPill.swift new file mode 100644 index 0000000..3e711b6 --- /dev/null +++ b/Sources/Pladder/Overlay/OverlayPill.swift @@ -0,0 +1,298 @@ +import SwiftUI +import PladderCore + +/// The pill itself, without the panel's shadow or forced scheme, so the +/// settings previews can render a live replica of each style. +struct OverlayPill: View { + let state: DictationState + /// The input level the meters follow while recording. + var level: Float = 0 + let style: OverlayStyle + let glass: Bool + /// A static replica in settings: no dot timer, no pulse, seeded bars. + var isPreview: Bool = false + /// The words so far, for the Live Transcript style. Ignored by the others. + var partial: String? + /// The live row is a fixed box on screen so the capsule cannot jitter as + /// the text grows. A settings card has no room for that box, so its + /// replica hugs its sample text, wrapped onto two short lines. + var hugsContent: Bool = false + /// While the pill flies in or out (or waits off screen) it is always the + /// Minimal disc: the bigger styles expand from it after arriving and + /// collapse back into it before diving. Settings replicas never fly, so + /// they are always settled. + var presentation: OverlayPresentation = .settled + /// The speed whose spring drives the disc↔row geometry on both sides of + /// the flight, so contracting to the disc takes exactly as long as + /// expanding out of it. Settings replicas never fly, so they keep the + /// default. + var animationSpeed: OverlayAnimationSpeed = .quick + + @Namespace private var glassNamespace + /// Minimal shows a pulsing dot for the first 0.7 s, then the bars. + @State private var showDot = true + @State private var pulsing = false + + private var phase: OverlayPhase { OverlayPhase(state) } + + private var flying: Bool { presentation != .settled } + + var body: some View { + GlassEffectContainer(spacing: 14) { + content + // The size driver: in flight the pill is proposed the disc's + // width, at rest its own — animated with the speed's morph + // spring on *both* sides of the flight, so contracting to the + // disc and expanding out of it are the same rate. The glass + // bubble outside this point follows the proposal. + .frame(width: flying ? Self.minimalDiameter : nil) + // What arrives inside fades in over the same span (the branch + // transitions below), so the row is still faint while the + // capsule is small; this clip keeps it inside the capsule + // rather than poking out of the disc. + .clipShape(Capsule()) + .animation(animationSpeed.morphAnimation, value: flying) + .modifier(PillBackground(glass: glass, namespace: glassNamespace)) + } + .task(id: phase) { + guard !isPreview else { return } + // Runs on every phase change, so leaving `.recording` is where + // the pulse is reset; otherwise the next take's dot would appear + // already at full scale with no animation left to run. + guard phase == .recording else { + pulsing = false + return + } + showDot = true + try? await Task.sleep(for: .milliseconds(700)) + // A phase change cancels this task, which is exactly what should + // stop the swap; nothing else to unwind. + guard !Task.isCancelled else { return } + withAnimation(.smooth(duration: 0.3)) { showDot = false } + } + } + + private var isError: Bool { + if case .error = state { return true } + return false + } + + private var isCopied: Bool { state == .copied } + + /// Both an error and the clipboard hint carry text, which needs the + /// Compact row's width, so they render as the row in every style. + private var needsRow: Bool { isError || isCopied || style != .minimal } + + /// The transition on every content branch: what is arriving fades in + /// over the morph, so it comes up with the capsule around it; what is + /// leaving goes at once, so its dot is gone before the arriving dot is + /// visible and nothing is seen sliding towards the centre of a shrinking + /// capsule. + private var contentHandover: AnyTransition { + .asymmetric( + insertion: .opacity.animation(animationSpeed.contentFade), + removal: .opacity.animation(.easeOut(duration: 0.08)) + ) + } + + @ViewBuilder + private var content: some View { + // In flight the pill is the Minimal disc whatever the style: the disc + // rises with the dot inside it, and the style's own shape comes out + // of it on arrival. Minimal shares that branch whether flying or + // not, so its bars and dot keep their identity — and their state — + // across the arrival. + if flying || !needsRow { + // A fixed square so the disc never changes size between the + // dot, the wave and the spinner. On release a row style's + // content goes at once and the Minimal wave comes up in the + // shrinking capsule, so every style leaves the way Minimal does. + minimalContent + .frame(width: Self.minimalDiameter, height: Self.minimalDiameter) + .transition(contentHandover) + } + // An error presents in every style (the controller makes sure of it), + // and the message needs the Compact row's width, so errors always + // render as the Compact row; the "press ⌘V" hint is text too and + // follows the same rule. `.menuBar` only ever reaches the view for + // those two. Live has its own recording row and falls back to the + // Compact rows for everything else. + else if style == .liveTranscript && !isError { + liveContent + .frame(minHeight: 32) + .padding(.horizontal, 18) + .padding(.vertical, 12) + .frame(minWidth: 140) + .transition(contentHandover) + } else { + compactContent + .frame(minHeight: 32) + .padding(.horizontal, 18) + .padding(.vertical, 12) + .frame(minWidth: 140) + .transition(contentHandover) + } + } + + @ViewBuilder + private var compactContent: some View { + switch state { + case .recording: + HStack(spacing: PillMetrics.rowSpacing) { + RecordingDot() + LevelBars(level: level, count: 14, maxHeight: 32, opacity: 1, seeded: isPreview) + } + case .transcribing: + HStack(spacing: PillMetrics.rowSpacing) { + ProgressView() + .controlSize(.small) + Text("Transcribing…") + .font(PillMetrics.font) + .foregroundStyle(.primary) + } + case .polishing: + // Only a dictation on its way to the refiner gets here, and it + // waits seconds rather than milliseconds, so the pill says why. + HStack(spacing: PillMetrics.rowSpacing) { + ProgressView() + .controlSize(.small) + Text("Polishing…") + .font(PillMetrics.font) + .foregroundStyle(.primary) + } + case .copied: + // Nothing pasted the text, so the user has to. The one thing the + // pill still says after a release: the paste that is normally the + // confirmation never happened. + HStack(spacing: 8) { + Image(systemName: "doc.on.clipboard") + .foregroundStyle(.secondary) + Text("Copied — press ⌘V") + .font(PillMetrics.font) + .foregroundStyle(.primary) + } + case .error(let failure): + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text(failure.text) + .font(PillMetrics.font) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.tail) + // Keeps a long message inside the panel instead of letting + // the capsule grow past its edges. + .frame(maxWidth: 232) + } + // `.inserting` is never mirrored onto the model, so it draws nothing. + case .inserting, .idle, .unavailable: + Color.clear.frame(width: 100) + } + } + + /// The words as they are recognised, beside a narrower meter. Only the + /// recording row differs from Compact: the spinner, the clipboard hint and + /// the error say the same thing in every style, and letting them keep the + /// live row's width would leave a mostly empty capsule on screen. + @ViewBuilder + private var liveContent: some View { + switch state { + case .recording: + HStack(spacing: PillMetrics.rowSpacing) { + RecordingDot() + LevelBars(level: level, count: Self.liveBarCount, maxHeight: 24, opacity: 1, seeded: isPreview) + LiveTranscriptText( + text: partial ?? "", + hugs: hugsContent, + width: hugsContent ? Self.previewTextWidth : Self.liveTextWidth + ) + } + // A capsule whose width changed with the text would jitter on + // every pass, so the row is a fixed width; its height follows the + // number of lines, one to three. + .frame(width: hugsContent ? nil : Self.liveRowWidth, alignment: .leading) + default: + compactContent + } + } + + /// Width of the live row's contents. With the 18 pt padding either side + /// the capsule comes to 440 pt, inside the 480 pt panel with room for its + /// shadow. + static let liveRowWidth: CGFloat = 404 + + /// The live row's meter is narrower than Compact's, to leave the words + /// room. + private static let liveBarCount = 8 + + /// What the dot, the meter and the two gaps take of that row. + private static let liveRowLead: CGFloat = PillMetrics.dotSize + PillMetrics.rowSpacing + + PillMetrics.barsWidth(count: liveBarCount) + PillMetrics.rowSpacing + + /// What is left of the row for the words. The text measures itself against + /// this to decide how much of the tail fits in three lines. + static var liveTextWidth: CGFloat { liveRowWidth - liveRowLead } + + /// The words in a settings card: wide enough for two short lines, so the + /// card reads as text beside the meter rather than a long thin pill. + static let previewTextWidth: CGFloat = 62 + + /// Diameter of the Minimal disc. Five bars at 3 pt with 3 pt gaps are + /// 27 pt wide and 20 pt tall, which sits inside the disc with room to + /// spare at the chord where the bars reach. + static let minimalDiameter: CGFloat = 44 + + /// Just enough to say "recording", and "still working" when a + /// transcription runs long: no text, and a disc rather than a pill. The + /// dot marks the start of a take, then a narrow wave takes over so the + /// disc still shows the microphone is live. + @ViewBuilder + private var minimalContent: some View { + switch state { + case .recording: + ZStack { + if showsDot { + // The pulse is Minimal's own start-of-take cue. A row + // style flying in keeps the dot at the row's size, so + // the dot it hands over to on arrival is the same dot. + RecordingDot() + .scaleEffect(pulsing && !needsRow ? 1.3 : 1.0) + .onAppear { + guard !isPreview else { return } + withAnimation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true)) { + pulsing = true + } + } + .transition(.opacity) + } else { + LevelBars(level: level, count: 5, maxHeight: 20, opacity: 0.8, seeded: isPreview) + .transition(.opacity) + } + } + case .transcribing, .polishing: + ProgressView() + .controlSize(.small) + case .copied: + // The hint leaves by the dive like a pasted dictation, and the + // disc it collapses into keeps the row's clipboard glyph rather + // than going empty, so what slides away still says what happened. + Image(systemName: "doc.on.clipboard") + .foregroundStyle(.secondary) + .transition(.opacity) + // An error is rendered as the row above, so it never reaches this; + // `.inserting` is never mirrored onto the model. + case .inserting, .error, .idle, .unavailable: + Color.clear + } + } + + /// A replica has no timer, so it goes straight to the bars. A row style + /// collapsing after a short take would otherwise put the dot back in the + /// middle of the capsule its own dot just left; the wave is what the + /// disc shows on the way out. + private var showsDot: Bool { + if isPreview { return false } + if presentation == .flyingOut && needsRow { return false } + return showDot + } +} diff --git a/Sources/Pladder/Overlay/OverlayView.swift b/Sources/Pladder/Overlay/OverlayView.swift index e26b5d9..b53384f 100644 --- a/Sources/Pladder/Overlay/OverlayView.swift +++ b/Sources/Pladder/Overlay/OverlayView.swift @@ -1,121 +1,6 @@ import SwiftUI import PladderCore -/// The state the overlay renders. Kept separate from the coordinator so the -/// panel can be driven independently (and shown while fading out after the -/// coordinator has already returned to `.idle`). -@MainActor -@Observable -final class OverlayModel { - var state: DictationState = .idle - /// Appearance forced by the Appearance setting; `.system` means follow. - /// AppKit's window propagation reaches a borderless panel inconsistently, - /// so the color scheme is set in SwiftUI directly. - var appearance: Appearance = .system - /// Which pill the user picked. `.menuBar` never presents except for - /// errors; the controller decides that, not the view. - var style: OverlayStyle = .compact - /// Liquid Glass behind the pill, or a flat window-background fill. - var glass: Bool = true - /// How fast the pill flies in and out. - var speed: OverlayAnimationSpeed = .quick - /// Where the pill is in the fly-in/fly-out presentation. The panel does - /// the sliding below the screen edge; this phase keeps the pill as the - /// Minimal disc whenever it is not settled, so it flies as the disc and - /// morphs to its style's shape once it has arrived. - var presentation: OverlayPresentation = .hidden - /// What the engine has heard so far, for the Live Transcript style. Nil in - /// every other style, and nil again the moment the key is released. - var partialTranscript: String? - init() {} -} - -enum OverlayPresentation: Equatable { - /// Off screen, parked as the disc so the next flight starts from it. - case hidden - /// Rising from the bottom edge as the disc; content forced to Minimal. - case flyingIn - /// At rest, in the style's own shape. - case settled - /// Collapsing to the disc, about to dive; content forced to Minimal. - case flyingOut -} - -/// The timing the animation setting maps to. Panel and view read the same -/// values so the slide and the morph stay in sync; the mapping lives here -/// because PladderCore never imports SwiftUI. -extension OverlayAnimationSpeed { - /// How long the panel takes to slide up from (or back down behind) the - /// bottom edge of the screen. - var flightDuration: TimeInterval { - switch self { - case .instant: 0.06 - case .quick: 0.20 - case .expressive: 0.35 - } - } - - /// How long the disc↔row morph takes, in both directions. Arrival is - /// slide-up then expand; departure is collapse then slide-down. The - /// controller waits exactly this long between the collapse and the dive, - /// so the two directions are mirror images, and the content crossfade - /// runs over the same span so what is inside the pill never outruns the - /// pill. - var morphDuration: TimeInterval { - switch self { - case .instant: 0.10 - case .quick: 0.26 - case .expressive: 0.5 - } - } - - /// The spring the disc↔row geometry uses. A spring's duration is - /// perceptual, so the bounce is kept small enough that the settle stays - /// inside `morphDuration`. - var morphAnimation: Animation { - switch self { - case .instant: .smooth(duration: morphDuration) - case .quick: .spring(duration: morphDuration, bounce: 0.12) - case .expressive: .spring(duration: morphDuration, bounce: 0.22) - } - } - - /// How the content arriving with a morph fades in: over the same span as - /// the geometry, but no bounce, since a spring on opacity would dip past - /// zero and flash the content back. What is leaving goes quickly instead - /// (`OverlayPill.contentHandover`), so the eye never sees two dots. - var contentFade: Animation { .easeInOut(duration: morphDuration) } -} - -/// What the pill looks like, with the live audio level projected out. -/// -/// `DictationState.recording` carries a level that changes many times a second. -/// Driving the capsule's morph animation off the state itself would restart -/// that animation on every meter update, so the morph animates on this -/// level-free phase instead while the bars animate on their own. -private enum OverlayPhase: Equatable { - case empty - case recording - case transcribing - case polishing - case copied - case error(DictationFailure) - - init(_ state: DictationState) { - switch state { - case .recording: self = .recording - case .transcribing: self = .transcribing - case .polishing: self = .polishing - case .copied: self = .copied - // The controller never mirrors `.inserting` onto the model, so this - // is only reached by a preview, and it draws nothing. - case .inserting: self = .empty - case .error(let failure): self = .error(failure) - case .idle, .unavailable: self = .empty - } - } -} - /// Liquid Glass pill: a capsule that samples the desktop behind the /// transparent panel and morphs between the recording, transcribing, copied /// and error states. @@ -127,6 +12,7 @@ struct OverlayView: View { var body: some View { OverlayPill( state: model.state, + level: model.level, style: model.style, glass: model.glass, partial: model.partialTranscript, @@ -145,472 +31,6 @@ struct OverlayView: View { } } -/// The pill itself, without the panel's shadow or forced scheme, so the -/// settings previews can render a live replica of each style. -struct OverlayPill: View { - let state: DictationState - let style: OverlayStyle - let glass: Bool - /// A static replica in settings: no dot timer, no pulse, seeded bars. - var isPreview: Bool = false - /// The words so far, for the Live Transcript style. Ignored by the others. - var partial: String? - /// The live row is a fixed box on screen so the capsule cannot jitter as - /// the text grows. A settings card has no room for that box, so its - /// replica hugs its sample text, wrapped onto two short lines. - var hugsContent: Bool = false - /// While the pill flies in or out (or waits off screen) it is always the - /// Minimal disc: the bigger styles expand from it after arriving and - /// collapse back into it before diving. Settings replicas never fly, so - /// they are always settled. - var presentation: OverlayPresentation = .settled - /// The speed whose spring drives the disc↔row geometry on both sides of - /// the flight, so contracting to the disc takes exactly as long as - /// expanding out of it. Settings replicas never fly, so they keep the - /// default. - var animationSpeed: OverlayAnimationSpeed = .quick - - @Namespace private var glassNamespace - /// Minimal shows a pulsing dot for the first 0.7 s, then the bars. - @State private var showDot = true - @State private var pulsing = false - - private var phase: OverlayPhase { OverlayPhase(state) } - - private var flying: Bool { presentation != .settled } - - var body: some View { - GlassEffectContainer(spacing: 14) { - content - // The size driver: in flight the pill is proposed the disc's - // width, at rest its own — animated with the speed's morph - // spring on *both* sides of the flight, so contracting to the - // disc and expanding out of it are the same rate. The glass - // bubble outside this point follows the proposal. - .frame(width: flying ? Self.minimalDiameter : nil) - // What arrives inside fades in over the same span (the branch - // transitions below), so the row is still faint while the - // capsule is small; this clip keeps it inside the capsule - // rather than poking out of the disc. - .clipShape(Capsule()) - .animation(animationSpeed.morphAnimation, value: flying) - .modifier(PillBackground(glass: glass, namespace: glassNamespace)) - } - .task(id: phase) { - guard !isPreview else { return } - // Runs on every phase change, so leaving `.recording` is where - // the pulse is reset; otherwise the next take's dot would appear - // already at full scale with no animation left to run. - guard phase == .recording else { - pulsing = false - return - } - showDot = true - try? await Task.sleep(for: .milliseconds(700)) - // A phase change cancels this task, which is exactly what should - // stop the swap; nothing else to unwind. - guard !Task.isCancelled else { return } - withAnimation(.smooth(duration: 0.3)) { showDot = false } - } - } - - private var isError: Bool { - if case .error = state { return true } - return false - } - - private var isCopied: Bool { state == .copied } - - /// Both an error and the clipboard hint carry text, which needs the - /// Compact row's width, so they render as the row in every style. - private var needsRow: Bool { isError || isCopied || style != .minimal } - - /// The transition on every content branch: what is arriving fades in - /// over the morph, so it comes up with the capsule around it; what is - /// leaving goes at once, so its dot is gone before the arriving dot is - /// visible and nothing is seen sliding towards the centre of a shrinking - /// capsule. - private var contentHandover: AnyTransition { - .asymmetric( - insertion: .opacity.animation(animationSpeed.contentFade), - removal: .opacity.animation(.easeOut(duration: 0.08)) - ) - } - - @ViewBuilder - private var content: some View { - // In flight the pill is the Minimal disc whatever the style: the disc - // rises with the dot inside it, and the style's own shape comes out - // of it on arrival. Minimal shares that branch whether flying or - // not, so its bars and dot keep their identity — and their state — - // across the arrival. - if flying || !needsRow { - // A fixed square so the disc never changes size between the - // dot, the wave and the spinner. On release a row style's - // content goes at once and the Minimal wave comes up in the - // shrinking capsule, so every style leaves the way Minimal does. - minimalContent - .frame(width: Self.minimalDiameter, height: Self.minimalDiameter) - .transition(contentHandover) - } - // An error presents in every style (the controller makes sure of it), - // and the message needs the Compact row's width, so errors always - // render as the Compact row; the "press ⌘V" hint is text too and - // follows the same rule. `.menuBar` only ever reaches the view for - // those two. Live has its own recording row and falls back to the - // Compact rows for everything else. - else if style == .liveTranscript && !isError { - liveContent - .frame(minHeight: 32) - .padding(.horizontal, 18) - .padding(.vertical, 12) - .frame(minWidth: 140) - .transition(contentHandover) - } else { - compactContent - .frame(minHeight: 32) - .padding(.horizontal, 18) - .padding(.vertical, 12) - .frame(minWidth: 140) - .transition(contentHandover) - } - } - - @ViewBuilder - private var compactContent: some View { - switch state { - case .recording(let level): - HStack(spacing: 10) { - RecordingDot() - LevelBars(level: level, count: 14, maxHeight: 32, opacity: 1, seeded: isPreview) - } - case .transcribing: - HStack(spacing: 10) { - ProgressView() - .controlSize(.small) - Text("Transcribing…") - .font(.system(size: 13, weight: .medium, design: .rounded)) - .foregroundStyle(.primary) - } - case .polishing: - // Only a dictation on its way to the refiner gets here, and it - // waits seconds rather than milliseconds, so the pill says why. - HStack(spacing: 10) { - ProgressView() - .controlSize(.small) - Text("Polishing…") - .font(.system(size: 13, weight: .medium, design: .rounded)) - .foregroundStyle(.primary) - } - case .copied: - // Nothing pasted the text, so the user has to. The one thing the - // pill still says after a release: the paste that is normally the - // confirmation never happened. - HStack(spacing: 8) { - Image(systemName: "doc.on.clipboard") - .foregroundStyle(.secondary) - Text("Copied — press ⌘V") - .font(.system(size: 13, weight: .medium, design: .rounded)) - .foregroundStyle(.primary) - } - case .error(let failure): - HStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - Text(failure.text) - .font(.system(size: 13, weight: .medium, design: .rounded)) - .foregroundStyle(.primary) - .lineLimit(1) - .truncationMode(.tail) - // Keeps a long message inside the panel instead of letting - // the capsule grow past its edges. - .frame(maxWidth: 232) - } - // `.inserting` is never mirrored onto the model, so it draws nothing. - case .inserting, .idle, .unavailable: - Color.clear.frame(width: 100) - } - } - - /// The words as they are recognised, beside a narrower meter. Only the - /// recording row differs from Compact: the spinner, the clipboard hint and - /// the error say the same thing in every style, and letting them keep the - /// live row's width would leave a mostly empty capsule on screen. - @ViewBuilder - private var liveContent: some View { - switch state { - case .recording(let level): - HStack(spacing: 10) { - RecordingDot() - LevelBars(level: level, count: 8, maxHeight: 24, opacity: 1, seeded: isPreview) - LiveTranscriptText( - text: partial ?? "", - hugs: hugsContent, - width: hugsContent ? Self.previewTextWidth : Self.liveTextWidth - ) - } - // A capsule whose width changed with the text would jitter on - // every pass, so the row is a fixed width; its height follows the - // number of lines, one to three. - .frame(width: hugsContent ? nil : Self.liveRowWidth, alignment: .leading) - default: - compactContent - } - } - - /// Width of the live row's contents. With the 18 pt padding either side - /// the capsule comes to 440 pt, inside the 480 pt panel with room for its - /// shadow. - static let liveRowWidth: CGFloat = 404 - - /// What the dot, the eight bars and the two 10 pt gaps take of that row. - /// The bars are 3 pt wide with 3 pt between them. - private static let liveRowLead: CGFloat = 8 + 10 + (8 * 3 + 7 * 3) + 10 - - /// What is left of the row for the words. The text measures itself against - /// this to decide how much of the tail fits in three lines. - static var liveTextWidth: CGFloat { liveRowWidth - liveRowLead } - - /// The words in a settings card: wide enough for two short lines, so the - /// card reads as text beside the meter rather than a long thin pill. - static let previewTextWidth: CGFloat = 62 - - /// Diameter of the Minimal disc. Five bars at 3 pt with 3 pt gaps are - /// 27 pt wide and 20 pt tall, which sits inside the disc with room to - /// spare at the chord where the bars reach. - static let minimalDiameter: CGFloat = 44 - - /// Just enough to say "recording", and "still working" when a - /// transcription runs long: no text, and a disc rather than a pill. The - /// dot marks the start of a take, then a narrow wave takes over so the - /// disc still shows the microphone is live. - @ViewBuilder - private var minimalContent: some View { - switch state { - case .recording(let level): - ZStack { - if showsDot { - // The pulse is Minimal's own start-of-take cue. A row - // style flying in keeps the dot at the row's size, so - // the dot it hands over to on arrival is the same dot. - RecordingDot() - .scaleEffect(pulsing && !needsRow ? 1.3 : 1.0) - .onAppear { - guard !isPreview else { return } - withAnimation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true)) { - pulsing = true - } - } - .transition(.opacity) - } else { - LevelBars(level: level, count: 5, maxHeight: 20, opacity: 0.8, seeded: isPreview) - .transition(.opacity) - } - } - case .transcribing, .polishing: - ProgressView() - .controlSize(.small) - case .copied: - // The hint leaves by the dive like a pasted dictation, and the - // disc it collapses into keeps the row's clipboard glyph rather - // than going empty, so what slides away still says what happened. - Image(systemName: "doc.on.clipboard") - .foregroundStyle(.secondary) - .transition(.opacity) - // An error is rendered as the row above, so it never reaches this; - // `.inserting` is never mirrored onto the model. - case .inserting, .error, .idle, .unavailable: - Color.clear - } - } - - /// A replica has no timer, so it goes straight to the bars. A row style - /// collapsing after a short take would otherwise put the dot back in the - /// middle of the capsule its own dot just left; the wave is what the - /// disc shows on the way out. - private var showsDot: Bool { - if isPreview { return false } - if presentation == .flyingOut && needsRow { return false } - return showDot - } -} - -/// The words so far, with the part that has not settled yet in secondary. -/// -/// Every live pass re-decodes the audio and returns a whole new string, so the -/// layout can change anywhere in it. Nothing here animates: a cross-fade -/// between two layouts draws both at once, which is unreadable at two passes a -/// second. Each pass simply replaces the text. The only thing carried between -/// passes is the common prefix with the previous string, which is the part the -/// engine has stopped changing; what came after it is drawn in secondary, so -/// the eye can see where the settled words end. -struct LiveTranscriptText: View { - let text: String - /// A replica in a settings card has no row to fill, so it hugs its sample - /// text, wrapped at `width` onto at most two lines and never trimmed. - var hugs: Bool = false - /// How wide the words may run before they wrap, so the view can work out - /// how much of the tail fits. - var width: CGFloat? - - /// Three lines of 13 pt is as much as the pill can show without turning - /// into a window. - static let maximumLines = 3 - - /// The font the words are drawn in, and the one they are measured in. - static let font = Font.system(size: 13, weight: .medium, design: .rounded) - - /// The text of the previous pass, for the settled/unsettled split. It is - /// only a colour, so lagging one pass behind costs nothing. - @State private var previous = "" - - /// The part of the text that is shown: the whole of it while it fits in - /// three lines, and its tail once it does not. SwiftUI's own head - /// truncation is no use here — with a line limit it keeps the first lines - /// and ellipsises the last one, which shows the oldest words and the - /// newest ones with the middle missing — so the cut is made here, at a - /// word boundary, against the font's real metrics. - private var shown: String { - guard let width, !hugs else { return text } - return LiveTranscriptMetrics.tail(of: text, fittingLines: Self.maximumLines, width: width) - } - - /// One `AttributedString` rather than two concatenated `Text`s, which - /// macOS 26 deprecates. - private var attributed: AttributedString { - let shown = shown - // The previous pass may have been trimmed to a different tail, so - // compare like with like from the end. - var stable = shown.commonPrefix(with: previous.suffix(shown.count)) - // A prefix that stops inside a word would paint half of it grey, so - // back up to the end of the last whole word. - if stable.count < shown.count, let lastSpace = stable.lastIndex(where: \.isWhitespace) { - stable = String(stable[.. some View { - if glass { - if let namespace { - content - .glassEffect(.regular, in: shape) - .glassEffectID("pill", in: namespace) - } else { - content - .glassEffect(.regular, in: shape) - } - } else { - content - .background(Color(nsColor: .windowBackgroundColor), in: shape) - .overlay(shape.stroke(Color(nsColor: .separatorColor), lineWidth: 1)) - } - } -} - -/// Bars whose heights follow the input level. The shaping (amplitude curve, -/// bell envelope, wobble, rise/fall blend) lives in the shared -/// `WaveformMeter`, so this wave matches the menu bar glyph exactly. -struct LevelBars: View { - let level: Float - let count: Int - let maxHeight: CGFloat - let opacity: Double - - private static let minScale: CGFloat = 0.1 - - @State private var meter: WaveformMeter - @State private var heights: [CGFloat] - - /// `seeded` pre-rolls the meter so a static replica (the settings cards, - /// which never see a level change) shows a wave rather than a row of - /// stubs. - init(level: Float, count: Int, maxHeight: CGFloat, opacity: Double, seeded: Bool = false) { - self.level = level - self.count = count - self.maxHeight = maxHeight - self.opacity = opacity - - var meter = WaveformMeter(count: count) - var initial = Array(repeating: Self.minScale, count: count) - if seeded { - var shaped: [Float] = [] - for _ in 0..<8 { shaped = meter.update(level: level) } - initial = shaped.map { max(Self.minScale, CGFloat($0)) } - } - _meter = State(initialValue: meter) - _heights = State(initialValue: initial) - } - - var body: some View { - HStack(alignment: .center, spacing: 3) { - ForEach(0.. CGFloat { + CGFloat(count) * barWidth + CGFloat(max(count - 1, 0)) * barSpacing + } +} + +/// The red "live" dot, shared by Compact, Minimal and the settings replicas. +struct RecordingDot: View { + var size: CGFloat = PillMetrics.dotSize + + var body: some View { + Circle() + .fill(.red) + .frame(width: size, height: size) + .shadow(color: .red.opacity(0.6), radius: 4) + } +} + +/// What sits behind the pill: Liquid Glass, or a flat window-background +/// capsule with a hairline border for people who want the desktop to stay +/// still. The shadow is added by whoever hosts the pill. +struct PillBackground: ViewModifier { + let glass: Bool + /// Only the live overlay morphs between states, so the glass identity is + /// optional; the settings replicas pass nothing. + var namespace: Namespace.ID? + + /// One shape for the rows and the Minimal disc: a capsule in a square is + /// a circle, so the disc↔row morph is purely the animated size. A + /// separate `Circle` would snap — `Circle()` in a row-sized frame draws a + /// disc in the middle of the row at once, and `AnyShape` cannot + /// interpolate between two shape types, so the collapse would be over + /// before it started. + private var shape: Capsule { Capsule() } + + @ViewBuilder + func body(content: Content) -> some View { + if glass { + if let namespace { + content + .glassEffect(.regular, in: shape) + .glassEffectID("pill", in: namespace) + } else { + content + .glassEffect(.regular, in: shape) + } + } else { + content + .background(Color(nsColor: .windowBackgroundColor), in: shape) + .overlay(shape.stroke(Color(nsColor: .separatorColor), lineWidth: 1)) + } + } +} + +/// Bars whose heights follow the input level. The shaping (amplitude curve, +/// bell envelope, wobble, rise/fall blend) lives in the shared +/// `WaveformMeter`, so this wave matches the menu bar glyph exactly. +struct LevelBars: View { + let level: Float + let count: Int + let maxHeight: CGFloat + let opacity: Double + + private static let minScale: CGFloat = 0.1 + + @State private var meter: WaveformMeter + @State private var heights: [CGFloat] + + /// `seeded` pre-rolls the meter so a static replica (the settings cards, + /// which never see a level change) shows a wave rather than a row of + /// stubs. + init(level: Float, count: Int, maxHeight: CGFloat, opacity: Double, seeded: Bool = false) { + self.level = level + self.count = count + self.maxHeight = maxHeight + self.opacity = opacity + + var meter = WaveformMeter(count: count) + var initial = Array(repeating: Self.minScale, count: count) + if seeded { + var shaped: [Float] = [] + for _ in 0..<8 { shaped = meter.update(level: level) } + initial = shaped.map { max(Self.minScale, CGFloat($0)) } + } + _meter = State(initialValue: meter) + _heights = State(initialValue: initial) + } + + var body: some View { + HStack(alignment: .center, spacing: PillMetrics.barSpacing) { + ForEach(0.. Void = {} + + @ObservationIgnored private var pollTask: Task? + + var needsAccessibility: Bool { !accessibilityTrusted } + var needsMicrophone: Bool { microphoneStatus != .authorized } + var needsAnyPermission: Bool { needsAccessibility || needsMicrophone } + + /// Prompts for Accessibility once per launch when it is missing, and + /// starts polling. + func start() { + startPolling() + if !accessibilityTrusted { Permissions.requestAccessibility() } + } + + func stop() { + pollTask?.cancel() + pollTask = nil + } + + /// Assigns only what changed, so views reading one grant are not redrawn + /// for every poll. + func refresh() { + let trusted = Permissions.isAccessibilityTrusted + if trusted != accessibilityTrusted { accessibilityTrusted = trusted } + let microphone = Permissions.microphoneStatus + if microphone != microphoneStatus { microphoneStatus = microphone } + let secure = SecureInput.isEnabled + if secure != secureInputEnabled { secureInputEnabled = secure } + onRefresh() + } + + func grantAccessibility() { + Permissions.requestAccessibility() + Permissions.openAccessibilitySettings() + startPolling() + } + + func grantMicrophone() { + Task { [weak self] in + if Permissions.microphoneStatus == .notDetermined { + _ = await Permissions.requestMicrophone() + } else { + Permissions.openMicrophoneSettings() + } + self?.startPolling() + } + } + + /// Refreshes now and restarts the two-second cadence from here, so a + /// grant the user just gave shows without waiting out the old timer. + private func startPolling() { + refresh() + pollTask?.cancel() + pollTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(2)) + guard let self else { return } + self.refresh() + } + } + } +} diff --git a/Sources/Pladder/PladderApp.swift b/Sources/Pladder/PladderApp.swift index f163aa8..e2474ff 100644 --- a/Sources/Pladder/PladderApp.swift +++ b/Sources/Pladder/PladderApp.swift @@ -8,9 +8,16 @@ import PladderSystem struct PladderApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate @State private var model: AppModel + /// The overlay demo and the screenshots draw with the app's own views but + /// are not the app: they get a throwaway settings file, so the live + /// configuration is never read, moved aside or written, and no menu bar + /// item, which would be a second, slashed Pladder in the bar. + private let isDeveloperMode: Bool init() { - let model = AppModel() + let isDeveloperMode = OverlayDemo.isRequested || Screenshots.directory != nil + self.isDeveloperMode = isDeveloperMode + let model = AppModel(location: isDeveloperMode ? .scratch : .live) _model = State(initialValue: model) // Deferred to the first run-loop turn so the app has finished launching // before we prompt for Accessibility. The screenshot and overlay demo @@ -20,12 +27,13 @@ struct PladderApp: App { } else if let directory = Screenshots.directory { Task { @MainActor in await Screenshots.run(into: directory) } } else { + AppDelegate.shutdown = { await model.shutdown() } Task { @MainActor in model.start() } } } var body: some Scene { - MenuBarExtra { + MenuBarExtra(isInserted: .constant(!isDeveloperMode)) { MenuContent(model: model) } label: { Image(nsImage: model.menuBarImage) @@ -43,9 +51,54 @@ struct PladderApp: App { /// `LSUIElement` in Info.plist already hides the Dock icon; setting the policy /// here too keeps `swift run` (no bundle, no Info.plist) behaving the same. +/// +/// Quitting gives back what a dictation borrowed before the process ends: a +/// latched recording would otherwise leave the speakers muted, and a paste +/// made in the last few seconds the user's clipboard holding the transcript. +/// SIGTERM, which `pkill` and `bundle.sh --install` send, quits the same way. +@MainActor final class AppDelegate: NSObject, NSApplicationDelegate { + /// What has to finish before the process may end. Set by the app; nil + /// in the developer modes, which quit at once. + static var shutdown: (@MainActor () async -> Void)? + /// How long quitting may wait for it. The restore and the unmute take + /// milliseconds; a dictation in flight with the polish model can take + /// seconds, and past this it is lost rather than the quit hanging. + private static let shutdownDeadline: Duration = .seconds(3) + + private var terminationSignal: DispatchSourceSignal? + func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) + signal(SIGTERM, SIG_IGN) + // Off the main queue, so a main thread that is stuck still hears the + // signal: the quit is asked for, and if it has not happened past its + // own deadline the process ends anyway, as an uncaught SIGTERM would + // have ended it at once. + let source = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .global(qos: .userInitiated)) + source.setEventHandler { + Task { @MainActor in NSApp.terminate(nil) } + DispatchQueue.global().asyncAfter(deadline: .now() + Self.signalExitDeadline) { exit(0) } + } + source.resume() + terminationSignal = source + } + + /// Longer than `shutdownDeadline`, so a quit that can proceed always + /// finishes first. + private nonisolated static let signalExitDeadline: DispatchTimeInterval = .seconds(5) + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + guard let shutdown = Self.shutdown else { return .terminateNow } + // A second Quit while this one waits quits at once. + Self.shutdown = nil + Task { @MainActor in + // Past the deadline the shutdown is abandoned, not stopped: the + // process ends around it. + _ = await firstOf(until: .now + Self.shutdownDeadline) { await shutdown() } + sender.reply(toApplicationShouldTerminate: true) + } + return .terminateLater } } @@ -57,27 +110,24 @@ private struct MenuContent: View { Text(model.statusLine) if model.canRetryEngine { - Button("Retry Model Download") { model.coordinator.reloadEngine() } + Button("Retry Model Download") { model.retryEngine() } } if let last = model.lastTranscriptSummary { Divider() - Button("Last: \(last)") { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(model.coordinator.lastTranscript?.text ?? "", forType: .string) - } + Button("Last: \(last)") { model.copyLastTranscript() } } // A correction the user made by hand after a paste, which the // on-device model agreed is a reusable spelling. A menu line rather // than an overlay toast: the pill is click-through by construction, // and a proposal arriving a minute later should wait, not interrupt. - if !model.proposals.isEmpty { + if !model.corrections.proposals.isEmpty { Divider() - ForEach(model.proposals) { proposal in + ForEach(model.corrections.proposals) { proposal in Menu("Learned “\(proposal.pair.heard)” → “\(proposal.pair.corrected)”?") { - Button("Add") { model.acceptProposal(proposal) } - Button("Dismiss") { model.dismissProposal(proposal) } + Button("Add") { model.corrections.accept(proposal) } + Button("Dismiss") { model.corrections.dismiss(proposal) } } } } @@ -96,13 +146,13 @@ private struct MenuContent: View { } .keyboardShortcut(",", modifiers: .command) - if model.needsAnyPermission { + if model.permissions.needsAnyPermission { Divider() - if model.needsAccessibility { - Button("Grant Accessibility…") { model.grantAccessibility() } + if model.permissions.needsAccessibility { + Button("Grant Accessibility…") { model.permissions.grantAccessibility() } } - if model.needsMicrophone { - Button("Grant Microphone…") { model.grantMicrophone() } + if model.permissions.needsMicrophone { + Button("Grant Microphone…") { model.permissions.grantMicrophone() } } } diff --git a/Sources/Pladder/PolishModelController.swift b/Sources/Pladder/PolishModelController.swift new file mode 100644 index 0000000..94eb54a --- /dev/null +++ b/Sources/Pladder/PolishModelController.swift @@ -0,0 +1,105 @@ +import Foundation +import Observation +import PladderCore +import PladderRefine + +/// Points the coordinator's refiner at the polish model the settings name, +/// and owns that model's file and memory. +/// +/// An S1-mini file is downloaded only while polish is on and that model is +/// chosen, the one network use besides the speech model's; it is loaded at +/// the first key-down, not here, and freed when polish goes off or another +/// model is picked. Only one S1-mini polisher is ever held, so switching +/// frees the other's memory. +@MainActor +@Observable +final class PolishModelController { + /// Whether Apple Intelligence can polish right now, for the polish + /// toggle's row. Refreshed with the permissions: it can be switched on + /// or off in System Settings while the app runs, and the read is cheap. + /// The coordinator never asks; an unavailable model makes the toggle a + /// no-op that pastes as dictated. + private(set) var availability: OnDeviceModelAvailability = TranscriptPolisher.availability + /// Where the chosen model's file stands; nil for Apple's, which has none. + /// Shown under the model picker. + private(set) var status: ModelFileStatus? + + /// The coordinator's one refiner; forwards to the chosen model. + let refiner: PolishRouter + + @ObservationIgnored private let applePolisher = TranscriptPolisher() + @ObservationIgnored private var s1MiniPolisher: S1MiniPolisher? + @ObservationIgnored private let files: ModelFiles + @ObservationIgnored private let statusRelay = MainActorRelay<(ModelFile, ModelFileStatus)>() + /// The file the settings name, nil for Apple's model. + @ObservationIgnored private var chosenFile: ModelFile? + /// Every call into `files`, in the order the settings asked for it: a + /// cancel and an ensure of the same file from a quick off and on must + /// not swap, or the cancel kills the download the ensure wanted. + @ObservationIgnored private let fileWork = OrderedTasks() + + init() { + refiner = PolishRouter(applePolisher) + let relay = statusRelay + files = ModelFiles(directory: ModelFiles.defaultDirectory) { file, status in + relay.send((file, status)) + } + statusRelay.handler = { [weak self] update in + guard let self, update.0 == self.chosenFile else { return } + self.status = update.1 + } + } + + func refreshAvailability() { + let current = TranscriptPolisher.availability + if current != availability { availability = current } + } + + func apply(model: PolishModel, polishing: Bool) { + // A download nobody will use any more stops: the user picked another + // model, or turned polish off before it finished. + if let previous = chosenFile, previous != ModelFile(for: model) || !polishing { + let files = self.files + fileWork.enqueue { await files.cancel(previous) } + } + guard let file = ModelFile(for: model) else { + chosenFile = nil + releaseS1Mini() + refiner.use(applePolisher) + status = nil + return + } + chosenFile = file + if s1MiniPolisher?.file != file { + releaseS1Mini() + let polisher = S1MiniPolisher(file: file, location: files.location(of: file)) + s1MiniPolisher = polisher + refiner.use(polisher) + } + if polishing { + Task.detached(priority: .utility) { await S1MiniPolisher.warmUpRuntime() } + } else if let polisher = s1MiniPolisher { + Task { await polisher.unload() } + } + let files = self.files + fileWork.enqueue { [weak self] in + if polishing { await files.ensure(file) } + let status = await files.status(of: file) + guard let self, file == self.chosenFile else { return } + self.status = status + } + } + + /// Tries a failed download again; the picker's "Try Again". + func retryDownload() { + guard let file = chosenFile else { return } + let files = self.files + fileWork.enqueue { await files.ensure(file) } + } + + private func releaseS1Mini() { + guard let polisher = s1MiniPolisher else { return } + s1MiniPolisher = nil + Task { await polisher.unload() } + } +} diff --git a/Sources/Pladder/Resources/Localizable.xcstrings b/Sources/Pladder/Resources/Localizable.xcstrings index 2e3b773..60636d6 100644 --- a/Sources/Pladder/Resources/Localizable.xcstrings +++ b/Sources/Pladder/Resources/Localizable.xcstrings @@ -21,26 +21,6 @@ } } }, - "%@ is also the push-to-talk key, so it never polishes. Record a different combination.": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "%@ ist schon die Push-to-Talk-Taste, deshalb wird damit nie überarbeitet. Nimm eine andere Kombination auf." - } - } - } - }, - "%@ is also the toggle key, so it never polishes. Record a different combination.": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "%@ ist auch die Start/Stopp-Taste, deshalb wird damit nie überarbeitet. Nimm eine andere Kombination auf." - } - } - } - }, "%@ is part of the push-to-talk key, so it can never be pressed separately.": { "localizations": { "de": { @@ -1241,16 +1221,6 @@ } } }, - "Without Accessibility, %@ cannot be detected, so this key is off until Accessibility is granted.": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Ohne Bedienungshilfen wird %@ nicht erkannt, deshalb ist diese Taste aus, bis die Bedienungshilfen erlaubt sind." - } - } - } - }, "Without a modifier, %@ can no longer be typed in other apps while Pladder is running.": { "localizations": { "de": { diff --git a/Sources/Pladder/Screenshots.swift b/Sources/Pladder/Screenshots.swift index fad493c..edcdda9 100644 --- a/Sources/Pladder/Screenshots.swift +++ b/Sources/Pladder/Screenshots.swift @@ -86,13 +86,7 @@ private struct Wallpaper: View { var body: some View { ZStack { - LinearGradient( - colors: scheme == .dark - ? [Color(red: 0.30, green: 0.36, blue: 0.70), Color(red: 0.10, green: 0.12, blue: 0.32)] - : [Color(red: 0.62, green: 0.78, blue: 0.97), Color(red: 0.24, green: 0.46, blue: 0.88)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) + DesktopWash(scheme: scheme) GeometryReader { proxy in let w = proxy.size.width let h = proxy.size.height @@ -116,13 +110,14 @@ private struct Wallpaper: View { /// A pill exactly as the overlay shows it, shadow included. private struct Pill: View { let state: DictationState + var level: Float = 0 var style: OverlayStyle = .compact var scale: CGFloat = 1 /// The words so far, for the Live Transcript pill. var partial: String? var body: some View { - OverlayPill(state: state, style: style, glass: true, isPreview: true, partial: partial) + OverlayPill(state: state, level: level, style: style, glass: true, isPreview: true, partial: partial) // A tile is narrower than the live row, and the proposed size // would squeeze the glass but not the row inside it; the pill // takes its own size and is scaled to fit instead. @@ -140,16 +135,17 @@ private struct StylesFigure: View { var body: some View { HStack(alignment: .top, spacing: 24) { tile("Compact") { - Pill(state: .recording(level: 0.6), scale: 1.15) + Pill(state: .recording, level: 0.6, scale: 1.15) } tile("Minimal") { - Pill(state: .recording(level: 0.6), style: .minimal, scale: 1.15) + Pill(state: .recording, level: 0.6, style: .minimal, scale: 1.15) } tile("Live") { // The real live row, at its real width: 440 pt of capsule // scaled to fit the tile. Pill( - state: .recording(level: 0.6), + state: .recording, + level: 0.6, style: .liveTranscript, scale: 0.6, partial: "the words show up as you say them, right here" @@ -179,7 +175,7 @@ private struct StylesFigure: View { private var menuBar: some View { HStack(spacing: 14) { Spacer() - Image(nsImage: MenuBarIcon.image(for: .recording(level: 0.6))) + Image(nsImage: MenuBarIcon.image(for: .recording, level: 0.6)) .renderingMode(.template) Image(systemName: "wifi") Image(systemName: "battery.75percent") @@ -213,7 +209,7 @@ private struct SocialFigure: View { } } .foregroundStyle(.white) - Pill(state: .recording(level: 0.6), scale: 1.5) + Pill(state: .recording, level: 0.6, scale: 1.5) .padding(.top, 24) } } diff --git a/Sources/Pladder/Settings/DictionaryView.swift b/Sources/Pladder/Settings/DictionaryView.swift index 40ccbd9..cb4cfa3 100644 --- a/Sources/Pladder/Settings/DictionaryView.swift +++ b/Sources/Pladder/Settings/DictionaryView.swift @@ -192,8 +192,7 @@ struct DictionaryView: View { guard panel.runModal() == .OK, let url = panel.url else { return } do { let data = try Data(contentsOf: url) - let imported = try JSONDecoder().decode([ImportedEntry].self, from: data) - merge(imported.map(\.entry)) + merge(try JSONDecoder().decode([DictionaryEntry].self, from: data)) } catch { errorMessage = String(localized: "Could not read that file. It should be a JSON array of { from, to, matchCase }.") } @@ -213,55 +212,10 @@ struct DictionaryView: View { } } - /// Adds entries, overwriting an existing row with the same key instead of - /// creating a duplicate. - /// - /// A regular rule is keyed by its `from`. A custom word — an empty `from`, - /// which is how `CustomWordCorrector` reads its terms — has no `from` to key - /// on, so it is keyed by its `to` instead. Without that second key every - /// custom word would either be dropped on import or duplicated on every - /// re-import. A row with neither is nothing at all and is skipped. + /// Adds entries, overwriting an existing row with the same key instead + /// of creating a duplicate (see `DictionaryEntry.mergeKey`). private func merge(_ incoming: [DictionaryEntry]) { - var result = model.settings.dictionary - var indexByKey = [String: Int]() - for (index, entry) in result.enumerated() { - if let key = Self.mergeKey(for: entry) { indexByKey[key] = index } - } - for var entry in incoming { - guard let key = Self.mergeKey(for: entry) else { continue } - if let index = indexByKey[key] { - // Keep the existing identity so selection and focus survive. - entry.id = result[index].id - result[index] = entry - } else { - indexByKey[key] = result.count - result.append(entry) - } - } - model.settings.dictionary = result - } - - /// `from:` for a replacement rule, `to:` for a custom word, nil for a row - /// with neither. - private static func mergeKey(for entry: DictionaryEntry) -> String? { - let from = entry.from.trimmingCharacters(in: .whitespaces) - if !from.isEmpty { return "from:" + from.lowercased() } - let to = entry.to.trimmingCharacters(in: .whitespaces) - if !to.isEmpty { return "to:" + to.lowercased() } - return nil - } -} - -/// `DictionaryEntry`'s synthesised decoder requires every key. Files people write -/// by hand rarely carry an `id`, so import goes through this looser shape. -private struct ImportedEntry: Decodable { - var id: UUID? - var from: String - var to: String - var matchCase: Bool? - - var entry: DictionaryEntry { - DictionaryEntry(id: id ?? UUID(), from: from, to: to, matchCase: matchCase ?? false) + model.settings.dictionary.merge(incoming) } } diff --git a/Sources/Pladder/Settings/GeneralSettingsView.swift b/Sources/Pladder/Settings/GeneralSettingsView.swift new file mode 100644 index 0000000..9554cb8 --- /dev/null +++ b/Sources/Pladder/Settings/GeneralSettingsView.swift @@ -0,0 +1,269 @@ +import SwiftUI +import PladderCore + +/// The General tab: one section per concern, each reading only what it +/// shows. +struct GeneralSettingsView: View { + @Bindable var model: AppModel + + var body: some View { + Form { + EngineSection(model: model) + PushToTalkSection(model: model) + AppearanceSection(model: model) + OverlaySection(model: model) + SoundsSection(model: model) + PermissionsSection(permissions: model.permissions) + } + .formStyle(.grouped) + // What can change behind the app's back is re-read here rather than + // on the permission poll: this window is where it is shown, and the + // user may have just changed it in System Settings. + .onAppear { + model.settingsWindowOpened() + } + } +} + +private struct EngineSection: View { + @Bindable var model: AppModel + + var body: some View { + Section { + Picker("Engine", selection: $model.settings.engineID) { + ForEach(model.registry.available) { entry in + Text(LocalizedStringKey(entry.displayName)).tag(entry.id) + } + } + } header: { + Text("Engine") + } footer: { + // The registry's own text, localized where it is built. + FootnoteText(verbatim: model.registry.entry(for: model.settings.engineID)?.detail ?? "") + } + } +} + +private struct PushToTalkSection: View { + @Bindable var model: AppModel + + var body: some View { + Section { + LabeledContent("Key") { + HotkeyRecorderField( + hotkey: $model.settings.hotkey, + setHotkeySuspended: model.setHotkeySuspended, + // The send key is off without Accessibility anyway, so + // only the push-to-talk key is constrained. + requiresRegularKey: model.hotkeys.hotkeyNeedsRegularKey, + systemShortcuts: model.hotkeys.systemShortcuts + ) + } + if let warning = HotkeyWarning.forKey( + model.settings.hotkey, standIn: model.hotkeys.standInHotkey, + systemShortcuts: model.hotkeys.systemShortcuts) { + WarningLabel(warning.text) + } + LabeledContent("Toggle key") { + HotkeyRecorderField( + hotkey: $model.settings.toggleHotkey, + setHotkeySuspended: model.setHotkeySuspended, + requiresRegularKey: model.hotkeys.hotkeyNeedsRegularKey, + allowsEmpty: true, + systemShortcuts: model.hotkeys.systemShortcuts + ) + } + if let warning = HotkeyWarning.forToggle( + model.settings.toggleHotkey, hotkey: model.settings.hotkey, + accessibilityTrusted: model.permissions.accessibilityTrusted, + systemShortcuts: model.hotkeys.systemShortcuts) { + WarningLabel(warning.text) + } + LabeledContent("Send key") { + HotkeyRecorderField( + hotkey: $model.settings.submitKey, + setHotkeySuspended: model.setHotkeySuspended, + // Empty has always meant off; now the field can say so. + allowsEmpty: true + ) + } + if let warning = HotkeyWarning.forSendKey( + model.settings.submitKey, hotkey: model.settings.hotkey, + accessibilityTrusted: model.permissions.accessibilityTrusted) { + WarningLabel(warning.text) + } + } header: { + Text("Push to Talk") + } footer: { + // Separate literals, so each translation stays whole and + // another key can add its own line. + VStack(alignment: .leading, spacing: 4) { + FootnoteText("Hold to record, release to insert. Press the send key while recording and Return is pressed after the text. Click a field and press any key combination to assign it.") + FootnoteText("Tap the toggle key to start recording and tap it again to insert. Set it to the same combination as the key and a short tap toggles while a hold still works as before. Escape discards a recording. Delete clears a field.") + } + } + } +} + +private struct AppearanceSection: View { + @Bindable var model: AppModel + + var body: some View { + Section { + LabeledContent("Appearance") { + HStack(spacing: 8) { + ForEach(Appearance.allCases, id: \.self) { appearance in + OptionCard( + title: appearance.displayName, + isSelected: model.settings.appearance == appearance, + action: { model.settings.appearance = appearance } + ) { + AppearanceThumbnail(appearance: appearance, glass: model.settings.overlayGlass) + } + } + } + } + } + } +} + +private struct OverlaySection: View { + @Bindable var model: AppModel + + /// Menu never flies a pill in, so the background and the speed have + /// nothing to act on there. + private var hasPill: Bool { model.settings.overlayStyle != .menuBar } + + var body: some View { + Section { + LabeledContent("Style") { + HStack(spacing: 8) { + ForEach(OverlayStyle.allCases, id: \.self) { style in + OptionCard( + title: style.displayName, + isSelected: model.settings.overlayStyle == style, + action: { model.settings.overlayStyle = style } + ) { + OverlayStyleThumbnail(style: style, glass: model.settings.overlayGlass) + } + } + } + } + LabeledContent("Background") { + HStack(spacing: 8) { + ForEach([true, false], id: \.self) { glass in + OptionCard( + title: glass ? String(localized: "Glass") : String(localized: "Flat"), + isSelected: model.settings.overlayGlass == glass, + isEnabled: hasPill, + action: { model.settings.overlayGlass = glass } + ) { + BackgroundThumbnail(glass: glass) + } + } + } + } + LabeledContent("Animation") { + HStack(spacing: 8) { + ForEach(OverlayAnimationSpeed.allCases, id: \.self) { speed in + OptionCard( + title: speed.displayName, + isSelected: model.settings.overlayAnimationSpeed == speed, + isEnabled: hasPill, + action: { model.settings.overlayAnimationSpeed = speed } + ) { + AnimationSpeedThumbnail(speed: speed) + } + } + } + } + } header: { + Text("Overlay") + } footer: { + FootnoteText("Shown while you dictate, flying up from the bottom edge as a circle and expanding into place. Menu relies on the wave in the menu bar alone; errors still appear.") + } + } +} + +private struct SoundsSection: View { + @Bindable var model: AppModel + + var body: some View { + Section("Sounds & Startup") { + Toggle("Play start and stop sounds", isOn: $model.settings.playSounds) + Toggle("Mute audio while dictating", isOn: $model.settings.muteOutputWhileDictating) + VStack(alignment: .leading, spacing: 2) { + Toggle("Launch at login", isOn: Binding( + get: { model.launchAtLoginEnabled }, + set: { model.setLaunchAtLogin($0) } + )) + if let error = model.launchAtLoginError { + Text(error) + .font(.callout) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } +} + +private struct PermissionsSection: View { + let permissions: PermissionMonitor + + var body: some View { + Section("Permissions") { + PermissionRow( + title: "Accessibility", + detail: String(localized: "Needed for a modifier-only key such as Right Command, for the send key and for pasting. Without it Option+Space still works and the text is copied for you to paste with ⌘V; a standard account needs an administrator to switch it on."), + granted: permissions.accessibilityTrusted, + action: permissions.grantAccessibility + ) + PermissionRow( + title: "Microphone", + detail: microphoneDetail, + granted: permissions.microphoneStatus == .authorized, + action: permissions.grantMicrophone + ) + } + } + + private var microphoneDetail: String { + switch permissions.microphoneStatus { + case .authorized: String(localized: "Granted.") + case .denied, .restricted: String(localized: "Denied. Enable it in System Settings.") + default: String(localized: "Not requested yet.") + } + } +} + +private extension Appearance { + var displayName: String { + switch self { + case .system: String(localized: "Auto") + case .light: String(localized: "Light") + case .dark: String(localized: "Dark") + } + } +} + +private extension OverlayStyle { + var displayName: String { + switch self { + case .menuBar: String(localized: "Menu") + case .minimal: String(localized: "Minimal") + case .compact: String(localized: "Compact") + case .liveTranscript: String(localized: "Live") + } + } +} + +private extension OverlayAnimationSpeed { + var displayName: String { + switch self { + case .instant: String(localized: "Ludicrous") + case .quick: String(localized: "Quick") + case .expressive: String(localized: "Chill") + } + } +} diff --git a/Sources/Pladder/Settings/HotkeyRecorder.swift b/Sources/Pladder/Settings/HotkeyRecorder.swift index 0380004..8e6cd7c 100644 --- a/Sources/Pladder/Settings/HotkeyRecorder.swift +++ b/Sources/Pladder/Settings/HotkeyRecorder.swift @@ -16,9 +16,11 @@ import SwiftUI /// Escape on its own cancels; Delete on its own clears, where that is allowed. struct HotkeyRecorderField: View { @Binding var hotkey: Hotkey - /// Called with `true` while recording. The caller suspends the global - /// monitor so the keys used to define the new chord cannot fire the old one. - var onRecordingChanged: (Bool) -> Void + /// Called with `true` when a recording starts with no other field + /// recording and `false` when the last one ends; recording in another + /// field ends this one. The caller suspends the global monitor meanwhile, + /// so the keys used to define the new chord cannot fire the old one. + var setHotkeySuspended: (Bool) -> Void /// Set while Accessibility is missing: a chord is then registered with /// Carbon, which needs exactly one regular key, so modifier-only chords /// are refused instead of being stored and silently never firing. @@ -41,7 +43,8 @@ struct HotkeyRecorderField: View { recorder.begin( requiresRegularKey: requiresRegularKey, allowsEmpty: allowsEmpty, - systemShortcuts: systemShortcuts + systemShortcuts: systemShortcuts, + setHotkeySuspended: setHotkeySuspended ) { hotkey = $0 } } } label: { @@ -60,7 +63,6 @@ struct HotkeyRecorderField: View { .frame(maxWidth: 260, alignment: .trailing) } } - .onChange(of: recorder.isRecording) { _, isRecording in onRecordingChanged(isRecording) } .onDisappear { recorder.cancel() } } @@ -127,13 +129,33 @@ final class HotkeyRecorder { /// back whenever a chord notice is cleared. private var secureInputNotice: String? + /// The app's one recording session, shared by every field. + private static let slot = HotkeyRecordingSlot { $0.cancel() } + private let token: HotkeyRecordingSlot.Token + + init() { + token = Self.slot.makeToken() + } + + /// A field torn down mid-recording without `onDisappear`: the monitors + /// hold this weakly, so it is freed, and the hotkey comes back here. + isolated deinit { + if let monitor { NSEvent.removeMonitor(monitor) } + if let resignObserver { NotificationCenter.default.removeObserver(resignObserver) } + Self.slot.release(token) + } + func begin( requiresRegularKey: Bool = false, allowsEmpty: Bool = false, systemShortcuts: Set = [], + setHotkeySuspended: @escaping (Bool) -> Void, commit: @escaping (Hotkey) -> Void ) { - cancel() + // A restart keeps the session rather than resuming and suspending + // the hotkey in between. + tearDown() + Self.slot.claim(token, by: self, setHotkeySuspended: setHotkeySuspended) self.commit = commit self.requiresRegularKey = requiresRegularKey self.allowsEmpty = allowsEmpty @@ -148,7 +170,8 @@ final class HotkeyRecorder { : nil notice = secureInputNotice isRecording = true - monitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp, .flagsChanged]) { event in + monitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp, .flagsChanged]) { [weak self] event in + guard let self else { return event } // Local monitors run on the main thread, but `NSEvent` is not // Sendable, so pull out the plain values before hopping. let key = KeyTransition( @@ -162,8 +185,8 @@ final class HotkeyRecorder { // behind that eats the next key press. resignObserver = NotificationCenter.default.addObserver( forName: NSWindow.didResignKeyNotification, object: nil, queue: .main - ) { _ in - MainActor.assumeIsolated { self.cancel() } + ) { [weak self] _ in + MainActor.assumeIsolated { self?.cancel() } } } @@ -194,9 +217,7 @@ final class HotkeyRecorder { return true } if allowsEmpty, Int(key.keyCode) == kVK_Delete, heldModifiers.isEmpty, pending == nil { - let commit = self.commit - end() - commit?(Hotkey(keyCodes: [])) + end(committing: Hotkey(keyCodes: [])) return true } heldKeys.insert(key.keyCode) @@ -231,9 +252,7 @@ final class HotkeyRecorder { notice = String(localized: "Only \(pending.sideAgnosticDisplayName) reached Pladder. If you pressed a regular key too, macOS or another app owns that shortcut; try a different key, for example Control+Shift+D.") return } - let commit = self.commit - end() - commit?(pending.canonical) + end(committing: pending.canonical) } } else if !held.isSubset(of: pending?.keyCodes ?? []) { // A key went down that is not part of the chord so far: the chord @@ -249,7 +268,18 @@ final class HotkeyRecorder { } } - private func end() { + /// Ends the recording. A chord to commit is stored before the hotkey is + /// given back, so the monitor restarts once, with the new chord, rather + /// than with the old one and then again. + private func end(committing chord: Hotkey? = nil) { + let commit = self.commit + tearDown() + if let chord { commit?(chord) } + Self.slot.release(token) + } + + /// Everything but the session: the monitors and what was pressed. + private func tearDown() { if let monitor { NSEvent.removeMonitor(monitor) } monitor = nil if let resignObserver { NotificationCenter.default.removeObserver(resignObserver) } diff --git a/Sources/Pladder/Settings/OptionCards.swift b/Sources/Pladder/Settings/OptionCards.swift index 3781b43..c14d02a 100644 --- a/Sources/Pladder/Settings/OptionCards.swift +++ b/Sources/Pladder/Settings/OptionCards.swift @@ -69,13 +69,7 @@ struct DesktopThumbnail: View { private var resolved: ColorScheme { scheme ?? environmentScheme } var body: some View { - LinearGradient( - colors: resolved == .dark - ? [Color(red: 0.30, green: 0.36, blue: 0.70), Color(red: 0.10, green: 0.12, blue: 0.32)] - : [Color(red: 0.62, green: 0.78, blue: 0.97), Color(red: 0.24, green: 0.46, blue: 0.88)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) + DesktopWash(scheme: resolved) // Overlay rather than ZStack: a scaled-down pill still lays out at // its full size, and in a ZStack that would stretch the gradient so // the card only shows its middle. The card clips whatever hangs out. @@ -110,7 +104,7 @@ struct AppearanceThumbnail: View { private func desktop(_ scheme: ColorScheme) -> some View { DesktopThumbnail(scheme: scheme) { - OverlayPill(state: .recording(level: 0.55), style: .compact, glass: glass, isPreview: true) + OverlayPill(state: .recording, level: 0.55, style: .compact, glass: glass, isPreview: true) .scaleEffect(0.5) } .overlay(alignment: .top) { @@ -150,8 +144,6 @@ struct OverlayStyleThumbnail: View { let style: OverlayStyle let glass: Bool - @Environment(\.colorScheme) private var scheme - private static let previewLevel: Float = 0.55 var body: some View { @@ -164,12 +156,12 @@ struct OverlayStyleThumbnail: View { private var content: some View { switch style { case .compact: - OverlayPill(state: .recording(level: Self.previewLevel), style: .compact, glass: glass, isPreview: true) + OverlayPill(state: .recording, level: Self.previewLevel, style: .compact, glass: glass, isPreview: true) .scaleEffect(0.5) case .minimal: // The disc is 44 pt; at 0.75 it reads as a disc without turning // into a dot. - OverlayPill(state: .recording(level: Self.previewLevel), style: .minimal, glass: glass, isPreview: true) + OverlayPill(state: .recording, level: Self.previewLevel, style: .minimal, glass: glass, isPreview: true) .scaleEffect(0.75) case .menuBar: // Nothing on the desktop at all: the wave in the menu bar is the @@ -181,7 +173,8 @@ struct OverlayStyleThumbnail: View { // scaled to about 72 pt: the words are small but read as words, // which is what tells this style from Compact. OverlayPill( - state: .recording(level: Self.previewLevel), + state: .recording, + level: Self.previewLevel, style: .liveTranscript, glass: glass, isPreview: true, @@ -204,7 +197,7 @@ struct OverlayStyleThumbnail: View { .fill(.white.opacity(0.2)) .frame(height: 9) .overlay(alignment: .trailing) { - Image(nsImage: MenuBarIcon.image(for: .recording(level: Self.previewLevel))) + Image(nsImage: MenuBarIcon.image(for: .recording, level: Self.previewLevel)) .resizable() .renderingMode(.template) .aspectRatio(contentMode: .fit) @@ -223,7 +216,7 @@ struct BackgroundThumbnail: View { var body: some View { DesktopThumbnail { - OverlayPill(state: .recording(level: 0.55), style: .compact, glass: glass, isPreview: true) + OverlayPill(state: .recording, level: 0.55, style: .compact, glass: glass, isPreview: true) .scaleEffect(0.5) } } @@ -253,3 +246,19 @@ struct AnimationSpeedThumbnail: View { } } } + +/// The diagonal wash a stand-in desktop is painted with, in the option cards +/// and the README screenshots alike. +struct DesktopWash: View { + let scheme: ColorScheme + + var body: some View { + LinearGradient( + colors: scheme == .dark + ? [Color(red: 0.30, green: 0.36, blue: 0.70), Color(red: 0.10, green: 0.12, blue: 0.32)] + : [Color(red: 0.62, green: 0.78, blue: 0.97), Color(red: 0.24, green: 0.46, blue: 0.88)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } +} diff --git a/Sources/Pladder/Settings/ProcessingSettingsView.swift b/Sources/Pladder/Settings/ProcessingSettingsView.swift new file mode 100644 index 0000000..5e8c6c4 --- /dev/null +++ b/Sources/Pladder/Settings/ProcessingSettingsView.swift @@ -0,0 +1,94 @@ +import SwiftUI +import PladderCore +import PladderRefine +import PladderSystem + +struct ProcessingSettingsView: View { + @Bindable var model: AppModel + + var body: some View { + Form { + Section { + ForEach(StandardProcessors.entries.map(\.id), id: \.self) { id in + Toggle(isOn: binding(for: id)) { + VStack(alignment: .leading, spacing: 2) { + Text(ProcessorText.name(id)) + Text(ProcessorText.detail(id)) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } header: { + Text("Processors") + } footer: { + FootnoteText("Each dictation runs through these in order before it is inserted.") + } + + Section { + Toggle("Append a space after each dictation", isOn: $model.settings.appendTrailingSpace) + } header: { + Text("Output") + } footer: { + FootnoteText("Separates consecutive dictations so pasted runs stay readable.") + } + + Section { + Toggle("Polish dictations", isOn: $model.settings.polishDictations) + Picker("Model", selection: $model.settings.polishModel) { + ForEach(PolishModel.allCases, id: \.self) { choice in + Text(choice.displayName).tag(choice) + } + } + polishModelStatus + } header: { + Text("Experimental") + } footer: { + FootnoteText("Before the text is pasted, the model cleans it up on this Mac: self-corrections, spoken punctuation, lists. Apple Intelligence adds one to two seconds to every dictation, S1-mini by Superwhisper about half a second. S1-mini is trained on English, also handles German and Spanish, and is downloaded once from Hugging Face when you pick it. Anything a model cannot fix is pasted as dictated.") + } + } + .formStyle(.grouped) + } + + /// What stands between the chosen model and a polished dictation: + /// Apple Intelligence switched off, or an S1-mini file still to come. + @ViewBuilder + private var polishModelStatus: some View { + if let status = model.polish.status { + switch status { + case .ready: + EmptyView() + case .missing: + FootnoteText("Downloads when polish is on.") + case .downloading(let fraction): + ProgressView(value: fraction) { + Text("Downloading \(model.settings.polishModel.displayName)…") + .font(.callout) + } currentValueLabel: { + Text(fraction, format: .percent.precision(.fractionLength(0))) + } + case .verifying: + ProgressView { + Text("Checking the download…").font(.callout) + } + case .failed(let failure): + HStack(alignment: .firstTextBaseline) { + WarningLabel(failure.text) + Spacer() + Button("Try Again") { model.polish.retryDownload() } + } + } + } else if let warning = model.polish.availability.polishWarning { + WarningLabel(warning) + } + } + + /// Settings stores the *disabled* IDs, so absence means on. + private func binding(for id: String) -> Binding { + Binding( + get: { !model.settings.disabledProcessors.contains(id) }, + set: { model.settings.setProcessor(id, enabled: $0) } + ) + } +} diff --git a/Sources/Pladder/Settings/SettingsView.swift b/Sources/Pladder/Settings/SettingsView.swift index 3587678..d2ff03b 100644 --- a/Sources/Pladder/Settings/SettingsView.swift +++ b/Sources/Pladder/Settings/SettingsView.swift @@ -1,9 +1,5 @@ -import AVFoundation import SwiftUI import PladderCore -import PladderRefine - -import PladderSystem struct SettingsView: View { @Bindable var model: AppModel @@ -28,395 +24,22 @@ struct SettingsView: View { } } -private extension Appearance { - var displayName: String { - switch self { - case .system: String(localized: "Auto") - case .light: String(localized: "Light") - case .dark: String(localized: "Dark") - } - } -} - -private extension OverlayStyle { - var displayName: String { - switch self { - case .menuBar: String(localized: "Menu") - case .minimal: String(localized: "Minimal") - case .compact: String(localized: "Compact") - case .liveTranscript: String(localized: "Live") - } - } -} - -private extension OverlayAnimationSpeed { - var displayName: String { - switch self { - case .instant: String(localized: "Ludicrous") - case .quick: String(localized: "Quick") - case .expressive: String(localized: "Chill") - } - } -} +/// An orange line under a control that says why it may not do what the user +/// expects. Already in the user's language. +struct WarningLabel: View { + let text: String -private struct GeneralSettingsView: View { - @Bindable var model: AppModel + init(_ text: String) { self.text = text } var body: some View { - Form { - Section { - Picker("Engine", selection: $model.settings.engineID) { - ForEach(model.registry.available) { entry in - Text(LocalizedStringKey(entry.displayName)).tag(entry.id) - } - } - } header: { - Text("Engine") - } footer: { - // The registry's own text, localized where it is built. - FootnoteText(verbatim: model.registry.entry(for: model.settings.engineID)?.detail ?? "") - } - - Section { - LabeledContent("Key") { - HotkeyRecorderField( - hotkey: $model.settings.hotkey, - onRecordingChanged: { model.coordinator.isHotkeySuspended = $0 }, - // The send key is off without Accessibility anyway, so - // only the push-to-talk key is constrained. - requiresRegularKey: model.hotkeyNeedsRegularKey, - systemShortcuts: model.systemShortcuts - ) - } - if let warning = hotkeyWarning { - Label(warning, systemImage: "exclamationmark.triangle") - .font(.callout) - .foregroundStyle(.orange) - .fixedSize(horizontal: false, vertical: true) - } - LabeledContent("Toggle key") { - HotkeyRecorderField( - hotkey: $model.settings.toggleHotkey, - onRecordingChanged: { model.coordinator.isHotkeySuspended = $0 }, - requiresRegularKey: model.hotkeyNeedsRegularKey, - allowsEmpty: true, - systemShortcuts: model.systemShortcuts - ) - } - if let warning = toggleKeyWarning { - Label(warning, systemImage: "exclamationmark.triangle") - .font(.callout) - .foregroundStyle(.orange) - .fixedSize(horizontal: false, vertical: true) - } - LabeledContent("Send key") { - HotkeyRecorderField( - hotkey: $model.settings.submitKey, - onRecordingChanged: { model.coordinator.isHotkeySuspended = $0 }, - // Empty has always meant off; now the field can say so. - allowsEmpty: true - ) - } - if let warning = submitKeyWarning { - Label(warning, systemImage: "exclamationmark.triangle") - .font(.callout) - .foregroundStyle(.orange) - .fixedSize(horizontal: false, vertical: true) - } - } header: { - Text("Push to Talk") - } footer: { - // Separate literals, so each translation stays whole and - // another key can add its own line. - VStack(alignment: .leading, spacing: 4) { - FootnoteText("Hold to record, release to insert. Press the send key while recording and Return is pressed after the text. Click a field and press any key combination to assign it.") - FootnoteText("Tap the toggle key to start recording and tap it again to insert. Set it to the same combination as the key and a short tap toggles while a hold still works as before. Escape discards a recording. Delete clears a field.") - } - } - - Section { - LabeledContent("Appearance") { - HStack(spacing: 8) { - ForEach(Appearance.allCases, id: \.self) { appearance in - OptionCard( - title: appearance.displayName, - isSelected: model.settings.appearance == appearance, - action: { model.settings.appearance = appearance } - ) { - AppearanceThumbnail(appearance: appearance, glass: model.settings.overlayGlass) - } - } - } - } - } - - Section { - LabeledContent("Style") { - HStack(spacing: 8) { - ForEach(OverlayStyle.allCases, id: \.self) { style in - OptionCard( - title: style.displayName, - isSelected: model.settings.overlayStyle == style, - action: { model.settings.overlayStyle = style } - ) { - OverlayStyleThumbnail(style: style, glass: model.settings.overlayGlass) - } - } - } - } - LabeledContent("Background") { - HStack(spacing: 8) { - OptionCard( - title: String(localized: "Glass"), - isSelected: model.settings.overlayGlass, - isEnabled: model.settings.overlayStyle != .menuBar, - action: { model.settings.overlayGlass = true } - ) { - BackgroundThumbnail(glass: true) - } - OptionCard( - title: String(localized: "Flat"), - isSelected: !model.settings.overlayGlass, - isEnabled: model.settings.overlayStyle != .menuBar, - action: { model.settings.overlayGlass = false } - ) { - BackgroundThumbnail(glass: false) - } - } - } - LabeledContent("Animation") { - HStack(spacing: 8) { - ForEach(OverlayAnimationSpeed.allCases, id: \.self) { speed in - OptionCard( - title: speed.displayName, - isSelected: model.settings.overlayAnimationSpeed == speed, - // Menu never flies a pill in, so like the - // background the speed has nothing to act on. - isEnabled: model.settings.overlayStyle != .menuBar, - action: { model.settings.overlayAnimationSpeed = speed } - ) { - AnimationSpeedThumbnail(speed: speed) - } - } - } - } - } header: { - Text("Overlay") - } footer: { - FootnoteText("Shown while you dictate, flying up from the bottom edge as a circle and expanding into place. Menu relies on the wave in the menu bar alone; errors still appear.") - } - - Section("Sounds & Startup") { - Toggle("Play start and stop sounds", isOn: $model.settings.playSounds) - Toggle("Mute audio while dictating", isOn: $model.settings.muteOutputWhileDictating) - VStack(alignment: .leading, spacing: 2) { - Toggle("Launch at login", isOn: launchAtLogin) - if let error = model.launchAtLoginError { - Text(error) - .font(.callout) - .foregroundStyle(.red) - .fixedSize(horizontal: false, vertical: true) - } - } - } - - Section("Permissions") { - PermissionRow( - title: "Accessibility", - detail: String(localized: "Needed for a modifier-only key such as Right Command, for the send key and for pasting. Without it Option+Space still works and the text is copied for you to paste with ⌘V; a standard account needs an administrator to switch it on."), - granted: model.accessibilityTrusted, - action: model.grantAccessibility - ) - PermissionRow( - title: "Microphone", - detail: micDetail, - granted: model.microphoneStatus == .authorized, - action: model.grantMicrophone - ) - } - } - .formStyle(.grouped) - // The shortcuts macOS owns are re-read here rather than on the - // permission poll: this window is where they are shown, and the user - // may have just changed them in System Settings. - .onAppear { - model.refreshPermissions() - model.refreshSystemShortcuts() - } - } - - /// In order: the stored chord cannot be detected, so the default stands in - /// for it — and if macOS owns that too, say so instead, since it is the - /// chord actually being listened for; macOS owns the stored chord, so it - /// may never arrive; and the standing warning that a chord without a - /// modifier is swallowed system wide, so a plain letter or Space would - /// become untypeable while Pladder runs. - private var hotkeyWarning: String? { - let hotkey = model.settings.hotkey - if let standIn = model.standInHotkey { - if let owner = model.systemShortcutConflict(for: standIn) { - return conflictWarning(owner: owner, chord: standIn) - } - return String(localized: "Without Accessibility, \(hotkey.displayName) cannot be detected, so \(standIn.sideAgnosticDisplayName) stands in for it until Accessibility is granted. Record a combination with a regular key to choose your own.") - } - if let owner = model.systemShortcutConflict(for: hotkey) { - return conflictWarning(owner: owner, chord: hotkey) - } - guard hotkey.modifierKeyCodes.isEmpty, !hotkey.keyCodes.isEmpty else { return nil } - return String(localized: "Without a modifier, \(hotkey.displayName) can no longer be typed in other apps while Pladder is running.") - } - - /// Nothing for an empty toggle key, or one equal to the key: that one is - /// hybrid, and whatever stands in for the key stands in for it too. - /// Otherwise a chord Carbon cannot register listens for nothing without - /// Accessibility, and has no stand-in of its own, since the only candidate - /// is the push-to-talk stand-in; and macOS may own the chord. - private var toggleKeyWarning: String? { - let toggle = model.settings.toggleHotkey - guard !toggle.isEmpty, toggle.canonical != model.settings.hotkey.canonical else { return nil } - if !model.accessibilityTrusted && !toggle.canBeRegisteredWithoutAccessibility { - return String(localized: "Without Accessibility, \(toggle.displayName) cannot be detected, so the toggle key is off until Accessibility is granted.") - } - if let owner = model.systemShortcutConflict(for: toggle) { - return conflictWarning(owner: owner, chord: toggle) - } - return nil - } - - /// An enabled macOS shortcut is dispatched by the window server before - /// either monitor sees the keys, so the chord may simply never arrive. - private func conflictWarning(owner: Hotkey, chord: Hotkey) -> String { - String(localized: "\(owner.sideAgnosticDisplayName) is a macOS keyboard shortcut, so \(chord.displayName) may never reach Pladder. Record another combination.") - } - - /// The send key presses Return through the same synthetic event as the - /// paste, so it is off entirely without Accessibility. Otherwise: a send - /// key the chord already contains can never be pressed on its own. - private var submitKeyWarning: String? { - let submitKey = model.settings.submitKey - guard !submitKey.keyCodes.isEmpty else { return nil } - if !model.accessibilityTrusted { - return String(localized: "The send key needs Accessibility.") - } - guard submitKey.keyCodes.isSubset(of: model.settings.hotkey.keyCodes) else { return nil } - return String(localized: "\(submitKey.displayName) is part of the push-to-talk key, so it can never be pressed separately.") - } - - private var launchAtLogin: Binding { - Binding( - get: { LaunchAtLogin.isEnabled }, - set: { model.setLaunchAtLogin($0) } - ) - } - - private var micDetail: String { - switch model.microphoneStatus { - case .authorized: String(localized: "Granted.") - case .denied, .restricted: String(localized: "Denied. Enable it in System Settings.") - default: String(localized: "Not requested yet.") - } - } -} - -private struct ProcessingSettingsView: View { - @Bindable var model: AppModel - - var body: some View { - Form { - Section { - ForEach(model.processors, id: \.id) { processor in - Toggle(isOn: binding(for: processor.id)) { - VStack(alignment: .leading, spacing: 2) { - // Core cannot import the catalog, so its - // processors are looked up by their English text. - Text(LocalizedStringKey(processor.displayName)) - Text(LocalizedStringKey(processor.detail)) - .font(.callout) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } - } header: { - Text("Processors") - } footer: { - FootnoteText("Each dictation runs through these in order before it is inserted.") - } - - Section { - Toggle("Append a space after each dictation", isOn: $model.settings.appendTrailingSpace) - } header: { - Text("Output") - } footer: { - FootnoteText("Separates consecutive dictations so pasted runs stay readable.") - } - - Section { - Toggle("Polish dictations", isOn: $model.settings.polishDictations) - Picker("Model", selection: $model.settings.polishModel) { - ForEach(PolishModel.allCases, id: \.self) { choice in - Text(choice.displayName).tag(choice) - } - } - polishModelStatus - } header: { - Text("Experimental") - } footer: { - FootnoteText("Before the text is pasted, the model cleans it up on this Mac: self-corrections, spoken punctuation, lists. Apple Intelligence adds one to two seconds to every dictation, S1-mini by Superwhisper about half a second. S1-mini is trained on English, also handles German and Spanish, and is downloaded once from Hugging Face when you pick it. Anything a model cannot fix is pasted as dictated.") - } - } - .formStyle(.grouped) - } - - /// What stands between the chosen model and a polished dictation: - /// Apple Intelligence switched off, or an S1-mini file still to come. - @ViewBuilder - private var polishModelStatus: some View { - if let status = model.polishModelStatus { - switch status { - case .ready: - EmptyView() - case .missing: - FootnoteText("Downloads when polish is on.") - case .downloading(let fraction): - ProgressView(value: fraction) { - Text("Downloading \(model.settings.polishModel.displayName)…") - .font(.callout) - } currentValueLabel: { - Text(fraction, format: .percent.precision(.fractionLength(0))) - } - case .verifying: - ProgressView { - Text("Checking the download…").font(.callout) - } - case .failed(let failure): - HStack(alignment: .firstTextBaseline) { - Label(failure.text, systemImage: "exclamationmark.triangle") - .font(.callout) - .foregroundStyle(.orange) - .fixedSize(horizontal: false, vertical: true) - Spacer() - Button("Try Again") { model.retryPolishModelDownload() } - } - } - } else if let warning = model.polishAvailability.polishKeyText { - Label(warning, systemImage: "exclamationmark.triangle") - .font(.callout) - .foregroundStyle(.orange) - .fixedSize(horizontal: false, vertical: true) - } - } - - /// Settings stores the *disabled* IDs, so absence means on. - private func binding(for id: String) -> Binding { - Binding( - get: { !model.settings.disabledProcessors.contains(id) }, - set: { model.settings.setProcessor(id, enabled: $0) } - ) + Label(text, systemImage: "exclamationmark.triangle") + .font(.callout) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) } } -private struct PermissionRow: View { +struct PermissionRow: View { let title: LocalizedStringKey /// Already in the user's language: the callers build it with /// `String(localized:)` because it depends on the permission's state. diff --git a/Sources/Pladder/SettingsLocation.swift b/Sources/Pladder/SettingsLocation.swift new file mode 100644 index 0000000..d3936fc --- /dev/null +++ b/Sources/Pladder/SettingsLocation.swift @@ -0,0 +1,52 @@ +import Foundation + +/// Where the app keeps its files. +/// +/// `live` is the user's configuration, unless `PLADDER_SETTINGS_PATH` points +/// a copy launched for testing at a file of its own, so it neither reads nor +/// writes the configuration of the copy in daily use. `scratch` is a +/// throwaway directory for the overlay demo and the screenshots, which must +/// never read, move aside or write anything real. +struct SettingsLocation { + let settingsURL: URL + /// Whether the pre-rename settings are copied across when there are none + /// yet. Only for the real configuration: a test copy starts from the + /// defaults, not from an old install. + let migratesLegacySettings: Bool + + /// The corrections the user dismissed, beside the settings but not in + /// them (see `DismissedCorrections`). + var dismissedCorrectionsURL: URL { + settingsURL.deletingLastPathComponent().appending(path: "dismissed-corrections.json") + } + + static var live: SettingsLocation { + if let path = ProcessInfo.processInfo.environment["PLADDER_SETTINGS_PATH"], !path.isEmpty { + return SettingsLocation(settingsURL: URL(filePath: path), migratesLegacySettings: false) + } + return SettingsLocation(settingsURL: applicationSupport.appending(path: "Pladder/settings.json"), + migratesLegacySettings: true) + } + + static var scratch: SettingsLocation { + let directory = FileManager.default.temporaryDirectory + .appending(path: "pladder-dev-\(ProcessInfo.processInfo.processIdentifier)") + return SettingsLocation(settingsURL: directory.appending(path: "settings.json"), migratesLegacySettings: false) + } + + private static var applicationSupport: URL { + FileManager.default.homeDirectoryForCurrentUser.appending(path: "Library/Application Support") + } + + /// One-time migration from the pre-rename location. The dictionary and + /// hotkey settings were kept in `~/Library/Application Support/SpeakUp/` + /// before the app was called Pladder; copy them across exactly once, only + /// when the new file does not exist yet. The old file is left in place. + func migrateLegacySettingsIfNeeded() { + guard migratesLegacySettings, !FileManager.default.fileExists(atPath: settingsURL.path) else { return } + let legacy = Self.applicationSupport.appending(path: "SpeakUp/settings.json") + try? FileManager.default.createDirectory( + at: settingsURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try? FileManager.default.copyItem(at: legacy, to: settingsURL) + } +} diff --git a/Sources/Pladder/StatusText.swift b/Sources/Pladder/StatusText.swift index 2c564cf..aff836f 100644 --- a/Sources/Pladder/StatusText.swift +++ b/Sources/Pladder/StatusText.swift @@ -1,6 +1,7 @@ import Foundation import PladderCore import PladderRefine +import PladderSystem /// The wording for every state `PladderCore` and `PladderEngines` report as a /// value. @@ -70,7 +71,7 @@ extension OnDeviceModelAvailability { /// The sentence under the "Polish dictations" toggle, nil when the model /// can run. Each says what dictations do meanwhile: they paste as /// dictated, only without the polish. - var polishKeyText: String? { + var polishWarning: String? { switch self { case .available: nil case .appleIntelligenceNotEnabled: @@ -110,3 +111,125 @@ extension ModelFileFailure { } } } + +/// The menu's first line and its last-transcript entry. +enum MenuStatus { + /// The chords the line can name, already worded for the monitor that + /// matches them. + struct Chords { + /// What to hold to dictate: the stored chord or its stand-in. + var hold: String + /// What ends a latched recording. + var stop: String + /// The stand-in is listening because Accessibility is missing. + var accessibilityOff: Bool + /// Carbon is standing in for a deaf tap. + var secureKeyboardEntry: Bool + } + + static func line(state: DictationState, engineStatus: EngineStatus, isLatched: Bool, chords: Chords) -> String { + switch state { + case .recording: + // The Menu style has no pill, so this line is its latched cue. + guard isLatched else { return String(localized: "Recording…") } + return String(localized: "Recording — press \(chords.stop) to stop") + case .transcribing: return String(localized: "Transcribing…") + case .polishing: return String(localized: "Polishing…") + case .inserting: return String(localized: "Inserting…") + case .error(let failure): return String(localized: "Error: \(failure.text)") + case .copied: return String(localized: "Copied — press ⌘V") + case .idle: + return readyLine(chords) + case .unavailable: + switch engineStatus { + case .downloading(let progress): + if let progress { + return String(localized: "Model: downloading \(Int((progress * 100).rounded()))%") + } + return String(localized: "Model: downloading…") + case .loading: return String(localized: "Model: loading…") + case .unloaded: return String(localized: "Model: not loaded") + case .failed(let failure): return String(localized: "Model failed: \(failure.text)") + case .ready: return readyLine(chords) + } + } + } + + /// What to say when nothing is happening. Say why the send key and the + /// swallowing stopped when they did: both are the tap's. A whole + /// sentence either way: a suffix glued on cannot be translated. + private static func readyLine(_ chords: Chords) -> String { + if chords.accessibilityOff { + return String(localized: "Ready — hold \(chords.hold) (Accessibility is off)") + } + if chords.secureKeyboardEntry { + return String(localized: "Ready — hold \(chords.hold) (Secure Keyboard Entry is on)") + } + return String(localized: "Ready — hold \(chords.hold)") + } + + /// A short, word-boundary-aware summary of the most recent transcript; + /// longer previews make the menu bar menu comically wide. + static func summary(of text: String) -> String? { + guard !text.isEmpty else { return nil } + let limit = 32 + guard text.count > limit else { return text } + let head = String(text.prefix(limit)) + if let space = head.lastIndex(of: " "), head.distance(from: head.startIndex, to: space) > 20 { + return String(head[.. String { + switch id { + case FillerRemover.processorID: String(localized: "Remove fillers") + case DictionaryReplacer.processorID: String(localized: "Dictionary") + case CustomWordCorrector.processorID: String(localized: "Custom words") + case WhitespaceNormalizer.processorID: String(localized: "Tidy whitespace") + case SpokenPunctuation.processorID: String(localized: "Spoken punctuation") + default: id + } + } + + static func detail(_ id: String) -> String { + switch id { + case FillerRemover.processorID: + String(localized: "Strips hesitation sounds like “uh” and “um” from English, German and Spanish transcripts.") + case DictionaryReplacer.processorID: + String(localized: "Applies your replacement rules. Edit them in the Dictionary tab.") + case CustomWordCorrector.processorID: + String(localized: "Repairs near misses of the words you list in the Dictionary tab with an empty Heard as.") + case WhitespaceNormalizer.processorID: + String(localized: "Trims the transcript and collapses runs of spaces.") + case SpokenPunctuation.processorID: + String(localized: "Turns spoken marks such as “comma”, “question mark” and “new paragraph” into the marks themselves, in English, German and Spanish.") + default: "" + } + } +} + +extension HotkeyWarning { + /// The sentence under the recorder field. + @MainActor var text: String { + switch self { + case .standIn(let stored, let standIn): + String(localized: "Without Accessibility, \(stored.displayName) cannot be detected, so \(standIn.sideAgnosticDisplayName) stands in for it until Accessibility is granted. Record a combination with a regular key to choose your own.") + case .systemShortcut(let owner, let chord): + String(localized: "\(owner.sideAgnosticDisplayName) is a macOS keyboard shortcut, so \(chord.displayName) may never reach Pladder. Record another combination.") + case .noModifier(let hotkey): + String(localized: "Without a modifier, \(hotkey.displayName) can no longer be typed in other apps while Pladder is running.") + case .toggleNeedsAccessibility(let toggle): + String(localized: "Without Accessibility, \(toggle.displayName) cannot be detected, so the toggle key is off until Accessibility is granted.") + case .sendKeyNeedsAccessibility: + String(localized: "The send key needs Accessibility.") + case .sendKeyInsideChord(let key): + String(localized: "\(key.displayName) is part of the push-to-talk key, so it can never be pressed separately.") + } + } +} diff --git a/Sources/PladderAudio/AVAudioEngineCapture.swift b/Sources/PladderAudio/AVAudioEngineCapture.swift index 9948a54..e53fe17 100644 --- a/Sources/PladderAudio/AVAudioEngineCapture.swift +++ b/Sources/PladderAudio/AVAudioEngineCapture.swift @@ -13,7 +13,8 @@ import os /// /// Threading: the tap block runs on a realtime audio thread. It must never touch /// actor state, allocate unpredictably, or `await` anything. Everything it needs -/// lives in `TapProcessor`, a lock-protected class it owns outright, plus the +/// lives in `TapProcessor`, which it owns outright, the recording's +/// `SampleAccumulator`, which is behind a lock, and the /// `AsyncStream.Continuation` for the level meter, which is safe to yield from any /// thread. Sample-rate conversion happens inside the tap block, which is the normal /// arrangement: `AVAudioConverter` is fast, deterministic, and doing it there avoids @@ -45,13 +46,12 @@ public actor AVAudioEngineCapture: AudioCapture { } private let engine = AVAudioEngine() - private var processor: TapProcessor? + /// The current recording's samples. A device change mid-recording installs a + /// new tap in front of the same accumulator, so the switch loses nothing. + private var accumulator: SampleAccumulator? private var levelContinuation: AsyncStream.Continuation? private var isRecording = false private var configurationObserver: NotificationObserver? - /// Samples salvaged from a previous `TapProcessor` when the audio device changed - /// mid-recording, so a device switch does not lose what was said before it. - private var carriedSamples: [Float] = [] public init() {} @@ -62,10 +62,7 @@ public actor AVAudioEngineCapture: AudioCapture { /// not been granted yet; `start()` retries, so a throw here is not fatal. public func warmUp() async throws { observeConfigurationChanges() - let format = engine.inputNode.inputFormat(forBus: 0) - guard format.sampleRate > 0, format.channelCount > 0 else { - throw CaptureError.noInputDevice - } + _ = try usableInputFormat() engine.prepare() } @@ -77,16 +74,17 @@ public actor AVAudioEngineCapture: AudioCapture { // warmUp() may have failed (no permission at launch); retry here. try startEngineIfNeeded() - carriedSamples.removeAll(keepingCapacity: false) let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) levelContinuation = continuation + let accumulator = SampleAccumulator() do { - try installTap(continuation: continuation) + try installTap(into: accumulator, continuation: continuation) } catch { continuation.finish() levelContinuation = nil throw error } + self.accumulator = accumulator isRecording = true Self.startMarker() return stream @@ -96,18 +94,12 @@ public actor AVAudioEngineCapture: AudioCapture { log.log("capture started") } - /// Takes everything captured so far — carried samples from device changes - /// plus what the tap accumulated — and keeps recording. Only the samples + /// Takes everything captured so far and keeps recording. Only the samples /// since the last `drain()` or `start()` are returned; `stop()` then sees /// just the tail. public func drain() async -> [Float] { - guard isRecording else { return [] } - var samples = carriedSamples - carriedSamples.removeAll(keepingCapacity: false) - if let processor { - samples.append(contentsOf: processor.drain()) - } - return samples + guard isRecording, let accumulator else { return [] } + return accumulator.drain() } public func stop() async -> CapturedAudio { @@ -118,12 +110,8 @@ public actor AVAudioEngineCapture: AudioCapture { engine.inputNode.removeTap(onBus: 0) // removeTap(onBus:) returns only once the tap block is no longer running, so // draining afterwards picks up every buffer the hardware delivered. - var samples = carriedSamples - carriedSamples.removeAll(keepingCapacity: false) - if let processor { - samples.append(contentsOf: processor.drain()) - } - processor = nil + let samples = accumulator?.drain() ?? [] + accumulator = nil levelContinuation?.finish() levelContinuation = nil @@ -156,12 +144,7 @@ public actor AVAudioEngineCapture: AudioCapture { private func startEngineIfNeeded() throws { guard !engine.isRunning else { return } - // Touching inputNode attaches it and makes the engine adopt the current input - // device's format. With no permission or no device this reports 0 Hz / 0 channels. - let format = engine.inputNode.inputFormat(forBus: 0) - guard format.sampleRate > 0, format.channelCount > 0 else { - throw CaptureError.noInputDevice - } + _ = try usableInputFormat() engine.prepare() do { try engine.start() @@ -170,19 +153,28 @@ public actor AVAudioEngineCapture: AudioCapture { } } - private func installTap(continuation: AsyncStream.Continuation) throws { - let input = engine.inputNode - let inputFormat = input.inputFormat(forBus: 0) - guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else { + private func usableInputFormat() throws -> AVAudioFormat { + // Touching inputNode attaches it and makes the engine adopt the current input + // device's format. With no permission or no device this reports 0 Hz / 0 channels. + let format = engine.inputNode.inputFormat(forBus: 0) + guard format.sampleRate > 0, format.channelCount > 0 else { throw CaptureError.noInputDevice } + return format + } + + private func installTap( + into accumulator: SampleAccumulator, + continuation: AsyncStream.Continuation + ) throws { + let input = engine.inputNode + let inputFormat = try usableInputFormat() let targetFormat = try AudioResampler.monoFloat32Format(sampleRate: Self.targetSampleRate) // One converter per recording keeps the resampler's filter state continuous // across tap buffers. let converter = try AudioResampler.makeConverter(from: inputFormat, to: targetFormat) - let processor = TapProcessor(converter: converter, targetFormat: targetFormat) - self.processor = processor + let processor = TapProcessor(converter: converter, targetFormat: targetFormat, accumulator: accumulator) input.removeTap(onBus: 0) input.installTap(onBus: 0, bufferSize: Self.tapBufferSize, format: inputFormat) { buffer, _ in @@ -208,27 +200,22 @@ public actor AVAudioEngineCapture: AudioCapture { } /// The default input or output device changed. AVAudioEngine tears down its graph - /// and stops, so restart it and — if a recording is in flight — reinstall the tap - /// against the new input format, keeping what has been captured so far. + /// and stops. With a recording in flight, restart it and reinstall the tap against + /// the new input format, in front of the same accumulator so what has been + /// captured so far is kept; otherwise the next `start()` restarts it. private func handleConfigurationChange() { - let wasRecording = isRecording - if wasRecording, let processor { - carriedSamples.append(contentsOf: processor.drain()) - engine.inputNode.removeTap(onBus: 0) - self.processor = nil - } - - guard wasRecording, isRecording, let continuation = levelContinuation else { return } + guard isRecording, let accumulator, let continuation = levelContinuation else { return } + engine.inputNode.removeTap(onBus: 0) + // Either failure leaves the recording without a tap until stop(), which + // still returns whatever was captured before the change; the new device + // may simply not be usable. do { try startEngineIfNeeded() + try installTap(into: accumulator, continuation: continuation) } catch { - // Nothing to do: the new device may not be usable. stop() still returns - // whatever was captured before the change. - return + Self.log.error("device change: capture did not resume: \(String(describing: error), privacy: .public)") } - try? installTap(continuation: continuation) } - } /// Unregisters a block-based notification observer when the owner goes away. @@ -247,24 +234,22 @@ private final class NotificationObserver: @unchecked Sendable { } } -/// Everything the realtime tap block touches, behind one lock. +/// The converter one tap block runs its buffers through, in front of the +/// recording's accumulator. /// -/// `@unchecked Sendable` because `AVAudioConverter` and `AVAudioPCMBuffer` are not -/// `Sendable`: the lock is what actually makes this safe. Taps are serialised by -/// CoreAudio, so in practice only the audio thread calls `process(_:)` and only the -/// actor calls `drain()`; the lock protects that hand-off. +/// `@unchecked Sendable` because `AVAudioConverter` is not `Sendable`. Only the tap +/// block calls `process(_:)`, and CoreAudio serialises tap blocks, so the converter +/// is never used from two threads; the samples it produces go into the +/// accumulator, which has a lock of its own. private final class TapProcessor: @unchecked Sendable { - private let lock = NSLock() private let converter: AVAudioConverter private let targetFormat: AVAudioFormat - private var samples: [Float] = [] + private let accumulator: SampleAccumulator - init(converter: AVAudioConverter, targetFormat: AVAudioFormat) { + init(converter: AVAudioConverter, targetFormat: AVAudioFormat, accumulator: SampleAccumulator) { self.converter = converter self.targetFormat = targetFormat - // A recording is usually a few seconds; reserve 10 s of 16 kHz mono up front so - // the audio thread rarely has to grow the array. - samples.reserveCapacity(160_000) + self.accumulator = accumulator } /// Called on the realtime audio thread. Converts to 16 kHz mono, accumulates, and @@ -278,19 +263,7 @@ private final class TapProcessor: @unchecked Sendable { } guard !converted.isEmpty else { return 0 } let level = AudioResampler.rmsLevel(converted) - lock.lock() - samples.append(contentsOf: converted) - lock.unlock() + accumulator.append(converted) return level } - - /// Takes everything captured so far and resets the accumulator. - func drain() -> [Float] { - lock.lock() - defer { - samples.removeAll(keepingCapacity: false) - lock.unlock() - } - return samples - } } diff --git a/Sources/PladderAudio/AudioResampler.swift b/Sources/PladderAudio/AudioResampler.swift index a4610f4..5caa574 100644 --- a/Sources/PladderAudio/AudioResampler.swift +++ b/Sources/PladderAudio/AudioResampler.swift @@ -150,7 +150,7 @@ public struct AudioResampler: Sendable { // MARK: Level - /// Perceptual level for a meter: RMS mapped so -50 dBFS is 0 and 0 dBFS is 1. + /// Perceptual level for a meter: RMS mapped so -60 dBFS is 0 and 0 dBFS is 1. public static func rmsLevel(_ samples: UnsafeBufferPointer) -> Float { guard !samples.isEmpty else { return 0 } var sumOfSquares = 0.0 diff --git a/Sources/PladderAudio/SampleAccumulator.swift b/Sources/PladderAudio/SampleAccumulator.swift new file mode 100644 index 0000000..eabf8f5 --- /dev/null +++ b/Sources/PladderAudio/SampleAccumulator.swift @@ -0,0 +1,50 @@ +import Synchronization + +/// The 16 kHz samples a recording has captured and not yet handed out. +/// +/// The tap block appends on the realtime audio thread; the capture actor +/// drains about once a second while the key is held and once more at stop. +/// One accumulator lives for the whole recording, across device changes, so +/// a switch of microphone swaps the tap in front of it without losing or +/// repeating anything said before the switch. +/// +/// A drain copies the samples out and keeps the buffer, so the audio thread +/// goes on appending into memory that is already reserved and touched rather +/// than growing a fresh array from nothing every second. The copy is the +/// only work done under the lock: about a second of audio, 64 KB. +final class SampleAccumulator: Sendable { + /// Ten seconds of 16 kHz mono: a dictation's first drain comes after one + /// second, so the audio thread rarely has to grow the array. + static let defaultCapacity = 160_000 + + private let samples: Mutex<[Float]> + + init(capacity: Int = SampleAccumulator.defaultCapacity) { + var reserved: [Float] = [] + reserved.reserveCapacity(capacity) + samples = Mutex(reserved) + } + + /// Called on the realtime audio thread. + func append(_ newSamples: [Float]) { + guard !newSamples.isEmpty else { return } + samples.withLock { $0.append(contentsOf: newSamples) } + } + + /// Everything appended since the previous drain, oldest first. + func drain() -> [Float] { + samples.withLock { buffer in + // A copy through the pointer, not `Array(buffer)`: that would + // share the storage, and the `removeAll` below would then have to + // allocate a new buffer instead of emptying this one. + let taken = buffer.withUnsafeBufferPointer { Array($0) } + buffer.removeAll(keepingCapacity: true) + return taken + } + } + + /// The reserved buffer's size, for the tests. + var capacity: Int { + samples.withLock { $0.capacity } + } +} diff --git a/Sources/PladderCLI/Bench.swift b/Sources/PladderCLI/Bench.swift new file mode 100644 index 0000000..79a58b6 --- /dev/null +++ b/Sources/PladderCLI/Bench.swift @@ -0,0 +1,332 @@ +import Foundation +import PladderBench +import PladderCore +import PladderEngines + +// MARK: - Fixtures + +struct Fixture { + var name: String + var samples: [Float] + var reference: String + var duration: Double { Double(samples.count) / CapturedAudio.sampleRate } +} + +/// Every audio file in `dir` that has a sibling `.txt`, shortest first so the +/// longest fixture, which heats the chip the most, runs last. +func loadFixtures(in dir: URL) throws -> [Fixture] { + let files = try FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) + var fixtures: [Fixture] = [] + for url in files where url.pathExtension.lowercased() != "txt" && !url.lastPathComponent.hasPrefix(".") { + let script = url.deletingPathExtension().appendingPathExtension("txt") + guard let reference = try? String(contentsOf: script, encoding: .utf8) else { + eprint("skipping \(url.lastPathComponent): no \(script.lastPathComponent)") + continue + } + let samples = try loadSamples(url) + fixtures.append(Fixture(name: url.deletingPathExtension().lastPathComponent, samples: samples, reference: reference)) + } + return fixtures.sorted { $0.duration < $1.duration } +} + +// MARK: - What both benchmarks share + +/// Exits with a usage status unless the options make a benchmark. +func checkBenchOptions(runs: Int, pause: Double) { + guard runs >= 2 else { + eprint("--runs must be at least 2 (the first run is discarded)") + exit(2) + } + guard pause >= 0 else { + eprint("--pause must not be negative") + exit(2) + } +} + +/// The title and the lines that say what ran on what. +func printBenchHeader(_ title: String, engine: any TranscriptionEngine) { + let chip = sysctlString("machdep.cpu.brand_string") ?? "unknown chip" + let os = ProcessInfo.processInfo.operatingSystemVersionString + print(title) + print("machine: \(chip), macOS \(os)") + print("model: \(engine.id) (\(engine.displayName))") +} + +/// Whether the chip stayed at its normal thermal state, run by run. +struct ThermalRecord { + private(set) var throttled = false + + /// The end of a run line: the warm-up mark and any thermal tag. + mutating func note(forRun run: Int) -> String { + let thermal = thermalTag() + throttled = throttled || !thermal.isEmpty + return (run == 1 ? " (warm-up, discarded)" : "") + thermal + } + + /// The lines after the table. + func printFooter() { + print("") + print(String(format: "load: %.2f (one-minute average at end)", loadAverage())) + if throttled { + print("warning: the chip left its normal thermal state during the run; numbers are not comparable") + } + } +} + +/// The columns both results tables have. +struct BenchRow { + var name: String + var duration: Double + var time: Double + var spread: Double + var wer: Double + + /// `times` and `errors` are the kept runs. + init(_ fixture: Fixture, times: [Double], errors: [Double]) { + name = fixture.name + duration = fixture.duration + time = median(times) + // Spread of the kept runs relative to the median: the noise floor + // for this fixture, so a difference smaller than it means nothing. + spread = (times.max()! - times.min()!) / time + wer = median(errors) + } +} + +/// The results table, with the timed column named `timed`. `extraHeader` and +/// `extraSeparator` add columns after the shared ones, and each row's +/// `extra` holds their cells. +func printBenchTable( + timed: String, + rows: [(row: BenchRow, extra: String)], + extraHeader: String = "", + extraSeparator: String = "" +) { + print("") + print("| Fixture | Audio | \(timed) | Spread | Realtime | WER |\(extraHeader)") + print("|---|---:|---:|---:|---:|---:|\(extraSeparator)") + for (row, extra) in rows { + print(String( + format: "| %@ | %.1f s | %.3f s | %.0f %% | %.0fx | %.1f %% |%@", + row.name, row.duration, row.time, row.spread * 100, row.duration / row.time, row.wer * 100, extra)) + } +} + +// MARK: - Whole buffer + +func runBench(dir: String, runs: Int, pause: Double) async throws { + checkBenchOptions(runs: runs, pause: pause) + let fixtures = try loadFixtures(in: URL(fileURLWithPath: dir)) + guard !fixtures.isEmpty else { + eprint("no fixtures in \(dir); run scripts/make-fixtures.sh first") + exit(1) + } + + let engine = makeEngine() + printBenchHeader("Pladder benchmark", engine: engine) + print("runs: \(runs) per fixture, first discarded, median reported") + print(String(format: "pause: %.0f s idle before every run, as between real dictations", pause)) + print(String(format: "load: %.2f (one-minute average at start)", loadAverage())) + print("") + + let loadTime = try await loadEngine(engine) + print(String(format: "model load (cold): %.2f s", loadTime.timeInterval)) + if let bytes = physicalFootprintBytes() { + print(String(format: "memory after load: %.0f MB (physical footprint)", Double(bytes) / 1_048_576)) + } + print("") + + var rows: [(row: BenchRow, extra: String)] = [] + let clock = ContinuousClock() + var thermal = ThermalRecord() + for fixture in fixtures { + var times: [Double] = [] + var errors: [Double] = [] + for run in 1...runs { + // Every run starts from idle, like a dictation does. Back-to-back + // runs would hand each other warm clocks and residual heat. + if pause > 0 { try await Task.sleep(for: .seconds(pause)) } + let started = clock.now + let transcript = try await engine.transcribe(fixture.samples) + let elapsed = (clock.now - started).timeInterval + let wer = WordErrorRate.compute(reference: fixture.reference, hypothesis: transcript.text) + let note = thermal.note(forRun: run) + print(String(format: "%@ run %d: %.3f s, WER %.1f%%%@", fixture.name, run, elapsed, wer * 100, note)) + if run > 1 { + times.append(elapsed) + errors.append(wer) + } + } + rows.append((BenchRow(fixture, times: times, errors: errors), "")) + } + + printBenchTable(timed: "Engine (median)", rows: rows) + thermal.printFooter() +} + +// MARK: - Paced + +/// The first word where two raw engine transcripts diverge, for the identity +/// gate. Nil when they are the same word sequence. +func firstWordDifference( + batch: String, + paced: String +) -> (index: Int, batch: String, paced: String)? { + let left = batch.split(whereSeparator: \.isWhitespace).map(String.init) + let right = paced.split(whereSeparator: \.isWhitespace).map(String.init) + for index in 0.." + let b = index < right.count ? right[index] : "" + if a != b { return (index, a, b) } + } + return nil +} + +/// What the live passes of one paced run cost. Lock-protected because the +/// live task records into it while the run's own task paces the audio. +final class LivePassLog: @unchecked Sendable { + private let lock = NSLock() + private var _times: [Double] = [] + var times: [Double] { lock.withLock { _times } } + func record(_ elapsed: Double) { lock.withLock { _times.append(elapsed) } } +} + +/// Paced bench variant: pushes each fixture in one-second chunks paced at real +/// time, like a live recording, then times `endUtterance` alone. Pacing takes +/// as long as the audio, so keep the fixture set small. +/// +/// Every fixture also goes through the same engine once, whole, and the two +/// raw engine texts are compared before any processor runs. That line is the +/// identity gate: the incremental path runs the windows the whole-buffer path +/// runs, so any difference is a bug. +/// +/// With `live`, the Live Transcript style is modelled too: a second task asks +/// the engine for the text so far every 0.5 s while the fixture is paced and +/// is cancelled just before the release, exactly as the coordinator's feed +/// loop is. That answers the two questions the style raises — what a live pass +/// costs, and whether a release that lands next to one is slower — and the +/// identity gate becomes a gate on the live passes as well, since a pass that +/// disturbed the session's windows would change the text. +func runPacedBench(dir: String, runs: Int, pause: Double, includeShort: Bool, live: Bool) async throws { + checkBenchOptions(runs: runs, pause: pause) + var fixtures = try loadFixtures(in: URL(fileURLWithPath: dir)) + // Below ~13 s both paths run one padded window, so there is nothing paced + // about the result; --all keeps them anyway. + if !includeShort { fixtures = fixtures.filter { $0.duration >= 13 } } + guard !fixtures.isEmpty else { + eprint("no paced fixtures in \(dir)") + exit(1) + } + + // One engine, two ways in. The paced path feeds it while the audio + // arrives; `transcribe` hands it the whole buffer, which is the call a + // recording transcribed at release makes. Comparing the two is the gate. + guard let engine = makeEngine() as? any StreamingTranscriptionEngine else { + eprint("\(StandardEngines.defaultEntry.id) does not transcribe while speaking; there is nothing to pace") + exit(1) + } + printBenchHeader("Pladder benchmark (paced)", engine: engine) + print("runs: \(runs) per fixture, first discarded, median of `endUtterance` reported") + print(String(format: "pause: %.0f s idle before every run", pause)) + print("compare: the same engine, whole buffer, once per fixture, raw text") + if live { + print("live: a live pass every 0.5 s while the fixture is paced, as the Live Transcript overlay makes") + } + print("") + + let loadTime = try await loadEngine(engine) + print(String(format: "model load (cold): %.2f s", loadTime.timeInterval)) + print("") + + var rows: [(row: BenchRow, extra: String)] = [] + let clock = ContinuousClock() + var thermal = ThermalRecord() + for fixture in fixtures { + var times: [Double] = [] + var errors: [Double] = [] + var livePassTimes: [Double] = [] + var livePassCounts: [Int] = [] + var lastText = "" + for run in 1...runs { + if pause > 0 { try await Task.sleep(for: .seconds(pause)) } + let utterance = try await engine.beginUtterance() + // The overlay's loop: a pass over the audio so far, every half + // second, for as long as the "key" is held. + let passes = LivePassLog() + let liveTask: Task? = live ? Task { + while !Task.isCancelled { + let started = clock.now + _ = await engine.livePass(utterance) + passes.record((clock.now - started).timeInterval) + try? await Task.sleep(for: .milliseconds(500)) + } + } : nil + // One-second chunks paced at real time, as the coordinator's + // feed task would deliver them. + var offset = 0 + let oneSecond = Int(CapturedAudio.sampleRate) + while offset + oneSecond < fixture.samples.count { + await engine.feed(Array(fixture.samples[offset.. 1 { + times.append(elapsed) + errors.append(wer) + livePassTimes.append(contentsOf: runPasses) + livePassCounts.append(runPasses.count) + } + } + + // The identity gate: the same samples through the same engine, whole. + // Anything but `identical: yes` is a bug in the incremental path, not + // a tuning matter. + // + // Timed like the paced runs above and after the same idle pause, so + // the two columns are comparable: a call made straight after a paced + // run would find the Neural Engine warm when every release the bench + // models is cold. + if pause > 0 { try await Task.sleep(for: .seconds(pause)) } + let batchStarted = clock.now + let batchTranscript = try await engine.transcribe(fixture.samples) + let batchElapsed = (clock.now - batchStarted).timeInterval + let batchWer = WordErrorRate.compute(reference: fixture.reference, hypothesis: batchTranscript.text) + print(String(format: "%@ whole: %.3f s, WER %.1f%%", fixture.name, batchElapsed, batchWer * 100)) + let difference = firstWordDifference(batch: batchTranscript.text, paced: lastText) + if let difference { + print("\(fixture.name) identical: no, first differing word \(difference.index): whole \"\(difference.batch)\" vs paced \"\(difference.paced)\"") + } else { + print("\(fixture.name) identical: yes") + } + + let livePasses = Int(median(livePassCounts.map(Double.init)).rounded()) + let liveCells = live ? String(format: " %d | %.3f s |", livePasses, median(livePassTimes)) : "" + rows.append(( + BenchRow(fixture, times: times, errors: errors), + " \(difference == nil ? "yes" : "no") |\(liveCells)")) + } + + printBenchTable( + timed: "endUtterance (median)", + rows: rows, + extraHeader: " Identical whole-buffer |\(live ? " Live passes | Live pass (median) |" : "")", + extraSeparator: "---|\(live ? "---:|---:|" : "")") + thermal.printFooter() +} diff --git a/Sources/PladderCLI/BenchProcess.swift b/Sources/PladderCLI/BenchProcess.swift new file mode 100644 index 0000000..d90f134 --- /dev/null +++ b/Sources/PladderCLI/BenchProcess.swift @@ -0,0 +1,129 @@ +import Foundation +import PladderCore +import PladderEngines +import PladderSystem + +/// `pladder-cli bench-process [--runs N]`: what the processor +/// pipeline costs on the fixtures' text (see "Processors" in +/// docs/BENCHMARKS.md). +/// +/// Every fixture's `.txt`, the script `scripts/make-fixtures.sh` wrote beside +/// its audio, is read as one line of transcript and run through the pipeline +/// `--process` runs, built from a fresh install's settings, in two variants: +/// "typical" has the filler "um," after the first word of every sentence, +/// "worst" also says "question mark" at the end of every sentence. Each +/// variant runs `runs` times; the first is discarded and the median of the +/// rest reported. +enum ProcessorBench { + /// Parses what follows `bench-process` on the command line and runs it. + static func run(arguments: [String]) { + var runs = 31 + var directory: String? + var rest = arguments[...] + while let argument = rest.popFirst() { + if argument == "--runs" { + guard let value = rest.popFirst(), let count = Int(value) else { usage() } + runs = count + } else if directory == nil { + directory = argument + } else { + usage() + } + } + guard let directory else { usage() } + guard runs >= 2 else { + eprint("--runs must be at least 2 (the first run is discarded)") + exit(2) + } + run(directory: directory, runs: runs) + } + + static func run(directory: String, runs: Int) { + let fixtures = loadScripts(in: directory) + guard !fixtures.isEmpty else { + eprint("no fixture scripts in \(directory); run scripts/make-fixtures.sh first") + exit(1) + } + let settings = DictationSettings(engineID: FluidAudioIncrementalEngine.engineID) + let pipeline = StandardProcessors.pipeline(for: settings) + + print("Pladder processor benchmark") + print("runs: \(runs) per variant, first discarded, median reported") + print("typical: \"um,\" after the first word of every sentence") + print("worst: typical, and \"question mark\" at the end of every sentence") + print("") + print("| Fixture | Words | Typical (median) | Worst (median) |") + print("|---|---:|---:|---:|") + let clock = ContinuousClock() + for (name, script) in fixtures { + let sentences = Self.sentences(in: script) + var medians: [Double] = [] + for variant in [typical(sentences), worst(sentences)] { + var times: [Double] = [] + for attempt in 1...runs { + let started = clock.now + // `await` whether or not `run` is async, so this file + // builds against either. + _ = pipeline.run(variant) + let elapsed = clock.now - started + if attempt > 1 { times.append(elapsed / .milliseconds(1)) } + } + medians.append(median(times)) + } + let words = script.split(whereSeparator: \.isWhitespace).count + print(String(format: "| %@ | %d | %.2f ms | %.2f ms |", name, words, medians[0], medians[1])) + } + } + + /// Every `.txt` in `directory` with its name, shortest first. + private static func loadScripts(in directory: String) -> [(name: String, script: String)] { + let url = URL(fileURLWithPath: directory) + guard let files = try? FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) else { + eprint("cannot read \(directory)") + exit(1) + } + let scripts = files + .filter { $0.pathExtension.lowercased() == "txt" && !$0.lastPathComponent.hasPrefix(".") } + .compactMap { file -> (name: String, script: String)? in + guard let script = try? String(contentsOf: file, encoding: .utf8) else { return nil } + return (file.deletingPathExtension().lastPathComponent, script) + } + return scripts.sorted { $0.script.count < $1.script.count } + } + + /// The script's sentences, each with its closing mark, line breaks read + /// as spaces: the fixtures hold one sentence per line, and the engine + /// writes one line. + static func sentences(in script: String) -> [String] { + var sentences: [String] = [] + var current = "" + for character in script { + current.append(character.isNewline ? " " : character) + if ".!?".contains(character) { + sentences.append(current.trimmingCharacters(in: .whitespaces)) + current = "" + } + } + let rest = current.trimmingCharacters(in: .whitespaces) + if !rest.isEmpty { sentences.append(rest) } + return sentences.filter { !$0.isEmpty } + } + + /// "um," after the first word of every sentence. + static func typical(_ sentences: [String]) -> String { + sentences.map { sentence in + var words = sentence.split(separator: " ", omittingEmptySubsequences: true).map(String.init) + words.insert("um,", at: min(1, words.count)) + return words.joined(separator: " ") + }.joined(separator: " ") + } + + /// The typical variant with "question mark" spoken before every + /// sentence's closing mark. + static func worst(_ sentences: [String]) -> String { + typical(sentences.map { sentence in + guard let last = sentence.last, ".!?".contains(last) else { return sentence + " question mark" } + return String(sentence.dropLast()) + " question mark" + String(last) + }) + } +} diff --git a/Sources/PladderCLI/Polish.swift b/Sources/PladderCLI/Polish.swift new file mode 100644 index 0000000..8d10fa7 --- /dev/null +++ b/Sources/PladderCLI/Polish.swift @@ -0,0 +1,163 @@ +import Foundation +import PladderBench +import PladderCore +import PladderEngines +import PladderRefine +import PladderSystem + +extension PolishModel { + /// The name `--model` takes for this model. A `switch` without a + /// `default`, so a new case does not compile until it has a name here. + var cliName: String { + switch self { + case .appleIntelligence: "apple" + case .s1Mini: "s1-mini" + case .s1Mini8Bit: "s1-mini-8bit" + } + } + + /// Every `--model` name, as the usage line lists them. + static var cliNames: String { + allCases.map(\.cliName).joined(separator: "|") + } + + init?(cliName: String) { + guard let model = Self.allCases.first(where: { $0.cliName == cliName }) else { return nil } + self = model + } +} + +/// Runs `TranscriptPolisher` over one transcript twice and prints what the +/// polish toggle would paste. The first run is cold (no `prepare()`), the second +/// warm, which is what a real press gets: the session is made and prewarmed +/// at key-down, seconds before the release. +func runPolish(_ path: String, model: PolishModel, options: PolishOptions) async throws { + let text: String + if path == "-" { + text = String(decoding: FileHandle.standardInput.readDataToEndOfFile(), as: UTF8.self) + } else { + text = try String(contentsOf: URL(fileURLWithPath: path), encoding: .utf8) + } + let transcript = text.trimmingCharacters(in: .whitespacesAndNewlines) + if model == .appleIntelligence { + print("availability: \(TranscriptPolisher.availability)") + } + print("input (\(transcript.split(whereSeparator: \.isWhitespace).count) words):") + print(transcript) + + func show(_ label: String, _ report: PolishReport) { + print("") + let timing = String(format: "%.3f", report.elapsed.timeInterval) + if let polished = report.text { + print("\(label): \(timing) s, \(report.wordsIn) words in, \(report.wordsOut) out, \(report.mode.rawValue)") + print(polished) + } else { + let reason = report.failure ?? "unknown" + print("\(label): \(timing) s, no polish (\(reason)); the text would be pasted as dictated") + } + } + + let polisher = try await makePolisher(model, options: options) + show("cold", await polisher.polish(transcript)) + await polisher.prepare() + try await Task.sleep(for: .seconds(2)) + show("warm", await polisher.polish(transcript)) +} + +/// What the command line changes about a polisher; nil is the app's own. +struct PolishOptions { + var instructionsPath: String? + var gguf: String? + var control: String? +} + +/// The polisher the app would use for `model`, downloading an S1-mini file +/// into the app's own model directory first if it is not there yet. With +/// `--gguf`, an S1-mini polisher over that file instead. +func makePolisher(_ model: PolishModel, options: PolishOptions = PolishOptions()) async throws -> any ReportingRefiner { + let instructionsPath = options.instructionsPath + let file: ModelFile + let location: URL + if let gguf = options.gguf { + if instructionsPath != nil { usage() } + location = URL(fileURLWithPath: gguf) + file = ModelFile(fileName: location.lastPathComponent, url: location, sha256: "", byteCount: 0) + } else if let pinned = ModelFile(for: model) { + if instructionsPath != nil { usage() } + let files = ModelFiles(directory: ModelFiles.defaultDirectory) { _, status in + if case .downloading(let fraction) = status { + eprint(String(format: "\rdownloading %3.0f%%", fraction * 100), terminator: "") + } else if status == .verifying { + eprint("\rverifying ") + } + } + await files.ensure(pinned) + let status = await files.finished(pinned) + guard status == .ready else { + eprint("\(pinned.fileName): \(status)") + exit(1) + } + file = pinned + location = files.location(of: pinned) + } else { + if options.control != nil { usage() } + guard let instructionsPath else { return TranscriptPolisher() } + let instructions = try String(contentsOf: URL(fileURLWithPath: instructionsPath), encoding: .utf8) + print("instructions: \(instructionsPath)") + return TranscriptPolisher(instructions: instructions) + } + print("model: \(options.gguf ?? file.fileName)") + if let control = options.control { print("control: \(control)") } + return S1MiniPolisher(file: file, location: location, control: options.control ?? S1MiniPolisher.controlLine) +} + +/// One case of a polish test set: what the speech model heard and what +/// should be pasted. +struct PolishCase: Decodable { + let id: String + let lang: String + let input: String + let expected: String +} + +/// Runs `model` over a test set the way the app would: the app's processors +/// first, then the polish, warm. Prints every answer, then per-language word +/// error rates against the expected text (case and punctuation ignored), +/// exact matches (everything counts) and the polish time. +func runPolishSet(_ path: String, model: PolishModel, options: PolishOptions) async throws { + let cases = try JSONDecoder().decode([PolishCase].self, from: Data(contentsOf: URL(fileURLWithPath: path))) + guard !cases.isEmpty else { + eprint("pladder-cli: \(path): the test set has no cases") + exit(1) + } + // The processors `--process` runs, with a fresh install's settings: no + // dictionary, so the set means the same on every machine. + let pipeline = StandardProcessors.pipeline(for: DictationSettings(engineID: StandardEngines.defaultEntry.id)) + let polisher = try await makePolisher(model, options: options) + await polisher.prepare() + // The first call pays for whatever prepare() could not warm. + _ = await polisher.polish("Warm up.") + + var rates: [String: [Double]] = [:] + var exact = 0 + var times: [Double] = [] + for item in cases { + let processed = pipeline.run(item.input) + let report = await polisher.polish(processed) + let output = report.text ?? processed + times.append(report.elapsed.timeInterval) + rates[item.lang, default: []].append(WordErrorRate.compute(reference: item.expected, hypothesis: output)) + let matched = output.trimmingCharacters(in: .whitespacesAndNewlines) == item.expected + if matched { exact += 1 } + let failure = report.failure.map { " (\($0))" } ?? "" + print(String(format: "%@ %-26@ %5.2f s%@", matched ? "=" : " ", item.id, report.elapsed.timeInterval, failure)) + print(" \(output.replacingOccurrences(of: "\n", with: "⏎"))") + } + print("") + let all = rates.values.flatMap { $0 } + let mean = { (values: [Double]) in values.reduce(0, +) / Double(max(values.count, 1)) } + let perLanguage = rates.keys.sorted().map { String(format: "%@ %.3f", $0, mean(rates[$0]!)) }.joined(separator: " ") + times.sort() + print(String(format: "WER %.3f (%@), exact %d of %d, polish median %.2f s, p90 %.2f s", + mean(all), perLanguage, exact, cases.count, times[times.count / 2], times[Int(Double(times.count) * 0.9)])) +} diff --git a/Sources/PladderCLI/Support.swift b/Sources/PladderCLI/Support.swift new file mode 100644 index 0000000..947d540 --- /dev/null +++ b/Sources/PladderCLI/Support.swift @@ -0,0 +1,111 @@ +@preconcurrency import AVFoundation +import Darwin +import Foundation +import PladderAudio +import PladderCore +import PladderEngines + +/// Writes to stderr, so nothing but results ever reaches stdout: a script +/// reading a transcript from `pladder-cli ` sees the transcript alone. +func eprint(_ message: String, terminator: String = "\n") { + FileHandle.standardError.write(Data((message + terminator).utf8)) +} + +/// The engine the app runs by default, built from the catalog the app builds +/// its registry from. +func makeEngine() -> any TranscriptionEngine { + StandardEngines.defaultEntry.make() +} + +func loadSamples(_ url: URL) throws -> [Float] { + let file = try AVAudioFile(forReading: url) + guard let input = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: AVAudioFrameCount(file.length)) + else { throw NSError(domain: "cli", code: 1, userInfo: [NSLocalizedDescriptionKey: "unsupported audio format"]) } + try file.read(into: input) + let target = try AudioResampler.monoFloat32Format() + return try AudioResampler.convert(input, to: target) +} + +/// Loads the engine, printing download progress, and returns the wall-clock +/// load time. In a fresh process this is the cold start the app pays at launch. +func loadEngine(_ engine: any TranscriptionEngine) async throws -> Duration { + let clock = ContinuousClock() + let started = clock.now + var lastPrinted = -1 + let statusTask = Task { + while !Task.isCancelled { + if case .downloading(let p) = await engine.status, let p { + let pct = Int(p * 100) + // stderr, so a first run's download never lands in a transcript. + if pct != lastPrinted { + eprint("downloading \(pct)%") + lastPrinted = pct + } + } + try? await Task.sleep(for: .milliseconds(500)) + } + } + defer { statusTask.cancel() } + do { + try await engine.load() + } catch { + // The engine's own wording for the failure — the line the menu shows — + // rather than a top-level trap printing the raw error, so a download + // that never finished can be diagnosed from the terminal. + if case .failed(let failure) = await engine.status { + // A developer tool: the enum's own shape is the diagnosis, and + // the wording that reaches users lives in the app. + eprint("model failed: \(failure)") + exit(1) + } + throw error + } + return clock.now - started +} + +func median(_ values: [Double]) -> Double { + let sorted = values.sorted() + guard !sorted.isEmpty else { return 0 } + let mid = sorted.count / 2 + return sorted.count.isMultiple(of: 2) ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid] +} + +func sysctlString(_ name: String) -> String? { + var size = 0 + guard sysctlbyname(name, nil, &size, nil, 0) == 0, size > 0 else { return nil } + var buffer = [UInt8](repeating: 0, count: size) + guard sysctlbyname(name, &buffer, &size, nil, 0) == 0 else { return nil } + // The value ends in a NUL, which is not part of the string. + return String(decoding: buffer.prefix(size).prefix { $0 != 0 }, as: UTF8.self) +} + +/// Physical memory footprint of this process, the number Activity Monitor +/// shows in its Memory column. +func physicalFootprintBytes() -> UInt64? { + var info = task_vm_info_data_t() + var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) + let result = withUnsafeMutablePointer(to: &info) { pointer in + pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count) + } + } + return result == KERN_SUCCESS ? info.phys_footprint : nil +} + +/// One-minute load average, so the conditions of a run are on the record. +func loadAverage() -> Double { + var loads = [Double](repeating: 0, count: 3) + return getloadavg(&loads, 3) > 0 ? loads[0] : 0 +} + +/// Empty when the chip is at its normal thermal state, else a tag for the +/// run line, because a throttled run is not comparable. +func thermalTag() -> String { + switch ProcessInfo.processInfo.thermalState { + case .nominal: return "" + case .fair: return " [thermal: fair]" + case .serious: return " [thermal: serious]" + case .critical: return " [thermal: critical]" + @unknown default: return " [thermal: unknown]" + } +} diff --git a/Sources/PladderCLI/Transcribe.swift b/Sources/PladderCLI/Transcribe.swift new file mode 100644 index 0000000..5a613a9 --- /dev/null +++ b/Sources/PladderCLI/Transcribe.swift @@ -0,0 +1,50 @@ +import Foundation +import PladderCore +import PladderEngines +import PladderSystem + +/// The app's settings, for `--process`. Decoded here rather than through +/// `SettingsStore.load()`, which moves a file it cannot decode aside: a CLI +/// built from another branch must never touch the live configuration. +func appSettings() -> Settings { + let url = ProcessInfo.processInfo.environment["PLADDER_SETTINGS_PATH"].flatMap { $0.isEmpty ? nil : URL(filePath: $0) } + ?? FileManager.default.homeDirectoryForCurrentUser + .appending(path: "Library/Application Support/Pladder/settings.json") + let fallback = Settings(engineID: StandardEngines.defaultEntry.id) + guard let data = try? Data(contentsOf: url) else { return fallback } + do { + return try JSONDecoder().decode(Settings.self, from: data) + } catch { + eprint("pladder-cli: \(url.path): \(error); processing with the defaults") + return fallback + } +} + +func transcribeFile(_ path: String, process: Bool, verbose: Bool) async { + do { + let engine = makeEngine() + let loadTime = try await loadEngine(engine) + if verbose { print(String(format: "model ready in %.1fs", loadTime.timeInterval)) } + + let samples = try loadSamples(URL(fileURLWithPath: path)) + let transcript = try await engine.transcribe(samples) + var text = transcript.text + if process { + let settings = DictationSettings(appSettings()) + let pipeline = StandardProcessors.pipeline(for: settings) + text = pipeline.run(text, disabled: settings.disabledProcessors) + } + if verbose { + print(String(format: "audio %.2fs, processed in %.3fs (%.0fx realtime)", transcript.audioDuration, transcript.processingTime, transcript.realtimeFactor)) + if process { print("RAW: \(transcript.text)") } + print("TEXT: \(text)") + } else { + print(text) + } + } catch { + // A file Core Audio cannot open (WebM, say) is the likely failure; a + // message and a status rather than a trap, for whatever called us. + eprint("pladder-cli: \(path): \(error)") + exit(1) + } +} diff --git a/Sources/PladderCLI/main.swift b/Sources/PladderCLI/main.swift index 68f1642..66122f3 100644 --- a/Sources/PladderCLI/main.swift +++ b/Sources/PladderCLI/main.swift @@ -1,12 +1,5 @@ -@preconcurrency import AVFoundation -import Darwin import Foundation -import PladderAudio -import PladderBench import PladderCore -import PladderEngines -import PladderRefine -import PladderSystem // Developer tool. // @@ -35,6 +28,11 @@ import PladderSystem // how many there were and what they cost. The // `identical:` column then also proves the live // passes leave the release's windows alone. +// pladder-cli bench-process +// time the processor pipeline on the fixtures' text, +// with a filler in every sentence and with a spoken +// question mark as well (see docs/BENCHMARKS.md). +// [--runs N] runs per variant, default 31; the first is discarded. // pladder-cli polish run the polisher over a transcript: once cold, // once after prepare() and a two-second wait, the // way a real press warms it. Prints both timings. @@ -53,626 +51,23 @@ import PladderSystem // // Fixtures are audio files with a sibling .txt holding the spoken script, as // produced by scripts/make-fixtures.sh. +// +// This file parses the command line and dispatches; each command lives in a +// file of its own: Transcribe.swift, Bench.swift (whole-buffer and paced), +// BenchProcess.swift and Polish.swift, with the shared helpers in Support.swift. func usage() -> Never { - FileHandle.standardError.write(Data(""" + eprint(""" usage: pladder-cli