8 Commits

Author SHA1 Message Date
Nick Payne c696fa0197 Merge branch 'main' into readiness-test 2025-07-04 08:53:17 +01:00
Nick Payne a0902f6eec remove swift-argument-parser - it was adding well over 1Mb of bloat to release builds 2025-07-02 20:58:28 +01:00
Nick Payne b7ce5e61d8 Merge branch 'main' into readiness-test 2025-07-02 16:48:35 +01:00
Nick Payne d10f16a119 if a sample rate conversion is requested, always honour it 2025-07-02 16:48:28 +01:00
Nick Payne a7be157d06 attempt to wait for device readiness 2025-07-02 16:13:38 +01:00
Nick Payne bfce968fb6 slightly better zeroes explanation. 2025-06-21 09:52:09 +01:00
Nick Payne 95f11b17c7 add a note about no audio zero encoding 2025-06-21 09:44:39 +01:00
Nick Payne 756c1b3535 a few readme clarifications 2025-06-21 09:41:20 +01:00
10 changed files with 431 additions and 99 deletions
-15
View File
@@ -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
}
+1 -11
View File
@@ -8,17 +8,7 @@ let package = Package(
platforms: [ platforms: [
.macOS("14.2") .macOS("14.2")
], ],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0")
],
targets: [ targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite. .executableTarget(name: "audiotee")
// Targets can depend on other targets in this package and products from dependencies.
.executableTarget(
name: "audiotee",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser")
]
)
] ]
) )
+12 -3
View File
@@ -1,12 +1,12 @@
# AudioTee # AudioTee
AudioTee captures your Mac's system audio output and writes PCM encoded chunks of it to `stdout` at regular intervals, either in base64-encoded JSON (good for humans, easy on terminals) or binary (good for other programs). It uses the [Core Audio taps](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) API introduced in macOS 14.2 (released in December 2023). You can do whatever you want with this audio - stream it somewhere else, save it to disk, visualize it, etc. AudioTee captures your Mac's system audio output and writes it in PCM encoded chunks to `stdout` at regular intervals, either in base64-encoded JSON (good for humans, easy on terminals) or binary (good for other programs). It uses the [Core Audio taps](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) API introduced in macOS 14.2 (released in December 2023). You can do whatever you want with this audio - stream it somewhere else, save it to disk, visualize it, etc.
By default, it taps the audio output from **all** running process and selects the most appropriate audio chunk output format to use based on the presence of a tty. Tap output is forced to `mono` (not configurable) and preserves your output device's sample rate unless you pass a `--sample-rate` flag. Only the default output device is currently supported. By default, it taps the audio output from **all** running process and selects the most appropriate audio chunk output format to use based on the presence of a tty. Tap output is forced to `mono` (not yet configurable) and preserves your output device's sample rate (configurable via the `--sample-rate` flag). Only the default output device is currently supported.
My original (and so far only) use case is streaming audio to a parent process which communicates with a realtime ASR service, so AudioTee makes some design decisions you might not agree with. Open an issue or a PR and we can talk about them. I'm also no Swift developer, so contributions improving codebase idioms and general hygiene are welcome. My original (and so far only) use case is streaming audio to a parent process which communicates with a realtime ASR service, so AudioTee makes some design decisions you might not agree with. Open an issue or a PR and we can talk about them. I'm also no Swift developer, so contributions improving codebase idioms and general hygiene are welcome.
Recording system audio is harder than it should be on macOS, and folks often wrestle with outdated advice and poorly documented APIs. It's a boring problem which stands in the way of lots of fun applications. There's more code here than you need to solve this problem yourself: the main classes of interest are probably `Core/AudioTapManager` and `Core/AudioRecorder`. Everything's wired together in `CLI/AudioTee`. The rest is just CLI configuration support, output formatting logic, and some utility functions you could probably live without. Recording system audio is harder than it should be on macOS, and folks often wrestle with outdated advice and poorly documented APIs. It's a boring problem which stands in the way of lots of fun applications. There's more code here than you need to solve this problem yourself: the main classes of interest are probably [`Core/AudioTapManager`](https://github.com/makeusabrew/audiotee/blob/main/Sources/Core/AudioTapManager.swift) and [`Core/AudioRecorder`](https://github.com/makeusabrew/audiotee/blob/main/Sources/Core/AudioRecorder.swift). Everything's wired together in [`CLI/AudioTee`](https://github.com/makeusabrew/audiotee/blob/main/Sources/CLI/AudioTee.swift). The rest is just CLI configuration support, output formatting logic, and some utility functions you could probably live without.
## Requirements ## Requirements
@@ -16,12 +16,16 @@ Recording system audio is harder than it should be on macOS, and folks often wre
## Quick start ## Quick start
The following will start capturing audio output from all running programs and write base64-encoded chunks of it to your terminal every 200ms:
```bash ```bash
git clone git@github.com:makeusabrew/audiotee.git git clone git@github.com:makeusabrew/audiotee.git
cd audiotee cd audiotee
swift run swift run
``` ```
If you're not playing audio when you run it, you'll just see packets full of `AAAAA...` - the base64 version of a bunch of zeroes.
## Build ## Build
```bash ```bash
@@ -48,6 +52,9 @@ Replace the path below with `.build/<arch>/<target>/audiotee`, e.g. `build/arm64
### Audio conversion ### Audio conversion
Note that performing sample rate conversion will also convert the output bit depth to
16-bit - assuming an original depth of 32-bit this results in a loss of dynamic range in exchange for half the output chunk size. For ASR services, 16-bit is sufficient, but in any case it's a behaviour worth being aware of.
```bash ```bash
# Convert to 16kHz mono (useful for ASR services) # Convert to 16kHz mono (useful for ASR services)
./audiotee --sample-rate 16000 ./audiotee --sample-rate 16000
@@ -60,6 +67,8 @@ Replace the path below with `.build/<arch>/<target>/audiotee`, e.g. `build/arm64
For now, only a subset of the `CATapDescription` (https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) interface is exposed. PRs welcome. For now, only a subset of the `CATapDescription` (https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps) interface is exposed. PRs welcome.
Note that trying to include or exclude a PID which isn't currently playing audio will probably fail to convert to an Audio Object and will cause the process to exit.
```bash ```bash
# Tap all system audio (default) # Tap all system audio (default)
./audiotee ./audiotee
+234
View File
@@ -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<T>(_ 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<T>(_ 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<T>(_ 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<T>(_ 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")
}
}
+86 -25
View File
@@ -1,9 +1,19 @@
import ArgumentParser
import CoreAudio import CoreAudio
import Foundation import Foundation
struct AudioTee: ParsableCommand { struct AudioTee {
static let configuration = CommandConfiguration( var format: OutputFormat = .auto
var includeProcesses: [Int32] = []
var excludeProcesses: [Int32] = []
var mute: Bool = false
var sampleRate: Double?
var chunkDuration: Double = 0.2
init() {}
static func main() {
let parser = SimpleArgumentParser(
programName: "audiotee",
abstract: "Capture system audio and stream to stdout", abstract: "Capture system audio and stream to stdout",
discussion: """ discussion: """
AudioTee captures system audio using Core Audio taps and streams it as structured output. AudioTee captures system audio using Core Audio taps and streams it as structured output.
@@ -31,33 +41,60 @@ struct AudioTee: ParsableCommand {
""" """
) )
@Option(name: .shortAndLong, help: "Output format") // Configure arguments
var format: OutputFormat = .auto parser.addOption(name: "format", shortName: "f", help: "Output format", defaultValue: "auto")
parser.addArrayOption(
@Option( name: "include-processes",
name: .long, help: "Process IDs to include (space-separated, empty = all processes)") help: "Process IDs to include (space-separated, empty = all processes)")
var includeProcesses: [Int32] = [] parser.addArrayOption(
name: "exclude-processes", help: "Process IDs to exclude (space-separated)")
@Option( parser.addFlag(name: "mute", help: "Mute processes being tapped")
name: .long, help: "Process IDs to exclude (space-separated)") parser.addOption(
var excludeProcesses: [Int32] = [] name: "sample-rate",
@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)") help: "Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)")
var sampleRate: Double? parser.addOption(
name: "chunk-duration", help: "Audio chunk duration in seconds", defaultValue: "0.2")
@Option( // Parse arguments
name: .long, do {
help: "Audio chunk duration in seconds (default: 0.2)") try parser.parse()
var chunkDuration: Double = 0.2
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 { func validate() throws {
if !includeProcesses.isEmpty && !excludeProcesses.isEmpty { 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
}
}
}
+1 -3
View File
@@ -1,6 +1,4 @@
import ArgumentParser enum OutputFormat: String, CaseIterable {
enum OutputFormat: String, CaseIterable, ExpressibleByArgument {
case json = "json" case json = "json"
case binary = "binary" case binary = "binary"
case auto = "auto" case auto = "auto"
+1 -2
View File
@@ -1,7 +1,6 @@
import ArgumentParser
import CoreAudio import CoreAudio
public enum TapMuteBehavior: String, CaseIterable, ExpressibleByArgument { public enum TapMuteBehavior: String, CaseIterable {
case unmuted = "unmuted" case unmuted = "unmuted"
case muted = "muted" case muted = "muted"
-5
View File
@@ -56,11 +56,6 @@ public class AudioFormatConverter {
public func transform(_ packet: AudioPacket) -> AudioPacket { public func transform(_ packet: AudioPacket) -> AudioPacket {
let inputData = packet.rawAudioData let inputData = packet.rawAudioData
// Short-circuit if no conversion needed
if sourceFormat.sampleRate == targetFormat.sampleRate {
return packet
}
// Calculate frame counts // Calculate frame counts
let inputFrameCount = let inputFrameCount =
inputData.count / Int(sourceFormat.streamDescription.pointee.mBytesPerFrame) inputData.count / Int(sourceFormat.streamDescription.pointee.mBytesPerFrame)
+65 -3
View File
@@ -4,6 +4,42 @@ import Foundation
public class AudioFormatManager { public class AudioFormatManager {
public static func getDeviceFormat(deviceID: AudioObjectID) -> AudioStreamBasicDescription { public static func getDeviceFormat(deviceID: AudioObjectID) -> AudioStreamBasicDescription {
// First, wait for the device to become alive/ready
let deviceReadyTimeout = 2.0 // 2 seconds max wait
let pollInterval = 0.1 // 100ms poll interval
let maxPolls = Int(deviceReadyTimeout / pollInterval)
Logger.debug(
"Waiting for audio device to become ready", context: ["device_id": String(deviceID)])
// Poll device readiness
for poll in 1...maxPolls {
if isAudioDeviceValid(deviceID) {
Logger.debug(
"Audio device is ready", context: ["device_id": String(deviceID), "polls": String(poll)])
break
}
if poll == maxPolls {
Logger.info(
"Device did not become ready within timeout, proceeding anyway",
context: [
"device_id": String(deviceID),
"timeout_seconds": String(deviceReadyTimeout),
])
break
}
Logger.info("------- not ready; retrying...")
Thread.sleep(forTimeInterval: pollInterval)
}
// Now attempt to get the stream format with limited retries
let maxRetries = 3 // Reduced since device should be ready
let retryDelayMs = 20 // Shorter delay since we've already waited for readiness
for attempt in 1...maxRetries {
var propertyAddress = getPropertyAddress( var propertyAddress = getPropertyAddress(
selector: kAudioDevicePropertyStreamFormat, selector: kAudioDevicePropertyStreamFormat,
scope: kAudioDevicePropertyScopeInput) scope: kAudioDevicePropertyScopeInput)
@@ -12,11 +48,37 @@ public class AudioFormatManager {
let status = AudioObjectGetPropertyData( let status = AudioObjectGetPropertyData(
deviceID, &propertyAddress, 0, nil, &propertySize, &streamFormat) deviceID, &propertyAddress, 0, nil, &propertySize, &streamFormat)
guard status == noErr else { if status == noErr {
fatalError("Failed to get stream format: \(status)") Logger.debug("Successfully retrieved device format", context: ["attempt": String(attempt)])
return streamFormat
} }
return streamFormat Logger.info(
"------- Failed to get stream format after device ready check, retrying...",
context: [
"attempt": String(attempt),
"max_retries": String(maxRetries),
"status": String(status),
"device_id": String(deviceID),
])
// Don't delay on the last attempt
if attempt < maxRetries {
Thread.sleep(forTimeInterval: Double(retryDelayMs) / 1000.0)
}
}
// If all attempts failed after device readiness confirmation, this is a genuine error
Logger.error(
"Failed to get device format after device readiness check and retries",
context: [
"device_id": String(deviceID),
"device_was_ready": "true",
])
fatalError(
"Failed to get stream format from ready device: \(deviceID). This indicates a Core Audio subsystem error."
)
} }
static func createMetadata(for format: AudioStreamBasicDescription) -> AudioStreamMetadata { static func createMetadata(for format: AudioStreamBasicDescription) -> AudioStreamMetadata {
-1
View File
@@ -1,4 +1,3 @@
import ArgumentParser
import AudioToolbox import AudioToolbox
import Foundation import Foundation