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
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,16 @@ swift run -c release pladder-cli polish <text file> # run the polish prompt ov
| Interrupted press | A non-chord key within 1 s of the chord press cancels the recording without transcribing | Anyone who records a lone Command key shares it with Cmd+C, Cmd+V and Cmd+Tab; the overlay waits 150 ms before showing so those never flash it. Option+Space shares no modifier with them |
| Secure Event Input | `IsSecureEventInputEnabled()` polled with the grant; sustained 3 s and a chord Carbon can register → Carbon monitor until it clears | A password field or Terminal's Secure Keyboard Entry stops taps receiving key events; modifier-only chords are unaffected and stay on the tap |
| Without Accessibility | Carbon `RegisterEventHotKey` plus clipboard-only output | A standard account cannot grant Accessibility without an admin. Carbon needs no permission but wants exactly one regular key and collapses left and right, so modifier-only chords are refused in the recorder; the transcript is left on the clipboard and the overlay says "press ⌘V". A stored chord Carbon cannot register, a lone Right Command say, is stood in for by the default Option+Space and the menu names it; the stored chord returns with the grant. `CopySymbolicHotKeys` only feeds the warning that an enabled macOS shortcut owns the recorded chord. `AppModel` polls the grant every two seconds and swaps the monitor in both directions |
| Send key | Press Right Option (configurable) while the hotkey is held and Return is posted 50 ms after Cmd+V | Sends a chat message or runs a command without a second trip to the keyboard; the Return is posted from a detached task so it stays off the release-to-paste path |
| Send key | Press Right Option (configurable) while the hotkey is held and Return is posted 50 ms after Cmd+V | Sends a chat message or runs a command without a second trip to the keyboard; the Return is posted from a detached task so it stays off the release-to-paste path. Only for a held recording: a latched one ends on the closing press, before any send key could arm |
| Polish hotkey | "Dictate and polish": a second recordable chord, off by default. A dictation started with it runs the usual pipeline, then Apple's on-device model (FoundationModels, `PladderRefine`) with a fixed cleanup prompt, then pastes | Self-corrections, spoken punctuation, number words and lists are beyond the deterministic processors, and the model runs on device with nothing to download. It costs one to three seconds, so it never touches the normal hotkey's path: the branch is one Bool read; the session is created and prewarmed at key-down; transcripts under four words skip it; anything the model cannot do (Apple Intelligence off, refusal, the 8 s timeout) pastes the text as dictated. Logged as its own `polished release-to-paste` line |
| Learned corrections | After a paste the field is watched through Accessibility for up to 60 s; a word the user corrects that passes a token diff, a phonetic gate (Soundex or edit distance ≤ 2) and a yes/no review by the on-device model becomes one menu line, "Learned “x” → “y”? Add / Dismiss" | Nothing runs before Cmd+V is posted: the hook is in `AppModel.handle(.inserted)`, the watcher lives on its own thread and reads only the pasted range plus a margin, the review runs on a detached task. Present only with Accessibility and Apple Intelligence, absent otherwise, no setting, no change to the menu bar glyph. Dismissed pairs go to `dismissed-corrections.json`, not settings, so a bug there can never cost the dictionary. Pure case changes are never proposed. Terminals and TUIs expose a screen buffer, not a field, so nothing is learned there |
| Toggle key | Another recordable chord, off by default. A chord of its own latches at release however long the press; equal to the push-to-talk chord it makes that key hybrid: a tap under 400 ms latches, a longer hold stops at release. The next press of any chord, Escape or the 10 min cap ends a latched recording; the overlay's dot squares off while it is latched | Two-minute dictations should not need a key held for two minutes. Handy and VoiceInk default to hybrid on one key; here it is opt-in, because a stray tap would otherwise leave the microphone open until the cap pastes two minutes of room noise. Hold, toggle and hybrid are decided in `HotkeyGestureTracker`, a clockless value type timed by the instant each monitor stamps on its events, so both monitors behave alike and a press that waits for the microphone cannot make the next release look longer. A same-chord press within 50 ms of its release is a bounce (some Bluetooth keyboards do this mid-hold): it never acts, and the first one seen turns on a 50 ms settle before every stopping release for the rest of the run, so only a keyboard that needs it pays for it and the release path is otherwise untouched. No separate press debounce: both monitors already report alternating presses and releases. Without Accessibility the toggle chord registers with Carbon like the key; one Carbon cannot register has no stand-in, except that a hybrid chord follows the key's. With a lone modifier as a hybrid key, the Command of a later Cmd+C ends a latched recording |
| Escape | Discards a recording without transcribing and plays the stop sound; taken only while a recording is on | Never taken globally, so Escape keeps closing dialogs. On the tap `HotkeyChordSet` catches it before the chord trackers, so the interrupted-press rule never sees it, and Escape with the chord's own modifiers held still counts; Carbon registers the bare key around each recording, from the main queue so the release path never waits on it. Under Secure Event Input a modifier-only chord stays on the tap, where no key-down arrives, so Escape cannot cancel there |
| Output | Clipboard + simulated Cmd+V; the old clipboard is restored off the critical path | Universal, fast |
| Post-processing | Filler remover, dictionary replacer, fuzzy custom-word corrector, whitespace normaliser, in that order | No latency, no network. An earlier Apple Intelligence step was removed from this path unmeasured; the model is back behind the polish hotkey only |
| Mute while dictating | Off by default; `kAudioDevicePropertyMute` on the default output device 200 ms into a recording, restored off the release path | Music or a call otherwise goes into the microphone. The delay means a tap-and-release never toggles anything; a device the user had already muted is left alone, and the device that was muted is the one unmuted even if the default changed meanwhile |
| UI language | Follows the macOS system language; no setting | String Catalogs (`Localizable.xcstrings` in the app, `KeyNames.xcstrings` in `PladderSystem`) are compiled by `swift build`; `bundle.sh` merges their `.lproj` folders into `Pladder.app/Contents/Resources`, so `Bundle.main` serves them and no code names a bundle. `swift run` shows English. Core and Engines emit enum cases; the app turns them into text. German first; more languages are catalog contributions |
| Recording cap | 10 min | Keeps the microphone from staying on when a key-up is lost |
| Recording cap | 10 min | Keeps the microphone from staying on when a key-up is lost. The cap ends a latched recording the same way |
| Benchmark | A script run by hand, not a test | A benchmark that fails on noise gets ignored |

