diff --git a/Package.resolved b/Package.resolved deleted file mode 100644 index 4ce6c02..0000000 --- a/Package.resolved +++ /dev/null @@ -1,15 +0,0 @@ -{ - "originHash" : "5f2b81278809343fed36ed8c17e7d6930bfd5b85261cdf5dadb17ab7ffdfc0e3", - "pins" : [ - { - "identity" : "swift-argument-parser", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser.git", - "state" : { - "revision" : "011f0c765fb46d9cac61bca19be0527e99c98c8b", - "version" : "1.5.1" - } - } - ], - "version" : 3 -} diff --git a/Package.swift b/Package.swift index a212da7..99e9b48 100644 --- a/Package.swift +++ b/Package.swift @@ -8,17 +8,7 @@ let package = Package( platforms: [ .macOS("14.2") ], - dependencies: [ - .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0") - ], targets: [ - // Targets are the basic building blocks of a package, defining a module or a test suite. - // Targets can depend on other targets in this package and products from dependencies. - .executableTarget( - name: "audiotee", - dependencies: [ - .product(name: "ArgumentParser", package: "swift-argument-parser") - ] - ) + .executableTarget(name: "audiotee") ] ) diff --git a/Sources/CLI/ArgumentParser.swift b/Sources/CLI/ArgumentParser.swift new file mode 100644 index 0000000..5917463 --- /dev/null +++ b/Sources/CLI/ArgumentParser.swift @@ -0,0 +1,234 @@ +import Foundation + +// MARK: - Error Types + +enum ArgumentParserError: Error, CustomStringConvertible { + case unknownOption(String) + case missingValue(String) + case invalidValue(String, String) + case validationFailed(String) + case helpRequested + + var description: String { + switch self { + case .unknownOption(let option): + return "Unknown option: \(option)" + case .missingValue(let option): + return "Missing value for option: \(option)" + case .invalidValue(let option, let value): + return "Invalid value '\(value)' for option: \(option)" + case .validationFailed(let message): + return message + case .helpRequested: + return "" // Help is handled separately + } + } +} + +// MARK: - Argument Configuration + +struct ArgumentConfig { + let name: String + let shortName: String? + let help: String + let isFlag: Bool + let isArray: Bool + let defaultValue: String? + + init( + name: String, shortName: String? = nil, help: String, isFlag: Bool = false, + isArray: Bool = false, defaultValue: String? = nil + ) { + self.name = name + self.shortName = shortName + self.help = help + self.isFlag = isFlag + self.isArray = isArray + self.defaultValue = defaultValue + } +} + +// MARK: - Simple Argument Parser + +class SimpleArgumentParser { + private let programName: String + private let abstract: String + private let discussion: String + private var configs: [ArgumentConfig] = [] + private var parsedValues: [String: [String]] = [:] + + init(programName: String, abstract: String, discussion: String = "") { + self.programName = programName + self.abstract = abstract + self.discussion = discussion + } + + func addOption(name: String, shortName: String? = nil, help: String, defaultValue: String? = nil) + { + configs.append( + ArgumentConfig(name: name, shortName: shortName, help: help, defaultValue: defaultValue)) + } + + func addArrayOption(name: String, shortName: String? = nil, help: String) { + configs.append(ArgumentConfig(name: name, shortName: shortName, help: help, isArray: true)) + } + + func addFlag(name: String, shortName: String? = nil, help: String) { + configs.append(ArgumentConfig(name: name, shortName: shortName, help: help, isFlag: true)) + } + + func parse(_ arguments: [String] = Array(CommandLine.arguments.dropFirst())) throws { + var i = 0 + + while i < arguments.count { + let arg = arguments[i] + + if arg == "--help" || arg == "-h" { + throw ArgumentParserError.helpRequested + } + + guard arg.hasPrefix("-") else { + throw ArgumentParserError.unknownOption(arg) + } + + let optionName = findOptionName(arg) + guard let config = findConfig(optionName) else { + throw ArgumentParserError.unknownOption(arg) + } + + if config.isFlag { + parsedValues[config.name] = ["true"] + i += 1 + } else { + // Need a value + i += 1 + guard i < arguments.count else { + throw ArgumentParserError.missingValue(arg) + } + + if config.isArray { + // Collect all values until next option or end + var values: [String] = [] + while i < arguments.count && !arguments[i].hasPrefix("-") { + values.append(arguments[i]) + i += 1 + } + if values.isEmpty { + throw ArgumentParserError.missingValue(arg) + } + parsedValues[config.name] = values + } else { + let value = arguments[i] + parsedValues[config.name] = [value] + i += 1 + } + } + } + + // Set default values for missing options + for config in configs { + if parsedValues[config.name] == nil, let defaultValue = config.defaultValue { + parsedValues[config.name] = [defaultValue] + } + } + } + + private func findOptionName(_ arg: String) -> String { + if arg.hasPrefix("--") { + return String(arg.dropFirst(2)) + } else if arg.hasPrefix("-") { + return String(arg.dropFirst(1)) + } + return arg + } + + private func findConfig(_ optionName: String) -> ArgumentConfig? { + return configs.first { config in + config.name == optionName || config.shortName == optionName + } + } + + func getValue(_ name: String, as type: T.Type) throws -> T { + guard let values = parsedValues[name], let value = values.first else { + throw ArgumentParserError.missingValue(name) + } + + return try convertValue(value, to: type, optionName: name) + } + + func getOptionalValue(_ name: String, as type: T.Type) throws -> T? { + guard let values = parsedValues[name], let value = values.first else { + return nil + } + + return try convertValue(value, to: type, optionName: name) + } + + func getArrayValue(_ name: String, as type: T.Type) throws -> [T] { + guard let values = parsedValues[name] else { + return [] + } + + return try values.map { try convertValue($0, to: type, optionName: name) } + } + + func getFlag(_ name: String) -> Bool { + return parsedValues[name]?.first == "true" + } + + private func convertValue(_ value: String, to type: T.Type, optionName: String) throws -> T { + if type == String.self { + return value as! T + } else if type == Int32.self { + guard let intValue = Int32(value) else { + throw ArgumentParserError.invalidValue(optionName, value) + } + return intValue as! T + } else if type == Double.self { + guard let doubleValue = Double(value) else { + throw ArgumentParserError.invalidValue(optionName, value) + } + return doubleValue as! T + } else if type == OutputFormat.self { + guard let format = OutputFormat(rawValue: value) else { + throw ArgumentParserError.invalidValue(optionName, value) + } + return format as! T + } + + throw ArgumentParserError.invalidValue(optionName, value) + } + + func printHelp() { + print(abstract) + + if !discussion.isEmpty { + print("\n\(discussion)") + } + + print("\nUSAGE:") + print(" \(programName) [OPTIONS]") + + let optionConfigs = configs.filter { !$0.isFlag } + let flagConfigs = configs.filter { $0.isFlag } + + if !optionConfigs.isEmpty { + print("\nOPTIONS:") + for config in optionConfigs { + let shortName = config.shortName.map { "-\($0), " } ?? "" + let defaultDesc = config.defaultValue.map { " (default: \($0))" } ?? "" + print(" \(shortName)--\(config.name) \(config.help)\(defaultDesc)") + } + } + + if !flagConfigs.isEmpty { + print("\nFLAGS:") + for config in flagConfigs { + let shortName = config.shortName.map { "-\($0), " } ?? "" + print(" \(shortName)--\(config.name) \(config.help)") + } + } + + print("\n -h, --help Show this help message") + } +} diff --git a/Sources/CLI/AudioTee.swift b/Sources/CLI/AudioTee.swift index ded0101..a4fc6f2 100644 --- a/Sources/CLI/AudioTee.swift +++ b/Sources/CLI/AudioTee.swift @@ -1,63 +1,100 @@ -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) - - 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 --format=json # Always use JSON format - audiotee --format=binary # Always use binary format - 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 - """ - ) - - @Option(name: .shortAndLong, help: "Output format") +struct AudioTee { var format: OutputFormat = .auto - - @Option( - name: .long, help: "Process IDs to include (space-separated, empty = all processes)") var includeProcesses: [Int32] = [] - - @Option( - name: .long, help: "Process IDs to exclude (space-separated)") var excludeProcesses: [Int32] = [] - - @Flag(name: .long, help: "Mute processes being tapped") var mute: Bool = false - - @Option( - name: .long, - help: "Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)") var sampleRate: Double? - - @Option( - name: .long, - help: "Audio chunk duration in seconds (default: 0.2)") var chunkDuration: Double = 0.2 + 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. + + 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) + + 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 --format=json # Always use JSON format + audiotee --format=binary # Always use binary format + 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 + """ + ) + + // Configure arguments + parser.addOption(name: "format", shortName: "f", help: "Output format", defaultValue: "auto") + 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.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") + + // Parse arguments + do { + try parser.parse() + + var audioTee = AudioTee() + + // Extract values + audioTee.format = try parser.getValue("format", as: OutputFormat.self) + 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.sampleRate = try parser.getOptionalValue("sample-rate", as: Double.self) + audioTee.chunkDuration = try parser.getValue("chunk-duration", as: Double.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 ValidationError("Cannot specify both --include-processes and --exclude-processes") + throw ArgumentParserError.validationFailed( + "Cannot specify both --include-processes and --exclude-processes") } } @@ -159,3 +196,27 @@ struct AudioTee: ParsableCommand { } } } + +// Helper for stderr output +var standardError = FileHandle.standardError + +extension FileHandle: @retroactive 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 + } + } +} diff --git a/Sources/CLI/OutputFormat.swift b/Sources/CLI/OutputFormat.swift index cd48ab6..a9646bc 100644 --- a/Sources/CLI/OutputFormat.swift +++ b/Sources/CLI/OutputFormat.swift @@ -1,6 +1,4 @@ -import ArgumentParser - -enum OutputFormat: String, CaseIterable, ExpressibleByArgument { +enum OutputFormat: String, CaseIterable { case json = "json" case binary = "binary" case auto = "auto" diff --git a/Sources/CLI/TapMuteBehavior.swift b/Sources/CLI/TapMuteBehavior.swift index 1f126a2..1934f71 100644 --- a/Sources/CLI/TapMuteBehavior.swift +++ b/Sources/CLI/TapMuteBehavior.swift @@ -1,7 +1,6 @@ -import ArgumentParser import CoreAudio -public enum TapMuteBehavior: String, CaseIterable, ExpressibleByArgument { +public enum TapMuteBehavior: String, CaseIterable { case unmuted = "unmuted" case muted = "muted" diff --git a/Sources/main.swift b/Sources/main.swift index 99b3d5a..4f1427f 100644 --- a/Sources/main.swift +++ b/Sources/main.swift @@ -1,5 +1,4 @@ -import ArgumentParser import AudioToolbox import Foundation -AudioTee.main() \ No newline at end of file +AudioTee.main()