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
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,16 +81,18 @@ 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
}
}
```

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.
Expand All @@ -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.

Expand Down
10 changes: 10 additions & 0 deletions Sources/yap/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
26 changes: 21 additions & 5 deletions Sources/yap/UI/MenuBarController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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)

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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() {
Expand Down
73 changes: 64 additions & 9 deletions Sources/yap/Yap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Loading