Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
47ae16b
Permission monitor: the two-second poll is a named constant
dinooo13 Oct 16, 2026
d1487b5
CI: a comment naming a docs/ heading that does not exist fails; ARCHI…
dinooo13 Oct 16, 2026
e403b15
Tests: ManualClock wakes a waiter when its sleeper arrives, and waitU…
dinooo13 Oct 16, 2026
6c45e6b
Tests: the coordinator fakes hold on a Gate instead of sleeping
dinooo13 Oct 16, 2026
1ab2259
Tests: the engine loader test waits for the superseded load on a Gate…
dinooo13 Oct 16, 2026
de4b068
Tests: OrderedTasks' cancel winds down on a Gate, in one run instead …
dinooo13 Oct 16, 2026
8894ccc
Output mute: the arm delay sleeps on an injected clock
dinooo13 Oct 16, 2026
a1eaec9
S1-mini: the test of a load interrupted by unload awaits the abandone…
dinooo13 Oct 16, 2026
8d2cc70
Tests: the sleeps that must stay real time say why
dinooo13 Oct 16, 2026
cff3dcb
CLI: bench-coordinator times the coordinator's own share of release-t…
dinooo13 Oct 16, 2026
8f8b591
Coordinator: the finish pipeline is DictationFinish, which returns an…
dinooo13 Oct 16, 2026
5aea383
Coordinator: the feed and warm loops are a RecordingEngineSession
dinooo13 Oct 16, 2026
b279fdb
Core: one Generation for the recording number and the mute's arm
dinooo13 Oct 16, 2026
55a69c9
Docs: superseded work is one rule with its sites listed, and the spea…
dinooo13 Oct 16, 2026
4634ed8
Hotkeys: which monitor and which stand-in is HotkeyRouting, a value i…
dinooo13 Oct 16, 2026
30eeb82
App: copying the last transcript goes through the coordinator and the…
dinooo13 Oct 16, 2026
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
27 changes: 27 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Docs

on:
push:
branches: [main]
paths:
- 'docs/**'
- 'Sources/**'
- 'Tests/**'
- 'scripts/check-doc-pointers.sh'
- '.github/workflows/docs.yml'
pull_request:
paths:
- 'docs/**'
- 'Sources/**'
- 'Tests/**'
- 'scripts/check-doc-pointers.sh'
- '.github/workflows/docs.yml'

