From 859c24492d059573103e86b376b7c13c94ae18a4 Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:43:55 +0200 Subject: [PATCH 01/80] Core: one Duration-to-seconds helper and one dictionary merge Six copies of the attoseconds arithmetic become Duration.timeInterval. The Dictionary tab's import, an accepted learned correction and the learner's known-rule check each normalised dictionary keys their own way; they now share DictionaryEntry.mergeKey, hasRule(for:) and merge(_:), with tests. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/Pladder/AppModel.swift | 30 ++--------- Sources/Pladder/Settings/DictionaryView.swift | 38 ++----------- Sources/PladderCLI/main.swift | 25 ++++----- .../Learning/CorrectionLearner.swift | 7 +-- .../PladderCore/Models/DictionaryEntry.swift | 53 +++++++++++++++++++ .../Support/Duration+TimeInterval.swift | 9 ++++ .../FluidAudioIncrementalEngine.swift | 7 +-- Sources/PladderRefine/S1MiniPolisher.swift | 3 +- .../PladderRefine/TranscriptPolisher.swift | 3 +- Sources/PladderSystem/PasteboardOutput.swift | 3 +- .../DictionaryEntryTests.swift | 45 ++++++++++++++++ 11 files changed, 130 insertions(+), 93 deletions(-) create mode 100644 Sources/PladderCore/Support/Duration+TimeInterval.swift diff --git a/Sources/Pladder/AppModel.swift b/Sources/Pladder/AppModel.swift index e0fc08a..2689f57 100644 --- a/Sources/Pladder/AppModel.swift +++ b/Sources/Pladder/AppModel.swift @@ -379,8 +379,7 @@ final class AppModel { private func handle(_ event: DictationCoordinator.Event, at instant: ContinuousClock.Instant) { func fmt(_ duration: Duration) -> String { - let d = Self.seconds(duration) - return String(format: "%.3f", d) + String(format: "%.3f", duration.timeInterval) } switch event { case .recordingStarted: @@ -398,7 +397,7 @@ final class AppModel { } guard let released = releaseInstant else { return } releaseInstant = nil - let total = Self.seconds(instant - released) + let total = (instant - released).timeInterval // A polish cycle gets its own line, so the plain one stays the // number the benchmark rule watches; one predicate finds both. let polish = timing.polish.map { "polish \(fmt($0)) (\(settings.polishModel.rawValue)), " } ?? "" @@ -431,7 +430,7 @@ final class AppModel { private func propose(_ proposal: CorrectionProposal) { let key = proposal.pair.key guard !proposals.contains(where: { $0.pair.key == key }), - !Self.dictionary(settings.dictionary, has: proposal.pair.heard) else { return } + !settings.dictionary.hasRule(for: proposal.pair.heard) else { return } proposals = Array(([proposal] + proposals).prefix(Self.maximumProposals)) } @@ -440,18 +439,7 @@ final class AppModel { /// settings setter, so it is saved and the next dictation uses it. func acceptProposal(_ proposal: CorrectionProposal) { proposals.removeAll { $0.id == proposal.id } - let from = proposal.pair.heard.lowercased() - var dictionary = settings.dictionary - let entry = DictionaryEntry(from: proposal.pair.heard, to: proposal.pair.corrected) - if let index = dictionary.firstIndex(where: { - $0.from.trimmingCharacters(in: .whitespaces).lowercased() == from - }) { - dictionary[index].to = entry.to - dictionary[index].matchCase = false - } else { - dictionary.append(entry) - } - settings.dictionary = dictionary + settings.dictionary.merge([DictionaryEntry(from: proposal.pair.heard, to: proposal.pair.corrected)]) } /// Drops the line and remembers the pair so it is never proposed again. @@ -461,16 +449,6 @@ final class AppModel { Task { await dismissed.dismiss(proposal.pair) } } - private static func dictionary(_ entries: [DictionaryEntry], has heard: String) -> Bool { - let key = heard.lowercased() - return entries.contains { $0.from.trimmingCharacters(in: .whitespaces).lowercased() == key } - } - - private static func seconds(_ duration: Duration) -> Double { - let parts = duration.components - return Double(parts.seconds) + Double(parts.attoseconds) / 1e18 - } - // MARK: Permissions func refreshPermissions() { diff --git a/Sources/Pladder/Settings/DictionaryView.swift b/Sources/Pladder/Settings/DictionaryView.swift index 40ccbd9..d48b44f 100644 --- a/Sources/Pladder/Settings/DictionaryView.swift +++ b/Sources/Pladder/Settings/DictionaryView.swift @@ -213,42 +213,10 @@ struct DictionaryView: View { } } - /// Adds entries, overwriting an existing row with the same key instead of - /// creating a duplicate. - /// - /// A regular rule is keyed by its `from`. A custom word — an empty `from`, - /// which is how `CustomWordCorrector` reads its terms — has no `from` to key - /// on, so it is keyed by its `to` instead. Without that second key every - /// custom word would either be dropped on import or duplicated on every - /// re-import. A row with neither is nothing at all and is skipped. + /// Adds entries, overwriting an existing row with the same key instead + /// of creating a duplicate (see `DictionaryEntry.mergeKey`). private func merge(_ incoming: [DictionaryEntry]) { - var result = model.settings.dictionary - var indexByKey = [String: Int]() - for (index, entry) in result.enumerated() { - if let key = Self.mergeKey(for: entry) { indexByKey[key] = index } - } - for var entry in incoming { - guard let key = Self.mergeKey(for: entry) else { continue } - if let index = indexByKey[key] { - // Keep the existing identity so selection and focus survive. - entry.id = result[index].id - result[index] = entry - } else { - indexByKey[key] = result.count - result.append(entry) - } - } - model.settings.dictionary = result - } - - /// `from:` for a replacement rule, `to:` for a custom word, nil for a row - /// with neither. - private static func mergeKey(for entry: DictionaryEntry) -> String? { - let from = entry.from.trimmingCharacters(in: .whitespaces) - if !from.isEmpty { return "from:" + from.lowercased() } - let to = entry.to.trimmingCharacters(in: .whitespaces) - if !to.isEmpty { return "to:" + to.lowercased() } - return nil + model.settings.dictionary.merge(incoming) } } diff --git a/Sources/PladderCLI/main.swift b/Sources/PladderCLI/main.swift index 68f1642..6c89d47 100644 --- a/Sources/PladderCLI/main.swift +++ b/Sources/PladderCLI/main.swift @@ -128,11 +128,6 @@ func loadEngine(_ engine: any TranscriptionEngine) async throws -> Duration { return clock.now - started } -func seconds(_ duration: Duration) -> Double { - let parts = duration.components - return Double(parts.seconds) + Double(parts.attoseconds) / 1e18 -} - func median(_ values: [Double]) -> Double { let sorted = values.sorted() guard !sorted.isEmpty else { return 0 } @@ -202,7 +197,7 @@ func transcribeFile(_ path: String, process: Bool, verbose: Bool) async { do { let engine = FluidAudioIncrementalEngine() let loadTime = try await loadEngine(engine) - if verbose { print(String(format: "model ready in %.1fs", seconds(loadTime))) } + if verbose { print(String(format: "model ready in %.1fs", loadTime.timeInterval)) } let samples = try loadSamples(URL(fileURLWithPath: path)) let transcript = try await engine.transcribe(samples) @@ -282,7 +277,7 @@ func runBench(dir: String, runs: Int, pause: Double) async throws { print("") let loadTime = try await loadEngine(engine) - print(String(format: "model load (cold): %.2f s", seconds(loadTime))) + print(String(format: "model load (cold): %.2f s", loadTime.timeInterval)) if let bytes = physicalFootprintBytes() { print(String(format: "memory after load: %.0f MB (physical footprint)", Double(bytes) / 1_048_576)) } @@ -301,7 +296,7 @@ func runBench(dir: String, runs: Int, pause: Double) async throws { if pause > 0 { try await Task.sleep(for: .seconds(pause)) } let started = clock.now let transcript = try await engine.transcribe(fixture.samples) - let elapsed = seconds(clock.now - started) + let elapsed = (clock.now - started).timeInterval let wer = WordErrorRate.compute(reference: fixture.reference, hypothesis: transcript.text) let thermal = thermalTag() throttled = throttled || !thermal.isEmpty @@ -395,7 +390,7 @@ func runPacedBench(dir: String, runs: Int, pause: Double, includeShort: Bool, li print("") let loadTime = try await loadEngine(engine) - print(String(format: "model load (cold): %.2f s", seconds(loadTime))) + print(String(format: "model load (cold): %.2f s", loadTime.timeInterval)) print("") struct Row { @@ -427,7 +422,7 @@ func runPacedBench(dir: String, runs: Int, pause: Double, includeShort: Bool, li while !Task.isCancelled { let started = clock.now _ = await engine.livePass() - passes.record(seconds(clock.now - started)) + passes.record((clock.now - started).timeInterval) try? await Task.sleep(for: .milliseconds(500)) } } : nil @@ -448,7 +443,7 @@ func runPacedBench(dir: String, runs: Int, pause: Double, includeShort: Bool, li liveTask?.cancel() let started = clock.now let transcript = try await engine.endUtterance(tail) - let elapsed = seconds(clock.now - started) + let elapsed = (clock.now - started).timeInterval var final = transcript final.audioDuration = fixture.duration lastText = final.text @@ -480,7 +475,7 @@ func runPacedBench(dir: String, runs: Int, pause: Double, includeShort: Bool, li if pause > 0 { try await Task.sleep(for: .seconds(pause)) } let batchStarted = clock.now let batchTranscript = try await engine.transcribe(fixture.samples) - let batchElapsed = seconds(clock.now - batchStarted) + let batchElapsed = (clock.now - batchStarted).timeInterval let batchWer = WordErrorRate.compute(reference: fixture.reference, hypothesis: batchTranscript.text) print(String(format: "%@ whole: %.3f s, WER %.1f%%", fixture.name, batchElapsed, batchWer * 100)) let difference = firstWordDifference(batch: batchTranscript.text, paced: lastText) @@ -539,7 +534,7 @@ func runPolish(_ path: String, model: PolishModel, options: PolishOptions) async func show(_ label: String, _ report: TranscriptPolisher.Report) { print("") - let timing = String(format: "%.3f", seconds(report.elapsed)) + let timing = String(format: "%.3f", report.elapsed.timeInterval) if let polished = report.text { print("\(label): \(timing) s, \(report.wordsIn) words in, \(report.wordsOut) out, \(report.mode.rawValue)") print(polished) @@ -647,12 +642,12 @@ func runPolishSet(_ path: String, model: PolishModel, options: PolishOptions) as let processed = await pipeline.run(item.input) let report = await polisher.polish(processed) let output = report.text ?? processed - times.append(seconds(report.elapsed)) + times.append(report.elapsed.timeInterval) rates[item.lang, default: []].append(WordErrorRate.compute(reference: item.expected, hypothesis: output)) let matched = output.trimmingCharacters(in: .whitespacesAndNewlines) == item.expected if matched { exact += 1 } let failure = report.failure.map { " (\($0))" } ?? "" - print(String(format: "%@ %-26@ %5.2f s%@", matched ? "=" : " ", item.id, seconds(report.elapsed), failure)) + print(String(format: "%@ %-26@ %5.2f s%@", matched ? "=" : " ", item.id, report.elapsed.timeInterval, failure)) print(" \(output.replacingOccurrences(of: "\n", with: "⏎"))") } print("") diff --git a/Sources/PladderCore/Learning/CorrectionLearner.swift b/Sources/PladderCore/Learning/CorrectionLearner.swift index 0f0a55d..923bfcd 100644 --- a/Sources/PladderCore/Learning/CorrectionLearner.swift +++ b/Sources/PladderCore/Learning/CorrectionLearner.swift @@ -69,15 +69,12 @@ public final class CorrectionLearner: Sendable { log("\(pairs.count) candidates") guard !pairs.isEmpty else { return } - let known = Set(await dictionary().compactMap { entry -> String? in - let from = entry.from.trimmingCharacters(in: .whitespaces).lowercased() - return from.isEmpty ? nil : from - }) + let rules = await dictionary() var seen: Set = [] var proposed = 0 for pair in pairs where seen.insert(pair.key).inserted { let name = "\(pair.heard) → \(pair.corrected)" - if known.contains(pair.heard.lowercased()) { + if rules.hasRule(for: pair.heard) { log("in the dictionary \(name)") continue } diff --git a/Sources/PladderCore/Models/DictionaryEntry.swift b/Sources/PladderCore/Models/DictionaryEntry.swift index 198be2a..55df99e 100644 --- a/Sources/PladderCore/Models/DictionaryEntry.swift +++ b/Sources/PladderCore/Models/DictionaryEntry.swift @@ -18,6 +18,27 @@ public struct DictionaryEntry: Codable, Sendable, Equatable, Identifiable, Hasha self.matchCase = matchCase } + /// `from` as rules are compared: trimmed and lowercased. Empty for a + /// custom word, which has no `from`. + public var normalizedFrom: String { Self.normalized(from) } + + /// The form two spellings of a heard phrase are compared in. + public static func normalized(_ text: String) -> String { + text.trimmingCharacters(in: .whitespaces).lowercased() + } + + /// What two entries collide on when one is added over the other: + /// `from:` for a replacement rule, `to:` for a custom word (an empty + /// `from`, which is how `CustomWordCorrector` reads its terms), nil for + /// a row with neither, which is nothing at all. + public var mergeKey: String? { + let from = normalizedFrom + if !from.isEmpty { return "from:" + from } + let to = Self.normalized(self.to) + if !to.isEmpty { return "to:" + to } + return nil + } + /// The ids of every entry that sits on a replacement cycle. /// /// An edge runs from rule A to a different rule B when A's `to` contains @@ -79,3 +100,35 @@ public struct DictionaryEntry: Codable, Sendable, Equatable, Identifiable, Hasha return false } } + +extension Array where Element == DictionaryEntry { + /// True when a rule already replaces `heard`, whatever its case or + /// surrounding spaces. + public func hasRule(for heard: String) -> Bool { + let key = DictionaryEntry.normalized(heard) + return !key.isEmpty && contains { $0.normalizedFrom == key } + } + + /// Adds `incoming`, overwriting an existing row with the same + /// `mergeKey` rather than creating a duplicate. The overwritten row keeps + /// its `id`, so selection and focus in the Dictionary tab survive. Rows + /// without a key are skipped. The one way entries are added: the + /// Dictionary tab's import and an accepted learned correction both come + /// through here. + public mutating func merge(_ incoming: [DictionaryEntry]) { + var indexByKey: [String: Int] = [:] + for (index, entry) in enumerated() { + if let key = entry.mergeKey { indexByKey[key] = index } + } + for var entry in incoming { + guard let key = entry.mergeKey else { continue } + if let index = indexByKey[key] { + entry.id = self[index].id + self[index] = entry + } else { + indexByKey[key] = count + append(entry) + } + } + } +} diff --git a/Sources/PladderCore/Support/Duration+TimeInterval.swift b/Sources/PladderCore/Support/Duration+TimeInterval.swift new file mode 100644 index 0000000..b15adff --- /dev/null +++ b/Sources/PladderCore/Support/Duration+TimeInterval.swift @@ -0,0 +1,9 @@ +import Foundation + +extension Duration { + /// The duration in seconds, for logs and for the `TimeInterval` APIs. + public var timeInterval: TimeInterval { + let parts = components + return Double(parts.seconds) + Double(parts.attoseconds) / 1e18 + } +} diff --git a/Sources/PladderEngines/FluidAudioIncrementalEngine.swift b/Sources/PladderEngines/FluidAudioIncrementalEngine.swift index 18219e4..ab1af02 100644 --- a/Sources/PladderEngines/FluidAudioIncrementalEngine.swift +++ b/Sources/PladderEngines/FluidAudioIncrementalEngine.swift @@ -207,7 +207,7 @@ public actor FluidAudioIncrementalEngine: StreamingTranscriptionEngine { return Transcript( text: result.text, audioDuration: Double(fedSampleCount) / CapturedAudio.sampleRate, - processingTime: Self.seconds(elapsed), + processingTime: elapsed.timeInterval, engineID: id ) } @@ -317,11 +317,6 @@ public actor FluidAudioIncrementalEngine: StreamingTranscriptionEngine { /// the window still holds 10 s of context at its narrowest. private static let windowHopSamples = 80_000 - private static func seconds(_ duration: Duration) -> Double { - let parts = duration.components - return Double(parts.seconds) + Double(parts.attoseconds) / 1e18 - } - /// What the menu says under `Model failed:` when the load throws. /// /// The distinction worth a user's attention is whether the network or the diff --git a/Sources/PladderRefine/S1MiniPolisher.swift b/Sources/PladderRefine/S1MiniPolisher.swift index 6c0f521..ce1d5dc 100644 --- a/Sources/PladderRefine/S1MiniPolisher.swift +++ b/Sources/PladderRefine/S1MiniPolisher.swift @@ -153,8 +153,7 @@ public actor S1MiniPolisher: TranscriptRefiner { } private static func log(_ report: TranscriptPolisher.Report, file: ModelFile) { - let secs = String(format: "%.3f", Double(report.elapsed.components.seconds) - + Double(report.elapsed.components.attoseconds) / 1e18) + let secs = String(format: "%.3f", report.elapsed.timeInterval) if let failure = report.failure { log.notice( "polish (\(file.fileName, privacy: .public)) failed after \(secs, privacy: .public) s, \(report.wordsIn, privacy: .public) words in: \(failure, privacy: .public)") diff --git a/Sources/PladderRefine/TranscriptPolisher.swift b/Sources/PladderRefine/TranscriptPolisher.swift index 092ec30..4d48a81 100644 --- a/Sources/PladderRefine/TranscriptPolisher.swift +++ b/Sources/PladderRefine/TranscriptPolisher.swift @@ -220,8 +220,7 @@ public actor TranscriptPolisher: TranscriptRefiner { } private static func log(_ report: Report) { - let secs = String(format: "%.3f", Double(report.elapsed.components.seconds) - + Double(report.elapsed.components.attoseconds) / 1e18) + let secs = String(format: "%.3f", report.elapsed.timeInterval) if let failure = report.failure { log.notice( "polish failed after \(secs, privacy: .public) s, \(report.wordsIn, privacy: .public) words in: \(failure, privacy: .public)") diff --git a/Sources/PladderSystem/PasteboardOutput.swift b/Sources/PladderSystem/PasteboardOutput.swift index 78e275c..fdddfa4 100644 --- a/Sources/PladderSystem/PasteboardOutput.swift +++ b/Sources/PladderSystem/PasteboardOutput.swift @@ -261,8 +261,7 @@ public actor PasteboardOutput: TextOutput { private func transcriptRead(_ promise: TranscriptPromise, at instant: ContinuousClock.Instant) { guard let current = pending, current.promise === promise, let posted = current.posted else { return } if current.lastRead == nil { - let after = (instant - posted).components - let seconds = Double(after.seconds) + Double(after.attoseconds) / 1e18 + let seconds = (instant - posted).timeInterval Self.log.notice("clipboard read \(seconds, format: .fixed(precision: 3)) s after Cmd+V") } pending?.lastRead = instant diff --git a/Tests/PladderCoreTests/DictionaryEntryTests.swift b/Tests/PladderCoreTests/DictionaryEntryTests.swift index 6948f68..3dedb70 100644 --- a/Tests/PladderCoreTests/DictionaryEntryTests.swift +++ b/Tests/PladderCoreTests/DictionaryEntryTests.swift @@ -55,3 +55,48 @@ import Testing #expect(replacer.apply(to: "a b claude code") == "a b Claude Code") } } + +@Suite struct DictionaryMergeTests { + @Test func aRuleWithTheSameHeardAsOverwritesAndKeepsItsID() { + let existing = DictionaryEntry(from: " Clode Code ", to: "claude code", matchCase: true) + var entries = [existing] + entries.merge([DictionaryEntry(from: "clode code", to: "Claude Code")]) + #expect(entries.count == 1) + #expect(entries[0].id == existing.id) + #expect(entries[0].to == "Claude Code") + #expect(entries[0].matchCase == false) + } + + @Test func aCustomWordIsKeyedByItsReplacement() { + var entries = [DictionaryEntry(from: "", to: "Pladder")] + entries.merge([DictionaryEntry(from: "", to: "pladder "), DictionaryEntry(from: "", to: "Parakeet")]) + #expect(entries.map(\.to) == ["pladder ", "Parakeet"]) + } + + @Test func aRowWithNeitherSideIsSkipped() { + var entries: [DictionaryEntry] = [] + entries.merge([DictionaryEntry(from: " ", to: "")]) + #expect(entries.isEmpty) + } + + @Test func duplicatesWithinOneImportCollapse() { + var entries: [DictionaryEntry] = [] + entries.merge([DictionaryEntry(from: "a", to: "b"), DictionaryEntry(from: "A", to: "c")]) + #expect(entries.map(\.to) == ["c"]) + } + + @Test func hasRuleIgnoresCaseAndSpacesButNotCustomWords() { + let entries = [DictionaryEntry(from: " Swift UI", to: "SwiftUI"), DictionaryEntry(from: "", to: "Pladder")] + #expect(entries.hasRule(for: "swift ui")) + #expect(!entries.hasRule(for: "pladder")) + #expect(!entries.hasRule(for: " ")) + } +} + +@Suite struct DurationTimeIntervalTests { + @Test func secondsAndFractionsSurvive() { + #expect(Duration.seconds(2).timeInterval == 2) + #expect(abs(Duration.milliseconds(1_500).timeInterval - 1.5) < 1e-12) + #expect(Duration.zero.timeInterval == 0) + } +} From 5394cff22a06a664390d0ef9456693ef2a3dcfe6 Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:17:03 +0200 Subject: [PATCH 02/80] Coordinator, app and settings: one end-of-recording path, a narrow settings type, an app split by job Coordinator - Takes DictationSettings, the part of Settings it acts on; the app hands it a new value only when that part changed, so UI clicks never rebuild the processors. - One endRecording() for every way a recording ends and one hotkeyConfigurationChanged() for every way the chord set changes. - Every wait goes through an injected clock. - The release waits for the feed loop to hand over a chunk it drained, so every sample reaches the engine once and the fed count is exact; no live pass starts after the release. - A cancel abandons the recording's own engine once, after the feed, and a press on its heels waits for that, so it cannot abandon the new utterance. - A streaming engine that cannot begin an utterance is transcribed whole at release instead of failing as not loaded. - Recordings are numbered; the output muter gets the number with each call. - inserted carries whether the text was pasted or copied and whether Return followed; shutdown() gives back the speakers and the clipboard at quit. App - AppModel is the composition root and settings owner; HotkeyRouter, PermissionMonitor, PolishModelController, CorrectionInbox and SettingsLocation each take one job; one generic MainActorRelay; menu wording and processor names move to StatusText. - Quit and SIGTERM wait (3 s at most) for the coordinator's shutdown. - The overlay demo and screenshots use a scratch settings file and no menu bar item. - Learning skips copies and sends. Settings - Decoded one key at a time on top of the defaults: an unknown case, a damaged dictionary row or a missing engine no longer moves the whole file aside. A file that lost something is copied aside first; backups get unique names. Saving keeps keys a newer build wrote; launchAtLogin, never read, is retired. Scripts and CI - bundle.sh no longer exits silently without a certificate, and --install stops only the installed copy. CI runs that path, fails on warnings in Sources, selects Xcode 26 strictly, and checks scripts never kill every Pladder. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 57 +- Sources/Pladder/AppModel.swift | 708 ++++------------ Sources/Pladder/CorrectionInbox.swift | 76 ++ Sources/Pladder/HotkeyRouter.swift | 138 ++++ Sources/Pladder/MainActorRelay.swift | 12 + Sources/Pladder/OverlayDemo.swift | 5 +- Sources/Pladder/PermissionMonitor.swift | 91 ++ Sources/Pladder/PladderApp.swift | 84 +- Sources/Pladder/PolishModelController.swift | 95 +++ Sources/Pladder/Settings/DictionaryView.swift | 16 +- Sources/Pladder/Settings/SettingsView.swift | 51 +- Sources/Pladder/SettingsLocation.swift | 52 ++ Sources/Pladder/StatusText.swift | 104 ++- Sources/PladderCLI/main.swift | 4 +- .../PladderCore/DictationCoordinator.swift | 777 ++++++++++-------- .../Models/DictationSettings.swift | 48 ++ .../PladderCore/Models/DictionaryEntry.swift | 13 + Sources/PladderCore/Models/Settings.swift | 217 +++-- .../PladderCore/Protocols/TextOutput.swift | 5 + .../Support/OutputMuterSessionShim.swift | 6 + Sources/PladderCore/Support/TimingLine.swift | 18 + .../PladderSystem/StandardProcessors.swift | 35 +- .../DictationCoordinatorTests.swift | 271 +++--- Tests/PladderCoreTests/ManualClock.swift | 74 ++ Tests/PladderCoreTests/SettingsTests.swift | 264 ++++++ Tests/PladderCoreTests/TimingLineTests.swift | 27 + .../StandardProcessorsTests.swift | 15 + scripts/bundle.sh | 18 +- scripts/overlay-demo.sh | 4 + 29 files changed, 2078 insertions(+), 1207 deletions(-) create mode 100644 Sources/Pladder/CorrectionInbox.swift create mode 100644 Sources/Pladder/HotkeyRouter.swift create mode 100644 Sources/Pladder/MainActorRelay.swift create mode 100644 Sources/Pladder/PermissionMonitor.swift create mode 100644 Sources/Pladder/PolishModelController.swift create mode 100644 Sources/Pladder/SettingsLocation.swift create mode 100644 Sources/PladderCore/Models/DictationSettings.swift create mode 100644 Sources/PladderCore/Support/OutputMuterSessionShim.swift create mode 100644 Sources/PladderCore/Support/TimingLine.swift create mode 100644 Tests/PladderCoreTests/ManualClock.swift create mode 100644 Tests/PladderCoreTests/SettingsTests.swift create mode 100644 Tests/PladderCoreTests/TimingLineTests.swift create mode 100644 Tests/PladderSystemTests/StandardProcessorsTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f441fec..621fbd9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,23 +1,26 @@ name: CI +# `paths` with negations rather than `paths-ignore`: GitHub documents the `!` +# re-include only for `paths`, and this is what makes an edit to this file +# run CI while the rest of .github and the docs do not. Later patterns win. on: push: branches: [main] - paths-ignore: - - 'docs/**' - - '.github/**' - - '!.github/**/*.yml' - - 'README.md' - - 'LICENSE' - - '**/*.md' + paths: + - '**' + - '!docs/**' + - '!**/*.md' + - '!LICENSE' + - '!.github/**' + - '.github/workflows/*.yml' pull_request: - paths-ignore: - - 'docs/**' - - '.github/**' - - '!.github/**/*.yml' - - 'README.md' - - 'LICENSE' - - '**/*.md' + paths: + - '**' + - '!docs/**' + - '!**/*.md' + - '!LICENSE' + - '!.github/**' + - '.github/workflows/*.yml' jobs: build-and-test: @@ -26,15 +29,33 @@ jobs: steps: - uses: actions/checkout@v4 - name: Select Xcode 26 - run: sudo xcode-select -s /Applications/Xcode_26.app || true + # The newest Xcode 26 on the runner. No fallback: building with the + # runner's default Xcode would test a toolchain Pladder does not use. + run: | + XCODE=$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1) + test -n "$XCODE" + sudo xcode-select -s "$XCODE" - name: Swift version run: swift --version - name: Build - run: swift build + # Warnings in Pladder's own sources fail the build; the dependencies' + # are theirs to fix. + run: | + set -o pipefail + swift build 2>&1 | tee build.log + if grep -E "^$PWD/Sources/.*warning:" build.log; then exit 1; fi - name: Test run: swift test - - name: Bundle (ad-hoc signed) - run: CODESIGN_IDENTITY=- ./scripts/bundle.sh + - name: Scripts never stop every Pladder + # The developer dictates with a running copy while scripts run; a + # bare kill by name would stop it mid-recording (see CLAUDE.md). + run: | + if grep -rnE 'pkill -x Pladder|killall Pladder' scripts; then exit 1; fi + - name: Bundle + # No CODESIGN_IDENTITY on purpose: the runner has no certificate, so + # this runs the auto-detection and its ad-hoc fallback, which once + # ended the script silently on a Mac without a certificate. + run: ./scripts/bundle.sh - uses: actions/upload-artifact@v4 with: name: Pladder.app diff --git a/Sources/Pladder/AppModel.swift b/Sources/Pladder/AppModel.swift index 2689f57..143583f 100644 --- a/Sources/Pladder/AppModel.swift +++ b/Sources/Pladder/AppModel.swift @@ -1,5 +1,4 @@ import AppKit -import AVFoundation import Foundation import Observation import PladderCore @@ -10,91 +9,41 @@ import PladderAudio import PladderEngines import PladderRefine -/// Composition root. Builds the engine registry, the settings store and the -/// coordinator, owns the overlay, and exposes everything the UI needs. +/// Composition root. Builds the engine registry, the settings store, the +/// coordinator and the parts around it, owns the settings, and turns the +/// coordinator's events into sounds, log lines and learning. +/// +/// What each part does lives with it: `HotkeyRouter` picks the hotkey +/// source, `PermissionMonitor` polls the grants, `PolishModelController` +/// owns the polish model, `CorrectionInbox` the learned corrections, and the +/// menu's wording is in `StatusText.swift`. @MainActor @Observable final class AppModel { let registry: EngineRegistry let coordinator: DictationCoordinator + let permissions: PermissionMonitor + let hotkeys: HotkeyRouter + let polish: PolishModelController + let corrections: CorrectionInbox - /// Text processors in pipeline order. The single source of truth for both - /// the settings toggles (ids, display names, details) and the runtime - /// pipeline `makePipeline` builds below. - let processors: [any TextProcessor] - - /// Mirrored permission state, refreshed on a timer so the menu and the - /// settings window stay correct after the user flips a switch in System - /// Settings (neither API offers a change notification). - private(set) var accessibilityTrusted: Bool = Permissions.isAccessibilityTrusted - private(set) var microphoneStatus: AVAuthorizationStatus = Permissions.microphoneStatus - - /// Secure Event Input, polled with the permissions. While it is on an - /// event tap sees no key-downs, so a chord with a regular key would fire - /// for nothing; `refreshPermissions()` hands such a chord to Carbon until - /// it clears. Only a *sustained* reading counts, so the password field the - /// user tabs through does not swap the monitor twice in four seconds. - private var secureInput = SustainedCondition() - private(set) var secureInputSustained = false - - /// The keyboard shortcuts macOS itself handles. Read on explicit triggers - /// only — launch, a permission flip, a hotkey edit, the settings window - /// opening — because `CopySymbolicHotKeys` is main-thread work linear in - /// the number of shortcuts and the answer changes about as often as - /// someone visits System Settings. Never on a key press. - private(set) var systemShortcuts: Set = [] - - /// Whether Apple Intelligence can polish right now, for the polish - /// toggle's row. Polled with the permissions: it can be switched on or - /// off in System Settings while the app runs, and the read is cheap. The - /// coordinator never asks; an unavailable model makes the toggle a - /// no-op that pastes as dictated. - private(set) var polishAvailability: OnDeviceModelAvailability = TranscriptPolisher.availability - - /// Where the chosen polish model's file stands; nil for Apple's, which - /// has none. Shown under the model picker. - private(set) var polishModelStatus: ModelFileStatus? - /// The coordinator's refiner; `applyPolishModel` points it at the model - /// the settings name. - private let polishRouter: PolishRouter - private let applePolisher = TranscriptPolisher() - /// The S1-mini polisher for the chosen file, nil while Apple's is chosen. - /// Only one is ever held, so switching frees the other's memory. - private var s1MiniPolisher: S1MiniPolisher? - private let modelFiles: ModelFiles - private let modelStatusRelay = ModelStatusRelay() - - /// The default chord, standing in for a stored chord Carbon cannot - /// register while Accessibility is missing. The stored chord is never - /// rewritten and returns with the grant. - var standInHotkey: Hotkey? { - accessibilityTrusted ? nil : settings.hotkey.standInWithoutAccessibility - } - - /// So the first `refreshPermissions()` sets the stand-in even though - /// nothing flipped; `hotkeyUsesTap` already matches the grant at that point. - private var didComputeEffectiveHotkey = false - - private let store: SettingsStore - private let events = EventRelay() - private let overlay: OverlayController - private var permissionTask: Task? - private var didRequestAccessibility = false + /// Set when `setLaunchAtLogin` fails, so settings can show the reason + /// under the toggle. + private(set) var launchAtLoginError: String? + /// The login item's state, which can be changed behind the app's back in + /// System Settings: re-read when the settings window opens. + private(set) var launchAtLoginEnabled = LaunchAtLogin.isEnabled - /// Both hotkey sources, kept for the app's life so switching between them - /// costs nothing. The tap swallows the chord's regular key and matches - /// left and right modifiers exactly, but needs Accessibility; Carbon needs - /// no permission at all and is what a standard account gets. - private let tapHotkey = GlobalHotkeyMonitor() - private let carbonHotkey = CarbonHotkeyMonitor() - /// Which of the two the coordinator is currently driven by. - private var hotkeyUsesTap: Bool + @ObservationIgnored private let store: SettingsStore + @ObservationIgnored private let overlay: OverlayController + @ObservationIgnored private let events = MainActorRelay<(DictationCoordinator.Event, ContinuousClock.Instant)>() /// When the hotkey was released, for the release-to-paste measurement. - private var releaseInstant: ContinuousClock.Instant? + @ObservationIgnored private var releaseInstant: ContinuousClock.Instant? + /// Release-to-paste time per dictation, the number the user feels. Read /// it with: log show --last 1h --predicate 'subsystem == "de.dinooo13.pladder"' - private static let timing = Logger(subsystem: "de.dinooo13.pladder", category: "timing") + private static let timingLog = Logger(subsystem: "de.dinooo13.pladder", category: "timing") /// A processor that throws is skipped by `ProcessorPipeline` rather than /// losing the dictation; this is where that gets logged. `Logger` is /// `Sendable`, so this is safe to reach from the `@Sendable` failure @@ -104,68 +53,30 @@ final class AppModel { /// the app quitting mid-recording, a device vanishing — is silent and /// baffling otherwise, so both ends of it are logged `.public`. private nonisolated static let muteLog = Logger(subsystem: "de.dinooo13.pladder", category: "mute") - /// The correction learner's stages. They quote the user's words, so the - /// text is `.private`: `log show` prints it only with private data on. - private nonisolated static let learningLog = Logger(subsystem: "de.dinooo13.pladder", category: "learning") - - /// Watches a field after the paste and proposes what the user corrected. - private let learner: CorrectionLearner - private let dismissedCorrections: DismissedCorrections - private let proposalRelay = ProposalRelay() - /// Corrections the model agreed with, newest first, waiting in the menu - /// for Add or Dismiss. Kept for the app's life or until answered. - private(set) var proposals: [CorrectionProposal] = [] - static let maximumProposals = 3 /// Hotkey behaviour worth knowing about after the fact, such as a /// keyboard that bounces. private static let hotkeyLog = Logger(subsystem: "de.dinooo13.pladder", category: "hotkey") - - /// Settings live in the coordinator (it reacts to hotkey/engine changes); - /// this forwards and persists. Applying the appearance covers every - /// window and menu at once, so no view has to care. - /// - /// Each side effect runs only when its own keys changed. Re-assigning - /// `NSApp.appearance` is not free: with a forced Light or Dark it makes - /// AppKit re-theme every window, so doing it on every click of a settings - /// card made the card rows lag behind the click. + private static let settingsLog = Logger(subsystem: "de.dinooo13.pladder", category: "settings") + + /// The settings, persisted on every real change. Assignments that change + /// nothing are ignored, and each side effect runs only when its own keys + /// changed. Re-assigning `NSApp.appearance` is not free: with a forced + /// Light or Dark it makes AppKit re-theme every window, so doing it on + /// every click of a settings card made the card rows lag behind the + /// click. The coordinator sees only `DictationSettings`, and only when + /// that part changed. var settings: Settings { - get { coordinator.settings } + get { storedSettings } set { - let old = coordinator.settings + let old = storedSettings guard newValue != old else { return } - coordinator.settings = newValue - if newValue.appearance != old.appearance { - applyAppearance(newValue.appearance) - } - if newValue.overlayStyle != old.overlayStyle || newValue.overlayGlass != old.overlayGlass { - overlay.applyStyle(newValue.overlayStyle, glass: newValue.overlayGlass) - } - if newValue.overlayAnimationSpeed != old.overlayAnimationSpeed { - overlay.applySpeed(newValue.overlayAnimationSpeed) - } - if newValue.hotkey != old.hotkey { - // A chord with a regular key no longer needs a stand-in, and a - // modifier-only one does. - updateEffectiveHotkey() - } - if newValue.polishModel != old.polishModel || newValue.polishDictations != old.polishDictations { - applyPolishModel() - } - try? store.save(newValue) - } - } - - private func applyAppearance(_ appearance: Appearance) { - let resolved: NSAppearance? = switch appearance { - case .system: nil - case .light: NSAppearance(named: .aqua) - case .dark: NSAppearance(named: .darkAqua) + storedSettings = newValue + settingsChanged(from: old) } - NSApp.appearance = resolved - overlay.applyAppearance(appearance) } + private var storedSettings: Settings - init() { + init(location: SettingsLocation) { // Engines, in the order the settings picker shows them. The first // entry is the default for new installs. var registry = EngineRegistry() @@ -189,120 +100,57 @@ final class AppModel { #endif self.registry = registry - let store = SettingsStore( - url: Self.settingsURL, - defaults: Settings(engineID: FluidAudioIncrementalEngine.engineID) - ) - // A test copy starts from the defaults, not from an old install. - if Self.settingsPathOverride == nil { - Self.migrateLegacySettings(to: Self.settingsURL) - } - self.store = store - + location.migrateLegacySettingsIfNeeded() + store = SettingsStore(url: location.settingsURL, defaults: Settings(engineID: FluidAudioIncrementalEngine.engineID)) // One read: the store moves an undecodable file aside on load, so a // second read could see different settings than the first. var initial = store.load() - // An engine that was removed in an update leaves a stale ID behind. // `EngineRegistry.make` already falls back to the first entry, so the // app works either way; rewriting the ID keeps the settings picker // showing what is actually running. - if registry.entry(for: initial.engineID) == nil, - let fallback = registry.available.first { + if registry.entry(for: initial.engineID) == nil, let fallback = registry.available.first { initial.engineID = fallback.id } + storedSettings = initial - // Processors, in pipeline order; the list and its reasons are in - // `StandardProcessors`, which the CLI shares. - let processorFactories = StandardProcessors.factories - self.processors = processorFactories.map { $0(initial) } - - let router = PolishRouter(applePolisher) - polishRouter = router - let modelStatusRelay = self.modelStatusRelay - modelFiles = ModelFiles(directory: ModelFiles.defaultDirectory) { file, status in - modelStatusRelay.send(file, status) - } + let permissions = PermissionMonitor() + self.permissions = permissions + let hotkeys = HotkeyRouter(accessibilityTrusted: permissions.accessibilityTrusted, hotkey: initial.hotkey) + self.hotkeys = hotkeys + let polish = PolishModelController() + self.polish = polish let events = self.events - let trusted = Permissions.isAccessibilityTrusted - hotkeyUsesTap = trusted let coordinator = DictationCoordinator( - settings: initial, + settings: DictationSettings(initial), registry: registry, capture: AVAudioEngineCapture(), output: PasteboardOutput(), outputMuter: OutputMuteController( control: CoreAudioOutputMute(), log: { Self.muteLog.info("\($0, privacy: .public)") }), - refiner: router, - hotkeyMonitor: trusted ? tapHotkey : carbonHotkey, - makePipeline: { s in - ProcessorPipeline(processorFactories.map { $0(s) }, onFailure: { id, error in + refiner: polish.refiner, + hotkeyMonitor: hotkeys.initialMonitor, + makePipeline: { settings in + StandardProcessors.pipeline(for: settings, onFailure: { id, error in Self.processorLog.error("processor \(id, privacy: .public) failed: \(error.localizedDescription, privacy: .public)") }) }, - onEvent: { [events] event in events.send(event) } + // Stamped when the coordinator emits it, not when the main actor + // gets to it, so the release-to-paste measurement does not + // include scheduling delay. + onEvent: { event in events.send((event, .now)) } ) self.coordinator = coordinator - - // Nothing here runs before the paste: `handle(.inserted)` hands the - // pasted text over, and the learner watches and reviews on its own - // thread and task. - let dismissed = DismissedCorrections(url: Self.dismissedCorrectionsURL) - dismissedCorrections = dismissed - let proposalRelay = self.proposalRelay - learner = CorrectionLearner( - observer: AXPasteObserver(), - reviewer: FoundationModelsCorrectionReviewer(), - dismissed: dismissed, - dictionary: { await coordinator.settings.dictionary }, - log: { Self.learningLog.info("\($0, privacy: .private)") }, - onProposal: { proposalRelay.send($0) } - ) - + hotkeys.attach(coordinator) + corrections = CorrectionInbox(dismissedURL: location.dismissedCorrectionsURL) overlay = OverlayController(coordinator: coordinator) - events.handler = { [weak self] event, at in self?.handle(event, at: at) } - proposalRelay.handler = { [weak self] proposal in self?.propose(proposal) } - modelStatusRelay.handler = { [weak self] file, status in - guard let self, ModelFile(for: self.settings.polishModel) == file else { return } - self.polishModelStatus = status - } - } - - /// `PLADDER_SETTINGS_PATH` points a copy launched for testing at a file - /// of its own, so it neither reads nor writes the configuration of the - /// copy in daily use; every recorder commit is saved at once. Development - /// only: no UI, and a normal launch never has it set. - static var settingsPathOverride: String? { - guard let path = ProcessInfo.processInfo.environment["PLADDER_SETTINGS_PATH"], - !path.isEmpty else { return nil } - return path - } - - static var settingsURL: URL { - if let path = settingsPathOverride { return URL(filePath: path) } - return FileManager.default - .homeDirectoryForCurrentUser - .appending(path: "Library/Application Support/Pladder/settings.json") - } - - /// The corrections the user dismissed, beside the settings but not in - /// them (see `DismissedCorrections`). - static var dismissedCorrectionsURL: URL { - settingsURL.deletingLastPathComponent().appending(path: "dismissed-corrections.json") - } - /// One-time migration from the pre-rename location. The dictionary and - /// hotkey settings were kept in `~/Library/Application Support/SpeakUp/` - /// before the app was called Pladder; copy them across exactly once, only - /// when the new file does not exist yet. The old file is left in place. - static func migrateLegacySettings(to destination: URL) { - guard !FileManager.default.fileExists(atPath: destination.path) else { return } - let legacy = FileManager.default - .homeDirectoryForCurrentUser - .appending(path: "Library/Application Support/SpeakUp/settings.json") - try? FileManager.default.copyItem(at: legacy, to: destination) + events.handler = { [weak self] in self?.handle($0.0, at: $0.1) } + corrections.dictionary = { [weak self] in self?.settings.dictionary ?? [] } + corrections.addRules = { [weak self] rules in self?.settings.dictionary.merge(rules) } + permissions.onRefresh = { [weak self] in self?.permissionsRefreshed() } } // MARK: Lifecycle @@ -311,106 +159,103 @@ final class AppModel { applyAppearance(settings.appearance) overlay.applyStyle(settings.overlayStyle, glass: settings.overlayGlass) overlay.applySpeed(settings.overlayAnimationSpeed) - refreshPermissions() - if !accessibilityTrusted && !didRequestAccessibility { - didRequestAccessibility = true - Permissions.requestAccessibility() - } + permissions.start() overlay.start() coordinator.start() - applyPolishModel() - startPermissionMirroring() + polish.apply(model: settings.polishModel, polishing: settings.polishDictations) } - // MARK: Polish model + /// The app is quitting: everything the coordinator borrowed is given + /// back first. The app delegate waits for this, with a deadline. + func shutdown() async { + permissions.stop() + overlay.stop() + await coordinator.shutdown() + } + + private func permissionsRefreshed() { + hotkeys.update( + accessibilityTrusted: permissions.accessibilityTrusted, + secureInputEnabled: permissions.secureInputEnabled) + polish.refreshAvailability() + } - /// Points the coordinator's refiner at the chosen model. An S1-mini file - /// is downloaded only while polish is on and that model is chosen, the - /// one network use besides the speech model's; it is loaded at the first - /// key-down, not here, and freed when polish goes off or another model - /// is picked. - private func applyPolishModel() { - guard let file = ModelFile(for: settings.polishModel) else { - releaseS1Mini() - polishRouter.use(applePolisher) - polishModelStatus = nil - return + /// Re-reads what can change behind the app's back and is about to be + /// shown: the grants, the shortcuts macOS owns, the login item. + func settingsWindowOpened() { + permissions.refresh() + hotkeys.refreshSystemShortcuts() + let enabled = LaunchAtLogin.isEnabled + if enabled != launchAtLoginEnabled { launchAtLoginEnabled = enabled } + } + + // MARK: Settings + + private func settingsChanged(from old: Settings) { + let dictation = DictationSettings(settings) + if dictation != coordinator.settings { coordinator.settings = dictation } + if settings.appearance != old.appearance { + applyAppearance(settings.appearance) } - if s1MiniPolisher?.file != file { - releaseS1Mini() - let polisher = S1MiniPolisher(file: file, location: modelFiles.location(of: file)) - s1MiniPolisher = polisher - polishRouter.use(polisher) + if settings.overlayStyle != old.overlayStyle || settings.overlayGlass != old.overlayGlass { + overlay.applyStyle(settings.overlayStyle, glass: settings.overlayGlass) } - let polishing = settings.polishDictations - if !polishing, let polisher = s1MiniPolisher { - Task { await polisher.unload() } + if settings.overlayAnimationSpeed != old.overlayAnimationSpeed { + overlay.applySpeed(settings.overlayAnimationSpeed) } - if polishing { - Task.detached(priority: .utility) { await S1MiniPolisher.warmUpRuntime() } + if settings.hotkey != old.hotkey { + hotkeys.hotkeyChanged(to: settings.hotkey) } - let files = modelFiles - Task { [weak self] in - if polishing { await files.ensure(file) } - let status = await files.status(of: file) - guard let self, ModelFile(for: self.settings.polishModel) == file else { return } - self.polishModelStatus = status + if settings.polishModel != old.polishModel || settings.polishDictations != old.polishDictations { + polish.apply(model: settings.polishModel, polishing: settings.polishDictations) + } + do { + try store.save(settings) + } catch { + Self.settingsLog.error("could not save settings: \(error.localizedDescription, privacy: .public)") } } - /// Tries a failed download again; the picker's "Try Again". - func retryPolishModelDownload() { - guard let file = ModelFile(for: settings.polishModel) else { return } - let files = modelFiles - Task { await files.ensure(file) } + /// Applying the appearance to `NSApp` covers every window and menu at + /// once, so no view has to care; the overlay's panel is told on its own. + private func applyAppearance(_ appearance: Appearance) { + NSApp.appearance = appearance.nsAppearance + overlay.applyAppearance(appearance) } - private func releaseS1Mini() { - guard let polisher = s1MiniPolisher else { return } - s1MiniPolisher = nil - Task { await polisher.unload() } + func setLaunchAtLogin(_ enabled: Bool) { + do { + try LaunchAtLogin.setEnabled(enabled) + launchAtLoginError = nil + } catch { + launchAtLoginError = error.localizedDescription + } + launchAtLoginEnabled = LaunchAtLogin.isEnabled } - func stop() { - permissionTask?.cancel() - overlay.stop() - coordinator.stop() - } + // MARK: Events private func handle(_ event: DictationCoordinator.Event, at instant: ContinuousClock.Instant) { - func fmt(_ duration: Duration) -> String { - String(format: "%.3f", duration.timeInterval) - } switch event { case .recordingStarted: if settings.playSounds { SoundPlayer.playStart() } case .recordingStopped: releaseInstant = instant if settings.playSounds { SoundPlayer.playStop() } - case .inserted(let transcript, let timing): + case .inserted(let insertion): + // Strictly after the paste and off the measured window, which + // ended when the coordinator emitted this event. Only a paste + // that stayed in its field can be corrected: a copy was never + // pasted, and a send emptied the field. defer { - // Strictly after the paste and off the measured window, which - // ended when the coordinator emitted this event: the learner - // spawns its own task and reads the field on its own thread. - // Without the grant nothing was pasted, only copied. - if accessibilityTrusted { learner.pasted(transcript.text) } + if insertion.result == .pasted, !insertion.submitted { + corrections.pasted(insertion.transcript.text) + } } guard let released = releaseInstant else { return } releaseInstant = nil - let total = (instant - released).timeInterval - // A polish cycle gets its own line, so the plain one stays the - // number the benchmark rule watches; one predicate finds both. - let polish = timing.polish.map { "polish \(fmt($0)) (\(settings.polishModel.rawValue)), " } ?? "" - let label = timing.polish == nil ? "release-to-paste" : "polished release-to-paste" - let stages = "stop \(fmt(timing.captureStop)), engine \(fmt(timing.engine)), " + - "process \(fmt(timing.processing)), \(polish)paste \(fmt(timing.insert))" - Self.timing.log( - """ - \(label, privacy: .public) \(total, format: .fixed(precision: 3), privacy: .public) s: \(stages, privacy: .public); \ - audio \(transcript.audioDuration, format: .fixed(precision: 1), privacy: .public) s, \ - engine-time \(transcript.processingTime, format: .fixed(precision: 3), privacy: .public) s - """ - ) + let line = insertion.timingLine(total: instant - released, polishModel: settings.polishModel.rawValue) + Self.timingLog.log("\(line, privacy: .public)") case .failed: releaseInstant = nil case .recordingDiscarded: @@ -421,155 +266,12 @@ final class AppModel { case .keyboardBounceObserved: // That wait comes before `recordingStopped`, so the timing line // cannot show it; this line is what explains a felt delay. - Self.hotkeyLog.notice("keyboard bounce observed: releases now settle for 50 ms before stopping") - } - } - - // MARK: Learned corrections - - private func propose(_ proposal: CorrectionProposal) { - let key = proposal.pair.key - guard !proposals.contains(where: { $0.pair.key == key }), - !settings.dictionary.hasRule(for: proposal.pair.heard) else { return } - proposals = Array(([proposal] + proposals).prefix(Self.maximumProposals)) - } - - /// Adds `heard → corrected` to the dictionary, overwriting a rule with the - /// same `from` the way the Dictionary tab's import does. Through the - /// settings setter, so it is saved and the next dictation uses it. - func acceptProposal(_ proposal: CorrectionProposal) { - proposals.removeAll { $0.id == proposal.id } - settings.dictionary.merge([DictionaryEntry(from: proposal.pair.heard, to: proposal.pair.corrected)]) - } - - /// Drops the line and remembers the pair so it is never proposed again. - func dismissProposal(_ proposal: CorrectionProposal) { - proposals.removeAll { $0.id == proposal.id } - let dismissed = dismissedCorrections - Task { await dismissed.dismiss(proposal.pair) } - } - - // MARK: Permissions - - func refreshPermissions() { - accessibilityTrusted = Permissions.isAccessibilityTrusted - microphoneStatus = Permissions.microphoneStatus - let polish = TranscriptPolisher.availability - if polish != polishAvailability { polishAvailability = polish } - let sustained = secureInput.observe(SecureInput.isEnabled) - if sustained != secureInputSustained { secureInputSustained = sustained } - // Granting Accessibility upgrades the hotkey to the tap; revoking it - // drops back to Carbon. Either way a recording in progress is dropped - // by the coordinator, since the old monitor's release can no longer - // arrive. - // - // Secure input is the second reason to leave the tap: it stops taps - // seeing key-downs, so a chord with a regular key is dead there. - // Carbon can take over only for a chord it can register, and a - // modifier-only chord is unaffected by secure input anyway, so both - // stay on the tap and nothing swaps. The push-to-talk chord alone - // decides; the toggle chord follows whichever monitor is up. - let wantsTap = accessibilityTrusted - && !(sustained && settings.hotkey.canBeRegisteredWithoutAccessibility) - let flipped = wantsTap != hotkeyUsesTap - if flipped { - hotkeyUsesTap = wantsTap - coordinator.replaceHotkeyMonitor(wantsTap ? tapHotkey : carbonHotkey) - } - // After the swap: the new monitor is started with the old stand-in and - // then, if it changed, once more with the new one. The other order - // would make the Carbon monitor log a failure for a modifier-only - // chord on the way to being replaced by the tap. - if flipped || !didComputeEffectiveHotkey { - didComputeEffectiveHotkey = true - updateEffectiveHotkey() - refreshSystemShortcuts() - } - } - - /// Re-reads the shortcuts macOS owns, which feed the warnings shown in the - /// settings window. Called when that window opens, the moment the answer - /// is about to be shown to the user and the recorder about to be handed it. - func refreshSystemShortcuts() { - let shortcuts = SystemShortcuts.enabled() - if shortcuts != systemShortcuts { systemShortcuts = shortcuts } - } - - /// With the grant the tap matches anything; without it a chord Carbon - /// cannot register listens for nothing, so the default takes its place. - private func updateEffectiveHotkey() { - coordinator.hotkeyOverride = standInHotkey - } - - /// The enabled macOS shortcut that swallows `hotkey`, if any. Shown as a - /// warning in both modes: the tap sees such a chord, but the system - /// shortcut fires as well. - func systemShortcutConflict(for hotkey: Hotkey) -> Hotkey? { - hotkey.systemShortcutConflict(in: systemShortcuts) - } - - var needsAccessibility: Bool { !accessibilityTrusted } - /// Without Accessibility the chord has to contain a regular key, so the - /// recorder refuses modifier-only chords and settings says why. - var hotkeyNeedsRegularKey: Bool { !accessibilityTrusted } - var needsMicrophone: Bool { microphoneStatus != .authorized } - var needsAnyPermission: Bool { needsAccessibility || needsMicrophone } - - /// Polls permission status every 2 s for the app's life. Neither API - /// offers a change notification, so this is how the menu and the settings - /// window notice a permission flipped in System Settings — and, since - /// `refreshPermissions()` swaps the hotkey source, how the app moves - /// between the event tap and Carbon in both directions. It used to stop - /// once both permissions were granted; a later revoke has to be picked up - /// too, and two cheap status reads every two seconds cost nothing. - private func startPermissionMirroring() { - refreshPermissions() - permissionTask?.cancel() - permissionTask = Task { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(for: .seconds(2)) - guard let self else { return } - self.refreshPermissions() - } - } - } - - func grantAccessibility() { - Permissions.requestAccessibility() - Permissions.openAccessibilitySettings() - startPermissionMirroring() - } - - func grantMicrophone() { - Task { [weak self] in - if Permissions.microphoneStatus == .notDetermined { - _ = await Permissions.requestMicrophone() - } else { - Permissions.openMicrophoneSettings() - } - self?.refreshPermissions() - self?.startPermissionMirroring() + let window = coordinator.bounceWindow.timeInterval * 1000 + Self.hotkeyLog.notice("keyboard bounce observed: releases now settle for \(window, format: .fixed(precision: 0)) ms before stopping") } } - /// Set when `setLaunchAtLogin` fails, so settings can show the reason - /// under the toggle. `LaunchAtLogin.isEnabled` is the source of truth for - /// the toggle itself, since it can be changed behind the app's back in - /// System Settings. - private(set) var launchAtLoginError: String? - - func setLaunchAtLogin(_ enabled: Bool) { - do { - try LaunchAtLogin.setEnabled(enabled) - launchAtLoginError = nil - settings.launchAtLogin = enabled - } catch { - launchAtLoginError = error.localizedDescription - settings.launchAtLogin = LaunchAtLogin.isEnabled - } - } - - // MARK: Menu presentation + // MARK: Menu /// The app icon's waveform glyph, varied by state (see `MenuBarIcon`). var menuBarImage: NSImage { @@ -578,69 +280,28 @@ final class AppModel { /// One line describing what the app is doing right now. var statusLine: String { - switch coordinator.state { - case .recording: - // The Menu style has no pill, so this line is its latched cue. - guard coordinator.isLatched else { return String(localized: "Recording…") } - return String(localized: "Recording — press \(stopKeyName) to stop") - case .transcribing: return String(localized: "Transcribing…") - case .polishing: return String(localized: "Polishing…") - case .inserting: return String(localized: "Inserting…") - case .error(let failure): return String(localized: "Error: \(failure.text)") - case .copied: return String(localized: "Copied — press ⌘V") - case .idle: - return readyLine - case .unavailable: - switch coordinator.engineStatus { - case .downloading(let progress): - if let progress { - return String(localized: "Model: downloading \(Int((progress * 100).rounded()))%") - } - return String(localized: "Model: downloading…") - case .loading: return String(localized: "Model: loading…") - case .unloaded: return String(localized: "Model: not loaded") - case .failed(let failure): return String(localized: "Model failed: \(failure.text)") - case .ready: return readyLine - } - } - } - - /// The chord to hold, named the way the monitor that matches it sees the - /// keys: the tap tells Left from Right, Carbon's mask cannot. - var effectiveHotkeyName: String { - guard !hotkeyUsesTap else { return settings.hotkey.displayName } - return settings.hotkey.sideAgnosticDisplayName - } - - /// What ends a latched recording. Any chord does; this names the one that - /// latched it: the toggle key when it is a chord of its own, otherwise - /// the key, or what stands in for it. - private var stopKeyName: String { + MenuStatus.line( + state: coordinator.state, + engineStatus: coordinator.engineStatus, + isLatched: coordinator.isLatched, + chords: menuChords) + } + + /// The chords the menu names. Without Accessibility the stored chord may + /// be listening for nothing, so the stand-in is named instead; "hold + /// Right Command" would be a lie. Any chord ends a latched recording; + /// the line names the one that latched it: the toggle key when it is a + /// chord of its own, otherwise the key, or what stands in for it. + private var menuChords: MenuStatus.Chords { + let standIn = hotkeys.standInHotkey?.sideAgnosticDisplayName + let hold = standIn ?? hotkeys.displayName(of: settings.hotkey) let toggle = settings.toggleHotkey - if !toggle.isEmpty, toggle.canonical != settings.hotkey.canonical { - return hotkeyUsesTap ? toggle.displayName : toggle.sideAgnosticDisplayName - } - return standInHotkey?.sideAgnosticDisplayName ?? effectiveHotkeyName - } - - /// True while a working Accessibility grant is being ignored because - /// Secure Event Input has the tap deaf and Carbon is standing in. - var usesCarbonForSecureInput: Bool { accessibilityTrusted && !hotkeyUsesTap } - - /// What to say when nothing is happening. Without Accessibility the stored - /// chord may be listening for nothing, in which case the stand-in is named - /// instead; "hold Right Command" would be a lie. - private var readyLine: String { - if let standIn = standInHotkey { - return String(localized: "Ready — hold \(standIn.sideAgnosticDisplayName) (Accessibility is off)") - } - // Say why the send key and the swallowing stopped: both are the tap's, - // and the tap is deaf until Secure Keyboard Entry goes off again. A - // whole sentence either way: a suffix glued on cannot be translated. - if usesCarbonForSecureInput { - return String(localized: "Ready — hold \(effectiveHotkeyName) (Secure Keyboard Entry is on)") - } - return String(localized: "Ready — hold \(effectiveHotkeyName)") + let stop = !toggle.isEmpty && toggle.canonical != settings.hotkey.canonical + ? hotkeys.displayName(of: toggle) : hold + return MenuStatus.Chords( + hold: hold, stop: stop, + accessibilityOff: standIn != nil, + secureKeyboardEntry: hotkeys.usesCarbonForSecureInput) } var canRetryEngine: Bool { @@ -648,55 +309,18 @@ final class AppModel { return false } - /// A short, word-boundary-aware summary of the most recent transcript for - /// the menu; longer previews make the menu bar menu comically wide. var lastTranscriptSummary: String? { - guard let text = coordinator.lastTranscript?.text, !text.isEmpty else { return nil } - let limit = 32 - guard text.count > limit else { return text } - let head = String(text.prefix(limit)) - if let space = head.lastIndex(of: " "), head.distance(from: head.startIndex, to: space) > 20 { - return String(head[.. Void)? - - nonisolated func send(_ file: ModelFile, _ status: ModelFileStatus) { - Task { @MainActor in self.handler?(file, status) } - } -} - -/// Bridges the learner's proposals onto the main actor, and lets the learner -/// be built before `self` exists, as `EventRelay` does for the coordinator. -@MainActor -final class ProposalRelay { - var handler: ((CorrectionProposal) -> Void)? - - nonisolated func send(_ proposal: CorrectionProposal) { - Task { @MainActor in self.handler?(proposal) } - } -} - -/// Bridges the coordinator's nonisolated `onEvent` callback back onto the main -/// actor, and lets us hand the coordinator a callback before `self` exists. -/// -/// Each event is stamped when the coordinator emits it, not when the main -/// actor gets around to handling it, so the release-to-paste measurement does -/// not include scheduling delay. -@MainActor -final class EventRelay { - var handler: ((DictationCoordinator.Event, ContinuousClock.Instant) -> Void)? - - nonisolated func send(_ event: DictationCoordinator.Event) { - let at = ContinuousClock.now - Task { @MainActor in self.handler?(event, at) } +extension Appearance { + /// `nil` follows the system. + var nsAppearance: NSAppearance? { + switch self { + case .system: nil + case .light: NSAppearance(named: .aqua) + case .dark: NSAppearance(named: .darkAqua) + } } } diff --git a/Sources/Pladder/CorrectionInbox.swift b/Sources/Pladder/CorrectionInbox.swift new file mode 100644 index 0000000..c4e6089 --- /dev/null +++ b/Sources/Pladder/CorrectionInbox.swift @@ -0,0 +1,76 @@ +import Foundation +import Observation +import os +import PladderCore +import PladderRefine +import PladderSystem + +/// The learned-corrections menu lines: the learner watches a field after +/// each paste and proposes what the user corrected; this keeps the proposals +/// until the user answers Add or Dismiss. +/// +/// Nothing here runs before the paste: `pasted` hands the text over and the +/// learner watches and reviews on its own thread and task. +@MainActor +@Observable +final class CorrectionInbox { + /// Corrections the model agreed with, newest first. Kept for the app's + /// life or until answered. + private(set) var proposals: [CorrectionProposal] = [] + static let maximumProposals = 3 + + /// The dictionary as it is now, and how a rule gets into it. Set by the + /// app once it exists. + @ObservationIgnored var dictionary: () -> [DictionaryEntry] = { [] } + @ObservationIgnored var addRules: ([DictionaryEntry]) -> Void = { _ in } + + @ObservationIgnored private let dismissed: DismissedCorrections + @ObservationIgnored private let relay = MainActorRelay() + @ObservationIgnored private var learner: CorrectionLearner? + + /// The learner's stages. They quote the user's words, so the text is + /// `.private`: `log show` prints it only with private data on. + private nonisolated static let log = Logger(subsystem: "de.dinooo13.pladder", category: "learning") + + init(dismissedURL: URL) { + dismissed = DismissedCorrections(url: dismissedURL) + let relay = self.relay + learner = CorrectionLearner( + observer: AXPasteObserver(), + reviewer: FoundationModelsCorrectionReviewer(), + dismissed: dismissed, + dictionary: { [weak self] in await self?.currentDictionary() ?? [] }, + log: { Self.log.info("\($0, privacy: .private)") }, + onProposal: { relay.send($0) } + ) + relay.handler = { [weak self] in self?.propose($0) } + } + + private func currentDictionary() -> [DictionaryEntry] { dictionary() } + + /// The text that was pasted, strictly after the paste. + func pasted(_ text: String) { + learner?.pasted(text) + } + + /// Adds `heard → corrected` to the dictionary, overwriting a rule with the + /// same `from` the way the Dictionary tab's import does. + func accept(_ proposal: CorrectionProposal) { + proposals.removeAll { $0.id == proposal.id } + addRules([DictionaryEntry(from: proposal.pair.heard, to: proposal.pair.corrected)]) + } + + /// Drops the line and remembers the pair so it is never proposed again. + func dismiss(_ proposal: CorrectionProposal) { + proposals.removeAll { $0.id == proposal.id } + let dismissed = self.dismissed + Task { await dismissed.dismiss(proposal.pair) } + } + + private func propose(_ proposal: CorrectionProposal) { + let key = proposal.pair.key + guard !proposals.contains(where: { $0.pair.key == key }), + !dictionary().hasRule(for: proposal.pair.heard) else { return } + proposals = Array(([proposal] + proposals).prefix(Self.maximumProposals)) + } +} diff --git a/Sources/Pladder/HotkeyRouter.swift b/Sources/Pladder/HotkeyRouter.swift new file mode 100644 index 0000000..bd15883 --- /dev/null +++ b/Sources/Pladder/HotkeyRouter.swift @@ -0,0 +1,138 @@ +import Foundation +import Observation +import PladderCore +import PladderSystem + +/// Decides which hotkey monitor drives the coordinator and which chord it +/// listens for. +/// +/// Both monitors are kept for the app's life so switching costs nothing. The +/// tap swallows the chord's regular key and matches left and right modifiers +/// exactly, but needs Accessibility; Carbon needs no permission at all and is +/// what a standard account gets. Granting Accessibility upgrades to the tap, +/// revoking it drops back to Carbon, and a sustained Secure Event Input, +/// which stops taps seeing key-downs, hands a chord with a regular key to +/// Carbon until it clears. A modifier-only chord is unaffected by secure +/// input and Carbon cannot register it, so it stays on the tap. The +/// push-to-talk chord alone decides; the toggle chord follows whichever +/// monitor is up. +/// +/// Without Accessibility a stored chord Carbon cannot register listens for +/// nothing, so the default stands in for it. The stored chord is never +/// rewritten and returns with the grant. +@MainActor +@Observable +final class HotkeyRouter { + /// Which of the two the coordinator is currently driven by. + private(set) var usesTap: Bool + private(set) var accessibilityTrusted: Bool + /// Only a *sustained* reading counts, so the password field the user + /// tabs through does not swap the monitor twice in four seconds. + private(set) var secureInputSustained = false + /// The keyboard shortcuts macOS itself handles. Read on explicit + /// triggers only — launch, a permission flip, the settings window + /// opening — because `CopySymbolicHotKeys` is main-thread work linear in + /// the number of shortcuts and the answer changes about as often as + /// someone visits System Settings. Never on a key press. + private(set) var systemShortcuts: Set = [] + /// The stored push-to-talk chord, as the app last reported it. + private(set) var hotkey: Hotkey + + @ObservationIgnored private let tap = GlobalHotkeyMonitor() + @ObservationIgnored private let carbon = CarbonHotkeyMonitor() + @ObservationIgnored private var secureInput = SustainedCondition() + @ObservationIgnored private weak var coordinator: DictationCoordinator? + /// So the first `update` applies the stand-in even though nothing + /// flipped; `usesTap` already matches the grant at that point. + @ObservationIgnored private var didApplyStandIn = false + + init(accessibilityTrusted: Bool, hotkey: Hotkey) { + self.accessibilityTrusted = accessibilityTrusted + self.hotkey = hotkey + usesTap = accessibilityTrusted + } + + /// The monitor the coordinator starts with. + var initialMonitor: any HotkeyMonitor { usesTap ? tap : carbon } + + func attach(_ coordinator: DictationCoordinator) { + self.coordinator = coordinator + } + + /// Called after every permission poll. + func update(accessibilityTrusted trusted: Bool, secureInputEnabled: Bool) { + if trusted != accessibilityTrusted { accessibilityTrusted = trusted } + let sustained = secureInput.observe(secureInputEnabled) + if sustained != secureInputSustained { secureInputSustained = sustained } + let wantsTap = Self.wantsTap( + accessibilityTrusted: trusted, secureInputSustained: sustained, hotkey: hotkey) + let flipped = wantsTap != usesTap + if flipped { + usesTap = wantsTap + // A recording in progress is dropped by the coordinator, since the + // old monitor's release can no longer arrive. + coordinator?.replaceHotkeyMonitor(wantsTap ? tap : carbon) + } + // After the swap: the new monitor is started with the old stand-in and + // then, if it changed, once more with the new one. The other order + // would make the Carbon monitor log a failure for a modifier-only + // chord on the way to being replaced by the tap. + if flipped || !didApplyStandIn { + didApplyStandIn = true + applyStandIn() + refreshSystemShortcuts() + } + } + + /// A chord with a regular key no longer needs a stand-in, and a + /// modifier-only one does. + func hotkeyChanged(to hotkey: Hotkey) { + guard hotkey != self.hotkey else { return } + self.hotkey = hotkey + applyStandIn() + } + + /// The tap, unless Accessibility is missing, or secure input has the tap + /// deaf and Carbon can take the chord over. + static func wantsTap(accessibilityTrusted: Bool, secureInputSustained: Bool, hotkey: Hotkey) -> Bool { + accessibilityTrusted && !(secureInputSustained && hotkey.canBeRegisteredWithoutAccessibility) + } + + /// The default chord, standing in for a stored chord Carbon cannot + /// register while Accessibility is missing. + var standInHotkey: Hotkey? { + accessibilityTrusted ? nil : hotkey.standInWithoutAccessibility + } + + /// True while a working Accessibility grant is being ignored because + /// Secure Event Input has the tap deaf and Carbon is standing in. + var usesCarbonForSecureInput: Bool { accessibilityTrusted && !usesTap } + + /// Without Accessibility the chord has to contain a regular key, so the + /// recorder refuses modifier-only chords and settings says why. + var hotkeyNeedsRegularKey: Bool { !accessibilityTrusted } + + /// A chord named the way the monitor that matches it sees the keys: the + /// tap tells Left from Right, Carbon's mask cannot. + func displayName(of chord: Hotkey) -> String { + usesTap ? chord.displayName : chord.sideAgnosticDisplayName + } + + /// Re-reads the shortcuts macOS owns, which feed the warnings shown in + /// the settings window. + func refreshSystemShortcuts() { + let shortcuts = SystemShortcuts.enabled() + if shortcuts != systemShortcuts { systemShortcuts = shortcuts } + } + + /// The enabled macOS shortcut that swallows `chord`, if any. Shown as a + /// warning in both modes: the tap sees such a chord, but the system + /// shortcut fires as well. + func systemShortcutConflict(for chord: Hotkey) -> Hotkey? { + chord.systemShortcutConflict(in: systemShortcuts) + } + + private func applyStandIn() { + coordinator?.hotkeyOverride = standInHotkey + } +} diff --git a/Sources/Pladder/MainActorRelay.swift b/Sources/Pladder/MainActorRelay.swift new file mode 100644 index 0000000..c7e33f0 --- /dev/null +++ b/Sources/Pladder/MainActorRelay.swift @@ -0,0 +1,12 @@ +/// Carries values from a nonisolated callback onto the main actor, and lets +/// the callback be handed to something built before its handler exists: the +/// coordinator's events, the learner's proposals and the model files' status +/// all start out that way in `AppModel.init`. +@MainActor +final class MainActorRelay { + var handler: ((Value) -> Void)? + + nonisolated func send(_ value: Value) { + Task { @MainActor in self.handler?(value) } + } +} diff --git a/Sources/Pladder/OverlayDemo.swift b/Sources/Pladder/OverlayDemo.swift index b2572f2..2d67fdc 100644 --- a/Sources/Pladder/OverlayDemo.swift +++ b/Sources/Pladder/OverlayDemo.swift @@ -57,14 +57,13 @@ enum OverlayDemo { settings.polishDictations = scenario.polish settings.overlayStyle = style settings.overlayAnimationSpeed = speed - settings.playSounds = false let registry = EngineRegistry([ .init(id: EchoEngine.engineID, displayName: "Echo", detail: "") { EchoEngine(text: scenario.text, delay: scenario.engineDelay) } ]) let coordinator = DictationCoordinator( - settings: settings, + settings: DictationSettings(settings), registry: registry, capture: DemoCapture(), output: DemoOutput(result: scenario.result), @@ -138,7 +137,7 @@ private actor DemoCapture: AudioCapture { levels = continuation Task { var tick = 0.0 - while await self.isRunning { + while self.isRunning { continuation.yield(Float(0.2 + 0.4 * abs(sin(tick / 3)))) tick += 1 try? await Task.sleep(for: .milliseconds(50)) diff --git a/Sources/Pladder/PermissionMonitor.swift b/Sources/Pladder/PermissionMonitor.swift new file mode 100644 index 0000000..528aeda --- /dev/null +++ b/Sources/Pladder/PermissionMonitor.swift @@ -0,0 +1,91 @@ +import AVFoundation +import Foundation +import Observation +import PladderSystem + +/// Mirrors the grants Pladder depends on, polled because neither API offers +/// a change notification: Accessibility, the microphone, and Secure Event +/// Input, which is not a grant but turns the event tap deaf in the same way. +/// +/// The poll runs every two seconds for the app's life. It used to stop once +/// both permissions were granted; a later revoke has to be picked up too, +/// and a few cheap status reads every two seconds cost nothing. Everything +/// that reacts to a change — the hotkey source, the stand-in chord, the +/// polish availability — hangs off `onRefresh`, which runs after every poll. +@MainActor +@Observable +final class PermissionMonitor { + private(set) var accessibilityTrusted = Permissions.isAccessibilityTrusted + private(set) var microphoneStatus = Permissions.microphoneStatus + /// Read with the grants: the hotkey router decides from a sustained + /// reading, not from this one. + private(set) var secureInputEnabled = SecureInput.isEnabled + + @ObservationIgnored var onRefresh: () -> Void = {} + + @ObservationIgnored private var pollTask: Task? + @ObservationIgnored private var didRequestAccessibility = false + + var needsAccessibility: Bool { !accessibilityTrusted } + var needsMicrophone: Bool { microphoneStatus != .authorized } + var needsAnyPermission: Bool { needsAccessibility || needsMicrophone } + + /// Prompts for Accessibility once per launch when it is missing, and + /// starts polling. + func start() { + refresh() + if !accessibilityTrusted && !didRequestAccessibility { + didRequestAccessibility = true + Permissions.requestAccessibility() + } + startPolling() + } + + func stop() { + pollTask?.cancel() + pollTask = nil + } + + /// Assigns only what changed, so views reading one grant are not redrawn + /// for every poll. + func refresh() { + let trusted = Permissions.isAccessibilityTrusted + if trusted != accessibilityTrusted { accessibilityTrusted = trusted } + let microphone = Permissions.microphoneStatus + if microphone != microphoneStatus { microphoneStatus = microphone } + let secure = SecureInput.isEnabled + if secure != secureInputEnabled { secureInputEnabled = secure } + onRefresh() + } + + func grantAccessibility() { + Permissions.requestAccessibility() + Permissions.openAccessibilitySettings() + startPolling() + } + + func grantMicrophone() { + Task { [weak self] in + if Permissions.microphoneStatus == .notDetermined { + _ = await Permissions.requestMicrophone() + } else { + Permissions.openMicrophoneSettings() + } + self?.startPolling() + } + } + + /// Refreshes now and restarts the two-second cadence from here, so a + /// grant the user just gave shows without waiting out the old timer. + private func startPolling() { + refresh() + pollTask?.cancel() + pollTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(2)) + guard let self else { return } + self.refresh() + } + } + } +} diff --git a/Sources/Pladder/PladderApp.swift b/Sources/Pladder/PladderApp.swift index f163aa8..67dd184 100644 --- a/Sources/Pladder/PladderApp.swift +++ b/Sources/Pladder/PladderApp.swift @@ -8,9 +8,16 @@ import PladderSystem struct PladderApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate @State private var model: AppModel + /// The overlay demo and the screenshots draw with the app's own views but + /// are not the app: they get a throwaway settings file, so the live + /// configuration is never read, moved aside or written, and no menu bar + /// item, which would be a second, slashed Pladder in the bar. + private let isDeveloperMode: Bool init() { - let model = AppModel() + let isDeveloperMode = OverlayDemo.isRequested || Screenshots.directory != nil + self.isDeveloperMode = isDeveloperMode + let model = AppModel(location: isDeveloperMode ? .scratch : .live) _model = State(initialValue: model) // Deferred to the first run-loop turn so the app has finished launching // before we prompt for Accessibility. The screenshot and overlay demo @@ -20,12 +27,13 @@ struct PladderApp: App { } else if let directory = Screenshots.directory { Task { @MainActor in await Screenshots.run(into: directory) } } else { + AppDelegate.shutdown = { await model.shutdown() } Task { @MainActor in model.start() } } } var body: some Scene { - MenuBarExtra { + MenuBarExtra(isInserted: .constant(!isDeveloperMode)) { MenuContent(model: model) } label: { Image(nsImage: model.menuBarImage) @@ -43,9 +51,63 @@ struct PladderApp: App { /// `LSUIElement` in Info.plist already hides the Dock icon; setting the policy /// here too keeps `swift run` (no bundle, no Info.plist) behaving the same. +/// +/// Quitting gives back what a dictation borrowed before the process ends: a +/// latched recording would otherwise leave the speakers muted, and a paste +/// made in the last few seconds the user's clipboard holding the transcript. +/// SIGTERM, which `pkill` and `bundle.sh --install` send, quits the same way. +@MainActor final class AppDelegate: NSObject, NSApplicationDelegate { + /// What has to finish before the process may end. Set by the app; nil + /// in the developer modes, which quit at once. + static var shutdown: (@MainActor () async -> Void)? + /// How long quitting may wait for it. The restore and the unmute take + /// milliseconds; a dictation in flight with the polish model can take + /// seconds, and past this it is lost rather than the quit hanging. + private static let shutdownDeadline: Duration = .seconds(3) + + private var terminationSignal: DispatchSourceSignal? + func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) + signal(SIGTERM, SIG_IGN) + let source = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main) + source.setEventHandler { NSApp.terminate(nil) } + source.resume() + terminationSignal = source + } + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + guard let shutdown = Self.shutdown else { return .terminateNow } + // A second Quit while this one waits quits at once. + Self.shutdown = nil + Task { @MainActor in + await Self.firstOf(shutdown, deadline: Self.shutdownDeadline) + sender.reply(toApplicationShouldTerminate: true) + } + return .terminateLater + } + + /// Returns when `work` finishes or `deadline` passes, whichever is first. + /// `work` keeps running past the deadline; the process ends around it. + private static func firstOf(_ work: @escaping @MainActor () async -> Void, deadline: Duration) async { + final class Once { var done = false } + let once = Once() + await withCheckedContinuation { (continuation: CheckedContinuation) in + func finish() { + guard !once.done else { return } + once.done = true + continuation.resume() + } + Task { @MainActor in + await work() + finish() + } + Task { @MainActor in + try? await Task.sleep(for: deadline) + finish() + } + } } } @@ -72,12 +134,12 @@ private struct MenuContent: View { // on-device model agreed is a reusable spelling. A menu line rather // than an overlay toast: the pill is click-through by construction, // and a proposal arriving a minute later should wait, not interrupt. - if !model.proposals.isEmpty { + if !model.corrections.proposals.isEmpty { Divider() - ForEach(model.proposals) { proposal in + ForEach(model.corrections.proposals) { proposal in Menu("Learned “\(proposal.pair.heard)” → “\(proposal.pair.corrected)”?") { - Button("Add") { model.acceptProposal(proposal) } - Button("Dismiss") { model.dismissProposal(proposal) } + Button("Add") { model.corrections.accept(proposal) } + Button("Dismiss") { model.corrections.dismiss(proposal) } } } } @@ -96,13 +158,13 @@ private struct MenuContent: View { } .keyboardShortcut(",", modifiers: .command) - if model.needsAnyPermission { + if model.permissions.needsAnyPermission { Divider() - if model.needsAccessibility { - Button("Grant Accessibility…") { model.grantAccessibility() } + if model.permissions.needsAccessibility { + Button("Grant Accessibility…") { model.permissions.grantAccessibility() } } - if model.needsMicrophone { - Button("Grant Microphone…") { model.grantMicrophone() } + if model.permissions.needsMicrophone { + Button("Grant Microphone…") { model.permissions.grantMicrophone() } } } diff --git a/Sources/Pladder/PolishModelController.swift b/Sources/Pladder/PolishModelController.swift new file mode 100644 index 0000000..be62ac5 --- /dev/null +++ b/Sources/Pladder/PolishModelController.swift @@ -0,0 +1,95 @@ +import Foundation +import Observation +import PladderCore +import PladderRefine + +/// Points the coordinator's refiner at the polish model the settings name, +/// and owns that model's file and memory. +/// +/// An S1-mini file is downloaded only while polish is on and that model is +/// chosen, the one network use besides the speech model's; it is loaded at +/// the first key-down, not here, and freed when polish goes off or another +/// model is picked. Only one S1-mini polisher is ever held, so switching +/// frees the other's memory. +@MainActor +@Observable +final class PolishModelController { + /// Whether Apple Intelligence can polish right now, for the polish + /// toggle's row. Refreshed with the permissions: it can be switched on + /// or off in System Settings while the app runs, and the read is cheap. + /// The coordinator never asks; an unavailable model makes the toggle a + /// no-op that pastes as dictated. + private(set) var availability: OnDeviceModelAvailability = TranscriptPolisher.availability + /// Where the chosen model's file stands; nil for Apple's, which has none. + /// Shown under the model picker. + private(set) var status: ModelFileStatus? + + /// The coordinator's one refiner; forwards to the chosen model. + let refiner: PolishRouter + + @ObservationIgnored private let applePolisher = TranscriptPolisher() + @ObservationIgnored private var s1MiniPolisher: S1MiniPolisher? + @ObservationIgnored private let files: ModelFiles + @ObservationIgnored private let statusRelay = MainActorRelay<(ModelFile, ModelFileStatus)>() + /// The file the settings name, nil for Apple's model. + @ObservationIgnored private var chosenFile: ModelFile? + + init() { + refiner = PolishRouter(applePolisher) + let relay = statusRelay + files = ModelFiles(directory: ModelFiles.defaultDirectory) { file, status in + relay.send((file, status)) + } + statusRelay.handler = { [weak self] update in + guard let self, update.0 == self.chosenFile else { return } + self.status = update.1 + } + } + + func refreshAvailability() { + let current = TranscriptPolisher.availability + if current != availability { availability = current } + } + + func apply(model: PolishModel, polishing: Bool) { + guard let file = ModelFile(for: model) else { + chosenFile = nil + releaseS1Mini() + refiner.use(applePolisher) + status = nil + return + } + chosenFile = file + if s1MiniPolisher?.file != file { + releaseS1Mini() + let polisher = S1MiniPolisher(file: file, location: files.location(of: file)) + s1MiniPolisher = polisher + refiner.use(polisher) + } + if polishing { + Task.detached(priority: .utility) { await S1MiniPolisher.warmUpRuntime() } + } else if let polisher = s1MiniPolisher { + Task { await polisher.unload() } + } + let files = self.files + Task { [weak self] in + if polishing { await files.ensure(file) } + let status = await files.status(of: file) + guard let self, file == self.chosenFile else { return } + self.status = status + } + } + + /// Tries a failed download again; the picker's "Try Again". + func retryDownload() { + guard let file = chosenFile else { return } + let files = self.files + Task { await files.ensure(file) } + } + + private func releaseS1Mini() { + guard let polisher = s1MiniPolisher else { return } + s1MiniPolisher = nil + Task { await polisher.unload() } + } +} diff --git a/Sources/Pladder/Settings/DictionaryView.swift b/Sources/Pladder/Settings/DictionaryView.swift index d48b44f..cb4cfa3 100644 --- a/Sources/Pladder/Settings/DictionaryView.swift +++ b/Sources/Pladder/Settings/DictionaryView.swift @@ -192,8 +192,7 @@ struct DictionaryView: View { guard panel.runModal() == .OK, let url = panel.url else { return } do { let data = try Data(contentsOf: url) - let imported = try JSONDecoder().decode([ImportedEntry].self, from: data) - merge(imported.map(\.entry)) + merge(try JSONDecoder().decode([DictionaryEntry].self, from: data)) } catch { errorMessage = String(localized: "Could not read that file. It should be a JSON array of { from, to, matchCase }.") } @@ -220,19 +219,6 @@ struct DictionaryView: View { } } -/// `DictionaryEntry`'s synthesised decoder requires every key. Files people write -/// by hand rarely carry an `id`, so import goes through this looser shape. -private struct ImportedEntry: Decodable { - var id: UUID? - var from: String - var to: String - var matchCase: Bool? - - var entry: DictionaryEntry { - DictionaryEntry(id: id ?? UUID(), from: from, to: to, matchCase: matchCase ?? false) - } -} - /// A table cell that edits a draft and writes back on submit or focus loss, so /// typing does not persist settings on every keystroke. private struct DictionaryField: View { diff --git a/Sources/Pladder/Settings/SettingsView.swift b/Sources/Pladder/Settings/SettingsView.swift index 3587678..7fd6bea 100644 --- a/Sources/Pladder/Settings/SettingsView.swift +++ b/Sources/Pladder/Settings/SettingsView.swift @@ -84,8 +84,8 @@ private struct GeneralSettingsView: View { onRecordingChanged: { model.coordinator.isHotkeySuspended = $0 }, // The send key is off without Accessibility anyway, so // only the push-to-talk key is constrained. - requiresRegularKey: model.hotkeyNeedsRegularKey, - systemShortcuts: model.systemShortcuts + requiresRegularKey: model.hotkeys.hotkeyNeedsRegularKey, + systemShortcuts: model.hotkeys.systemShortcuts ) } if let warning = hotkeyWarning { @@ -98,9 +98,9 @@ private struct GeneralSettingsView: View { HotkeyRecorderField( hotkey: $model.settings.toggleHotkey, onRecordingChanged: { model.coordinator.isHotkeySuspended = $0 }, - requiresRegularKey: model.hotkeyNeedsRegularKey, + requiresRegularKey: model.hotkeys.hotkeyNeedsRegularKey, allowsEmpty: true, - systemShortcuts: model.systemShortcuts + systemShortcuts: model.hotkeys.systemShortcuts ) } if let warning = toggleKeyWarning { @@ -224,14 +224,14 @@ private struct GeneralSettingsView: View { PermissionRow( title: "Accessibility", detail: String(localized: "Needed for a modifier-only key such as Right Command, for the send key and for pasting. Without it Option+Space still works and the text is copied for you to paste with ⌘V; a standard account needs an administrator to switch it on."), - granted: model.accessibilityTrusted, - action: model.grantAccessibility + granted: model.permissions.accessibilityTrusted, + action: model.permissions.grantAccessibility ) PermissionRow( title: "Microphone", detail: micDetail, - granted: model.microphoneStatus == .authorized, - action: model.grantMicrophone + granted: model.permissions.microphoneStatus == .authorized, + action: model.permissions.grantMicrophone ) } } @@ -240,8 +240,7 @@ private struct GeneralSettingsView: View { // permission poll: this window is where they are shown, and the user // may have just changed them in System Settings. .onAppear { - model.refreshPermissions() - model.refreshSystemShortcuts() + model.settingsWindowOpened() } } @@ -253,13 +252,13 @@ private struct GeneralSettingsView: View { /// become untypeable while Pladder runs. private var hotkeyWarning: String? { let hotkey = model.settings.hotkey - if let standIn = model.standInHotkey { - if let owner = model.systemShortcutConflict(for: standIn) { + if let standIn = model.hotkeys.standInHotkey { + if let owner = model.hotkeys.systemShortcutConflict(for: standIn) { return conflictWarning(owner: owner, chord: standIn) } return String(localized: "Without Accessibility, \(hotkey.displayName) cannot be detected, so \(standIn.sideAgnosticDisplayName) stands in for it until Accessibility is granted. Record a combination with a regular key to choose your own.") } - if let owner = model.systemShortcutConflict(for: hotkey) { + if let owner = model.hotkeys.systemShortcutConflict(for: hotkey) { return conflictWarning(owner: owner, chord: hotkey) } guard hotkey.modifierKeyCodes.isEmpty, !hotkey.keyCodes.isEmpty else { return nil } @@ -274,10 +273,10 @@ private struct GeneralSettingsView: View { private var toggleKeyWarning: String? { let toggle = model.settings.toggleHotkey guard !toggle.isEmpty, toggle.canonical != model.settings.hotkey.canonical else { return nil } - if !model.accessibilityTrusted && !toggle.canBeRegisteredWithoutAccessibility { + if !model.permissions.accessibilityTrusted && !toggle.canBeRegisteredWithoutAccessibility { return String(localized: "Without Accessibility, \(toggle.displayName) cannot be detected, so the toggle key is off until Accessibility is granted.") } - if let owner = model.systemShortcutConflict(for: toggle) { + if let owner = model.hotkeys.systemShortcutConflict(for: toggle) { return conflictWarning(owner: owner, chord: toggle) } return nil @@ -295,7 +294,7 @@ private struct GeneralSettingsView: View { private var submitKeyWarning: String? { let submitKey = model.settings.submitKey guard !submitKey.keyCodes.isEmpty else { return nil } - if !model.accessibilityTrusted { + if !model.permissions.accessibilityTrusted { return String(localized: "The send key needs Accessibility.") } guard submitKey.keyCodes.isSubset(of: model.settings.hotkey.keyCodes) else { return nil } @@ -304,13 +303,13 @@ private struct GeneralSettingsView: View { private var launchAtLogin: Binding { Binding( - get: { LaunchAtLogin.isEnabled }, + get: { model.launchAtLoginEnabled }, set: { model.setLaunchAtLogin($0) } ) } private var micDetail: String { - switch model.microphoneStatus { + switch model.permissions.microphoneStatus { case .authorized: String(localized: "Granted.") case .denied, .restricted: String(localized: "Denied. Enable it in System Settings.") default: String(localized: "Not requested yet.") @@ -324,13 +323,11 @@ private struct ProcessingSettingsView: View { var body: some View { Form { Section { - ForEach(model.processors, id: \.id) { processor in - Toggle(isOn: binding(for: processor.id)) { + ForEach(StandardProcessors.entries.map(\.id), id: \.self) { id in + Toggle(isOn: binding(for: id)) { VStack(alignment: .leading, spacing: 2) { - // Core cannot import the catalog, so its - // processors are looked up by their English text. - Text(LocalizedStringKey(processor.displayName)) - Text(LocalizedStringKey(processor.detail)) + Text(ProcessorText.name(id)) + Text(ProcessorText.detail(id)) .font(.callout) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -372,7 +369,7 @@ private struct ProcessingSettingsView: View { /// Apple Intelligence switched off, or an S1-mini file still to come. @ViewBuilder private var polishModelStatus: some View { - if let status = model.polishModelStatus { + if let status = model.polish.status { switch status { case .ready: EmptyView() @@ -396,10 +393,10 @@ private struct ProcessingSettingsView: View { .foregroundStyle(.orange) .fixedSize(horizontal: false, vertical: true) Spacer() - Button("Try Again") { model.retryPolishModelDownload() } + Button("Try Again") { model.polish.retryDownload() } } } - } else if let warning = model.polishAvailability.polishKeyText { + } else if let warning = model.polish.availability.polishWarning { Label(warning, systemImage: "exclamationmark.triangle") .font(.callout) .foregroundStyle(.orange) diff --git a/Sources/Pladder/SettingsLocation.swift b/Sources/Pladder/SettingsLocation.swift new file mode 100644 index 0000000..d3936fc --- /dev/null +++ b/Sources/Pladder/SettingsLocation.swift @@ -0,0 +1,52 @@ +import Foundation + +/// Where the app keeps its files. +/// +/// `live` is the user's configuration, unless `PLADDER_SETTINGS_PATH` points +/// a copy launched for testing at a file of its own, so it neither reads nor +/// writes the configuration of the copy in daily use. `scratch` is a +/// throwaway directory for the overlay demo and the screenshots, which must +/// never read, move aside or write anything real. +struct SettingsLocation { + let settingsURL: URL + /// Whether the pre-rename settings are copied across when there are none + /// yet. Only for the real configuration: a test copy starts from the + /// defaults, not from an old install. + let migratesLegacySettings: Bool + + /// The corrections the user dismissed, beside the settings but not in + /// them (see `DismissedCorrections`). + var dismissedCorrectionsURL: URL { + settingsURL.deletingLastPathComponent().appending(path: "dismissed-corrections.json") + } + + static var live: SettingsLocation { + if let path = ProcessInfo.processInfo.environment["PLADDER_SETTINGS_PATH"], !path.isEmpty { + return SettingsLocation(settingsURL: URL(filePath: path), migratesLegacySettings: false) + } + return SettingsLocation(settingsURL: applicationSupport.appending(path: "Pladder/settings.json"), + migratesLegacySettings: true) + } + + static var scratch: SettingsLocation { + let directory = FileManager.default.temporaryDirectory + .appending(path: "pladder-dev-\(ProcessInfo.processInfo.processIdentifier)") + return SettingsLocation(settingsURL: directory.appending(path: "settings.json"), migratesLegacySettings: false) + } + + private static var applicationSupport: URL { + FileManager.default.homeDirectoryForCurrentUser.appending(path: "Library/Application Support") + } + + /// One-time migration from the pre-rename location. The dictionary and + /// hotkey settings were kept in `~/Library/Application Support/SpeakUp/` + /// before the app was called Pladder; copy them across exactly once, only + /// when the new file does not exist yet. The old file is left in place. + func migrateLegacySettingsIfNeeded() { + guard migratesLegacySettings, !FileManager.default.fileExists(atPath: settingsURL.path) else { return } + let legacy = Self.applicationSupport.appending(path: "SpeakUp/settings.json") + try? FileManager.default.createDirectory( + at: settingsURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try? FileManager.default.copyItem(at: legacy, to: settingsURL) + } +} diff --git a/Sources/Pladder/StatusText.swift b/Sources/Pladder/StatusText.swift index 2c564cf..4ebb4a0 100644 --- a/Sources/Pladder/StatusText.swift +++ b/Sources/Pladder/StatusText.swift @@ -70,7 +70,7 @@ extension OnDeviceModelAvailability { /// The sentence under the "Polish dictations" toggle, nil when the model /// can run. Each says what dictations do meanwhile: they paste as /// dictated, only without the polish. - var polishKeyText: String? { + var polishWarning: String? { switch self { case .available: nil case .appleIntelligenceNotEnabled: @@ -110,3 +110,105 @@ extension ModelFileFailure { } } } + +/// The menu's first line and its last-transcript entry. +enum MenuStatus { + /// The chords the line can name, already worded for the monitor that + /// matches them. + struct Chords { + /// What to hold to dictate: the stored chord or its stand-in. + var hold: String + /// What ends a latched recording. + var stop: String + /// The stand-in is listening because Accessibility is missing. + var accessibilityOff: Bool + /// Carbon is standing in for a deaf tap. + var secureKeyboardEntry: Bool + } + + static func line(state: DictationState, engineStatus: EngineStatus, isLatched: Bool, chords: Chords) -> String { + switch state { + case .recording: + // The Menu style has no pill, so this line is its latched cue. + guard isLatched else { return String(localized: "Recording…") } + return String(localized: "Recording — press \(chords.stop) to stop") + case .transcribing: return String(localized: "Transcribing…") + case .polishing: return String(localized: "Polishing…") + case .inserting: return String(localized: "Inserting…") + case .error(let failure): return String(localized: "Error: \(failure.text)") + case .copied: return String(localized: "Copied — press ⌘V") + case .idle: + return readyLine(chords) + case .unavailable: + switch engineStatus { + case .downloading(let progress): + if let progress { + return String(localized: "Model: downloading \(Int((progress * 100).rounded()))%") + } + return String(localized: "Model: downloading…") + case .loading: return String(localized: "Model: loading…") + case .unloaded: return String(localized: "Model: not loaded") + case .failed(let failure): return String(localized: "Model failed: \(failure.text)") + case .ready: return readyLine(chords) + } + } + } + + /// What to say when nothing is happening. Say why the send key and the + /// swallowing stopped when they did: both are the tap's. A whole + /// sentence either way: a suffix glued on cannot be translated. + private static func readyLine(_ chords: Chords) -> String { + if chords.accessibilityOff { + return String(localized: "Ready — hold \(chords.hold) (Accessibility is off)") + } + if chords.secureKeyboardEntry { + return String(localized: "Ready — hold \(chords.hold) (Secure Keyboard Entry is on)") + } + return String(localized: "Ready — hold \(chords.hold)") + } + + /// A short, word-boundary-aware summary of the most recent transcript; + /// longer previews make the menu bar menu comically wide. + static func summary(of text: String) -> String? { + guard !text.isEmpty else { return nil } + let limit = 32 + guard text.count > limit else { return text } + let head = String(text.prefix(limit)) + if let space = head.lastIndex(of: " "), head.distance(from: head.startIndex, to: space) > 20 { + return String(head[.. String { + switch id { + case FillerRemover.processorID: String(localized: "Remove fillers") + case DictionaryReplacer.processorID: String(localized: "Dictionary") + case CustomWordCorrector.processorID: String(localized: "Custom words") + case WhitespaceNormalizer.processorID: String(localized: "Tidy whitespace") + case SpokenPunctuation.processorID: String(localized: "Spoken punctuation") + default: id + } + } + + static func detail(_ id: String) -> String { + switch id { + case FillerRemover.processorID: + String(localized: "Strips hesitation sounds like “uh” and “um” from English, German and Spanish transcripts.") + case DictionaryReplacer.processorID: + String(localized: "Applies your replacement rules. Edit them in the Dictionary tab.") + case CustomWordCorrector.processorID: + String(localized: "Repairs near misses of the words you list in the Dictionary tab with an empty Heard as.") + case WhitespaceNormalizer.processorID: + String(localized: "Trims the transcript and collapses runs of spaces.") + case SpokenPunctuation.processorID: + String(localized: "Turns spoken marks such as “comma”, “question mark” and “new paragraph” into the marks themselves, in English, German and Spanish.") + default: "" + } + } +} diff --git a/Sources/PladderCLI/main.swift b/Sources/PladderCLI/main.swift index 6c89d47..cd6185e 100644 --- a/Sources/PladderCLI/main.swift +++ b/Sources/PladderCLI/main.swift @@ -203,8 +203,8 @@ func transcribeFile(_ path: String, process: Bool, verbose: Bool) async { let transcript = try await engine.transcribe(samples) var text = transcript.text if process { - let settings = appSettings() - let pipeline = ProcessorPipeline(StandardProcessors.factories.map { $0(settings) }, onFailure: { id, error in + let settings = DictationSettings(appSettings()) + let pipeline = StandardProcessors.pipeline(for: settings, onFailure: { id, error in FileHandle.standardError.write(Data("pladder-cli: processor \(id) failed: \(error)\n".utf8)) }) text = await pipeline.run(text, disabled: settings.disabledProcessors) diff --git a/Sources/PladderCore/DictationCoordinator.swift b/Sources/PladderCore/DictationCoordinator.swift index 7d7411d..c93bb44 100644 --- a/Sources/PladderCore/DictationCoordinator.swift +++ b/Sources/PladderCore/DictationCoordinator.swift @@ -14,6 +14,10 @@ import Observation /// /// Escape is the cancel key while a recording is on, and only then: the /// monitor is told at the start and the end of every recording. +/// +/// Every wait goes through `clock`, so tests drive the timers instead of +/// sleeping through them. Event instants come from the monitors and the +/// wall clock; only the waiting is the clock's. @MainActor @Observable public final class DictationCoordinator { @@ -42,7 +46,9 @@ public final class DictationCoordinator { /// Exposed so callers (and tests) can await completion of a cycle. public private(set) var inFlight: Task? - public var settings: Settings { + /// The app hands over a new value only when it differs; see + /// `DictationSettings`. + public var settings: DictationSettings { didSet { settingsChanged(from: oldValue) } } @@ -53,13 +59,10 @@ public final class DictationCoordinator { didSet { guard isHotkeySuspended != oldValue else { return } if isHotkeySuspended { - hotkeyTask?.cancel() - hotkeyMonitor.stop() - if state.isRecording { - Task { await cancelRecording() } - } + stopHotkey() + dropRecording() } else { - startHotkey() + hotkeyConfigurationChanged() } } } @@ -72,21 +75,12 @@ public final class DictationCoordinator { /// chord is left untouched and comes back the moment this is cleared, /// which is what happens when Accessibility is granted. Nil means "listen /// for the stored chord". Applies to the dictate chord only; a toggle - /// chord equal to the stored chord follows the - /// override, so a stood-in hybrid key stays hybrid. + /// chord equal to the stored chord follows the override, so a stood-in + /// hybrid key stays hybrid. public var hotkeyOverride: Hotkey? { didSet { guard hotkeyOverride != oldValue else { return } - // The old chord's release can no longer arrive, exactly as for a - // stored-chord change or a monitor swap. - if state.isRecording { - Task { await cancelRecording() } - } - // Before `start()` there is nothing to restart, and `start()` - // reads the override itself, so setting it at launch costs one - // registration rather than two. - guard hotkeyTask != nil, !isHotkeySuspended else { return } - startHotkey() + hotkeyConfigurationChanged() } } @@ -113,9 +107,25 @@ public final class DictationCoordinator { public var deferReleases = false /// How long an error stays on screen before returning to idle. public var errorDisplayDuration: Duration = .seconds(2) - /// How long the "press ⌘V" hint stays on screen before returning to idle. - /// Nil follows the overlay animation speed; tests set it directly. - public var copiedDisplayDuration: Duration? + + /// How long the Neural Engine is left idle between warm passes. One pass + /// at key-down is not enough for a long dictation: on an M1 a pass after + /// ten seconds of idle costs about 110 ms more than one made back to back, + /// and that is larger than the capture stop, the processors and the paste + /// together. + public var warmupInterval: Duration = .seconds(2) + + /// How often the Live Transcript style asks the engine what it has heard + /// so far. A live pass costs what a warm pass costs, so this is the warm + /// interval with a much shorter fuse: four times a second of Neural Engine + /// time buys text that keeps up with the speaker, at the price of a + /// release being more likely to land inside a pass. + public var livePassInterval: Duration = .milliseconds(500) + + /// How often the other styles hand captured audio to a streaming engine, + /// so it confirms windows before release. A second of audio is cheap to + /// copy and far shorter than the engine's 15 s window. + public var feedInterval: Duration = .seconds(1) private let loader: EngineLoader private let capture: any AudioCapture @@ -127,12 +137,17 @@ public final class DictationCoordinator { /// the polish a no-op. private let refiner: (any TranscriptRefiner)? private var hotkeyMonitor: any HotkeyMonitor - private let makePipeline: @Sendable (Settings) -> ProcessorPipeline + private let makePipeline: @Sendable (DictationSettings) -> ProcessorPipeline /// Rebuilt when settings change so that no processor is constructed on the /// release-to-paste path; `DictionaryReplacer` compiles a regex per entry. private var pipeline: ProcessorPipeline + private let clock: any Clock private let onEvent: @Sendable (Event) -> Void + /// Set by `start()`. Before it nothing listens, so a monitor swap or a + /// chord change made while the app is still setting up costs nothing, + /// and `start()` registers what is current by then, once. + private var isStarted = false private var hotkeyTask: Task? private var levelTask: Task? /// Returns `.error` or `.copied` to idle after its display duration. @@ -145,6 +160,62 @@ public final class DictationCoordinator { /// Waits out the bounce window of a deferred release. private var settleTask: Task? + /// Engines replaced by a settings change while a cycle was still running. + /// Unloaded when the cycle ends. + private var pendingUnloads: [any TranscriptionEngine] = [] + + /// The engine that was ready when the current recording started. Nil + /// between cycles. + private var cycleEngine: (any TranscriptionEngine)? + /// Whether this recording streams into `cycleEngine`. False for a batch + /// engine, and for a streaming one that could not begin an utterance: + /// that recording is transcribed whole at release instead of lost. + private var cycleStreams = false + + /// Keeps the engine warm for as long as the key is held. + /// + /// Cancelled at release, but cancellation cannot abort a CoreML call that + /// has already started, so a release landing inside a warm pass waits for + /// it. That is bounded by one pass and shows in the log as `engine` + /// exceeding `engine-time`. The interval trades that risk against the cold + /// penalty: longer means more dictations start cold, shorter means more + /// land on a pass in flight. The live pass in the feed loop has exactly + /// the same property, at its own cadence. + private var warmupTask: Task? + + /// Streaming state for the current recording. Non-nil only while + /// `cycleStreams`. + private var feedTask: Task? + /// Samples handed to the streaming engine so far; the tail from `stop()` + /// completes the utterance at release. Counted before each hand-over, so + /// a chunk on its way to the engine is never missing from it. + private var fedSampleCount = 0 + + /// Numbers recordings. A press that waited for the microphone or the + /// engine checks it is still its own recording before going on, and the + /// output muter matches the end of one to its start with it, whichever + /// order the two arrive in. + private var recording = 0 + /// The tail of the last cancelled recording: the microphone stopping and + /// the engine dropping the utterance. A press that comes before it is + /// done waits for it, so the engine cannot be told to drop the new + /// utterance in place of the old one. + private var cancelCleanup: Task? + /// The mute that was armed at key-down and the restore that undoes it, + /// kept so quitting can wait for the speakers to come back. + private var muteRestoreTask: Task? + + /// Half a second of silence. Transcribing it at key-down brings the + /// Neural Engine up from idle while the user is still speaking; every + /// utterance is padded to the model's full window, so this is the same + /// encoder pass the real call makes. + private static let warmupSamples = [Float](repeating: 0, count: 8_000) + + /// Hotkey events the loop has finished acting on. Internal, for tests + /// that need to know an event was seen before they assert that it did + /// nothing. + @ObservationIgnored private(set) var handledHotkeyEvents = 0 + /// Wall-clock time of each stage between the hotkey release and the paste. public struct CycleTiming: Sendable, Equatable { public var captureStop: Duration @@ -170,10 +241,29 @@ public final class DictationCoordinator { } } + /// What reached the output at the end of a cycle. + public struct Insertion: Sendable, Equatable { + /// `text` is what went out, after processing and polish, without the + /// trailing space. + public var transcript: Transcript + public var timing: CycleTiming + /// Pasted into the front app, or only left on the clipboard. + public var result: InsertResult + /// Return followed the paste, so the field was sent and emptied. + public var submitted: Bool + + public init(transcript: Transcript, timing: CycleTiming, result: InsertResult, submitted: Bool) { + self.transcript = transcript + self.timing = timing + self.result = result + self.submitted = submitted + } + } + public enum Event: Sendable { case recordingStarted case recordingStopped - case inserted(Transcript, CycleTiming) + case inserted(Insertion) case failed(DictationFailure) /// Escape ended the recording; nothing is transcribed. The app plays /// the stop sound so the user hears the microphone go off, unlike an @@ -187,14 +277,15 @@ public final class DictationCoordinator { } public init( - settings: Settings, + settings: DictationSettings, registry: EngineRegistry, capture: any AudioCapture, output: any TextOutput, outputMuter: (any OutputMuter)? = nil, refiner: (any TranscriptRefiner)? = nil, hotkeyMonitor: any HotkeyMonitor, - makePipeline: @escaping @Sendable (Settings) -> ProcessorPipeline, + makePipeline: @escaping @Sendable (DictationSettings) -> ProcessorPipeline, + clock: any Clock = ContinuousClock(), onEvent: @escaping @Sendable (Event) -> Void = { _ in } ) { self.settings = settings @@ -205,6 +296,7 @@ public final class DictationCoordinator { self.hotkeyMonitor = hotkeyMonitor self.makePipeline = makePipeline self.pipeline = makePipeline(settings) + self.clock = clock self.onEvent = onEvent loader = EngineLoader(registry: registry, engineID: settings.engineID) loader.onStatusChange = { [weak self] in self?.setEngineStatus($0) } @@ -214,22 +306,33 @@ public final class DictationCoordinator { /// Loads the engine, warms the mic, and starts listening for the hotkey. public func start() { + isStarted = true if !isHotkeySuspended { startHotkey() } Task { try? await capture.warmUp() } loader.load() } public func stop() { - hotkeyTask?.cancel() - hotkeyMonitor.stop() + isStarted = false + stopHotkey() loader.stop() - levelTask?.cancel() transientResetTask?.cancel() - maxDurationTask?.cancel() - settleTask?.cancel() - if state.isRecording { - Task { await cancelRecording() } - } + dropRecording() + } + + /// The app is quitting. Stops listening, drops a recording in progress, + /// lets a dictation already on its way out finish, and gives back what + /// was borrowed — the speakers and the clipboard — before it returns. + /// The app waits for this, with a deadline of its own. + public func shutdown() async { + isStarted = false + stopHotkey() + loader.stop() + transientResetTask?.cancel() + if state.isRecording { await cancelRecording() } + await inFlight?.value + await muteRestoreTask?.value + await output.flush() } /// Re-run engine load, for example after a failed download. @@ -238,17 +341,11 @@ public final class DictationCoordinator { } /// Swaps the hotkey source, for example when Accessibility is granted or - /// revoked and the app moves between the event tap and Carbon. The old - /// monitor's release would never arrive on the new stream, so a recording - /// in progress is dropped, exactly as for a chord change. + /// revoked and the app moves between the event tap and Carbon. public func replaceHotkeyMonitor(_ monitor: any HotkeyMonitor) { - hotkeyTask?.cancel() - hotkeyMonitor.stop() + stopHotkey() hotkeyMonitor = monitor - if state.isRecording { - Task { await cancelRecording() } - } - if !isHotkeySuspended { startHotkey() } + hotkeyConfigurationChanged() } private func setEngineStatus(_ status: EngineStatus) { @@ -263,22 +360,18 @@ public final class DictationCoordinator { } } - private func settingsChanged(from old: Settings) { + private func settingsChanged(from old: DictationSettings) { + guard settings != old else { return } // Rebuilt here so that no processor is constructed on the - // release-to-paste path; `DictionaryReplacer` compiles a regex per - // entry. `AppModel.settings` ignores assignments that change nothing, - // so this runs only on real changes. + // release-to-paste path. Only dictation settings reach this, and the + // app assigns only real changes, so it runs a handful of times a day. pipeline = makePipeline(settings) + // The submit key counts: the restarted monitor would never deliver + // the pending release for the old configuration. So does the toggle + // key, which may also turn the key hybrid or back. if old.hotkey != settings.hotkey || old.submitKey != settings.submitKey || old.toggleHotkey != settings.toggleHotkey { - // The old key's release will never arrive on the new stream. - // The submit key counts too: the restarted monitor would never - // deliver the pending release for the old configuration. So does - // the toggle key, which may also turn the key hybrid or back. - if state.isRecording { - Task { await cancelRecording() } - } - if !isHotkeySuspended { startHotkey() } + hotkeyConfigurationChanged() } if old.engineID != settings.engineID, let previous = loader.select(settings.engineID) { // The replaced engine is unloaded only once nothing is using it: @@ -294,138 +387,6 @@ public final class DictationCoordinator { } } - /// Engines replaced by a settings change while a cycle was still running. - /// Unloaded when the cycle ends. - private var pendingUnloads: [any TranscriptionEngine] = [] - - /// The engine that was ready when the current recording started. Nil - /// between cycles. - private var cycleEngine: (any TranscriptionEngine)? - /// The chord that started the current recording. Only its release or - /// cancel ends the recording. Nil between cycles. - private var cycleRole: HotkeyRole? - - /// Half a second of silence. Transcribing it at key-down brings the - /// Neural Engine up from idle while the user is still speaking; every - /// utterance is padded to the model's full window, so this is the same - /// encoder pass the real call makes. - private static let warmupSamples = [Float](repeating: 0, count: 8_000) - - /// How long the Neural Engine is left idle between warm passes. One pass - /// at key-down is not enough for a long dictation: on an M1 a pass after - /// ten seconds of idle costs about 110 ms more than one made back to back, - /// and that is larger than the capture stop, the processors and the paste - /// together. Settable so tests do not have to wait seconds. - public var warmupInterval: Duration = .seconds(2) - - /// How often the Live Transcript style asks the engine what it has heard - /// so far. A live pass costs what a warm pass costs, so this is the warm - /// interval with a much shorter fuse: four times a second of Neural Engine - /// time buys text that keeps up with the speaker, at the price of a - /// release being more likely to land inside a pass. Settable for tests. - public var livePassInterval: Duration = .milliseconds(500) - - /// Keeps the engine warm for as long as the key is held. - /// - /// Cancelled at release, but cancellation cannot abort a CoreML call that - /// has already started, so a release landing inside a warm pass waits for - /// it. That is bounded by one pass and shows in the log as `engine` - /// exceeding `engine-time`. The interval trades that risk against the cold - /// penalty: longer means more dictations start cold, shorter means more - /// land on a pass in flight. The live pass in the feed loop has exactly - /// the same property, at its own cadence. - private var warmupTask: Task? - - /// Streaming state for the current recording. Non-nil only when - /// `cycleEngine` is a `StreamingTranscriptionEngine`. - private var feedTask: Task? - /// Samples handed to the streaming engine so far; the tail from `stop()` - /// completes the utterance at release. - private var fedSampleCount = 0 - - /// Feeds a second of captured audio at a time to the engine while the - /// user is still speaking, so the sliding-window engine confirms chunks - /// before release. Skipped chunks are not a loss: `stop()` returns only - /// what came after the last drain, and anything a `drain()` raced is - /// recovered by the tail. - /// - /// In `live` mode the same loop also asks the engine for the text so far - /// and publishes it, on the `livePassInterval` cadence. One loop and not - /// two: two would drain the same chunks against each other, and a live - /// pass could overlap the next one. - private func startStreamingFeed(_ engine: any StreamingTranscriptionEngine, live: Bool) { - fedSampleCount = 0 - feedTask = Task { [weak self] in - while !Task.isCancelled { - if !live { try? await Task.sleep(for: .seconds(1)) } - guard let self, !Task.isCancelled, self.state.isRecording else { return } - let chunk = await self.capture.drain() - if !chunk.isEmpty { - await engine.feed(chunk) - self.fedSampleCount += chunk.count - } - guard live else { continue } - let text = await engine.livePass() - // A pass that finishes after the release belongs to a - // recording that is already on its way to the clipboard; - // publishing it would put stale text back on screen. - guard !Task.isCancelled, self.state.isRecording else { return } - self.partialTranscript = text - try? await Task.sleep(for: self.livePassInterval) - } - } - } - - /// Warms once immediately, so a short dictation still gets the key-down - /// pass, then keeps warming until cancelled. Detached and at utility - /// priority so it never competes with the feed or the UI. - private func startWarmupLoop(_ warm: @escaping @Sendable () async -> Void) { - warmupTask?.cancel() - warmupTask = Task.detached(priority: .utility) { [interval = warmupInterval] in - while !Task.isCancelled { - await warm() - if Task.isCancelled { return } - try? await Task.sleep(for: interval) - } - } - } - - private func stopWarmupLoop() { - warmupTask?.cancel() - warmupTask = nil - } - - /// Stops the feed and tells the streaming engine the utterance was - /// discarded. Safe to call when nothing streaming was started. - private func abandonStreaming() { - feedTask?.cancel() - feedTask = nil - fedSampleCount = 0 - let engine = cycleEngine - Task { - if let streaming = engine as? (any StreamingTranscriptionEngine) { - await streaming.abandonUtterance() - } - } - } - - /// Wraps the two engine entry points behind one call so `finish` can - /// transcribe with either kind. For a streaming engine only the tail is - /// pushed through `endUtterance`; the rest went through `feed` while the - /// user was speaking. The returned Transcript carries the whole - /// utterance's audio duration either way. - private func transcribeCycle( - tailSamples: [Float], - with engine: any TranscriptionEngine - ) async throws -> Transcript { - guard let streaming = engine as? (any StreamingTranscriptionEngine) else { - return try await engine.transcribe(tailSamples) - } - return try await streaming.endUtterance(tailSamples) - } - - // MARK: Hotkey - private func drainPendingUnloads() { guard !pendingUnloads.isEmpty else { return } let engines = pendingUnloads @@ -435,9 +396,32 @@ public final class DictationCoordinator { } } - private func startHotkey() { + // MARK: Hotkey + + /// The chords, the send key or the monitor changed. A release from the + /// old configuration can never arrive on the new stream, so a recording + /// in progress is dropped, and the monitor starts over unless it is + /// suspended or the coordinator has not started yet. + private func hotkeyConfigurationChanged() { + dropRecording() + guard isStarted, !isHotkeySuspended else { return } + startHotkey() + } + + private func stopHotkey() { hotkeyTask?.cancel() + hotkeyTask = nil hotkeyMonitor.stop() + } + + /// Cancels a recording in progress from a synchronous context. + private func dropRecording() { + guard state.isRecording else { return } + Task { await cancelRecording() } + } + + private func startHotkey() { + stopHotkey() startGesture() var chords: [HotkeyRole: Hotkey] = [.dictate: hotkeyOverride ?? settings.hotkey] if let toggle = separateToggleChord { chords[.toggle] = toggle } @@ -445,34 +429,38 @@ public final class DictationCoordinator { hotkeyTask = Task { [weak self] in for await tagged in stream { guard let self else { return } - // When the key moved, not when this loop got to it: a press - // waits here for the microphone to start. - let at = tagged.instant ?? .now - // Only the chord that started the recording may end it, and - // the gesture tracker is what knows which one that is: with - // nested chords the other tracker reports the hand-over as its - // own release. - switch tagged.event { - case .pressed: - let wasDeferring = self.gesture.deferReleases - let outcome = self.gesture.pressed(tagged.role, at: at) - if self.gesture.deferReleases, !wasDeferring { self.onEvent(.keyboardBounceObserved) } - await self.act(outcome) - case .released(let submit): - await self.act(self.gesture.released(tagged.role, submit: submit, at: at)) - // Another key went down right after the chord: the user typed - // Cmd+C, not a dictation. Drop the audio without transcribing, - // without a stop sound and without a timing line. - case .cancelled: - await self.act(self.gesture.interrupted(tagged.role)) - // Escape while a recording is on: drop it, and say so. - case .escape: - await self.escapePressed() - } + await self.handle(tagged) + self.handledHotkeyEvents += 1 } } } + private func handle(_ tagged: HotkeyMonitorEvent) async { + // When the key moved, not when this loop got to it: a press waits + // here for the microphone to start. + let at = tagged.instant ?? .now + // Only the chord that started the recording may end it, and the + // gesture tracker is what knows which one that is: with nested + // chords the other tracker reports the hand-over as its own release. + switch tagged.event { + case .pressed: + let wasDeferring = gesture.deferReleases + let outcome = gesture.pressed(tagged.role, at: at) + if gesture.deferReleases, !wasDeferring { onEvent(.keyboardBounceObserved) } + await act(outcome) + case .released(let submit): + await act(gesture.released(tagged.role, submit: submit, at: at)) + // Another key went down right after the chord: the user typed Cmd+C, + // not a dictation. Drop the audio without transcribing, without a + // stop sound and without a timing line. + case .cancelled: + await act(gesture.interrupted(tagged.role)) + // Escape while a recording is on: drop it, and say so. + case .escape: + await escapePressed() + } + } + /// A toggle chord equal to the push-to-talk chord, stored or standing in /// for it, is not a second chord but the hybrid mode of the first: two /// roles cannot share a chord, and a stand-in that replaces a hybrid @@ -507,8 +495,8 @@ public final class DictationCoordinator { private func act(_ outcome: HotkeyGestureTracker.Outcome) async { if let settle = outcome.settle { armSettle(settle) } switch outcome.action { - case .start(let role): - await hotkeyPressed(role: role) + case .start: + await hotkeyPressed() // A press the state machine refused (engine loading, a cycle in // flight, a microphone that failed) must not leave the tracker // holding or latching a recording that never began. @@ -525,28 +513,20 @@ public final class DictationCoordinator { private func armSettle(_ settle: HotkeyGestureTracker.Settle) { settleTask?.cancel() - settleTask = Task { [weak self] in - try? await Task.sleep(for: settle.after) + settleTask = Task { [weak self, clock] in + try? await clock.sleep(for: settle.after) guard let self, !Task.isCancelled else { return } // A stale token, one a bounce overtook, is ignored by the tracker. await self.act(self.gesture.timerFired(token: settle.token)) } } - /// The recording ended, however: the gesture starts over and Escape is - /// the system's again. Synchronous, and the monitor call only flips a - /// flag or queues work on the main thread, so on the release path this - /// costs nothing before `recordingStopped`. - private func endGesture() { - settleTask?.cancel() - settleTask = nil - gesture.reset() - isLatched = false - hotkeyMonitor.setCancelKeyEnabled(false) - } + // MARK: Recording /// Public so tests and a menu item can drive the state machine directly. - public func hotkeyPressed(role: HotkeyRole = .dictate) async { + /// Which chord pressed, and so which may end the recording, is the + /// gesture tracker's business, not the coordinator's. + public func hotkeyPressed() async { // `.copied` is the hint from the previous dictation, not a busy state: // a press replaces it rather than being dropped. switch state { @@ -558,76 +538,201 @@ public final class DictationCoordinator { // The engine that was ready at press transcribes this cycle, even if // the settings switch engines mid-recording. cycleEngine = loader.engine - cycleRole = role + cycleStreams = false willPolish = settings.polishDictations partialTranscript = nil // Read once, at press: switching the style mid-recording must not // leave the loop half live, with nothing warming the engine. - let live = settings.overlayStyle == .liveTranscript + let live = settings.liveTranscript // Flip state before the await so the overlay reacts on key-down and a // second concurrent press cannot start capture twice. state = .recording(level: 0) + recording += 1 + let mine = recording // Escape cancels from here on, and while the microphone comes up. hotkeyMonitor.setCancelKeyEnabled(true) + // Normally long finished: only a press right on the heels of a cancel + // waits here. + await cancelCleanup?.value + let levels: AsyncStream do { - let levels = try await capture.start() - guard state.isRecording else { - // Cancelled or superseded while the mic was starting. - willPolish = false - _ = await capture.stop() - abandonStreaming() - return - } - onEvent(.recordingStarted) - // Read at press, so flipping the setting mid-recording cannot - // arm a mute half way through. The muter waits out its own delay - // before touching anything, so this is off the key-down path too. - if settings.muteOutputWhileDictating, let outputMuter { - Task { await outputMuter.recordingStarted() } + levels = try await capture.start() + } catch { + guard isCurrent(mine) else { return } + willPolish = false + endRecording() + cycleEngine = nil + fail(.microphone(detail: error.localizedDescription)) + return + } + guard isCurrent(mine) else { + // Cancelled while the mic was starting; the cancel has already + // ended the recording, but the microphone came up after its stop. + // A newer recording, if one has started since, owns it now. + if !state.isRecording { _ = await capture.stop() } + return + } + onEvent(.recordingStarted) + armOutputMute() + startKeyDownWork() + await startEngineWork(live: live, recording: mine) + // The utterance may have been cancelled while it began. + guard isCurrent(mine) else { return } + levelTask = Task { [weak self] in + for await level in levels { + guard let self, self.state.isRecording else { return } + self.state = .recording(level: level) } - // Work that keeps the release-to-paste path short: the clipboard - // snapshot and, for batch engines, a Neural Engine warm-up (the - // feed below does that for streaming engines). Both run while the - // user is still speaking. - Task { [weak self] in await self?.output.prepare() } - // The model's load is the one cost this feature can hide: about - // 700 ms cold, paid while the user is still speaking. - if willPolish, let refiner { - Task.detached(priority: .utility) { await refiner.prepare() } + } + maxDurationTask = Task { [weak self, clock, maximumDuration] in + try? await clock.sleep(for: maximumDuration) + guard let self, !Task.isCancelled, self.state.isRecording else { return } + // Lost key-up, not an intentional release: submitting would + // send a message unattended, so never submit here. + self.hotkeyReleased(submit: false) + } + } + + /// Work that keeps the release-to-paste path short, all of it run while + /// the user is still speaking: the clipboard snapshot and the polish + /// model's load, about 700 ms cold. + private func startKeyDownWork() { + Task { [weak self] in await self?.output.prepare() } + if willPolish, let refiner { + Task.detached(priority: .utility) { await refiner.prepare() } + } + } + + /// True while recording number `id` is the one in progress. + private func isCurrent(_ id: Int) -> Bool { + state.isRecording && recording == id + } + + /// Starts streaming into the cycle's engine, or keeps a batch engine + /// warm. A streaming engine that cannot begin an utterance is used like a + /// batch engine for this recording: transcribed whole at release, slower + /// but not lost. + private func startEngineWork(live: Bool, recording id: Int) async { + if let streaming = cycleEngine as? (any StreamingTranscriptionEngine), + (try? await streaming.beginUtterance()) != nil { + guard isCurrent(id) else { + // Cancelled while it began. A newer recording's own + // `beginUtterance` replaces this one, so only drop it when + // there is none. + if !state.isRecording { await streaming.abandonUtterance() } + return } - if let streaming = cycleEngine as? (any StreamingTranscriptionEngine) { - try? await streaming.beginUtterance() - startStreamingFeed(streaming, live: live) - // The live loop's pass is the warm pass, so a second loop - // would only compete with it for the Neural Engine. - if !live { startWarmupLoop { await streaming.warmPass() } } - } else if let engine = cycleEngine { - startWarmupLoop { [warmupSamples = Self.warmupSamples] in - _ = try? await engine.transcribe(warmupSamples) - } + cycleStreams = true + startStreamingFeed(streaming, live: live) + // The live loop's pass is the warm pass, so a second loop would + // only compete with it for the Neural Engine. + if !live { startWarmupLoop { await streaming.warmPass() } } + } else if let engine = cycleEngine { + startWarmupLoop { [warmupSamples = Self.warmupSamples] in + _ = try? await engine.transcribe(warmupSamples) } - levelTask?.cancel() - levelTask = Task { [weak self] in - for await level in levels { - guard let self, self.state.isRecording else { return } - self.state = .recording(level: level) + } + } + + /// Hands captured audio to the engine while the user is still speaking, + /// so the windowed engine confirms windows before release. + /// + /// In `live` mode the same loop also asks the engine for the text so far + /// and publishes it, on the `livePassInterval` cadence. One loop and not + /// two: two would drain the same chunks against each other, and a live + /// pass could overlap the next one. + /// + /// At release the loop is cancelled and the release waits for it to + /// exit. A chunk it drained just before the release is still handed + /// over, so every sample reaches the engine once and in order, and + /// nothing new — no further drain and no live pass — starts after it. + private func startStreamingFeed(_ engine: any StreamingTranscriptionEngine, live: Bool) { + fedSampleCount = 0 + feedTask = Task { [weak self, clock, feedInterval, livePassInterval] in + while !Task.isCancelled { + if !live { try? await clock.sleep(for: feedInterval) } + guard let self, !Task.isCancelled, self.state.isRecording else { return } + let chunk = await self.capture.drain() + if !chunk.isEmpty { + self.fedSampleCount += chunk.count + await engine.feed(chunk) } + guard live, !Task.isCancelled else { continue } + let text = await engine.livePass() + // A pass that finishes after the release belongs to a + // recording that is already on its way to the clipboard; + // publishing it would put stale text back on screen. + guard !Task.isCancelled, self.state.isRecording else { return } + self.partialTranscript = text + try? await clock.sleep(for: livePassInterval) } - maxDurationTask?.cancel() - maxDurationTask = Task { [weak self, maximumDuration] in - try? await Task.sleep(for: maximumDuration) - guard let self, !Task.isCancelled, self.state.isRecording else { return } - // Lost key-up, not an intentional release: submitting would - // send a message unattended, so never submit here. - self.hotkeyReleased(submit: false) + } + } + + /// Warms once immediately, so a short dictation still gets the key-down + /// pass, then keeps warming until cancelled. Detached and at utility + /// priority so it never competes with the feed or the UI. + private func startWarmupLoop(_ warm: @escaping @Sendable () async -> Void) { + warmupTask?.cancel() + warmupTask = Task.detached(priority: .utility) { [clock, interval = warmupInterval] in + while !Task.isCancelled { + await warm() + if Task.isCancelled { return } + try? await clock.sleep(for: interval) } - } catch { - willPolish = false - endGesture() - fail(.microphone(detail: error.localizedDescription)) } } + /// Mutes the speakers for this recording when the setting is on. Read at + /// press, so flipping the setting mid-recording cannot arm a mute half + /// way through. The muter waits out its own delay before touching + /// anything, so this is off the key-down path too. + private func armOutputMute() { + guard settings.muteOutputWhileDictating, let outputMuter else { return } + let session = recording + Task { await outputMuter.recordingStarted(session: session) } + } + + /// Puts the speakers back. Unconditional, not gated on the setting: + /// turning it off mid-recording must still restore the device, and with + /// nothing muted this is two integer reads on an actor. Detached so the + /// paste never waits on a CoreAudio call. The session number lets the + /// muter match this to its start whichever arrives first. + private func restoreOutputDevice() { + guard let outputMuter else { return } + let session = recording + let previous = muteRestoreTask + muteRestoreTask = Task.detached(priority: .utility) { + await previous?.value + await outputMuter.recordingEnded(session: session) + } + } + + /// Everything that ends a recording, however it ends: the gesture starts + /// over, Escape is the system's again, the meter, cap, warm-up and feed + /// stop, the partial is cleared and the speakers are put back. + /// Synchronous, and every call in it only flips a flag, cancels a task or + /// spawns one, so on the release path this costs nothing before + /// `recordingStopped`. + private func endRecording() { + settleTask?.cancel() + settleTask = nil + gesture.reset() + isLatched = false + hotkeyMonitor.setCancelKeyEnabled(false) + levelTask?.cancel() + levelTask = nil + maxDurationTask?.cancel() + maxDurationTask = nil + feedTask?.cancel() + // Before the state flip, so no further pass is queued ahead of the + // real call. + warmupTask?.cancel() + warmupTask = nil + partialTranscript = nil + restoreOutputDevice() + } + /// Returns immediately; the transcription runs in `inFlight`. Presses that /// arrive while it runs are dropped by the `.idle` guard rather than queued. /// With `submit`, Return follows the pasted text. @@ -635,79 +740,75 @@ public final class DictationCoordinator { guard state.isRecording else { return } // Whatever ended it — the chord, a toggle press, the cap — a latched // recording is over and the next press starts a new one. - endGesture() - levelTask?.cancel() - maxDurationTask?.cancel() - feedTask?.cancel() + let feed = feedTask + endRecording() feedTask = nil - // Before the state flip, so no further pass is queued ahead of the - // real call. - stopWarmupLoop() state = .transcribing - // The partial was a picture of the recording, and the recording is - // over. The only statement this feature adds to the release path, and - // it runs before `recordingStopped`, so it is not even inside the - // measured window. - partialTranscript = nil - // Before `recordingStopped`, so restoring the speakers is outside the - // release-to-paste window the app measures, and detached so the paste - // never waits on a CoreAudio call. Unconditional, not gated on the - // setting: turning it off mid-recording must still put the device - // back. With nothing muted this is two integer reads on an actor. - if let outputMuter { - Task.detached(priority: .utility) { await outputMuter.recordingEnded() } - } + // Anything the user copied while speaking is snapshotted now, beside + // the engine pass, rather than inside the paste. + Task { [weak self] in await self?.output.prepare() } onEvent(.recordingStopped) let engine = cycleEngine ?? loader.engine - let fedSamples = fedSampleCount - fedSampleCount = 0 + let streams = cycleStreams cycleEngine = nil - cycleRole = nil + cycleStreams = false inFlight = Task { [weak self] in guard let self else { return } + // The feed loop finishes handing over a chunk it drained before + // the release, then exits; usually it is asleep and this is one + // hop. What it does wait for is engine work `endUtterance` would + // have waited for anyway, so it counts as engine time. + let handover = ContinuousClock.now + await feed?.value + let feedWait = ContinuousClock.now - handover let stopped = ContinuousClock.now let audio = await self.capture.stop() let captureStop = ContinuousClock.now - stopped - await self.finish(audio, fedSamples: fedSamples, with: engine, submit: submit, captureStop: captureStop) + await self.finish( + audio, fedSamples: streams ? self.fedSampleCount : 0, streams: streams, with: engine, + submit: submit, captureStop: captureStop, feedWait: feedWait) } } private func finish( _ audio: CapturedAudio, fedSamples: Int, + streams: Bool, with engine: any TranscriptionEngine, submit: Bool, - captureStop: Duration + captureStop: Duration, + feedWait: Duration ) async { defer { drainPendingUnloads() willPolish = false } + let streaming = streams ? engine as? (any StreamingTranscriptionEngine) : nil // Streaming engines were already fed `fedSamples` while recording; // only the tail came through `stop()`. let totalDuration = Double(fedSamples + audio.samples.count) / CapturedAudio.sampleRate guard totalDuration >= minimumDuration else { - if let streaming = engine as? (any StreamingTranscriptionEngine) { - await streaming.abandonUtterance() - } + await streaming?.abandonUtterance() becomeIdle() return } do { var timing = CycleTiming(captureStop: captureStop, engine: .zero, processing: .zero, insert: .zero) var started = ContinuousClock.now - var transcript = try await transcribeCycle(tailSamples: audio.samples, with: engine) - timing.engine = ContinuousClock.now - started + var transcript = if let streaming { + try await streaming.endUtterance(audio.samples) + } else { + try await engine.transcribe(audio.samples) + } + timing.engine = feedWait + (ContinuousClock.now - started) // The streaming engine only ever saw the tail; the coordinator // knows the whole utterance. if transcript.audioDuration == 0 { transcript.audioDuration = totalDuration } lastTranscript = transcript - let settings = self.settings started = ContinuousClock.now - let processed = await pipeline - .run(transcript.text, disabled: settings.disabledProcessors) + let processed = await pipeline.run(transcript.text, disabled: settings.disabledProcessors) timing.processing = ContinuousClock.now - started guard !processed.isEmpty else { becomeIdle() @@ -734,10 +835,11 @@ public final class DictationCoordinator { started = ContinuousClock.now let result = try await output.insert(toInsert, submit: submit) timing.insert = ContinuousClock.now - started - var inserted = transcript - inserted.text = final - lastTranscript = inserted - onEvent(.inserted(inserted, timing)) + transcript.text = final + lastTranscript = transcript + onEvent(.inserted(Insertion( + transcript: transcript, timing: timing, result: result, + submitted: submit && result == .pasted))) if result == .copied { showCopied() } else { becomeIdle() } } catch { fail(DictationFailure(error)) @@ -758,29 +860,29 @@ public final class DictationCoordinator { } } - /// Cancel an in-progress recording without transcribing. + /// Cancel an in-progress recording without transcribing. Covers the paths + /// that never transcribe: an interrupted chord, Escape, a hotkey change, + /// `stop()`. public func cancelRecording() async { guard state.isRecording else { return } - endGesture() - levelTask?.cancel() - maxDurationTask?.cancel() - stopWarmupLoop() - partialTranscript = nil + let feed = feedTask + endRecording() + feedTask = nil + fedSampleCount = 0 willPolish = false - // Same restore as at release, for the paths that never transcribe: - // an interrupted chord, a hotkey change, `stop()`. - if let outputMuter { - Task.detached(priority: .utility) { await outputMuter.recordingEnded() } - } becomeIdle() - abandonStreaming() + let engine = cycleStreams ? cycleEngine as? (any StreamingTranscriptionEngine) : nil cycleEngine = nil - cycleRole = nil - let engine = loader.engine - _ = await capture.stop() - if let streaming = engine as? (any StreamingTranscriptionEngine) { - await streaming.abandonUtterance() + cycleStreams = false + // The microphone goes off first. The engine drops the utterance only + // once the feed is out of the way, so no chunk lands after it. + let cleanup = Task { [capture] in + _ = await capture.stop() + await feed?.value + await engine?.abandonUtterance() } + cancelCleanup = cleanup + await cleanup.value drainPendingUnloads() } @@ -799,9 +901,8 @@ public final class DictationCoordinator { private func showCopied() { state = .copied transientResetTask?.cancel() - let hold = copiedDisplayDuration ?? settings.overlayAnimationSpeed.copiedHoldDuration - transientResetTask = Task { [weak self] in - try? await Task.sleep(for: hold) + transientResetTask = Task { [weak self, clock, hold = settings.copiedHoldDuration] in + try? await clock.sleep(for: hold) guard let self, !Task.isCancelled, case .copied = self.state else { return } self.becomeIdle() } @@ -812,8 +913,8 @@ public final class DictationCoordinator { state = .error(failure) onEvent(.failed(failure)) transientResetTask?.cancel() - transientResetTask = Task { [weak self, errorDisplayDuration] in - try? await Task.sleep(for: errorDisplayDuration) + transientResetTask = Task { [weak self, clock, errorDisplayDuration] in + try? await clock.sleep(for: errorDisplayDuration) guard let self, !Task.isCancelled, case .error = self.state else { return } self.becomeIdle() } diff --git a/Sources/PladderCore/Models/DictationSettings.swift b/Sources/PladderCore/Models/DictationSettings.swift new file mode 100644 index 0000000..60d5d26 --- /dev/null +++ b/Sources/PladderCore/Models/DictationSettings.swift @@ -0,0 +1,48 @@ +import Foundation + +/// The part of `Settings` the coordinator acts on, and nothing else. +/// +/// The app derives it from `Settings` and hands the coordinator a new value +/// only when it differs, so a click on an appearance card or the sounds +/// toggle never reaches the state machine and never rebuilds the processors. +/// The overlay's choices that matter here arrive already decided: whether +/// the Live Transcript style is on, and how long the "press ⌘V" hint stays. +public struct DictationSettings: Sendable, Equatable { + public var engineID: EngineID + public var hotkey: Hotkey + public var submitKey: Hotkey + public var toggleHotkey: Hotkey + public var polishDictations: Bool + public var disabledProcessors: Set + public var dictionary: [DictionaryEntry] + public var appendTrailingSpace: Bool + public var muteOutputWhileDictating: Bool + /// The Live Transcript style asks the engine for the text so far while + /// the key is held, in place of the warm pass. + public var liveTranscript: Bool + /// How long the "press ⌘V" hint stays on screen before returning to idle. + public var copiedHoldDuration: Duration + + public init(_ settings: Settings) { + engineID = settings.engineID + hotkey = settings.hotkey + submitKey = settings.submitKey + toggleHotkey = settings.toggleHotkey + polishDictations = settings.polishDictations + disabledProcessors = settings.disabledProcessors + dictionary = settings.dictionary + appendTrailingSpace = settings.appendTrailingSpace + muteOutputWhileDictating = settings.muteOutputWhileDictating + liveTranscript = settings.overlayStyle == .liveTranscript + copiedHoldDuration = settings.overlayAnimationSpeed.copiedHoldDuration + } + + /// The defaults `Settings(engineID:)` has. + public init(engineID: EngineID) { + self.init(Settings(engineID: engineID)) + } + + public mutating func setProcessor(_ id: String, enabled: Bool) { + if enabled { disabledProcessors.remove(id) } else { disabledProcessors.insert(id) } + } +} diff --git a/Sources/PladderCore/Models/DictionaryEntry.swift b/Sources/PladderCore/Models/DictionaryEntry.swift index 55df99e..f027b47 100644 --- a/Sources/PladderCore/Models/DictionaryEntry.swift +++ b/Sources/PladderCore/Models/DictionaryEntry.swift @@ -18,6 +18,19 @@ public struct DictionaryEntry: Codable, Sendable, Equatable, Identifiable, Hasha self.matchCase = matchCase } + private enum CodingKeys: String, CodingKey { case id, from, to, matchCase } + + /// Only `from` and `to` are required. Files people write by hand, for the + /// Dictionary tab's import or in the settings file, rarely carry an `id`, + /// and `matchCase` has a default. + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + from = try c.decode(String.self, forKey: .from) + to = try c.decode(String.self, forKey: .to) + matchCase = try c.decodeIfPresent(Bool.self, forKey: .matchCase) ?? false + } + /// `from` as rules are compared: trimmed and lowercased. Empty for a /// custom word, which has no `from`. public var normalizedFrom: String { Self.normalized(from) } diff --git a/Sources/PladderCore/Models/Settings.swift b/Sources/PladderCore/Models/Settings.swift index 1411570..4f4d506 100644 --- a/Sources/PladderCore/Models/Settings.swift +++ b/Sources/PladderCore/Models/Settings.swift @@ -69,7 +69,6 @@ public struct Settings: Codable, Sendable, Equatable { /// Insert a trailing space after each dictation so consecutive dictations /// don't run together. public var appendTrailingSpace: Bool - public var launchAtLogin: Bool /// Play a short sound on record start/stop. public var playSounds: Bool /// Mute the default output device while the key is held, so music or a @@ -84,82 +83,90 @@ public struct Settings: Codable, Sendable, Equatable { /// Speed of the fly-in/fly-out presentation animation. public var overlayAnimationSpeed: OverlayAnimationSpeed - public init( - engineID: EngineID, - hotkey: Hotkey = .optionSpace, - submitKey: Hotkey = .keyV, - polishDictations: Bool = false, - polishModel: PolishModel = .appleIntelligence, - toggleHotkey: Hotkey = Hotkey(keyCodes: []), - disabledProcessors: Set = [], - dictionary: [DictionaryEntry] = [], - appendTrailingSpace: Bool = true, - launchAtLogin: Bool = false, - playSounds: Bool = true, - muteOutputWhileDictating: Bool = false, - appearance: Appearance = .system, - overlayStyle: OverlayStyle = .compact, - overlayGlass: Bool = true, - overlayAnimationSpeed: OverlayAnimationSpeed = .quick - ) { + /// The defaults, here and nowhere else: the decoder starts from them too. + public init(engineID: EngineID) { self.engineID = engineID - self.hotkey = hotkey - self.submitKey = submitKey - self.polishDictations = polishDictations - self.polishModel = polishModel - self.toggleHotkey = toggleHotkey - self.disabledProcessors = disabledProcessors - self.dictionary = dictionary - self.appendTrailingSpace = appendTrailingSpace - self.launchAtLogin = launchAtLogin - self.playSounds = playSounds - self.muteOutputWhileDictating = muteOutputWhileDictating - self.appearance = appearance - self.overlayStyle = overlayStyle - self.overlayGlass = overlayGlass - self.overlayAnimationSpeed = overlayAnimationSpeed + hotkey = .optionSpace + submitKey = .keyV + polishDictations = false + polishModel = .appleIntelligence + toggleHotkey = Hotkey(keyCodes: []) + disabledProcessors = [] + dictionary = [] + appendTrailingSpace = true + playSounds = true + muteOutputWhileDictating = false + appearance = .system + overlayStyle = .compact + overlayGlass = true + overlayAnimationSpeed = .quick } - // Decoding tolerates missing keys so adding a field in a later version - // never makes an existing settings file unreadable. - private enum CodingKeys: String, CodingKey { - case engineID, hotkey, submitKey, polishDictations, polishModel, disabledProcessors, dictionary, appendTrailingSpace, launchAtLogin, playSounds, appearance - case overlayStyle, overlayGlass, overlayAnimationSpeed, muteOutputWhileDictating - case toggleHotkey + private enum CodingKeys: String, CodingKey, CaseIterable { + case engineID, hotkey, submitKey, polishDictations, polishModel, toggleHotkey + case disabledProcessors, dictionary, appendTrailingSpace, playSounds + case muteOutputWhileDictating, appearance, overlayStyle, overlayGlass, overlayAnimationSpeed // Read once for the migration, never written. case polishHotkey } + /// Keys earlier versions wrote and this one no longer does. `SettingsStore` + /// keeps keys it does not know, so a newer build's settings survive an + /// older build saving; these it drops, since nothing will read them again. + /// `launchAtLogin` mirrored the login item, which is its own source of + /// truth and was never read back. + static let retiredKeys = [CodingKeys.polishHotkey.rawValue, "launchAtLogin"] + + /// Starts from the defaults and takes every value the file has that this + /// build can read, one key at a time. A value it cannot read — a case a + /// newer version added, a hand edit, one damaged dictionary row — keeps + /// its default instead of failing the whole file, because a failure moves + /// the file aside and the user starts over with an empty dictionary. Only + /// a file that is not a JSON object at all fails. What was dropped is + /// reported through `SettingsDecodingReport`, so the store can keep a copy. public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) - engineID = try c.decode(EngineID.self, forKey: .engineID) + let report = decoder.userInfo[SettingsDecodingReport.key] as? SettingsDecodingReport + func read(_ type: T.Type, _ key: CodingKeys) -> T? { + guard c.contains(key) else { return nil } + do { + return try c.decodeIfPresent(type, forKey: key) + } catch { + report?.dropped(key.rawValue) + return nil + } + } + + // A missing or unreadable engine is repaired by the app, which knows + // the registry; an empty ID is never registered. + self.init(engineID: read(EngineID.self, .engineID) ?? EngineID("")) // An empty chord can never fire, so treat it like a missing key. - let decodedHotkey = try c.decodeIfPresent(Hotkey.self, forKey: .hotkey) - hotkey = decodedHotkey.flatMap { $0.keyCodes.isEmpty ? nil : $0 } ?? .optionSpace + if let hotkey = read(Hotkey.self, .hotkey), !hotkey.keyCodes.isEmpty { self.hotkey = hotkey } // Unlike the hotkey, an empty submit key is meaningful: it is how the - // feature is switched off. - submitKey = try c.decodeIfPresent(Hotkey.self, forKey: .submitKey) ?? .keyV + // feature is switched off. The same holds for the toggle key. + if let submitKey = read(Hotkey.self, .submitKey) { self.submitKey = submitKey } + if let toggleHotkey = read(Hotkey.self, .toggleHotkey) { self.toggleHotkey = toggleHotkey } // Once a chord of its own, the polish is now a Processing toggle. // A stored chord migrates to `true`, so the feature the user asked // for turns on with the update; nothing is written under the old key. - let legacyPolish = try c.decodeIfPresent(Hotkey.self, forKey: .polishHotkey) ?? Hotkey(keyCodes: []) - polishDictations = try c.decodeIfPresent(Bool.self, forKey: .polishDictations) - ?? (!legacyPolish.isEmpty) - // A model this build does not know, say from a newer version, falls - // back to Apple's rather than making the whole file unreadable. - polishModel = (try? c.decodeIfPresent(PolishModel.self, forKey: .polishModel)) ?? .appleIntelligence - // Like the submit key, empty is meaningful: off. - toggleHotkey = try c.decodeIfPresent(Hotkey.self, forKey: .toggleHotkey) ?? Hotkey(keyCodes: []) - disabledProcessors = try c.decodeIfPresent(Set.self, forKey: .disabledProcessors) ?? [] - dictionary = try c.decodeIfPresent([DictionaryEntry].self, forKey: .dictionary) ?? [] - appendTrailingSpace = try c.decodeIfPresent(Bool.self, forKey: .appendTrailingSpace) ?? true - launchAtLogin = try c.decodeIfPresent(Bool.self, forKey: .launchAtLogin) ?? false - playSounds = try c.decodeIfPresent(Bool.self, forKey: .playSounds) ?? true - appearance = try c.decodeIfPresent(Appearance.self, forKey: .appearance) ?? .system - overlayStyle = try c.decodeIfPresent(OverlayStyle.self, forKey: .overlayStyle) ?? .compact - overlayGlass = try c.decodeIfPresent(Bool.self, forKey: .overlayGlass) ?? true - overlayAnimationSpeed = try c.decodeIfPresent(OverlayAnimationSpeed.self, forKey: .overlayAnimationSpeed) ?? .quick - muteOutputWhileDictating = try c.decodeIfPresent(Bool.self, forKey: .muteOutputWhileDictating) ?? false + if let polish = read(Bool.self, .polishDictations) { + polishDictations = polish + } else if let legacy = read(Hotkey.self, .polishHotkey) { + polishDictations = !legacy.isEmpty + } + if let polishModel = read(PolishModel.self, .polishModel) { self.polishModel = polishModel } + if let disabled = read([String].self, .disabledProcessors) { disabledProcessors = Set(disabled) } + if let rows = read([Lossy].self, .dictionary) { + dictionary = rows.compactMap(\.value) + if dictionary.count < rows.count { report?.dropped(CodingKeys.dictionary.rawValue) } + } + if let value = read(Bool.self, .appendTrailingSpace) { appendTrailingSpace = value } + if let value = read(Bool.self, .playSounds) { playSounds = value } + if let value = read(Bool.self, .muteOutputWhileDictating) { muteOutputWhileDictating = value } + if let value = read(Appearance.self, .appearance) { appearance = value } + if let value = read(OverlayStyle.self, .overlayStyle) { overlayStyle = value } + if let value = read(Bool.self, .overlayGlass) { overlayGlass = value } + if let value = read(OverlayAnimationSpeed.self, .overlayAnimationSpeed) { overlayAnimationSpeed = value } } // Encoding mirrors the synthesized one, minus the legacy `polishHotkey` @@ -175,7 +182,6 @@ public struct Settings: Codable, Sendable, Equatable { try c.encode(disabledProcessors, forKey: .disabledProcessors) try c.encode(dictionary, forKey: .dictionary) try c.encode(appendTrailingSpace, forKey: .appendTrailingSpace) - try c.encode(launchAtLogin, forKey: .launchAtLogin) try c.encode(playSounds, forKey: .playSounds) try c.encode(muteOutputWhileDictating, forKey: .muteOutputWhileDictating) try c.encode(appearance, forKey: .appearance) @@ -193,6 +199,30 @@ public struct Settings: Codable, Sendable, Equatable { } } +/// Collects the keys `Settings` had to drop while decoding. Passed in through +/// the decoder's `userInfo`; nil there, which is every decode but the store's, +/// means nobody is asking. +public final class SettingsDecodingReport: @unchecked Sendable { + // `@unchecked`: written only by the decode that owns it, on one thread, + // and read after that decode returns. + static let key = CodingUserInfoKey(rawValue: "de.dinooo13.pladder.settingsDecodingReport")! + + public private(set) var droppedKeys: [String] = [] + + public init() {} + + func dropped(_ key: String) { droppedKeys.append(key) } +} + +/// One element of an array that decodes to nil instead of failing the array. +private struct Lossy: Decodable { + let value: Value? + + init(from decoder: Decoder) throws { + value = try? Value(from: decoder) + } +} + /// Loads and saves `Settings` as JSON. Pure Foundation, so it is testable with /// a temp directory. public final class SettingsStore: Sendable { @@ -204,28 +234,65 @@ public final class SettingsStore: Sendable { self.defaults = defaults } - /// Returns the saved settings, or the defaults when there is no file. A - /// file that exists but cannot be decoded is moved aside rather than left - /// in place to be overwritten by the next save, so a user's dictionary is - /// never silently lost. + /// Returns the saved settings, or the defaults when there is no file. + /// + /// Nothing the user wrote is ever lost to a load. A file that is not + /// settings at all is moved aside rather than left to be overwritten by + /// the next save; a file that decoded with something dropped is copied + /// aside and kept. Each copy gets a name of its own, so an older one is + /// never replaced. public func load() -> Settings { guard let data = try? Data(contentsOf: url) else { return defaults } + let decoder = JSONDecoder() + let report = SettingsDecodingReport() + decoder.userInfo[SettingsDecodingReport.key] = report do { - return try JSONDecoder().decode(Settings.self, from: data) + let settings = try decoder.decode(Settings.self, from: data) + if !report.droppedKeys.isEmpty { + try? FileManager.default.copyItem(at: url, to: backupURL()) + } + return settings } catch { - let broken = url.appendingPathExtension("broken") - try? FileManager.default.removeItem(at: broken) - try? FileManager.default.moveItem(at: url, to: broken) + try? FileManager.default.moveItem(at: url, to: backupURL()) return defaults } } + /// Writes `settings`, keeping every key in the existing file that this + /// build does not know: a newer build's settings must survive an older + /// one saving over them, which is what happens when two copies of the + /// app, or two worktrees, share the file. public func save(_ settings: Settings) throws { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(settings) + let ours = try JSONEncoder().encode(settings) + guard var object = try JSONSerialization.jsonObject(with: ours) as? [String: Any] else { return } + if let existing = try? Data(contentsOf: url), + let theirs = try? JSONSerialization.jsonObject(with: existing) as? [String: Any] { + var kept = theirs.filter { !Settings.retiredKeys.contains($0.key) } + kept.merge(object) { _, ours in ours } + object = kept + } + let data = try JSONSerialization.data( + withJSONObject: object, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) try FileManager.default.createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true) try data.write(to: url, options: .atomic) } + + /// `settings.broken-