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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ swift run -c release pladder-cli polish <text file> # run the polish prompt ov
| 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 |
| 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 |
Expand Down
4 changes: 0 additions & 4 deletions Sources/Pladder/Overlay/OverlayController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,6 @@ 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 @@ -105,7 +103,6 @@ final class OverlayController {
}
model.state = state
model.partialTranscript = coordinator.partialTranscript
model.latched = coordinator.isLatched
cancelHide()
schedulePresent()
case .transcribing:
Expand Down Expand Up @@ -272,7 +269,6 @@ final class OverlayController {
self.model.presentation = .hidden
self.model.state = .idle
self.model.partialTranscript = nil
self.model.latched = false
}
}
}
34 changes: 5 additions & 29 deletions Sources/Pladder/Overlay/OverlayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,6 @@ final class OverlayModel {
/// 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?
/// The chord was let go and the microphone is still on: a toggle press or
/// a hybrid tap. The red dot squares off into a stop sign so the user can
/// tell a latched recording from a held one.
var latched = false
init() {}
}

Expand Down Expand Up @@ -135,8 +131,7 @@ struct OverlayView: View {
glass: model.glass,
partial: model.partialTranscript,
presentation: model.presentation,
animationSpeed: model.speed,
latched: model.latched
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
Expand Down Expand Up @@ -174,9 +169,6 @@ struct OverlayPill: View {
/// expanding out of it. Settings replicas never fly, so they keep the
/// default.
var animationSpeed: OverlayAnimationSpeed = .quick
/// A latched recording: the dot is drawn as a stop square. Settings
/// replicas keep the default.
var latched = false

@Namespace private var glassNamespace
/// Minimal shows a pulsing dot for the first 0.7 s, then the bars.
Expand Down Expand Up @@ -289,7 +281,7 @@ struct OverlayPill: View {
switch state {
case .recording(let level):
HStack(spacing: 10) {
RecordingDot(latched: latched)
RecordingDot()
LevelBars(level: level, count: 14, maxHeight: 32, opacity: 1, seeded: isPreview)
}
case .transcribing:
Expand Down Expand Up @@ -349,7 +341,7 @@ struct OverlayPill: View {
switch state {
case .recording(let level):
HStack(spacing: 10) {
RecordingDot(latched: latched)
RecordingDot()
LevelBars(level: level, count: 8, maxHeight: 24, opacity: 1, seeded: isPreview)
LiveTranscriptText(
text: partial ?? "",
Expand Down Expand Up @@ -397,17 +389,7 @@ struct OverlayPill: View {
switch state {
case .recording(let level):
ZStack {
if latched {
// The square and a narrower wave side by side: the disc
// still shows the level, and the square says the
// microphone stays on without the key. 8 + 6 + 21 pt fits
// the 44 pt disc.
HStack(spacing: 6) {
RecordingDot(latched: true)
LevelBars(level: level, count: 4, maxHeight: 20, opacity: 0.8, seeded: isPreview)
}
.transition(.opacity)
} else if showsDot {
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.
Expand Down Expand Up @@ -530,20 +512,14 @@ struct LiveTranscriptText: View {
}

/// The red "live" dot, shared by Compact, Minimal and the settings replicas.
/// Latched, it squares off into a stop sign: the recording carries on until
/// the next press.
struct RecordingDot: View {
var size: CGFloat = 8
var latched = false

var body: some View {
// One shape whose corners animate: a square with half-size corners is
// the circle, so the dot morphs rather than swaps.
RoundedRectangle(cornerRadius: latched ? 2 : size / 2, style: .continuous)
Circle()
.fill(.red)
.frame(width: size, height: size)
.shadow(color: .red.opacity(0.6), radius: 4)
.animation(.smooth(duration: 0.2), value: latched)
}
}

Expand Down
Loading