678557caf7
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>
271 lines
9.3 KiB
Swift
271 lines
9.3 KiB
Swift
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] = []
|
|
var mute: Bool = false
|
|
var stereo: Bool = false
|
|
var sampleRate: Double?
|
|
var chunkDuration: Double = 0.2
|
|
var captureMic: Bool = false
|
|
var micOutputPath: String?
|
|
|
|
init() {}
|
|
|
|
static func main() {
|
|
let parser = SimpleArgumentParser(
|
|
programName: "audiotee",
|
|
abstract: "Capture system audio and stream to stdout",
|
|
discussion: """
|
|
AudioTee captures system audio using Core Audio taps and streams it as structured output.
|
|
|
|
Process filtering:
|
|
• include-processes: Only tap specified process IDs (empty = all processes)
|
|
• exclude-processes: Tap all processes except specified ones
|
|
• mute: How to handle processes being tapped
|
|
|
|
Examples:
|
|
audiotee # Auto format, tap all processes
|
|
audiotee --sample-rate 16000 # Convert to 16kHz mono for ASR
|
|
audiotee --sample-rate 8000 # Convert to 8kHz for telephony
|
|
audiotee --include-processes 1234 # Only tap process 1234
|
|
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
|
|
"""
|
|
)
|
|
|
|
// Configure arguments
|
|
parser.addArrayOption(
|
|
name: "include-processes",
|
|
help: "Process IDs to include (space-separated, empty = all processes)")
|
|
parser.addArrayOption(
|
|
name: "exclude-processes", help: "Process IDs to exclude (space-separated)")
|
|
parser.addFlag(name: "mute", help: "Mute processes being tapped")
|
|
parser.addFlag(name: "stereo", help: "Records in stereo")
|
|
parser.addOption(
|
|
name: "sample-rate",
|
|
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 {
|
|
try parser.parse()
|
|
|
|
var audioTee = AudioTee()
|
|
|
|
// Extract values
|
|
audioTee.includeProcesses = try parser.getArrayValue("include-processes", as: Int32.self)
|
|
audioTee.excludeProcesses = try parser.getArrayValue("exclude-processes", as: Int32.self)
|
|
audioTee.mute = parser.getFlag("mute")
|
|
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()
|
|
|
|
// Run
|
|
try audioTee.run()
|
|
|
|
} catch ArgumentParserError.helpRequested {
|
|
parser.printHelp()
|
|
exit(0)
|
|
} catch ArgumentParserError.validationFailed(let message) {
|
|
print("Error: \(message)", to: &standardError)
|
|
exit(1)
|
|
} catch let error as ArgumentParserError {
|
|
print("Error: \(error.description)", to: &standardError)
|
|
parser.printHelp()
|
|
exit(1)
|
|
} catch {
|
|
print("Error: \(error)", to: &standardError)
|
|
exit(1)
|
|
}
|
|
}
|
|
|
|
func validate() throws {
|
|
if !includeProcesses.isEmpty && !excludeProcesses.isEmpty {
|
|
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 {
|
|
setupSignalHandlers()
|
|
|
|
AudioTeeLogging.logger.info("Starting AudioTee...")
|
|
|
|
// Validate chunk duration
|
|
guard chunkDuration > 0 && chunkDuration <= 5.0 else {
|
|
AudioTeeLogging.logger.error(
|
|
"Invalid chunk duration",
|
|
context: ["chunk_duration": String(chunkDuration), "valid_range": "0.0 < duration <= 5.0"])
|
|
throw ExitCode.failure
|
|
}
|
|
|
|
// Convert include/exclude processes to TapConfiguration format
|
|
let (processes, isExclusive) = convertProcessFlags()
|
|
|
|
let tapConfig = TapConfiguration(
|
|
processes: processes,
|
|
muteBehavior: mute ? .muted : .unmuted,
|
|
isExclusive: isExclusive,
|
|
isMono: !stereo
|
|
)
|
|
|
|
let audioTapManager = AudioTapManager()
|
|
do {
|
|
try audioTapManager.setupAudioTap(with: tapConfig)
|
|
} catch AudioTeeError.pidTranslationFailed(let failedPIDs) {
|
|
AudioTeeLogging.logger.error(
|
|
"Failed to translate process IDs to audio objects",
|
|
context: [
|
|
"failed_pids": failedPIDs.map(String.init).joined(separator: ", "),
|
|
"suggestion": "Check that the process IDs exist and are running",
|
|
])
|
|
throw ExitCode.failure
|
|
} catch {
|
|
AudioTeeLogging.logger.error(
|
|
"Failed to setup audio tap", context: ["error": String(describing: error)])
|
|
throw ExitCode.failure
|
|
}
|
|
|
|
guard let deviceID = audioTapManager.getDeviceID() else {
|
|
AudioTeeLogging.logger.error("Failed to get device ID from audio tap manager")
|
|
throw ExitCode.failure
|
|
}
|
|
|
|
let outputHandler = BinaryAudioOutputHandler()
|
|
let recorder = try AudioRecorder(
|
|
deviceID: deviceID, outputHandler: outputHandler, convertToSampleRate: sampleRate,
|
|
chunkDuration: chunkDuration)
|
|
try recorder.startRecording()
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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())
|
|
}
|
|
}
|
|
|
|
private func convertProcessFlags() -> ([Int32], Bool) {
|
|
if !includeProcesses.isEmpty {
|
|
// Include specific processes only
|
|
return (includeProcesses, false)
|
|
} else if !excludeProcesses.isEmpty {
|
|
// Exclude specific processes (tap everything except these)
|
|
return (excludeProcesses, true)
|
|
} else {
|
|
// Default: tap everything
|
|
return ([], true)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper for stderr output
|
|
var standardError = FileHandle.standardError
|
|
|
|
extension FileHandle: TextOutputStream {
|
|
public func write(_ string: String) {
|
|
let data = Data(string.utf8)
|
|
self.write(data)
|
|
}
|
|
}
|
|
|
|
// Exit code handling
|
|
enum ExitCode: Error {
|
|
case failure
|
|
}
|
|
|
|
extension ExitCode {
|
|
var code: Int32 {
|
|
switch self {
|
|
case .failure:
|
|
return 1
|
|
}
|
|
}
|
|
}
|