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
26 changes: 21 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,12 @@ you one transcript, with timings and speaker labels.
If `fn` does something else on your Mac, `yap doctor` says how to get it back.
There is also `--hotkey`, and `dictation.hotkey` in the config file.

"Copy last transcript" in the menu bar is there for the press that landed in
"Settings…" in the menu bar opens a window covering every setting below, and
every change lands immediately — the daemon does not need a restart to notice.
"Open Config File" at the bottom of it opens the JSON, for anyone who would
rather type.

"Copy Last Transcript" in the menu bar is there for the press that landed in
the wrong window. yap holds the most recent one in memory and nowhere else.

"Quit yap" stops the background daemon until your next login. `yap start`
Expand Down Expand Up @@ -73,7 +78,8 @@ Command and drag the mark out of it once; it stays where you put it.
## Configuration

`~/.config/yap/config.json`. Every key is optional and a flag beats the file.
"Edit config…" in the menu bar opens it, filled in with the defaults — and an
The Settings window is a GUI over this exact file — there is no second store —
and "Open Config File" in it opens the JSON, filled in with the defaults. An
upgrade adds a line for anything new, so the file always lists what this yap
can do. Your own values are never touched.

Expand All @@ -82,6 +88,7 @@ can do. Your own values are never touched.
"recordings_dir": "~/Recordings",
"meeting_detection": false,
"meeting_auto_record": false,
"meeting_excluded_apps": [],
"mic_voice_processing": true,
"on_stop": "my-hook",
"transcription": { "enabled": true },
Expand All @@ -97,9 +104,9 @@ can do. Your own values are never touched.
```

Save it and yap picks it up. The hotkey, `tap_to_toggle`, the overlay,
`mute_output`, `newline_after_release`, `meeting_detection` and
`meeting_auto_record` all change on the spot. A new `model` or `recordings_dir` wants a restart, and yap says so when it
sees one.
`mute_output`, `newline_after_release`, `meeting_detection`,
`meeting_auto_record` and `meeting_excluded_apps` 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 @@ -121,6 +128,15 @@ transcript.
visible Stop action. It only has an effect when `meeting_detection` is on, and is
off by default.

`meeting_excluded_apps` is the bundle identifiers of apps that never trigger the
meeting prompt — the list behind the "Ignore <App>" button on the prompt and the
"Ignore" button on the auto-record banner. Clicking it adds the app here, ends
any recording that button started, and yap says nothing about that app again.
Manage the list under Meetings in the Settings window: it shows each app by icon
and name, removes one with the minus button, and "Add App…" excludes an app
ahead of time. Detection stays fail-open — an app you have never excluded still
gets offered, even one yap has never heard of.

`mic_voice_processing` cancels speaker echo on the mic track. On by default: a
call coming out of your speakers goes back into the mic. Without it, the other
side gets transcribed twice, the second time as you. If some audio route
Expand Down
91 changes: 89 additions & 2 deletions Sources/yap/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Foundation
/// "mic_voice_processing": true,
/// "meeting_detection": false,
/// "meeting_auto_record": false,
/// "meeting_excluded_apps": [],
/// "on_stop": "my-hook",
/// "dictation": {
/// "model": "parakeet-tdt-ctc-110m",
Expand Down Expand Up @@ -67,6 +68,17 @@ enum Config {
load()?["meeting_auto_record"] as? Bool ?? false
}

/// Bundle identifiers that never trigger the meeting prompt. Built by the
/// "Ignore <App>" button on the prompt and editable in Settings.
///
/// An exclusion list rather than an allowlist: detection stays fail-open,
/// so a meeting app nobody has heard of still gets offered. Bundle ids,
/// not names or paths — stable across renames and localization, and
/// resolvable back to an icon and a name through `NSWorkspace`.
static func meetingExcludedApps() -> [String] {
load()?["meeting_excluded_apps"] as? [String] ?? []
}

/// Apple voice processing (acoustic echo cancellation) on the mic, so
/// speaker playback doesn't bleed into the mic track and get transcribed
/// as "me". Default on: the mic track always pairs with a system track, so
Expand Down Expand Up @@ -162,7 +174,7 @@ enum Config {
// MARK: - File

/// Every value here is the built-in default, so writing this file changes
/// nothing about how yap behaves — it exists so "Edit config…" has
/// nothing about how yap behaves — it exists so "Open Config File" has
/// something to open and the watcher has something to watch. `on_stop` is
/// left out deliberately: there is no sensible default hook.
static let template = """
Expand All @@ -172,6 +184,7 @@ enum Config {
"mic_voice_processing": true,
"meeting_detection": false,
"meeting_auto_record": false,
"meeting_excluded_apps": [],
"dictation": {
"model": "parakeet-tdt-ctc-110m",
"hotkey": "fn",
Expand Down Expand Up @@ -199,6 +212,80 @@ enum Config {
warn("warning: could not create \(path.path): \(error)")
}
}

// MARK: - Writing

/// Apply a change to the config file. The Settings window's only write
/// path; the watcher turns the save back into a live reload.
///
/// A file that does not parse is left exactly as it is. Someone is
/// mid-edit in a text editor, and losing their work to a toggle click is
/// far worse than a setting that does not stick.
static func update(_ mutate: (inout [String: Any]) -> Void) {
ensureFileExists()
guard
let data = try? Data(contentsOf: path),
var config = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else {
warn("warning: \(path.path) is not valid JSON — not writing")
return
}
mutate(&config)
do {
try serialized(config).write(to: path, atomically: true, encoding: .utf8)
} catch {
warn("warning: could not write \(path.path): \(error)")
}
}

/// The config file as text: two-space JSON with keys in template order,
/// so a file the GUI writes reads like the one the template writes.
///
/// Everything round-trips. Keys yap has never heard of keep their values
/// and land after the ones it has, ordered among themselves by name —
/// hand-adding a key to this file must never be punished by a click in
/// the Settings window.
static func serialized(_ config: [String: Any]) -> String {
var lines: [String] = []
for key in inTemplateOrder(config.keys) {
guard let value = config[key] else { continue }
// `dictation` is the one object the template spreads over lines;
// every other value, nested objects included, is one token.
if key == "dictation", let section = value as? [String: Any] {
var inner: [String] = []
for name in inTemplateOrder(section.keys) {
guard let value = section[name] else { continue }
inner.append(" \"\(name)\": \(token(value))")
}
lines.append(" \"\(key)\": {\n" + inner.joined(separator: ",\n") + "\n }")
} else {
lines.append(" \"\(key)\": \(token(value))")
}
}
return "{\n" + lines.joined(separator: ",\n") + "\n}\n"
}

/// One JSON value on one line, spelled the way the template spells it.
///
/// A nested object gets `{ "k": v }` rather than Foundation's
/// `{"k":v}` — `transcription` is written this way in the template, and a
/// GUI click should not reformat a line the user never touched.
private static func token(_ value: Any) -> String {
if let object = value as? [String: Any] {
guard !object.isEmpty else { return "{}" }
let pairs = inTemplateOrder(object.keys).compactMap { key -> String? in
object[key].map { "\"\(key)\": \(token($0))" }
}
return "{ " + pairs.joined(separator: ", ") + " }"
}
guard
let data = try? JSONSerialization.data(
withJSONObject: value,
options: [.fragmentsAllowed, .withoutEscapingSlashes]),
let text = String(data: data, encoding: .utf8)
else { return "null" }
return text
}
}

/// Calls back, on the main queue, whenever the config file is saved.
Expand Down Expand Up @@ -285,7 +372,7 @@ final class ConfigWatcher {
/// The file is gone. Watch its directory instead and re-arm the moment
/// something puts one back: a rename still in flight, an editor that
/// unlinks before it writes, or someone deleting the config and getting
/// it back from "Edit config…". A directory kqueue costs exactly what a
/// it back from "Open Config File". A directory kqueue costs exactly what a
/// file one does — nothing until the kernel has news — and the
/// alternative is hot reload staying dead until the next restart.
private func watchDirectory() {
Expand Down
24 changes: 16 additions & 8 deletions Sources/yap/ConfigBackfill.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import Foundation
/// has, which is a different job from reading values out of it.
///
/// A config written by an older yap has no line for anything added since, so
/// "Edit config…" opens a file that hides half the settings and the only way
/// to discover `tap_to_toggle` is the README. The template every new install
/// gets lists them all; this brings an existing file up to the same standard.
/// "Open Config File" opens a file that hides half the settings and the only
/// way to discover `tap_to_toggle` is the Settings window or the README. The
/// template every new install gets lists them all; this brings an existing
/// file up to the same standard.
extension Config {
/// Add the keys this build knows about that the file on disk does not.
///
Expand Down Expand Up @@ -72,11 +73,18 @@ extension Config {

/// The keys in the order the template lists them, so an inserted line
/// reads where the documented file would have put it rather than wherever
/// the alphabet lands.
private static func inTemplateOrder(_ keys: some Collection<String>) -> [String] {
keys.sorted {
(template.range(of: "\"\($0)\"")?.lowerBound ?? template.endIndex)
< (template.range(of: "\"\($1)\"")?.lowerBound ?? template.endIndex)
/// the alphabet lands. Shared with `Config.serialized(_:)`, which owes the
/// GUI-written file the same order.
///
/// Keys the template does not list share one position and are broken apart
/// by name — a total order, so rewriting the file twice cannot shuffle
/// somebody's hand-added keys around.
static func inTemplateOrder(_ keys: some Collection<String>) -> [String] {
func position(_ key: String) -> String.Index {
template.range(of: "\"\(key)\"")?.lowerBound ?? template.endIndex
}
return keys.sorted {
position($0) == position($1) ? $0 < $1 : position($0) < position($1)
}
}

Expand Down
10 changes: 10 additions & 0 deletions Sources/yap/Detection/MeetingTitle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ enum MeetingTitle {
return NSString.path(withComponents: Array(components[...end]))
}

/// Bundle identifier of the app behind a capture pid, for the exclusion
/// list. Reading Info.plist off the bundle path — no TCC, no AX, and it
/// works for sandboxed and hardened apps alike.
///
/// A process with no `.app` around it has no identity we could store, so
/// it can never be excluded and never grows an Ignore button.
static func bundleID(forPID pid: pid_t) -> String? {
appBundlePath(forPID: pid).flatMap { Bundle(path: $0)?.bundleIdentifier }
}

/// Best-effort meeting name from the capturing app's windows.
static func capture(forCapturePID pid: pid_t) -> String? {
let appPath = appBundlePath(forPID: pid)
Expand Down
Loading
Loading