From f613356587f4279dfffb43f8c0d1ec6213040684 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Fri, 21 Aug 2026 17:45:05 +0100 Subject: [PATCH] Tap the hotkey to toggle dictation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dictation.tap_to_toggle` makes a tap start recording and a second tap stop it, instead of holding the key down. A press held past half a second still ends on release, so push-to-talk keeps working and the mode never latches the mic open on someone's muscle memory. Config-only and read at the point of use, like the other dictation niceties, so it hot-reloads; the menu bar's idle line and the boot banner say which verb applies. Also fixes a state race the new mode makes easy to hit: a press landing while the previous press's transcription was in flight had its indicator, overlay and capture torn down by that older press's completion, leaving the mic open with no release to stop it. Presses now carry a generation and finishDictation only cleans up for its own. Measured on an M4, p50 of 7 runs, parakeet-tdt-ctc-110m, before/after: 2 s 34/36 ms, 5 s 43/40 ms, 10 s 49/51 ms, 20 s 75/76 ms — noise, as expected from a change that does not touch the transcription path. The press path gains one config read, measured at 25.6 us p50. --- README.md | 12 +++-- Sources/yap/Config.swift | 10 ++++ Sources/yap/UI/MenuBarController.swift | 26 +++++++-- Sources/yap/Yap.swift | 73 ++++++++++++++++++++++---- 4 files changed, 104 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index fc0add8..6e34fa2 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ Command and drag the mark out of it once; it stays where you put it. "dictation": { "model": "parakeet-tdt-ctc-110m", "hotkey": "fn", + "tap_to_toggle": false, "overlay": true, "newline_after_release": false, "mute_output": false @@ -88,9 +89,10 @@ Command and drag the mark out of it once; it stays where you put it. } ``` -Save it and yap picks it up. The hotkey, the overlay, `mute_output`, -`newline_after_release` and `meeting_detection` all change on the spot. A new -`model` or `recordings_dir` wants a restart, and yap says so when it sees one. +Save it and yap picks it up. The hotkey, `tap_to_toggle`, the overlay, +`mute_output`, `newline_after_release` and `meeting_detection` all change on the +spot. A new `model` or `recordings_dir` wants a restart, and yap says so when it +sees one. `newline_after_release` hits Return once the text is in, which is what you want for chat boxes. @@ -99,6 +101,10 @@ for chat boxes. room and the room includes whatever you are playing, so a video behind a press gets transcribed along with you. Off by default because you can hear it happen. +`tap_to_toggle` turns the press into a switch: tap the key, talk with your hands +free, tap again to finish. A press you actually hold still ends when you let go, +so both habits work and a hold never leaves the mic latched open. + `meeting_detection` offers to record when something else grabs the mic. Off by default, and nothing is watching until you turn it on. diff --git a/Sources/yap/Config.swift b/Sources/yap/Config.swift index a58040d..c037d5c 100644 --- a/Sources/yap/Config.swift +++ b/Sources/yap/Config.swift @@ -11,6 +11,7 @@ import Foundation /// "dictation": { /// "model": "parakeet-tdt-ctc-110m", /// "hotkey": "fn", +/// "tap_to_toggle": false, /// "overlay": true, /// "newline_after_release": false, /// "mute_output": false @@ -85,6 +86,14 @@ enum Config { return key } + /// Tap the hotkey to start recording and tap again to stop, instead of + /// holding it. A press held longer than half a second still behaves like + /// push-to-talk — release ends it — so the mode never traps a hold user + /// with a latched-open mic. Default off. + static func tapToToggle() -> Bool { + section("dictation")?["tap_to_toggle"] as? Bool ?? false + } + /// Show the recording pill at the bottom of the screen. Default on. static func overlayEnabled() -> Bool { section("dictation")?["overlay"] as? Bool ?? true @@ -158,6 +167,7 @@ enum Config { "dictation": { "model": "parakeet-tdt-ctc-110m", "hotkey": "fn", + "tap_to_toggle": false, "overlay": true, "newline_after_release": false, "mute_output": false diff --git a/Sources/yap/UI/MenuBarController.swift b/Sources/yap/UI/MenuBarController.swift index 33de72d..94e26ed 100644 --- a/Sources/yap/UI/MenuBarController.swift +++ b/Sources/yap/UI/MenuBarController.swift @@ -23,6 +23,9 @@ final class MenuBarController { /// Name of the push-to-talk key, as the state line spells it. Live: the /// config file can change the key while the daemon runs. private var hotkeyName: String + /// Whether a tap latches the mic rather than a hold holding it. Only the + /// idle line's verb depends on it; live the same way the key name is. + private var tapToToggle: Bool private let statusItem: NSStatusItem private let stateLabel: NSMenuItem @@ -49,8 +52,9 @@ final class MenuBarController { /// Clicked "Stop recording". var onStopRecording: (() -> Void)? - init(modelID: String, hotkeyName: String) { + init(modelID: String, hotkeyName: String, tapToToggle: Bool) { self.hotkeyName = hotkeyName + self.tapToToggle = tapToToggle statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) let menu = NSMenu() @@ -59,7 +63,11 @@ final class MenuBarController { // the actions. We drive enablement ourselves instead. menu.autoenablesItems = false - stateLabel = NSMenuItem(title: Self.idleTitle(hotkeyName), action: nil, keyEquivalent: "") + stateLabel = NSMenuItem( + title: Self.idleTitle(hotkeyName, tapToToggle), + action: nil, + keyEquivalent: "" + ) stateLabel.isEnabled = false menu.addItem(stateLabel) @@ -141,6 +149,13 @@ final class MenuBarController { refresh() } + /// The config file flipped tap_to_toggle. + func setTapToToggle(_ enabled: Bool) { + guard enabled != tapToToggle else { return } + tapToToggle = enabled + refresh() + } + /// A dictation press produced text. See `lastTranscript` for why we keep /// it and how far that goes. func setLastTranscript(_ text: String) { @@ -186,12 +201,13 @@ final class MenuBarController { case .transcribing: stateLabel.title = "transcribing…" case .idle: - stateLabel.title = recordingSince == nil ? Self.idleTitle(hotkeyName) : "● recording" + stateLabel.title = + recordingSince == nil ? Self.idleTitle(hotkeyName, tapToToggle) : "● recording" } } - private static func idleTitle(_ hotkey: String) -> String { - "idle · hold \(hotkey) to dictate" + private static func idleTitle(_ hotkey: String, _ tapToToggle: Bool) -> String { + "idle · \(tapToToggle ? "tap" : "hold") \(hotkey) to dictate" } @objc private func toggleClicked() { diff --git a/Sources/yap/Yap.swift b/Sources/yap/Yap.swift index 8891b46..e7a3b40 100644 --- a/Sources/yap/Yap.swift +++ b/Sources/yap/Yap.swift @@ -131,7 +131,8 @@ struct Run: ParsableCommand { MainActor.assumeIsolated { NSApp.terminate(nil) } } - var banner = "listening on \(key.rawValue) hold · \(chosenModel.id)" + var banner = + "listening on \(key.rawValue) \(Config.tapToToggle() ? "tap" : "hold") · \(chosenModel.id)" if Config.meetingDetectionEnabled() { banner += " · watching for meetings → \(root.path)" } @@ -271,6 +272,23 @@ final class Daemon: NSObject, NSApplicationDelegate { /// not look like a press — and stops a session transcript finishing in the /// background from clearing the indicator out from under a live press. private var dictating = false + /// True while the capture engine is live — a press, or a latched tap, in + /// flight. Distinct from `dictating`, which stays true through the + /// transcription that follows and only guards the indicator. + private var recording = false + /// When the live press started, for telling a tap from a hold in toggle + /// mode. + private var pressStartedAt = Date.distantPast + /// Whether the live press latches (`tap_to_toggle` read at its key-down). + /// Decided once per press, same reasoning as `overlayShown`: a config + /// saved mid-press must not change what the release edge means. + private var pressLatches = false + /// Monotonic press id. finishDictation only cleans up for its own press, + /// so a completion racing a newer press cannot tear down that press's + /// state. + private var pressGeneration = 0 + /// Longer than this and a toggle-mode press is a hold, not a tap. + private static let tapThreshold: TimeInterval = 0.5 /// Pending lift of the detector's dictation suppression. Held so a new /// press can cancel the previous press's timer. private var unsuppress: DispatchWorkItem? @@ -309,7 +327,11 @@ final class Daemon: NSObject, NSApplicationDelegate { // config's own toggle is read per press, so it can be flipped while // the daemon runs. self.overlay = noOverlay ? nil : RecordingOverlay() - self.menuBar = MenuBarController(modelID: model.id, hotkeyName: hotkey.rawValue) + self.menuBar = MenuBarController( + modelID: model.id, + hotkeyName: hotkey.rawValue, + tapToToggle: Config.tapToToggle() + ) // Opt-in, and it only ever fires for *other* processes: the detector // skips its own pid. Dictation therefore no longer prompts you to // record yourself, which it did back when the two halves were separate @@ -422,12 +444,26 @@ final class Daemon: NSObject, NSApplicationDelegate { private func handle(_ event: HotkeyMonitor.Event) { switch event { - case .pressed: beginDictation() - case .released: endDictation() + case .pressed: + // Pressed while the mic is live only happens for a latched tap — + // hold mode's edges strictly alternate — and it means "stop". + // Deliberately not gated on the config: a latch must always be + // stoppable, even if the setting was turned off underneath it. + if recording { endDictation() } else { beginDictation() } + case .released: + guard recording else { return } + // Hold mode always ends here. Toggle mode ends here too when the + // key was held past the threshold — push-to-talk keeps working — + // and otherwise the quick tap leaves the recording latched. + let wasHeld = Date().timeIntervalSince(pressStartedAt) > Self.tapThreshold + if !pressLatches || wasHeld { endDictation() } } } private func beginDictation() { + // State bookkeeping must not re-run under a second press: capture.start + // is idempotent, the generation bump below is not. + guard !recording else { return } // Decided once per press. The file can be saved mid-press, and a pill // that appears halfway through — or a level pump feeding a window // nobody can see — is worse than honouring the answer we started on. @@ -456,29 +492,40 @@ final class Daemon: NSObject, NSApplicationDelegate { unsuppress = nil detector?.suppressed = true dictating = true + recording = true + pressStartedAt = Date() + pressLatches = Config.tapToToggle() + pressGeneration += 1 warn("● dictating") if overlayShown { overlay?.show(.recording) } menuBar.setDictating(true) } private func endDictation() { - guard dictating else { return } + guard recording else { return } + // Cleared before the mic goes down, so the stopping tap's own release + // edge — and any synthetic release from setKey/reenable — is a no-op. + recording = false let samples = capture.stop() let seconds = Double(samples.count) / AudioCapture.targetSampleRate let rms = computeRMS(samples) warn(String(format: "○ captured %.2fs · rms %.3f", seconds, rms)) guard !samples.isEmpty else { - finishDictation() + finishDictation(generation: pressGeneration) return } if overlayShown { overlay?.show(.transcribing) } menuBar.setTranscribing() + // Whose press this is. finishDictation refuses to clean up for anyone + // else's, so a newer press starting while this one transcribes keeps + // its indicator, its overlay and its capture. + let generation = pressGeneration // Inherits this method's main-actor isolation, so injection and the // menu-bar reset need no hop; the await on the transcriber suspends // rather than blocks, leaving the main actor free the whole time. - Task { [transcriber, newlineFlag, echoTranscripts, weak self] in + Task { [transcriber, newlineFlag, echoTranscripts, generation, weak self] in // Timed from the moment the key came up, through injection, so the // log reports what the user actually waits for rather than just // the model. Transcription is nearly all of it, but injection is a @@ -546,11 +593,16 @@ final class Daemon: NSObject, NSApplicationDelegate { } catch { warn("transcription failed: \(error)") } - self?.finishDictation() + self?.finishDictation(generation: generation) } } - private func finishDictation() { + private func finishDictation(generation: Int) { + // A newer press owns the indicator, the overlay and the detector + // suppression now. Clearing them here is the race this guards against: + // it used to hide the new press's pill and, because `dictating` went + // false under it, leave its capture running with no release to stop it. + guard generation == pressGeneration else { return } dictating = false overlay?.hide() overlayShown = false @@ -670,6 +722,9 @@ final class Daemon: NSObject, NSApplicationDelegate { monitor.setKey(key) menuBar.setHotkeyName(key.rawValue) } + // Only the idle menu line: the mode itself is read at each key-down, so + // a flip takes effect on the next press either way. + menuBar.setTapToToggle(Config.tapToToggle()) let wantDetector = Config.meetingDetectionEnabled() if wantDetector, detector == nil {