From ed765f34f88dc8c1e83ac2da0d1f492ad88d54d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 21:39:05 +0000 Subject: [PATCH 1/2] Make the host's identity and cue catalog host-supplied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine baked Blurt into six constants a third-party host inherited with no opt-out, and shipped a 192-voice cue catalog whose audio lives only in Blurt's app bundle. Both are now the host's to supply. HostIdentity replaces BlurtIdentity as one value carrying the Keychain service, the os_log subsystem (and the engine's queue labels), the UserDefaults prefix, the ~/Library/Logs directory, the product name update alerts say, and the release feed UpdateChecker reads. Every engine component reads HostIdentity.current; a host overrides all six with one configure(_:) at its composition root, or inherits .blurt by doing nothing — which is what keeps this app byte-for-byte unchanged on disk. BlurtApp.init configures .blurt explicitly, because the identity belongs to the host. Process-wide rather than injected: the readers are static-let loggers, an enum of defaults keys, and a Keychain facade — none of them things a caller constructs. The derivations are pure functions of the value (defaultsKey, logURL, queueLabel, logger), so the tests exercise them against a constructed identity instead of mutating the shared one, which every suite reads in parallel. DefaultsKey raw values lose their "Blurt" prefix and gain `key`, the identity's prefix plus the raw value — so Blurt still writes BlurtSoundPack and a second embedder gets its own namespace instead of writing into Blurt's slot. SoundPack keeps only the descriptor; the new SoundPackCatalog is a host-supplied value carrying the picker sections, the lookup and the decode-with-default rule, and Blurt's 192 voices are generated into the app target next to the .m4a cues they name. A catalog and its audio are one artifact — a voice list whose stems name files in someone else's bundle is a picker in which every choice plays silence, which is exactly what a package consumer used to get. SoundPackStore stays engine-side so its key remains a DefaultsKey case and cannot fall out of PersistedSettings.resetAll. SoundPack.none is now installed after the voices, so a voice wrongly claiming that reserved id loses its own slot rather than making "no sound" unselectable — a host has no check.sh to catch the collision. check.sh's catalog gate follows the file to the app target and also pins that defaultVoiceID names a real voice, the failure mode the host-supplied default introduces. NOTE: App/Blurt/Blurt/SoundPackCatalog.swift is a new app-target source file, so Blurt.xcodeproj needs `xcodegen generate` on a Mac before the project-drift check can pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VbZNKdeyrVbn2cQtyBuFo8 --- AGENTS.md | 47 ++- App/Blurt/Blurt/App.swift | 14 + App/Blurt/Blurt/AppDelegate.swift | 4 +- App/Blurt/Blurt/CueSoundPlayer.swift | 2 +- App/Blurt/Blurt/Hotkey/DictationKeyTap.swift | 3 +- App/Blurt/Blurt/SoundPackCatalog.swift | 207 +++++++++++++ App/Blurt/Blurt/Update/UpdateCheckModel.swift | 2 +- .../Blurt/Wizard/Steps/SoundStepView.swift | 14 +- .../BlurtEngine/Audio/AudioRouteMonitor.swift | 4 +- Sources/BlurtEngine/Audio/MicCapture.swift | 2 +- Sources/BlurtEngine/Audio/SoundPack.swift | 104 ++----- .../BlurtEngine/Audio/SoundPackCatalog.swift | 285 ++++++------------ .../BlurtEngine/Audio/SoundPackStore.swift | 20 +- Sources/BlurtEngine/BlurtIdentity.swift | 23 -- Sources/BlurtEngine/Config/APIKeyStore.swift | 2 +- .../BlurtEngine/Config/CustomStyleStore.swift | 2 +- Sources/BlurtEngine/Config/DefaultsKey.swift | 40 ++- .../Config/DeveloperModeStore.swift | 2 +- .../Config/EnhancedTranscriptsStore.swift | 2 +- .../BlurtEngine/Config/KeyTermsStore.swift | 2 +- .../Config/PersistedSettings.swift | 2 +- Sources/BlurtEngine/HostIdentity.swift | 161 ++++++++++ .../BlurtEngine/Hotkey/TriggerKeyStore.swift | 2 +- .../Permissions/SigningIdentity.swift | 2 +- .../Pipeline/DictationLog+Errors.swift | 2 +- .../BlurtEngine/Pipeline/DictationLog.swift | 14 +- .../DictationSession+Observation.swift | 2 +- .../Pipeline/DictationSession.swift | 2 +- .../Pipeline/OverlayOriginStore.swift | 4 +- Sources/BlurtEngine/README.md | 72 +++-- .../STT/AssemblyAITranscriber.swift | 2 +- .../Update/LastUpdateCheckStore.swift | 2 +- .../Update/UpdateAlertContent.swift | 7 +- .../BlurtEngine/Update/UpdateChecker.swift | 6 +- .../BlurtEngineTests/BlurtIdentityTests.swift | 24 -- Tests/BlurtEngineTests/DefaultsKeyTests.swift | 39 ++- .../BlurtEngineTests/HostIdentityTests.swift | 81 +++++ .../PersistedSettingsTests.swift | 2 +- .../SoundPackStoreTests.swift | 39 ++- Tests/BlurtEngineTests/SoundPackTests.swift | 89 ++++-- scripts/check-invariants.sh | 4 +- scripts/check.sh | 35 ++- scripts/generate-sounds.swift | 26 +- scripts/reset-install.sh | 6 +- 44 files changed, 929 insertions(+), 477 deletions(-) create mode 100644 App/Blurt/Blurt/SoundPackCatalog.swift delete mode 100644 Sources/BlurtEngine/BlurtIdentity.swift create mode 100644 Sources/BlurtEngine/HostIdentity.swift delete mode 100644 Tests/BlurtEngineTests/BlurtIdentityTests.swift create mode 100644 Tests/BlurtEngineTests/HostIdentityTests.swift diff --git a/AGENTS.md b/AGENTS.md index 437e58b0..c1be9ebb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,9 @@ Sources/BlurtEngine/ the engine (dependency-free Swift package) README.md the engine's developer guide (quick start, seams, error table) Audio/ MicCapture (+meter/+warm), MicLiveness (mic bring-up gate), AudioRoute (+Monitor)/AudioTransport — CoreAudio routing, SoundPack/Catalog/Store + (the voice *descriptors*; the voices themselves are app-side) + HostIdentity.swift the host's Keychain service, log subsystem, defaults prefix, log + directory, product name and release feed — one overridable value Config/ Keychain-backed API key, key terms, developer mode, DefaultsKey + PersistedSettings (every defaults key, and the reset sweep over them) FocusCapture/ Accessibility reads of the frontmost app / focused field @@ -66,7 +69,9 @@ Sources/BlurtEngine/ the engine (dependency-free Swift package) App/Blurt/ project.yml XcodeGen source of truth — Blurt.xcodeproj is GENERATED Blurt/ the shell: App.swift scenes, AppCoordinator, Wizard/, Overlay/, - MenuBar/, Hotkey/DictationKeyTap, Update/, CueSoundPlayer + MenuBar/, Hotkey/DictationKeyTap, Update/, CueSoundPlayer, + SoundPackCatalog.swift (GENERATED — the cue voices, beside the + Resources/Sounds/ audio they name) Shared/ UITestIdentifiers.swift — compiled into BOTH app and UI-test targets BlurtUITests/ XCUITest bundle (see Tests) Tests/BlurtEngineTests/ Swift Testing suites; Stubs/ holds the seam doubles @@ -78,7 +83,7 @@ scripts/ check.sh, check-site.sh, check-portability.sh, chec beautify.swift (the site's window imagery, capture then composite), generate-branding-images.sh (records how the logo assets were generated — provenance, not a renderer), - generate-sounds.swift (regenerates the cues AND + generate-sounds.swift (regenerates the cues AND the app's SoundPackCatalog.swift together) Brewfile Homebrew-managed check.sh tools — the whole toolchain evals/ offline decision support — the repo's only Python, none of it @@ -122,8 +127,8 @@ the two checks that genuinely need the build products stay behind them (step 12) 1. **Repo-integrity guards** — no external SPM dependencies; sound-catalog integrity (every `SoundPackCatalog` voice has both cue files, no orphans, no duplicate or reserved ids); and **site integrity** (`scripts/check-site.sh`). All three run in `--portable` too. The catalog and - the audio are generated together but ship from different targets, so drift plays silence with - nothing raising an error. The site guard exists for the same reason on the web: `pages.yml` + the audio are generated together — both into the app target, one as source and one as + resources — so drift plays silence with nothing raising an error. The site guard exists for the same reason on the web: `pages.yml` uploads `site/` verbatim with no build step, so a renamed asset, a stale absolute URL, or a missing `CNAME` produces no error in this repo — just a 404 on the live site. @@ -274,7 +279,7 @@ satisfied and toggling the row does nothing. Two defences, in this order: `AppDelegate.runAccessibilityGrantMigration` is the self-heal. It compares `SigningIdentity.current()` — the app's **designated requirement**, serialized and namespaced (`dr:`) — against the identity recorded in `accessibility.lastSigningTeam`; if it changed and the app is untrusted, it runs -`tccutil reset Accessibility` on **the running bundle id** (never `BlurtIdentity.subsystem`, which +`tccutil reset Accessibility` on **the running bundle id** (never `HostIdentity.current.subsystem`, which would have a dev build clearing the release's grant) so the wizard's normal grant flow captures a matching requirement, and records the new identity only when the reset succeeded. `SigningIdentityMigration` is the pure decision; `SigningIdentity` is the thin `Security`/`tccutil` @@ -769,6 +774,20 @@ the omit-vs-`[]` rule live; `KeytermsWireTests` pins both. ## Settings, persistence, and cues +**`HostIdentity`** comes first, because most of what follows is namespaced by it: the Keychain +service the API key lands in, the `os_log` subsystem (and the engine's dispatch-queue labels), the +`UserDefaults` prefix every `DefaultsKey` composes with, the `~/Library/Logs` directory the +developer-mode logs go to, the product name update alerts say, and the GitHub release the update +check reads. These were hard constants, which made them Blurt's with no opt-out — a second app +embedding the engine wrote into _Blurt's_ Keychain item, log directory and defaults keys. They are +one value now; `HostIdentity.current` is what the engine reads, `.blurt` is what an unconfigured host +inherits (so nothing about this app changed), and `BlurtApp.init` calls `HostIdentity.configure(_:)` +with it because the identity belongs to the host, not the engine. It is process-wide rather than +injected for the obvious reason: its readers are `static let` loggers, an enum of defaults keys and a +Keychain facade, none of which a caller constructs. The derivations are pure functions of the value +(`defaultsKey(_:)`, `logURL(_:)`, `queueLabel(_:)`, `logger(_:)`) so the tests exercise them against +a constructed identity instead of mutating the shared one, which every suite reads in parallel. + Engine-side stores, all `UserDefaults`-backed value types with the same shape: - **`TriggerKeyStore`** (`BlurtTriggerKeyCode`), **`SoundPackStore`** (`BlurtSoundPack`), @@ -791,8 +810,11 @@ Engine-side stores, all `UserDefaults`-backed value types with the same shape: launch reset calls. So adding a store and adding it to every "reset to a clean state" sweep aren't merely the same edit, they're the same line: there is no roster to keep in sync. (It used to be a hand-maintained array, and the forgotten half of that edit happened twice — the overlay origin and - the update-check stamp.) Raw values are the on-disk contract: renaming one abandons every existing - user's setting, so rename cases freely and raw values never. The sweep lives next to the enum + the update-check stamp.) A case's raw value is the _unprefixed_ half of the key: the key actually + written is `HostIdentity.current.defaultsPrefix` + the raw value, which under Blurt's identity is + the `Blurt…` names above and under a third-party host is that host's own namespace. Both halves are + the on-disk contract: changing either abandons every existing user's setting, so rename cases freely + and raw values never. The sweep lives next to the enum (not in the shell) so a reset needing more than a defaults removal has one place to grow and stays inside the test target; the roster itself is internal so no caller re-rolls its own sweep. `SigningIdentityMigration.lastSigningIdentityDefaultsKey` is deliberately **outside** the roster: @@ -806,9 +828,14 @@ Engine-side stores, all `UserDefaults`-backed value types with the same shape: Record cues: **`SoundPack`** is a selectable start/stop chime voice (vintage synth samples; `id` doubles as the bundled stem `-start.m4a` / `-stop.m4a` under -`App/Blurt/Blurt/Resources/Sounds/`), listed by **`SoundPackCatalog.swift`**, which is _generated_ by -`scripts/generate-sounds.swift` alongside the audio. Regenerate both halves together — `check.sh`'s -sound-catalog guard exists because a drift plays silence with no error. **`RecordingCueGate`** is the +`App/Blurt/Blurt/Resources/Sounds/`), and **`SoundPackCatalog`** is the set of them a host supplies — +picker sections, lookup, and the decode-with-default rule `SoundPackStore` and the `@AppStorage` +views share. The engine ships **no voices and no audio**: Blurt's 192 are generated into the app +target (`App/Blurt/Blurt/SoundPackCatalog.swift`) by `scripts/generate-sounds.swift` alongside the +`.m4a` files they name, because a voice list whose cues live in someone else's bundle is a picker in +which every choice plays silence. Regenerate both halves together — `check.sh`'s sound-catalog guard +exists because a drift plays silence with no error — and note that adding or removing a voice changes +the app target's file list, so `xcodegen generate` and a committed `.pbxproj` are part of that edit. **`RecordingCueGate`** is the pure edge detector deciding when the chimes fire — on the `.recording` edge, i.e. once audio is actually flowing, never at the press (see the latency notes above for why that ordering is load-bearing); the AppKit `CueSoundPlayer` just plays what it resolves. diff --git a/App/Blurt/Blurt/App.swift b/App/Blurt/Blurt/App.swift index 861bafa5..3a9b0426 100644 --- a/App/Blurt/Blurt/App.swift +++ b/App/Blurt/Blurt/App.swift @@ -1,9 +1,23 @@ +import BlurtEngine import SwiftUI @main struct BlurtApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + /// Tell the engine which app it is running inside — the Keychain item, the + /// logging subsystem, the `UserDefaults` prefix, the log directory, the product + /// name in update alerts, and the release feed the update check reads. + /// + /// `.blurt` *is* the engine's default, so this call changes nothing about how + /// Blurt behaves. It is here because it is the composition root: the identity + /// belongs to the host, and a fork or a second embedder replaces exactly this + /// one line rather than hunting constants through the engine. Earliest hook a + /// SwiftUI `App` has, which is what `HostIdentity.configure(_:)` asks for. + init() { + HostIdentity.configure(.blurt) + } + var body: some Scene { // Primary window: the setup wizard until the app is fully configured, then // the "ready" screen (see `MainWindowRoot`). diff --git a/App/Blurt/Blurt/AppDelegate.swift b/App/Blurt/Blurt/AppDelegate.swift index 2e4e14bc..e3463588 100644 --- a/App/Blurt/Blurt/AppDelegate.swift +++ b/App/Blurt/Blurt/AppDelegate.swift @@ -215,14 +215,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { lastIdentity: defaults.string(forKey: key), currentIdentity: SigningIdentity.current(), isTrusted: AXIsProcessTrusted(), - // The *running* bundle id, not `BlurtIdentity.subsystem`: debug builds ship + // The *running* bundle id, not `HostIdentity.current.subsystem`: debug builds ship // under `dev.alex.blurt.dev` (see `project.yml`), and resetting the constant // would clear the released Blurt's grant from a dev build — the one app // whose permissions this process has no business touching. The constant is // the fallback for the unreachable case of a bundle with no id at all. reset: { SigningIdentity.resetAccessibilityGrant( - bundleID: Bundle.main.bundleIdentifier ?? BlurtIdentity.subsystem) + bundleID: Bundle.main.bundleIdentifier ?? HostIdentity.current.subsystem) } ) if let persist { defaults.set(persist, forKey: key) } diff --git a/App/Blurt/Blurt/CueSoundPlayer.swift b/App/Blurt/Blurt/CueSoundPlayer.swift index 59deb02d..6b7b0b23 100644 --- a/App/Blurt/Blurt/CueSoundPlayer.swift +++ b/App/Blurt/Blurt/CueSoundPlayer.swift @@ -123,7 +123,7 @@ final class CueSoundPlayer { /// `force` skips the already-loaded short-circuit, for a re-prime where the /// selection is unchanged and only the output route moved. private func loadCurrentPack(force: Bool = false) async { - let pack = SoundPackStore().soundPack + let pack = SoundPackStore(catalog: .blurt).soundPack guard force || pack != loadedPack else { return } loadGeneration += 1 let generation = loadGeneration diff --git a/App/Blurt/Blurt/Hotkey/DictationKeyTap.swift b/App/Blurt/Blurt/Hotkey/DictationKeyTap.swift index 130d9bd4..93a82b34 100644 --- a/App/Blurt/Blurt/Hotkey/DictationKeyTap.swift +++ b/App/Blurt/Blurt/Hotkey/DictationKeyTap.swift @@ -25,8 +25,7 @@ import os /// prove single-threaded access to the router state instead of guarding it with /// a hand-held lock. final class DictationKeyTap { - private static let logger = Logger( - subsystem: BlurtIdentity.subsystem, category: "DictationKeyTap") + private static let logger = HostIdentity.current.logger("DictationKeyTap") private let onStart: @Sendable () -> Void private let onStop: @Sendable () -> Void diff --git a/App/Blurt/Blurt/SoundPackCatalog.swift b/App/Blurt/Blurt/SoundPackCatalog.swift new file mode 100644 index 00000000..edac03e8 --- /dev/null +++ b/App/Blurt/Blurt/SoundPackCatalog.swift @@ -0,0 +1,207 @@ +// Generated by scripts/generate-sounds.swift — do not edit by hand. +// One SoundPack per voice (Yamaha DX7 ROM1A/1B, then Roland Juno-106). + +import BlurtEngine + +extension SoundPackCatalog { + /// Blurt's cue voices. App-side, not engine-side, because the `.m4a` files the + /// ids name live in this target's `Resources/Sounds/` — the same script writes + /// both, and `check.sh` fails if they ever disagree. + static let blurt = SoundPackCatalog(voices: blurtVoices, defaultVoiceID: "rom1a-6") + + /// Every selectable voice, grouped by synth, in render order. + private static let blurtVoices: [SoundPack] = [ + SoundPack(id: "rom1a-0", label: "Brass 1", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-1", label: "Brass 2", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-2", label: "Brass 3", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-3", label: "Strings 1", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-4", label: "Strings 2", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-5", label: "Strings 3", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-6", label: "Orchestra", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-7", label: "Piano 1", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-8", label: "Piano 2", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-9", label: "Piano 3", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-10", label: "E.Piano 1", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-11", label: "Guitar 1", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-12", label: "Guitar 2", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-13", label: "Syn-Lead 1", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-14", label: "Bass 1", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-15", label: "Bass 2", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-16", label: "E.Organ 1", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-17", label: "Pipes 1", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-18", label: "Harpsich 1", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-19", label: "Clav 1", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-20", label: "Vibe 1", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-21", label: "Marimba", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-22", label: "Koto", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-23", label: "Flute 1", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-24", label: "Orch-Chime", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-25", label: "Tub Bells", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-26", label: "Steel Drum", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-27", label: "Timpani", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-28", label: "Refs Whisl", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-29", label: "Voice 1", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-30", label: "Train", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1a-31", label: "Take Off", group: "Yamaha DX7 · ROM1A"), + SoundPack(id: "rom1b-0", label: "Piano 4", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-1", label: "Piano 5", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-2", label: "E.Piano 2", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-3", label: "E.Piano 3", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-4", label: "E.Piano 4", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-5", label: "Piano 5ths", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-6", label: "Celeste", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-7", label: "Toy Piano", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-8", label: "Harpsich 2", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-9", label: "Harpsich 3", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-10", label: "Clav 2", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-11", label: "Clav 3", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-12", label: "E.Organ 2", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-13", label: "E.Organ 3", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-14", label: "E.Organ 4", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-15", label: "E.Organ 5", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-16", label: "Pipes 2", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-17", label: "Pipes 3", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-18", label: "Pipes 4", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-19", label: "Caliope", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-20", label: "Accordion", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-21", label: "Sitar", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-22", label: "Guitar 3", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-23", label: "Guitar 4", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-24", label: "Guitar 5", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-25", label: "Guitar 6", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-26", label: "Lute", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-27", label: "Banjo", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-28", label: "Harp 1", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-29", label: "Harp 2", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-30", label: "Bass 3", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "rom1b-31", label: "Bass 4", group: "Yamaha DX7 · ROM1B"), + SoundPack(id: "juno-0", label: "Brass", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-1", label: "Brass Swell", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-2", label: "Trumpet", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-3", label: "Flutes", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-4", label: "Moving Strings", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-5", label: "Brass & Strings", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-6", label: "Choir", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-7", label: "Piano I", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-8", label: "Organ I", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-9", label: "Organ II", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-10", label: "Combo Organ", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-11", label: "Calliope", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-12", label: "Donald Pluck", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-13", label: "Celeste", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-14", label: "E Piano I", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-15", label: "E Piano II", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-16", label: "Clock Chimes", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-17", label: "Steel Drums", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-18", label: "Xylophone", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-19", label: "Brass III", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-20", label: "Fanfare", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-21", label: "Strings III", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-22", label: "Pizzicato", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-23", label: "High Strings", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-24", label: "Bass Clarinet", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-25", label: "English Horn", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-26", label: "Brass Ensemble", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-27", label: "Guitar", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-28", label: "Koto", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-29", label: "Dark Pluck", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-30", label: "Funky I", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-31", label: "Synth Bass I", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-32", label: "Lead I", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-33", label: "Lead II", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-34", label: "Lead III", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-35", label: "Funky II", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-36", label: "Synth Bass II", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-37", label: "Funky III", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-38", label: "Thud Wah", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-39", label: "Going Up", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-40", label: "Piano II", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-41", label: "Clav.", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-42", label: "Frontier Organ", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-43", label: "Snare Drum", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-44", label: "Tom Toms", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-45", label: "Timpani", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-46", label: "Shaker", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-47", label: "Synth Pad", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-48", label: "Sweep I", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-49", label: "Pluck Sweep", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-50", label: "Repeater", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-51", label: "Sweep II", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-52", label: "Pluck Bell", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-53", label: "Dark Synth Piano", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-54", label: "Sustainer", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-55", label: "Wah Release", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-56", label: "Gong", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-57", label: "Resonance Funk", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-58", label: "Drum Booms", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-59", label: "Dust Storm", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-60", label: "Rocket Men", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-61", label: "Hand Claps", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-62", label: "FX Sweep", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-63", label: "Caverns", group: "Roland Juno-106 · A"), + SoundPack(id: "juno-64", label: "Strings", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-65", label: "Violin", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-66", label: "Chorus Vibes", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-67", label: "Organ I", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-68", label: "Harpsichord I", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-69", label: "Recorder", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-70", label: "Perc Pluck", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-71", label: "Noise Sweep", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-72", label: "Space Chimes", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-73", label: "Nylon Guitar", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-74", label: "Orchestral Pad", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-75", label: "Bright Pluck", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-76", label: "Organ Bell", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-77", label: "Accordion", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-78", label: "FX Rise 1", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-79", label: "FX Rise 2", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-80", label: "Brass", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-81", label: "Helicopter", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-82", label: "Lute", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-83", label: "Chorus Funk", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-84", label: "Tomita", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-85", label: "FX Sweep 1", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-86", label: "Sharp Reed", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-87", label: "Bass Pluck", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-88", label: "Resonant Rise", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-89", label: "Harpsichord 2", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-90", label: "Dark Ensemble", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-91", label: "Contact Wah", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-92", label: "Noise Sweep 2", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-93", label: "Glassy Wah", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-94", label: "Phase Ensemble", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-95", label: "Chorused Bell", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-96", label: "Clav", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-97", label: "Organ 2", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-98", label: "Bassoon", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-99", label: "Auto Noise Sweep", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-100", label: "Brass Ensemble", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-101", label: "Ethereal", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-102", label: "Chorus Bell 2", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-103", label: "Blizzard", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-104", label: "E Piano Trem", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-105", label: "Clarinet", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-106", label: "Thunder", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-107", label: "Reedy Organ", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-108", label: "Flute / Horn", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-109", label: "Toy Rhodes", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-110", label: "Surf's Up", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-111", label: "OW Bass", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-112", label: "Piccolo", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-113", label: "Melodic Taps", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-114", label: "Meow Bass", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-115", label: "Violin (high)", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-116", label: "High Bells", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-117", label: "Rolling Wah", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-118", label: "Ping Bell", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-119", label: "Brassy Organ", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-120", label: "Low Dark Strings", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-121", label: "Piccolo Trumpet", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-122", label: "Cello", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-123", label: "High Strings", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-124", label: "Rocket Men", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-125", label: "Forbidden Planet", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-126", label: "Froggy", group: "Roland Juno-106 · B"), + SoundPack(id: "juno-127", label: "Owgan", group: "Roland Juno-106 · B"), + ] +} diff --git a/App/Blurt/Blurt/Update/UpdateCheckModel.swift b/App/Blurt/Blurt/Update/UpdateCheckModel.swift index 864a8182..2e6f34cd 100644 --- a/App/Blurt/Blurt/Update/UpdateCheckModel.swift +++ b/App/Blurt/Blurt/Update/UpdateCheckModel.swift @@ -38,7 +38,7 @@ final class UpdateCheckModel { /// The clock, injected alongside the store so the throttle is drivable in a /// test without waiting a day. private let now: () -> Date - private let log = Logger(subsystem: BlurtIdentity.subsystem, category: "update") + private let log = HostIdentity.current.logger("update") /// Guards against a second check while one is in flight (double-click, or the /// button and menu both fired), so we never stack two result alerts. Observable diff --git a/App/Blurt/Blurt/Wizard/Steps/SoundStepView.swift b/App/Blurt/Blurt/Wizard/Steps/SoundStepView.swift index 3ed830cb..73f11aa6 100644 --- a/App/Blurt/Blurt/Wizard/Steps/SoundStepView.swift +++ b/App/Blurt/Blurt/Wizard/Steps/SoundStepView.swift @@ -8,20 +8,20 @@ struct SoundStepView: View { var coordinator: AppCoordinator // Empty means "no pack persisted" — the unset default belongs to - // `SoundPack.fromPersisted` (below), which names no known pack for `""` and so - // resolves `defaultPack`. See `HotkeyStepView` for why the view must not restate - // it. + // `SoundPackCatalog.fromPersisted` (below), which names no known voice for `""` + // and so resolves the catalog's default. See `HotkeyStepView` for why the view + // must not restate it. @AppStorage(SoundPackStore.defaultsKey) private var soundPackID = "" private var selection: Binding { Binding( get: { - SoundPack.fromPersisted(soundPackID) + SoundPackCatalog.blurt.fromPersisted(soundPackID) }, set: { newValue in // Write through the store (see `HotkeyStepView` for why): the store owns the // encoding, `@AppStorage` observes the key to re-render. - SoundPackStore().soundPack = newValue + SoundPackStore(catalog: .blurt).soundPack = newValue coordinator.soundPackChanged() }) } @@ -33,9 +33,9 @@ struct SoundStepView: View { accessibilityID: UITestIdentifiers.soundPicker, selection: selection ) { Text(SoundPack.none.label).tag(SoundPack.none) - ForEach(SoundPack.groups, id: \.self) { group in + ForEach(SoundPackCatalog.blurt.groups, id: \.self) { group in Section(group) { - ForEach(SoundPack.voices(in: group)) { pack in + ForEach(SoundPackCatalog.blurt.voices(in: group)) { pack in Text(pack.label).tag(pack) } } diff --git a/Sources/BlurtEngine/Audio/AudioRouteMonitor.swift b/Sources/BlurtEngine/Audio/AudioRouteMonitor.swift index 4d17d1d9..a1a24fa3 100644 --- a/Sources/BlurtEngine/Audio/AudioRouteMonitor.swift +++ b/Sources/BlurtEngine/Audio/AudioRouteMonitor.swift @@ -29,7 +29,7 @@ import os /// `@unchecked Sendable` because the listener registrations below are confined /// to `queue` rather than protected by a lock — see their declarations. public final class AudioRouteMonitor: @unchecked Sendable { - private static let logger = Logger(subsystem: BlurtIdentity.subsystem, category: "AudioRoute") + private static let logger = HostIdentity.current.logger("AudioRoute") /// Fires once per observed route change. `.bufferingNewest(1)` because this is /// an invalidation signal, not a log: a consumer that was busy through three @@ -58,7 +58,7 @@ public final class AudioRouteMonitor: @unchecked Sendable { let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) self.outputRouteChanges = stream self.continuation = continuation - self.queue = DispatchQueue(label: "\(BlurtIdentity.subsystem).AudioRoute") + self.queue = DispatchQueue(label: HostIdentity.current.queueLabel("AudioRoute")) queue.sync { installDefaultDeviceListener() retargetFormatListener() diff --git a/Sources/BlurtEngine/Audio/MicCapture.swift b/Sources/BlurtEngine/Audio/MicCapture.swift index 5d7230c7..07fba0d8 100644 --- a/Sources/BlurtEngine/Audio/MicCapture.swift +++ b/Sources/BlurtEngine/Audio/MicCapture.swift @@ -15,7 +15,7 @@ public actor MicCapture: MicCaptureProtocol { // log show --predicate 'subsystem == "dev.alex.blurt"' --last 1h // Stderr is unreachable for .app bundles launched via Finder/LaunchServices, // so go through the unified logging system instead. - static let logger = Logger(subsystem: BlurtIdentity.subsystem, category: "MicCapture") + static let logger = HostIdentity.current.logger("MicCapture") public nonisolated let levels: AsyncStream private nonisolated let levelsContinuation: AsyncStream.Continuation diff --git a/Sources/BlurtEngine/Audio/SoundPack.swift b/Sources/BlurtEngine/Audio/SoundPack.swift index f0651b85..31cf5412 100644 --- a/Sources/BlurtEngine/Audio/SoundPack.swift +++ b/Sources/BlurtEngine/Audio/SoundPack.swift @@ -1,95 +1,47 @@ -/// A selectable record-start/stop cue voice. Every voice ships from an authentic -/// vintage synth — Yamaha DX7 (ROM1A/ROM1B) and Roland Juno-106 (KR-106 factory -/// presets); `SoundPackCatalog.swift` (generated by `scripts/generate-sounds.swift`) -/// supplies the full list. `id` doubles as the bundled cue stem: `-start.m4a` -/// / `-stop.m4a` under `Resources/Sounds/`. The `.none` pack plays nothing. +/// A selectable record-start/stop cue voice: an `id`, a display `label`, and the +/// picker `group` it belongs to. `id` doubles as the cue stem the host resolves +/// against its own bundle: `-start` / `-stop`. The `.none` pack plays +/// nothing. +/// +/// **The engine ships no voices and no audio.** A `SoundPack` is a descriptor the +/// host supplies — Blurt's 192 vintage-synth voices are generated by +/// `scripts/generate-sounds.swift` into the app target, next to the `.m4a` cues +/// they name (`App/Blurt/Blurt/SoundPackCatalog.swift`). That split is the point: +/// a catalog whose files aren't in the same bundle resolves to silence, so the +/// list and the audio ship together or not at all. Collect them into a +/// `SoundPackCatalog` to get the picker sections, lookup, and the +/// decode-with-default rule. public struct SoundPack: Sendable, Hashable, Identifiable { public let id: String - /// The voice's name, e.g. "ORCHESTRA" or "A11 Brass" — shown in the picker. + /// The voice's name, e.g. "Orchestra" or "A11 Brass" — shown in the picker. public let label: String /// The picker section this voice belongs to, e.g. "Yamaha DX7 · ROM1A" or /// "Roland Juno-106 · A". nil for `.none`. public let group: String? - /// The "no sound" choice. + /// Public because a host builds its own catalog: this is how voices are + /// declared from outside the module. + public init(id: String, label: String, group: String?) { + self.id = id + self.label = label + self.group = group + } + + /// The "no sound" choice. Not part of any catalog — `SoundPackCatalog` keeps it + /// reachable no matter what voices it was handed, so "silence the cues" is + /// never a choice a host's catalog can take away. public static let none = SoundPack(id: "none", label: "None", group: nil) - /// True for the `.none` pack — the one voice with no bundled cues and no synth + /// True for the `.none` pack — the one voice with no cue files and no synth /// credit. A catalog entry always carries a `group` (its picker section), so its /// absence is what marks the silent pack. Internal: the app asks via /// `startFileName`/`stopFileName`, so exporting this would only trip /// periphery's redundant-public check. var isSilent: Bool { group == nil } - /// Bundled cue stem for the start cue, or nil when no sound plays. + /// Cue stem for the start cue, or nil when no sound plays. public var startFileName: String? { isSilent ? nil : "\(id)-start" } - /// Bundled cue stem for the stop cue, or nil when no sound plays. + /// Cue stem for the stop cue, or nil when no sound plays. public var stopFileName: String? { isSilent ? nil : "\(id)-stop" } - - /// `.none` plus the full catalog, keyed by `id`. Private: the picker builds - /// itself from `.none` + `groups` + `voices(in:)`, so this exists only to back - /// `find(id:)`. Stored, not computed: `find` runs from a `Binding` getter on - /// every settings render and from `CueSoundPlayer`'s pack load, and a computed - /// version rebuilt a ~190-element array and scanned it linearly each time. - private static let byID: [String: SoundPack] = { - var index = [SoundPack.none.id: SoundPack.none] - for pack in catalog { - index[pack.id] = pack - } - return index - }() - - /// The default pack: ORCHESTRA (Yamaha DX7 ROM1A voice 6), falling back to - /// `.none` only if the catalog is somehow empty. - /// - /// Internal for the same reason as `isSilent` and `find(id:)`: the app reaches the - /// default only through `fromPersisted` — restating it view-side is exactly what - /// `SoundStepView` was corrected not to do — so exporting it would just trip - /// periphery's redundant-public check. - static let defaultPack: SoundPack = catalog.first { $0.id == "rom1a-6" } ?? .none - - /// Looks a pack up by its persisted `id`. Internal for the same reason as - /// `isSilent`: the app reaches packs through `fromPersisted`, so exporting this - /// would only trip periphery's redundant-public check. - static func find(id: String) -> SoundPack? { byID[id] } - - /// Decodes a persisted pack id, falling back to `defaultPack` when the id is - /// unset or names no known pack. The single decode-with-default rule shared by - /// `SoundPackStore` and the `@AppStorage` views that read the raw id directly - /// (so they re-render live on a Settings change) — mirroring - /// `TriggerKey.fromPersisted`. - public static func fromPersisted(_ id: String?) -> SoundPack { - guard let id, let pack = find(id: id) else { return .defaultPack } - return pack - } - - /// Distinct group names, in catalog order — the picker's sections. Dedupes - /// through a `Set` (`insert(_:).inserted` as the filter, matching - /// `KeyTermsStore.parse`) rather than an array `contains` scan per element. - /// Stored for the same reason as `byID`: the picker reads it, then `voices(in:)` - /// per group, on every settings render. - public static let groups: [String] = { - var seen = Set() - return catalog.compactMap { pack -> String? in - guard let group = pack.group, seen.insert(group).inserted else { return nil } - return group - } - }() - - /// The voices belonging to one group, in catalog order. Backed by a stored index - /// so the picker's per-group read is a dictionary hit rather than a full-catalog - /// filter per section. - public static func voices(in group: String) -> [SoundPack] { - voicesByGroup[group] ?? [] - } - - private static let voicesByGroup: [String: [SoundPack]] = { - var index: [String: [SoundPack]] = [:] - for pack in catalog { - guard let group = pack.group else { continue } - index[group, default: []].append(pack) - } - return index - }() } diff --git a/Sources/BlurtEngine/Audio/SoundPackCatalog.swift b/Sources/BlurtEngine/Audio/SoundPackCatalog.swift index 042c6d71..2112bd2c 100644 --- a/Sources/BlurtEngine/Audio/SoundPackCatalog.swift +++ b/Sources/BlurtEngine/Audio/SoundPackCatalog.swift @@ -1,200 +1,89 @@ -// Generated by scripts/generate-sounds.swift — do not edit by hand. -// One SoundPack per voice (Yamaha DX7 ROM1A/1B, then Roland Juno-106). +/// A host's set of selectable cue voices: the picker's sections, the lookup +/// behind a persisted id, and which voice an unset setting means. +/// +/// The engine ships **no** catalog. It used to — 192 generated voices, whose +/// `startFileName`/`stopFileName` named `.m4a` files that live in Blurt's app +/// bundle and nowhere in the package — so a third-party consumer got a full +/// picker in which every choice played silence. The voices and the audio are one +/// artifact (`scripts/generate-sounds.swift` writes both, and `check.sh` pins +/// that they agree), and that artifact belongs to whoever ships the bundle. Blurt +/// builds `SoundPackCatalog.blurt` in the app target; `CueSoundPlayer` resolves +/// the stems against `Bundle.main`. +/// +/// A value rather than a set of statics on `SoundPack`, so nothing about "which +/// voices exist" is process-global: a host with two catalogs (a preview picker, a +/// test fixture) constructs two. +public struct SoundPackCatalog: Sendable { + /// Distinct group names, in catalog order — the picker's sections. Deduped + /// through a `Set` (`insert(_:).inserted` as the filter, matching + /// `KeyTermsStore.parse`) rather than an array `contains` scan per element, and + /// resolved once at construction: the picker reads it, then `voices(in:)` per + /// group, on every settings render. + public let groups: [String] -extension SoundPack { - /// Every selectable voice, grouped by synth, in render order. - public static let catalog: [SoundPack] = [ - SoundPack(id: "rom1a-0", label: "Brass 1", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-1", label: "Brass 2", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-2", label: "Brass 3", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-3", label: "Strings 1", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-4", label: "Strings 2", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-5", label: "Strings 3", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-6", label: "Orchestra", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-7", label: "Piano 1", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-8", label: "Piano 2", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-9", label: "Piano 3", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-10", label: "E.Piano 1", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-11", label: "Guitar 1", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-12", label: "Guitar 2", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-13", label: "Syn-Lead 1", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-14", label: "Bass 1", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-15", label: "Bass 2", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-16", label: "E.Organ 1", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-17", label: "Pipes 1", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-18", label: "Harpsich 1", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-19", label: "Clav 1", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-20", label: "Vibe 1", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-21", label: "Marimba", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-22", label: "Koto", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-23", label: "Flute 1", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-24", label: "Orch-Chime", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-25", label: "Tub Bells", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-26", label: "Steel Drum", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-27", label: "Timpani", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-28", label: "Refs Whisl", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-29", label: "Voice 1", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-30", label: "Train", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1a-31", label: "Take Off", group: "Yamaha DX7 · ROM1A"), - SoundPack(id: "rom1b-0", label: "Piano 4", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-1", label: "Piano 5", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-2", label: "E.Piano 2", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-3", label: "E.Piano 3", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-4", label: "E.Piano 4", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-5", label: "Piano 5ths", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-6", label: "Celeste", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-7", label: "Toy Piano", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-8", label: "Harpsich 2", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-9", label: "Harpsich 3", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-10", label: "Clav 2", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-11", label: "Clav 3", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-12", label: "E.Organ 2", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-13", label: "E.Organ 3", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-14", label: "E.Organ 4", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-15", label: "E.Organ 5", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-16", label: "Pipes 2", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-17", label: "Pipes 3", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-18", label: "Pipes 4", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-19", label: "Caliope", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-20", label: "Accordion", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-21", label: "Sitar", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-22", label: "Guitar 3", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-23", label: "Guitar 4", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-24", label: "Guitar 5", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-25", label: "Guitar 6", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-26", label: "Lute", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-27", label: "Banjo", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-28", label: "Harp 1", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-29", label: "Harp 2", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-30", label: "Bass 3", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "rom1b-31", label: "Bass 4", group: "Yamaha DX7 · ROM1B"), - SoundPack(id: "juno-0", label: "Brass", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-1", label: "Brass Swell", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-2", label: "Trumpet", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-3", label: "Flutes", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-4", label: "Moving Strings", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-5", label: "Brass & Strings", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-6", label: "Choir", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-7", label: "Piano I", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-8", label: "Organ I", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-9", label: "Organ II", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-10", label: "Combo Organ", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-11", label: "Calliope", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-12", label: "Donald Pluck", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-13", label: "Celeste", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-14", label: "E Piano I", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-15", label: "E Piano II", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-16", label: "Clock Chimes", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-17", label: "Steel Drums", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-18", label: "Xylophone", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-19", label: "Brass III", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-20", label: "Fanfare", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-21", label: "Strings III", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-22", label: "Pizzicato", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-23", label: "High Strings", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-24", label: "Bass Clarinet", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-25", label: "English Horn", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-26", label: "Brass Ensemble", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-27", label: "Guitar", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-28", label: "Koto", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-29", label: "Dark Pluck", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-30", label: "Funky I", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-31", label: "Synth Bass I", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-32", label: "Lead I", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-33", label: "Lead II", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-34", label: "Lead III", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-35", label: "Funky II", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-36", label: "Synth Bass II", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-37", label: "Funky III", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-38", label: "Thud Wah", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-39", label: "Going Up", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-40", label: "Piano II", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-41", label: "Clav.", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-42", label: "Frontier Organ", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-43", label: "Snare Drum", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-44", label: "Tom Toms", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-45", label: "Timpani", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-46", label: "Shaker", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-47", label: "Synth Pad", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-48", label: "Sweep I", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-49", label: "Pluck Sweep", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-50", label: "Repeater", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-51", label: "Sweep II", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-52", label: "Pluck Bell", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-53", label: "Dark Synth Piano", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-54", label: "Sustainer", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-55", label: "Wah Release", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-56", label: "Gong", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-57", label: "Resonance Funk", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-58", label: "Drum Booms", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-59", label: "Dust Storm", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-60", label: "Rocket Men", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-61", label: "Hand Claps", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-62", label: "FX Sweep", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-63", label: "Caverns", group: "Roland Juno-106 · A"), - SoundPack(id: "juno-64", label: "Strings", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-65", label: "Violin", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-66", label: "Chorus Vibes", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-67", label: "Organ I", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-68", label: "Harpsichord I", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-69", label: "Recorder", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-70", label: "Perc Pluck", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-71", label: "Noise Sweep", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-72", label: "Space Chimes", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-73", label: "Nylon Guitar", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-74", label: "Orchestral Pad", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-75", label: "Bright Pluck", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-76", label: "Organ Bell", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-77", label: "Accordion", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-78", label: "FX Rise 1", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-79", label: "FX Rise 2", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-80", label: "Brass", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-81", label: "Helicopter", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-82", label: "Lute", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-83", label: "Chorus Funk", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-84", label: "Tomita", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-85", label: "FX Sweep 1", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-86", label: "Sharp Reed", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-87", label: "Bass Pluck", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-88", label: "Resonant Rise", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-89", label: "Harpsichord 2", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-90", label: "Dark Ensemble", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-91", label: "Contact Wah", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-92", label: "Noise Sweep 2", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-93", label: "Glassy Wah", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-94", label: "Phase Ensemble", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-95", label: "Chorused Bell", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-96", label: "Clav", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-97", label: "Organ 2", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-98", label: "Bassoon", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-99", label: "Auto Noise Sweep", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-100", label: "Brass Ensemble", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-101", label: "Ethereal", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-102", label: "Chorus Bell 2", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-103", label: "Blizzard", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-104", label: "E Piano Trem", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-105", label: "Clarinet", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-106", label: "Thunder", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-107", label: "Reedy Organ", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-108", label: "Flute / Horn", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-109", label: "Toy Rhodes", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-110", label: "Surf's Up", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-111", label: "OW Bass", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-112", label: "Piccolo", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-113", label: "Melodic Taps", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-114", label: "Meow Bass", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-115", label: "Violin (high)", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-116", label: "High Bells", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-117", label: "Rolling Wah", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-118", label: "Ping Bell", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-119", label: "Brassy Organ", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-120", label: "Low Dark Strings", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-121", label: "Piccolo Trumpet", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-122", label: "Cello", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-123", label: "High Strings", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-124", label: "Rocket Men", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-125", label: "Forbidden Planet", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-126", label: "Froggy", group: "Roland Juno-106 · B"), - SoundPack(id: "juno-127", label: "Owgan", group: "Roland Juno-106 · B"), - ] + /// The voice an unset (or unrecognized) setting resolves to. Internal for the + /// same reason as `find(id:)`: hosts reach it through `fromPersisted`, and + /// restating the default view-side is exactly what `SoundStepView` was + /// corrected not to do. + let defaultPack: SoundPack + + /// Backs `find(id:)`. Stored, not computed: `find` runs from a `Binding` getter + /// on every settings render and from `CueSoundPlayer`'s pack load, and a + /// computed version rebuilt the index and scanned it linearly each time. + private let byID: [String: SoundPack] + + /// Backs `voices(in:)` so the picker's per-group read is a dictionary hit + /// rather than a full-catalog filter per section. + private let voicesByGroup: [String: [SoundPack]] + + /// Builds a catalog from `voices`, resolving `defaultVoiceID` to the voice an + /// unset setting means — falling back to `SoundPack.none` when nothing in + /// `voices` carries that id, so a typo silences the cues rather than trapping. + /// + /// `SoundPack.none` is added to the lookup **after** the voices, so a voice + /// that (wrongly) claims the reserved id `none` loses to the silent pack rather + /// than making "no sound" unselectable. `check.sh`'s catalog check flags that + /// collision in this repo; the ordering is what keeps a host that has no such + /// check from losing the silence choice. + public init(voices: [SoundPack], defaultVoiceID: String) { + var seenGroups = Set() + groups = voices.compactMap { voice -> String? in + guard let group = voice.group, seenGroups.insert(group).inserted else { return nil } + return group + } + + var index: [String: SoundPack] = [:] + var byGroup: [String: [SoundPack]] = [:] + for voice in voices { + index[voice.id] = voice + guard let group = voice.group else { continue } + byGroup[group, default: []].append(voice) + } + // Spelled `SoundPack.none`, never `.none`: the expected type here is + // `SoundPack?`, where a leading-dot `.none` is `Optional.none` — i.e. it would + // *remove* the silent pack rather than install it. + index[SoundPack.none.id] = SoundPack.none + byID = index + voicesByGroup = byGroup + + defaultPack = index[defaultVoiceID] ?? SoundPack.none + } + + /// The voices belonging to one group, in catalog order. + public func voices(in group: String) -> [SoundPack] { voicesByGroup[group] ?? [] } + + /// Looks a voice up by its persisted `id`. Internal: hosts reach voices through + /// `fromPersisted`, so exporting this would only trip periphery's + /// redundant-public check. + func find(id: String) -> SoundPack? { byID[id] } + + /// Decodes a persisted pack id, falling back to `defaultPack` when the id is + /// unset or names no voice in this catalog. The single decode-with-default rule + /// shared by `SoundPackStore` and the `@AppStorage` views that read the raw id + /// directly (so they re-render live on a Settings change) — mirroring + /// `TriggerKey.fromPersisted`. + public func fromPersisted(_ id: String?) -> SoundPack { + guard let id, let pack = find(id: id) else { return defaultPack } + return pack + } } diff --git a/Sources/BlurtEngine/Audio/SoundPackStore.swift b/Sources/BlurtEngine/Audio/SoundPackStore.swift index 719d1aca..7776bde0 100644 --- a/Sources/BlurtEngine/Audio/SoundPackStore.swift +++ b/Sources/BlurtEngine/Audio/SoundPackStore.swift @@ -1,21 +1,31 @@ import Foundation /// Persists the chosen `SoundPack` (by its `id`) in `UserDefaults`. Defaults to -/// `SoundPack.defaultPack` when unset or when the stored id isn't a known pack. -/// Same shape as `TriggerKeyStore`. +/// the catalog's default voice when unset or when the stored id isn't one of its +/// voices. Same shape as `TriggerKeyStore`, plus the catalog — which the host +/// owns, since the engine ships no voices (see `SoundPackCatalog`). +/// +/// The store stays in the engine even though the catalog left it, because its key +/// is a `DefaultsKey` case and that enum is the single roster +/// `PersistedSettings.resetAll` sweeps. Moving persistence host-side would have +/// stranded the sound setting outside every reset — the exact bug the roster was +/// created to prevent, twice. public struct SoundPackStore { /// UserDefaults key holding the selected pack id. Public so SwiftUI views can /// observe it directly (e.g. `@AppStorage`) and re-render on change. - public static let defaultsKey = DefaultsKey.soundPack.rawValue + public static var defaultsKey: String { DefaultsKey.soundPack.key } + + private let catalog: SoundPackCatalog private let defaults: UserDefaults - public init(defaults: UserDefaults = .standard) { + public init(catalog: SoundPackCatalog, defaults: UserDefaults = .standard) { + self.catalog = catalog self.defaults = defaults } public var soundPack: SoundPack { get { - SoundPack.fromPersisted(defaults.string(forKey: Self.defaultsKey)) + catalog.fromPersisted(defaults.string(forKey: Self.defaultsKey)) } nonmutating set { defaults.set(newValue.id, forKey: Self.defaultsKey) diff --git a/Sources/BlurtEngine/BlurtIdentity.swift b/Sources/BlurtEngine/BlurtIdentity.swift deleted file mode 100644 index cf811f38..00000000 --- a/Sources/BlurtEngine/BlurtIdentity.swift +++ /dev/null @@ -1,23 +0,0 @@ -/// The single definition of Blurt's reverse-DNS identity string. Shared by -/// every unified-logging subsystem and derived keys so the documented -/// log-discovery predicates can't drift between components. -public enum BlurtIdentity { - /// Reverse-DNS identity ("dev.alex.blurt"): the logging subsystem and the - /// prefix for derived keys/labels. Must match `BUNDLE_ID` in - /// `scripts/reset-install.sh`, which hard-codes the same value for its - /// `defaults`/`tccutil` cleanup (bash can't read this constant). - /// - /// It is also the **release** bundle id, but is not interchangeable with the - /// running one: debug builds ship under `dev.alex.blurt.dev` so a dev install - /// is a separate app to macOS (see `project.yml`). One log subsystem across - /// both is deliberate — the documented `log show` predicates stay valid - /// whichever build is running — but anything addressing *this process's* - /// container, defaults domain or TCC records must read - /// `Bundle.main.bundleIdentifier` instead. - public static let subsystem = "dev.alex.blurt" - - /// The Keychain service for the API key item: the plain app name, so the - /// entry appears as "blurt" in Keychain Access instead of a developer-domain - /// string. Must match `KEYCHAIN_SERVICE` in `scripts/reset-install.sh`. - public static let keychainService = "blurt" -} diff --git a/Sources/BlurtEngine/Config/APIKeyStore.swift b/Sources/BlurtEngine/Config/APIKeyStore.swift index ff93ffc2..ff8f802b 100644 --- a/Sources/BlurtEngine/Config/APIKeyStore.swift +++ b/Sources/BlurtEngine/Config/APIKeyStore.swift @@ -18,7 +18,7 @@ public enum APIKeyStore { /// their own `MemoizedKeyStore`, and `KeychainStoreTests` uses an isolated /// service/account — so the real key is never read or written by a test run. private static let memo = MemoizedKeyStore( - keychain: KeychainStore(service: BlurtIdentity.keychainService, account: "AssemblyAIAPIKey")) + keychain: KeychainStore(service: HostIdentity.current.keychainService, account: "AssemblyAIAPIKey")) /// The stored key, or `nil` if none has been saved (or it's empty). public static var current: String? { memo.current } diff --git a/Sources/BlurtEngine/Config/CustomStyleStore.swift b/Sources/BlurtEngine/Config/CustomStyleStore.swift index 78878f8d..1fde7a9f 100644 --- a/Sources/BlurtEngine/Config/CustomStyleStore.swift +++ b/Sources/BlurtEngine/Config/CustomStyleStore.swift @@ -17,7 +17,7 @@ import Foundation public struct CustomStyleStore { /// `UserDefaults` key for the raw text the user typed. Public so the Settings /// field can bind `@AppStorage` to it. - public static let defaultsKey = DefaultsKey.customStyle.rawValue + public static var defaultsKey: String { DefaultsKey.customStyle.key } /// The most UTF-8 bytes the Settings field accepts — /// `CleanupInstruction.customStyleBudget`, the real headroom the dictation diff --git a/Sources/BlurtEngine/Config/DefaultsKey.swift b/Sources/BlurtEngine/Config/DefaultsKey.swift index 0f04e621..a39e76bb 100644 --- a/Sources/BlurtEngine/Config/DefaultsKey.swift +++ b/Sources/BlurtEngine/Config/DefaultsKey.swift @@ -14,20 +14,36 @@ /// /// A key that is *not* a user setting stays out of this enum on purpose — see /// `SigningIdentityMigration.lastSigningIdentityDefaultsKey`, which records what the -/// TCC migration already did. Those also don't carry the `Blurt` prefix every case +/// TCC migration already did. Those also don't carry the host prefix every case /// here does, and `DefaultsKeyTests` pins that so the distinction stays visible. /// -/// Raw values are the on-disk contract: **renaming one silently abandons every -/// existing user's setting**, so change a case name freely and its raw value never. +/// Raw values are the **unprefixed** half of the on-disk contract: the key actually +/// written is `key`, the host identity's `defaultsPrefix` followed by the raw value, +/// so Blurt still writes `BlurtSoundPack` and a host that configures its own prefix +/// gets its own namespace instead of writing into Blurt's. **Renaming a raw value +/// silently abandons every existing user's setting**, so change a case name freely +/// and its raw value never — and see `HostIdentity.defaultsPrefix` for why the +/// prefix carries the same warning. enum DefaultsKey: String, CaseIterable { - case triggerKeyCode = "BlurtTriggerKeyCode" - case soundPack = "BlurtSoundPack" - case keyTerms = "BlurtKeyTerms" - case developerMode = "BlurtDeveloperMode" - case enhancedTranscripts = "BlurtEnhancedTranscripts" - case customStyle = "BlurtCustomStyle" + case triggerKeyCode = "TriggerKeyCode" + case soundPack = "SoundPack" + case keyTerms = "KeyTerms" + case developerMode = "DeveloperMode" + case enhancedTranscripts = "EnhancedTranscripts" + case customStyle = "CustomStyle" /// `OverlayOriginStore` persists a point, so it owns two keys rather than one. - case overlayCustomOriginX = "BlurtOverlayCustomOriginX" - case overlayCustomOriginY = "BlurtOverlayCustomOriginY" - case lastUpdateCheck = "BlurtLastUpdateCheck" + case overlayCustomOriginX = "OverlayCustomOriginX" + case overlayCustomOriginY = "OverlayCustomOriginY" + case lastUpdateCheck = "LastUpdateCheck" + + /// The key this case actually reads and writes, under the configured host + /// identity. A computed property rather than a stored string because the + /// identity is only known once the host has configured it — and because it is + /// then fixed for the process, so this resolves to the same value every time. + var key: String { key(in: .current) } + + /// The key this case would write under `identity`. Split out so the tests can + /// pin the composition against a value instead of the process-wide identity, + /// which every other suite is reading concurrently. + func key(in identity: HostIdentity) -> String { identity.defaultsKey(rawValue) } } diff --git a/Sources/BlurtEngine/Config/DeveloperModeStore.swift b/Sources/BlurtEngine/Config/DeveloperModeStore.swift index 1dbfaeca..708078df 100644 --- a/Sources/BlurtEngine/Config/DeveloperModeStore.swift +++ b/Sources/BlurtEngine/Config/DeveloperModeStore.swift @@ -8,7 +8,7 @@ import Foundation public struct DeveloperModeStore { /// UserDefaults key holding the switch. Public so SwiftUI views can observe /// it directly (e.g. `@AppStorage`) and re-render on change. - public static let defaultsKey = DefaultsKey.developerMode.rawValue + public static var defaultsKey: String { DefaultsKey.developerMode.key } private let defaults: UserDefaults init(defaults: UserDefaults = .standard) { diff --git a/Sources/BlurtEngine/Config/EnhancedTranscriptsStore.swift b/Sources/BlurtEngine/Config/EnhancedTranscriptsStore.swift index 8bd1cd49..6df79fcd 100644 --- a/Sources/BlurtEngine/Config/EnhancedTranscriptsStore.swift +++ b/Sources/BlurtEngine/Config/EnhancedTranscriptsStore.swift @@ -11,7 +11,7 @@ import Foundation public struct EnhancedTranscriptsStore { /// UserDefaults key holding the switch. Public so SwiftUI views can observe /// it directly (e.g. `@AppStorage`) and re-render on change. - public static let defaultsKey = DefaultsKey.enhancedTranscripts.rawValue + public static var defaultsKey: String { DefaultsKey.enhancedTranscripts.key } /// The value an unset key reads as. Public so the Settings toggle's `@AppStorage` /// default comes from here instead of restating `true` — the view and the diff --git a/Sources/BlurtEngine/Config/KeyTermsStore.swift b/Sources/BlurtEngine/Config/KeyTermsStore.swift index 363d8e50..a5f4de39 100644 --- a/Sources/BlurtEngine/Config/KeyTermsStore.swift +++ b/Sources/BlurtEngine/Config/KeyTermsStore.swift @@ -28,7 +28,7 @@ public struct KeyTermsStore { /// Public so the Settings field can bind `@AppStorage` to it and so the app /// can clear it when resetting to a clean state under UI testing (matching /// `TriggerKeyStore`/`SoundPackStore`). - public static let defaultsKey = DefaultsKey.keyTerms.rawValue + public static var defaultsKey: String { DefaultsKey.keyTerms.key } private let defaults: UserDefaults diff --git a/Sources/BlurtEngine/Config/PersistedSettings.swift b/Sources/BlurtEngine/Config/PersistedSettings.swift index d1ac123f..d966b677 100644 --- a/Sources/BlurtEngine/Config/PersistedSettings.swift +++ b/Sources/BlurtEngine/Config/PersistedSettings.swift @@ -20,7 +20,7 @@ public enum PersistedSettings { /// through `@testable`. Exporting the list as well would invite a caller to walk /// it and do its own sweep — which is what `resetAll` replaced — and would trip /// periphery's redundant-public check besides. - static let allDefaultsKeys: [String] = DefaultsKey.allCases.map(\.rawValue) + static var allDefaultsKeys: [String] { DefaultsKey.allCases.map(\.key) } /// Clears every key in the roster, returning the engine's settings to their /// unset defaults. The operation lives beside `DefaultsKey` so a reset diff --git a/Sources/BlurtEngine/HostIdentity.swift b/Sources/BlurtEngine/HostIdentity.swift new file mode 100644 index 00000000..a60ef47d --- /dev/null +++ b/Sources/BlurtEngine/HostIdentity.swift @@ -0,0 +1,161 @@ +import Foundation +import Synchronization +import os + +/// Everything the engine needs to know about *which app it is running inside*: +/// the Keychain item the API key lands in, the unified-logging subsystem, the +/// `UserDefaults` key prefix, the directory under `~/Library/Logs` the +/// developer-mode logs are written to, the product name user-facing copy says, +/// and the GitHub release the update check reads. +/// +/// These used to be hard constants, which made them Blurt's with no opt-out: a +/// second app embedding the engine wrote into *Blurt's* Keychain item, log +/// directory and defaults keys. They are one value now, and every engine +/// component reads `HostIdentity.current`, so a host overrides all of them with a +/// single `configure(_:)` at its composition root — or inherits `.blurt` by doing +/// nothing, which is what keeps this repo's own behaviour byte-for-byte +/// unchanged. +/// +/// A process-wide value rather than a parameter threaded through every +/// initializer, because the things that read it are not objects a caller +/// constructs: `static let` loggers, an enum of defaults keys, a Keychain facade. +/// Injecting it would have meant an identity parameter on every store, view and +/// test in the repo to configure something that is, by definition, fixed for the +/// lifetime of a process. +public struct HostIdentity: Sendable, Equatable { + /// The product name as it appears in user-facing copy ("Blurt") — distinct + /// from `subsystem`, which is the reverse-DNS identity. `UpdateAlertContent` + /// is its only reader today. + public let productName: String + + /// Reverse-DNS identity ("dev.alex.blurt"): the `os_log` subsystem for every + /// category, and the prefix for the engine's dispatch-queue labels. For Blurt + /// this must match `BUNDLE_ID` in `scripts/reset-install.sh`, which hard-codes + /// the same value for its `defaults`/`tccutil` cleanup (bash can't read this + /// constant). + /// + /// It is also Blurt's **release** bundle id, but is not interchangeable with + /// the running one: debug builds ship under `dev.alex.blurt.dev` so a dev + /// install is a separate app to macOS (see `project.yml`). One log subsystem + /// across both is deliberate — the documented `log show` predicates stay valid + /// whichever build is running — but anything addressing *this process's* + /// container, defaults domain or TCC records must read + /// `Bundle.main.bundleIdentifier` instead. + public let subsystem: String + + /// The Keychain service for the API key item. Blurt uses the plain app name, + /// so the entry appears as "blurt" in Keychain Access instead of a + /// developer-domain string; it must match `KEYCHAIN_SERVICE` in + /// `scripts/reset-install.sh`. + /// + /// This is the one gap a host could already work around before the identity + /// existed — compose against `APIKeyGateway` with your own conformance instead + /// of `ProductionAPIKeyStore` and the service never comes up — but overriding + /// it here means `APIKeyStore` itself is usable as shipped. + public let keychainService: String + + /// Prefixed onto every `DefaultsKey` raw value to form the key actually + /// written (`"Blurt"` + `"SoundPack"`). The prefix is what separates the + /// engine's settings from anything else sharing the host's defaults domain, so + /// two apps embedding the engine no longer collide on `BlurtSoundPack`. + /// + /// **On-disk contract.** Changing this for an app that has already shipped + /// abandons every existing user's settings exactly as renaming a `DefaultsKey` + /// raw value would — pick it once, at the point the host is first published. + public let defaultsPrefix: String + + /// The directory under `~/Library/Logs` the developer-mode dictation and error + /// logs are written to (`~/Library/Logs/Blurt/{dictations,errors}.jsonl`). + public let logDirectoryName: String + + /// The "latest release" endpoint `UpdateChecker` reads, and therefore the repo + /// whose DMG the update alert offers. Still an initializer parameter on + /// `UpdateChecker` (tests substitute a local URL); this is the default a host + /// gets without passing one. + public let releaseURL: URL + + public init( + productName: String, + subsystem: String, + keychainService: String, + defaultsPrefix: String, + logDirectoryName: String, + releaseURL: URL + ) { + self.productName = productName + self.subsystem = subsystem + self.keychainService = keychainService + self.defaultsPrefix = defaultsPrefix + self.logDirectoryName = logDirectoryName + self.releaseURL = releaseURL + } + + /// Blurt's own identity, and the value an unconfigured host inherits — so the + /// engine behaves exactly as it did when these were constants. It is the single + /// definition of each of those strings; `App.swift` configures *this* value + /// rather than restating them, and `HostIdentityTests` pins the two that + /// `scripts/reset-install.sh` also hard-codes. + public static let blurt = HostIdentity( + productName: "Blurt", + subsystem: "dev.alex.blurt", + keychainService: "blurt", + defaultsPrefix: "Blurt", + logDirectoryName: "Blurt", + releaseURL: URL( + staticString: "https://api.github.com/repos/AssemblyAI/blurt/releases/latest")) + + /// The identity every engine component reads. `.blurt` until a host calls + /// `configure(_:)`. + public static var current: HostIdentity { storage.withLock { $0 } } + + /// Adopt `identity` for the rest of the process. **Call this once, at your + /// composition root, before constructing any engine type** (Blurt does it in + /// `BlurtApp.init`). + /// + /// The ordering is a real requirement, not politeness: the readers are lazily + /// initialized `static let`s — loggers, the log-append queue, `APIKeyStore`'s + /// memoized Keychain item — so one that was touched before this call keeps the + /// identity it resolved with. There is deliberately no enforcement of that: + /// trapping on a second call would make the engine unusable from a test that + /// wants to exercise two identities, and refusing the second call silently + /// would be worse than the misconfiguration it was guarding against. + public static func configure(_ identity: HostIdentity) { + storage.withLock { $0 = identity } + } + + /// `Mutex` rather than a `nonisolated(unsafe) var`, for the reason + /// `MemoizedKeyStore` and `APIKeyGateway` use one: this is read from every + /// isolation domain in the engine (the `DictationSession` actor, the log's + /// serial queue, `@MainActor` views) and a torn read of a struct this size is + /// not hypothetical. + private static let storage = Mutex(.blurt) + + // MARK: - Derivations + // + // Pure functions of the value, so the tests exercise them against a + // constructed `HostIdentity` rather than by mutating the process-wide one — + // which, being process-wide, would race every other suite reading `current`. + + /// The `UserDefaults` key a `DefaultsKey` case writes: the prefix, then the + /// case's raw value. + func defaultsKey(_ suffix: String) -> String { defaultsPrefix + suffix } + + /// `~/Library/Logs//` — where the developer-mode + /// logs land. Neither the directory nor the file is created by asking. + func logURL(_ fileName: String) -> URL { + URL.libraryDirectory.appending(path: "Logs/\(logDirectoryName)/\(fileName)") + } + + /// A `Logger` on this identity's subsystem. Spelled once here so the engine's + /// loggers don't each restate `subsystem:` — and public because the host's own + /// components want the same subsystem, so their lines show up under the + /// documented `log show` predicates too (Blurt's `DictationKeyTap` and + /// `UpdateCheckModel` are the in-repo examples). + public func logger(_ category: String) -> Logger { + Logger(subsystem: subsystem, category: category) + } + + /// A dispatch-queue label under this identity's reverse-DNS prefix, matching + /// what the queues were named when the prefix was a constant. + func queueLabel(_ name: String) -> String { "\(subsystem).\(name)" } +} diff --git a/Sources/BlurtEngine/Hotkey/TriggerKeyStore.swift b/Sources/BlurtEngine/Hotkey/TriggerKeyStore.swift index 0517f716..b501258e 100644 --- a/Sources/BlurtEngine/Hotkey/TriggerKeyStore.swift +++ b/Sources/BlurtEngine/Hotkey/TriggerKeyStore.swift @@ -6,7 +6,7 @@ import Foundation public struct TriggerKeyStore { /// UserDefaults key holding the trigger keycode. Public so SwiftUI views can /// observe it directly (e.g. `@AppStorage`) and re-render on change. - public static let defaultsKey = DefaultsKey.triggerKeyCode.rawValue + public static var defaultsKey: String { DefaultsKey.triggerKeyCode.key } private let defaults: UserDefaults public init(defaults: UserDefaults = .standard) { diff --git a/Sources/BlurtEngine/Permissions/SigningIdentity.swift b/Sources/BlurtEngine/Permissions/SigningIdentity.swift index 0dcb6b26..f51c121c 100644 --- a/Sources/BlurtEngine/Permissions/SigningIdentity.swift +++ b/Sources/BlurtEngine/Permissions/SigningIdentity.swift @@ -7,7 +7,7 @@ import os /// Accessibility grant. Kept separate from the pure `SigningIdentityMigration` /// so the decision logic stays testable and these system calls stay thin. public enum SigningIdentity { - private static let log = Logger(subsystem: BlurtIdentity.subsystem, category: "SigningIdentity") + private static let log = HostIdentity.current.logger("SigningIdentity") /// Namespace marker on a recorded identity. Every build before this one recorded /// a bare Team ID (10 alphanumerics, no colon), so the prefix keeps the two diff --git a/Sources/BlurtEngine/Pipeline/DictationLog+Errors.swift b/Sources/BlurtEngine/Pipeline/DictationLog+Errors.swift index 9af6522e..7c54de42 100644 --- a/Sources/BlurtEngine/Pipeline/DictationLog+Errors.swift +++ b/Sources/BlurtEngine/Pipeline/DictationLog+Errors.swift @@ -37,7 +37,7 @@ extension DictationLog { /// Where failures land. Sibling of `defaultURL` in the same directory, so the /// one "delete my logs" gesture (`scripts/reset-install.sh`) covers both. - static let defaultErrorURL = URL.libraryDirectory.appending(path: "Logs/Blurt/errors.jsonl") + static var defaultErrorURL: URL { HostIdentity.current.logURL("errors.jsonl") } /// `defaultErrorURL` as a home-abbreviated path, for the Developer section's /// footer. Derived here next to the URL the writer uses, for the same reason diff --git a/Sources/BlurtEngine/Pipeline/DictationLog.swift b/Sources/BlurtEngine/Pipeline/DictationLog.swift index de9c584f..a87eb017 100644 --- a/Sources/BlurtEngine/Pipeline/DictationLog.swift +++ b/Sources/BlurtEngine/Pipeline/DictationLog.swift @@ -2,7 +2,8 @@ import Foundation import os /// Append-only JSONL log of completed transcripts at -/// `~/Library/Logs/Blurt/dictations.jsonl`. Used to build a real-world +/// `~/Library/Logs//dictations.jsonl` (`~/Library/Logs/Blurt/…` under the +/// default `HostIdentity`). Used to build a real-world /// corpus for prompt iteration. Written only while developer mode is switched /// on (`DeveloperModeStore` — the Settings window's Developer section, which /// also displays this path), so a user who never opts in has no dictation @@ -91,7 +92,11 @@ public enum DictationLog { /// can display the path next to the switch that enables writing to it. The /// file (and its directory) are only created when the first entry is /// appended, so reading this never touches the disk. - public static let defaultURL = URL.libraryDirectory.appending(path: "Logs/Blurt/dictations.jsonl") + /// + /// The directory is the host identity's (`~/Library/Logs/Blurt` for Blurt), so + /// a second app embedding the engine writes its own corpus rather than + /// interleaving lines into Blurt's. + public static var defaultURL: URL { HostIdentity.current.logURL("dictations.jsonl") } /// `defaultURL` as a home-abbreviated path (`~/Library/Logs/…`) for the label /// beside the developer-mode switch. Derived here, next to the URL the writer @@ -118,7 +123,7 @@ public enum DictationLog { // // Internal so a test can `sync {}` on it to drain a dispatched write, rather // than polling the filesystem and hoping. - static let queue = DispatchQueue(label: "\(BlurtIdentity.subsystem).DictationLog") + static let queue = DispatchQueue(label: HostIdentity.current.queueLabel("DictationLog")) /// Append a completed transcript to the JSONL log. **Gated on developer mode:** /// with the switch off (the default) this returns without touching the disk, so @@ -229,6 +234,5 @@ public enum DictationLog { try handle.write(contentsOf: line) } - private static let logger = Logger( - subsystem: BlurtIdentity.subsystem, category: "DictationLog") + private static let logger = HostIdentity.current.logger("DictationLog") } diff --git a/Sources/BlurtEngine/Pipeline/DictationSession+Observation.swift b/Sources/BlurtEngine/Pipeline/DictationSession+Observation.swift index 3dacca77..535362d1 100644 --- a/Sources/BlurtEngine/Pipeline/DictationSession+Observation.swift +++ b/Sources/BlurtEngine/Pipeline/DictationSession+Observation.swift @@ -11,7 +11,7 @@ extension DictationSession { /// release). `DictationPerformanceTests` guards the same paths with /// wall-clock budgets; these intervals are for interactive profiling. static let signposter = OSSignposter( - subsystem: BlurtIdentity.subsystem, category: "DictationPipeline") + subsystem: HostIdentity.current.subsystem, category: "DictationPipeline") /// Signpost interval name for the press → `.recording` startup path. static let pressSignpostName: StaticString = "PressStart" /// Signpost interval name for the release → transcribe → inject hot path. diff --git a/Sources/BlurtEngine/Pipeline/DictationSession.swift b/Sources/BlurtEngine/Pipeline/DictationSession.swift index de5c56b9..e64d3998 100644 --- a/Sources/BlurtEngine/Pipeline/DictationSession.swift +++ b/Sources/BlurtEngine/Pipeline/DictationSession.swift @@ -5,7 +5,7 @@ public actor DictationSession { /// Off-pool home for the press-time AX field read — see its use in /// `performPress` for why blocking IPC must not run on the cooperative pool. static let contextQueue = DispatchQueue( - label: "\(BlurtIdentity.subsystem).FieldContext", qos: .userInitiated, + label: HostIdentity.current.queueLabel("FieldContext"), qos: .userInitiated, attributes: .concurrent) /// `internal(set)`, not `private(set)`: `private` is file-scoped, and the one diff --git a/Sources/BlurtEngine/Pipeline/OverlayOriginStore.swift b/Sources/BlurtEngine/Pipeline/OverlayOriginStore.swift index 289ff148..18a0de41 100644 --- a/Sources/BlurtEngine/Pipeline/OverlayOriginStore.swift +++ b/Sources/BlurtEngine/Pipeline/OverlayOriginStore.swift @@ -14,8 +14,8 @@ import Foundation /// persistence belongs next to it. public struct OverlayOriginStore { /// Public so the reset sweep and `@AppStorage`-style observers can name them. - public static let xDefaultsKey = DefaultsKey.overlayCustomOriginX.rawValue - public static let yDefaultsKey = DefaultsKey.overlayCustomOriginY.rawValue + public static var xDefaultsKey: String { DefaultsKey.overlayCustomOriginX.key } + public static var yDefaultsKey: String { DefaultsKey.overlayCustomOriginY.key } private let defaults: UserDefaults diff --git a/Sources/BlurtEngine/README.md b/Sources/BlurtEngine/README.md index 2b295e38..78607ecb 100644 --- a/Sources/BlurtEngine/README.md +++ b/Sources/BlurtEngine/README.md @@ -119,9 +119,22 @@ genuinely being captured. Cueing at the press invites speech that nothing can ca `RecordingCueGate` is the other phase projection, and the reason the chimes don't retrigger: call `cue(for:)` with **every** phase and it returns `.start` only on the edge **into** `.recording` — in production the connecting→recording one, i.e. once the mic is actually delivering audio, never at the press — `.stop` only on the recording→not-recording edge, and `nil` while a phase repeats or when two non-recording phases follow each other. It's a value type holding one edge bit — keep a single instance for the host's lifetime. -Which chime plays is a `SoundPack`: an `id`, a display `label`, and the picker `group` it belongs to. `SoundPack.catalog` is generated by `scripts/generate-sounds.swift` from Yamaha DX7 (ROM1A/ROM1B) and Roland Juno-106 factory presets; `groups` and `voices(in:)` build a sectioned picker off stored indexes rather than rescanning all 192 entries per render, and `fromPersisted(_:)` is the one decode-with-default rule (mirroring `TriggerKey.fromPersisted`) so a view reading the raw id can't disagree with `SoundPackStore`. `startFileName` / `stopFileName` give the `-start` / `-stop` stems, or `nil` for the silent `SoundPack.none`. +Which chime plays is a `SoundPack`: an `id`, a display `label`, and the picker `group` it belongs to. `startFileName` / `stopFileName` give the `-start` / `-stop` stems, or `nil` for the silent `SoundPack.none`. -The cue audio itself is **not** in the package — the `.m4a` files live in the app bundle, so the engine hands you stems and a host supplies the files, or its own player entirely. See [`SoundPack` ships metadata without the audio](#soundpack-ships-metadata-without-the-audio) for what that costs a package consumer. +**The voices and the audio are both yours to supply.** The engine ships neither — no catalog, no `.m4a` files — because they are one artifact: a voice list whose stems name files that aren't in your bundle is a picker in which every choice plays silence, which is exactly what a package consumer used to get from the 192 entries that shipped here. Collect your voices into a `SoundPackCatalog`: + +```swift +let catalog = SoundPackCatalog( + voices: [SoundPack(id: "chime", label: "Chime", group: "Built-in")], + defaultVoiceID: "chime") + +let store = SoundPackStore(catalog: catalog) // persists the id under SoundPack +let pack = store.soundPack // or catalog.fromPersisted(rawIDFromAppStorage) +``` + +`groups` and `voices(in:)` build a sectioned picker off stored indexes rather than rescanning the list per render, and `fromPersisted(_:)` is the one decode-with-default rule (mirroring `TriggerKey.fromPersisted`) so a view reading the raw id can't disagree with `SoundPackStore`. A `defaultVoiceID` naming no voice resolves to `SoundPack.none` rather than trapping, and `SoundPack.none` stays reachable whatever you pass — a voice claiming that reserved id loses its own slot instead of taking away "no sound". `SoundPackStore` stays engine-side because its key is a `DefaultsKey` case, and that enum is the single roster `PersistedSettings.resetAll` sweeps. + +Blurt's own catalog is generated into the _app_ target by `scripts/generate-sounds.swift` — 192 Yamaha DX7 (ROM1A/ROM1B) and Roland Juno-106 factory voices, written alongside the 384 `.m4a` cues they name, with `check.sh` failing if the two ever disagree. `CueSoundPlayer` resolves the stems against `Bundle.main`. ### Errors @@ -203,16 +216,18 @@ Setup gating has a projection too: **`SetupReadiness.isReady(permissions:hasAPIK Accessibility grants carry one more wrinkle worth inheriting rather than rediscovering. macOS keys the grant to the app's _designated requirement_, so when what that requirement pins changes, the grant is orphaned — the "toggle is on, still denied" state. **`SigningIdentityMigration`** is the pure decision (persisted identity, current identity, live trust state, and the reset side effect all injected) and **`SigningIdentity`** the thin adapter that reads what a grant taken right now would pin: the **Team ID** for team-signed builds, so certificate rotation inside a team keeps the grant, and the **cdhash** for ad-hoc ones, where every build is a new app to `tccd`. Without the migration, a reviewer installing a second ad-hoc dev build finds the app already switched on in System Settings and can never satisfy the wizard, because that row belongs to the previous build's signature. Its defaults key is deliberately _outside_ the settings roster below — it records what a migration already did, and a settings reset must not forget it. -Each completed dictation is appended to **`DictationLog`** (a local JSONL history at `~/Library/Logs/Blurt/dictations.jsonl` — `DictationLog.defaultURL`, or `defaultDisplayPath` for the home-abbreviated form to show in UI) with its context snapshot — but only while developer mode is switched on. Each _failed_ dictation is appended to a sibling error log at `~/Library/Logs/Blurt/errors.jsonl` (`defaultErrorDisplayPath` for the UI form) behind the same switch: one line per failure with the stable `BlurtError` case label, the full description (which for `.sttFailed` carries the API status and server message), and the focused app/window/field — but no prior text, selected text, or prompt, since none of it explains a failure. The two logs are separate files so the dictations log stays a corpus whose every line carries a `transcript`. **`DeveloperModeStore`** persists that opt-in in `UserDefaults` (`BlurtDeveloperMode`, off by default); with it off, nothing is written to disk. Blurt surfaces the switch (and both log paths) in the Settings window's Developer section. +Each completed dictation is appended to **`DictationLog`** (a local JSONL history at `~/Library/Logs//dictations.jsonl` — `DictationLog.defaultURL`, or `defaultDisplayPath` for the home-abbreviated form to show in UI) with its context snapshot — but only while developer mode is switched on. Each _failed_ dictation is appended to a sibling error log at `errors.jsonl` in the same directory (`defaultErrorDisplayPath` for the UI form) behind the same switch: one line per failure with the stable `BlurtError` case label, the full description (which for `.sttFailed` carries the API status and server message), and the focused app/window/field — but no prior text, selected text, or prompt, since none of it explains a failure. The two logs are separate files so the dictations log stays a corpus whose every line carries a `transcript`. **`DeveloperModeStore`** persists that opt-in in `UserDefaults` (`BlurtDeveloperMode`, off by default); with it off, nothing is written to disk. Blurt surfaces the switch (and both log paths) in the Settings window's Developer section. ## Settings and persistence Every user setting is a small `UserDefaults`-backed struct with the same shape: a public `defaultsKey` (so a SwiftUI view can bind `@AppStorage` straight to it and re-render live on a Settings change), an injected `UserDefaults`, and a decode-with-default rule owned by the store rather than restated in the views. +Keys are the host identity's `defaultsPrefix` followed by the `DefaultsKey` case's raw value; the table shows them under the default `.blurt` identity. + | Store | Key | Unset means | | -------------------------- | -------------------------------- | --------------------------------- | | `TriggerKeyStore` | `BlurtTriggerKeyCode` | right ⌘ | -| `SoundPackStore` | `BlurtSoundPack` | ORCHESTRA (DX7 ROM1A voice 6) | +| `SoundPackStore` | `BlurtSoundPack` | the catalog's `defaultVoiceID` | | `KeyTermsStore` | `BlurtKeyTerms` | no key terms | | `DeveloperModeStore` | `BlurtDeveloperMode` | off — nothing is logged to disk | | `EnhancedTranscriptsStore` | `BlurtEnhancedTranscripts` | **on** — the `llm` block is sent | @@ -220,9 +235,9 @@ Every user setting is a small `UserDefaults`-backed struct with the same shape: | `OverlayOriginStore` | `BlurtOverlayCustomOriginX`/`…Y` | pill never dragged; default place | | `LastUpdateCheckStore` | `BlurtLastUpdateCheck` | never checked | -Several are read-only by design (`KeyTermsStore`, `CustomStyleStore`, `DeveloperModeStore`, `EnhancedTranscriptsStore`): the Settings control binds `@AppStorage` to `defaultsKey` and is the sole writer, so normalization lives on the read side — a whitespace-only custom-style field reads back as "no instructions" — and a setter here would fight the text field. The four a host constructs itself (`TriggerKeyStore`, `SoundPackStore`, `OverlayOriginStore`, `LastUpdateCheckStore`) take a `UserDefaults` publicly; the rest keep that injection internal. +Several are read-only by design (`KeyTermsStore`, `CustomStyleStore`, `DeveloperModeStore`, `EnhancedTranscriptsStore`): the Settings control binds `@AppStorage` to `defaultsKey` and is the sole writer, so normalization lives on the read side — a whitespace-only custom-style field reads back as "no instructions" — and a setter here would fight the text field. The four a host constructs itself (`TriggerKeyStore`, `SoundPackStore`, `OverlayOriginStore`, `LastUpdateCheckStore`) take a `UserDefaults` publicly; the rest keep that injection internal. (`SoundPackStore` also takes the `SoundPackCatalog` it decodes against — see [Record cues](#record-cues).) -The keys themselves are one internal `DefaultsKey` enum and each store reads its case from it, so **`PersistedSettings.resetAll(in:)`** sweeps `DefaultsKey.allCases` rather than a hand-maintained list — adding a store and adding it to the reset are not merely the same edit, they're the same line. That's not tidiness: it _was_ a hand-maintained array, and both times a store was copy-edited into existence the second half was forgotten (the overlay origin and the update-check stamp), so a pill dragged during a UI-test run survived `reset-install.sh`'s clean-install path into the next one. Raw values are the on-disk contract — rename a case freely, never its raw value, or every existing user's setting is silently abandoned. +The keys themselves are one internal `DefaultsKey` enum and each store reads its case from it, so **`PersistedSettings.resetAll(in:)`** sweeps `DefaultsKey.allCases` rather than a hand-maintained list — adding a store and adding it to the reset are not merely the same edit, they're the same line. That's not tidiness: it _was_ a hand-maintained array, and both times a store was copy-edited into existence the second half was forgotten (the overlay origin and the update-check stamp), so a pill dragged during a UI-test run survived `reset-install.sh`'s clean-install path into the next one. Raw values are half the on-disk contract and the identity's `defaultsPrefix` is the other — rename a case freely, never its raw value or your prefix, or every existing user's setting is silently abandoned. Two string helpers carry the "usable text" rule shared by focus capture, the context/prompt, and the stores: `trimmedNonEmpty()` (on both `String` and `String?` — trims surrounding whitespace, treats blank as absent) and `prefix(maxUTF8Bytes:)`, which drops whole `Character`s from the end so a multi-scalar emoji is removed intact rather than sliced into an invalid fragment. The latter is the single truncation rule behind the custom-style budget, shared by the Settings field's counter and `CleanupInstruction.sendable(appending:)` — the two disagreed once, and that shipped. @@ -230,7 +245,7 @@ Two string helpers carry the "usable text" rule shared by focus capture, the con The engine carries the _decision_ half of Blurt's self-update, for the same reason `SetupReadiness` and `UpdateAlertContent` live here: these are rules and wording, and the AppKit shell that applies them has no test target. Nothing here installs anything — it reports whether a newer DMG exists and where to download it, and the user still installs it themselves. -- **`UpdateChecker.check(current:)`** GETs the repo's latest GitHub release through the shared `HTTPTransport` seam (so tests substitute `FakeHTTPTransport` instead of the live API) and returns `.upToDate` or `.available(version:dmgURL:)`. It throws on network failure, malformed JSON, an unparseable tag, or a newer release with no DMG asset — the app maps every throw to one "couldn't check" caption. +- **`UpdateChecker.check(current:)`** GETs the latest GitHub release of the identity's `releaseURL` repo through the shared `HTTPTransport` seam (so tests substitute `FakeHTTPTransport` instead of the live API) and returns `.upToDate` or `.available(version:dmgURL:)`. It throws on network failure, malformed JSON, an unparseable tag, or a newer release with no DMG asset — the app maps every throw to one "couldn't check" caption. - **`SemanticVersion`** parses a dotted numeric version with an optional leading `v` (GitHub tags are `v0.1.39`; `CFBundleShortVersionString` is the bare form), comparing component-wise with missing trailing components as zero, so `1.2 == 1.2.0`. A malformed tag reads as "can't determine" rather than crashing. - **`AutomaticUpdateCheck.shouldRun(isConfigured:lastCheck:now:)`** gates the launch check: never over an unfinished wizard, at most once per `minimumInterval` (24 h, because the unauthenticated GitHub API is rate-limited per IP and five relaunches in an afternoon must not be five fetches), and treating a timestamp in the future — clock correction, a restored backup — as due rather than trusting it. `launchDelay` (3 s) keeps the fetch off the launch path and out of the window before the main `NSWindow` exists to host the result's sheet. - **`UpdateAlertContent`** owns the wording: title, body, buttons in presentation order, and the URL the _default_ button opens (`nil` when it only dismisses, so "which button downloads" is never re-derived by matching a button title). Its initializer is private, so every alert comes from one of the named results rather than being assembled ad hoc in the shell. `appVersionLabel(_:)` is the one phrasing of the running-version label the alerts and the Settings row share. @@ -265,34 +280,41 @@ Run `swift test` for the engine suites (`--filter DictationSessionTests` for one ## Embedding outside Blurt -The dictation pipeline itself is clean — the three seams, `DictationSession`, the phase projections, and the transcriber carry nothing Blurt-specific. Three other things do, and none of them is a bug in the engine so much as the shape of a package that has only ever had one host. Know them before you ship this inside another app. +The dictation pipeline itself is clean — the three seams, `DictationSession`, the phase projections, and the transcriber carry nothing Blurt-specific. Everything that _was_ Blurt-specific now has a seam: the identity below, and the cue catalog, which the engine no longer ships at all. One thing still doesn't, and it isn't a bug in the engine so much as the shape of a package that has only ever had one host. + +### Tell the engine which app it is: `HostIdentity` -### Blurt's identity is baked into engine defaults +Everything the engine writes somewhere addressable is namespaced by one value. Set it once, at your composition root, before you construct any engine type: -A third-party host embedding the engine today inherits these with **no opt-out**: +```swift +HostIdentity.configure( + HostIdentity( + productName: "Acme Voice", // update alerts say this + subsystem: "com.acme.voice", // os_log subsystem + queue labels + keychainService: "acme-voice", // the API key's Keychain item + defaultsPrefix: "AcmeVoice", // AcmeVoiceTriggerKeyCode, AcmeVoiceSoundPack, … + logDirectoryName: "Acme Voice", // ~/Library/Logs/Acme Voice/{dictations,errors}.jsonl + releaseURL: acmeLatestReleaseURL)) // UpdateChecker's default feed +``` -| Constant | Value | -| --------------------------------------------- | ------------------------------------------------------------------------ | -| `BlurtIdentity.keychainService` | `blurt` — so the API key writes into Blurt's own Keychain item | -| `BlurtIdentity.subsystem` | `dev.alex.blurt` — the `os_log` subsystem for every category | -| `DictationLog.defaultURL` / `defaultErrorURL` | `~/Library/Logs/Blurt/{dictations,errors}.jsonl` | -| `DefaultsKey` raw values | `BlurtTriggerKeyCode`, `BlurtSoundPack`, … in the host's defaults domain | -| `UpdateChecker`'s default `releaseURL` | `api.github.com/repos/AssemblyAI/blurt/releases/latest` | -| `UpdateAlertContent`'s product name | `Blurt`, and `GitHubRelease.dmgAsset` expects a `Blurt.dmg` | +| Field | What it namespaces | +| ------------------ | ------------------------------------------------------------------------------- | +| `keychainService` | `APIKeyStore`'s generic-password item | +| `subsystem` | every `os_log` category, `OSSignposter`, and the engine's dispatch-queue labels | +| `defaultsPrefix` | every `DefaultsKey` — the prefix, then the case's raw value | +| `logDirectoryName` | `DictationLog.defaultURL` / `defaultErrorURL` under `~/Library/Logs` | +| `productName` | `UpdateAlertContent`'s wording, including `appVersionLabel(_:)` | +| `releaseURL` | `UpdateChecker`'s default release feed (still overridable per instance) | -Only two of those have a seam. `UpdateChecker`'s `releaseURL` is an initializer parameter, and the log URLs are parameters on the writers — though those are _internal_, for tests, so from outside the module the log path is fixed too. The Keychain service, the logging subsystem, the defaults prefix, and the product name are hard constants. +Doing nothing inherits `HostIdentity.blurt`, which is exactly the set of constants these used to be — so this changed no behaviour for the app in this repo, whose `BlurtApp.init` configures `.blurt` explicitly because the identity is the host's to state. `HostIdentity.current.logger(_:)` is public too, so your own components can log under the same subsystem. -Closing this properly means a **host-supplied identity value** threaded through those types (or keeping the affected ones app-internal). Until then, a second app on the same machine shares Blurt's Keychain item, log directory, and defaults keys. The one gap you can work around today is the key: compose against `APIKeyGateway` with your own conformance instead of `ProductionAPIKeyStore`, and the Keychain service never comes up. +Three things to know. The value is **process-wide**, not injected: its readers are lazily-initialized `static let` loggers, an enum of defaults keys and a Keychain facade, none of which a caller constructs — which is also why the "before you construct any engine type" ordering is a real requirement rather than politeness (a reader that already resolved keeps the identity it resolved with). `defaultsPrefix` is an **on-disk contract** with your shipped users, exactly as a `DefaultsKey` raw value is: changing it later abandons their settings. And `GitHubRelease.dmgAsset` takes the first asset whose name ends in `.dmg`, so it needs nothing from you — but `Update/` is still Blurt's self-update feature living in a dictation engine, which is its own gap below. ### `Update/` isn't part of a dictation engine `UpdateChecker`, `AutomaticUpdateCheck`, `GitHubRelease`, `UpdateAlertContent`, and `LastUpdateCheckStore` are Blurt's own self-update feature, and they live in the engine for a concrete reason: the AppKit shell has **no unit-test target** (`App/Blurt` builds the app and an XCUITest bundle, nothing else), so a launch gate or an alert's wording written inline there would be covered by nothing. That reasoning is sound for this repo and wrong for a published package — as shipped, the dictation library contains a self-updater for a specific app. -Two ways out, neither taken yet: drop it from the public product (its own target, so it keeps its Swift Testing coverage without riding inside `BlurtEngine`), or generalize it so the repo slug, asset name, and product name come from the host. Note the second is not free — `LastUpdateCheckStore` reads its key from the internal `DefaultsKey` enum, and that enum is deliberately the single roster `PersistedSettings.resetAll` sweeps, so a target split has to keep that invariant intact rather than stranding the update stamp outside the reset (which is exactly the bug the roster was created to prevent, twice). - -### `SoundPack` ships metadata without the audio - -`SoundPack.catalog` has 192 voices, and all 384 `.m4a` cues live in `App/Blurt/Blurt/Resources/Sounds/` — not in the package target. A package consumer therefore gets 192 ids and labels whose `startFileName` / `stopFileName` resolve to nothing at all. Either the resources move into the target (`resources: [.process("Resources/Sounds")]` in `Package.swift`, and the app loads them from `Bundle.module` instead of `Bundle.main`), or the cue catalog becomes app-side and leaves the engine holding only `RecordingCueGate`. Whichever way it goes, `scripts/generate-sounds.swift` writes both the audio and `SoundPackCatalog.swift` together and `check.sh` pins that they agree, so the generator and the integrity check move with the files. +Two ways out, neither taken yet: drop it from the public product (its own target, so it keeps its Swift Testing coverage without riding inside `BlurtEngine`), or finish generalizing it — the repo slug and the product name already come from `HostIdentity`, and `GitHubRelease.dmgAsset` matches any `.dmg`, so what remains is that a dictation library contains a self-updater at all. Note the second is not free — `LastUpdateCheckStore` reads its key from the internal `DefaultsKey` enum, and that enum is deliberately the single roster `PersistedSettings.resetAll` sweeps, so a target split has to keep that invariant intact rather than stranding the update stamp outside the reset (which is exactly the bug the roster was created to prevent, twice). ## Invariants — don't break these diff --git a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift index ecf175a3..ed17f030 100644 --- a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift +++ b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift @@ -5,7 +5,7 @@ import os /// log show --predicate 'subsystem == "dev.alex.blurt" && category == "Transcriber"' --last 1h /// File-scoped so both `send(_:body:audioDurationMs:)` (wall-clock) and /// `MetricsLogger` (the DNS/TCP/TLS/TTFB split) can write to it. -private let transcriberLog = Logger(subsystem: BlurtIdentity.subsystem, category: "Transcriber") +private let transcriberLog = HostIdentity.current.logger("Transcriber") /// `TranscriberProtocol` backed by AssemblyAI's **dictation** API. /// diff --git a/Sources/BlurtEngine/Update/LastUpdateCheckStore.swift b/Sources/BlurtEngine/Update/LastUpdateCheckStore.swift index de4278f0..8e931acf 100644 --- a/Sources/BlurtEngine/Update/LastUpdateCheckStore.swift +++ b/Sources/BlurtEngine/Update/LastUpdateCheckStore.swift @@ -7,7 +7,7 @@ import Foundation /// state" sweep clears it too. public struct LastUpdateCheckStore { /// Public so the reset sweep can name it. - public static let defaultsKey = DefaultsKey.lastUpdateCheck.rawValue + public static var defaultsKey: String { DefaultsKey.lastUpdateCheck.key } private let defaults: UserDefaults diff --git a/Sources/BlurtEngine/Update/UpdateAlertContent.swift b/Sources/BlurtEngine/Update/UpdateAlertContent.swift index a5f0afa6..3dd45370 100644 --- a/Sources/BlurtEngine/Update/UpdateAlertContent.swift +++ b/Sources/BlurtEngine/Update/UpdateAlertContent.swift @@ -58,9 +58,10 @@ public struct UpdateAlertContent: Equatable, Sendable { return "\(productName) \(version)" } - /// The product name as it appears in user-facing copy — distinct from - /// `BlurtIdentity.subsystem`, which is the reverse-DNS identity. - private static let productName = "Blurt" + /// The product name as it appears in user-facing copy — the host identity's, + /// so an embedding app's alerts name *it* rather than Blurt. Distinct from + /// `HostIdentity.subsystem`, which is the reverse-DNS identity. + private static var productName: String { HostIdentity.current.productName } /// The reassuring result a user-initiated check always shows, so pressing /// "Check for Updates" visibly confirms it ran. diff --git a/Sources/BlurtEngine/Update/UpdateChecker.swift b/Sources/BlurtEngine/Update/UpdateChecker.swift index cc45451b..4800ad9c 100644 --- a/Sources/BlurtEngine/Update/UpdateChecker.swift +++ b/Sources/BlurtEngine/Update/UpdateChecker.swift @@ -17,9 +17,11 @@ public struct UpdateChecker: Sendable { private let releaseURL: URL private let transport: any HTTPTransport + /// `releaseURL` defaults to the configured host identity's, so a host that + /// called `HostIdentity.configure(_:)` checks *its* repo without passing one; + /// tests still substitute a local URL here. public init( - releaseURL: URL = URL( - staticString: "https://api.github.com/repos/AssemblyAI/blurt/releases/latest"), + releaseURL: URL = HostIdentity.current.releaseURL, transport: any HTTPTransport = URLSession.shared ) { self.releaseURL = releaseURL diff --git a/Tests/BlurtEngineTests/BlurtIdentityTests.swift b/Tests/BlurtEngineTests/BlurtIdentityTests.swift deleted file mode 100644 index 2a2bcba9..00000000 --- a/Tests/BlurtEngineTests/BlurtIdentityTests.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Testing - -@testable import BlurtEngine - -@Suite("BlurtIdentity") -struct BlurtIdentityTests { - @Test("the reverse-DNS identity matches the value scripts hard-code") - func subsystemPinned() { - // `scripts/reset-install.sh` hard-codes the same string for its - // `defaults`/`security` cleanup (bash can't read this constant), so a drift - // here would silently break that script's Keychain/log cleanup. Changing - // this value requires updating the script in the same change. - #expect(BlurtIdentity.subsystem == "dev.alex.blurt") - } - - @Test("the Keychain service matches the value scripts hard-code") - func keychainServicePinned() { - // `scripts/reset-install.sh` hard-codes the same string for its `security` - // cleanup (bash can't read this constant), so a drift here would silently - // break that script's Keychain cleanup. Changing this value requires - // updating the script in the same change. - #expect(BlurtIdentity.keychainService == "blurt") - } -} diff --git a/Tests/BlurtEngineTests/DefaultsKeyTests.swift b/Tests/BlurtEngineTests/DefaultsKeyTests.swift index 75ae0dcd..abb73c11 100644 --- a/Tests/BlurtEngineTests/DefaultsKeyTests.swift +++ b/Tests/BlurtEngineTests/DefaultsKeyTests.swift @@ -2,27 +2,33 @@ import Testing @testable import BlurtEngine -/// `DefaultsKey`'s raw values are the on-disk contract with every installed copy of -/// Blurt: a renamed raw value doesn't fail, it silently abandons the user's setting -/// and reads back the unset default. These pin the properties that keep the set -/// legible; which store owns which case is pinned in `PersistedSettingsTests`. +/// A `DefaultsKey`'s composed `key` is the on-disk contract with every installed +/// copy of the host app: change either half — the case's raw value or the +/// identity's prefix — and it doesn't fail, it silently abandons the user's +/// setting and reads back the unset default. These pin the properties that keep +/// the set legible; which store owns which case is pinned in +/// `PersistedSettingsTests`. @Suite("DefaultsKey") struct DefaultsKeyTests { @Test("raw values are unique") func rawValuesAreUnique() { // Two cases sharing a raw value would make two settings fight over one slot, - // and `PersistedSettings.allDefaultsKeys` would carry the duplicate. + // and `PersistedSettings.allDefaultsKeys` would carry the duplicate. Checked + // on the raw values rather than the composed keys because the prefix is + // common to all of them: it can't separate two cases that collide. let raws = DefaultsKey.allCases.map(\.rawValue) #expect(Set(raws).count == raws.count) } - @Test("every user setting is Blurt-prefixed") - func rawValuesAreNamespaced() { + @Test("every user setting is written under the host prefix") + func keysAreNamespaced() { // The app shares its defaults domain with anything else writing there, so user - // settings carry the prefix. It also separates them from the non-setting keys - // deliberately kept out of the reset sweep — see the assertion below. + // settings carry the prefix — and under Blurt's identity that reproduces the + // exact keys already on disk (`BlurtSoundPack`, …). It also separates them + // from the non-setting keys deliberately kept out of the reset sweep — see the + // assertion below. for key in DefaultsKey.allCases { - #expect(key.rawValue.hasPrefix("Blurt"), "\(key) should be Blurt-prefixed") + #expect(key.key(in: .blurt) == "Blurt" + key.rawValue, "\(key) should be Blurt-prefixed") } // The TCC migration marker records what already happened rather than something // the user chose, so it is neither a case here nor swept by `resetAll` — @@ -30,4 +36,17 @@ struct DefaultsKeyTests { // unprefixed name is the visible half of that distinction. #expect(!SigningIdentityMigration.lastSigningIdentityDefaultsKey.hasPrefix("Blurt")) } + + @Test("a host's prefix replaces Blurt's rather than being appended to it") + func keysFollowTheConfiguredIdentity() { + // The point of the prefix being host-supplied: a second app embedding the + // engine writes `AcmeVoiceSoundPack`, not into Blurt's slot. The raw value is + // the unprefixed half, so nothing here says "Blurt". + let acme = HostIdentity( + productName: "Acme Voice", subsystem: "com.acme.voice", keychainService: "acme-voice", + defaultsPrefix: "AcmeVoice", logDirectoryName: "Acme Voice", + releaseURL: HostIdentity.blurt.releaseURL) + #expect(DefaultsKey.soundPack.key(in: acme) == "AcmeVoiceSoundPack") + #expect(DefaultsKey.soundPack.key(in: .blurt) == "BlurtSoundPack") + } } diff --git a/Tests/BlurtEngineTests/HostIdentityTests.swift b/Tests/BlurtEngineTests/HostIdentityTests.swift new file mode 100644 index 00000000..b045cfaf --- /dev/null +++ b/Tests/BlurtEngineTests/HostIdentityTests.swift @@ -0,0 +1,81 @@ +import Foundation +import Testing + +@testable import BlurtEngine + +@Suite("HostIdentity") +struct HostIdentityTests { + /// A second identity, used to prove the derivations actually read the value + /// rather than the constants they replaced. Deliberately shares no substring + /// with Blurt's, so a derivation that still hard-codes "Blurt" can't pass by + /// coincidence. + private static let acme = HostIdentity( + productName: "Acme Voice", + subsystem: "com.acme.voice", + keychainService: "acme-voice", + defaultsPrefix: "AcmeVoice", + logDirectoryName: "Acme Voice", + releaseURL: URL(staticString: "https://api.github.com/repos/acme/voice/releases/latest")) + + @Test("the reverse-DNS identity matches the value scripts hard-code") + func subsystemPinned() { + // `scripts/reset-install.sh` hard-codes the same string for its + // `defaults`/`security` cleanup (bash can't read this constant), so a drift + // here would silently break that script's Keychain/log cleanup. Changing + // this value requires updating the script in the same change. + #expect(HostIdentity.blurt.subsystem == "dev.alex.blurt") + } + + @Test("the Keychain service matches the value scripts hard-code") + func keychainServicePinned() { + // `scripts/reset-install.sh` hard-codes the same string for its `security` + // cleanup (bash can't read this constant), so a drift here would silently + // break that script's Keychain cleanup. Changing this value requires + // updating the script in the same change. + #expect(HostIdentity.blurt.keychainService == "blurt") + } + + @Test("an unconfigured engine is Blurt") + func defaultsToBlurt() { + // The whole compatibility claim of making these host-supplied: a host that + // never calls `configure` behaves exactly as the engine did when they were + // constants. Read-only, so it doesn't race the suites running alongside it. + #expect(HostIdentity.current == .blurt) + } + + @Test("defaults keys are the prefix plus the case's raw value") + func defaultsKeysComposeFromThePrefix() { + // Blurt's prefix reproduces the keys already on disk — renaming one abandons + // every existing user's setting, so this is the on-disk contract, not style. + #expect(HostIdentity.blurt.defaultsKey("SoundPack") == "BlurtSoundPack") + #expect(Self.acme.defaultsKey("SoundPack") == "AcmeVoiceSoundPack") + } + + @Test("log URLs live under the identity's directory in ~/Library/Logs") + func logURLsFollowTheIdentity() { + #expect( + HostIdentity.blurt.logURL("dictations.jsonl").path(percentEncoded: false) + .hasSuffix("Library/Logs/Blurt/dictations.jsonl")) + #expect( + Self.acme.logURL("errors.jsonl").path(percentEncoded: false) + .hasSuffix("Library/Logs/Acme Voice/errors.jsonl")) + } + + @Test("loggers and queue labels carry the identity's subsystem") + func subsystemDerivations() { + // `Logger` exposes nothing to compare, so the queue label — built from the + // same field, and the thing a spindump names — is what's assertable here. + #expect(HostIdentity.blurt.queueLabel("DictationLog") == "dev.alex.blurt.DictationLog") + #expect(Self.acme.queueLabel("DictationLog") == "com.acme.voice.DictationLog") + } + + // There is deliberately no test that calls `configure(_:)` and reads the result + // back. `current` is process-wide and Swift Testing runs suites in parallel, so + // a test that swapped in a second identity — even for two statements — would be + // visible to every suite asserting a Blurt-prefixed defaults key, the + // `Library/Logs/Blurt` path, or "Blurt" in an update alert. Trading those for a + // two-line `Mutex` round trip would buy coverage with flakes. What the feature + // actually needs proving is that the *derivations* read the value rather than + // the constants they replaced, and that is what the tests above do, against a + // constructed identity that touches nothing shared. +} diff --git a/Tests/BlurtEngineTests/PersistedSettingsTests.swift b/Tests/BlurtEngineTests/PersistedSettingsTests.swift index b05b14ef..54eb0664 100644 --- a/Tests/BlurtEngineTests/PersistedSettingsTests.swift +++ b/Tests/BlurtEngineTests/PersistedSettingsTests.swift @@ -58,7 +58,7 @@ struct PersistedSettingsTests { OverlayOriginStore.yDefaultsKey, LastUpdateCheckStore.defaultsKey, ] - #expect(storeKeys == Set(DefaultsKey.allCases.map(\.rawValue))) + #expect(storeKeys == Set(DefaultsKey.allCases.map(\.key))) // No two stores sharing a slot — the Set above would have quietly absorbed a // collision, and two stores on one key means each overwrites the other. #expect(storeKeys.count == 9) diff --git a/Tests/BlurtEngineTests/SoundPackStoreTests.swift b/Tests/BlurtEngineTests/SoundPackStoreTests.swift index 422971f5..03e79b9b 100644 --- a/Tests/BlurtEngineTests/SoundPackStoreTests.swift +++ b/Tests/BlurtEngineTests/SoundPackStoreTests.swift @@ -5,33 +5,46 @@ import Testing @Suite("SoundPackStore") struct SoundPackStoreTests { - @Test("defaults to ORCHESTRA when unset") - func defaultsToOrchestra() { - let store = SoundPackStore(defaults: freshDefaults()) - #expect(store.soundPack == .defaultPack) - #expect(store.soundPack.id == "rom1a-6") + private static let brass = SoundPack(id: "rom1a-0", label: "Brass 1", group: "DX7") + private static let clav = SoundPack(id: "rom1a-19", label: "Clav 1", group: "DX7") + + /// A fixture rather than Blurt's 192 voices: the store's job is the persistence + /// round trip and the decode-with-default, and the engine no longer ships a + /// catalog to borrow. + private static let catalog = SoundPackCatalog( + voices: [brass, clav], defaultVoiceID: "rom1a-0") + + @Test("defaults to the catalog's default voice when unset") + func defaultsToCatalogDefault() { + let store = SoundPackStore(catalog: Self.catalog, defaults: freshDefaults()) + #expect(store.soundPack == Self.brass) } @Test("persists and reads back a chosen pack") - func roundTrips() throws { + func roundTrips() { let defaults = freshDefaults() - let store = SoundPackStore(defaults: defaults) - let clav = try #require(SoundPack.find(id: "rom1a-19")) - store.soundPack = clav - #expect(SoundPackStore(defaults: defaults).soundPack == clav) + SoundPackStore(catalog: Self.catalog, defaults: defaults).soundPack = Self.clav + #expect(SoundPackStore(catalog: Self.catalog, defaults: defaults).soundPack == Self.clav) } @Test("none is a storable, distinct value") func storesNone() { let defaults = freshDefaults() - SoundPackStore(defaults: defaults).soundPack = .none - #expect(SoundPackStore(defaults: defaults).soundPack == .none) + SoundPackStore(catalog: Self.catalog, defaults: defaults).soundPack = .none + #expect(SoundPackStore(catalog: Self.catalog, defaults: defaults).soundPack == .none) } @Test("an unknown stored value falls back to the default") func unknownFallsBack() { let defaults = freshDefaults() defaults.set("trombone", forKey: SoundPackStore.defaultsKey) - #expect(SoundPackStore(defaults: defaults).soundPack == .defaultPack) + #expect(SoundPackStore(catalog: Self.catalog, defaults: defaults).soundPack == Self.brass) + } + + @Test("the key is the roster's, so a reset clears the choice") + func usesTheRosterKey() { + // The store stayed engine-side when the catalog left precisely so its key + // remains a `DefaultsKey` case; `PersistedSettingsTests` pins the rest. + #expect(SoundPackStore.defaultsKey == DefaultsKey.soundPack.key) } } diff --git a/Tests/BlurtEngineTests/SoundPackTests.swift b/Tests/BlurtEngineTests/SoundPackTests.swift index 3a4fcd47..cfda2764 100644 --- a/Tests/BlurtEngineTests/SoundPackTests.swift +++ b/Tests/BlurtEngineTests/SoundPackTests.swift @@ -12,32 +12,77 @@ struct SoundPackTests { #expect(SoundPack.none.stopFileName == nil) } - @Test("catalog covers DX7 (64) + Juno-106 (128), grouped by synth") - func catalog() { - #expect(SoundPack.catalog.count == 192) - #expect(SoundPack.groups.first == "Yamaha DX7 · ROM1A") - #expect(SoundPack.voices(in: "Yamaha DX7 · ROM1A").count == 32) - #expect(SoundPack.voices(in: "Yamaha DX7 · ROM1B").count == 32) - #expect(SoundPack.voices(in: "Roland Juno-106 · A").count == 64) - #expect(SoundPack.voices(in: "Roland Juno-106 · B").count == 64) + @Test("a voice exposes its name and cue stems") + func voice() { + let harp = SoundPack(id: "rom1b-28", label: "Harp 1", group: "Yamaha DX7 · ROM1B") + #expect(harp.label == "Harp 1") + #expect(harp.startFileName == "rom1b-28-start") + #expect(harp.stopFileName == "rom1b-28-stop") + // A group is what marks a real voice; only `.none` is silent. + #expect(!harp.isSilent) } +} - @Test("a voice exposes its name and file stems") - func voice() { - let harp = SoundPack.find(id: "rom1b-28") - #expect(harp?.label == "Harp 1") - #expect(harp?.startFileName == "rom1b-28-start") - #expect(harp?.stopFileName == "rom1b-28-stop") - #expect(SoundPack.find(id: "rom1a-10")?.label == "E.Piano 1") - #expect(SoundPack.find(id: "juno-0")?.label == "Brass") +/// The catalog is host-supplied now — the engine ships no voices, because the +/// `.m4a` files their stems name ship with whoever ships the bundle. So these +/// exercise the machinery against small fixtures rather than pinning Blurt's 192 +/// generated entries; that the generated list and the audio agree is +/// `check.sh`'s catalog check, which is the only place the two halves meet. +@Suite("SoundPackCatalog") +struct SoundPackCatalogTests { + private static let brass = SoundPack(id: "rom1a-0", label: "Brass 1", group: "DX7") + private static let strings = SoundPack(id: "rom1a-3", label: "Strings 1", group: "DX7") + private static let juno = SoundPack(id: "juno-0", label: "Brass", group: "Juno") + + private static let catalog = SoundPackCatalog( + voices: [brass, strings, juno], defaultVoiceID: "rom1a-3") + + @Test("groups are the sections in catalog order, deduped") + func groups() { + #expect(Self.catalog.groups == ["DX7", "Juno"]) + #expect(Self.catalog.voices(in: "DX7") == [Self.brass, Self.strings]) + #expect(Self.catalog.voices(in: "Juno") == [Self.juno]) + // A section nobody declared is empty rather than a crash — the picker asks + // only for names it got from `groups`, but a stale binding shouldn't trap. + #expect(Self.catalog.voices(in: "Moog").isEmpty) } - @Test("default pack is Orchestra; lookups round-trip and reject unknowns") + @Test("lookups round-trip and reject unknowns") func lookup() { - #expect(SoundPack.defaultPack.id == "rom1a-6") - #expect(SoundPack.defaultPack.label == "Orchestra") - #expect(SoundPack.find(id: "none") == SoundPack.none) - #expect(SoundPack.find(id: "rom1a-0")?.label == "Brass 1") - #expect(SoundPack.find(id: "trombone") == nil) + #expect(Self.catalog.find(id: "rom1a-0") == Self.brass) + #expect(Self.catalog.find(id: "trombone") == nil) + // `.none` belongs to no catalog and is reachable from every one of them. + #expect(Self.catalog.find(id: "none") == SoundPack.none) + } + + @Test("an unset or unknown persisted id falls back to the default voice") + func fromPersisted() { + #expect(Self.catalog.defaultPack == Self.strings) + #expect(Self.catalog.fromPersisted(nil) == Self.strings) + #expect(Self.catalog.fromPersisted("") == Self.strings) + #expect(Self.catalog.fromPersisted("trombone") == Self.strings) + #expect(Self.catalog.fromPersisted("juno-0") == Self.juno) + #expect(Self.catalog.fromPersisted("none") == SoundPack.none) + } + + @Test("a default id naming no voice silences the cues instead of trapping") + func unknownDefaultVoiceID() { + // A host's catalog is data the engine can't validate at compile time. Falling + // back to `.none` means a typo (or an empty catalog) costs the chimes, not the + // launch. + let broken = SoundPackCatalog(voices: [Self.brass], defaultVoiceID: "rom1a-6") + #expect(broken.defaultPack == SoundPack.none) + #expect(SoundPackCatalog(voices: [], defaultVoiceID: "rom1a-6").groups.isEmpty) + } + + @Test("a voice claiming the reserved none id can't take away silence") + func reservedNoneID() { + // check.sh flags this collision in *this* repo; a host has no such gate, so + // the resolution order is what guarantees "no sound" stays selectable. The + // impostor loses its own slot, which is the harmless half of the trade. + let impostor = SoundPack(id: "none", label: "Impostor", group: "DX7") + let catalog = SoundPackCatalog(voices: [impostor], defaultVoiceID: "none") + #expect(catalog.find(id: "none") == SoundPack.none) + #expect(catalog.fromPersisted("none") == SoundPack.none) } } diff --git a/scripts/check-invariants.sh b/scripts/check-invariants.sh index 92886b7e..88d5580a 100755 --- a/scripts/check-invariants.sh +++ b/scripts/check-invariants.sh @@ -92,7 +92,7 @@ PATTERNS=( "LSUIElement" "import KeyboardShortcuts" "AppUpdater|Sparkle|SPUUpdater" - "KeychainStore\\(service: *(BlurtIdentity\\.keychainService|\"blurt\")" + "KeychainStore\\(service: *(HostIdentity\\.current\\.keychainService|\"blurt\")" "@available\\(\\*, *deprecated" ) SCOPES=( @@ -139,7 +139,7 @@ PROBES=( " LSUIElement: true" "import KeyboardShortcuts" "let updater = AppUpdater(owner: \"assemblyai\", repo: \"blurt\")" - "let store = KeychainStore(service: BlurtIdentity.keychainService, account: \"AssemblyAIAPIKey\")" + "let store = KeychainStore(service: HostIdentity.current.keychainService, account: \"AssemblyAIAPIKey\")" "@available(*, deprecated, renamed: \"NewName\")" ) diff --git a/scripts/check.sh b/scripts/check.sh index a4575b55..c91fa655 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -273,18 +273,20 @@ check_ignore_rules() { run_check "ignore rules don't shadow tracked files" check_ignore_rules # Sound-catalog integrity. `SoundPackCatalog.swift` and the cue audio are both -# emitted by scripts/generate-sounds.swift, but they land in different targets — -# engine source vs. app resources — so a partial regeneration or commit can leave -# them disagreeing. Nothing at runtime notices: `SoundPack.startFileName` names a +# emitted by scripts/generate-sounds.swift into the app target, but as source and +# as resources — so a partial regeneration or commit can still leave them +# disagreeing. Nothing at runtime notices: `SoundPack.startFileName` names a # stem, `Bundle.main.url(forResource:)` returns nil for it, and the cue play is a # silent no-op, so the user picks that voice and simply hears nothing. Unit tests -# can't cover it either — the engine deliberately ships no resources, so the two +# can't cover it either — the engine ships neither the voices nor the audio (see +# `SoundPackCatalog`), and the app target has no unit-test bundle, so the two # halves only meet here. Pure text/filesystem, so it runs in --portable too. check_sound_catalog() { - local catalog="$REPO_ROOT/Sources/BlurtEngine/Audio/SoundPackCatalog.swift" + local catalog="$APP_DIR/Blurt/SoundPackCatalog.swift" local sounds_dir="$APP_DIR/Blurt/Resources/Sounds" local violation=0 local catalog_ids duplicate_ids expected_sounds actual_sounds missing_sounds orphan_sounds + local default_voice_id catalog_ids="$(sed -n 's/.*SoundPack(id: "\([^"]*\)".*/\1/p' "$catalog" | sort)" # A broken id extraction is reported like any other failure rather than via @@ -303,10 +305,27 @@ check_sound_catalog() { violation=1 fi - # `none` belongs to SoundPack.none; a catalog entry claiming it would shadow the - # silent pack in `find(id:)`, so a user could never select "no sound" again. + # `none` belongs to SoundPack.none, which `SoundPackCatalog` keeps reachable + # whatever it is handed — so an entry claiming that id loses its own slot and + # ships two unreachable cue files rather than costing the user "no sound". + # Still a generator bug: it means a voice silently vanished from the picker. if printf '%s\n' "$catalog_ids" | grep -qx 'none'; then - echo "error: a catalog entry uses the reserved id 'none' — it would shadow SoundPack.none" >&2 + echo "error: a catalog entry uses the reserved id 'none' — SoundPack.none wins the" >&2 + echo " lookup, so that voice is unreachable and its two cues ship for nothing" >&2 + violation=1 + fi + + # The default voice is the app's to name now that the catalog is host-supplied + # (`SoundPackCatalog(voices:defaultVoiceID:)`), and a default naming no voice + # falls back to `.none` — i.e. a fresh install would ship with the cues off, + # which reads as "the chimes are broken" rather than as a bad id. + default_voice_id="$(sed -n 's/.*defaultVoiceID: "\([^"]*\)".*/\1/p' "$catalog" | head -1)" + if [ -z "$default_voice_id" ]; then + echo "error: no defaultVoiceID found in $catalog — the catalog names no default voice" >&2 + violation=1 + elif ! printf '%s\n' "$catalog_ids" | grep -qx -- "$default_voice_id"; then + echo "error: defaultVoiceID '$default_voice_id' names no voice in the catalog —" >&2 + echo " a fresh install would resolve to SoundPack.none and play no cues" >&2 violation=1 fi diff --git a/scripts/generate-sounds.swift b/scripts/generate-sounds.swift index aa01b551..b459654e 100644 --- a/scripts/generate-sounds.swift +++ b/scripts/generate-sounds.swift @@ -281,9 +281,16 @@ for n in junoNames.indices { } print("wrote \(count) normalized cue .m4a files to \(outDir.path)") -// Emit the Swift catalog the app reads (one SoundPack per voice). -let catalogURL = scriptDir.appendingPathComponent("../Sources/BlurtEngine/Audio/SoundPackCatalog.swift") +// Emit the Swift catalog the app reads (one SoundPack per voice). It lands in the +// APP target, next to the Resources/Sounds/ files written above: the engine ships +// neither the audio nor the voice list, because a catalog whose stems name files +// in someone else's bundle is a picker in which every choice plays silence. Both +// halves are this script's output, and check.sh fails if they disagree. +let catalogURL = scriptDir.appendingPathComponent("../App/Blurt/Blurt/SoundPackCatalog.swift") .standardizedFileURL +// The voice an unset `BlurtSoundPack` resolves to. Emitted rather than left to the +// engine, which has no opinion about which voices exist. +let defaultVoiceID = "rom1a-6" // ORCHESTRA (Yamaha DX7 ROM1A voice 6) func swiftString(_ s: String) -> String { "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\"" @@ -292,9 +299,17 @@ var lines: [String] = [ "// Generated by scripts/generate-sounds.swift — do not edit by hand.", "// One SoundPack per voice (Yamaha DX7 ROM1A/1B, then Roland Juno-106).", "", - "extension SoundPack {", + "import BlurtEngine", + "", + "extension SoundPackCatalog {", + " /// Blurt's cue voices. App-side, not engine-side, because the `.m4a` files the", + " /// ids name live in this target's `Resources/Sounds/` — the same script writes", + " /// both, and `check.sh` fails if they ever disagree.", + " static let blurt = SoundPackCatalog(" + + "voices: blurtVoices, defaultVoiceID: \(swiftString(defaultVoiceID)))", + "", " /// Every selectable voice, grouped by synth, in render order.", - " public static let catalog: [SoundPack] = [", + " private static let blurtVoices: [SoundPack] = [", ] for r in rows { lines.append( @@ -305,3 +320,6 @@ lines.append(" ]") lines.append("}") try (lines.joined(separator: "\n") + "\n").write(to: catalogURL, atomically: true, encoding: .utf8) print("wrote catalog (\(rows.count) voices) to \(catalogURL.path)") +print("note: the cues and the catalog are app-target files, referenced individually in the") +print(" .pbxproj — after adding or removing a voice, run `xcodegen generate` in") +print(" App/Blurt and commit the result, or check.sh fails on project drift.") diff --git a/scripts/reset-install.sh b/scripts/reset-install.sh index c4d9cf7c..97a8d7e9 100755 --- a/scripts/reset-install.sh +++ b/scripts/reset-install.sh @@ -6,8 +6,8 @@ set -euo pipefail # App/Blurt/project.yml), where the split is also explained: releases are # `dev.alex.blurt`, every debug configuration is `dev.alex.blurt.dev`, so a dev # build is a separate app with its own permissions, defaults and install path. -# The first must match `BlurtIdentity.subsystem` -# (Sources/BlurtEngine/BlurtIdentity.swift) — the code's single definition of +# The first must match `HostIdentity.blurt.subsystem` +# (Sources/BlurtEngine/HostIdentity.swift) — the code's single definition of # this string. A full reset means both: this script exists to get back to a # clean preinstall state, and leaving half the state behind is how you end up # debugging the other build's leftovers. @@ -62,7 +62,7 @@ for bundle_id in "${BUNDLE_IDS[@]}"; do done # AssemblyAI API key lives in the login keychain as a generic password. The -# keychain service is `BlurtIdentity.keychainService` (used by APIKeyStore, +# keychain service is `HostIdentity.blurt.keychainService` (used by APIKeyStore, # Sources/BlurtEngine/Config/APIKeyStore.swift). Must match that constant. # Installs that predate the service rename may still hold the key under the # old service (the lowercase bundle id), so a full reset deletes both. From c9908ea872af7ae25c8b81304e2f1633ed7f6682 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 18 Aug 2026 08:45:11 -0700 Subject: [PATCH 2/2] run xcodegen --- App/Blurt/Blurt.xcodeproj/project.pbxproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/App/Blurt/Blurt.xcodeproj/project.pbxproj b/App/Blurt/Blurt.xcodeproj/project.pbxproj index 4fdc5afe..b9126c7a 100644 --- a/App/Blurt/Blurt.xcodeproj/project.pbxproj +++ b/App/Blurt/Blurt.xcodeproj/project.pbxproj @@ -237,6 +237,7 @@ 8DEF999DFD616C2FD17923DE /* juno-119-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 114E0EA1827EA005CF188FC8 /* juno-119-start.m4a */; }; 8E70ED1163B872F8D691CDC5 /* juno-118-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = E084A62CF8F167165466AE30 /* juno-118-start.m4a */; }; 8E89114ADA5FCE4AF9DFB3C0 /* juno-116-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 486F5FE65FF8C13A35C28271 /* juno-116-start.m4a */; }; + 8F3F1CC7203A148A68DFCCFF /* SoundPackCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78C5D6E7BD14D68FEB9BA215 /* SoundPackCatalog.swift */; }; 8FE4B9B139BB9557FBC5589B /* OverlayWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A27D7F43295DE2A81EFFDDBC /* OverlayWindowController.swift */; }; 91C1257866F6E2D4EE65BD09 /* WindowAccessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B2312DE4BC0F4F070830AD7 /* WindowAccessor.swift */; }; 91D775ACB2351468D47731FF /* rom1a-12-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = E2BCA8E305B596F960CEE1AE /* rom1a-12-start.m4a */; }; @@ -671,6 +672,7 @@ 77C74C74945D0A990578D911 /* rom1b-10-start.m4a */ = {isa = PBXFileReference; path = "rom1b-10-start.m4a"; sourceTree = ""; }; 77E9E7F17972483F6FBF1CCD /* Blurt.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Blurt.entitlements; sourceTree = ""; }; 786672855B44AB80CAF4D782 /* rom1b-29-stop.m4a */ = {isa = PBXFileReference; path = "rom1b-29-stop.m4a"; sourceTree = ""; }; + 78C5D6E7BD14D68FEB9BA215 /* SoundPackCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundPackCatalog.swift; sourceTree = ""; }; 791EFC0EE82C0E91F316B51E /* juno-97-stop.m4a */ = {isa = PBXFileReference; path = "juno-97-stop.m4a"; sourceTree = ""; }; 792258F8AAF3AF35104EE0CF /* rom1a-19-start.m4a */ = {isa = PBXFileReference; path = "rom1a-19-start.m4a"; sourceTree = ""; }; 79F8F36D7C5D074BC5135FEA /* juno-90-start.m4a */ = {isa = PBXFileReference; path = "juno-90-start.m4a"; sourceTree = ""; }; @@ -917,6 +919,7 @@ 7D222ED050CC93DEB330E773 /* CueSoundPlayer.swift */, 08A7417835D8538AC4A7E2FB /* DictationComposition.swift */, 0566F4FC45C72A6DA586126E /* Info.plist */, + 78C5D6E7BD14D68FEB9BA215 /* SoundPackCatalog.swift */, 6072A6B0AC8219B5484326B3 /* UITestSupport.swift */, 7D6DE85EA50E7CD4CC977211 /* Branding */, DF92BBDB4B9C15FAE100B783 /* Hotkey */, @@ -2011,6 +2014,7 @@ B5ADC67B78CA3EA70D6BAEB3 /* RecentDictationsSection.swift in Sources */, 6A1402BFED3758AAD9C84ADF /* SettingRow.swift in Sources */, 1D1100AB3D47EFC80D47B04B /* SettingsWindowRoot.swift in Sources */, + 8F3F1CC7203A148A68DFCCFF /* SoundPackCatalog.swift in Sources */, 69E27229D8C64D7119918B7A /* SoundStepView.swift in Sources */, 1817ACD2F7D8F7AF3C9B1C7C /* UITestIdentifiers.swift in Sources */, E1CA5C692CB2A7E1BF231928 /* UITestSupport.swift in Sources */,