Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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, "<section>".` 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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
90 changes: 19 additions & 71 deletions Sources/Pladder/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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))
Expand All @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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)
}
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -252,26 +212,22 @@ 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")
}
}

// 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,
Expand All @@ -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)
Expand All @@ -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) }
Expand All @@ -325,7 +274,6 @@ final class AppModel {
}

extension Appearance {
/// `nil` follows the system.
var nsAppearance: NSAppearance? {
switch self {
case .system: nil
Expand Down
Loading
Loading