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
27 changes: 22 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ what this yap can do. Your own values are never touched.
```json
{
"recordings_dir": "~/Recordings",
"recording_routes": {},
"meeting_detection": false,
"meeting_auto_record": false,
"meeting_excluded_apps": [],
Expand All @@ -107,9 +108,9 @@ what this yap 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`,
`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.
`meeting_auto_record`, `meeting_excluded_apps`, `recordings_dir` and
`recording_routes` all change on the spot. A new `model` wants a restart, and
yap says so when it sees one.

`hotkey` is a modifier held on its own — `fn`, `rightOption`, `rightCommand`,
`rightControl`, `rightShift`, `leftOption`, `leftControl`, `leftShift` — or a
Expand Down Expand Up @@ -154,6 +155,20 @@ speech service keeps an input stream open, so detection reads it as a call and
offers to ignore it under the name `CoreSpeech`. Ignore it once and it stops
being a meeting.

`recordings_dir` is where recorded sessions land, `~/Recordings` by default.

`recording_routes` sends calls from particular apps somewhere else: a map from
bundle identifier to folder, so a Zoom call lands in a work folder and a
FaceTime call in a personal one. A value starting with `~` is expanded; a
relative one like `"work"` sits under `recordings_dir`. Only a recording yap
started from a detected call is routed — one you start from the menu bar
always goes to `recordings_dir`, and so does a call from an app that is not
listed. If a route's folder cannot be created (an unmounted volume, say), the
session falls back to `recordings_dir` and yap logs a warning naming the route.
Manage the list under Recordings in the Settings window: + picks the app and
then its folder, − removes the selected route, and double-clicking a row
changes its folder.

`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 All @@ -169,8 +184,10 @@ Name… buttons. Open reveals the transcript in Finder; Name… renames the fold
and updates its metadata and heading. Turn it off to use yap as a plain
recorder: `on_stop` then fires when the recording stops rather than after the
transcript. Nothing is lost either way. Turn it back on, restart, and yap works
through every session under `recordings_dir` that has no transcript yet, firing
`on_stop` again for each.
through every session under `recordings_dir` and every route folder that has
no transcript yet, firing `on_stop` again for each. A folder whose route was
removed is no longer scanned, so a session left there waits until the route
comes back.

## Models

Expand Down
19 changes: 19 additions & 0 deletions Sources/yap/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Foundation
///
/// {
/// "recordings_dir": "~/Recordings",
/// "recording_routes": { "us.zoom.xos": "~/Work" },
/// "transcription": { "enabled": true },
/// "mic_voice_processing": true,
/// "meeting_detection": false,
Expand Down Expand Up @@ -38,6 +39,23 @@ enum Config {
return URL(fileURLWithPath: (dir as NSString).expandingTildeInPath, isDirectory: true)
}

/// Bundle id -> folder for sessions yap starts from a detected call. A
/// value is `~`-expanded; a relative one ("work") sits under
/// `recordings_dir`. Manual sessions and apps not listed use `resolveRoot()`.
/// Read at each session start, so edits apply to the next call.
static func recordingRoutes() -> [String: String] {
load()?["recording_routes"] as? [String: String] ?? [:]
}

/// Where a session for `bundleID` lands: its route, or the recordings root.
static func resolveRoot(for bundleID: String?) -> URL {
let root = resolveRoot()
guard let bundleID, let raw = recordingRoutes()[bundleID], !raw.isEmpty else { return root }
let expanded = (raw as NSString).expandingTildeInPath
// An absolute path ignores the base; a relative one is joined to it.
return URL(fileURLWithPath: expanded, isDirectory: true, relativeTo: root).standardizedFileURL
}

