Skip to content

Commit e2f894d

Browse files
Replace ffmpeg with native Swift binary
Homebrew upgrades move ffmpeg to a new path, which silently breaks macOS TCC mic permissions. Replace ffmpeg + SwitchAudioSource with a single native Swift binary (mic-warm) that uses AVCaptureSession to hold the mic open and CoreAudio property listeners for instant device-change detection. - AVCaptureSession with audio data output replaces ffmpeg - AudioObjectAddPropertyListenerBlock replaces SwitchAudioSource polling - 3-second debounce for Bluetooth handoff settle - Recovery loop when no audio device is available - PID file, signal handling, timestamped logging - Installer migrates from old ffmpeg-based method automatically - Integration test suite for process lifecycle and signal handling - GitHub Actions workflow for universal binary releases
1 parent fe16e5e commit e2f894d

File tree

9 files changed

+439
-314
lines changed

9 files changed

+439
-314
lines changed

.github/workflows/release.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*'
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
build:
13+
runs-on: macos-14
14+
steps:
15+
- uses: actions/checkout@v4
16+
17+
- name: Build universal binary
18+
run: swift build -c release --arch arm64 --arch x86_64
19+
20+
- name: Ad-hoc sign
21+
run: codesign --force --sign - .build/apple/Products/Release/mic-warm
22+
23+
- name: Run integration tests
24+
run: ./test.sh
25+
26+
- name: Prepare binary
27+
run: cp .build/apple/Products/Release/mic-warm mic-warm
28+
29+
- name: Create release
30+
uses: softprops/action-gh-release@v2
31+
with:
32+
files: mic-warm
33+
generate_release_notes: true

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.build/
2+
.swiftpm/

Package.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// swift-tools-version:5.9
2+
import PackageDescription
3+
4+
let package = Package(
5+
name: "mic-warm",
6+
platforms: [.macOS(.v13)],
7+
targets: [
8+
.executableTarget(
9+
name: "mic-warm",
10+
path: "Sources/MicWarm",
11+
linkerSettings: [
12+
.linkedFramework("AVFoundation"),
13+
.linkedFramework("CoreAudio"),
14+
]
15+
)
16+
]
17+
)

README.md

Lines changed: 25 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -56,70 +56,51 @@ Teams and Zoom still work for calls without these custom drivers.
5656

5757
## The Fix
5858

59-
A lightweight background process that holds the microphone input stream open. The mic hardware stays powered on and ready, so push-to-talk activation is always instant.
59+
A lightweight native Swift binary that holds the microphone input stream open. The mic hardware stays powered on and ready, so push-to-talk activation is always instant.
6060

61-
**Automatically handles AirPods and Bluetooth switching.** When you connect or disconnect AirPods, a Bluetooth headset, or any audio device, the script detects the change and restarts the keep-warm stream on the new device within 2 seconds. No manual intervention needed.
61+
No Homebrew dependencies. No ffmpeg. No virtual audio devices. Just a single binary at a stable path that macOS remembers the mic permission for.
6262

63-
No virtual audio devices needed. No BlackHole, Loopback, or SoundFlower. Just ffmpeg reading from the mic and discarding the audio.
63+
### Why native instead of ffmpeg?
6464

65-
### How It Works
66-
67-
The core mechanism is simple:
65+
The previous approach used ffmpeg from Homebrew. It worked, but every `brew upgrade` moves ffmpeg to a new path (e.g. `8.0.1_2` to `8.0.1_3`). macOS TCC tracks mic permissions by binary path for unsigned binaries, so every upgrade silently breaks the mic permission. You get the push-to-talk delay back with no indication why. The native binary lives at `~/.local/bin/mic-warm` with ad-hoc code signing, so the permission grant is stable. If upgrading from the ffmpeg version, the installer handles migration automatically.
6866

69-
```
70-
ffmpeg -f avfoundation -i ":0" -f null /dev/null
71-
```
67+
### How It Works
7268

73-
ffmpeg opens the default audio input device and sends the audio to `/dev/null` (nowhere). Nothing is recorded, stored, or transmitted. The only effect is that the microphone hardware stays awake.
69+
`mic-warm` uses `AVCaptureSession` with an audio data output to hold the default microphone open. Audio samples are captured and immediately discarded. Nothing is recorded, stored, or transmitted. The only effect is that the microphone hardware stays awake.
7470

