AudioTee Swift CLI repo
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import CoreAudio
|
||||
import Foundation
|
||||
|
||||
public class AudioBuffer {
|
||||
private var buffer = Data()
|
||||
private let targetChunkDuration: Double
|
||||
private let streamFormat: AudioStreamBasicDescription
|
||||
|
||||
public init(format: AudioStreamBasicDescription, chunkDuration: Double = 0.2) {
|
||||
self.streamFormat = format
|
||||
self.targetChunkDuration = chunkDuration
|
||||
}
|
||||
|
||||
public func append(_ data: Data) {
|
||||
buffer.append(data)
|
||||
}
|
||||
|
||||
public func processChunks() -> [AudioPacket] {
|
||||
var packets: [AudioPacket] = []
|
||||
|
||||
while let packet = nextChunk() {
|
||||
packets.append(packet)
|
||||
}
|
||||
|
||||
return packets
|
||||
}
|
||||
|
||||
public func flushRemaining() -> AudioPacket? {
|
||||
guard !buffer.isEmpty else { return nil }
|
||||
|
||||
let packet = AudioPacket(
|
||||
timestamp: Date(),
|
||||
duration: 0.0, // Unknown duration for final chunk
|
||||
peakAmplitude: 0.0,
|
||||
rawAudioData: buffer
|
||||
)
|
||||
|
||||
buffer.removeAll()
|
||||
return packet
|
||||
}
|
||||
|
||||
private func nextChunk() -> AudioPacket? {
|
||||
let bytesPerFrame = Int(streamFormat.mBytesPerFrame)
|
||||
let samplesPerChunk = Int(streamFormat.mSampleRate * targetChunkDuration)
|
||||
let bytesPerChunk = samplesPerChunk * bytesPerFrame
|
||||
|
||||
guard buffer.count >= bytesPerChunk else { return nil }
|
||||
|
||||
let chunkData = buffer.prefix(bytesPerChunk)
|
||||
|
||||
let packet = AudioPacket(
|
||||
timestamp: Date(),
|
||||
duration: Double(samplesPerChunk) / streamFormat.mSampleRate,
|
||||
peakAmplitude: 0.0, // No analysis in raw mode
|
||||
rawAudioData: Data(chunkData)
|
||||
)
|
||||
|
||||
buffer.removeFirst(bytesPerChunk)
|
||||
return packet
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import AVFoundation
|
||||
import CoreAudio
|
||||
import Foundation
|
||||
|
||||
/// Simple audio format converter using AVFoundation
|
||||
public class AudioFormatConverter {
|
||||
private let avConverter: AVAudioConverter
|
||||
private let sourceFormat: AVAudioFormat
|
||||
private let targetFormat: AVAudioFormat
|
||||
|
||||
public init(sourceFormat: AudioStreamBasicDescription, targetFormat: AudioStreamBasicDescription)
|
||||
throws
|
||||
{
|
||||
var mutableSourceFormat = sourceFormat
|
||||
var mutableTargetFormat = targetFormat
|
||||
|
||||
guard let sourceAVFormat = AVAudioFormat(streamDescription: &mutableSourceFormat),
|
||||
let targetAVFormat = AVAudioFormat(streamDescription: &mutableTargetFormat)
|
||||
else {
|
||||
throw AudioConverterError.invalidFormat
|
||||
}
|
||||
|
||||
guard let converter = AVAudioConverter(from: sourceAVFormat, to: targetAVFormat) else {
|
||||
throw AudioConverterError.creationFailed
|
||||
}
|
||||
|
||||
self.sourceFormat = sourceAVFormat
|
||||
self.targetFormat = targetAVFormat
|
||||
self.avConverter = converter
|
||||
|
||||
Logger.debug(
|
||||
"Audio converter created",
|
||||
context: [
|
||||
"source_sample_rate": String(sourceAVFormat.sampleRate),
|
||||
"target_sample_rate": String(targetAVFormat.sampleRate),
|
||||
"source_channels": String(sourceAVFormat.channelCount),
|
||||
"target_channels": String(targetAVFormat.channelCount),
|
||||
])
|
||||
|
||||
// Warn about upsampling once during initialization
|
||||
if targetAVFormat.sampleRate > sourceAVFormat.sampleRate {
|
||||
Logger.info(
|
||||
"Upsampling audio - this doesn't add frequency content above the original Nyquist limit",
|
||||
context: [
|
||||
"source_rate": String(sourceAVFormat.sampleRate),
|
||||
"target_rate": String(targetAVFormat.sampleRate),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the target format as AudioStreamBasicDescription
|
||||
public var targetFormatDescription: AudioStreamBasicDescription {
|
||||
return targetFormat.streamDescription.pointee
|
||||
}
|
||||
|
||||
public func transform(_ packet: AudioPacket) -> AudioPacket {
|
||||
let inputData = packet.rawAudioData
|
||||
|
||||
// Short-circuit if no conversion needed
|
||||
if sourceFormat.sampleRate == targetFormat.sampleRate {
|
||||
return packet
|
||||
}
|
||||
|
||||
// Calculate frame counts
|
||||
let inputFrameCount =
|
||||
inputData.count / Int(sourceFormat.streamDescription.pointee.mBytesPerFrame)
|
||||
let outputFrameCount = Int(
|
||||
Double(inputFrameCount) * (targetFormat.sampleRate / sourceFormat.sampleRate))
|
||||
|
||||
// Create input buffer
|
||||
guard
|
||||
let inputBuffer = AVAudioPCMBuffer(
|
||||
pcmFormat: sourceFormat, frameCapacity: AVAudioFrameCount(inputFrameCount))
|
||||
else {
|
||||
Logger.error("Failed to create input buffer")
|
||||
return packet
|
||||
}
|
||||
|
||||
// Copy input data to buffer
|
||||
inputData.withUnsafeBytes { bytes in
|
||||
let dest = inputBuffer.audioBufferList.pointee.mBuffers.mData!
|
||||
dest.copyMemory(from: bytes.baseAddress!, byteCount: inputData.count)
|
||||
}
|
||||
inputBuffer.frameLength = AVAudioFrameCount(inputFrameCount)
|
||||
|
||||
// Create output buffer
|
||||
guard
|
||||
let outputBuffer = AVAudioPCMBuffer(
|
||||
pcmFormat: targetFormat, frameCapacity: AVAudioFrameCount(outputFrameCount))
|
||||
else {
|
||||
Logger.error("Failed to create output buffer")
|
||||
return packet
|
||||
}
|
||||
|
||||
// Perform conversion - simpler approach
|
||||
var error: NSError?
|
||||
|
||||
let status = avConverter.convert(to: outputBuffer, error: &error) {
|
||||
requestedPackets, outStatus in
|
||||
// Always provide our input buffer and let converter manage it
|
||||
outStatus.pointee = .haveData
|
||||
return inputBuffer
|
||||
}
|
||||
|
||||
// Check if conversion produced output (regardless of status code)
|
||||
guard outputBuffer.frameLength > 0 else {
|
||||
Logger.error(
|
||||
"Audio conversion produced no output",
|
||||
context: [
|
||||
"status": String(describing: status),
|
||||
"error": String(describing: error),
|
||||
"input_frames": String(inputBuffer.frameLength),
|
||||
"output_capacity": String(outputBuffer.frameCapacity),
|
||||
])
|
||||
return packet
|
||||
}
|
||||
|
||||
// Extract converted data
|
||||
let outputData = Data(
|
||||
bytes: outputBuffer.audioBufferList.pointee.mBuffers.mData!,
|
||||
count: Int(outputBuffer.frameLength * targetFormat.streamDescription.pointee.mBytesPerFrame))
|
||||
|
||||
// Return new packet with converted audio (keeping original metadata for simplicity)
|
||||
return AudioPacket(
|
||||
timestamp: packet.timestamp,
|
||||
duration: packet.duration,
|
||||
peakAmplitude: packet.peakAmplitude,
|
||||
rawAudioData: outputData
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience Constructors
|
||||
|
||||
extension AudioFormatConverter {
|
||||
/// Create a converter to a specific sample rate with mono PCM 16-bit output
|
||||
/// Since the tap already converts to mono, we hardcode channels to 1
|
||||
public static func toSampleRate(
|
||||
_ sampleRate: Double, from sourceFormat: AudioStreamBasicDescription
|
||||
) throws -> AudioFormatConverter {
|
||||
var targetFormat = AudioStreamBasicDescription()
|
||||
targetFormat.mSampleRate = sampleRate
|
||||
targetFormat.mFormatID = kAudioFormatLinearPCM
|
||||
targetFormat.mFormatFlags = kAudioFormatFlagIsPacked | kAudioFormatFlagIsSignedInteger
|
||||
targetFormat.mBytesPerPacket = 2
|
||||
targetFormat.mFramesPerPacket = 1
|
||||
targetFormat.mBytesPerFrame = 2
|
||||
targetFormat.mChannelsPerFrame = 1 // Always mono since tap handles this
|
||||
targetFormat.mBitsPerChannel = 16
|
||||
|
||||
return try AudioFormatConverter(sourceFormat: sourceFormat, targetFormat: targetFormat)
|
||||
}
|
||||
|
||||
/// Common sample rates for validation
|
||||
public static let supportedSampleRates: [Double] = [
|
||||
8000, 16000, 22050, 24000, 32000, 44100, 48000,
|
||||
]
|
||||
|
||||
/// Validate if a sample rate is supported
|
||||
public static func isValidSampleRate(_ sampleRate: Double) -> Bool {
|
||||
return supportedSampleRates.contains(sampleRate)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Types
|
||||
|
||||
// AudioConverterError moved to Sources/Core/Errors/AudioTeeErrors.swift
|
||||
@@ -0,0 +1,54 @@
|
||||
import AudioToolbox
|
||||
import CoreAudio
|
||||
import Foundation
|
||||
|
||||
public class AudioFormatManager {
|
||||
public static func getDeviceFormat(deviceID: AudioObjectID) -> AudioStreamBasicDescription {
|
||||
var propertyAddress = getPropertyAddress(
|
||||
selector: kAudioDevicePropertyStreamFormat,
|
||||
scope: kAudioDevicePropertyScopeInput)
|
||||
var propertySize = UInt32(MemoryLayout<AudioStreamBasicDescription>.stride)
|
||||
var streamFormat = AudioStreamBasicDescription()
|
||||
let status = AudioObjectGetPropertyData(
|
||||
deviceID, &propertyAddress, 0, nil, &propertySize, &streamFormat)
|
||||
|
||||
guard status == noErr else {
|
||||
fatalError("Failed to get stream format: \(status)")
|
||||
}
|
||||
|
||||
return streamFormat
|
||||
}
|
||||
|
||||
static func createMetadata(for format: AudioStreamBasicDescription) -> AudioStreamMetadata {
|
||||
return AudioStreamMetadata(
|
||||
sampleRate: format.mSampleRate,
|
||||
channelsPerFrame: format.mChannelsPerFrame,
|
||||
bitsPerChannel: format.mBitsPerChannel,
|
||||
isFloat: format.mFormatFlags & kAudioFormatFlagIsFloat != 0,
|
||||
captureMode: "audio",
|
||||
deviceName: nil, // TODO: Get device name if needed
|
||||
deviceUID: nil, // TODO: Get device UID if needed
|
||||
encoding: format.mFormatFlags & kAudioFormatFlagIsFloat != 0 ? "pcm_f32le" : "pcm_s16le"
|
||||
)
|
||||
}
|
||||
|
||||
public static func writeMetadata(for format: AudioStreamBasicDescription) {
|
||||
let metadata = createMetadata(for: format)
|
||||
Logger.writeMessage(.metadata, data: metadata)
|
||||
Logger.writeMessage(.streamStart, data: Optional<String>.none)
|
||||
}
|
||||
|
||||
public static func logFormatInfo(_ format: AudioStreamBasicDescription) {
|
||||
Logger.debug(
|
||||
"Using device's native format",
|
||||
context: [
|
||||
"channels": String(format.mChannelsPerFrame),
|
||||
"sample_rate": String(format.mSampleRate),
|
||||
"bits_per_channel": String(format.mBitsPerChannel),
|
||||
"format_id": String(format.mFormatID),
|
||||
"format_flags": String(format: "0x%08x", format.mFormatFlags),
|
||||
"bytes_per_frame": String(format.mBytesPerFrame),
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
|
||||
public struct AudioPacket {
|
||||
public let timestamp: Date
|
||||
public let duration: Double
|
||||
public let peakAmplitude: Float // useful for level monitoring
|
||||
public let rawAudioData: Data
|
||||
|
||||
public init(
|
||||
timestamp: Date,
|
||||
duration: Double,
|
||||
peakAmplitude: Float,
|
||||
rawAudioData: Data
|
||||
) {
|
||||
self.timestamp = timestamp
|
||||
self.duration = duration
|
||||
self.peakAmplitude = peakAmplitude
|
||||
self.rawAudioData = rawAudioData
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import AudioToolbox
|
||||
import CoreAudio
|
||||
import Foundation
|
||||
|
||||
public class AudioRecorder {
|
||||
private var deviceID: AudioObjectID
|
||||
private var ioProcID: AudioDeviceIOProcID?
|
||||
private var sourceFormat: AudioStreamBasicDescription?
|
||||
private var finalFormat: AudioStreamBasicDescription?
|
||||
private var audioBuffer: AudioBuffer?
|
||||
private var outputHandler: AudioOutputHandler
|
||||
private var converter: AudioFormatConverter?
|
||||
private var chunkDuration: Double
|
||||
|
||||
init(
|
||||
deviceID: AudioObjectID, outputHandler: AudioOutputHandler, convertToSampleRate: Double? = nil,
|
||||
chunkDuration: Double = 0.2
|
||||
) {
|
||||
self.deviceID = deviceID
|
||||
self.outputHandler = outputHandler
|
||||
self.chunkDuration = chunkDuration
|
||||
|
||||
// Get source format and set up conversion if requested
|
||||
let sourceFormat = AudioFormatManager.getDeviceFormat(deviceID: deviceID)
|
||||
self.sourceFormat = sourceFormat
|
||||
|
||||
if let targetSampleRate = convertToSampleRate {
|
||||
// Validate sample rate
|
||||
guard AudioFormatConverter.isValidSampleRate(targetSampleRate) else {
|
||||
Logger.error("Invalid sample rate", context: ["sample_rate": String(targetSampleRate)])
|
||||
self.converter = nil
|
||||
self.finalFormat = sourceFormat
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let converter = try AudioFormatConverter.toSampleRate(targetSampleRate, from: sourceFormat)
|
||||
self.converter = converter
|
||||
self.finalFormat = converter.targetFormatDescription
|
||||
Logger.info(
|
||||
"Audio conversion enabled", context: ["target_sample_rate": String(targetSampleRate)])
|
||||
} catch {
|
||||
Logger.error(
|
||||
"Failed to create audio converter, using original format",
|
||||
context: ["error": String(describing: error)])
|
||||
self.converter = nil
|
||||
self.finalFormat = sourceFormat
|
||||
}
|
||||
} else {
|
||||
self.converter = nil
|
||||
self.finalFormat = sourceFormat
|
||||
}
|
||||
}
|
||||
|
||||
func startRecording() {
|
||||
Logger.debug("Starting audio recording")
|
||||
|
||||
guard let sourceFormat = sourceFormat, let finalFormat = finalFormat else {
|
||||
fatalError("Audio formats not initialized")
|
||||
}
|
||||
|
||||
// Set up the audio buffer using source format and configurable chunk duration
|
||||
self.audioBuffer = AudioBuffer(format: sourceFormat, chunkDuration: chunkDuration)
|
||||
|
||||
// Log format info and send metadata for FINAL format
|
||||
AudioFormatManager.logFormatInfo(finalFormat)
|
||||
let metadata = AudioFormatManager.createMetadata(for: finalFormat)
|
||||
outputHandler.handleMetadata(metadata)
|
||||
outputHandler.handleStreamStart()
|
||||
|
||||
// Set up and start the IO proc
|
||||
setupAndStartIOProc()
|
||||
|
||||
Logger.info("Audio device started successfully")
|
||||
}
|
||||
|
||||
// FIXME: note to self, what about installTap? Would require audio engine and a node?
|
||||
private func setupAndStartIOProc() {
|
||||
Logger.debug("Creating IO proc")
|
||||
var status = AudioDeviceCreateIOProcID(
|
||||
deviceID,
|
||||
{
|
||||
(inDevice, inNow, inInputData, inInputTime, outOutputData, inOutputTime, inClientData)
|
||||
-> OSStatus in
|
||||
let recorder = Unmanaged<AudioRecorder>.fromOpaque(inClientData!).takeUnretainedValue()
|
||||
return recorder.processAudio(inInputData)
|
||||
},
|
||||
Unmanaged.passUnretained(self).toOpaque(),
|
||||
&ioProcID
|
||||
)
|
||||
|
||||
guard status == noErr else {
|
||||
fatalError("Failed to create IO proc: \(status)")
|
||||
}
|
||||
|
||||
Logger.debug("Starting audio device")
|
||||
status = AudioDeviceStart(deviceID, ioProcID)
|
||||
|
||||
if status != noErr {
|
||||
cleanupIOProc()
|
||||
fatalError("Failed to start audio device: \(status). Device ID: \(deviceID)")
|
||||
}
|
||||
}
|
||||
|
||||
private func processAudio(_ inputData: UnsafePointer<AudioBufferList>) -> OSStatus {
|
||||
let bufferList = inputData.pointee
|
||||
let firstBuffer = bufferList.mBuffers
|
||||
|
||||
guard firstBuffer.mData != nil && firstBuffer.mDataByteSize > 0 else {
|
||||
"Warning: Received empty audio buffer".print(to: .standardError)
|
||||
return noErr
|
||||
}
|
||||
|
||||
// Append raw audio data to buffer
|
||||
let audioData = Data(bytes: firstBuffer.mData!, count: Int(firstBuffer.mDataByteSize))
|
||||
audioBuffer?.append(audioData)
|
||||
|
||||
// Process and send complete chunks, applying conversion if needed
|
||||
audioBuffer?.processChunks().forEach { packet in
|
||||
let processedPacket = converter?.transform(packet) ?? packet
|
||||
outputHandler.handleAudioPacket(processedPacket)
|
||||
}
|
||||
|
||||
return noErr
|
||||
}
|
||||
|
||||
func stopRecording() {
|
||||
// Send any remaining buffered audio, applying conversion if needed
|
||||
if let finalPacket = audioBuffer?.flushRemaining() {
|
||||
let processedPacket = converter?.transform(finalPacket) ?? finalPacket
|
||||
outputHandler.handleAudioPacket(processedPacket)
|
||||
}
|
||||
|
||||
outputHandler.handleStreamStop()
|
||||
cleanupIOProc()
|
||||
}
|
||||
|
||||
private func cleanupIOProc() {
|
||||
if let ioProcID = ioProcID {
|
||||
AudioDeviceStop(deviceID, ioProcID)
|
||||
AudioDeviceDestroyIOProcID(deviceID, ioProcID)
|
||||
self.ioProcID = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
|
||||
public struct AudioStreamMetadata: Codable {
|
||||
public let sampleRate: Double
|
||||
public let channelsPerFrame: UInt32
|
||||
public let bitsPerChannel: UInt32
|
||||
public let isFloat: Bool
|
||||
public let captureMode: String
|
||||
public let deviceName: String?
|
||||
public let deviceUID: String?
|
||||
public let encoding: String
|
||||
|
||||
public enum CodingKeys: String, CodingKey {
|
||||
case sampleRate = "sample_rate"
|
||||
case channelsPerFrame = "channels_per_frame"
|
||||
case bitsPerChannel = "bits_per_channel"
|
||||
case isFloat = "is_float"
|
||||
case captureMode = "capture_mode"
|
||||
case deviceName = "device_name"
|
||||
case deviceUID = "device_uid"
|
||||
case encoding
|
||||
}
|
||||
|
||||
public init(
|
||||
sampleRate: Double, channelsPerFrame: UInt32, bitsPerChannel: UInt32, isFloat: Bool,
|
||||
captureMode: String, deviceName: String?, deviceUID: String?, encoding: String
|
||||
) {
|
||||
self.sampleRate = sampleRate
|
||||
self.channelsPerFrame = channelsPerFrame
|
||||
self.bitsPerChannel = bitsPerChannel
|
||||
self.isFloat = isFloat
|
||||
self.captureMode = captureMode
|
||||
self.deviceName = deviceName
|
||||
self.deviceUID = deviceUID
|
||||
self.encoding = encoding
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import AVFoundation
|
||||
import AudioToolbox
|
||||
import CoreAudio
|
||||
import Foundation
|
||||
|
||||
class AudioTapManager {
|
||||
private var tapID: AudioObjectID?
|
||||
private var deviceID: AudioObjectID?
|
||||
|
||||
init() {
|
||||
// Empty init - setup happens in setupAudioTap()
|
||||
}
|
||||
|
||||
deinit {
|
||||
Logger.debug("Cleaning up audio tap manager")
|
||||
|
||||
if let tapID = tapID {
|
||||
AudioHardwareDestroyProcessTap(tapID)
|
||||
self.tapID = nil
|
||||
}
|
||||
|
||||
if let deviceID = deviceID {
|
||||
AudioHardwareDestroyAggregateDevice(deviceID)
|
||||
self.deviceID = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets up the audio tap and aggregate device
|
||||
func setupAudioTap(with config: TapConfiguration) throws {
|
||||
Logger.debug("Setting up audio tap manager")
|
||||
|
||||
tapID = try createSystemAudioTap(with: config)
|
||||
deviceID = try createAggregateDevice()
|
||||
|
||||
guard let tapID = tapID, let deviceID = deviceID else {
|
||||
throw AudioTeeError.setupFailed
|
||||
}
|
||||
|
||||
try addTapToAggregateDevice(tapID: tapID, deviceID: deviceID)
|
||||
|
||||
Logger.debug("Audio tap manager setup complete")
|
||||
}
|
||||
|
||||
/// Returns the aggregate device ID for recording
|
||||
func getDeviceID() -> AudioObjectID? {
|
||||
return deviceID
|
||||
}
|
||||
|
||||
private func createSystemAudioTap(with config: TapConfiguration) throws -> AudioObjectID {
|
||||
Logger.debug("Creating tap description")
|
||||
// Create a tap description
|
||||
let description = CATapDescription()
|
||||
|
||||
// Configure the tap to capture all system audio
|
||||
description.name = "audiotee-tap"
|
||||
description.processes = try translatePIDsToProcessObjects(config.processes) // Properly translate PIDs
|
||||
description.isPrivate = true
|
||||
description.muteBehavior = config.muteBehavior.coreAudioValue
|
||||
description.isMixdown = true
|
||||
description.isMono = true
|
||||
description.isExclusive = config.isExclusive
|
||||
description.deviceUID = nil // system default
|
||||
description.stream = 0 // first stream of output device
|
||||
|
||||
Logger.debug(
|
||||
"Tap description configured",
|
||||
context: [
|
||||
"name": description.name,
|
||||
"processes": String(describing: config.processes),
|
||||
"private": String(description.isPrivate),
|
||||
"mute": String(describing: description.muteBehavior),
|
||||
"mixdown": String(description.isMixdown),
|
||||
"mono": String(description.isMono),
|
||||
"exclusive": String(description.isExclusive),
|
||||
])
|
||||
|
||||
// Create the tap
|
||||
Logger.debug("Creating tap")
|
||||
var tapID = AudioObjectID(kAudioObjectUnknown)
|
||||
let status = AudioHardwareCreateProcessTap(description, &tapID)
|
||||
|
||||
Logger.debug(
|
||||
"AudioHardwareCreateProcessTap completed", context: ["status": String(status)])
|
||||
guard status == kAudioHardwareNoError else {
|
||||
Logger.error("Failed to create audio tap", context: ["status": String(status)])
|
||||
throw AudioTeeError.tapCreationFailed(status)
|
||||
}
|
||||
|
||||
// Get the format of the audio tap
|
||||
var propertyAddress = getPropertyAddress(selector: kAudioTapPropertyFormat)
|
||||
var propertySize = UInt32(MemoryLayout<AudioStreamBasicDescription>.stride)
|
||||
var streamDescription = AudioStreamBasicDescription()
|
||||
let formatStatus = AudioObjectGetPropertyData(
|
||||
tapID, &propertyAddress, 0, nil, &propertySize, &streamDescription)
|
||||
|
||||
if formatStatus == noErr {
|
||||
Logger.debug(
|
||||
"Tap format retrieved",
|
||||
context: [
|
||||
"channels": String(streamDescription.mChannelsPerFrame),
|
||||
"sample_rate": String(Int(streamDescription.mSampleRate)),
|
||||
])
|
||||
}
|
||||
|
||||
return tapID
|
||||
}
|
||||
|
||||
private func createAggregateDevice() throws -> AudioObjectID {
|
||||
let uid = UUID().uuidString
|
||||
let description =
|
||||
[
|
||||
kAudioAggregateDeviceNameKey: "audiotee-aggregate-device",
|
||||
kAudioAggregateDeviceUIDKey: uid,
|
||||
kAudioAggregateDeviceSubDeviceListKey: [] as CFArray,
|
||||
kAudioAggregateDeviceMasterSubDeviceKey: 0,
|
||||
kAudioAggregateDeviceIsPrivateKey: true,
|
||||
kAudioAggregateDeviceIsStackedKey: false,
|
||||
] as [String: Any]
|
||||
|
||||
var deviceID: AudioObjectID = 0
|
||||
let status = AudioHardwareCreateAggregateDevice(description as CFDictionary, &deviceID)
|
||||
|
||||
guard status == kAudioHardwareNoError else {
|
||||
Logger.error("Failed to create aggregate device", context: ["status": String(status)])
|
||||
throw AudioTeeError.aggregateDeviceCreationFailed(status)
|
||||
}
|
||||
|
||||
return deviceID
|
||||
}
|
||||
|
||||
private func addTapToAggregateDevice(tapID: AudioObjectID, deviceID: AudioObjectID) throws {
|
||||
// Get the tap's UID
|
||||
var propertyAddress = getPropertyAddress(selector: kAudioTapPropertyUID)
|
||||
var propertySize = UInt32(MemoryLayout<CFString>.stride)
|
||||
var tapUID: CFString = "" as CFString
|
||||
_ = withUnsafeMutablePointer(to: &tapUID) { tapUID in
|
||||
AudioObjectGetPropertyData(tapID, &propertyAddress, 0, nil, &propertySize, tapUID)
|
||||
}
|
||||
|
||||
// Add the tap to the aggregate device
|
||||
propertyAddress = getPropertyAddress(
|
||||
selector: kAudioAggregateDevicePropertyTapList)
|
||||
let tapArray = [tapUID] as CFArray
|
||||
propertySize = UInt32(MemoryLayout<CFArray>.stride)
|
||||
|
||||
let status = withUnsafePointer(to: tapArray) { ptr in
|
||||
AudioObjectSetPropertyData(deviceID, &propertyAddress, 0, nil, propertySize, ptr)
|
||||
}
|
||||
|
||||
guard status == kAudioHardwareNoError else {
|
||||
Logger.error(
|
||||
"Failed to add tap to aggregate device", context: ["status": String(status)])
|
||||
throw AudioTeeError.tapAssignmentFailed(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Core AudioTee Errors
|
||||
|
||||
public enum AudioTeeError: Error {
|
||||
case setupFailed
|
||||
case tapCreationFailed(OSStatus)
|
||||
case aggregateDeviceCreationFailed(OSStatus)
|
||||
case tapAssignmentFailed(OSStatus)
|
||||
case pidTranslationFailed([Int32])
|
||||
}
|
||||
|
||||
// MARK: - Audio Format Conversion Errors
|
||||
|
||||
public enum AudioConverterError: Error {
|
||||
case invalidFormat
|
||||
case creationFailed
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Protocol for handling audio output in different formats
|
||||
public protocol AudioOutputHandler {
|
||||
func handleAudioPacket(_ packet: AudioPacket)
|
||||
func handleMetadata(_ metadata: AudioStreamMetadata)
|
||||
func handleStreamStart()
|
||||
func handleStreamStop()
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
|
||||
/// Auto-detecting output handler based on TTY
|
||||
public class AutoAudioOutputHandler: AudioOutputHandler {
|
||||
private let handler: AudioOutputHandler
|
||||
|
||||
public init() {
|
||||
// Auto-detect based on whether stdout is a terminal
|
||||
if isatty(STDOUT_FILENO) != 0 {
|
||||
handler = JSONAudioOutputHandler()
|
||||
} else {
|
||||
handler = BinaryAudioOutputHandler()
|
||||
}
|
||||
}
|
||||
|
||||
public func handleAudioPacket(_ packet: AudioPacket) {
|
||||
handler.handleAudioPacket(packet)
|
||||
}
|
||||
|
||||
public func handleMetadata(_ metadata: AudioStreamMetadata) {
|
||||
handler.handleMetadata(metadata)
|
||||
}
|
||||
|
||||
public func handleStreamStart() {
|
||||
handler.handleStreamStart()
|
||||
}
|
||||
|
||||
public func handleStreamStop() {
|
||||
handler.handleStreamStop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
|
||||
/// Binary output with JSON headers (pipe-optimised)
|
||||
public class BinaryAudioOutputHandler: AudioOutputHandler {
|
||||
public init() {}
|
||||
|
||||
public func handleAudioPacket(_ packet: AudioPacket) {
|
||||
// Create metadata without the audio data
|
||||
let metadata = AudioPacketMetadata(from: packet)
|
||||
|
||||
// Write JSON metadata line
|
||||
Logger.writeMessage(.audio, data: metadata)
|
||||
|
||||
// Write raw binary audio data directly to stdout
|
||||
FileHandle.standardOutput.write(packet.rawAudioData)
|
||||
}
|
||||
|
||||
public func handleMetadata(_ metadata: AudioStreamMetadata) {
|
||||
Logger.writeMessage(.metadata, data: metadata)
|
||||
}
|
||||
|
||||
public func handleStreamStart() {
|
||||
Logger.writeMessage(.streamStart, data: Optional<String>.none)
|
||||
}
|
||||
|
||||
public func handleStreamStop() {
|
||||
Logger.writeMessage(.streamStop, data: Optional<String>.none)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
|
||||
/// Base64-encoded JSON output (terminal-safe)
|
||||
public class JSONAudioOutputHandler: AudioOutputHandler {
|
||||
public init() {}
|
||||
|
||||
public func handleAudioPacket(_ packet: AudioPacket) {
|
||||
let jsonPacket = JSONAudioPacket(from: packet)
|
||||
Logger.writeMessage(.audio, data: jsonPacket)
|
||||
}
|
||||
|
||||
public func handleMetadata(_ metadata: AudioStreamMetadata) {
|
||||
Logger.writeMessage(.metadata, data: metadata)
|
||||
}
|
||||
|
||||
public func handleStreamStart() {
|
||||
Logger.writeMessage(.streamStart, data: Optional<String>.none)
|
||||
}
|
||||
|
||||
public func handleStreamStop() {
|
||||
Logger.writeMessage(.streamStop, data: Optional<String>.none)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
|
||||
// Unified message types for all AudioTee output
|
||||
public enum MessageType: String, Codable {
|
||||
// Stream lifecycle
|
||||
case metadata
|
||||
case streamStart = "stream_start"
|
||||
case streamStop = "stream_stop"
|
||||
|
||||
// Audio data
|
||||
case audio
|
||||
|
||||
// Logging
|
||||
case info
|
||||
case error
|
||||
case debug
|
||||
}
|
||||
|
||||
// Base message envelope that wraps all outputs
|
||||
public struct Message<T: Codable>: Codable {
|
||||
public let timestamp: Date
|
||||
public let type: MessageType
|
||||
public let data: T?
|
||||
|
||||
public enum CodingKeys: String, CodingKey {
|
||||
case timestamp
|
||||
case type = "message_type"
|
||||
case data
|
||||
}
|
||||
|
||||
public init(type: MessageType, data: T? = nil) {
|
||||
self.timestamp = Date()
|
||||
self.type = type
|
||||
self.data = data
|
||||
}
|
||||
}
|
||||
|
||||
// Simple log data for logging messages
|
||||
public struct LogData: Codable {
|
||||
public let message: String
|
||||
public let context: [String: String]?
|
||||
|
||||
public init(message: String, context: [String: String]? = nil) {
|
||||
self.message = message
|
||||
self.context = context
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Foundation
|
||||
|
||||
/// JSON-serializable version of AudioPacket with base64-encoded audio data
|
||||
public struct JSONAudioPacket: Codable {
|
||||
public let timestamp: Date
|
||||
public let duration: Double
|
||||
public let peakAmplitude: Float
|
||||
public let audioData: String // base64 encoded audio data
|
||||
|
||||
public enum CodingKeys: String, CodingKey {
|
||||
case timestamp
|
||||
case duration
|
||||
case peakAmplitude = "peak_amplitude"
|
||||
case audioData = "audio_data"
|
||||
}
|
||||
|
||||
public init(from packet: AudioPacket) {
|
||||
self.timestamp = packet.timestamp
|
||||
self.duration = packet.duration
|
||||
self.peakAmplitude = packet.peakAmplitude
|
||||
self.audioData = packet.rawAudioData.base64EncodedString()
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata-only packet for binary output (without base64 audio data)
|
||||
public struct AudioPacketMetadata: Codable {
|
||||
public let timestamp: Date
|
||||
public let duration: Double
|
||||
public let peakAmplitude: Float
|
||||
public let audioLength: Int // Length of raw audio data in bytes
|
||||
|
||||
public enum CodingKeys: String, CodingKey {
|
||||
case timestamp
|
||||
case duration
|
||||
case peakAmplitude = "peak_amplitude"
|
||||
case audioLength = "audio_length"
|
||||
}
|
||||
|
||||
public init(from packet: AudioPacket) {
|
||||
self.timestamp = packet.timestamp
|
||||
self.duration = packet.duration
|
||||
self.peakAmplitude = packet.peakAmplitude
|
||||
self.audioLength = packet.rawAudioData.count
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
|
||||
public class Logger {
|
||||
nonisolated(unsafe) private static let dateFormatter: ISO8601DateFormatter = {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [
|
||||
.withInternetDateTime,
|
||||
.withFractionalSeconds,
|
||||
]
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static let jsonEncoder: JSONEncoder = {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .custom { date, encoder in
|
||||
var container = encoder.singleValueContainer()
|
||||
try container.encode(dateFormatter.string(from: date))
|
||||
}
|
||||
return encoder
|
||||
}()
|
||||
|
||||
// Write any message with the unified envelope
|
||||
public static func writeMessage<T: Codable>(_ type: MessageType, data: T? = nil) {
|
||||
let message = Message(type: type, data: data)
|
||||
do {
|
||||
let jsonData = try jsonEncoder.encode(message)
|
||||
FileHandle.standardOutput.write(jsonData)
|
||||
FileHandle.standardOutput.write("\n".data(using: .utf8)!)
|
||||
} catch {
|
||||
// TODO: handle at some point
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience methods for different message types
|
||||
public static func info(_ message: String, context: [String: String]? = nil) {
|
||||
let logData = LogData(message: message, context: context)
|
||||
writeMessage(.info, data: logData)
|
||||
}
|
||||
|
||||
public static func error(_ message: String, context: [String: String]? = nil) {
|
||||
let logData = LogData(message: message, context: context)
|
||||
writeMessage(.error, data: logData)
|
||||
}
|
||||
|
||||
public static func debug(_ message: String, context: [String: String]? = nil) {
|
||||
let logData = LogData(message: message, context: context)
|
||||
writeMessage(.debug, data: logData)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import AVFoundation
|
||||
import AudioToolbox
|
||||
import CoreAudio
|
||||
import Foundation
|
||||
|
||||
// MARK: - Audio Device Utilities
|
||||
|
||||
/// Checks if an audio device is valid and alive
|
||||
func isAudioDeviceValid(_ deviceID: AudioObjectID) -> Bool {
|
||||
var address = getPropertyAddress(selector: kAudioDevicePropertyDeviceIsAlive)
|
||||
|
||||
var isAlive: UInt32 = 0
|
||||
var size = UInt32(MemoryLayout<UInt32>.size)
|
||||
let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &isAlive)
|
||||
|
||||
let valid = status == kAudioHardwareNoError && isAlive == 1
|
||||
|
||||
Logger.debug(
|
||||
"Checked device validity",
|
||||
context: [
|
||||
"device_id": String(deviceID),
|
||||
"status": String(status),
|
||||
"is_alive": String(isAlive),
|
||||
"valid": String(valid),
|
||||
])
|
||||
return valid
|
||||
}
|
||||
|
||||
/// Creates an AudioObjectPropertyAddress with the given selector and optional scope/element
|
||||
func getPropertyAddress(
|
||||
selector: AudioObjectPropertySelector,
|
||||
scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal,
|
||||
element: AudioObjectPropertyElement = kAudioObjectPropertyElementMain
|
||||
) -> AudioObjectPropertyAddress {
|
||||
return AudioObjectPropertyAddress(mSelector: selector, mScope: scope, mElement: element)
|
||||
}
|
||||
|
||||
/// Translates an array of process IDs to AudioObjectIDs using Core Audio
|
||||
/// Returns an array of AudioObjectIDs for valid processes
|
||||
/// Throws an error if any PIDs cannot be translated
|
||||
func translatePIDsToProcessObjects(_ pids: [Int32]) throws -> [AudioObjectID] {
|
||||
guard !pids.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
var processObjects: [AudioObjectID] = []
|
||||
var failedPIDs: [Int32] = []
|
||||
|
||||
for pid in pids {
|
||||
var address = getPropertyAddress(selector: kAudioHardwarePropertyTranslatePIDToProcessObject)
|
||||
var processObject: AudioObjectID = 0
|
||||
var size = UInt32(MemoryLayout<AudioObjectID>.size)
|
||||
var mutablePid = pid // Create mutable copy for the API call
|
||||
|
||||
let status = AudioObjectGetPropertyData(
|
||||
AudioObjectID(kAudioObjectSystemObject),
|
||||
&address,
|
||||
UInt32(MemoryLayout<pid_t>.size),
|
||||
&mutablePid,
|
||||
&size,
|
||||
&processObject
|
||||
)
|
||||
|
||||
if status == kAudioHardwareNoError && processObject != kAudioObjectUnknown {
|
||||
processObjects.append(processObject)
|
||||
Logger.debug(
|
||||
"Translated PID to process object",
|
||||
context: [
|
||||
"pid": String(pid),
|
||||
"process_object": String(processObject),
|
||||
])
|
||||
} else {
|
||||
failedPIDs.append(pid)
|
||||
Logger.debug(
|
||||
"Failed to translate PID to process object",
|
||||
context: [
|
||||
"pid": String(pid),
|
||||
"status": String(status),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// Throw error if any PIDs failed to translate
|
||||
if !failedPIDs.isEmpty {
|
||||
throw AudioTeeError.pidTranslationFailed(failedPIDs)
|
||||
}
|
||||
|
||||
return processObjects
|
||||
}
|
||||
|
||||
extension String {
|
||||
func print(to fileHandle: FileHandle) {
|
||||
if let data = (self + "\n").data(using: .utf8) {
|
||||
fileHandle.write(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import ArgumentParser
|
||||
import AudioToolbox
|
||||
import Foundation
|
||||
|
||||
AudioTee.main()
|
||||
Reference in New Issue
Block a user