Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef1d1f247d | |||
| c68ca87234 | |||
| 506e15684b | |||
| 4bbdee96ac | |||
| 80d7555b60 | |||
| c696fa0197 | |||
| a0902f6eec | |||
| b7ce5e61d8 | |||
| d10f16a119 | |||
| a7be157d06 | |||
| bfce968fb6 | |||
| 95f11b17c7 | |||
| 756c1b3535 |
@@ -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
|
|
||||||
}
|
|
||||||
+3
-9
@@ -8,17 +8,11 @@ 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.
|
|
||||||
// Targets can depend on other targets in this package and products from dependencies.
|
|
||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "audiotee",
|
name: "audiotee",
|
||||||
dependencies: [
|
swiftSettings: [
|
||||||
.product(name: "ArgumentParser", package: "swift-argument-parser")
|
.define("ENABLE_TCC_SPI")
|
||||||
]
|
])
|
||||||
)
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,13 +243,35 @@ Info, error, and debug messages (useful for monitoring):
|
|||||||
|
|
||||||
## Permissions
|
## Permissions
|
||||||
|
|
||||||
There is no provision in the code to pre-emptively check for the required `NSAudioCaptureUsageDescription` permission,
|
AudioTee requires system audio recording permissions to function. You can handle these permissions in two ways:
|
||||||
so you'll be prompted the first time AudioTee tries to record anything. If you want to check and/or request permissions ahead of time, check out [AudioCap's clever TCC probing approach](https://github.com/insidegui/AudioCap/blob/main/AudioCap/ProcessTap/AudioRecordingPermission.swift).
|
|
||||||
|
### Lazy permissions (default approach)
|
||||||
|
|
||||||
|
Simply run `./audiotee` and you'll be prompted for permissions the first time AudioTee tries to record audio from the tap. Note that some terminal emulators (at least `iTerm`) will **not** prompt at all, nor will the process fail: instead, AudioTee will happily run but will record a stream of empty data. The built in macOS terminal **does** prompt for permissions and blocks until granted.
|
||||||
|
|
||||||
|
### Explicit permissions management
|
||||||
|
|
||||||
|
Use the `--permissions` flag to check or request permissions ahead of time:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check current permission status
|
||||||
|
./audiotee --permissions
|
||||||
|
|
||||||
|
# Request permissions with user prompt
|
||||||
|
./audiotee --permissions --request
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that the same caveat as above exists here regarding terminal emulators. If you know why, or how to fix it, please help out.
|
||||||
|
|
||||||
|
**Exit codes** indicate permission status, making this approach ideal for scripting:
|
||||||
|
- `0`: Permissions granted
|
||||||
|
- `1`: Permission status unknown
|
||||||
|
- `2`: Permissions denied
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- [Apple Core Audio Taps Documentation](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps)
|
- [Apple Core Audio Taps Documentation](https://developer.apple.com/documentation/coreaudio/capturing-system-audio-with-core-audio-taps)
|
||||||
- [AudioCap Implementation](https://github.com/insidegui/AudioCap)
|
- [AudioCap Implementation](https://github.com/insidegui/AudioCap) - in particular, their awesome TCC probing approach to check for the audio capture permissions, which AudioTee lifts almost in its entirety. Thank you.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+110
-25
@@ -1,13 +1,29 @@
|
|||||||
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
|
||||||
|
var permissionsMode: Bool = false
|
||||||
|
var requestPermissions: Bool = false
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Permission modes:
|
||||||
|
• --permissions: Check current audio recording permissions
|
||||||
|
• --permissions --request: Request audio recording permissions
|
||||||
|
|
||||||
Output formats:
|
Output formats:
|
||||||
• json: Base64-encoded audio in JSON messages (safe for terminals)
|
• json: Base64-encoded audio in JSON messages (safe for terminals)
|
||||||
• binary: Raw binary audio with JSON metadata headers (efficient for pipes)
|
• binary: Raw binary audio with JSON metadata headers (efficient for pipes)
|
||||||
@@ -19,6 +35,8 @@ struct AudioTee: ParsableCommand {
|
|||||||
• mute: How to handle processes being tapped
|
• mute: How to handle processes being tapped
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
audiotee --permissions # Check audio recording permissions
|
||||||
|
audiotee --permissions --request # Request audio recording permissions
|
||||||
audiotee # Auto format, tap all processes
|
audiotee # Auto format, tap all processes
|
||||||
audiotee --format=json # Always use JSON format
|
audiotee --format=json # Always use JSON format
|
||||||
audiotee --format=binary # Always use binary format
|
audiotee --format=binary # Always use binary format
|
||||||
@@ -31,37 +49,80 @@ struct AudioTee: ParsableCommand {
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
@Option(name: .shortAndLong, help: "Output format")
|
// Configure arguments
|
||||||
var format: OutputFormat = .auto
|
parser.addFlag(name: "permissions", help: "Check audio recording permissions")
|
||||||
|
parser.addFlag(name: "request", help: "Request permissions (use with --permissions)")
|
||||||
@Option(
|
parser.addOption(name: "format", shortName: "f", help: "Output format", defaultValue: "auto")
|
||||||
name: .long, help: "Process IDs to include (space-separated, empty = all processes)")
|
parser.addArrayOption(
|
||||||
var includeProcesses: [Int32] = []
|
name: "include-processes",
|
||||||
|
help: "Process IDs to include (space-separated, empty = all processes)")
|
||||||
@Option(
|
parser.addArrayOption(
|
||||||
name: .long, help: "Process IDs to exclude (space-separated)")
|
name: "exclude-processes", help: "Process IDs to exclude (space-separated)")
|
||||||
var excludeProcesses: [Int32] = []
|
parser.addFlag(name: "mute", help: "Mute processes being tapped")
|
||||||
|
parser.addOption(
|
||||||
@Flag(name: .long, help: "Mute processes being tapped")
|
name: "sample-rate",
|
||||||
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.permissionsMode = parser.getFlag("permissions")
|
||||||
|
audioTee.requestPermissions = parser.getFlag("request")
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
if requestPermissions && !permissionsMode {
|
||||||
|
throw ArgumentParserError.validationFailed(
|
||||||
|
"--request can only be used with --permissions")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func run() throws {
|
func run() throws {
|
||||||
|
// Handle permissions mode
|
||||||
|
if permissionsMode {
|
||||||
|
let permissionsHandler = PermissionsHandler(shouldRequest: requestPermissions)
|
||||||
|
permissionsHandler.handle() // This will exit with appropriate code
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue with normal audio tapping functionality
|
||||||
setupSignalHandlers()
|
setupSignalHandlers()
|
||||||
|
|
||||||
Logger.info("Starting AudioTee...")
|
Logger.info("Starting AudioTee...")
|
||||||
@@ -159,3 +220,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,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"
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import CoreFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Handles audio recording permissions for the CLI, including checking status and requesting permissions.
|
||||||
|
/// Uses exit codes to communicate permission status:
|
||||||
|
/// - 0: granted (authorized)
|
||||||
|
/// - 1: unknown
|
||||||
|
/// - 2: denied
|
||||||
|
struct PermissionsHandler {
|
||||||
|
private let shouldRequest: Bool
|
||||||
|
|
||||||
|
init(shouldRequest: Bool) {
|
||||||
|
self.shouldRequest = shouldRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles the permissions workflow and exits with appropriate exit code
|
||||||
|
func handle() -> Never {
|
||||||
|
let permissionHandler = AudioRecordingPermission()
|
||||||
|
|
||||||
|
if shouldRequest {
|
||||||
|
print("Requesting audio recording permissions...")
|
||||||
|
permissionHandler.request()
|
||||||
|
|
||||||
|
// Wait for the permission request to complete
|
||||||
|
while permissionHandler.status == .unknown {
|
||||||
|
// Run the main run loop to allow DispatchQueue.main.async to execute
|
||||||
|
let result = CFRunLoopRunInMode(CFRunLoopMode.defaultMode, 0.1, true)
|
||||||
|
if result == CFRunLoopRunResult.stopped || result == CFRunLoopRunResult.finished {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get final status and exit with appropriate code
|
||||||
|
let status = permissionHandler.status
|
||||||
|
print("Audio recording permission status: \(status.rawValue)")
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case .authorized:
|
||||||
|
exit(0) // granted
|
||||||
|
case .unknown:
|
||||||
|
exit(1) // unknown
|
||||||
|
case .denied:
|
||||||
|
exit(2) // denied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import OSLog
|
||||||
|
import Observation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// Adapted with a huge debt of gratitude from https://github.com/insidegui/AudioCap/blob/main/AudioCap/ProcessTap/AudioRecordingPermission.swift
|
||||||
|
|
||||||
|
/// Uses TCC SPI in order to check/request system audio recording permission.
|
||||||
|
@Observable
|
||||||
|
final class AudioRecordingPermission {
|
||||||
|
// private let logger = Logger(subsystem: kAppSubsystem, category: String(describing: AudioRecordingPermission.self))
|
||||||
|
|
||||||
|
enum Status: String {
|
||||||
|
case unknown
|
||||||
|
case denied
|
||||||
|
case authorized
|
||||||
|
}
|
||||||
|
|
||||||
|
private(set) var status: Status = .unknown
|
||||||
|
|
||||||
|
init() {
|
||||||
|
#if ENABLE_TCC_SPI
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
self.updateStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus()
|
||||||
|
#else
|
||||||
|
status = .authorized
|
||||||
|
#endif // ENABLE_TCC_SPI
|
||||||
|
}
|
||||||
|
|
||||||
|
func request() {
|
||||||
|
#if ENABLE_TCC_SPI
|
||||||
|
// logger.debug(#function)
|
||||||
|
print("DEBUG: TCC SPI request called")
|
||||||
|
|
||||||
|
guard let request = Self.requestSPI else {
|
||||||
|
// logger.fault("Request SPI missing")
|
||||||
|
print("DEBUG: Request SPI is nil - TCC framework loading failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print("DEBUG: Calling TCC request function...")
|
||||||
|
request("kTCCServiceAudioCapture" as CFString, nil) { [weak self] granted in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
// self.logger.info("Request finished with result: \(granted, privacy: .public)")
|
||||||
|
print("DEBUG: TCC request completed with result: \(granted)")
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
print("DEBUG: Updating status on main queue...")
|
||||||
|
if granted {
|
||||||
|
self.status = .authorized
|
||||||
|
print("DEBUG: Status set to authorized")
|
||||||
|
} else {
|
||||||
|
self.status = .denied
|
||||||
|
print("DEBUG: Status set to denied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
print("DEBUG: ENABLE_TCC_SPI not defined")
|
||||||
|
#endif // ENABLE_TCC_SPI
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateStatus() {
|
||||||
|
#if ENABLE_TCC_SPI
|
||||||
|
// logger.debug(#function)
|
||||||
|
|
||||||
|
guard let preflight = Self.preflightSPI else {
|
||||||
|
// logger.fault("Preflight SPI missing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = preflight("kTCCServiceAudioCapture" as CFString, nil)
|
||||||
|
|
||||||
|
if result == 1 {
|
||||||
|
status = .denied
|
||||||
|
} else if result == 0 {
|
||||||
|
status = .authorized
|
||||||
|
} else {
|
||||||
|
status = .unknown
|
||||||
|
}
|
||||||
|
#endif // ENABLE_TCC_SPI
|
||||||
|
}
|
||||||
|
|
||||||
|
#if ENABLE_TCC_SPI
|
||||||
|
private typealias PreflightFuncType = @convention(c) (CFString, CFDictionary?) -> Int
|
||||||
|
private typealias RequestFuncType = @convention(c) (
|
||||||
|
CFString, CFDictionary?, @escaping (Bool) -> Void
|
||||||
|
) -> Void
|
||||||
|
|
||||||
|
/// `dlopen` handle to the TCC framework.
|
||||||
|
private static let apiHandle: UnsafeMutableRawPointer? = {
|
||||||
|
let tccPath = "/System/Library/PrivateFrameworks/TCC.framework/Versions/A/TCC"
|
||||||
|
print("DEBUG: Attempting to load TCC framework from: \(tccPath)")
|
||||||
|
|
||||||
|
guard let handle = dlopen(tccPath, RTLD_NOW) else {
|
||||||
|
print("DEBUG: dlopen failed for TCC framework")
|
||||||
|
assertionFailure("dlopen failed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
print("DEBUG: TCC framework loaded successfully")
|
||||||
|
return handle
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// `dlsym` function handle for `TCCAccessPreflight`.
|
||||||
|
private static let preflightSPI: PreflightFuncType? = {
|
||||||
|
guard let apiHandle else { return nil }
|
||||||
|
|
||||||
|
let fnName = "TCCAccessPreflight"
|
||||||
|
|
||||||
|
guard let funcSym = dlsym(apiHandle, fnName) else {
|
||||||
|
assertionFailure("Couldn't find symbol")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let fn = unsafeBitCast(funcSym, to: PreflightFuncType.self)
|
||||||
|
|
||||||
|
return fn
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// `dlsym` function handle for `TCCAccessRequest`.
|
||||||
|
private static let requestSPI: RequestFuncType? = {
|
||||||
|
guard let apiHandle else {
|
||||||
|
print("DEBUG: No API handle for TCCAccessRequest")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let fnName = "TCCAccessRequest"
|
||||||
|
print("DEBUG: Looking for symbol: \(fnName)")
|
||||||
|
|
||||||
|
guard let funcSym = dlsym(apiHandle, fnName) else {
|
||||||
|
print("DEBUG: Couldn't find symbol: \(fnName)")
|
||||||
|
assertionFailure("Couldn't find symbol")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
print("DEBUG: Found TCCAccessRequest symbol successfully")
|
||||||
|
let fn = unsafeBitCast(funcSym, to: RequestFuncType.self)
|
||||||
|
|
||||||
|
return fn
|
||||||
|
}()
|
||||||
|
#endif // ENABLE_TCC_SPI
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import ArgumentParser
|
|
||||||
import AudioToolbox
|
import AudioToolbox
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user