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:
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import AudioTeeCore
|
||||
import Foundation
|
||||
|
||||
/// CLI-specific output handler that writes raw PCM audio to stdout
|
||||
/// and lifecycle messages to stderr via the logger.
|
||||
/// CLI-specific output handler that writes raw PCM audio to a file descriptor
|
||||
/// (stdout by default) and lifecycle messages to stderr via the logger.
|
||||
///
|
||||
/// `source` tags every stderr message so a consumer running two tracks at
|
||||
/// once (e.g. system audio + microphone, each on its own fd) can tell which
|
||||
/// track a given metadata/lifecycle message belongs to.
|
||||
class BinaryAudioOutputHandler: AudioOutputHandler {
|
||||
private let fd = STDOUT_FILENO
|
||||
private let fd: Int32
|
||||
private let source: String
|
||||
|
||||
init(fd: Int32 = STDOUT_FILENO, source: String = "audio") {
|
||||
self.fd = fd
|
||||
self.source = source
|
||||
}
|
||||
|
||||
func handleAudioData(_ pointer: UnsafeRawPointer, count: Int) {
|
||||
var written = 0
|
||||
@@ -21,14 +31,24 @@ class BinaryAudioOutputHandler: AudioOutputHandler {
|
||||
}
|
||||
|
||||
func handleMetadata(_ metadata: AudioStreamMetadata) {
|
||||
AudioTeeLogging.logger.writeMessage(.metadata, data: metadata)
|
||||
let taggedMetadata = AudioStreamMetadata(
|
||||
sampleRate: metadata.sampleRate,
|
||||
channelsPerFrame: metadata.channelsPerFrame,
|
||||
bitsPerChannel: metadata.bitsPerChannel,
|
||||
isFloat: metadata.isFloat,
|
||||
captureMode: source,
|
||||
deviceName: metadata.deviceName,
|
||||
deviceUID: metadata.deviceUID,
|
||||
encoding: metadata.encoding
|
||||
)
|
||||
AudioTeeLogging.logger.writeMessage(.metadata, data: taggedMetadata)
|
||||
}
|
||||
|
||||
func handleStreamStart() {
|
||||
AudioTeeLogging.logger.writeMessage(.streamStart, data: Optional<String>.none)
|
||||
AudioTeeLogging.logger.writeMessage(.streamStart, data: source)
|
||||
}
|
||||
|
||||
func handleStreamStop() {
|
||||
AudioTeeLogging.logger.writeMessage(.streamStop, data: Optional<String>.none)
|
||||
AudioTeeLogging.logger.writeMessage(.streamStop, data: source)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.stephanetailland.audiotee</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>audiotee</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>NSAudioCaptureUsageDescription</key>
|
||||
<string>audiotee captures system audio for local, on-device meeting transcription.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>audiotee captures microphone audio for local, on-device meeting transcription.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
Reference in New Issue
Block a user