## Pluggability rules
Expand All @@ -60,6 +62,7 @@ swift run -c release pladder-cli polish <text file> # run the polish prompt ov
- Adding a processor: implement `TextProcessor` in its own file, append a factory to `processorFactories` in `AppModel`. The pipeline is rebuilt when settings change, never per dictation. A processor sits on the critical path, so the benchmark rule applies.
- Adding a prompt: build an `OnDeviceLanguageModel(instructions:)` in `PladderRefine` and call `respond(to:)` or `respond(to:generating:)`; availability, prewarm, timeout and the drain of an abandoned call come with it. The coordinator only ever sees `TranscriptRefiner`.
- The correction learner's two seams are protocols in `PladderCore`, `PastedTextObserver` and `CorrectionReviewer`, with fakes in the tests; the Accessibility and Foundation Models implementations live in `PladderSystem` (`AXPasteObserver`) and `PladderRefine` (`FoundationModelsCorrectionReviewer`).
- 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, so tests run it with in-memory fakes.

Expand All @@ -81,3 +84,4 @@ The developer dictates with a running Pladder all day, often into Claude session
- Never `pkill -x Pladder` or `killall Pladder`: that also kills the copy in use, mid-recording, and the text is lost. Stop only the copy you launched: `pkill -f "$PWD/dist/Pladder.app"`.
- Do not post synthetic hotkey events unless the user has asked for a live UI test. Every running copy reacts to them, so they start, cut short, or paste the user's recordings.
- Do not edit `~/Library/Application Support/Pladder/settings.json`; it is the live configuration.
- A copy launched for testing gets its own settings file with `PLADDER_SETTINGS_PATH=/tmp/<branch>/settings.json "$PWD/dist/Pladder.app/Contents/MacOS/Pladder"`, and a different chord from the copy in use, so the two never fire together. Launched as the bare binary so the environment reaches it; its command line still contains `$PWD/dist/Pladder.app`, so the `pkill -f` above stops it.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ Yes. Press the send key, Right Option by default, at any point while you hold th
**Can it clean up what I said?**
Record a key for Dictate and polish in Settings and hold that instead. The dictation goes through Apple Intelligence on your Mac before it is pasted, which takes a second or two. It needs Apple Intelligence turned on in System Settings; without it that key pastes the text as dictated.

**Can I toggle instead of holding?**
Yes. Record a toggle key in Settings: one tap starts a recording, the next tap inserts it. Give it the same combination as the push-to-talk key and that key does both: tap to start and tap again to insert, or hold and release as before. Escape discards a recording either way.

**What about long dictations?**
Recordings stop at 10 minutes, so a lost key-up never leaves the microphone on. Audio longer than 15 seconds is transcribed in overlapping windows.