/// Shell command to spawn after each session's transcript is written (or
/// after recording, if transcription is disabled), or nil.
static func onStop() -> String? {
Expand Down Expand Up @@ -176,6 +194,7 @@ enum Config {
static let template = """
{
"recordings_dir": "~/Recordings",
"recording_routes": {},
"transcription": { "enabled": true },
"mic_voice_processing": true,
"meeting_detection": false,
Expand Down
51 changes: 43 additions & 8 deletions Sources/yap/ConfigBackfill.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ extension Config {
/// Nested section first: inserting inside `dictation` leaves the file's own
/// opening brace where it was, while inserting at the top level moves every
/// offset after it — including the one just computed.
private static func backfilled(
static func backfilled(
_ text: String, inner: [String], outer: [String], defaults: [String: Any]
) -> String? {
var updated = text
Expand Down Expand Up @@ -127,8 +127,10 @@ extension Config {
return result
}

/// Put `"key": default` lines just inside the brace that opens an object —
/// the one after `marker`, or the file's own when there is no marker.
/// Put `"key": default` lines where the template lists them: after the
/// nearest earlier template key the object already has, or just inside
/// the brace that opens it — the one after `marker`, or the file's own
/// when there is no marker — when no such key exists.
///
/// Text rather than a re-serialize, so nothing but the new lines moves.
/// Nil when that brace does not end a line, which is the compact-file case
Expand All @@ -150,23 +152,56 @@ extension Config {
text[brace.upperBound...].first == "\n"
else { return nil }

var lines = ""
// Keys go in template order, one at a time, each looking for its
// anchor in the text as it stands — so a key just added is the anchor
// for the next, and two missing neighbours come out in order.
let ordered = inTemplateOrder(defaults.keys)
var updated = text
// An offset rather than an index: every insertion lands at or after
// the brace, so the offset survives each one where the index would not.
let braceEnd = text.distance(from: text.startIndex, to: brace.upperBound)
for key in keys {
guard let value = defaults[key], let literal = literal(value, indent: indent) else {
return nil
}
lines += "\n\(indent)\"\(key)\": \(literal),"
let line = "\n\(indent)\"\(key)\": \(literal),"
let braceIndex = updated.index(updated.startIndex, offsetBy: braceEnd)
let at = anchor(for: key, among: ordered, in: updated, from: braceIndex) ?? braceIndex
updated.insert(contentsOf: line, at: at)
}
return updated
}

/// The end of the line holding the nearest template key listed before
/// `key` that the object already has, so the new line lands under it.
/// Only a line that ends in `,` qualifies: a value that continues on the
/// next line (the `dictation` object, an array spread out by hand) has no
/// edge to insert on, and a last line without its comma would need
/// editing itself. Nil when nothing qualifies.
private static func anchor(
for key: String, among ordered: [String], in text: String, from start: String.Index
) -> String.Index? {
guard let position = ordered.firstIndex(of: key) else { return nil }
for earlier in ordered[..<position].reversed() {
guard let found = text.range(of: "\"\(earlier)\":", range: start..<text.endIndex) else {
continue
}
let lineEnd = text[found.upperBound...].firstIndex(of: "\n") ?? text.endIndex
let last = text[found.upperBound..<lineEnd].last { !$0.isWhitespace }
guard last == "," else { continue }
return lineEnd
}
return text.replacingCharacters(in: brace, with: "{\(lines)")
return nil
}

/// One JSON value as it would be written in the file. An object is spread
/// over lines at the caller's indent, the way the template writes
/// `dictation`; anything else is a single token.
private static func literal(_ value: Any, indent: String) -> String? {
// An object being written for the first time may as well have a stable
// key order; a scalar has nothing to sort.
let pretty = value is [String: Any]
// key order; a scalar has nothing to sort. An empty object is a single
// `{}` token — Foundation pretty-prints it as `{\n\n}`.
let pretty = (value as? [String: Any]).map { !$0.isEmpty } ?? false
var options: JSONSerialization.WritingOptions = [.fragmentsAllowed, .withoutEscapingSlashes]
if pretty { options.formUnion([.prettyPrinted, .sortedKeys]) }
guard
Expand Down
32 changes: 16 additions & 16 deletions Sources/yap/Daemon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import Foundation
final class Daemon: NSObject, NSApplicationDelegate {
private let transcriber: any Transcriber
private let coordinator: TranscriptionCoordinator
private let root: URL
private let monitor: HotkeyMonitor
private let capture = AudioCapture()
private let overlay = RecordingOverlay()
Expand Down Expand Up @@ -93,13 +92,11 @@ final class Daemon: NSObject, NSApplicationDelegate {
init(
transcriber: any Transcriber,
model: TranscriptionModel,
root: URL,
hotkey: HotkeyBinding,
echoTranscripts: Bool,
debugHotkey: Bool
) {
self.transcriber = transcriber
self.root = root
self.model = model
self.hotkey = hotkey
self.echoTranscripts = echoTranscripts
Expand Down Expand Up @@ -225,7 +222,7 @@ final class Daemon: NSObject, NSApplicationDelegate {
// Only now: a backlog transcribing through a model that is
// still loading would queue behind the same warm-up anyway,
// and this way the state line tells one story at a time.
await self.coordinator.resumePending(root: self.root)
await self.coordinator.resumePending()
} catch {
warn("warmup failed: \(error)")
self.menuBar.setModelFailed()
Expand Down Expand Up @@ -595,7 +592,7 @@ final class Daemon: NSObject, NSApplicationDelegate {

// MARK: - sessions

private func startSession(auto: Bool = false, title: String? = nil) {
private func startSession(auto: Bool = false, title: String? = nil, app: MeetingApp? = nil) {
guard session == nil else { return }
// Same gate as a press, different report: this one was asked for by a
// click, so it owes an answer rather than a log line.
Expand All @@ -605,8 +602,19 @@ final class Daemon: NSObject, NSApplicationDelegate {
return
}
do {
let newSession = try RecordingSession(root: root)
let routed = Config.resolveRoot(for: app?.bundleID)
let newSession: RecordingSession
do {
newSession = try RecordingSession(root: routed)
} catch where routed != Config.resolveRoot() {
// A route the disk cannot honour (unmounted volume, permission
// denied) must not cost the call: fall back to the root and
// say so.
warn("route: cannot create \(routed.path) for \(app?.bundleID ?? "?") — using recordings_dir")
newSession = try RecordingSession(root: Config.resolveRoot())
}
newSession.title = title
newSession.appBundleID = app?.bundleID
try newSession.start()
session = newSession
autoStarted = auto
Expand Down Expand Up @@ -709,7 +717,7 @@ final class Daemon: NSObject, NSApplicationDelegate {
// Standing consent is read at each event so config saves take
// effect without a restart. Recording is always announced.
if Config.meetingAutoRecord() {
self.startSession(auto: true, title: title)
self.startSession(auto: true, title: title, app: app)
guard self.session != nil else { return }
detector?.acceptCurrentMeeting()
showToast(
Expand Down Expand Up @@ -738,7 +746,7 @@ final class Daemon: NSObject, NSApplicationDelegate {
) { [weak self] in
guard let self, self.session == nil else { return }
detector?.acceptCurrentMeeting()
self.startSession(auto: true, title: title)
self.startSession(auto: true, title: title, app: app)
}
}
// An unanswered prompt outlives the call it asked about (it sits for
Expand Down Expand Up @@ -841,14 +849,6 @@ final class Daemon: NSObject, NSApplicationDelegate {
warn("config: model changed to \(configured.id) — restart yap to load it")
}

// Same reasoning, different reason: a live session is writing into the
// old root, and resumePending was handed it at boot. Moving the daemon
// mid-flight would strand both.
if Config.resolveRoot() != root {
warn("config: recordings_dir changed — restart yap to use it")
}


warn("config reloaded")
}

Expand Down
6 changes: 5 additions & 1 deletion Sources/yap/RecordingSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ final class RecordingSession {
private(set) var dir: URL
let startedAt = Date()
var title: String?
/// Bundle id of the app whose call started this session; nil for manual
/// sessions and for detected clients without a bundle.
var appBundleID: String?

private let mic = MicRecorder()
private let system = SystemAudioRecorder()
Expand Down Expand Up @@ -112,7 +115,7 @@ final class RecordingSession {
let systemStart = system.firstBufferAt ?? startedAt
let earliest = min(micStart, systemStart)

let meta: [String: Any] = [
var meta: [String: Any] = [
"started": iso.string(from: startedAt),
"ended": iso.string(from: ended),
"duration_seconds": Int(ended.timeIntervalSince(startedAt)),
Expand All @@ -122,6 +125,7 @@ final class RecordingSession {
"system": Int(systemStart.timeIntervalSince(earliest) * 1000),
],
]
if let appBundleID { meta["app"] = appBundleID }
if let data = try? JSONSerialization.data(
withJSONObject: meta,
options: [.prettyPrinted, .sortedKeys]
Expand Down
29 changes: 18 additions & 11 deletions Sources/yap/Transcription/TranscriptionCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,22 +72,29 @@ actor TranscriptionCoordinator {
while let task = drainTask { await task.value }
}

/// Scan the recordings root for sessions that finished (meta.json exists)
/// but were never transcribed. Folder names sort chronologically, so
/// oldest-first is a name sort.
func resumePending(root: URL) {
/// Scan the recordings root and every route folder for sessions that
/// finished (meta.json exists) but were never transcribed. Folder names
/// sort chronologically, so oldest-first is a name sort.
func resumePending() {
guard Config.transcriptionEnabled() else { return }
guard let entries = try? FileManager.default.contentsOfDirectory(
at: root, includingPropertiesForKeys: nil
) else { return }

let fm = FileManager.default
let pending = entries
.filter {
// The root and every routed folder, once each: two routes to one
// folder, or a route pointing back at the root, must not queue a
// session twice.
var seen: Set<String> = []
let roots = ([Config.resolveRoot()] + Config.recordingRoutes().keys.map { Config.resolveRoot(for: $0) })
.filter { seen.insert($0.standardizedFileURL.path).inserted }
var pending: [URL] = []
for root in roots {
guard let entries = try? fm.contentsOfDirectory(at: root, includingPropertiesForKeys: nil) else {
continue
}
pending += entries.filter {
fm.fileExists(atPath: $0.appendingPathComponent("meta.json").path)
&& !fm.fileExists(atPath: $0.appendingPathComponent("transcript.json").path)
}
.sorted { $0.lastPathComponent < $1.lastPathComponent }
}
pending.sort { $0.lastPathComponent < $1.lastPathComponent }
for dir in pending where !queue.contains(dir) {
queue.append(dir)
}
Expand Down
Loading
Loading