diff --git a/CLAUDE.md b/CLAUDE.md index 054ddd6..3b70e85 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,8 @@ Push-to-talk dictation for macOS 26 on Apple Silicon. Hold a key, speak, release, and the words are pasted where the cursor is. Everything runs on the Mac. +How the code fits together — the dictation flow, the hotkey pipeline, the clipboard timing, the settings file, the polish and the correction learner — is in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). + ## What the project optimises for 1. **Speed of the release-to-paste path.** This is the metric. The path runs from the hotkey release to the paste: capture stop, engine, processors, output. In code it is everything between the coordinator's `recordingStopped` and `inserted` events. Nothing goes on that path without a before-and-after benchmark. @@ -62,7 +64,7 @@ swift run -c release pladder-cli polish-set docs/polish-set.json --model s1-mini ## Pluggability rules -- `PladderCore` imports Foundation only. It never imports FluidAudio, AVFoundation or AppKit, so tests compile fast and engines are truly swappable. +- `PladderCore` imports Foundation and Observation only. It never imports FluidAudio, AVFoundation or AppKit, so tests compile fast and engines are truly swappable. - Adding an engine: implement `TranscriptionEngine` in its own file under `PladderEngines`, add an entry to `StandardEngines.entries`. One file plus one catalog line; the app's registry, the settings picker and `pladder-cli` read the catalog. The engine lifecycle — building, loading, status polling and swapping — lives in `EngineLoader`. An engine that transcribes while the key is held also implements `StreamingTranscriptionEngine`: `beginUtterance` returns an `Utterance` handle, every later call names it, and a call naming one that is no longer current does nothing, so a cancelled recording's late feed or abandon cannot reach the next one. `UtteranceSlot` in Core keeps that bookkeeping. - Adding a processor: implement `TextProcessor` in its own file — synchronous, unable to fail (one that cannot do its job returns its input), and without user-facing text — append an `Entry` to `StandardProcessors.entries` in `PladderSystem`, which the app and `pladder-cli --process` both build from, and word it in `ProcessorText` in `StatusText.swift`. The pipeline is rebuilt when `DictationSettings` change, never per dictation. A processor sits on the critical path, so the benchmark rule applies; `pladder-cli bench-process` measures it. - Adding a polish model: a `PolishModel` case in `Settings`, a `ModelFile` pinned to a commit with its SHA-256 if it downloads, a `TranscriptRefiner` in `PladderRefine`, and a branch in `PolishModelController.apply`; `PolishRouter` hands it the calls, the picker and `polish-set` read the enum. Judge it with `pladder-cli polish-set` before it goes in. @@ -73,6 +75,14 @@ swift run -c release pladder-cli polish-set docs/polish-set.json --model s1-mini - Engine and capture are actors. The coordinator is `@MainActor` because it drives UI. It owns the state machine and nothing else; every dependency is injected, its clock included, so tests run it with in-memory fakes and fire its timers with `ManualClock`. It sees `DictationSettings`, the part of `Settings` it acts on, and the app hands it a new value only when that part changed. - The app target is split by job: `AppModel` is the composition root and owns the settings; `HotkeyRouter` picks the tap or Carbon (the rule itself is `HotkeySource.choose` in Core); `PermissionMonitor` polls the grants; `PolishModelController` owns the polish model and its file; `CorrectionInbox` the learned corrections; `SettingsLocation` the files. Decisions worth testing go to Core as values (`HotkeyWarning`, `HotkeySource`, the timing line) and the app words them in `StatusText.swift`. Views talk to `AppModel` and its parts, not to the coordinator. +## Code style + +- A comment stays in code only when it says something the code cannot say and a reader would otherwise get wrong: a constraint from outside the code (macOS, AppKit, a framework, a model), a measured number and where it comes from, or a trap — an ordering, a race or an invariant that looks removable but is not. One to three lines, at the one place it applies. +- No doc comment that restates a name or a signature, no narration of what the next lines do, no history ("used to", "the fix for"), nothing CLAUDE.md already says (a measured number may stand again beside the constant it sets), and no explanation repeated in a second file. Where a comment would only explain what something is, rename it instead. +- Design explanations too long for a comment go in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md); the code points there with one line, `// See docs/ARCHITECTURE.md, "
".` Keep the section names stable. +- Tests need almost no comments: the test's name says what it checks. Keep only what a reader would get wrong, such as why a test sleeps in real time or that it never touches the general pasteboard. +- `// MARK:` lines are fine in source files; tests do without them. + ## Risks - **Audio format.** The microphone delivers 48 kHz; the engine wants 16 kHz mono Float32. Conversion is isolated in `AudioResampler` and unit tested against synthesised buffers. diff --git a/README.md b/README.md index b02d57a..55a492f 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ Yes. `pladder-cli` transcribes an audio file and prints only the text. [docs/HER ## Contributing -Issues and pull requests are welcome. The [CLAUDE.md](CLAUDE.md) file states what the project optimises for and the rule that every change on the release-to-paste path ships with a benchmark. [docs/BENCHMARKS.md](docs/BENCHMARKS.md) has the procedure. +Issues and pull requests are welcome. The [CLAUDE.md](CLAUDE.md) file states what the project optimises for and the rule that every change on the release-to-paste path ships with a benchmark. [docs/BENCHMARKS.md](docs/BENCHMARKS.md) has the procedure. [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) explains how the code fits together. ## The name diff --git a/Sources/Pladder/AppModel.swift b/Sources/Pladder/AppModel.swift index 9f73f68..4ebb62d 100644 --- a/Sources/Pladder/AppModel.swift +++ b/Sources/Pladder/AppModel.swift @@ -9,14 +9,7 @@ import PladderAudio import PladderEngines import PladderRefine -/// 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`. +// The composition root. The parts split off it: docs/ARCHITECTURE.md, "The app". @MainActor @Observable final class AppModel { @@ -26,41 +19,24 @@ final class AppModel { let hotkeys: HotkeyRouter let polish: PolishModelController let corrections: CorrectionInbox - - /// 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. + // Can change behind the app's back in System Settings: re-read when settings open. private(set) var launchAtLoginEnabled = LaunchAtLogin.isEnabled @ObservationIgnored private let store: SettingsStore @ObservationIgnored private let overlay: OverlayController @ObservationIgnored private let output: PasteboardOutput @ObservationIgnored private let events = MainActorRelay<(DictationCoordinator.Event, ContinuousClock.Instant)>() - - /// When the hotkey was released, for the release-to-paste measurement. @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 timingLog = Logger(subsystem: "de.dinooo13.pladder", category: "timing") - /// Every mute and unmute of the output device. A mute that gets stuck — - /// the app quitting mid-recording, a device vanishing — is silent and - /// baffling otherwise, so both ends of it are logged `.public`. + // A stuck mute is silent and baffling otherwise, so both ends are logged `.public`. private nonisolated static let muteLog = Logger(subsystem: "de.dinooo13.pladder", category: "mute") - /// 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") 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. + // Each side effect runs only when its own keys changed: re-assigning `NSApp.appearance` + // with a forced Light or Dark re-themes every window, and on every click of a + // settings card it made the rows lag behind the click. var settings: Settings { get { storedSettings } set { @@ -73,9 +49,7 @@ final class AppModel { private var storedSettings: Settings init(location: SettingsLocation) { - // Engines, in the order the settings picker shows them. The first - // entry is the default for new installs. The catalog's detail is the - // English catalog key; it is worded here, where the catalog is. + // The catalog's detail is an English catalog key, worded here, where the catalog is. var registry = EngineRegistry() for var entry in StandardEngines.entries { entry.detail = String(localized: String.LocalizationValue(entry.detail)) @@ -95,13 +69,10 @@ final class AppModel { location.migrateLegacySettingsIfNeeded() store = SettingsStore(url: location.settingsURL, defaults: Settings(engineID: StandardEngines.defaultEntry.id)) - // One read: the store moves an undecodable file aside on load, so a - // second read could see different settings than the first. + // One read: the store moves an undecodable file aside on load, so a second read + // could see different settings. 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. + // A removed engine's ID would still work, but the picker should show what runs. if registry.entry(for: initial.engineID) == nil, let fallback = registry.available.first { initial.engineID = fallback.id } @@ -128,9 +99,8 @@ final class AppModel { refiner: polish.refiner, hotkeyMonitor: hotkeys.initialMonitor, makePipeline: { StandardProcessors.pipeline(for: $0) }, - // Stamped when the coordinator emits it, not when the main actor - // gets to it, so the release-to-paste measurement does not - // include scheduling delay. + // Stamped when emitted, not when the main actor gets to it, so the release-to-paste + // measurement leaves out scheduling delay. onEvent: { event in events.send((event, .now)) } ) self.coordinator = coordinator @@ -159,8 +129,6 @@ final class AppModel { polish.apply(model: settings.polishModel, polishing: settings.polishDictations) } - /// 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() @@ -174,8 +142,6 @@ final class AppModel { polish.refreshAvailability() } - /// Re-reads what can change behind the app's back and is about to be - /// shown: the grants, the shortcuts macOS owns, the login item. func settingsWindowOpened() { permissions.refresh() hotkeys.refreshSystemShortcuts() @@ -187,9 +153,7 @@ final class AppModel { private func settingsChanged(from old: Settings) { let monitor = settings.hotkey != old.hotkey ? hotkeys.hotkeyChanged(to: settings.hotkey) : nil - // Together, so a new chord and the stand-in and monitor it calls for - // restart the monitor once. - coordinator.update(DictationSettings(settings), hotkeyOverride: hotkeys.standInHotkey, monitor: monitor) + coordinator.update(DictationSettings(settings), standInHotkey: hotkeys.standInHotkey, monitor: monitor) if settings.appearance != old.appearance { applyAppearance(settings.appearance) } @@ -209,8 +173,6 @@ final class AppModel { } } - /// 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) @@ -236,10 +198,8 @@ final class AppModel { releaseInstant = instant if settings.playSounds { SoundPlayer.playStop() } 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. + // After the paste, outside the measured window. Only a paste that stayed in its + // field can be corrected: a copy was never pasted, and a send emptied the field. defer { if insertion.result == .pasted, !insertion.submitted { corrections.pasted(insertion.transcript.text) @@ -252,13 +212,11 @@ final class AppModel { case .failed: releaseInstant = nil case .recordingDiscarded: - // Escape: no paste follows, so no timing line either, but the - // microphone did go off and the user should hear it. + // No paste, so no timing line, but the microphone went off and the user should hear it. releaseInstant = nil if settings.playSounds { SoundPlayer.playStop() } case .keyboardBounceObserved: - // That wait comes before `recordingStopped`, so the timing line - // cannot show it; this line is what explains a felt delay. + // That wait comes before `recordingStopped`, so the timing line cannot show it. let window = coordinator.bounceWindow.timeInterval * 1000 Self.hotkeyLog.notice("keyboard bounce observed: releases now settle for \(window, format: .fixed(precision: 0)) ms before stopping") } @@ -266,12 +224,10 @@ final class AppModel { // MARK: Menu - /// The app icon's waveform glyph, varied by state (see `MenuBarIcon`). var menuBarImage: NSImage { MenuBarIcon.image(for: coordinator.state, level: coordinator.inputLevel) } - /// One line describing what the app is doing right now. var statusLine: String { MenuStatus.line( state: coordinator.state, @@ -280,11 +236,8 @@ final class AppModel { 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. + // Without Accessibility the stored chord may listen for nothing, so the stand-in is + // named: "hold Right Command" would be a lie. private var menuChords: MenuStatus.Chords { let standIn = hotkeys.standInHotkey?.sideAgnosticDisplayName let hold = standIn ?? hotkeys.displayName(of: settings.hotkey) @@ -297,18 +250,14 @@ final class AppModel { secureKeyboardEntry: hotkeys.usesCarbonForSecureInput) } - /// While a recorder field records a new chord, the monitor is down so - /// the keys being pressed cannot fire the old one. func setHotkeySuspended(_ suspended: Bool) { coordinator.isHotkeySuspended = suspended } - /// The menu's "Retry Model Download". func retryEngine() { coordinator.reloadEngine() } - /// The menu's "Last: …" line puts the whole transcript on the clipboard. func copyLastTranscript() { guard let text = coordinator.lastTranscript?.text else { return } Task { await output.copy(text) } @@ -325,7 +274,6 @@ final class AppModel { } extension Appearance { - /// `nil` follows the system. var nsAppearance: NSAppearance? { switch self { case .system: nil diff --git a/Sources/Pladder/CorrectionInbox.swift b/Sources/Pladder/CorrectionInbox.swift index 7b9a856..c622829 100644 --- a/Sources/Pladder/CorrectionInbox.swift +++ b/Sources/Pladder/CorrectionInbox.swift @@ -5,33 +5,20 @@ import PladderCore import PladderRefine import PladderSystem -/// The learned-corrections menu lines: the learner watches a field after -/// each paste and proposes what the user corrected; this keeps the proposals -/// until the user answers Add or Dismiss. -/// -/// Nothing here runs before the paste: `pasted` hands the text over and the -/// learner watches and reviews on its own thread and task. @MainActor @Observable final class CorrectionInbox { - /// Corrections the model agreed with, newest first. Kept for the app's - /// life or until answered. private(set) var proposals: [CorrectionProposal] = [] static let maximumProposals = 3 - - /// The dictionary as it is now, and how a rule gets into it. Set by the - /// app once it exists. @ObservationIgnored var dictionary: () -> [DictionaryEntry] = { [] } @ObservationIgnored var addRules: ([DictionaryEntry]) -> Void = { _ in } - /// Returns once no dictation is being recorded or is on its way out. @ObservationIgnored var waitUntilQuiet: @MainActor () async -> Void = {} @ObservationIgnored private let dismissed: DismissedCorrections @ObservationIgnored private let relay = MainActorRelay() @ObservationIgnored private var learner: CorrectionLearner? - /// The learner's stages. They quote the user's words, so the text is - /// `.private`: `log show` prints it only with private data on. + // The stages quote the user's words, so the text is `.private`. private nonisolated static let log = Logger(subsystem: "de.dinooo13.pladder", category: "learning") init(dismissedURL: URL) { @@ -43,10 +30,6 @@ final class CorrectionInbox { dismissed: dismissed, dictionary: { [weak self] in await self?.currentDictionary() ?? [] }, log: { Self.log.info("\($0, privacy: .private)") }, - // The review and Apple's polish share the system model, which - // answers one request at a time, so a review is held while a - // dictation is recorded or on its way out: one running at the - // release would eat into that dictation's polish budget. waitUntilQuiet: { [weak self] in await self?.waitForQuiet() }, onProposal: { relay.send($0) } ) @@ -56,19 +39,15 @@ final class CorrectionInbox { private func currentDictionary() -> [DictionaryEntry] { dictionary() } private func waitForQuiet() async { await waitUntilQuiet() } - /// The text that was pasted, strictly after the paste. func pasted(_ text: String) { learner?.pasted(text) } - /// Adds `heard → corrected` to the dictionary, overwriting a rule with the - /// same `from` the way the Dictionary tab's import does. func accept(_ proposal: CorrectionProposal) { proposals.removeAll { $0.id == proposal.id } addRules([DictionaryEntry(from: proposal.pair.heard, to: proposal.pair.corrected)]) } - /// Drops the line and remembers the pair so it is never proposed again. func dismiss(_ proposal: CorrectionProposal) { proposals.removeAll { $0.id == proposal.id } let dismissed = self.dismissed diff --git a/Sources/Pladder/HotkeyRouter.swift b/Sources/Pladder/HotkeyRouter.swift index 79a2752..9030b7e 100644 --- a/Sources/Pladder/HotkeyRouter.swift +++ b/Sources/Pladder/HotkeyRouter.swift @@ -3,48 +3,24 @@ 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. +// Both monitors live for the app's life, so switching costs nothing. The rule is +// `HotkeySource.choose`; see docs/ARCHITECTURE.md, "Hotkeys". @MainActor @Observable final class HotkeyRouter { - /// Which of the two the coordinator is currently driven by. private(set) var usesTap: Bool private(set) var accessibilityTrusted: Bool - /// The keyboard shortcuts macOS itself handles. Read on explicit - /// triggers only — launch, a permission flip, the settings window - /// opening — because `CopySymbolicHotKeys` is main-thread work linear in - /// the number of shortcuts and the answer changes about as often as - /// someone visits System Settings. Never on a key press. + // `CopySymbolicHotKeys` is main-thread work linear in the shortcut count: read at launch, + // a permission flip and settings opening, never on a key press. private(set) var systemShortcuts: Set = [] - /// The stored push-to-talk chord, as the app last reported it. private(set) var hotkey: Hotkey @ObservationIgnored private let tap = GlobalHotkeyMonitor() @ObservationIgnored private let carbon = CarbonHotkeyMonitor() - /// Only a *sustained* reading counts, so the password field the user - /// tabs through does not swap the monitor twice in four seconds. @ObservationIgnored private var secureInput = SustainedCondition() - /// The last poll's answer, so a chord change between polls decides with it. @ObservationIgnored private var secureInputSustained = false @ObservationIgnored private weak var coordinator: DictationCoordinator? - /// So the first `update` applies the stand-in even though nothing - /// flipped; `usesTap` already matches the grant at that point. + // So the first `update` applies the stand-in even though nothing flipped. @ObservationIgnored private var didApplyStandIn = false init(accessibilityTrusted: Bool, hotkey: Hotkey) { @@ -53,44 +29,32 @@ final class HotkeyRouter { usesTap = accessibilityTrusted } - /// The monitor the coordinator starts with. var initialMonitor: any HotkeyMonitor { usesTap ? tap : carbon } func attach(_ coordinator: DictationCoordinator) { self.coordinator = coordinator } - /// Called after every permission poll. func update(accessibilityTrusted trusted: Bool, secureInputEnabled: Bool) { if trusted != accessibilityTrusted { accessibilityTrusted = trusted } secureInputSustained = secureInput.observe(secureInputEnabled) let flipped = chooseMonitor() guard flipped != nil || !didApplyStandIn else { return } didApplyStandIn = true - // One update: set one after the other, the new monitor would first be - // started with the old stand-in, which Carbon may refuse. A recording - // in progress is dropped, since the old monitor's release can no - // longer arrive. + // One update: the new monitor started first with the old stand-in + // would have Carbon refuse a modifier-only chord. if let coordinator { - coordinator.update(coordinator.settings, hotkeyOverride: standInHotkey, monitor: flipped) + coordinator.update(coordinator.settings, standInHotkey: standInHotkey, monitor: flipped) } refreshSystemShortcuts() } - /// A chord with a regular key no longer needs a stand-in, and a - /// modifier-only one does; under Secure Event Input it may also need the - /// other monitor, which Carbon cannot register it on. Returns that - /// monitor when it changed, and the app hands the coordinator it, the new - /// `standInHotkey` and the new chord in one restart, rather than leaving - /// the chord on the wrong monitor until the next permission poll. func hotkeyChanged(to hotkey: Hotkey) -> (any HotkeyMonitor)? { guard hotkey != self.hotkey else { return nil } self.hotkey = hotkey return chooseMonitor() } - /// Re-decides between the tap and Carbon; returns the monitor to switch - /// to, or nil when it stays. private func chooseMonitor() -> (any HotkeyMonitor)? { let wantsTap = HotkeySource.choose( accessibilityTrusted: accessibilityTrusted, secureInputSustained: secureInputSustained, @@ -100,28 +64,18 @@ final class HotkeyRouter { return wantsTap ? tap : carbon } - /// The default chord, standing in for a stored chord Carbon cannot - /// register while Accessibility is missing. var standInHotkey: Hotkey? { accessibilityTrusted ? nil : hotkey.standInWithoutAccessibility } - /// True while a working Accessibility grant is being ignored because - /// Secure Event Input has the tap deaf and Carbon is standing in. var usesCarbonForSecureInput: Bool { accessibilityTrusted && !usesTap } - - /// Without Accessibility the chord has to contain a regular key, so the - /// recorder refuses modifier-only chords and settings says why. var hotkeyNeedsRegularKey: Bool { !accessibilityTrusted } - /// A chord named the way the monitor that matches it sees the keys: the - /// tap tells Left from Right, Carbon's mask cannot. + // Named as the matching monitor sees it: the tap tells Left from Right, Carbon cannot. func displayName(of chord: Hotkey) -> String { usesTap ? chord.displayName : chord.sideAgnosticDisplayName } - /// Re-reads the shortcuts macOS owns, which feed the warnings shown in - /// the settings window. func refreshSystemShortcuts() { let shortcuts = SystemShortcuts.enabled() if shortcuts != systemShortcuts { systemShortcuts = shortcuts } diff --git a/Sources/Pladder/MainActorRelay.swift b/Sources/Pladder/MainActorRelay.swift index c7e33f0..fe0ac08 100644 --- a/Sources/Pladder/MainActorRelay.swift +++ b/Sources/Pladder/MainActorRelay.swift @@ -1,7 +1,3 @@ -/// 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)? diff --git a/Sources/Pladder/MenuBarIcon.swift b/Sources/Pladder/MenuBarIcon.swift index 27c23e1..3cdcdfe 100644 --- a/Sources/Pladder/MenuBarIcon.swift +++ b/Sources/Pladder/MenuBarIcon.swift @@ -1,35 +1,24 @@ import AppKit import PladderCore -/// The app icon's waveform glyph, drawn as a menu bar template image so it -/// takes the menu bar's tint in light and dark mode. The bar layout mirrors -/// `scripts/make-icon.swift`; state is conveyed by the bars themselves rather -/// than by swapping SF Symbols. +// A template image, so it takes the menu bar's tint; the bars mirror +// `scripts/make-icon.swift`. enum MenuBarIcon { - /// What the glyph looks like for a given dictation state. Small enough to - /// cache every variant, so the label can update on each level change - /// while recording without redrawing. + // Few enough to cache every variant, so the label follows the level without redrawing. enum Variant: Hashable { case idle - /// Bars follow the input level, quantised to `levelSteps` so the - /// image cache stays bounded. + // Quantised, so the image cache stays bounded. case recording(step: Int) - /// Transcribing, polishing or inserting: dimmed to read as "busy". case busy - /// Engine unavailable or an error: slashed like `mic.slash`. case off static let levelSteps = 6 init(state: DictationState, level: Float) { switch state { - // The clipboard hint is not a failure and not work in progress; - // the glyph reads as ready while the overlay carries the message. + // The clipboard hint is neither failure nor work: the overlay carries the message. case .idle, .copied: self = .idle case .recording: - // Quantise the same shared amplitude curve the overlay's - // meter uses, so glyph and overlay wave agree on what the - // input level means. let amp = CGFloat(WaveformMeter.amplitude(for: level)) self = .recording(step: Int((amp * CGFloat(Self.levelSteps - 1)).rounded())) case .transcribing, .polishing, .inserting: self = .busy @@ -38,11 +27,9 @@ enum MenuBarIcon { } } - /// Bell-shaped envelope from the app icon. private static let envelope: [CGFloat] = [0.20, 0.36, 0.58, 0.82, 1.0, 0.82, 0.58, 0.36, 0.20] - // Geometry in points. Bar and gap widths land on half points so the - // capsules stay crisp on Retina menu bars. + // Bar and gap widths on half points keep the capsules crisp on Retina menu bars. private static let barWidth: CGFloat = 1.5 private static let gap: CGFloat = 1.0 private static let maxBarHeight: CGFloat = 14 @@ -66,8 +53,6 @@ enum MenuBarIcon { private static func heights(for variant: Variant) -> [CGFloat] { switch variant { case .recording(let step): - // Quiet input flattens the glyph to a line; louder input grows it - // back towards the full envelope, matching the overlay's meter. let amount = CGFloat(step) / CGFloat(Variant.levelSteps - 1) let floor = envelope.min()! return envelope.map { floor + ($0 - floor) * amount } @@ -102,8 +87,6 @@ enum MenuBarIcon { return image } - /// A diagonal stroke with a cleared halo, the same idiom SF Symbols use - /// for their `.slash` variants. private static func drawSlash(in rect: NSRect) { let inset: CGFloat = 3 let slash = NSBezierPath() diff --git a/Sources/Pladder/Overlay/LiveTranscriptText.swift b/Sources/Pladder/Overlay/LiveTranscriptText.swift index 5a20ebc..1e2695e 100644 --- a/Sources/Pladder/Overlay/LiveTranscriptText.swift +++ b/Sources/Pladder/Overlay/LiveTranscriptText.swift @@ -2,53 +2,32 @@ import AppKit import SwiftUI import PladderCore -/// The words so far, with the part that has not settled yet in secondary. -/// -/// Every live pass re-decodes the audio and returns a whole new string, so the -/// layout can change anywhere in it. Nothing here animates: a cross-fade -/// between two layouts draws both at once, which is unreadable at two passes a -/// second. Each pass simply replaces the text. The only thing carried between -/// passes is the common prefix with the previous string, which is the part the -/// engine has stopped changing; what came after it is drawn in secondary, so -/// the eye can see where the settled words end. +// Every live pass returns a whole new string. Nothing animates: a cross-fade draws two +// layouts at once, unreadable at two passes a second. The prefix shared with the +// previous pass has stopped changing and is drawn primary, the rest secondary. struct LiveTranscriptText: View { let text: String - /// A replica in a settings card has no row to fill, so it hugs its sample - /// text, wrapped at `width` onto at most two lines and never trimmed. var hugs: Bool = false - /// How wide the words may run before they wrap, so the view can work out - /// how much of the tail fits. var width: CGFloat? - - /// Three lines of 13 pt is as much as the pill can show without turning - /// into a window. static let maximumLines = 3 - /// The text of the previous pass, for the settled/unsettled split. It is - /// only a colour, so lagging one pass behind costs nothing. + // Only a colour, so lagging one pass behind costs nothing. @State private var previous = "" - /// The part of the text that is shown: the whole of it while it fits in - /// three lines, and its tail once it does not. SwiftUI's own head - /// truncation is no use here — with a line limit it keeps the first lines - /// and ellipsises the last one, which shows the oldest words and the - /// newest ones with the middle missing — so the cut is made here, at a - /// word boundary, against the font's real metrics. + // SwiftUI's head truncation with a line limit keeps the first lines and drops the + // middle, so the tail is cut here, at a word, against the font's real metrics. private var shown: String { guard let width, !hugs else { return text } return LiveTranscriptMetrics.tail(of: text, fittingLines: Self.maximumLines, width: width) } - /// One `AttributedString` rather than two concatenated `Text`s, which - /// macOS 26 deprecates. + // Not two concatenated `Text`s, which macOS 26 deprecates. private var attributed: AttributedString { let shown = shown - // The previous pass may have been trimmed to a different tail, so - // compare like with like from the end. + // The previous pass may have been cut to a different tail: compare from the end. var stable = shown.commonPrefix(with: previous.suffix(shown.count)) - // A prefix that stops inside a word would paint half of it grey, so - // back up to the end of the last whole word. + // A prefix stopping inside a word would paint half of it grey. if stable.count < shown.count, let lastSpace = stable.lastIndex(where: \.isWhitespace) { stable = String(stable[.. String { guard !text.isEmpty, width > 0, lines > 0 else { return text } let limit = lineHeight * CGFloat(lines) + 0.5 @@ -107,8 +67,7 @@ enum LiveTranscriptMetrics { let starts = wordStarts(in: text) guard starts.count > 1 else { return text } - // Fits is monotonic: the later a tail starts, the shorter it is, so - // the first start that fits is the one to use. + // Fitting is monotonic: the later a tail starts, the shorter it is. var low = 0 var high = starts.count - 1 while low < high { @@ -122,7 +81,6 @@ enum LiveTranscriptMetrics { return String(text[starts[low]...]) } - /// Index of the first character of every word. private static func wordStarts(in text: String) -> [String.Index] { var starts: [String.Index] = [] var afterSpace = true diff --git a/Sources/Pladder/Overlay/OverlayController.swift b/Sources/Pladder/Overlay/OverlayController.swift index 3da2cc6..76015be 100644 --- a/Sources/Pladder/Overlay/OverlayController.swift +++ b/Sources/Pladder/Overlay/OverlayController.swift @@ -3,8 +3,7 @@ import Foundation import PladderCore import PladderSystem -/// Mirrors the coordinator's state onto the overlay panel and decides when the -/// pill appears and disappears. +// When the pill appears and how it leaves: docs/ARCHITECTURE.md, "The overlay". @MainActor final class OverlayController { private let coordinator: DictationCoordinator @@ -17,22 +16,15 @@ final class OverlayController { private let stateLoop = ObservationLoop() private let levelLoop = ObservationLoop() private var visible = false - /// The pill is being held up across a polish pass. It leaves by the dive - /// like a pasted dictation, not by the fade, and that has to hold even - /// when `.polishing` never comes: a transcript under the polish minimum - /// is pasted straight from `.transcribing`. + // A pill held up for polish leaves by the dive, even when `.polishing` never comes: + // a transcript under the polish minimum is pasted straight from `.transcribing`. private var heldForPolish = false - /// The pill gathered into the disc at the release before the text was - /// pasted, and waits there with a spinner in it until the `.idle` branch - /// sends it down. Diving at once and flying back up to say - /// "Transcribing…" put one release through two exits. + // The disc gathered at the release waits, with a spinner, for `.idle`: diving at once + // and flying back up to say "Transcribing…" put one release through two exits. private var holdingDisc = false - /// How long a press has to last before the pill appears. Cmd+C, Cmd+V and - /// Cmd+Tab all begin with the same modifier as the default chord and are - /// over well inside this, so the press the tracker is about to cancel - /// never shows anything. A real dictation lasts far longer and pays for - /// this only in seeing the pill a sixth of a second later. + // Cmd+C, Cmd+V and Cmd+Tab share a lone-Command chord's modifier and are over well + // inside this, so a press the tracker will cancel never shows the pill. private static let presentDelay: Duration = .milliseconds(150) init(coordinator: DictationCoordinator) { @@ -58,22 +50,15 @@ final class OverlayController { holdingDisc = false } - /// Pushes the app's appearance onto the pill's SwiftUI scheme. The panel - /// itself takes it at every `show`: a borderless panel that is never key - /// or main does not reliably follow an `NSApp.appearance` change. func applyAppearance(_ appearance: Appearance) { model.appearance = appearance } - /// Pushes the overlay style and background choice onto the model, which - /// the pill and the panel's size both read. func applyStyle(_ style: OverlayStyle, glass: Bool) { model.style = style model.glass = glass } - /// Pushes the animation speed onto the model; both the panel's slide and - /// the pill's morph read it. func applySpeed(_ speed: OverlayAnimationSpeed) { model.speed = speed } @@ -81,8 +66,7 @@ final class OverlayController { private func observe() { stateLoop.start { [coordinator] in _ = coordinator.state - // Read so a new partial re-arms this too: between two passes the - // state stays `.recording` and nothing else would fire. + // Between two live passes the state stays `.recording`, so a new partial must re-arm. _ = coordinator.partialTranscript } onChange: { [weak self] in guard let self else { return } @@ -90,17 +74,14 @@ final class OverlayController { } } - /// The meter's level, on its own loop: it changes twenty times a second - /// while recording, and only the bars need to hear about it, not the - /// presentation logic `apply` runs. + // Its own loop: the level changes twenty times a second and only the bars need it. private func observeLevel() { levelLoop.start { [coordinator] in _ = coordinator.inputLevel } onChange: { [weak self] in guard let self else { return } - // Only while recording: at the release the pill gathers from - // the row it was showing, last level included, and the level - // dropping to zero would flatten the bars mid-gather. + // Only while recording: the pill gathers from its last row, and the level dropping + // to zero would flatten the bars mid-gather. if self.coordinator.state.isRecording { self.model.level = self.coordinator.inputLevel } @@ -108,14 +89,11 @@ final class OverlayController { } private func apply(_ state: DictationState) { - // Anything but a recording supersedes a pill that has not appeared yet. if !state.isRecording { cancelPresent() } switch state { case .recording: heldForPolish = false - // Menu Bar relies on the menu bar glyph alone, so nothing is - // presented. If the style was switched mid-dictation the pill may - // already be up; fade it out the same way idle does. + // Menu Bar shows nothing; a pill up from a style switch mid-dictation fades out. guard model.style != .menuBar else { if visible { scheduleHide(after: .zero, flight: false) } return @@ -125,18 +103,13 @@ final class OverlayController { cancelHide() schedulePresent() case .transcribing: - // The pill gathers into the disc from the recording row it was - // showing at the release, and the model is deliberately left - // alone so that is what gathers. The text normally lands inside - // the gathering, the disc dives, and the paste is the - // confirmation; a transcription that outlasts it holds the disc - // (`scheduleHide`). + // The model is left alone, so the recording row is what gathers into the disc; the + // paste is the confirmation. A transcription that outlasts the gather holds the disc. guard model.style != .menuBar else { if visible { scheduleHide(after: .zero, flight: false) } return } - // A polish cycle is seconds, not milliseconds: the pill stays up - // and says what is happening until `.idle` dives it out. + // A polish takes seconds: the pill stays up and says so until `.idle` dives it out. if coordinator.willPolish { heldForPolish = true model.partialTranscript = nil @@ -144,56 +117,39 @@ final class OverlayController { cancelHide() return } - // A new partial can re-run this once the pill is on its way out; - // only the first `.transcribing` acts. A pill that was never up - // stays down: the paste is the confirmation. + // Only the first `.transcribing` acts, and a pill that was never up stays down. guard visible else { return } scheduleHide(after: .zero, flight: true) case .polishing: - // Menu never shows the pill (errors aside), so a polish pass in - // Menu style fades whatever may be on screen out. guard model.style != .menuBar else { if visible { scheduleHide(after: .zero, flight: false) } return } - // Normally the pill is already up from `.transcribing` and this - // only morphs it onto the Polishing row; `present` covers a - // release inside the present delay, where it flies in fresh. + // Normally a morph of the pill already up; `present` covers a release inside the + // present delay, where it flies in fresh. heldForPolish = true model.state = state cancelHide() present(flight: true) case .inserting: - // Milliseconds long, and `.idle` or `.copied` follows at once, so - // nothing is shown and nothing is hidden here: hiding would - // flicker between the paste and the clipboard hint. + // Milliseconds long; hiding here would flicker between the paste and the copy hint. break case .error: - // Errors show in every style, Menu Bar included: a failed paste - // must never be silent. They fade in place rather than fly: an - // alarm should be there at once, not arrive a moment later. + // In every style, Menu Bar included: a failed paste must never be silent. Fades in + // place: an alarm should be there at once, not arrive a moment later. heldForPolish = false model.state = state cancelHide() present(flight: false) scheduleHide(after: .seconds(2), flight: false) case .copied: - // Same rule as an error: the text is on the clipboard and nothing - // pasted it, so the user has to be told in every style. No hide is - // scheduled here — the coordinator holds `.copied` for its display - // duration and the `.idle` branch below hides the pill after it. + // The coordinator holds `.copied` for its duration; the `.idle` branch hides it. model.state = state cancelHide() present(flight: false) case .idle, .unavailable: - // Deliberately *not* updating the model here: the pill keeps - // whatever it was showing — the recording row, the disc, the - // clipboard hint — and leaves the screen with it. `scheduleHide` - // resets the model once the panel is out. The clipboard hint - // leaves the way a pasted dictation does, collapsing into the - // disc and diving, so the two paths end alike, and so does a - // pill held up across a polish pass; only a discarded recording - // and an error fade in place. + // The model is not updated: the pill leaves with what it showed. The clipboard hint + // and a pill held for polish dive like a pasted dictation; a discard and an error fade. let flight = model.state == .copied || heldForPolish heldForPolish = false // The disc has already gathered and was waiting for this. @@ -208,12 +164,8 @@ final class OverlayController { } } - /// A flight presentation is the flying disc: the pill rises from the - /// screen's bottom edge as the Minimal circle and expands into its - /// style's shape on arrival. Errors and the clipboard hint skip the - /// flight — they must be immediate — and fade in where the pill rests; - /// the pill is parked as the disc while hidden, so they open out of it - /// during the fade. + // Errors and the clipboard hint skip the flight and fade in where the pill rests: + // they must be immediate. private func present(flight: Bool) { guard !visible else { return } visible = true @@ -221,9 +173,7 @@ final class OverlayController { if flight { model.presentation = .flyingIn panel.show(flight: true) { - // The view animates on every presentation change, so this - // expands the disc into the style's shape. A release during - // the flight has already gathered it for the dive, and a + // A release during the flight has already gathered the disc for the dive, and a // waiting disc must not open into the row. guard self.visible else { return } self.model.presentation = .settled @@ -234,10 +184,8 @@ final class OverlayController { } } - /// Presents after `presentDelay`, unless the pill is already up (a press - /// inside the previous take's fade-out, where waiting would blink it) or - /// a wait is already running (a partial re-applying `.recording` must not - /// push the appearance back again). + // Not when already up (a press inside the last fade-out would blink it) or already + // waiting (a partial re-applying `.recording` must not push it back). private func schedulePresent() { guard !visible, presentTask == nil else { return } presentTask = Task { [weak self] in @@ -260,8 +208,6 @@ final class OverlayController { hideTask = nil } - /// `flight` matches the presentation: a recording pill dives back down - /// through the screen's bottom edge, anything else fades in place. private func scheduleHide(after delay: Duration, flight: Bool) { hideTask?.cancel() hideTask = Task { [weak self] in @@ -269,16 +215,11 @@ final class OverlayController { guard !Task.isCancelled, let self, self.visible else { return } self.visible = false if flight { - // First the capsule gathers into the disc, then the panel - // slides down behind the screen edge: the mirror of the - // arrival, which slides up and then expands. self.model.presentation = .flyingOut try? await Task.sleep(for: .seconds(self.model.speed.morphDuration)) guard !Task.isCancelled, !self.visible else { return } - // Nothing pasted yet — a cold engine, a release inside an - // engine pass — so the disc stays where it is, a spinner in - // place of the wave, until the `.idle` branch sends it down. - // The hint and an error open out of it in place. + // Nothing pasted yet (a cold engine, a release inside an engine pass), so the disc + // waits with a spinner until the `.idle` branch sends it down. switch self.coordinator.state { case .transcribing, .polishing, .inserting, .copied, .error: self.model.state = .transcribing @@ -296,7 +237,6 @@ final class OverlayController { } } - /// Slides the gathered disc down behind the screen edge, then parks it. private func dive() async { panel.hide(flight: true) try? await Task.sleep(for: .seconds(model.speed.flightDuration)) @@ -304,11 +244,8 @@ final class OverlayController { park() } - /// Once the panel is out, put the model back to idle. `OverlayPill` - /// restarts the Minimal dot and its pulse when the phase *leaves* - /// `.recording`, so a model left at the last recording level would open - /// the next take on bare bars. A press inside the flight cancels the hide, - /// so that one take skips the dot intro. + // `OverlayPill` restarts the Minimal dot when the phase leaves `.recording`, so a model + // left at the last recording would open the next take on bare bars. private func park() { model.presentation = .hidden model.state = .idle diff --git a/Sources/Pladder/Overlay/OverlayModel.swift b/Sources/Pladder/Overlay/OverlayModel.swift index 95dbd54..74aca49 100644 --- a/Sources/Pladder/Overlay/OverlayModel.swift +++ b/Sources/Pladder/Overlay/OverlayModel.swift @@ -1,55 +1,31 @@ import SwiftUI import PladderCore -/// The state the overlay renders. Kept separate from the coordinator so the -/// panel can be driven independently (and shown while fading out after the -/// coordinator has already returned to `.idle`). +// Apart from the coordinator, so the pill can keep showing what it was while it leaves. @MainActor @Observable final class OverlayModel { var state: DictationState = .idle - /// The input level while recording, mirrored on its own so a level tick - /// redraws the meter and nothing else. var level: Float = 0 - /// Appearance forced by the Appearance setting; `.system` means follow. - /// AppKit's window propagation reaches a borderless panel inconsistently, - /// so the color scheme is set in SwiftUI directly. + // AppKit reaches a borderless panel inconsistently, so the scheme is set in SwiftUI. var appearance: Appearance = .system - /// Which pill the user picked. `.menuBar` never presents except for - /// errors; the controller decides that, not the view. var style: OverlayStyle = .compact - /// Liquid Glass behind the pill, or a flat window-background fill. var glass: Bool = true - /// How fast the pill flies in and out. var speed: OverlayAnimationSpeed = .quick - /// Where the pill is in the fly-in/fly-out presentation. The panel does - /// the sliding below the screen edge; this phase keeps the pill as the - /// Minimal disc whenever it is not settled, so it flies as the disc and - /// morphs to its style's shape once it has arrived. var presentation: OverlayPresentation = .hidden - /// What the engine has heard so far, for the Live Transcript style. Nil in - /// every other style, and nil again the moment the key is released. var partialTranscript: String? init() {} } enum OverlayPresentation: Equatable { - /// Off screen, parked as the disc so the next flight starts from it. case hidden - /// Rising from the bottom edge as the disc; content forced to Minimal. case flyingIn - /// At rest, in the style's own shape. case settled - /// Collapsing to the disc, about to dive; content forced to Minimal. case flyingOut } -/// The timing the animation setting maps to. Panel and view read the same -/// values so the slide and the morph stay in sync; the mapping lives here -/// because PladderCore never imports SwiftUI. +// Here, because PladderCore never imports SwiftUI. extension OverlayAnimationSpeed { - /// How long the panel takes to slide up from (or back down behind) the - /// bottom edge of the screen. var flightDuration: TimeInterval { switch self { case .instant: 0.06 @@ -58,12 +34,8 @@ extension OverlayAnimationSpeed { } } - /// How long the disc↔row morph takes, in both directions. Arrival is - /// slide-up then expand; departure is collapse then slide-down. The - /// controller waits exactly this long between the collapse and the dive, - /// so the two directions are mirror images, and the content crossfade - /// runs over the same span so what is inside the pill never outruns the - /// pill. + // The controller waits exactly this long between the collapse and the dive, and the + // content crossfade runs over the same span, so the two directions mirror each other. var morphDuration: TimeInterval { switch self { case .instant: 0.10 @@ -72,9 +44,7 @@ extension OverlayAnimationSpeed { } } - /// The spring the disc↔row geometry uses. A spring's duration is - /// perceptual, so the bounce is kept small enough that the settle stays - /// inside `morphDuration`. + // The bounce is kept small enough that the spring settles inside `morphDuration`. var morphAnimation: Animation { switch self { case .instant: .smooth(duration: morphDuration) @@ -83,19 +53,10 @@ extension OverlayAnimationSpeed { } } - /// How the content arriving with a morph fades in: over the same span as - /// the geometry, but no bounce, since a spring on opacity would dip past - /// zero and flash the content back. What is leaving goes quickly instead - /// (`OverlayPill.contentHandover`), so the eye never sees two dots. + // No bounce: a spring on opacity would dip past zero and flash the content back. var contentFade: Animation { .easeInOut(duration: morphDuration) } } -/// What the pill looks like, with the live audio level projected out. -/// -/// `DictationState.recording` carries a level that changes many times a second. -/// Driving the capsule's morph animation off the state itself would restart -/// that animation on every meter update, so the morph animates on this -/// level-free phase instead while the bars animate on their own. enum OverlayPhase: Equatable { case empty case recording @@ -110,8 +71,7 @@ enum OverlayPhase: Equatable { case .transcribing: self = .transcribing case .polishing: self = .polishing case .copied: self = .copied - // The controller never mirrors `.inserting` onto the model, so this - // is only reached by a preview, and it draws nothing. + // The controller never mirrors `.inserting`; only a preview gets here. case .inserting: self = .empty case .error(let failure): self = .error(failure) case .idle, .unavailable: self = .empty diff --git a/Sources/Pladder/Overlay/OverlayPanel.swift b/Sources/Pladder/Overlay/OverlayPanel.swift index eea9e7c..f4f4679 100644 --- a/Sources/Pladder/Overlay/OverlayPanel.swift +++ b/Sources/Pladder/Overlay/OverlayPanel.swift @@ -2,20 +2,11 @@ import AppKit import SwiftUI import PladderCore -/// Borderless floating panel that shows dictation status without ever taking -/// focus away from the app the user is typing into. -/// -/// The combination that makes this work: `.nonactivatingPanel` so ordering the -/// window front does not activate Pladder, `canBecomeKey == false` so it never -/// becomes the key window, and `ignoresMouseEvents` so clicks fall through to -/// whatever is underneath. +// Never takes focus from the app being typed into: non-activating, never key, and +// click-through. final class OverlayPanel: NSPanel { - /// Includes room around the capsule for its soft shadow and for the - /// widest error message; the window itself draws nothing, so nothing is - /// clipped and the pill sizes itself to its content inside this box. A - /// smaller pill (Minimal) just centres in the invisible box, and every - /// style has to fit the error row, so only the live transcript — which - /// needs room for its text — asks for more. + // Room for the shadow and the widest error message, which every style must fit; + // only the live transcript needs more. private static func size(for style: OverlayStyle) -> NSSize { switch style { case .liveTranscript: NSSize(width: 480, height: 140) @@ -23,22 +14,16 @@ final class OverlayPanel: NSPanel { } } - /// How long the pill takes to fade out when it does not fly. Flightless - /// hides — the clipboard hint, an error — use the plain fade; the - /// controller waits it out before it resets the model, so the last - /// content stays on screen for the whole fade. + // The controller waits this out before it resets the model, so the content stays + // for the whole fade. static let fadeOutDuration: TimeInterval = 0.25 - /// How far below its resting frame the panel starts (and dives back to), - /// so the pill enters and leaves through the screen's bottom edge. The - /// pill never sits higher than the panel's top, which is 64 pt above the - /// screen bottom, and it draws its own shadow. + // Far enough below the resting frame to start and end behind the screen's bottom edge. private static func flightDistance(for height: CGFloat) -> CGFloat { height + 80 } private let model: OverlayModel - /// Bumped on every show/hide so a fade-out that is superseded by a new - /// show does not order the window out afterwards. + // So a fade-out superseded by a new show does not order the window out afterwards. private var generation = 0 init(model: OverlayModel) { @@ -58,9 +43,7 @@ final class OverlayPanel: NSPanel { isMovableByWindowBackground = false backgroundColor = .clear isOpaque = false - // The capsule draws its own shadow. A window shadow on a transparent - // panel is computed from the window's rectangle and shows up as a faint - // box around the pill. + // A window shadow on a transparent panel shows as a faint box around the pill. hasShadow = false ignoresMouseEvents = true isReleasedWhenClosed = false @@ -80,20 +63,14 @@ final class OverlayPanel: NSPanel { func show(flight: Bool, onArrival: (@MainActor @Sendable () -> Void)? = nil) { generation &+= 1 let token = generation - // A borderless panel that is never key or main does not reliably - // inherit an appearance changed through `NSApp.appearance` after it - // was created, so re-sync on every show. The settings window restyles - // itself; the panel only exists between its appearances. + // A borderless panel that is never key or main does not reliably follow an + // `NSApp.appearance` change, so it is re-synced on every show. appearance = NSApp.appearance let final = targetFrame() alphaValue = 1 - // `orderFrontRegardless` avoids requiring Pladder to be active, which - // an accessory app never is. + // `orderFrontRegardless`: an accessory app is never active. if flight { - // Start fully below the bottom edge — the window is clipped - // there, so nothing flashes — and slide up to rest. The pill is - // always opaque; the disc-to-style morph is SwiftUI's job once - // the slide lands. + // Below the bottom edge the window is clipped, so nothing flashes on the way up. setFrame(final.offsetBy(dx: 0, dy: -Self.flightDistance(for: final.height)), display: false) orderFrontRegardless() NSAnimationContext.runAnimationGroup({ context in @@ -107,11 +84,8 @@ final class OverlayPanel: NSPanel { } }) } else { - // A dive that is still running keeps driving the frame after a - // plain `setFrame`, so the panel would finish below the screen - // edge with the hint on it. Only an animated frame supersedes - // it: the clipboard hint or an error arriving mid-dive brings the - // pill back up inside the fade. + // A running dive keeps driving the frame after a plain `setFrame`, and the panel + // would end below the edge with the hint on it; only an animated frame supersedes it. let diving = isVisible && frame != final if !diving { setFrame(final, display: false) } orderFrontRegardless() @@ -155,16 +129,13 @@ final class OverlayPanel: NSPanel { } } - /// Bottom-centre of whichever screen the pointer is on, so the pill shows up - /// where the user is looking on a multi-display setup. + // The screen the pointer is on, where the user is looking. private func targetFrame() -> NSRect { let mouse = NSEvent.mouseLocation let screen = NSScreen.screens.first { $0.frame.contains(mouse) } ?? NSScreen.main ?? NSScreen.screens.first guard let frame = screen?.frame else { return self.frame } - // The style can change between showings; the hosting view's - // autoresizing mask follows `setFrame`. let size = Self.size(for: model.style) return NSRect( x: frame.midX - size.width / 2, diff --git a/Sources/Pladder/Overlay/OverlayPill.swift b/Sources/Pladder/Overlay/OverlayPill.swift index 3e711b6..f788615 100644 --- a/Sources/Pladder/Overlay/OverlayPill.swift +++ b/Sources/Pladder/Overlay/OverlayPill.swift @@ -1,35 +1,20 @@ import SwiftUI import PladderCore -/// The pill itself, without the panel's shadow or forced scheme, so the -/// settings previews can render a live replica of each style. +// Without the panel's shadow or forced scheme, so settings can show a live replica. struct OverlayPill: View { let state: DictationState - /// The input level the meters follow while recording. var level: Float = 0 let style: OverlayStyle let glass: Bool - /// A static replica in settings: no dot timer, no pulse, seeded bars. var isPreview: Bool = false - /// The words so far, for the Live Transcript style. Ignored by the others. var partial: String? - /// The live row is a fixed box on screen so the capsule cannot jitter as - /// the text grows. A settings card has no room for that box, so its - /// replica hugs its sample text, wrapped onto two short lines. var hugsContent: Bool = false - /// While the pill flies in or out (or waits off screen) it is always the - /// Minimal disc: the bigger styles expand from it after arriving and - /// collapse back into it before diving. Settings replicas never fly, so - /// they are always settled. + // In flight, or off screen, the pill is always the Minimal disc; replicas never fly. var presentation: OverlayPresentation = .settled - /// The speed whose spring drives the disc↔row geometry on both sides of - /// the flight, so contracting to the disc takes exactly as long as - /// expanding out of it. Settings replicas never fly, so they keep the - /// default. var animationSpeed: OverlayAnimationSpeed = .quick @Namespace private var glassNamespace - /// Minimal shows a pulsing dot for the first 0.7 s, then the bars. @State private var showDot = true @State private var pulsing = false @@ -40,33 +25,25 @@ struct OverlayPill: View { var body: some View { GlassEffectContainer(spacing: 14) { content - // The size driver: in flight the pill is proposed the disc's - // width, at rest its own — animated with the speed's morph - // spring on *both* sides of the flight, so contracting to the - // disc and expanding out of it are the same rate. The glass - // bubble outside this point follows the proposal. + // In flight the pill is proposed the disc's width, at rest its own, animated with the + // same spring both ways, so contracting and expanding run at one rate. .frame(width: flying ? Self.minimalDiameter : nil) - // What arrives inside fades in over the same span (the branch - // transitions below), so the row is still faint while the - // capsule is small; this clip keeps it inside the capsule - // rather than poking out of the disc. + // What arrives inside fades in over the same span, so the row is faint while the + // capsule is small; the clip keeps it inside the capsule. .clipShape(Capsule()) .animation(animationSpeed.morphAnimation, value: flying) .modifier(PillBackground(glass: glass, namespace: glassNamespace)) } .task(id: phase) { guard !isPreview else { return } - // Runs on every phase change, so leaving `.recording` is where - // the pulse is reset; otherwise the next take's dot would appear - // already at full scale with no animation left to run. + // Leaving `.recording` resets the pulse, or the next take's dot would start at full + // scale with no animation left to run. guard phase == .recording else { pulsing = false return } showDot = true try? await Task.sleep(for: .milliseconds(700)) - // A phase change cancels this task, which is exactly what should - // stop the swap; nothing else to unwind. guard !Task.isCancelled else { return } withAnimation(.smooth(duration: 0.3)) { showDot = false } } @@ -79,15 +56,11 @@ struct OverlayPill: View { private var isCopied: Bool { state == .copied } - /// Both an error and the clipboard hint carry text, which needs the - /// Compact row's width, so they render as the row in every style. + // An error and the clipboard hint carry text, which needs the Compact row's width. private var needsRow: Bool { isError || isCopied || style != .minimal } - /// The transition on every content branch: what is arriving fades in - /// over the morph, so it comes up with the capsule around it; what is - /// leaving goes at once, so its dot is gone before the arriving dot is - /// visible and nothing is seen sliding towards the centre of a shrinking - /// capsule. + // What arrives fades in over the morph; what leaves goes at once, so its dot is gone + // before the arriving one shows and nothing slides into a shrinking capsule. private var contentHandover: AnyTransition { .asymmetric( insertion: .opacity.animation(animationSpeed.contentFade), @@ -97,26 +70,17 @@ struct OverlayPill: View { @ViewBuilder private var content: some View { - // In flight the pill is the Minimal disc whatever the style: the disc - // rises with the dot inside it, and the style's own shape comes out - // of it on arrival. Minimal shares that branch whether flying or - // not, so its bars and dot keep their identity — and their state — - // across the arrival. + // Minimal shares the flying branch, so its bars and dot keep their identity, and + // their state, across the arrival. if flying || !needsRow { - // A fixed square so the disc never changes size between the - // dot, the wave and the spinner. On release a row style's - // content goes at once and the Minimal wave comes up in the - // shrinking capsule, so every style leaves the way Minimal does. + // A fixed square, so the disc never changes size between the dot, the wave and the + // spinner. minimalContent .frame(width: Self.minimalDiameter, height: Self.minimalDiameter) .transition(contentHandover) } - // An error presents in every style (the controller makes sure of it), - // and the message needs the Compact row's width, so errors always - // render as the Compact row; the "press ⌘V" hint is text too and - // follows the same rule. `.menuBar` only ever reaches the view for - // those two. Live has its own recording row and falls back to the - // Compact rows for everything else. + // `.menuBar` reaches the view only for an error or the clipboard hint, which take the + // Compact row in every style. Live has its own recording row and Compact's for the rest. else if style == .liveTranscript && !isError { liveContent .frame(minHeight: 32) @@ -151,8 +115,6 @@ struct OverlayPill: View { .foregroundStyle(.primary) } case .polishing: - // Only a dictation on its way to the refiner gets here, and it - // waits seconds rather than milliseconds, so the pill says why. HStack(spacing: PillMetrics.rowSpacing) { ProgressView() .controlSize(.small) @@ -161,9 +123,6 @@ struct OverlayPill: View { .foregroundStyle(.primary) } case .copied: - // Nothing pasted the text, so the user has to. The one thing the - // pill still says after a release: the paste that is normally the - // confirmation never happened. HStack(spacing: 8) { Image(systemName: "doc.on.clipboard") .foregroundStyle(.secondary) @@ -180,20 +139,15 @@ struct OverlayPill: View { .foregroundStyle(.primary) .lineLimit(1) .truncationMode(.tail) - // Keeps a long message inside the panel instead of letting - // the capsule grow past its edges. .frame(maxWidth: 232) } - // `.inserting` is never mirrored onto the model, so it draws nothing. case .inserting, .idle, .unavailable: Color.clear.frame(width: 100) } } - /// The words as they are recognised, beside a narrower meter. Only the - /// recording row differs from Compact: the spinner, the clipboard hint and - /// the error say the same thing in every style, and letting them keep the - /// live row's width would leave a mostly empty capsule on screen. + // Only the recording row differs from Compact: the others, at the live row's width, + // would leave a mostly empty capsule. @ViewBuilder private var liveContent: some View { switch state { @@ -207,54 +161,35 @@ struct OverlayPill: View { width: hugsContent ? Self.previewTextWidth : Self.liveTextWidth ) } - // A capsule whose width changed with the text would jitter on - // every pass, so the row is a fixed width; its height follows the - // number of lines, one to three. + // A width that followed the text would jitter on every pass; the height follows + // the lines, one to three. .frame(width: hugsContent ? nil : Self.liveRowWidth, alignment: .leading) default: compactContent } } - /// Width of the live row's contents. With the 18 pt padding either side - /// the capsule comes to 440 pt, inside the 480 pt panel with room for its - /// shadow. + // With 18 pt padding either side the capsule is 440 pt, inside the 480 pt panel with + // room for its shadow. static let liveRowWidth: CGFloat = 404 - - /// The live row's meter is narrower than Compact's, to leave the words - /// room. private static let liveBarCount = 8 - - /// What the dot, the meter and the two gaps take of that row. private static let liveRowLead: CGFloat = PillMetrics.dotSize + PillMetrics.rowSpacing + PillMetrics.barsWidth(count: liveBarCount) + PillMetrics.rowSpacing - /// What is left of the row for the words. The text measures itself against - /// this to decide how much of the tail fits in three lines. static var liveTextWidth: CGFloat { liveRowWidth - liveRowLead } - - /// The words in a settings card: wide enough for two short lines, so the - /// card reads as text beside the meter rather than a long thin pill. static let previewTextWidth: CGFloat = 62 - /// Diameter of the Minimal disc. Five bars at 3 pt with 3 pt gaps are - /// 27 pt wide and 20 pt tall, which sits inside the disc with room to - /// spare at the chord where the bars reach. + // Five 3 pt bars with 3 pt gaps are 27 × 20 pt, inside the disc with room to spare. static let minimalDiameter: CGFloat = 44 - /// Just enough to say "recording", and "still working" when a - /// transcription runs long: no text, and a disc rather than a pill. The - /// dot marks the start of a take, then a narrow wave takes over so the - /// disc still shows the microphone is live. @ViewBuilder private var minimalContent: some View { switch state { case .recording: ZStack { if showsDot { - // The pulse is Minimal's own start-of-take cue. A row - // style flying in keeps the dot at the row's size, so - // the dot it hands over to on arrival is the same dot. + // A row style flying in keeps the dot at the row's size, so the dot it hands over to + // on arrival is the same dot. RecordingDot() .scaleEffect(pulsing && !needsRow ? 1.3 : 1.0) .onAppear { @@ -273,23 +208,18 @@ struct OverlayPill: View { ProgressView() .controlSize(.small) case .copied: - // The hint leaves by the dive like a pasted dictation, and the - // disc it collapses into keeps the row's clipboard glyph rather - // than going empty, so what slides away still says what happened. + // The disc the hint collapses into keeps its glyph, so what slides away still says + // what happened. Image(systemName: "doc.on.clipboard") .foregroundStyle(.secondary) .transition(.opacity) - // An error is rendered as the row above, so it never reaches this; - // `.inserting` is never mirrored onto the model. case .inserting, .error, .idle, .unavailable: Color.clear } } - /// A replica has no timer, so it goes straight to the bars. A row style - /// collapsing after a short take would otherwise put the dot back in the - /// middle of the capsule its own dot just left; the wave is what the - /// disc shows on the way out. + // A replica has no timer. A row style collapsing after a short take would otherwise + // put the dot back in the capsule its own dot just left. private var showsDot: Bool { if isPreview { return false } if presentation == .flyingOut && needsRow { return false } diff --git a/Sources/Pladder/Overlay/OverlayView.swift b/Sources/Pladder/Overlay/OverlayView.swift index b53384f..ce8808b 100644 --- a/Sources/Pladder/Overlay/OverlayView.swift +++ b/Sources/Pladder/Overlay/OverlayView.swift @@ -1,9 +1,6 @@ import SwiftUI import PladderCore -/// Liquid Glass pill: a capsule that samples the desktop behind the -/// transparent panel and morphs between the recording, transcribing, copied -/// and error states. struct OverlayView: View { let model: OverlayModel @@ -19,9 +16,7 @@ struct OverlayView: View { presentation: model.presentation, animationSpeed: model.speed ) - // Glass carries its own edge highlight; this is only enough shadow - // to lift the pill off a light desktop. The flat background gets - // the same treatment. + // Glass carries its own edge highlight; this only lifts the pill off a light desktop. .compositingGroup() .shadow(color: .black.opacity(0.16), radius: 8, y: 3) .animation(.smooth(duration: 0.25), value: phase) @@ -31,10 +26,6 @@ struct OverlayView: View { } } -/// Overrides the SwiftUI color scheme under a forced appearance. AppKit's -/// window-appearance propagation reaches a borderless panel inconsistently, -/// so this sets the scheme in the environment directly, which is what -/// `glassEffect` and the text colours follow. private struct ForcedScheme: ViewModifier { let appearance: Appearance diff --git a/Sources/Pladder/Overlay/PillParts.swift b/Sources/Pladder/Overlay/PillParts.swift index c77676a..9dffd3e 100644 --- a/Sources/Pladder/Overlay/PillParts.swift +++ b/Sources/Pladder/Overlay/PillParts.swift @@ -2,13 +2,11 @@ import AppKit import SwiftUI import PladderCore -/// The pill's type and meter geometry, in one place: every row draws with -/// these, and the live row measures its words and works out its width from -/// them, so the measurement can never drift from what is drawn. +// The live row measures its words with these, so the measurement never drifts from +// what is drawn. enum PillMetrics { static let fontSize: CGFloat = 13 static let font = Font.system(size: fontSize, weight: .medium, design: .rounded) - /// The same face as an `NSFont`, for measuring. @MainActor static let nsFont: NSFont = { let base = NSFont.systemFont(ofSize: fontSize, weight: .medium) guard let descriptor = base.fontDescriptor.withDesign(.rounded), @@ -16,7 +14,6 @@ enum PillMetrics { else { return base } return rounded }() - /// Between the dot, the meter and the words or label of a row. static let rowSpacing: CGFloat = 10 static let dotSize: CGFloat = 8 static let barWidth: CGFloat = 3 @@ -27,7 +24,6 @@ enum PillMetrics { } } -/// The red "live" dot, shared by Compact, Minimal and the settings replicas. struct RecordingDot: View { var size: CGFloat = PillMetrics.dotSize @@ -39,21 +35,12 @@ struct RecordingDot: View { } } -/// What sits behind the pill: Liquid Glass, or a flat window-background -/// capsule with a hairline border for people who want the desktop to stay -/// still. The shadow is added by whoever hosts the pill. struct PillBackground: ViewModifier { let glass: Bool - /// Only the live overlay morphs between states, so the glass identity is - /// optional; the settings replicas pass nothing. var namespace: Namespace.ID? - /// One shape for the rows and the Minimal disc: a capsule in a square is - /// a circle, so the disc↔row morph is purely the animated size. A - /// separate `Circle` would snap — `Circle()` in a row-sized frame draws a - /// disc in the middle of the row at once, and `AnyShape` cannot - /// interpolate between two shape types, so the collapse would be over - /// before it started. + // One shape for row and disc: a capsule in a square is a circle, so the morph is only + // the size. `AnyShape` cannot interpolate between two shape types; a `Circle` would snap. private var shape: Capsule { Capsule() } @ViewBuilder @@ -75,9 +62,6 @@ struct PillBackground: ViewModifier { } } -/// Bars whose heights follow the input level. The shaping (amplitude curve, -/// bell envelope, wobble, rise/fall blend) lives in the shared -/// `WaveformMeter`, so this wave matches the menu bar glyph exactly. struct LevelBars: View { let level: Float let count: Int @@ -89,9 +73,7 @@ struct LevelBars: View { @State private var meter: WaveformMeter @State private var heights: [CGFloat] - /// `seeded` pre-rolls the meter so a static replica (the settings cards, - /// which never see a level change) shows a wave rather than a row of - /// stubs. + // `seeded` pre-rolls the meter, so a replica that never sees a level shows a wave. init(level: Float, count: Int, maxHeight: CGFloat, opacity: Double, seeded: Bool = false) { self.level = level self.count = count diff --git a/Sources/Pladder/OverlayDemo.swift b/Sources/Pladder/OverlayDemo.swift index 2d67fdc..5544b57 100644 --- a/Sources/Pladder/OverlayDemo.swift +++ b/Sources/Pladder/OverlayDemo.swift @@ -2,17 +2,9 @@ import AppKit import Foundation import PladderCore -/// `Pladder --overlay-demo` plays one dictation per overlay path through the -/// real coordinator and the real pill, then quits. The engine, capture, -/// paste, refiner and hotkey are stand-ins and the press and release come -/// from here, so it never starts the hotkey, the microphone or the engine and -/// can run beside a copy of Pladder that is in use. It prints every press, -/// release and state change with its wall-clock time, which is what -/// `scripts/overlay-demo.sh` lines the screen recording up against. -/// -/// `--style compact|minimal|liveTranscript` and -/// `--speed instant|quick|expressive` pick the pill; the default is Compact -/// at Quick. +// `--overlay-demo`: every overlay path through the real coordinator and pill, with +// stand-ins for the engine, capture, paste, refiner and hotkey, so it can run beside +// a copy in use. `scripts/overlay-demo.sh` lines its timed output up with a recording. @MainActor enum OverlayDemo { static var isRequested: Bool { CommandLine.arguments.contains("--overlay-demo") } @@ -21,8 +13,6 @@ enum OverlayDemo { let name: String let text: String var polish = false - /// How long the engine takes; past the pill's collapse it holds the - /// disc, spinner in it, until the paste. var engineDelay: Duration = .milliseconds(200) var result: InsertResult = .pasted } @@ -127,8 +117,7 @@ enum OverlayDemo { } } -/// A steady wave for the meter and two seconds of silence at the release, -/// enough to clear the coordinator's minimum duration. +// Two seconds of silence at the release clear the coordinator's minimum duration. private actor DemoCapture: AudioCapture { private var levels: AsyncStream.Continuation? @@ -166,7 +155,7 @@ private final class DemoOutput: TextOutput, @unchecked Sendable { func prepare() async {} } -/// About as long as the on-device model takes for a short dictation. +// About as long as the on-device model takes for a short dictation. private final class DemoRefiner: TranscriptRefiner, @unchecked Sendable { func prepare() async {} func refine(_ text: String) async -> String? { diff --git a/Sources/Pladder/PermissionMonitor.swift b/Sources/Pladder/PermissionMonitor.swift index 177339a..4368d2f 100644 --- a/Sources/Pladder/PermissionMonitor.swift +++ b/Sources/Pladder/PermissionMonitor.swift @@ -3,22 +3,13 @@ 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. +// Polled because none of these notifies a change, and for the app's life, so a +// later revoke is picked up too. Secure Event Input is no grant but deafens the tap. @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 = {} @@ -29,8 +20,6 @@ final class PermissionMonitor { var needsMicrophone: Bool { microphoneStatus != .authorized } var needsAnyPermission: Bool { needsAccessibility || needsMicrophone } - /// Prompts for Accessibility once per launch when it is missing, and - /// starts polling. func start() { startPolling() if !accessibilityTrusted { Permissions.requestAccessibility() } @@ -41,8 +30,7 @@ final class PermissionMonitor { pollTask = nil } - /// Assigns only what changed, so views reading one grant are not redrawn - /// for every poll. + // Assigns only what changed, so views reading one grant are not redrawn every poll. func refresh() { let trusted = Permissions.isAccessibilityTrusted if trusted != accessibilityTrusted { accessibilityTrusted = trusted } @@ -70,8 +58,7 @@ final class PermissionMonitor { } } - /// Refreshes now and restarts the two-second cadence from here, so a - /// grant the user just gave shows without waiting out the old timer. + // Restarts the cadence, so a grant just given shows without waiting out the timer. private func startPolling() { refresh() pollTask?.cancel() diff --git a/Sources/Pladder/PladderApp.swift b/Sources/Pladder/PladderApp.swift index e2474ff..c62ba50 100644 --- a/Sources/Pladder/PladderApp.swift +++ b/Sources/Pladder/PladderApp.swift @@ -8,10 +8,8 @@ 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. + // The overlay demo and the screenshots are not the app: a throwaway settings file, + // and no menu bar item, which would be a second, slashed Pladder in the bar. private let isDeveloperMode: Bool init() { @@ -19,9 +17,7 @@ struct PladderApp: App { 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 - // modes never start the hotkey, microphone or engine. + // Deferred, so the app has finished launching before the Accessibility prompt. if OverlayDemo.isRequested { Task { @MainActor in await OverlayDemo.run() } } else if let directory = Screenshots.directory { @@ -43,27 +39,18 @@ struct PladderApp: App { Settings { SettingsView(model: model) } - // The settings view sizes itself; without this the window opens at a - // default size and lets the user squash the forms. + // Without this the window opens at a default size and lets the user squash the forms. .windowResizability(.contentSize) } } -/// `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. +// `LSUIElement` hides the Dock icon; setting the policy too keeps `swift run`, which +// has no Info.plist, the same. See docs/ARCHITECTURE.md, "Dictation flow", for quitting. @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. + // A dictation in flight with polish can take seconds; past this it is lost rather + // than the quit hanging. private static let shutdownDeadline: Duration = .seconds(3) private var terminationSignal: DispatchSourceSignal? @@ -71,10 +58,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) signal(SIGTERM, SIG_IGN) - // Off the main queue, so a main thread that is stuck still hears the - // signal: the quit is asked for, and if it has not happened past its - // own deadline the process ends anyway, as an uncaught SIGTERM would - // have ended it at once. + // Off the main queue, so a stuck main thread still hears the signal; past its own + // deadline the process ends anyway, as an uncaught SIGTERM would have. let source = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .global(qos: .userInitiated)) source.setEventHandler { Task { @MainActor in NSApp.terminate(nil) } @@ -84,8 +69,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { terminationSignal = source } - /// Longer than `shutdownDeadline`, so a quit that can proceed always - /// finishes first. + // Longer than `shutdownDeadline`, so a quit that can proceed always finishes first. private nonisolated static let signalExitDeadline: DispatchTimeInterval = .seconds(5) func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { @@ -93,8 +77,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // A second Quit while this one waits quits at once. Self.shutdown = nil Task { @MainActor in - // Past the deadline the shutdown is abandoned, not stopped: the - // process ends around it. _ = await firstOf(until: .now + Self.shutdownDeadline) { await shutdown() } sender.reply(toApplicationShouldTerminate: true) } @@ -118,10 +100,8 @@ private struct MenuContent: View { Button("Last: \(last)") { model.copyLastTranscript() } } - // A correction the user made by hand after a paste, which the - // on-device model agreed is a reusable spelling. A menu line rather - // than an overlay toast: the pill is click-through by construction, - // and a proposal arriving a minute later should wait, not interrupt. + // A menu line, not a toast: the pill is click-through, and a proposal arriving a + // minute later should wait, not interrupt. if !model.corrections.proposals.isEmpty { Divider() ForEach(model.corrections.proposals) { proposal in @@ -135,8 +115,8 @@ private struct MenuContent: View { Divider() Button("Settings…") { - // An accessory app is never active, so a plain openSettings() puts - // the window behind whatever the user was using. + // An accessory app is never active, so a plain `openSettings()` puts the window + // behind whatever the user was using. NSApp.activate(ignoringOtherApps: true) openSettings() Task { @MainActor in diff --git a/Sources/Pladder/PolishModelController.swift b/Sources/Pladder/PolishModelController.swift index 94eb54a..8bf150a 100644 --- a/Sources/Pladder/PolishModelController.swift +++ b/Sources/Pladder/PolishModelController.swift @@ -3,39 +3,20 @@ 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. + // Apple Intelligence can be switched in System Settings while the app runs. private(set) var availability: OnDeviceModelAvailability = TranscriptPolisher.availability - /// Where the chosen model's file stands; nil for Apple's, which has none. - /// Shown under the model picker. private(set) var status: ModelFileStatus? - - /// The coordinator's one refiner; forwards to the chosen model. let refiner: PolishRouter @ObservationIgnored private let applePolisher = TranscriptPolisher() @ObservationIgnored private var s1MiniPolisher: S1MiniPolisher? @ObservationIgnored private let files: ModelFiles @ObservationIgnored private let statusRelay = MainActorRelay<(ModelFile, ModelFileStatus)>() - /// The file the settings name, nil for Apple's model. @ObservationIgnored private var chosenFile: ModelFile? - /// Every call into `files`, in the order the settings asked for it: a - /// cancel and an ensure of the same file from a quick off and on must - /// not swap, or the cancel kills the download the ensure wanted. + // A quick polish off and on must not let its cancel reach `files` after its ensure. @ObservationIgnored private let fileWork = OrderedTasks() init() { @@ -56,8 +37,6 @@ final class PolishModelController { } func apply(model: PolishModel, polishing: Bool) { - // A download nobody will use any more stops: the user picked another - // model, or turned polish off before it finished. if let previous = chosenFile, previous != ModelFile(for: model) || !polishing { let files = self.files fileWork.enqueue { await files.cancel(previous) } @@ -90,7 +69,6 @@ final class PolishModelController { } } - /// Tries a failed download again; the picker's "Try Again". func retryDownload() { guard let file = chosenFile else { return } let files = self.files diff --git a/Sources/Pladder/PolishRouter.swift b/Sources/Pladder/PolishRouter.swift index 330e160..d057b68 100644 --- a/Sources/Pladder/PolishRouter.swift +++ b/Sources/Pladder/PolishRouter.swift @@ -1,10 +1,8 @@ import PladderCore import Synchronization -/// The coordinator's one refiner. The coordinator is built once and keeps -/// its refiner for life, while the polish model can be switched at any time, -/// so this hands each call to the model the settings name. The switch is a -/// lock around one reference; the call itself runs outside it. +// The coordinator keeps its refiner for life, while the model can be switched at any +// time. The call itself runs outside the lock. final class PolishRouter: TranscriptRefiner { private let current: Mutex diff --git a/Sources/Pladder/Screenshots.swift b/Sources/Pladder/Screenshots.swift index edcdda9..35a2076 100644 --- a/Sources/Pladder/Screenshots.swift +++ b/Sources/Pladder/Screenshots.swift @@ -2,14 +2,10 @@ import AppKit import SwiftUI import PladderCore -/// `Pladder --screenshots ` renders the pictures the README shows into -/// `` and quits: the real overlay pill on a stand-in desktop, in light -/// and dark, plus the social preview card. It never starts the hotkey, the -/// microphone or the engine, so it can run beside a copy of Pladder that is -/// in use. `scripts/make-screenshots.sh` wraps it. +// `--screenshots `: the README's pictures. Never starts the hotkey, the microphone +// or the engine, so it can run beside a copy in use; `scripts/make-screenshots.sh` wraps it. @MainActor enum Screenshots { - /// The output directory when the flag is present, nil for a normal launch. static var directory: URL? { let args = CommandLine.arguments guard let index = args.firstIndex(of: "--screenshots"), index + 1 < args.count else { return nil } @@ -21,8 +17,7 @@ enum Screenshots { for scheme in [ColorScheme.light, .dark] { let suffix = scheme == .dark ? "dark" : "light" - // Four tiles of 290 pt with 24 pt between them and 16 pt of - // padding either side. + // Four tiles of 290 pt, 24 pt between them and 16 pt of padding either side. await capture(StylesFigure(), size: CGSize(width: 1264, height: 250), scheme: scheme, to: directory.appending(path: "styles-\(suffix).png")) } @@ -32,7 +27,6 @@ enum Screenshots { NSApp.terminate(nil) } - /// Shows `figure` in a transparent borderless window and captures it. private static func capture(_ figure: Figure, size: CGSize, scheme: ColorScheme, to url: URL) async { let window = NSWindow( contentRect: NSRect(origin: .zero, size: size), @@ -59,10 +53,8 @@ enum Screenshots { window.orderOut(nil) } - /// `screencapture -l` grabs one window at its backing scale. The child - /// inherits the Screen Recording grant of the terminal that launched - /// Pladder, which is why the script runs the binary directly rather - /// than through `open`. + // The child inherits the Screen Recording grant of the terminal that launched + // Pladder, which is why the script runs the binary directly, not through `open`. private static func capture(window: NSWindow, to url: URL) { let process = Process() process.executableURL = URL(filePath: "/usr/sbin/screencapture") @@ -79,8 +71,6 @@ enum Screenshots { // MARK: - Views -/// A stand-in desktop: the diagonal wash the option cards use, with a few -/// soft blobs so the glass has something to refract. private struct Wallpaper: View { @Environment(\.colorScheme) private var scheme @@ -107,20 +97,17 @@ private struct Wallpaper: View { } } -/// A pill exactly as the overlay shows it, shadow included. private struct Pill: View { let state: DictationState var level: Float = 0 var style: OverlayStyle = .compact var scale: CGFloat = 1 - /// The words so far, for the Live Transcript pill. var partial: String? var body: some View { OverlayPill(state: state, level: level, style: style, glass: true, isPreview: true, partial: partial) - // A tile is narrower than the live row, and the proposed size - // would squeeze the glass but not the row inside it; the pill - // takes its own size and is scaled to fit instead. + // The proposed size would squeeze the glass but not the row inside it, so the pill + // takes its own size and is scaled to fit. .fixedSize() .compositingGroup() .shadow(color: .black.opacity(0.16), radius: 8, y: 3) @@ -128,7 +115,6 @@ private struct Pill: View { } } -/// The four overlay styles side by side, each on its own desktop. private struct StylesFigure: View { @Environment(\.colorScheme) private var scheme @@ -141,8 +127,7 @@ private struct StylesFigure: View { Pill(state: .recording, level: 0.6, style: .minimal, scale: 1.15) } tile("Live") { - // The real live row, at its real width: 440 pt of capsule - // scaled to fit the tile. + // The real live row at its real width, scaled to fit the tile. Pill( state: .recording, level: 0.6, @@ -171,7 +156,6 @@ private struct StylesFigure: View { } } - /// A slice of menu bar with the live wave glyph where Pladder sits. private var menuBar: some View { HStack(spacing: 14) { Spacer() @@ -190,7 +174,6 @@ private struct StylesFigure: View { } } -/// The 1280×640 card GitHub shows when the repository is shared. private struct SocialFigure: View { var body: some View { Wallpaper() diff --git a/Sources/Pladder/Settings/DictionaryView.swift b/Sources/Pladder/Settings/DictionaryView.swift index cb4cfa3..c357137 100644 --- a/Sources/Pladder/Settings/DictionaryView.swift +++ b/Sources/Pladder/Settings/DictionaryView.swift @@ -3,8 +3,6 @@ import SwiftUI import UniformTypeIdentifiers import PladderCore -/// Identifies one editable text cell, so the toolbar can move focus into a row -/// it just created and each field knows when it lost focus. private struct DictionaryCell: Hashable { enum Column: Hashable { case from, to } var id: DictionaryEntry.ID @@ -21,10 +19,8 @@ struct DictionaryView: View { private var entries: [DictionaryEntry] { model.settings.dictionary } - /// Recomputed from the current entries whenever the table redraws, so - /// typing a new rule that closes a cycle is reflected without any extra - /// wiring. Cheap: the dictionary is small and this is not on the - /// release-to-paste path. + // Recomputed on every redraw, so a rule that closes a cycle shows at once; cheap, + // and nowhere near the release path. private var cyclicIDs: Set { DictionaryEntry.cyclicIDs(in: entries) } var body: some View { @@ -96,8 +92,7 @@ struct DictionaryView: View { } .width(76) } - // Without this an empty table paints a stack of striped placeholder - // rows, which reads as broken when the dictionary is empty. + // An empty table otherwise paints striped placeholder rows, which reads as broken. .alternatingRowBackgrounds(.disabled) .frame(minHeight: 220) .onDeleteCommand(perform: removeSelected) @@ -148,8 +143,7 @@ struct DictionaryView: View { } } - /// Runs the same two processors the pipeline runs, in the same order, so - /// the preview cannot drift from the real behaviour. + // The same two processors the pipeline runs, in its order, so this cannot drift. private var testResult: String { let replaced = DictionaryReplacer(entries: entries).apply(to: sample) return CustomWordCorrector(entries: entries).apply(to: replaced) @@ -161,8 +155,7 @@ struct DictionaryView: View { let entry = DictionaryEntry(from: "", to: "") model.settings.dictionary.append(entry) selection = [entry.id] - // The row does not exist in the view tree until the next update, so - // focus has to wait a turn. + // The row is not in the view tree until the next update, so focus waits a turn. Task { focusedCell = DictionaryCell(id: entry.id, column: .from) } } @@ -212,15 +205,13 @@ struct DictionaryView: View { } } - /// Adds entries, overwriting an existing row with the same key instead - /// of creating a duplicate (see `DictionaryEntry.mergeKey`). private func merge(_ incoming: [DictionaryEntry]) { model.settings.dictionary.merge(incoming) } } -/// A table cell that edits a draft and writes back on submit or focus loss, so -/// typing does not persist settings on every keystroke. +// Edits a draft and writes back on submit or focus loss, so typing does not save +// the settings on every keystroke. private struct DictionaryField: View { @Binding var text: String let prompt: LocalizedStringKey @@ -230,8 +221,7 @@ private struct DictionaryField: View { @State private var draft = "" var body: some View { - // Inside a grouped Form a TextField renders its title as a label, so - // pass the placeholder as `prompt` and hide the label. + // Inside a grouped Form a TextField's title renders as a label. TextField("", text: $draft, prompt: Text(prompt)) .labelsHidden() .textFieldStyle(.plain) @@ -239,8 +229,7 @@ private struct DictionaryField: View { .onAppear { draft = text } .onSubmit { commit() } .onChange(of: text) { _, new in - // An import or a reset happened elsewhere; adopt it unless the - // user is mid-edit in this very cell. + // An import or a reset elsewhere; adopted unless the user is editing this very cell. if focus.wrappedValue != cell { draft = new } } .onChange(of: focus.wrappedValue) { old, new in diff --git a/Sources/Pladder/Settings/GeneralSettingsView.swift b/Sources/Pladder/Settings/GeneralSettingsView.swift index 9554cb8..32d9241 100644 --- a/Sources/Pladder/Settings/GeneralSettingsView.swift +++ b/Sources/Pladder/Settings/GeneralSettingsView.swift @@ -1,8 +1,6 @@ import SwiftUI import PladderCore -/// The General tab: one section per concern, each reading only what it -/// shows. struct GeneralSettingsView: View { @Bindable var model: AppModel @@ -16,9 +14,8 @@ struct GeneralSettingsView: View { PermissionsSection(permissions: model.permissions) } .formStyle(.grouped) - // What can change behind the app's back is re-read here rather than - // on the permission poll: this window is where it is shown, and the - // user may have just changed it in System Settings. + // Re-read here rather than on the poll: the user may have just changed it in System + // Settings, and this window is where it shows. .onAppear { model.settingsWindowOpened() } @@ -53,8 +50,7 @@ private struct PushToTalkSection: View { HotkeyRecorderField( hotkey: $model.settings.hotkey, setHotkeySuspended: model.setHotkeySuspended, - // The send key is off without Accessibility anyway, so - // only the push-to-talk key is constrained. + // The send key is off without Accessibility anyway. requiresRegularKey: model.hotkeys.hotkeyNeedsRegularKey, systemShortcuts: model.hotkeys.systemShortcuts ) @@ -83,7 +79,6 @@ private struct PushToTalkSection: View { HotkeyRecorderField( hotkey: $model.settings.submitKey, setHotkeySuspended: model.setHotkeySuspended, - // Empty has always meant off; now the field can say so. allowsEmpty: true ) } @@ -95,8 +90,7 @@ private struct PushToTalkSection: View { } header: { Text("Push to Talk") } footer: { - // Separate literals, so each translation stays whole and - // another key can add its own line. + // Separate literals, so each translation stays whole. VStack(alignment: .leading, spacing: 4) { FootnoteText("Hold to record, release to insert. Press the send key while recording and Return is pressed after the text. Click a field and press any key combination to assign it.") FootnoteText("Tap the toggle key to start recording and tap it again to insert. Set it to the same combination as the key and a short tap toggles while a hold still works as before. Escape discards a recording. Delete clears a field.") @@ -130,8 +124,7 @@ private struct AppearanceSection: View { private struct OverlaySection: View { @Bindable var model: AppModel - /// Menu never flies a pill in, so the background and the speed have - /// nothing to act on there. + // Menu never flies a pill in, so the background and the speed have nothing to act on. private var hasPill: Bool { model.settings.overlayStyle != .menuBar } var body: some View { diff --git a/Sources/Pladder/Settings/HotkeyRecorder.swift b/Sources/Pladder/Settings/HotkeyRecorder.swift index 8e6cd7c..59fce2e 100644 --- a/Sources/Pladder/Settings/HotkeyRecorder.swift +++ b/Sources/Pladder/Settings/HotkeyRecorder.swift @@ -5,31 +5,15 @@ import PladderCore import PladderSystem import SwiftUI -/// A button that shows the current push-to-talk chord and, when clicked, -/// records a new one from whatever the user presses next. -/// -/// Any key or combination is allowed, lone modifiers included. Left and right -/// modifiers are distinct in a modifier-only chord; with a regular key the -/// side is ignored and the left-hand key is stored. The chord is committed -/// once every key has been let go, so a chord of several keys can be built up -/// in any order. -/// Escape on its own cancels; Delete on its own clears, where that is allowed. +// The chord is committed once every key is let go, so it can be built up in any +// order. Escape alone cancels; Delete alone clears, where that is allowed. struct HotkeyRecorderField: View { @Binding var hotkey: Hotkey - /// Called with `true` when a recording starts with no other field - /// recording and `false` when the last one ends; recording in another - /// field ends this one. The caller suspends the global monitor meanwhile, - /// so the keys used to define the new chord cannot fire the old one. var setHotkeySuspended: (Bool) -> Void - /// Set while Accessibility is missing: a chord is then registered with - /// Carbon, which needs exactly one regular key, so modifier-only chords - /// are refused instead of being stored and silently never firing. + // Without Accessibility a chord goes to Carbon, which needs one regular key, so a + // modifier-only chord is refused rather than stored to never fire. var requiresRegularKey = false - /// Delete with nothing pending commits an empty chord, which turns the - /// key off. The push-to-talk key can never be empty. var allowsEmpty = false - /// The shortcuts macOS owns, so a chord that collides with one can be - /// warned about while it is being pressed rather than after it is stored. var systemShortcuts: Set = [] @State private var recorder = HotkeyRecorder() @@ -68,17 +52,15 @@ struct HotkeyRecorderField: View { private var label: String { guard recorder.isRecording else { return hotkey.displayName } - // Naming the modifiers that did arrive is the honest version of - // "Needs a regular key…": the user may well have pressed one and had - // macOS eat it before Pladder saw it. + // Naming the modifiers that did arrive is the honest version of "Needs a regular + // key": macOS may have eaten the key before Pladder saw it. if let refused = recorder.refusedModifiers { return String(localized: "Only \(refused.sideAgnosticDisplayName) arrived…") } return recorder.pending?.displayName ?? String(localized: "Press keys…") } - /// Whole sentences rather than fragments glued together: a translation - /// cannot be assembled from clauses. + // Whole sentences: a translation cannot be assembled from clauses. private var help: String { switch (recorder.isRecording, requiresRegularKey) { case (true, false): @@ -97,23 +79,16 @@ struct HotkeyRecorderField: View { } } -/// Owns the local event monitor for one recording session. -/// -/// A local `NSEvent` monitor is enough: the settings window is key while the -/// user records, and swallowing key-downs there keeps Space from clicking the -/// button and Cmd+Q from quitting the app mid-recording. +// A local monitor is enough, since the settings window is key while recording. +// Swallowing key-downs keeps Space from clicking the button and Cmd+Q from quitting. @MainActor @Observable final class HotkeyRecorder { private(set) var isRecording = false - /// The chord that will be committed: the keys held at the last moment a - /// key went down. Releasing keys never shrinks it. + // The keys held the last time one went down: releasing keys never shrinks it. private(set) var pending: Hotkey? - /// The modifiers the user let go of while a regular key was required. - /// The recording stays open so they can simply try again. private(set) var refusedModifiers: Hotkey? - /// What went wrong, or what will go wrong, with what is being pressed. - /// A warning only: it never stops a chord being stored. + // A warning only: it never stops a chord being stored. private(set) var notice: String? private var heldModifiers: Set = [] @@ -125,11 +100,8 @@ final class HotkeyRecorder { private var requiresRegularKey = false private var allowsEmpty = false private var systemShortcuts: Set = [] - /// Shown for the whole session while Secure Event Input is on, and put - /// back whenever a chord notice is cleared. private var secureInputNotice: String? - /// The app's one recording session, shared by every field. private static let slot = HotkeyRecordingSlot { $0.cancel() } private let token: HotkeyRecordingSlot.Token @@ -137,8 +109,8 @@ final class HotkeyRecorder { token = Self.slot.makeToken() } - /// A field torn down mid-recording without `onDisappear`: the monitors - /// hold this weakly, so it is freed, and the hotkey comes back here. + // A field torn down mid-recording need not get `onDisappear`; the monitors hold this + // weakly, so it is freed and gives the hotkey back here. isolated deinit { if let monitor { NSEvent.removeMonitor(monitor) } if let resignObserver { NotificationCenter.default.removeObserver(resignObserver) } @@ -152,19 +124,15 @@ final class HotkeyRecorder { setHotkeySuspended: @escaping (Bool) -> Void, commit: @escaping (Hotkey) -> Void ) { - // A restart keeps the session rather than resuming and suspending - // the hotkey in between. + // A restart keeps the session rather than resuming and suspending the hotkey between. tearDown() Self.slot.claim(token, by: self, setHotkeySuspended: setHotkeySuspended) self.commit = commit self.requiresRegularKey = requiresRegularKey self.allowsEmpty = allowsEmpty self.systemShortcuts = systemShortcuts - // A warning, not a refusal: the recorder reads the settings window's - // own key events, which secure input does not gate, so recording - // normally works. What it does mean is that the chord about to be - // stored will not fire until Secure Keyboard Entry is off, unless - // Carbon can register it. + // A warning, not a refusal: secure input does not gate the window's own key events, + // but the chord stored now will not fire until it is off, unless Carbon can take it. secureInputNotice = SecureInput.isEnabled ? String(localized: "Secure Keyboard Entry is on. A key combination recorded now may not reach Pladder, and one without a regular key will not fire until it is off.") : nil @@ -172,8 +140,7 @@ final class HotkeyRecorder { isRecording = true monitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp, .flagsChanged]) { [weak self] event in guard let self else { return event } - // Local monitors run on the main thread, but `NSEvent` is not - // Sendable, so pull out the plain values before hopping. + // `NSEvent` is not Sendable, so the plain values are pulled out before hopping. let key = KeyTransition( type: event.type, keyCode: event.keyCode, flags: UInt64(event.modifierFlags.rawValue), @@ -181,8 +148,8 @@ final class HotkeyRecorder { let swallow = MainActor.assumeIsolated { self.handle(key) } return swallow ? nil : event } - // Clicking elsewhere ends the recording rather than leaving a monitor - // behind that eats the next key press. + // Clicking elsewhere ends the recording rather than leaving a monitor that eats the + // next key press. resignObserver = NotificationCenter.default.addObserver( forName: NSWindow.didResignKeyNotification, object: nil, queue: .main ) { [weak self] _ in @@ -201,7 +168,7 @@ final class HotkeyRecorder { var isRepeat: Bool } - /// Returns true when the event must not reach the window. + // True when the event must not reach the window. private func handle(_ key: KeyTransition) -> Bool { switch key.type { case .flagsChanged: @@ -238,15 +205,8 @@ final class HotkeyRecorder { if held.isEmpty { if let pending { guard !requiresRegularKey || pending.canBeRegisteredWithoutAccessibility else { - // Carbon cannot register this, so storing it would leave - // the user with a key that does nothing. Keep recording; - // Escape still cancels. - // - // The regular key may well have been pressed: macOS - // dispatches an enabled shortcut such as Control+Space - // before the front app sees the key, so the monitor is - // left with the modifiers alone. Say so rather than - // implying Pladder mis-read the keys. + // Carbon cannot register this, so keep recording. The regular key may well have been + // pressed: macOS dispatches an enabled shortcut such as Control+Space first. self.pending = nil refusedModifiers = pending notice = String(localized: "Only \(pending.sideAgnosticDisplayName) reached Pladder. If you pressed a regular key too, macOS or another app owns that shortcut; try a different key, for example Control+Shift+D.") @@ -255,22 +215,18 @@ final class HotkeyRecorder { end(committing: pending.canonical) } } else if !held.isSubset(of: pending?.keyCodes ?? []) { - // A key went down that is not part of the chord so far: the chord - // is whatever is held now. let chord = Hotkey(keyCodes: held) pending = chord refusedModifiers = nil - // Warning only, in both modes: the tap does see such a chord, but - // the macOS shortcut fires alongside it. + // Warning only: the tap does see such a chord, but the macOS shortcut fires too. notice = chord.systemShortcutConflict(in: systemShortcuts).map { String(localized: "\($0.sideAgnosticDisplayName) is a macOS keyboard shortcut and will fire as well.") } ?? secureInputNotice } } - /// Ends the recording. A chord to commit is stored before the hotkey is - /// given back, so the monitor restarts once, with the new chord, rather - /// than with the old one and then again. + // The chord is stored before the hotkey is given back, so the monitor restarts once, + // with the new chord. private func end(committing chord: Hotkey? = nil) { let commit = self.commit tearDown() @@ -278,7 +234,6 @@ final class HotkeyRecorder { Self.slot.release(token) } - /// Everything but the session: the monitors and what was pressed. private func tearDown() { if let monitor { NSEvent.removeMonitor(monitor) } monitor = nil diff --git a/Sources/Pladder/Settings/OptionCards.swift b/Sources/Pladder/Settings/OptionCards.swift index c14d02a..9d5f5fb 100644 --- a/Sources/Pladder/Settings/OptionCards.swift +++ b/Sources/Pladder/Settings/OptionCards.swift @@ -2,10 +2,6 @@ import AppKit import SwiftUI import PladderCore -/// A System-Settings-style picker cell: a picture of the choice with its name -/// under it and an accent ring when it is the current one. Used instead of a -/// segmented control where the choice is visual and a word alone does not say -/// what it looks like. struct OptionCard: View { let title: String let isSelected: Bool @@ -13,9 +9,7 @@ struct OptionCard: View { let action: @MainActor () -> Void @ViewBuilder let thumbnail: Thumbnail - /// Thumbnails are laid out at this size and then scaled, so the mini - /// windows and pills keep their proportions whatever the card size. - /// (Computed, since a generic type cannot hold static stored values.) + // Computed, since a generic type cannot hold static stored values. private static var designSize: CGSize { CGSize(width: 88, height: 56) } private static var scale: CGFloat { 2 / 3 } private static var cardSize: CGSize { @@ -28,13 +22,11 @@ struct OptionCard: View { thumbnail .frame(width: Self.designSize.width, height: Self.designSize.height) .clipShape(RoundedRectangle(cornerRadius: 8)) - // A hairline edge, mostly for dark mode, where a dark - // desktop otherwise sinks into the window behind it. + // A hairline edge, mostly for dark mode, where a dark desktop sinks into the window. .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(.primary.opacity(0.2), lineWidth: 1.5)) .scaleEffect(Self.scale) .frame(width: Self.cardSize.width, height: Self.cardSize.height) - // The ring sits in the padding, so selecting a card does - // not move the picture or reflow the row. + // The ring sits in the padding, so selecting a card does not reflow the row. .padding(3) .overlay( RoundedRectangle(cornerRadius: 8) @@ -55,12 +47,8 @@ struct OptionCard: View { } } -/// A stand-in desktop for a card: the same diagonal blue wash the stock -/// wallpapers use, light or dark, with whatever the card is illustrating -/// centred on top. struct DesktopThumbnail: View { - /// `nil` follows the settings window's own scheme; the Appearance cards - /// force theirs so both can be shown side by side. + // The Appearance cards force theirs, so light and dark can be shown side by side. var scheme: ColorScheme? @ViewBuilder let content: Content @@ -70,9 +58,8 @@ struct DesktopThumbnail: View { var body: some View { DesktopWash(scheme: resolved) - // Overlay rather than ZStack: a scaled-down pill still lays out at - // its full size, and in a ZStack that would stretch the gradient so - // the card only shows its middle. The card clips whatever hangs out. + // Overlay rather than ZStack: a scaled-down pill still lays out at full size, and in a + // ZStack that would stretch the gradient so the card only shows its middle. .overlay { content .environment(\.colorScheme, resolved) @@ -80,9 +67,6 @@ struct DesktopThumbnail: View { } } -/// Light, Dark and Auto, shown on the Compact pill (the default overlay) so -/// the card previews the thing the setting most visibly changes. Auto splits -/// the card along the diagonal, the way System Settings does. struct AppearanceThumbnail: View { let appearance: Appearance let glass: Bool @@ -108,7 +92,6 @@ struct AppearanceThumbnail: View { .scaleEffect(0.5) } .overlay(alignment: .top) { - // The menu bar, just enough of it to read as "a Mac". Rectangle() .fill(.white.opacity(0.2)) .frame(height: 2) @@ -116,8 +99,6 @@ struct AppearanceThumbnail: View { } } -/// Half the card, split by the diagonal that runs from the top-right corner to -/// the bottom-left one. private struct DiagonalHalf: Shape { let leading: Bool @@ -137,9 +118,7 @@ private struct DiagonalHalf: Shape { } } -/// One overlay style, shown as it actually looks: the cards render the real -/// `OverlayPill` scaled down rather than a hand-drawn imitation, so they can -/// never drift from the pill itself. +// The real `OverlayPill` scaled down, so the cards can never drift from it. struct OverlayStyleThumbnail: View { let style: OverlayStyle let glass: Bool @@ -159,19 +138,13 @@ struct OverlayStyleThumbnail: View { OverlayPill(state: .recording, level: Self.previewLevel, style: .compact, glass: glass, isPreview: true) .scaleEffect(0.5) case .minimal: - // The disc is 44 pt; at 0.75 it reads as a disc without turning - // into a dot. + // The disc is 44 pt; at 0.75 it still reads as a disc, not a dot. OverlayPill(state: .recording, level: Self.previewLevel, style: .minimal, glass: glass, isPreview: true) .scaleEffect(0.75) case .menuBar: - // Nothing on the desktop at all: the wave in the menu bar is the - // whole of this style. menuBar case .liveTranscript: - // The live row hugs a two-line sample in a preview. The card's - // design box is 88 pt wide, so the pill, 171 pt at full size, is - // scaled to about 72 pt: the words are small but read as words, - // which is what tells this style from Compact. + // Scaled to about 72 pt of the card's 88: small, but the words read as words. OverlayPill( state: .recording, level: Self.previewLevel, @@ -181,18 +154,16 @@ struct OverlayStyleThumbnail: View { partial: String(localized: "see it as you speak"), hugsContent: true ) - // The card is narrower than the pill; proposed at the card's - // width the glass shrinks but the row does not, and the dot ends - // up outside the capsule. The pill takes its own size instead. + // Proposed at the card's width the glass shrinks but the row does not, and the dot + // ends up outside the capsule. .fixedSize() .scaleEffect(0.42) } } private var menuBar: some View { - // The same strip the Appearance cards draw, tall enough to hold the - // wave glyph. The image is resized, not scaled: `scaleEffect` keeps - // the 22×16 layout and would push the strip to the icon's height. + // Resized, not scaled: `scaleEffect` keeps the 22×16 layout and would push the strip + // to the icon's height. Rectangle() .fill(.white.opacity(0.2)) .frame(height: 9) @@ -209,8 +180,6 @@ struct OverlayStyleThumbnail: View { } } -/// Glass or flat, shown on the Compact pill since that is where the difference -/// is easiest to see. struct BackgroundThumbnail: View { let glass: Bool @@ -222,9 +191,6 @@ struct BackgroundThumbnail: View { } } -/// A speed for the fly-in/fly-out. An animation cannot be captured in a -/// still picture, so each card says it with a speed instead: lightning for -/// almost instant, a hare for quick, a tortoise for the long expressive one. struct AnimationSpeedThumbnail: View { let speed: OverlayAnimationSpeed @@ -247,8 +213,6 @@ struct AnimationSpeedThumbnail: View { } } -/// The diagonal wash a stand-in desktop is painted with, in the option cards -/// and the README screenshots alike. struct DesktopWash: View { let scheme: ColorScheme diff --git a/Sources/Pladder/Settings/ProcessingSettingsView.swift b/Sources/Pladder/Settings/ProcessingSettingsView.swift index 5e8c6c4..4997500 100644 --- a/Sources/Pladder/Settings/ProcessingSettingsView.swift +++ b/Sources/Pladder/Settings/ProcessingSettingsView.swift @@ -51,8 +51,6 @@ struct ProcessingSettingsView: View { .formStyle(.grouped) } - /// What stands between the chosen model and a polished dictation: - /// Apple Intelligence switched off, or an S1-mini file still to come. @ViewBuilder private var polishModelStatus: some View { if let status = model.polish.status { @@ -84,7 +82,6 @@ struct ProcessingSettingsView: View { } } - /// Settings stores the *disabled* IDs, so absence means on. private func binding(for id: String) -> Binding { Binding( get: { !model.settings.disabledProcessors.contains(id) }, diff --git a/Sources/Pladder/Settings/SettingsView.swift b/Sources/Pladder/Settings/SettingsView.swift index d2ff03b..89a5e78 100644 --- a/Sources/Pladder/Settings/SettingsView.swift +++ b/Sources/Pladder/Settings/SettingsView.swift @@ -15,17 +15,13 @@ struct SettingsView: View { ProcessingSettingsView(model: model) .tabItem { Label("Processing", systemImage: "wand.and.sparkles") } } - // Deliberately no material or glass on the window itself: Apple's own - // settings windows are plain, and Liquid Glass belongs on the controls - // and the overlay pill. The fixed width keeps switching tabs from - // resizing the window sideways; height follows the tallest content. + // No material or glass on the window: Apple's own settings windows are plain. The + // fixed width keeps switching tabs from resizing the window sideways. .frame(width: 540) .frame(minHeight: 540) } } -/// An orange line under a control that says why it may not do what the user -/// expects. Already in the user's language. struct WarningLabel: View { let text: String @@ -41,8 +37,6 @@ struct WarningLabel: View { struct PermissionRow: View { let title: LocalizedStringKey - /// Already in the user's language: the callers build it with - /// `String(localized:)` because it depends on the permission's state. let detail: String let granted: Bool let action: @MainActor () -> Void @@ -69,15 +63,10 @@ struct PermissionRow: View { } } -/// Section footer styling, the native macOS pattern: secondary colour, callout -/// size, wrapping instead of truncating. struct FootnoteText: View { private let text: Text - /// A literal, which the String Catalog translates. init(_ key: LocalizedStringKey) { text = Text(key) } - /// Text that is already in the user's language, or has no translation to - /// give it: an engine's detail line, an error from the system. init(verbatim text: String) { self.text = Text(text) } var body: some View { diff --git a/Sources/Pladder/SettingsLocation.swift b/Sources/Pladder/SettingsLocation.swift index d3936fc..25a730a 100644 --- a/Sources/Pladder/SettingsLocation.swift +++ b/Sources/Pladder/SettingsLocation.swift @@ -1,21 +1,12 @@ 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. +// `PLADDER_SETTINGS_PATH` gives a copy launched for testing a file of its own. +// `scratch` is for the overlay demo and the screenshots, which must touch nothing 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. + // 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") } @@ -38,10 +29,7 @@ struct SettingsLocation { 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. + // `SpeakUp` was the app's name before Pladder. Copied once; the old file stays. func migrateLegacySettingsIfNeeded() { guard migratesLegacySettings, !FileManager.default.fileExists(atPath: settingsURL.path) else { return } let legacy = Self.applicationSupport.appending(path: "SpeakUp/settings.json") diff --git a/Sources/Pladder/StatusText.swift b/Sources/Pladder/StatusText.swift index aff836f..11fce65 100644 --- a/Sources/Pladder/StatusText.swift +++ b/Sources/Pladder/StatusText.swift @@ -3,14 +3,8 @@ import PladderCore import PladderRefine import PladderSystem -/// The wording for every state `PladderCore` and `PladderEngines` report as a -/// value. -/// -/// Core imports Foundation only and must not produce user-facing text, so it -/// emits enum cases and this is the single place that turns them into -/// sentences — which also means the sentences are here, in the app, where the -/// String Catalog is. None of it runs between `recordingStopped` and -/// `inserted`: these are read by the menu and the overlay, and on failure. +// Read by the menu, the overlay and settings; none of it runs between +// `recordingStopped` and `inserted`. extension UnavailableReason { var text: String { switch self { @@ -25,8 +19,7 @@ extension EngineFailure { var text: String { switch self { case .download(let reason): - // Retry calls `load()` again, which resumes the `.partial` rather - // than starting the ~460 MB over; saying so is the point. + // Retry resumes the partial download rather than starting the ~460 MB over. String(localized: "Download failed: \(reason.text) (Retry resumes it)") case .incompleteFiles: String(localized: "Model files incomplete (Retry re-downloads them)") @@ -68,9 +61,7 @@ extension DictationFailure { } 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. + // Each sentence says what dictations do meanwhile: they paste as dictated. var polishWarning: String? { switch self { case .available: nil @@ -87,8 +78,7 @@ extension OnDeviceModelAvailability { } extension PolishModel { - /// The picker's wording. S1-mini's licence asks for its name exactly - /// so: "S1-mini" by "Superwhisper". + // S1-mini's licence asks for the name exactly so: "S1-mini" by "Superwhisper". var displayName: String { switch self { case .appleIntelligence: String(localized: "Apple Intelligence") @@ -99,7 +89,6 @@ extension PolishModel { } extension ModelFileFailure { - /// Each says what dictations do meanwhile, like the Apple sentences. var text: String { switch self { case .download: @@ -112,18 +101,11 @@ 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 } @@ -155,9 +137,7 @@ enum MenuStatus { } } - /// 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. + // 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)") @@ -168,8 +148,6 @@ enum MenuStatus { 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 @@ -182,9 +160,6 @@ enum MenuStatus { } } -/// The Processing tab's wording for the standard processors, by id. Core -/// holds no strings, so a processor is a stable id there and its name and -/// one-line description are here, beside the catalog. enum ProcessorText { static func name(_ id: String) -> String { switch id { @@ -215,7 +190,6 @@ enum ProcessorText { } extension HotkeyWarning { - /// The sentence under the recorder field. @MainActor var text: String { switch self { case .standIn(let stored, let standIn): diff --git a/Sources/PladderAudio/AVAudioEngineCapture.swift b/Sources/PladderAudio/AVAudioEngineCapture.swift index e53fe17..c8af8a2 100644 --- a/Sources/PladderAudio/AVAudioEngineCapture.swift +++ b/Sources/PladderAudio/AVAudioEngineCapture.swift @@ -2,31 +2,13 @@ import AVFoundation import PladderCore import os -/// Microphone capture built on `AVAudioEngine`. -/// -/// Lifetime: the engine is created once and lives for the life of the app, but it -/// only *runs* while a recording is in flight. A running input engine makes macOS -/// show the orange "microphone in use" indicator, which would otherwise be on -/// permanently. `warmUp()` attaches the input node and prepares the graph so the -/// first `start()` only pays for `AVAudioEngine.start()`, which is a few -/// milliseconds; `stop()` pauses the engine again once the tap is removed. -/// -/// Threading: the tap block runs on a realtime audio thread. It must never touch -/// actor state, allocate unpredictably, or `await` anything. Everything it needs -/// lives in `TapProcessor`, which it owns outright, the recording's -/// `SampleAccumulator`, which is behind a lock, and the -/// `AsyncStream.Continuation` for the level meter, which is safe to yield from any -/// thread. Sample-rate conversion happens inside the tap block, which is the normal -/// arrangement: `AVAudioConverter` is fast, deterministic, and doing it there avoids -/// shipping raw 48 kHz buffers across an isolation boundary. -/// -/// There is no `AVAudioSession` on macOS, so nothing here configures one. +// The engine runs only while recording: a running input engine keeps the orange +// microphone indicator on. The tap block runs on a realtime thread: no actor state, +// no awaits, no unpredictable allocation. public actor AVAudioEngineCapture: AudioCapture { - /// Sample rate every buffer is converted to, matching `CapturedAudio.sampleRate`. public nonisolated static let targetSampleRate: Double = 16_000 - /// ~43 ms at 48 kHz: small enough for a lively level meter, large enough that the - /// realtime thread is not woken excessively. + // ~43 ms at 48 kHz: a lively meter without waking the realtime thread too often. private static let tapBufferSize: AVAudioFrameCount = 2048 public enum CaptureError: Error, CustomStringConvertible, Sendable { @@ -46,8 +28,8 @@ public actor AVAudioEngineCapture: AudioCapture { } private let engine = AVAudioEngine() - /// The current recording's samples. A device change mid-recording installs a - /// new tap in front of the same accumulator, so the switch loses nothing. + // A device change mid-recording installs a new tap in front of the same + // accumulator, so the switch loses nothing. private var accumulator: SampleAccumulator? private var levelContinuation: AsyncStream.Continuation? private var isRecording = false @@ -57,9 +39,7 @@ public actor AVAudioEngineCapture: AudioCapture { // MARK: AudioCapture - /// Prepares the engine ahead of time without running it. Throws if there is no - /// usable input, which on a first launch usually means microphone permission has - /// not been granted yet; `start()` retries, so a throw here is not fatal. + // Throws without microphone permission on a first launch; `start()` retries. public func warmUp() async throws { observeConfigurationChanges() _ = try usableInputFormat() @@ -71,7 +51,6 @@ public actor AVAudioEngineCapture: AudioCapture { _ = await stop() } observeConfigurationChanges() - // warmUp() may have failed (no permission at launch); retry here. try startEngineIfNeeded() let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) @@ -94,9 +73,6 @@ public actor AVAudioEngineCapture: AudioCapture { log.log("capture started") } - /// Takes everything captured so far and keeps recording. Only the samples - /// since the last `drain()` or `start()` are returned; `stop()` then sees - /// just the tail. public func drain() async -> [Float] { guard isRecording, let accumulator else { return [] } return accumulator.drain() @@ -108,18 +84,16 @@ public actor AVAudioEngineCapture: AudioCapture { isRecording = false engine.inputNode.removeTap(onBus: 0) - // removeTap(onBus:) returns only once the tap block is no longer running, so - // draining afterwards picks up every buffer the hardware delivered. + // `removeTap(onBus:)` returns only once the tap block has stopped, so this drain + // gets every buffer the hardware delivered. let samples = accumulator?.drain() ?? [] accumulator = nil levelContinuation?.finish() levelContinuation = nil - // Off the release-to-paste path: the samples are complete, and the - // pause blocks on the current hardware I/O cycle. The microphone - // indicator going off a few milliseconds later is invisible; the - // pause blocking here is not. + // Off the release path: the pause blocks on the current hardware I/O cycle, and the + // microphone indicator going off a few milliseconds later is invisible. Task { [weak self] in await self?.pauseIfIdle() } let logged = CapturedAudio(samples: samples) Self.log.log("capture stop: \(logged.samples.count) samples (\(logged.duration, format: .fixed(precision: 2)) s), pause deferred") @@ -128,9 +102,7 @@ public actor AVAudioEngineCapture: AudioCapture { private static let log = Logger(subsystem: "de.dinooo13.pladder", category: "capture") - /// Pauses the engine unless a new recording started while the pause was - /// queued. `startEngineIfNeeded` skips `start()` when the engine is still - /// running, so a back-to-back recording stays live either way. + // `startEngineIfNeeded` skips a running engine, so a back-to-back recording stays live. private func pauseIfIdle() { guard !isRecording else { Self.log.log("pause skipped: a recording is active") @@ -154,8 +126,7 @@ public actor AVAudioEngineCapture: AudioCapture { } private func usableInputFormat() throws -> AVAudioFormat { - // Touching inputNode attaches it and makes the engine adopt the current input - // device's format. With no permission or no device this reports 0 Hz / 0 channels. + // With no permission or no device the input node reports 0 Hz and 0 channels. let format = engine.inputNode.inputFormat(forBus: 0) guard format.sampleRate > 0, format.channelCount > 0 else { throw CaptureError.noInputDevice @@ -171,14 +142,14 @@ public actor AVAudioEngineCapture: AudioCapture { let inputFormat = try usableInputFormat() let targetFormat = try AudioResampler.monoFloat32Format(sampleRate: Self.targetSampleRate) - // One converter per recording keeps the resampler's filter state continuous - // across tap buffers. + // One converter per recording keeps the resampler's filter state continuous across + // buffers: no clicks at the boundaries. let converter = try AudioResampler.makeConverter(from: inputFormat, to: targetFormat) let processor = TapProcessor(converter: converter, targetFormat: targetFormat, accumulator: accumulator) input.removeTap(onBus: 0) input.installTap(onBus: 0, bufferSize: Self.tapBufferSize, format: inputFormat) { buffer, _ in - // Realtime audio thread. No actor hops, no locks held by anyone else. + // Realtime audio thread: no actor hops, no locks held by anyone else. let level = processor.process(buffer) continuation.yield(level) } @@ -199,16 +170,12 @@ public actor AVAudioEngineCapture: AudioCapture { configurationObserver = NotificationObserver(token: token) } - /// The default input or output device changed. AVAudioEngine tears down its graph - /// and stops. With a recording in flight, restart it and reinstall the tap against - /// the new input format, in front of the same accumulator so what has been - /// captured so far is kept; otherwise the next `start()` restarts it. + // AVAudioEngine tears down its graph and stops when the default device changes. private func handleConfigurationChange() { guard isRecording, let accumulator, let continuation = levelContinuation else { return } engine.inputNode.removeTap(onBus: 0) - // Either failure leaves the recording without a tap until stop(), which - // still returns whatever was captured before the change; the new device - // may simply not be usable. + // Either failure leaves the recording without a tap until `stop()`, which still + // returns what was captured before the change. do { try startEngineIfNeeded() try installTap(into: accumulator, continuation: continuation) @@ -218,10 +185,8 @@ public actor AVAudioEngineCapture: AudioCapture { } } -/// Unregisters a block-based notification observer when the owner goes away. -/// -/// The actor cannot do this in its own `deinit` (a nonisolated deinit may not touch -/// non-`Sendable` isolated state), so the token's lifetime does the work instead. +// The actor's nonisolated `deinit` may not touch its state, so the token's lifetime +// unregisters the observer. private final class NotificationObserver: @unchecked Sendable { private let token: any NSObjectProtocol @@ -234,13 +199,8 @@ private final class NotificationObserver: @unchecked Sendable { } } -/// The converter one tap block runs its buffers through, in front of the -/// recording's accumulator. -/// -/// `@unchecked Sendable` because `AVAudioConverter` is not `Sendable`. Only the tap -/// block calls `process(_:)`, and CoreAudio serialises tap blocks, so the converter -/// is never used from two threads; the samples it produces go into the -/// accumulator, which has a lock of its own. +// `@unchecked Sendable`: only the tap block calls `process`, and CoreAudio +// serialises tap blocks. private final class TapProcessor: @unchecked Sendable { private let converter: AVAudioConverter private let targetFormat: AVAudioFormat @@ -252,8 +212,6 @@ private final class TapProcessor: @unchecked Sendable { self.accumulator = accumulator } - /// Called on the realtime audio thread. Converts to 16 kHz mono, accumulates, and - /// returns the meter level for this buffer. func process(_ buffer: AVAudioPCMBuffer) -> Float { let converted: [Float] do { diff --git a/Sources/PladderAudio/AudioResampler.swift b/Sources/PladderAudio/AudioResampler.swift index 5caa574..6841bac 100644 --- a/Sources/PladderAudio/AudioResampler.swift +++ b/Sources/PladderAudio/AudioResampler.swift @@ -1,15 +1,9 @@ import AVFoundation -/// Errors thrown while converting audio buffers. public enum AudioConversionError: Error, CustomStringConvertible, Sendable { - /// `AVAudioConverter` refused the format pair (for example a zero sample rate, - /// which is what an input node reports before microphone permission is granted). case converterUnavailable(from: String, to: String) - /// `AVAudioFormat` or `AVAudioPCMBuffer` allocation failed. case allocationFailed - /// The output format is not deinterleaved Float32, so there is no `[Float]` to read. case unsupportedOutputFormat - /// `AVAudioConverter` reported an error mid-conversion. case conversionFailed(String) public var description: String { @@ -28,14 +22,7 @@ public enum AudioConversionError: Error, CustomStringConvertible, Sendable { public var localizedDescription: String { description } } -/// Pure audio helpers: format conversion and level metering. -/// -/// These live apart from `AVAudioEngineCapture` so the conversion path — the risky -/// part, since the microphone hands us 48 kHz stereo and the transcription engines -/// want 16 kHz mono — can be unit tested against a synthesised buffer with no -/// hardware and no permission prompt. public struct AudioResampler: Sendable { - /// The format every engine in this app consumes: 16 kHz mono Float32, deinterleaved. public static func monoFloat32Format(sampleRate: Double = 16_000) throws -> AVAudioFormat { guard let format = AVAudioFormat( commonFormat: .pcmFormatFloat32, @@ -48,9 +35,6 @@ public struct AudioResampler: Sendable { return format } - /// Makes a converter for a single recording. Reusing one converter across all - /// buffers of a recording keeps the resampler's internal filter state continuous, - /// so there are no clicks at buffer boundaries. public static func makeConverter(from input: AVAudioFormat, to output: AVAudioFormat) throws -> AVAudioConverter { guard input.sampleRate > 0, input.channelCount > 0 else { throw AudioConversionError.converterUnavailable(from: "\(input)", to: "\(output)") @@ -61,19 +45,11 @@ public struct AudioResampler: Sendable { return converter } - /// One-shot conversion of a complete buffer. Creates a converter, pushes the - /// buffer through it and flushes, so the tail of the resampling filter is included. - /// - /// Channel downmix (stereo -> mono) is done by `AVAudioConverter` itself. public static func convert(_ buffer: AVAudioPCMBuffer, to format: AVAudioFormat) throws -> [Float] { let converter = try makeConverter(from: buffer.format, to: format) return try run(buffer, through: converter, to: format, endOfStream: true) } - /// Streaming conversion of one buffer of an ongoing recording, reusing `converter`. - /// - /// Unlike `convert(_:to:)` this never signals end of stream, because more buffers - /// are coming; it stops as soon as the converter runs dry of input. public static func convertChunk( _ buffer: AVAudioPCMBuffer, using converter: AVAudioConverter, @@ -96,22 +72,13 @@ public struct AudioResampler: Sendable { guard buffer.frameLength > 0 else { return [] } let ratio = format.sampleRate / buffer.format.sampleRate - // Enough room for the resampled frames plus the filter's tail. let capacity = AVAudioFrameCount((Double(buffer.frameLength) * ratio).rounded(.up)) + 1024 var output: [Float] = [] output.reserveCapacity(Int(capacity)) - // The input block hands the converter the buffer exactly once. Afterwards it - // reports either "no more input right now" (streaming: the converter returns - // .inputRanDry and we come back with the next tap buffer) or "end of stream" - // (one-shot: the converter flushes and returns .endOfStream). Returning - // .haveData again here would make the converter consume the same buffer - // forever, which is the classic AVAudioConverter infinite loop. - // The input buffer and the "already handed over" flag live in a box because - // `AVAudioConverterInputBlock` is `@Sendable` and cannot capture a mutable - // local or a non-Sendable `AVAudioPCMBuffer`. The converter only ever calls - // the block synchronously from `convert(to:error:withInputFrom:)` below, on - // this thread, so there is no actual concurrent access. + // The block hands the buffer over exactly once, then reports "no data now" or "end + // of stream": `.haveData` again would make the converter consume it forever. A box, + // because the block is `@Sendable`; the converter calls it synchronously, here. let state = ConversionState(buffer: buffer) let inputBlock: AVAudioConverterInputBlock = { _, statusPointer in guard let input = state.take() else { @@ -135,8 +102,7 @@ public struct AudioResampler: Sendable { switch status { case .haveData: - // Output buffer filled up before the input was exhausted; go around - // again. A zero-frame .haveData would mean no progress, so bail out. + // A zero-frame `.haveData` would mean no progress. if chunk.frameLength == 0 { return output } case .inputRanDry, .endOfStream: return output @@ -150,7 +116,6 @@ public struct AudioResampler: Sendable { // MARK: Level - /// Perceptual level for a meter: RMS mapped so -60 dBFS is 0 and 0 dBFS is 1. public static func rmsLevel(_ samples: UnsafeBufferPointer) -> Float { guard !samples.isEmpty else { return 0 } var sumOfSquares = 0.0 @@ -160,22 +125,18 @@ public struct AudioResampler: Sendable { } let rms = (sumOfSquares / Double(samples.count)).squareRoot() guard rms > 0 else { return 0 } - // -60 dBFS maps to 0 and 0 dBFS to 1. Conversational speech through a - // laptop microphone sits around -35...-20 dBFS, so the square root lifts - // that band into the upper half of the meter instead of leaving it flat. + // -60 dBFS maps to 0 and 0 dBFS to 1. Speech through a laptop microphone sits around + // -35 to -20 dBFS, so the square root lifts that band into the upper half. let dBFS = 20 * log10(rms) let linear = min(1, max(0, (dBFS + 60) / 60)) return Float(linear.squareRoot()) } - /// Convenience overload for an array of samples. public static func rmsLevel(_ samples: [Float]) -> Float { samples.withUnsafeBufferPointer { rmsLevel($0) } } } -/// Hands the input buffer to the converter exactly once. -/// See `AudioResampler.run(_:through:to:endOfStream:)`. private final class ConversionState: @unchecked Sendable { private var buffer: AVAudioPCMBuffer? diff --git a/Sources/PladderAudio/SampleAccumulator.swift b/Sources/PladderAudio/SampleAccumulator.swift index eabf8f5..17faa31 100644 --- a/Sources/PladderAudio/SampleAccumulator.swift +++ b/Sources/PladderAudio/SampleAccumulator.swift @@ -1,20 +1,9 @@ import Synchronization -/// The 16 kHz samples a recording has captured and not yet handed out. -/// -/// The tap block appends on the realtime audio thread; the capture actor -/// drains about once a second while the key is held and once more at stop. -/// One accumulator lives for the whole recording, across device changes, so -/// a switch of microphone swaps the tap in front of it without losing or -/// repeating anything said before the switch. -/// -/// A drain copies the samples out and keeps the buffer, so the audio thread -/// goes on appending into memory that is already reserved and touched rather -/// than growing a fresh array from nothing every second. The copy is the -/// only work done under the lock: about a second of audio, 64 KB. +// A drain copies out and keeps the buffer, so the audio thread appends into memory +// already reserved. The copy, about a second of audio, is all the work under the lock. final class SampleAccumulator: Sendable { - /// Ten seconds of 16 kHz mono: a dictation's first drain comes after one - /// second, so the audio thread rarely has to grow the array. + // The first drain comes after a second, so the audio thread rarely grows the array. static let defaultCapacity = 160_000 private let samples: Mutex<[Float]> @@ -25,25 +14,22 @@ final class SampleAccumulator: Sendable { samples = Mutex(reserved) } - /// Called on the realtime audio thread. + // Realtime audio thread. func append(_ newSamples: [Float]) { guard !newSamples.isEmpty else { return } samples.withLock { $0.append(contentsOf: newSamples) } } - /// Everything appended since the previous drain, oldest first. func drain() -> [Float] { samples.withLock { buffer in - // A copy through the pointer, not `Array(buffer)`: that would - // share the storage, and the `removeAll` below would then have to - // allocate a new buffer instead of emptying this one. + // Through the pointer, not `Array(buffer)`: that would share the storage, and the + // `removeAll` below would then allocate a new buffer instead of emptying this one. let taken = buffer.withUnsafeBufferPointer { Array($0) } buffer.removeAll(keepingCapacity: true) return taken } } - /// The reserved buffer's size, for the tests. var capacity: Int { samples.withLock { $0.capacity } } diff --git a/Sources/PladderBench/WordErrorRate.swift b/Sources/PladderBench/WordErrorRate.swift index 459254d..efe8a4e 100644 --- a/Sources/PladderBench/WordErrorRate.swift +++ b/Sources/PladderBench/WordErrorRate.swift @@ -1,12 +1,6 @@ import Foundation -/// Word error rate between a reference script and a transcript, for the -/// benchmark. Both texts are normalised first (lowercased, punctuation -/// removed) so casing and punctuation choices the engine makes do not count -/// as errors; only the words do. public enum WordErrorRate { - /// Lowercases, drops punctuation and collapses whitespace so two texts - /// that differ only in presentation compare equal word for word. public static func normalize(_ text: String) -> [String] { text.lowercased() .replacingOccurrences(of: "[’']", with: "", options: .regularExpression) @@ -14,9 +8,6 @@ public enum WordErrorRate { .map(String.init) } - /// Substitutions + deletions + insertions, divided by the reference word - /// count. 0 means a perfect match; can exceed 1 when the hypothesis has - /// many extra words. Returns 0 for an empty reference and empty hypothesis. public static func compute(reference: String, hypothesis: String) -> Double { let ref = normalize(reference) let hyp = normalize(hypothesis) @@ -24,7 +15,6 @@ public enum WordErrorRate { return Double(editDistance(ref, hyp)) / Double(ref.count) } - /// Levenshtein distance over word arrays, two rows of memory. static func editDistance(_ a: [String], _ b: [String]) -> Int { if a.isEmpty { return b.count } if b.isEmpty { return a.count } diff --git a/Sources/PladderCLI/Bench.swift b/Sources/PladderCLI/Bench.swift index 79a58b6..f43491c 100644 --- a/Sources/PladderCLI/Bench.swift +++ b/Sources/PladderCLI/Bench.swift @@ -12,8 +12,7 @@ struct Fixture { var duration: Double { Double(samples.count) / CapturedAudio.sampleRate } } -/// Every audio file in `dir` that has a sibling `.txt`, shortest first so the -/// longest fixture, which heats the chip the most, runs last. +// Shortest first, so the longest fixture, which heats the chip most, runs last. func loadFixtures(in dir: URL) throws -> [Fixture] { let files = try FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) var fixtures: [Fixture] = [] @@ -31,7 +30,6 @@ func loadFixtures(in dir: URL) throws -> [Fixture] { // MARK: - What both benchmarks share -/// Exits with a usage status unless the options make a benchmark. func checkBenchOptions(runs: Int, pause: Double) { guard runs >= 2 else { eprint("--runs must be at least 2 (the first run is discarded)") @@ -43,7 +41,6 @@ func checkBenchOptions(runs: Int, pause: Double) { } } -/// The title and the lines that say what ran on what. func printBenchHeader(_ title: String, engine: any TranscriptionEngine) { let chip = sysctlString("machdep.cpu.brand_string") ?? "unknown chip" let os = ProcessInfo.processInfo.operatingSystemVersionString @@ -52,18 +49,15 @@ func printBenchHeader(_ title: String, engine: any TranscriptionEngine) { print("model: \(engine.id) (\(engine.displayName))") } -/// Whether the chip stayed at its normal thermal state, run by run. struct ThermalRecord { private(set) var throttled = false - /// The end of a run line: the warm-up mark and any thermal tag. mutating func note(forRun run: Int) -> String { let thermal = thermalTag() throttled = throttled || !thermal.isEmpty return (run == 1 ? " (warm-up, discarded)" : "") + thermal } - /// The lines after the table. func printFooter() { print("") print(String(format: "load: %.2f (one-minute average at end)", loadAverage())) @@ -73,7 +67,6 @@ struct ThermalRecord { } } -/// The columns both results tables have. struct BenchRow { var name: String var duration: Double @@ -81,21 +74,16 @@ struct BenchRow { var spread: Double var wer: Double - /// `times` and `errors` are the kept runs. init(_ fixture: Fixture, times: [Double], errors: [Double]) { name = fixture.name duration = fixture.duration time = median(times) - // Spread of the kept runs relative to the median: the noise floor - // for this fixture, so a difference smaller than it means nothing. + // The noise floor for this fixture: a difference smaller than this means nothing. spread = (times.max()! - times.min()!) / time wer = median(errors) } } -/// The results table, with the timed column named `timed`. `extraHeader` and -/// `extraSeparator` add columns after the shared ones, and each row's -/// `extra` holds their cells. func printBenchTable( timed: String, rows: [(row: BenchRow, extra: String)], @@ -143,8 +131,8 @@ func runBench(dir: String, runs: Int, pause: Double) async throws { var times: [Double] = [] var errors: [Double] = [] for run in 1...runs { - // Every run starts from idle, like a dictation does. Back-to-back - // runs would hand each other warm clocks and residual heat. + // Every run starts from idle, like a dictation; back to back, runs would hand each + // other warm clocks and residual heat. if pause > 0 { try await Task.sleep(for: .seconds(pause)) } let started = clock.now let transcript = try await engine.transcribe(fixture.samples) @@ -166,8 +154,6 @@ func runBench(dir: String, runs: Int, pause: Double) async throws { // MARK: - Paced -/// The first word where two raw engine transcripts diverge, for the identity -/// gate. Nil when they are the same word sequence. func firstWordDifference( batch: String, paced: String @@ -182,8 +168,7 @@ func firstWordDifference( return nil } -/// What the live passes of one paced run cost. Lock-protected because the -/// live task records into it while the run's own task paces the audio. +// Locked: the live task records into it while the run's own task paces the audio. final class LivePassLog: @unchecked Sendable { private let lock = NSLock() private var _times: [Double] = [] @@ -191,36 +176,19 @@ final class LivePassLog: @unchecked Sendable { func record(_ elapsed: Double) { lock.withLock { _times.append(elapsed) } } } -/// Paced bench variant: pushes each fixture in one-second chunks paced at real -/// time, like a live recording, then times `endUtterance` alone. Pacing takes -/// as long as the audio, so keep the fixture set small. -/// -/// Every fixture also goes through the same engine once, whole, and the two -/// raw engine texts are compared before any processor runs. That line is the -/// identity gate: the incremental path runs the windows the whole-buffer path -/// runs, so any difference is a bug. -/// -/// With `live`, the Live Transcript style is modelled too: a second task asks -/// the engine for the text so far every 0.5 s while the fixture is paced and -/// is cancelled just before the release, exactly as the coordinator's feed -/// loop is. That answers the two questions the style raises — what a live pass -/// costs, and whether a release that lands next to one is slower — and the -/// identity gate becomes a gate on the live passes as well, since a pass that -/// disturbed the session's windows would change the text. +// The identity gate: every fixture also goes through the engine whole, and the raw +// texts must match. With `live`, a pass over the audio so far runs every 0.5 s while +// pacing and is cancelled before the release, as the coordinator's feed loop is. func runPacedBench(dir: String, runs: Int, pause: Double, includeShort: Bool, live: Bool) async throws { checkBenchOptions(runs: runs, pause: pause) var fixtures = try loadFixtures(in: URL(fileURLWithPath: dir)) - // Below ~13 s both paths run one padded window, so there is nothing paced - // about the result; --all keeps them anyway. + // Below ~13 s both paths run one padded window: nothing paced about the result. if !includeShort { fixtures = fixtures.filter { $0.duration >= 13 } } guard !fixtures.isEmpty else { eprint("no paced fixtures in \(dir)") exit(1) } - // One engine, two ways in. The paced path feeds it while the audio - // arrives; `transcribe` hands it the whole buffer, which is the call a - // recording transcribed at release makes. Comparing the two is the gate. guard let engine = makeEngine() as? any StreamingTranscriptionEngine else { eprint("\(StandardEngines.defaultEntry.id) does not transcribe while speaking; there is nothing to pace") exit(1) @@ -250,8 +218,6 @@ func runPacedBench(dir: String, runs: Int, pause: Double, includeShort: Bool, li for run in 1...runs { if pause > 0 { try await Task.sleep(for: .seconds(pause)) } let utterance = try await engine.beginUtterance() - // The overlay's loop: a pass over the audio so far, every half - // second, for as long as the "key" is held. let passes = LivePassLog() let liveTask: Task? = live ? Task { while !Task.isCancelled { @@ -261,8 +227,6 @@ func runPacedBench(dir: String, runs: Int, pause: Double, includeShort: Bool, li try? await Task.sleep(for: .milliseconds(500)) } } : nil - // One-second chunks paced at real time, as the coordinator's - // feed task would deliver them. var offset = 0 let oneSecond = Int(CapturedAudio.sampleRate) while offset + oneSecond < fixture.samples.count { @@ -271,10 +235,8 @@ func runPacedBench(dir: String, runs: Int, pause: Double, includeShort: Bool, li offset += oneSecond } let tail = Array(fixture.samples[offset...]) - // Cancelled and not awaited, exactly as the release does it: a - // pass already inside CoreML cannot be aborted, so the timed call - // below waits for it, and that wait is part of what the style - // costs. + // Cancelled and not awaited, as the release does: a pass already inside CoreML + // cannot be aborted, so the timed call waits for it, which is part of the style's cost. liveTask?.cancel() let started = clock.now let transcript = try await engine.endUtterance(utterance, tail: tail) @@ -295,14 +257,8 @@ func runPacedBench(dir: String, runs: Int, pause: Double, includeShort: Bool, li } } - // The identity gate: the same samples through the same engine, whole. - // Anything but `identical: yes` is a bug in the incremental path, not - // a tuning matter. - // - // Timed like the paced runs above and after the same idle pause, so - // the two columns are comparable: a call made straight after a paced - // run would find the Neural Engine warm when every release the bench - // models is cold. + // Anything but `identical: yes` is a bug in the incremental path. Timed after the + // same idle pause: straight after a paced run the Neural Engine would be warm. if pause > 0 { try await Task.sleep(for: .seconds(pause)) } let batchStarted = clock.now let batchTranscript = try await engine.transcribe(fixture.samples) diff --git a/Sources/PladderCLI/BenchProcess.swift b/Sources/PladderCLI/BenchProcess.swift index d90f134..1109e80 100644 --- a/Sources/PladderCLI/BenchProcess.swift +++ b/Sources/PladderCLI/BenchProcess.swift @@ -3,19 +3,8 @@ import PladderCore import PladderEngines import PladderSystem -/// `pladder-cli bench-process [--runs N]`: what the processor -/// pipeline costs on the fixtures' text (see "Processors" in -/// docs/BENCHMARKS.md). -/// -/// Every fixture's `.txt`, the script `scripts/make-fixtures.sh` wrote beside -/// its audio, is read as one line of transcript and run through the pipeline -/// `--process` runs, built from a fresh install's settings, in two variants: -/// "typical" has the filler "um," after the first word of every sentence, -/// "worst" also says "question mark" at the end of every sentence. Each -/// variant runs `runs` times; the first is discarded and the median of the -/// rest reported. +// What the processor pipeline costs: "Processors" in docs/BENCHMARKS.md. enum ProcessorBench { - /// Parses what follows `bench-process` on the command line and runs it. static func run(arguments: [String]) { var runs = 31 var directory: String? @@ -62,8 +51,6 @@ enum ProcessorBench { var times: [Double] = [] for attempt in 1...runs { let started = clock.now - // `await` whether or not `run` is async, so this file - // builds against either. _ = pipeline.run(variant) let elapsed = clock.now - started if attempt > 1 { times.append(elapsed / .milliseconds(1)) } @@ -75,7 +62,6 @@ enum ProcessorBench { } } - /// Every `.txt` in `directory` with its name, shortest first. private static func loadScripts(in directory: String) -> [(name: String, script: String)] { let url = URL(fileURLWithPath: directory) guard let files = try? FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) else { @@ -91,9 +77,7 @@ enum ProcessorBench { return scripts.sorted { $0.script.count < $1.script.count } } - /// The script's sentences, each with its closing mark, line breaks read - /// as spaces: the fixtures hold one sentence per line, and the engine - /// writes one line. + // The fixtures hold one sentence per line; the engine writes one line. static func sentences(in script: String) -> [String] { var sentences: [String] = [] var current = "" @@ -109,7 +93,6 @@ enum ProcessorBench { return sentences.filter { !$0.isEmpty } } - /// "um," after the first word of every sentence. static func typical(_ sentences: [String]) -> String { sentences.map { sentence in var words = sentence.split(separator: " ", omittingEmptySubsequences: true).map(String.init) @@ -118,8 +101,6 @@ enum ProcessorBench { }.joined(separator: " ") } - /// The typical variant with "question mark" spoken before every - /// sentence's closing mark. static func worst(_ sentences: [String]) -> String { typical(sentences.map { sentence in guard let last = sentence.last, ".!?".contains(last) else { return sentence + " question mark" } diff --git a/Sources/PladderCLI/Polish.swift b/Sources/PladderCLI/Polish.swift index 8d10fa7..03a7890 100644 --- a/Sources/PladderCLI/Polish.swift +++ b/Sources/PladderCLI/Polish.swift @@ -6,8 +6,7 @@ import PladderRefine import PladderSystem extension PolishModel { - /// The name `--model` takes for this model. A `switch` without a - /// `default`, so a new case does not compile until it has a name here. + // No `default`, so a new case does not compile until it has a name here. var cliName: String { switch self { case .appleIntelligence: "apple" @@ -16,7 +15,6 @@ extension PolishModel { } } - /// Every `--model` name, as the usage line lists them. static var cliNames: String { allCases.map(\.cliName).joined(separator: "|") } @@ -27,10 +25,7 @@ extension PolishModel { } } -/// Runs `TranscriptPolisher` over one transcript twice and prints what the -/// polish toggle would paste. The first run is cold (no `prepare()`), the second -/// warm, which is what a real press gets: the session is made and prewarmed -/// at key-down, seconds before the release. +// Cold, then warm: a real press prewarms the session at key-down, before release. func runPolish(_ path: String, model: PolishModel, options: PolishOptions) async throws { let text: String if path == "-" { @@ -64,16 +59,12 @@ func runPolish(_ path: String, model: PolishModel, options: PolishOptions) async show("warm", await polisher.polish(transcript)) } -/// What the command line changes about a polisher; nil is the app's own. struct PolishOptions { var instructionsPath: String? var gguf: String? var control: String? } -/// The polisher the app would use for `model`, downloading an S1-mini file -/// into the app's own model directory first if it is not there yet. With -/// `--gguf`, an S1-mini polisher over that file instead. func makePolisher(_ model: PolishModel, options: PolishOptions = PolishOptions()) async throws -> any ReportingRefiner { let instructionsPath = options.instructionsPath let file: ModelFile @@ -111,8 +102,6 @@ func makePolisher(_ model: PolishModel, options: PolishOptions = PolishOptions() return S1MiniPolisher(file: file, location: location, control: options.control ?? S1MiniPolisher.controlLine) } -/// One case of a polish test set: what the speech model heard and what -/// should be pasted. struct PolishCase: Decodable { let id: String let lang: String @@ -120,22 +109,17 @@ struct PolishCase: Decodable { let expected: String } -/// Runs `model` over a test set the way the app would: the app's processors -/// first, then the polish, warm. Prints every answer, then per-language word -/// error rates against the expected text (case and punctuation ignored), -/// exact matches (everything counts) and the polish time. func runPolishSet(_ path: String, model: PolishModel, options: PolishOptions) async throws { let cases = try JSONDecoder().decode([PolishCase].self, from: Data(contentsOf: URL(fileURLWithPath: path))) guard !cases.isEmpty else { eprint("pladder-cli: \(path): the test set has no cases") exit(1) } - // The processors `--process` runs, with a fresh install's settings: no - // dictionary, so the set means the same on every machine. + // A fresh install's settings: no dictionary, so the set means the same everywhere. let pipeline = StandardProcessors.pipeline(for: DictationSettings(engineID: StandardEngines.defaultEntry.id)) let polisher = try await makePolisher(model, options: options) await polisher.prepare() - // The first call pays for whatever prepare() could not warm. + // The first call pays for whatever `prepare()` could not warm. _ = await polisher.polish("Warm up.") var rates: [String: [Double]] = [:] diff --git a/Sources/PladderCLI/Support.swift b/Sources/PladderCLI/Support.swift index 947d540..3ea5547 100644 --- a/Sources/PladderCLI/Support.swift +++ b/Sources/PladderCLI/Support.swift @@ -5,14 +5,11 @@ import PladderAudio import PladderCore import PladderEngines -/// Writes to stderr, so nothing but results ever reaches stdout: a script -/// reading a transcript from `pladder-cli ` sees the transcript alone. +// Nothing but results reaches stdout: a script reading a transcript sees it alone. func eprint(_ message: String, terminator: String = "\n") { FileHandle.standardError.write(Data((message + terminator).utf8)) } -/// The engine the app runs by default, built from the catalog the app builds -/// its registry from. func makeEngine() -> any TranscriptionEngine { StandardEngines.defaultEntry.make() } @@ -26,8 +23,6 @@ func loadSamples(_ url: URL) throws -> [Float] { return try AudioResampler.convert(input, to: target) } -/// Loads the engine, printing download progress, and returns the wall-clock -/// load time. In a fresh process this is the cold start the app pays at launch. func loadEngine(_ engine: any TranscriptionEngine) async throws -> Duration { let clock = ContinuousClock() let started = clock.now @@ -36,7 +31,6 @@ func loadEngine(_ engine: any TranscriptionEngine) async throws -> Duration { while !Task.isCancelled { if case .downloading(let p) = await engine.status, let p { let pct = Int(p * 100) - // stderr, so a first run's download never lands in a transcript. if pct != lastPrinted { eprint("downloading \(pct)%") lastPrinted = pct @@ -49,12 +43,8 @@ func loadEngine(_ engine: any TranscriptionEngine) async throws -> Duration { do { try await engine.load() } catch { - // The engine's own wording for the failure — the line the menu shows — - // rather than a top-level trap printing the raw error, so a download - // that never finished can be diagnosed from the terminal. + // The engine's own diagnosis, rather than a trap printing the raw error. if case .failed(let failure) = await engine.status { - // A developer tool: the enum's own shape is the diagnosis, and - // the wording that reaches users lives in the app. eprint("model failed: \(failure)") exit(1) } @@ -79,8 +69,7 @@ func sysctlString(_ name: String) -> String? { return String(decoding: buffer.prefix(size).prefix { $0 != 0 }, as: UTF8.self) } -/// Physical memory footprint of this process, the number Activity Monitor -/// shows in its Memory column. +// The number Activity Monitor shows in its Memory column. func physicalFootprintBytes() -> UInt64? { var info = task_vm_info_data_t() var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) @@ -92,14 +81,11 @@ func physicalFootprintBytes() -> UInt64? { return result == KERN_SUCCESS ? info.phys_footprint : nil } -/// One-minute load average, so the conditions of a run are on the record. func loadAverage() -> Double { var loads = [Double](repeating: 0, count: 3) return getloadavg(&loads, 3) > 0 ? loads[0] : 0 } -/// Empty when the chip is at its normal thermal state, else a tag for the -/// run line, because a throttled run is not comparable. func thermalTag() -> String { switch ProcessInfo.processInfo.thermalState { case .nominal: return "" diff --git a/Sources/PladderCLI/Transcribe.swift b/Sources/PladderCLI/Transcribe.swift index 5a613a9..319bac5 100644 --- a/Sources/PladderCLI/Transcribe.swift +++ b/Sources/PladderCLI/Transcribe.swift @@ -3,9 +3,8 @@ import PladderCore import PladderEngines import PladderSystem -/// The app's settings, for `--process`. Decoded here rather than through -/// `SettingsStore.load()`, which moves a file it cannot decode aside: a CLI -/// built from another branch must never touch the live configuration. +// Not `SettingsStore.load()`, which moves a file it cannot decode aside: a CLI built +// from another branch must never touch the live configuration. func appSettings() -> Settings { let url = ProcessInfo.processInfo.environment["PLADDER_SETTINGS_PATH"].flatMap { $0.isEmpty ? nil : URL(filePath: $0) } ?? FileManager.default.homeDirectoryForCurrentUser @@ -42,8 +41,7 @@ func transcribeFile(_ path: String, process: Bool, verbose: Bool) async { print(text) } } catch { - // A file Core Audio cannot open (WebM, say) is the likely failure; a - // message and a status rather than a trap, for whatever called us. + // A file Core Audio cannot open (WebM, say): a message and a status, not a trap. eprint("pladder-cli: \(path): \(error)") exit(1) } diff --git a/Sources/PladderCLI/main.swift b/Sources/PladderCLI/main.swift index 66122f3..a1b9591 100644 --- a/Sources/PladderCLI/main.swift +++ b/Sources/PladderCLI/main.swift @@ -1,60 +1,8 @@ import Foundation import PladderCore -// Developer tool. -// -// pladder-cli