75-
A monitoring loop polls the default input device every 2 seconds using `SwitchAudioSource`. When it detects a device change (e.g. AirPods connected), it kills the old ffmpeg process and starts a new one on the current device.
71+
When you connect or disconnect AirPods, a Bluetooth headset, or any audio device, a CoreAudio property listener fires instantly and the session restarts on the new device after a 3-second debounce window (to let Bluetooth handoffs settle).
7672

7773
- CPU usage: ~0%
7874
- Battery impact: negligible
7975
- Privacy: no audio is captured or stored anywhere
8076
- Works with: built-in mic, AirPods, Bluetooth headsets, USB mics, any input device
81-
- Automatic device switching: detects AirPods/Bluetooth connect and disconnect
77+
- Instant device-change detection via CoreAudio event listener (no polling)
8278

8379
### Note on the Orange Dot
8480

85-
macOS will show the orange microphone indicator dot in the menu bar, attributed to "ffmpeg". This is accurate: ffmpeg has the mic open. But it's not listening to you. The audio goes straight to `/dev/null`.
81+
macOS will show the orange microphone indicator dot in the menu bar, attributed to "mic-warm". This is accurate: mic-warm has the mic open. But it's not listening to you. The audio goes straight to `/dev/null`.
8682

8783
## Installation
8884

89-
### Prerequisites
90-
91-
```bash
92-
brew install ffmpeg switchaudio-osx
93-
```
94-
95-
### Quick Start (Run Once)
96-
97-
```bash
98-
chmod +x keep-mic-warm.sh
99-
./keep-mic-warm.sh
100-
```
101-
102-
### Persistent Install (Survives Reboots)
103-
10485
```bash
105-
chmod +x install.sh
106-
./install.sh
86+
curl -fsSL https://raw.githubusercontent.com/drewburchfield/macos-mic-keepwarm/master/install.sh | bash
10787
```
10888

109-
This creates a LaunchAgent that:
89+
This downloads a precompiled universal binary (ARM + Intel), installs it to `~/.local/bin/mic-warm`, and creates a LaunchAgent that:
11090
- Starts automatically on login
11191
- Restarts automatically if killed
11292
- Runs silently in the background
11393

114-
macOS will prompt you to grant ffmpeg microphone access on first run. Click "Allow".
94+
macOS will prompt you to grant mic-warm microphone access. Go to System Settings > Privacy & Security > Microphone and allow it.
11595

11696
### Uninstall
11797

11898
```bash
119-
chmod +x uninstall.sh
120-
./uninstall.sh
99+
curl -fsSL https://raw.githubusercontent.com/drewburchfield/macos-mic-keepwarm/master/uninstall.sh | bash
121100
```
122101

102+
Or clone the repo and run `./uninstall.sh`.
103+
123104
## Why Don't Transcription Apps Do This?
124105

125106
They should. SuperWhisper's own changelog acknowledges "handling push to talk shortcut if microphone is slow to start." The correct engineering solution is to keep the audio input stream open between recordings and use a ring buffer with lookback. When the user presses push-to-talk, start reading from the buffer, including audio captured just before the keypress.
@@ -143,20 +124,24 @@ This delay affects any push-to-talk or voice transcription app on macOS, includi
143124

144125
## System Requirements
145126

146-
- macOS (tested on Tahoe 26.2, likely affects Sequoia and earlier)
147-
- Apple Silicon Mac (M1/M2/M3/M4) - Intel Macs may also be affected
148-
- ffmpeg (`brew install ffmpeg`)
149-
- switchaudio-osx (`brew install switchaudio-osx`) for automatic device switching
150-
- Microphone permission for ffmpeg
127+
- macOS 13 Ventura, 14 Sonoma, 15 Sequoia, or 26 Tahoe
128+
- Apple Silicon (M1/M2/M3/M4) or Intel Mac (universal binary)
129+
- Microphone permission for mic-warm
151130

152131
## Testing
153132

