add microphone capture and stable code signing for TCC persistence

Adds --capture-mic/--mic-output for a second, independently-captured audio
track (mic vs system, written to separate outputs to avoid interleaving
corruption). Embeds Info.plist at link time so the binary carries a stable
CFBundleIdentifier and the usage-description keys TCC requires, and adds
scripts/build-signed.sh + scripts/create-signing-identity.sh so a rebuilt
binary keeps the same signing identity instead of losing granted
permissions on every rebuild.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sttlab-tech
2026-08-09 13:10:08 +02:00
parent 56ac954369
commit 678557caf7
10 changed files with 678 additions and 9 deletions
+67 -2
View File
@@ -2,6 +2,10 @@ import AudioTeeCore
import CoreAudio
import Foundation
// Set by the SIGINT/SIGTERM handlers, which being passed to the C `signal()`
// API cannot capture `self` and so can't touch instance state directly.
private var shouldStop = false
struct AudioTee {
var includeProcesses: [Int32] = []
var excludeProcesses: [Int32] = []
@@ -9,6 +13,8 @@ struct AudioTee {
var stereo: Bool = false
var sampleRate: Double?
var chunkDuration: Double = 0.2
var captureMic: Bool = false
var micOutputPath: String?
init() {}
@@ -32,6 +38,8 @@ struct AudioTee {
audiotee --include-processes 1234 5678 9012 # Tap only these processes
audiotee --exclude-processes 1234 5678 # Tap everything except these
audiotee --mute # Mute processes being tapped
audiotee --capture-mic --mic-output mic.pcm > system.pcm
# Capture system audio and mic to separate files
"""
)
@@ -48,6 +56,12 @@ struct AudioTee {
help: "Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)")
parser.addOption(
name: "chunk-duration", help: "Audio chunk duration in seconds", defaultValue: "0.2")
parser.addFlag(
name: "capture-mic",
help: "Also capture the default input device (microphone) as a second track")
parser.addOption(
name: "mic-output",
help: "File path to write microphone PCM audio to (required with --capture-mic)")
// Parse arguments
do {
@@ -62,6 +76,8 @@ struct AudioTee {
audioTee.stereo = parser.getFlag("stereo")
audioTee.sampleRate = try parser.getOptionalValue("sample-rate", as: Double.self)
audioTee.chunkDuration = try parser.getValue("chunk-duration", as: Double.self)
audioTee.captureMic = parser.getFlag("capture-mic")
audioTee.micOutputPath = try parser.getOptionalValue("mic-output", as: String.self)
// Validate
try audioTee.validate()
@@ -90,6 +106,13 @@ struct AudioTee {
throw ArgumentParserError.validationFailed(
"Cannot specify both --include-processes and --exclude-processes")
}
if captureMic && micOutputPath == nil {
throw ArgumentParserError.validationFailed(
"--mic-output is required when --capture-mic is set")
}
if !captureMic && micOutputPath != nil {
throw ArgumentParserError.validationFailed("--mic-output requires --capture-mic")
}
}
func run() throws {
@@ -143,8 +166,14 @@ struct AudioTee {
chunkDuration: chunkDuration)
try recorder.startRecording()
// Run until the run loop is stopped (by signal handler)
while true {
let micRecorder = try setupMicRecorderIfNeeded()
try micRecorder?.startRecording()
// Run until the run loop is stopped (by signal handler). shouldStop is
// checked on every iteration (not just the CFRunLoopRun result) because
// a signal can arrive during setup, before this loop is ever entered
// CFRunLoopStop has no lasting effect on a run loop that isn't running yet.
while !shouldStop {
let result = CFRunLoopRunInMode(CFRunLoopMode.defaultMode, 0.1, false)
if result == CFRunLoopRunResult.stopped || result == CFRunLoopRunResult.finished {
break
@@ -153,15 +182,51 @@ struct AudioTee {
AudioTeeLogging.logger.info("Shutting down...")
recorder.stopRecording()
micRecorder?.stopRecording()
}
/// Sets up a second, independent recording pipeline reading from the
/// default input device (microphone) when --capture-mic was requested.
/// Its audio is written to its own file rather than stdout: writes from
/// two concurrent Core Audio IO threads interleaved on one fd/stream
/// would otherwise corrupt both tracks.
private func setupMicRecorderIfNeeded() throws -> AudioRecorder? {
guard captureMic, let micOutputPath = micOutputPath else {
return nil
}
let micDeviceID: AudioObjectID
do {
micDeviceID = try InputDeviceResolver.defaultInputDevice()
} catch {
AudioTeeLogging.logger.error(
"Failed to resolve default input device", context: ["error": String(describing: error)])
throw ExitCode.failure
}
let micFd = open(micOutputPath, O_WRONLY | O_CREAT | O_TRUNC, 0o644)
guard micFd >= 0 else {
AudioTeeLogging.logger.error(
"Failed to open mic output file",
context: ["path": micOutputPath, "errno": String(errno)])
throw ExitCode.failure
}
let micOutputHandler = BinaryAudioOutputHandler(fd: micFd, source: "mic")
return try AudioRecorder(
deviceID: micDeviceID, outputHandler: micOutputHandler, convertToSampleRate: sampleRate,
chunkDuration: chunkDuration)
}
private func setupSignalHandlers() {
signal(SIGINT) { _ in
AudioTeeLogging.logger.info("Received SIGINT, initiating graceful shutdown...")
shouldStop = true
CFRunLoopStop(CFRunLoopGetMain())
}
signal(SIGTERM) { _ in
AudioTeeLogging.logger.info("Received SIGTERM, initiating graceful shutdown...")
shouldStop = true
CFRunLoopStop(CFRunLoopGetMain())
}
}