AudioTee Swift CLI repo

This commit is contained in:
Nick Payne
2025-06-11 15:33:40 +01:00
commit 4a8bc657e3
26 changed files with 1559 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
import ArgumentParser
import CoreAudio
import Foundation
struct AudioTee: ParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Capture system audio and stream to stdout",
discussion: """
AudioTee captures system audio using Core Audio taps and streams it as structured output.
Output formats:
• json: Base64-encoded audio in JSON messages (safe for terminals)
• binary: Raw binary audio with JSON metadata headers (efficient for pipes)
• auto: Automatically choose based on whether stdout is a terminal (default)
Tap configuration:
• processes: List of process IDs to tap (empty = all processes)
• mute: How to handle processes being tapped
• exclusive: Whether to use exclusive mode
Examples:
audiotee # Auto format (JSON in terminal, binary when piped)
audiotee --format=json # Always use JSON format
audiotee --format=binary # Always use binary format
audiotee --convert-to=16000 # Convert to 16kHz mono for ASR
audiotee --convert-to=8000 # Convert to 8kHz for telephony
audiotee --processes 1234 # Only tap process 1234
audiotee --processes 1234 5678 9012 # Tap multiple processes
audiotee --mute=muted # Mute processes being tapped
audiotee --no-exclusive # Don't use exclusive mode
"""
)
@Option(name: .shortAndLong, help: "Output format")
var format: OutputFormat = .auto
@Option(
name: .long, help: "Process IDs to tap (space-separated for multiple, empty = all processes)")
var processes: [Int32] = []
@Option(name: .long, help: "Mute behavior for tapped processes")
var mute: TapMuteBehavior = .unmuted
@Flag(name: .long, inversion: .prefixedNo, help: "Use exclusive mode to capture all processes")
var exclusive: Bool = true
@Option(
name: .long,
help: "Convert audio to specified sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)")
var convertTo: Double?
@Option(
name: .long,
help: "Audio chunk duration in seconds (default: 0.2)")
var chunkDuration: Double = 0.2
func run() throws {
setupSignalHandlers()
Logger.info("Starting AudioTee...")
Logger.debug("Using output format: \(format)")
// Validate chunk duration
guard chunkDuration > 0 && chunkDuration <= 5.0 else {
Logger.error(
"Invalid chunk duration",
context: ["chunk_duration": String(chunkDuration), "valid_range": "0.0 < duration <= 5.0"])
throw ExitCode.failure
}
let tapConfig = TapConfiguration(
processes: processes,
muteBehavior: mute,
isExclusive: exclusive
)
let audioTapManager = AudioTapManager()
do {
try audioTapManager.setupAudioTap(with: tapConfig)
} catch AudioTeeError.pidTranslationFailed(let failedPIDs) {
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 {
Logger.error(
"Failed to setup audio tap", context: ["error": String(describing: error)])
throw ExitCode.failure
}
guard let deviceID = audioTapManager.getDeviceID() else {
Logger.error("Failed to get device ID from audio tap manager")
throw ExitCode.failure
}
let outputHandler = createOutputHandler(for: format)
let recorder = AudioRecorder(
deviceID: deviceID, outputHandler: outputHandler, convertToSampleRate: convertTo,
chunkDuration: chunkDuration)
recorder.startRecording()
// Run until the run loop is stopped (by signal handler)
while true {
let result = CFRunLoopRunInMode(CFRunLoopMode.defaultMode, 0.1, false)
if result == CFRunLoopRunResult.stopped || result == CFRunLoopRunResult.finished {
break
}
}
Logger.info("Shutting down...")
recorder.stopRecording()
}
private func setupSignalHandlers() {
signal(SIGINT) { _ in
Logger.info("Received SIGINT, initiating graceful shutdown...")
CFRunLoopStop(CFRunLoopGetMain())
}
signal(SIGTERM) { _ in
Logger.info("Received SIGTERM, initiating graceful shutdown...")
CFRunLoopStop(CFRunLoopGetMain())
}
}
private func createOutputHandler(for format: OutputFormat) -> AudioOutputHandler {
switch format {
case .json:
return JSONAudioOutputHandler()
case .binary:
return BinaryAudioOutputHandler()
case .auto:
return AutoAudioOutputHandler()
}
}
}
+18
View File
@@ -0,0 +1,18 @@
import ArgumentParser
enum OutputFormat: String, CaseIterable, ExpressibleByArgument {
case json = "json"
case binary = "binary"
case auto = "auto"
var description: String {
switch self {
case .json:
return "Base64-encoded JSON (terminal-safe)"
case .binary:
return "Binary with JSON headers (pipe-optimised)"
case .auto:
return "Auto-detect based on TTY (default)"
}
}
}
+11
View File
@@ -0,0 +1,11 @@
public struct TapConfiguration {
public let processes: [Int32]
public let muteBehavior: TapMuteBehavior
public let isExclusive: Bool
public init(processes: [Int32], muteBehavior: TapMuteBehavior, isExclusive: Bool) {
self.processes = processes
self.muteBehavior = muteBehavior
self.isExclusive = isExclusive
}
}
+25
View File
@@ -0,0 +1,25 @@
import ArgumentParser
import CoreAudio
public enum TapMuteBehavior: String, CaseIterable, ExpressibleByArgument {
case unmuted = "unmuted"
case muted = "muted"
public var description: String {
switch self {
case .unmuted:
return "Don't mute processes (default)"
case .muted:
return "Mute processes being tapped"
}
}
public var coreAudioValue: CATapMuteBehavior {
switch self {
case .unmuted:
return .unmuted
case .muted:
return .muted
}
}
}