Expand Down
49 changes: 45 additions & 4 deletions Sources/Pladder/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ final class AppModel {
/// for Add or Dismiss. Kept for the app's life or until answered.
private(set) var proposals: [CorrectionProposal] = []
static let maximumProposals = 3
/// Hotkey behaviour worth knowing about after the fact, such as a
/// keyboard that bounces.
private static let hotkeyLog = Logger(subsystem: "de.dinooo13.pladder", category: "hotkey")

/// Settings live in the coordinator (it reacts to hotkey/engine changes);
/// this forwards and persists. Applying the appearance covers every
Expand Down Expand Up @@ -174,7 +177,10 @@ final class AppModel {
url: Self.settingsURL,
defaults: Settings(engineID: FluidAudioIncrementalEngine.engineID)
)
Self.migrateLegacySettings(to: Self.settingsURL)
// A test copy starts from the defaults, not from an old install.
if Self.settingsPathOverride == nil {
Self.migrateLegacySettings(to: Self.settingsURL)
}
self.store = store

// One read: the store moves an undecodable file aside on load, so a
Expand Down Expand Up @@ -246,8 +252,19 @@ final class AppModel {
proposalRelay.handler = { [weak self] proposal in self?.propose(proposal) }
}

/// `PLADDER_SETTINGS_PATH` points a copy launched for testing at a file
/// of its own, so it neither reads nor writes the configuration of the
/// copy in daily use; every recorder commit is saved at once. Development
/// only: no UI, and a normal launch never has it set.
static var settingsPathOverride: String? {
guard let path = ProcessInfo.processInfo.environment["PLADDER_SETTINGS_PATH"],
!path.isEmpty else { return nil }
return path
}

static var settingsURL: URL {
FileManager.default
if let path = settingsPathOverride { return URL(filePath: path) }
return FileManager.default
.homeDirectoryForCurrentUser
.appending(path: "Library/Application Support/Pladder/settings.json")
}
Expand Down Expand Up @@ -329,6 +346,15 @@ 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.
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.
Self.hotkeyLog.notice("keyboard bounce observed: releases now settle for 50 ms before stopping")
}
}

Expand Down Expand Up @@ -395,7 +421,8 @@ final class AppModel {
// seeing key-downs, so a chord with a regular key is dead there.
// Carbon can take over only for a chord it can register, and a
// modifier-only chord is unaffected by secure input anyway, so both
// stay on the tap and nothing swaps.
// stay on the tap and nothing swaps. The push-to-talk chord alone
// decides; the toggle chord follows whichever monitor is up.
let wantsTap = accessibilityTrusted
&& !(sustained && settings.hotkey.canBeRegisteredWithoutAccessibility)
let flipped = wantsTap != hotkeyUsesTap
Expand Down Expand Up @@ -506,7 +533,10 @@ final class AppModel {
/// One line describing what the app is doing right now.
var statusLine: String {
switch coordinator.state {
case .recording: return String(localized: "Recording…")
case .recording:
// The Menu style has no pill, so this line is its latched cue.
guard coordinator.isLatched else { return String(localized: "Recording…") }
return String(localized: "Recording — press \(stopKeyName) to stop")
case .transcribing: return String(localized: "Transcribing…")
case .polishing: return String(localized: "Polishing…")
case .inserting: return String(localized: "Inserting…")
Expand Down Expand Up @@ -536,6 +566,17 @@ final class AppModel {
return settings.hotkey.sideAgnosticDisplayName
}

/// What ends a latched recording. Any chord does; this names the one that
/// latched it: the toggle key when it is a chord of its own, otherwise
/// the key, or what stands in for it.
private var stopKeyName: String {
let toggle = settings.toggleHotkey
if !toggle.isEmpty, toggle.canonical != settings.hotkey.canonical {
return hotkeyUsesTap ? toggle.displayName : toggle.sideAgnosticDisplayName
}
return standInHotkey?.sideAgnosticDisplayName ?? effectiveHotkeyName
}

/// True while a working Accessibility grant is being ignored because
/// Secure Event Input has the tap deaf and Carbon is standing in.
var usesCarbonForSecureInput: Bool { accessibilityTrusted && !hotkeyUsesTap }
Expand Down
4 changes: 4 additions & 0 deletions Sources/Pladder/Overlay/OverlayController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ final class OverlayController {
// Read so a new partial re-arms this too: between two passes the
// state stays `.recording` and nothing else would fire.
_ = coordinator.partialTranscript
// A latch changes nothing else: the state stays `.recording`.
_ = coordinator.isLatched
} onChange: { [weak self] in
Task { @MainActor [weak self] in
guard let self, self.running else { return }
Expand All @@ -103,6 +105,7 @@ final class OverlayController {
}
model.state = state
model.partialTranscript = coordinator.partialTranscript
model.latched = coordinator.isLatched
cancelHide()
schedulePresent()
case .transcribing:
Expand Down Expand Up @@ -269,6 +272,7 @@ final class OverlayController {
self.model.presentation = .hidden
self.model.state = .idle
self.model.partialTranscript = nil
self.model.latched = false
}
}
}
Loading
Loading