jobs:
doc-pointers:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Comments point at headings that exist
run: ./scripts/check-doc-pointers.sh
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ swift run -c release pladder-cli <audio file> [--process] [--verbose] # transcri
./scripts/make-fixtures.sh # synthesise benchmark fixtures into bench/fixtures (gitignored)
swift run -c release pladder-cli bench bench/fixtures # whole-buffer benchmark
swift run -c release pladder-cli bench bench/fixtures --paced # feed at real time, time endUtterance, check identity
swift run -c release pladder-cli bench-coordinator [--polish] [--live] # the coordinator's own share of release-to-paste, stand-ins of fixed latency
swift run -c release pladder-cli polish <text file> [--model apple|s1-mini|s1-mini-8bit] # one transcript, cold and warm
swift run -c release pladder-cli polish-set docs/polish-set.json --model s1-mini-8bit # a polish model over the test set: error rate, exact matches, timings
./scripts/overlay-demo.sh [dir] [--style …] [--speed …] # play every overlay path with stand-ins, record the screen, cut contact sheets
Expand Down Expand Up @@ -73,7 +74,7 @@ swift run -c release pladder-cli polish-set docs/polish-set.json --model s1-mini
- Adding a hotkey role: a case in `HotkeyRole`, a `Hotkey` field in `Settings` with `[]` meaning off, an entry in the chords `startHotkey` hands the monitor and a mode in `startGesture`, a `HotkeyRecorderField` row. The monitors and trackers need nothing.
- Adding a language: add a `<code>` localization to both catalogs; nothing else. Adding a *string*: the key is the exact English text, and `PladderCore` never holds one — it emits an enum case and `Sources/Pladder/StatusText.swift` words it.
- Engine and capture are actors. The coordinator is `@MainActor` because it drives UI. It owns the state machine and nothing else; every dependency is injected, 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.
- The app target is split by job: `AppModel` is the composition root and owns the settings; `HotkeyRouter` holds the tap and Carbon monitors (which one, and the stand-in, is `HotkeyRouting` 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`, `HotkeyRouting`, the timing line) and the app words them in `StatusText.swift`. Views talk to `AppModel` and its parts, not to the coordinator.

## Code style

Expand Down
8 changes: 2 additions & 6 deletions Sources/Pladder/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ final class AppModel {

@ObservationIgnored private let store: SettingsStore
@ObservationIgnored private let overlay: OverlayController
@ObservationIgnored private let output: PasteboardOutput
@ObservationIgnored private let events = MainActorRelay<(DictationCoordinator.Event, ContinuousClock.Instant)>()
@ObservationIgnored private var releaseInstant: ContinuousClock.Instant?
private static let timingLog = Logger(subsystem: "de.dinooo13.pladder", category: "timing")
Expand Down Expand Up @@ -85,14 +84,12 @@ final class AppModel {
let polish = PolishModelController()
self.polish = polish

let output = PasteboardOutput()
self.output = output
let events = self.events
let coordinator = DictationCoordinator(
settings: DictationSettings(initial),
registry: registry,
capture: AVAudioEngineCapture(),
output: output,
output: PasteboardOutput(),
outputMuter: OutputMuteController(
control: CoreAudioOutputMute(),
log: { Self.muteLog.info("\($0, privacy: .public)") }),
Expand Down Expand Up @@ -259,8 +256,7 @@ final class AppModel {
}

func copyLastTranscript() {
guard let text = coordinator.lastTranscript?.text else { return }
Task { await output.copy(text) }
Task { await coordinator.copyLastTranscript() }
}

var canRetryEngine: Bool {
Expand Down
63 changes: 24 additions & 39 deletions Sources/Pladder/HotkeyRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,77 +3,62 @@ import Observation
import PladderCore
import PladderSystem

// Both monitors live for the app's life, so switching costs nothing. The rule is
// `HotkeySource.choose`; see docs/ARCHITECTURE.md, "Hotkeys".
// Both monitors live for the app's life, so switching costs nothing. The decision is
// `HotkeyRouting`; see docs/ARCHITECTURE.md, "Hotkeys".
@MainActor
@Observable
final class HotkeyRouter {
private(set) var usesTap: Bool
private(set) var accessibilityTrusted: Bool
// Written only when it changed, so a poll with nothing new invalidates no view.
private var routing: HotkeyRouting
// `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<Hotkey> = []
private(set) var hotkey: Hotkey

@ObservationIgnored private let tap = GlobalHotkeyMonitor()
@ObservationIgnored private let carbon = CarbonHotkeyMonitor()
@ObservationIgnored private var secureInput = SustainedCondition()
@ObservationIgnored private var secureInputSustained = false
@ObservationIgnored private weak var coordinator: DictationCoordinator?
// So the first `update` applies the stand-in even though nothing flipped.
@ObservationIgnored private var didApplyStandIn = false

init(accessibilityTrusted: Bool, hotkey: Hotkey) {
self.accessibilityTrusted = accessibilityTrusted
self.hotkey = hotkey
usesTap = accessibilityTrusted
routing = HotkeyRouting(accessibilityTrusted: accessibilityTrusted, hotkey: hotkey)
}

var initialMonitor: any HotkeyMonitor { usesTap ? tap : carbon }
var initialMonitor: any HotkeyMonitor { monitor(for: routing.source) }

func attach(_ coordinator: DictationCoordinator) {
self.coordinator = coordinator
}

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: the new monitor started first with the old stand-in
// would have Carbon refuse a modifier-only chord.
func update(accessibilityTrusted: Bool, secureInputEnabled: Bool) {
var next = routing
let change = next.observe(accessibilityTrusted: accessibilityTrusted, secureInputEnabled: secureInputEnabled)
if next != routing { routing = next }
guard let change else { return }
if let coordinator {
coordinator.update(coordinator.settings, standInHotkey: standInHotkey, monitor: flipped)
coordinator.update(
coordinator.settings, standInHotkey: change.standInHotkey, monitor: change.source.map(monitor(for:)))
}
refreshSystemShortcuts()
}

func hotkeyChanged(to hotkey: Hotkey) -> (any HotkeyMonitor)? {
guard hotkey != self.hotkey else { return nil }
self.hotkey = hotkey
return chooseMonitor()
guard hotkey != routing.hotkey else { return nil }
return routing.hotkeyChanged(to: hotkey).map(monitor(for:))
}

private func chooseMonitor() -> (any HotkeyMonitor)? {
let wantsTap = HotkeySource.choose(
accessibilityTrusted: accessibilityTrusted, secureInputSustained: secureInputSustained,
hotkey: hotkey) == .tap
guard wantsTap != usesTap else { return nil }
usesTap = wantsTap
return wantsTap ? tap : carbon
}

var standInHotkey: Hotkey? {
accessibilityTrusted ? nil : hotkey.standInWithoutAccessibility
private func monitor(for source: HotkeySource) -> any HotkeyMonitor {
switch source {
case .tap: tap
case .carbon: carbon
}
}

var usesCarbonForSecureInput: Bool { accessibilityTrusted && !usesTap }
var hotkeyNeedsRegularKey: Bool { !accessibilityTrusted }
var standInHotkey: Hotkey? { routing.standInHotkey }
var usesCarbonForSecureInput: Bool { routing.accessibilityTrusted && routing.source == .carbon }
var hotkeyNeedsRegularKey: Bool { !routing.accessibilityTrusted }

// 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
routing.source == .tap ? chord.displayName : chord.sideAgnosticDisplayName
}

func refreshSystemShortcuts() {
Expand Down
1 change: 1 addition & 0 deletions Sources/Pladder/OverlayDemo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ private final class DemoOutput: TextOutput, @unchecked Sendable {
init(result: InsertResult) { self.result = result }
func insert(_ text: String, submit: Bool) async throws -> InsertResult { result }
func prepare() async {}
func copy(_ text: String) async {}
}

// About as long as the on-device model takes for a short dictation.
Expand Down
4 changes: 3 additions & 1 deletion Sources/Pladder/PermissionMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ final class PermissionMonitor {

@ObservationIgnored private var pollTask: Task<Void, Never>?

private static let pollInterval: Duration = .seconds(2)

var needsAccessibility: Bool { !accessibilityTrusted }
var needsMicrophone: Bool { microphoneStatus != .authorized }
var needsAnyPermission: Bool { needsAccessibility || needsMicrophone }
Expand Down Expand Up @@ -64,7 +66,7 @@ final class PermissionMonitor {
pollTask?.cancel()
pollTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(2))
try? await Task.sleep(for: Self.pollInterval)
guard let self else { return }
self.refresh()
}
Expand Down
Loading
Loading