From 01c913d5046abaa5517a77d99ba668b2aa75119f Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Mon, 7 Sep 2026 09:26:55 +0100 Subject: [PATCH 1/2] Name the process that has the microphone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt and the auto-record banner offered an Ignore button only when the capture pid's executable sat inside an .app bundle. Two common clients don't: a system daemon has no bundle at all, and proc_pidpath returns nothing for a process whose binary was replaced under it, which is every self-updating app's helper. Both produced a bare "Recording meeting" pill with no name and no way to exclude it — measured live, macOS's speech daemon under "Hey Siri" reads as `nil "nil"` on main. Core Audio knows what the path doesn't. Every input stream carries a bundle id of its own ('pbid', 0.02 ms to read), so identity now falls back to it, resolves it through the installed copy and collapses it onto the outermost .app: a helper reads as the app that owns it, under the same id the Settings picker would have stored, and a daemon keeps its raw identifier with its last component for a name. Only a bare executable is left anonymous, which is honest — there is nothing to store. Excluding an app then has to mean it, or the fix is cosmetic. A declined client stays the one the detector follows, and following it is a cache hit that skips the scan, so a process holding an input stream for hours hid every other app behind it: after ignoring macOS's speech daemon, no real call could be detected while Siri listened. An excluded client now drops out of the scan like our own pid does, and the scan carries on. Measured on an M4: device gate 0.11 ms, cached re-check 0.74 ms, full client scan 3.8 ms at 32 clients — the comment claiming ~45 ms was stale, and is updated. Nothing new runs while idle. --- README.md | 7 ++ Sources/yap/Daemon.swift | 36 +++++----- Sources/yap/Detection/MeetingDetector.swift | 79 +++++++++++++++++---- Sources/yap/Detection/MeetingTitle.swift | 76 ++++++++++++++++---- 4 files changed, 153 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index fa7e7cb..8b5c1f7 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,13 @@ and name, and the + and − under it add one ahead of time or stop ignoring the one you select. Detection stays fail-open — an app you have never excluded still gets offered, even one yap has never heard of. +What holds the microphone is not always an app you launched. A helper process +belonging to one counts as its app — Ignore covers every helper Chrome or Teams +starts — and a background daemon counts as itself: with "Hey Siri" on, macOS's +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. + `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 diff --git a/Sources/yap/Daemon.swift b/Sources/yap/Daemon.swift index 366c795..9a31034 100644 --- a/Sources/yap/Daemon.swift +++ b/Sources/yap/Daemon.swift @@ -661,24 +661,23 @@ final class Daemon: NSObject, NSApplicationDelegate { /// call: a manual session holds the mic, and detection must stay down /// until it ends. private func wire(_ detector: MeetingDetector) { - detector.onMeetingStart = { [weak self, weak detector] pid, appName in + detector.onMeetingStart = { [weak self, weak detector] pid, app in guard let self else { return } // Before anything else, including the AX title lookup and the // back-to-back stop: an excluded app is invisible to every piece // of meeting logic, not merely unprompted. Read fresh at each // event, the same standing-consent pattern as auto-record below. - let bundleID = MeetingTitle.bundleID(forPID: pid) - if let bundleID, Config.meetingExcludedApps().contains(bundleID) { - warn("◇ \(appName ?? bundleID) is excluded — ignoring") - // Marks the pid, so the detector stops re-firing every poll; - // its end-of-meeting path clears the mark on its own. - detector?.declineCurrentMeeting() + if let app, Config.meetingExcludedApps().contains(app.bundleID) { + warn("◇ \(app.name) is excluded — ignoring") + // Drops the client out of the detector's scan, so it neither + // re-fires every poll nor hides another app behind it. + detector?.ignoreCurrentClient() return } let title = MeetingTitle.capture(forCapturePID: pid) - let who = appName ?? "Your microphone" + let who = app?.name ?? "Your microphone" warn("◆ \(who) is in use" + (title.map { " · \($0)" } ?? "")) if let session = self.session { // A quiet gap followed by the same capture pid can be a device @@ -696,14 +695,14 @@ final class Daemon: NSObject, NSApplicationDelegate { // the user is looking at. Both need it to also end the recording // this event may have just started. let ignoreApp: @MainActor () -> Void = { [weak self, weak detector] in - guard let bundleID else { return } + guard let app else { return } Config.update { config in var list = config["meeting_excluded_apps"] as? [String] ?? [] - if !list.contains(bundleID) { list.append(bundleID) } + if !list.contains(app.bundleID) { list.append(app.bundleID) } config["meeting_excluded_apps"] = list } - warn("◇ \(appName ?? bundleID) added to the ignore list") - detector?.declineCurrentMeeting() + warn("◇ \(app.name) added to the ignore list") + detector?.ignoreCurrentClient() self?.stopSessionIfAutoStarted() } @@ -714,10 +713,10 @@ final class Daemon: NSObject, NSApplicationDelegate { guard self.session != nil else { return } detector?.acceptCurrentMeeting() showToast( - title: appName.map { "Recording \($0) call" } ?? "Recording meeting", + title: app.map { "Recording \($0.name) call" } ?? "Recording meeting", body: title ?? "Stop from the menu bar or here", button: "Stop", - secondaryButton: bundleID != nil ? "Ignore" : nil, + secondaryButton: app.map { _ in "Ignore" }, onSecondary: ignoreApp ) { [weak self, weak detector] in detector?.declineCurrentMeeting() @@ -727,12 +726,13 @@ final class Daemon: NSObject, NSApplicationDelegate { } askUser( - title: appName.map { "\($0) is in a call" } ?? "Your microphone is in use", + title: app.map { "\($0.name) is in a call" } ?? "Your microphone is in use", body: title ?? "Record this meeting?", button: "Record", - // Only when we have both a name to show and an identity to - // store; a process with no app bundle gets Dismiss and no more. - secondaryButton: appName.flatMap { bundleID != nil ? "Ignore \($0)" : nil }, + // Only when macOS names the client at all: a bare executable + // with no bundle behind it has no identity we could store, so + // it gets Dismiss and no more. + secondaryButton: app.map { "Ignore \($0.name)" }, onSecondary: ignoreApp, onDismiss: { detector?.declineCurrentMeeting() } ) { [weak self] in diff --git a/Sources/yap/Detection/MeetingDetector.swift b/Sources/yap/Detection/MeetingDetector.swift index 96341eb..84dc5c1 100644 --- a/Sources/yap/Detection/MeetingDetector.swift +++ b/Sources/yap/Detection/MeetingDetector.swift @@ -15,9 +15,9 @@ import Foundation /// device read. @MainActor final class MeetingDetector { - /// Something took the mic. The arguments are its capture pid and a display - /// name ("Microsoft Teams") when that pid belongs to an app. - var onMeetingStart: ((pid_t, String?) -> Void)? + /// Something took the mic. The arguments are its capture pid and who it + /// belongs to, when macOS can say — see `MeetingTitle.app`. + var onMeetingStart: ((pid_t, MeetingApp?) -> Void)? /// The mic went quiet a moment ago (~2 s). The call could still come back /// from a device switch, so this is only for things that are cheap to @@ -71,6 +71,10 @@ final class MeetingDetector { /// The process the user said no to. A dropout of that same process stays /// declined, but a different app taking the mic is a different call. private var declinedPID: pid_t? + /// Processes the caller told us to pretend aren't there — an app the user + /// excluded. Held by pid and dropped when the mic goes quiet, so removing + /// an exclusion takes effect the next time that app takes the mic. + private var ignoredPIDs: Set = [] private var loggedPollFailure = false init(capturePID: ((pid_t?) -> pid_t?)? = nil) { @@ -100,6 +104,7 @@ final class MeetingDetector { inMeeting = false askedPID = nil declinedPID = nil + ignoredPIDs = [] } /// The user dismissed the prompt for whoever holds the mic right now. @@ -108,6 +113,24 @@ final class MeetingDetector { declinedPID = observedPID } + /// The client holding the mic belongs to an app the user excluded: drop it + /// out of the scan entirely, like our own pid, and keep looking. + /// + /// Not `declineCurrentMeeting`, which only stops the *prompt*. A declined + /// client stays the one we follow, and following it is a cache hit that + /// short-circuits the scan — so a process that holds an input stream for + /// hours (macOS's speech daemon, a self-updating app's helper) would hide + /// every other app behind it, and no real call could be detected for as + /// long as it ran. An excluded app is meant to be invisible to meeting + /// logic, not merely unprompted, and this is what makes that true. + func ignoreCurrentClient() { + guard let pid = observedPID else { return } + ignoredPIDs.insert(pid) + capturing = nil + observedPID = nil + consecutiveActive = 0 + } + /// The user accepted the prompt for the client seen on the latest poll. func acceptCurrentMeeting() { acceptedPID = observedPID ?? askedPID @@ -181,17 +204,18 @@ final class MeetingDetector { // about, and never for one the user turned down. guard pid != askedPID, pid != declinedPID else { return } askedPID = pid - onMeetingStart?(pid, Self.appName(forPID: pid)) + onMeetingStart?(pid, MeetingTitle.app(forPID: pid, audioBundleID: audioBundleID(of: pid))) } /// The capturing process to follow, excluding yap itself. /// /// Three tiers, cheapest first, because this runs every second forever: - /// ask the devices whether anyone at all is capturing (~0.1 ms, and the + /// ask the devices whether anyone at all is capturing (0.11 ms, and the /// answer is no all day), then re-check the client we already know - /// about (~2 ms), and only scan every client when neither settles it. - /// Interrogating all ~50 audio clients costs ~45 ms — that is the main - /// thread, so it stays off the common path. + /// about (0.74 ms), and only scan every client when neither settles it. + /// Interrogating every audio client costs 3.8 ms at 32 of them (M4, p50) + /// and grows with the machine's — that is the main thread, so it stays off + /// the common path. /// /// Before a prompt is accepted, `preferred` is nil and any external input /// client can start a meeting. Afterwards it is the pid behind that prompt: @@ -199,6 +223,11 @@ final class MeetingDetector { private func currentCapturingPID(preferred: pid_t?) -> pid_t? { guard anyInputDeviceRunning() else { capturing = nil + // The mic is genuinely idle, so exclusions get re-read from + // config the next time an app takes it. A pid recycled onto a + // different app before that would be ignored in its place; it + // costs one undetected call and heals at the next quiet gap. + ignoredPIDs = [] return nil } // Same client as last poll? Confirm the id wasn't recycled onto another @@ -225,6 +254,9 @@ final class MeetingDetector { // the keypress, under its own pid. `suppressed` handles that, and // the two are easy to confuse when a prompt appears. guard let pid = pidProperty(object), pid != ownPID else { continue } + // An app the user excluded is invisible here rather than merely + // unprompted, so the scan carries on to whatever else has the mic. + guard !ignoredPIDs.contains(pid) else { continue } guard preferred == nil || pid == preferred else { continue } capturing = (object, pid) return pid @@ -264,12 +296,15 @@ final class MeetingDetector { return AudioObjectGetPropertyDataSize(device, &address, 0, nil, &size) == noErr && size > 0 } - /// Finder's name for the app owning `pid` — "Google Chrome" for a renderer - /// buried in Chrome's Frameworks directory, since the outermost `.app` is - /// the one a human would recognise. Daemons and XPC services have no `.app` - /// and get no name. - private static func appName(forPID pid: pid_t) -> String? { - MeetingTitle.appBundlePath(forPID: pid).map(FileManager.default.displayName(atPath:)) + /// Core Audio's own attribution for the client we are following, read only + /// when we are about to prompt: 0.02 ms, and for a helper process or a + /// system daemon it is the only identity there is. See `MeetingTitle.app`. + /// + /// Nil when the pid came from the test seam rather than a scan, which + /// leaves identity to the executable path alone. + private func audioBundleID(of pid: pid_t) -> String? { + guard let capturing, capturing.pid == pid else { return nil } + return stringProperty(capturing.object, kAudioProcessPropertyBundleID) } // MARK: - Core Audio plumbing @@ -335,6 +370,22 @@ final class MeetingDetector { return value } + /// A CFString property, transferred out as a Swift string. Core Audio + /// hands back a +1 reference, so it is taken as retained. + private func stringProperty( + _ object: AudioObjectID, + _ selector: AudioObjectPropertySelector + ) -> String? { + var address = Self.globalAddress(selector) + var value: Unmanaged? + var size = UInt32(MemoryLayout?>.size) + let status = withUnsafeMutablePointer(to: &value) { + AudioObjectGetPropertyData(object, &address, 0, nil, &size, $0) + } + guard status == noErr, let string = value?.takeRetainedValue() else { return nil } + return string as String + } + private static func globalAddress( _ selector: AudioObjectPropertySelector ) -> AudioObjectPropertyAddress { diff --git a/Sources/yap/Detection/MeetingTitle.swift b/Sources/yap/Detection/MeetingTitle.swift index 9a8c703..d879ac7 100644 --- a/Sources/yap/Detection/MeetingTitle.swift +++ b/Sources/yap/Detection/MeetingTitle.swift @@ -2,11 +2,24 @@ import AppKit import ApplicationServices import Darwin -/// Best-effort meeting titles from the window-owning app behind a capture pid. +/// Who is on the microphone: an identity stable enough to keep in the +/// exclusion list, and a name to put in front of the user. +struct MeetingApp { + /// Bundle id of the outermost app bundle behind the capture client, or the + /// identifier Core Audio attributes the stream to when there is no bundle. + let bundleID: String + /// What to call it on screen. + let name: String +} + +/// Who is on the microphone and what their meeting is called: an identity for +/// the capture pid, and a best-effort title from the window-owning app behind +/// it. /// /// Cost contract: call only when prompting, accepting, or resuming after a -/// quiet gap — never from the detector poll loop or dictation path. Each call -/// makes a handful of AX round-trips bounded by a 0.25 second timeout. +/// quiet gap — never from the detector poll loop or dictation path. The title +/// lookups each make a handful of AX round-trips bounded by a 0.25 second +/// timeout. @MainActor enum MeetingTitle { /// Path to the outermost app bundle holding the pid's executable. @@ -20,19 +33,56 @@ enum MeetingTitle { guard let path = String(bytes: buffer[.. String? { - appBundlePath(forPID: pid).flatMap { Bundle(path: $0)?.bundleIdentifier } + /// Core Audio answers where the path doesn't. It attributes every input + /// stream to a bundle id of its own — helpers and system daemons + /// included — and the read costs 0.02 ms. Empty only for a bare + /// executable, which genuinely has no identity worth storing: that is the + /// one case left with no name and no Ignore button. + static func app(forPID pid: pid_t, audioBundleID: String?) -> MeetingApp? { + if let app = identify(bundleAt: appBundlePath(forPID: pid)) { return app } + guard let audioBundleID, !audioBundleID.isEmpty else { return nil } + // Resolved through the installed copy and collapsed onto its container, + // so a helper reads as "Google Chrome" rather than "Google Chrome + // Helper": one Ignore then covers every helper the app starts, under + // the same id Settings' app picker would have stored. + let installed = NSWorkspace.shared.urlForApplication(withBundleIdentifier: audioBundleID) + if let app = identify(bundleAt: installed.flatMap { outermostAppBundle(in: $0.path) }) { + return app + } + // A daemon: nothing installed to resolve and no name to look up, but + // the identifier is stable, so it can still be excluded. Its last + // component is the closest thing to a name it has. + return MeetingApp( + bundleID: audioBundleID, + name: audioBundleID.components(separatedBy: ".").last ?? audioBundleID + ) + } + + /// Bundle id and Finder name of an app bundle path, if it has both. + private static func identify(bundleAt path: String?) -> MeetingApp? { + guard let path, let bundleID = Bundle(path: path)?.bundleIdentifier else { return nil } + return MeetingApp(bundleID: bundleID, name: FileManager.default.displayName(atPath: path)) + } + + /// The first `.app` on a path — the one a human would recognise, so a + /// renderer buried in Chrome's Frameworks directory reads as Chrome. + private static func outermostAppBundle(in path: String) -> String? { + let components = (path as NSString).pathComponents + guard let end = components.firstIndex(where: { $0.hasSuffix(".app") }) else { return nil } + return NSString.path(withComponents: Array(components[...end])) } /// Best-effort meeting name from the capturing app's windows. From 198bd20dc7677a1cf54f2e033ea6d42a8df6c252 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Mon, 7 Sep 2026 09:26:55 +0100 Subject: [PATCH 2/2] chore: bump version to 0.3.1 --- CHANGELOG.md | 18 ++++++++++++++++++ Sources/yap/Yap.swift | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb1da0f..2d56e84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ section here does not ship. Releases before 0.3.0 are on the [Releases page](https://github.com/TerrifiedBug/yap/releases). +## 0.3.1 + +Meeting detection now names what has your microphone, and ignoring something +actually makes it go away. + +- The prompt and the recording banner name the client in the two cases where + they used to say nothing at all. A helper process counts as the app that + owns it, so one "Ignore" covers every helper Chrome or Teams starts. A + background process counts as itself, so the speech service macOS keeps + listening with when "Hey Siri" is on shows up as `CoreSpeech` and can be + ignored like an app. Before, either of those produced a bare "Recording + meeting" pill with no Ignore button on it — nothing to click, and no way to + stop it happening again short of turning detection off. +- An ignored app is now genuinely out of the picture. It used to stay the + client yap was following, which hid everything else behind it: once you had + ignored a process that holds the microphone for hours, no real call could be + detected until it let go. + ## 0.3.0 yap is an app now. Everything that used to need a terminal happens in the menu diff --git a/Sources/yap/Yap.swift b/Sources/yap/Yap.swift index d8c7508..dbc7ba6 100644 --- a/Sources/yap/Yap.swift +++ b/Sources/yap/Yap.swift @@ -12,7 +12,7 @@ struct Yap: ParsableCommand { // The single source of truth for what this binary is. Nothing else // holds a version constant, and the release workflow greps this // literal against the tag, so a build can never claim the wrong one. - version: "0.3.0", + version: "0.3.1", // Two subcommands, and that is the product: `run` is the app, `bench` // is the only thing a terminal can do that the menu bar cannot. // Everything else — setup, permissions, the login item, the model,