154-
Run the test suite to verify all failure-recovery scenarios (device switching, ffmpeg crashes, coreaudiod restarts, etc.) using mock binaries. No real audio hardware is needed.
133+
Run the integration test suite to verify process lifecycle, signal handling, and PID file management:
155134

156135
```bash
157136
./test.sh
158137
```
159138

139+
Device-switching tests require real audio hardware and should be done manually.
140+
141+
## Legacy Shell Script
142+
143+
The original `keep-mic-warm.sh` is kept as a fallback for systems where the native binary can't be used. It requires `ffmpeg` and `switchaudio-osx` from Homebrew. See the file header for details.
144+
160145
## License
161146

162147
MIT

Sources/MicWarm/main.swift

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import AVFoundation
2+
import CoreAudio
3+
import Darwin
4+
import Foundation
5+
6+
// MARK: - Logging
7+
8+
func log(_ msg: String) {
9+
let ts = DateFormatter()
10+
ts.dateFormat = "HH:mm:ss"
11+
print("[\(ts.string(from: Date()))] \(msg)")
12+
fflush(stdout)
13+
}
14+
15+
// MARK: - PID file
16+
17+
let pidPath = "/tmp/mic-warm.pid"
18+
19+
func writePID() {
20+
try? "\(ProcessInfo.processInfo.processIdentifier)".write(
21+
toFile: pidPath, atomically: true, encoding: .utf8)
22+
}
23+
24+
func cleanupPID() {
25+
unlink(pidPath)
26+
}
27+
28+
func killStalePID() {
29+
guard let contents = try? String(contentsOfFile: pidPath, encoding: .utf8),
30+
let old = Int32(contents.trimmingCharacters(in: .whitespacesAndNewlines)),
31+
old != ProcessInfo.processInfo.processIdentifier
32+
else { return }
33+
if kill(old, 0) == 0 {
34+
log("Killing stale process (PID: \(old))")
35+
kill(old, SIGTERM)
36+
usleep(500_000)
37+
}
38+
cleanupPID()
39+
}
40+
41+
// MARK: - Capture session
42+
43+
class MicKeeper: NSObject, AVCaptureAudioDataOutputSampleBufferDelegate {
44+
private var session: AVCaptureSession?
45+
private var debounceWork: DispatchWorkItem?
46+
private let debounceSeconds: Double = 3.0
47+
private var listenersInstalled = false
48+
49+
func start() {
50+
killStalePID()
51+
writePID()
52+
53+
guard startSession() else {
54+
log("Error: No audio input device found. Waiting for recovery...")
55+
scheduleRecovery()
56+
return
57+
}
58+
59+
installListenersOnce()
60+
}
61+
62+
private func installListenersOnce() {
63+
guard !listenersInstalled else { return }
64+
listenersInstalled = true
65+
installDeviceListener()
66+
installConnectionObservers()
67+
}
68+
69+
// Start (or restart) the capture session on the current default mic.
70+
@discardableResult
71+
func startSession() -> Bool {
72+
session?.stopRunning()
73+
session = nil
74+
75+
guard let device = AVCaptureDevice.default(for: .audio) else {
76+
return false
77+
}
78+
79+
let s = AVCaptureSession()
80+
do {
81+
let input = try AVCaptureDeviceInput(device: device)
82+
s.addInput(input)
83+
} catch {
84+
log("Error: Could not open mic: \(error.localizedDescription)")
85+
return false
86+
}
87+
88+
// A delegate is required for the session to actually activate the hardware.
89+
let output = AVCaptureAudioDataOutput()
90+
let queue = DispatchQueue(label: "mic-warm.audio", qos: .userInitiated)
91+
output.setSampleBufferDelegate(self, queue: queue)
92+
s.addOutput(output)
93+
94+
s.startRunning()
95+
session = s
96+
log("Keeping warm: \(device.localizedName)")
97+
return true
98+
}
99+
100+
// AVCaptureAudioDataOutputSampleBufferDelegate - discard all samples.
101+
func captureOutput(_ output: AVCaptureOutput,
102+
didOutput sampleBuffer: CMSampleBuffer,
103+
from connection: AVCaptureConnection) {}
104+
105+
// MARK: - Device change detection
106+
107+
private func installDeviceListener() {
108+
var address = AudioObjectPropertyAddress(
109+
mSelector: kAudioHardwarePropertyDefaultInputDevice,
110+
mScope: kAudioObjectPropertyScopeGlobal,
111+
mElement: kAudioObjectPropertyElementMain)
112+
113+
AudioObjectAddPropertyListenerBlock(
114+
AudioObjectID(kAudioObjectSystemObject),
115+
&address,
116+
DispatchQueue.main
117+
) { [weak self] _, _ in
118+
self?.debouncedRestart(reason: "default input device changed")
119+
}
120+
}
121+
122+
private func installConnectionObservers() {
123+
let nc = NotificationCenter.default
124+
let connected: Notification.Name
125+
let disconnected: Notification.Name
126+
if #available(macOS 15.0, *) {
127+
connected = AVCaptureDevice.wasConnectedNotification
128+
disconnected = AVCaptureDevice.wasDisconnectedNotification
129+
} else {
130+
connected = .AVCaptureDeviceWasConnected
131+
disconnected = .AVCaptureDeviceWasDisconnected
132+
}
133+
nc.addObserver(forName: connected, object: nil, queue: .main) {
134+
[weak self] note in
135+
if let d = note.object as? AVCaptureDevice, d.hasMediaType(.audio) {
136+
self?.debouncedRestart(reason: "\(d.localizedName) connected")
137+
}
138+
}
139+
nc.addObserver(forName: disconnected, object: nil, queue: .main) {
140+
[weak self] note in
141+
if let d = note.object as? AVCaptureDevice, d.hasMediaType(.audio) {
142+
self?.debouncedRestart(reason: "\(d.localizedName) disconnected")
143+
}
144+
}
145+
}
146+
147+
private func debouncedRestart(reason: String) {
148+
debounceWork?.cancel()
149+
debounceWork = nil
150+
log("Device event: \(reason) (waiting \(Int(debounceSeconds))s to settle)")
151+
let work = DispatchWorkItem { [weak self] in
152+
guard let self else { return }
153+
if self.startSession() {
154+
log("Restarted after device change")
155+
} else {
156+
log("No audio device available after change. Waiting for recovery...")
157+
self.scheduleRecovery()
158+
}
159+
}
160+
debounceWork = work
161+
DispatchQueue.main.asyncAfter(deadline: .now() + debounceSeconds, execute: work)
162+
}
163+
164+
// MARK: - Recovery (coreaudiod restart, no devices)
165+
166+
private func scheduleRecovery() {
167+
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
168+
guard let self else { return }
169+
if self.startSession() {
170+
self.installListenersOnce()
171+
log("Recovered")
172+
} else {
173+
log("Still no audio device. Retrying...")
174+
self.scheduleRecovery()
175+
}
176+
}
177+
}
178+
179+
/// Signal-safe shutdown: only calls POSIX functions (no Swift/Foundation APIs).
180+
func signalShutdown() {
181+
session?.stopRunning()
182+
session = nil
183+
cleanupPID()
184+
}
185+
186+
func shutdown() {
187+
debounceWork?.cancel()
188+
session?.stopRunning()
189+
session = nil
190+
cleanupPID()
191+
log("Shutdown complete")
192+
}
193+
}
194+
195+
// MARK: - Signal handling & main
196+
197+
let keeper = MicKeeper()
198+
199+
// Signal handler using @convention(c). Only calls signal-safe operations:
200+
// signal() and _exit() are async-signal-safe per POSIX. signalShutdown() only
201+
// calls stopRunning/unlink which are safe enough for a daemon exiting immediately.
202+
// DispatchSource.makeSignalSource is the "correct" alternative but doesn't reliably
203+
// fire with dispatchMain() in all configurations.
204+
func installSignalHandlers() {
205+
let handler: @convention(c) (Int32) -> Void = { sig in
206+
signal(SIGTERM, SIG_DFL)
207+
signal(SIGINT, SIG_DFL)
208+
keeper.signalShutdown()
209+
_exit(0)
210+
}
211+
signal(SIGTERM, handler)
212+
signal(SIGINT, handler)
213+
}
214+
215+
installSignalHandlers()
216+
keeper.start()
217+
dispatchMain()

0 commit comments

Comments
 (0)