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
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ swift run -c release pladder-cli <audio file> # transcribe one file, print timin
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 polish <text file> # run the polish prompt over a transcript, print both timings
./scripts/overlay-demo.sh [dir] [--style …] [--speed …] # play every overlay path with stand-ins, record the screen, cut contact sheets
```

## Decisions
Expand All @@ -44,12 +45,12 @@ swift run -c release pladder-cli polish <text file> # run the polish prompt ov
| 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. 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 |
| Polish toggle | A Processing-tab toggle (Experimental section), off by default, no key of its own. When on, every dictation 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 and sits on the normal hotkey's path, which is why it is marked experimental and off: the branch is one Bool read; the session is 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 is the same as for a held recording, in and out; only the menu's status line says which key stops it | 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 |
| 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 experimental polish toggle 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. The cap ends a latched recording the same way |
Expand Down Expand Up @@ -83,5 +84,6 @@ 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.
- To see an overlay change, run `scripts/overlay-demo.sh` and read its contact sheets. It drives the real coordinator and pill with stand-ins for the engine, microphone, paste and hotkey, so nothing reaches a running copy.
- 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.
10 changes: 5 additions & 5 deletions Sources/Pladder/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,11 @@ final class AppModel {
/// someone visits System Settings. Never on a key press.
private(set) var systemShortcuts: Set<Hotkey> = []

/// Whether Apple Intelligence can polish right now, for the polish key's
/// row. Polled with the permissions: it can be switched on or off in
/// System Settings while the app runs, and the read is cheap. The
/// coordinator never asks; an unavailable model makes the polish key a
/// plain dictation on its own.
/// Whether Apple Intelligence can polish right now, for the polish
/// toggle's row. Polled with the permissions: it can be switched on or
/// off in System Settings while the app runs, and the read is cheap. The
/// coordinator never asks; an unavailable model makes the toggle a
/// no-op that pastes as dictated.
private(set) var polishAvailability: OnDeviceModelAvailability = TranscriptPolisher.availability

/// The default chord, standing in for a stored chord Carbon cannot
Expand Down
29 changes: 22 additions & 7 deletions Sources/Pladder/Overlay/OverlayController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ final class OverlayController {
private var presentTask: Task<Void, Never>?
private var running = false
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 spinner's fade, and that has to
/// hold even when `.polishing` never comes: a transcript under the
/// polish minimum is pasted straight from `.transcribing`.
private var heldForPolish = false

/// How long transcription has to run before the pill comes back to say so.
/// A normal dictation is pasted well inside this, and progress shown for
Expand Down Expand Up @@ -94,6 +99,7 @@ final class OverlayController {
switch state {
case .recording:
cancelSpinner()
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.
Expand All @@ -114,15 +120,14 @@ final class OverlayController {
if visible { scheduleHide(after: .zero, flight: false) }
return
}
// A polish cycle is seconds, not milliseconds: keep the pill up,
// say what is happening, and let `.polishing` and then `.idle`
// take over.
// A polish cycle is seconds, not milliseconds: the pill stays up
// and says what is happening until `.idle` dives it out.
if coordinator.willPolish {
cancelSpinner()
heldForPolish = true
model.partialTranscript = nil
model.state = .transcribing
cancelHide()
present(flight: true)
return
}
// A new partial can re-run this while the spinner is already
Expand All @@ -141,11 +146,17 @@ final class OverlayController {
self.present(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.
cancelSpinner()
heldForPolish = true
model.state = state
cancelHide()
present(flight: true)
Expand All @@ -159,6 +170,7 @@ final class OverlayController {
// must never be silent. They fade in place rather than fly: an
// alarm should be there at once, not arrive a moment later.
cancelSpinner()
heldForPolish = false
model.state = state
cancelHide()
present(flight: false)
Expand All @@ -178,11 +190,14 @@ final class OverlayController {
// 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; only the spinner
// and an error fade in place.
// disc and diving, so the two paths end alike, and so does a
// pill held up across a polish pass; only the spinner and an
// error fade in place.
cancelSpinner()
let flight = model.state == .copied || heldForPolish
heldForPolish = false
guard visible else { return }
scheduleHide(after: .zero, flight: model.state == .copied)
scheduleHide(after: .zero, flight: flight)
}
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/Pladder/Overlay/OverlayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ struct OverlayPill: View {
.foregroundStyle(.primary)
}
case .polishing:
// Only a dictation started with the polish key gets here, and it
// 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: 10) {
ProgressView()
Expand Down
Loading
Loading