Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2040510e9e | |||
| 3449a9bb9c | |||
| ac0ae46cfa | |||
| 8c3ee0f4e7 | |||
| abaa019bd2 | |||
| 98e33b7bcb | |||
| ee8968e2d6 | |||
| 4bc36019c2 | |||
| 1b537eb395 | |||
| 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
|
||||
}
|
||||
+1
-11
@@ -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")
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
# 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.
|
||||
**⚠️ API Instability Warning: The AudioTee API is unstable at present and subject to change without notice.**
|
||||
|
||||
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.
|
||||
AudioTee captures your Mac's system audio output and writes it in PCM encoded chunks to `stdout` at regular intervals. All logging and metadata information is written to `stderr`, meaning at its simplest you can
|
||||
capture system audio to a file like this:
|
||||
|
||||
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.
|
||||
```bash
|
||||
/path/to/audiotee > output.pcm
|
||||
```
|
||||
|
||||
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.
|
||||
System audio is captured using 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, visualise it, etc.
|
||||
|
||||
By default, audiotee captures audio output from **all** running processes. 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. I have internal variations (and, franky, improvements) of audiotee which allow recording mic input as well as system audio, and I'm open to making that part of the main API.
|
||||
|
||||
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
|
||||
|
||||
@@ -16,12 +25,26 @@ Recording system audio is harder than it should be on macOS, and folks often wre
|
||||
|
||||
## Quick start
|
||||
|
||||
The following will start capturing audio output from all running programs and write raw PCM audio data to your terminal:
|
||||
|
||||
```bash
|
||||
git clone git@github.com:makeusabrew/audiotee.git
|
||||
cd audiotee
|
||||
swift run
|
||||
```
|
||||
|
||||
More usefully, you can redirect `stdout` to a file:
|
||||
|
||||
```bash
|
||||
swift run audiotee --sample-rate 16000 > output.pcm
|
||||
```
|
||||
|
||||
Which you can play back using something like `ffplay`:
|
||||
|
||||
```bash
|
||||
ffplay -f s16le -ar 16000 output.pcm
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
@@ -36,18 +59,24 @@ swift build -c release
|
||||
Replace the path below with `.build/<arch>/<target>/audiotee`, e.g. `build/arm64-apple-macosx/release/audiotee` for a release build on Apple Silicon.
|
||||
|
||||
```bash
|
||||
# Auto-detect output format (JSON in terminal, binary when piped)
|
||||
# Write raw PCM audio to stdout (logs go to stderr)
|
||||
./audiotee
|
||||
|
||||
# Always use JSON format (terminal-safe)
|
||||
./audiotee --format json
|
||||
# Redirect audio to a file
|
||||
./audiotee > output.pcm
|
||||
|
||||
# Always use binary format (pipe-optimised)
|
||||
./audiotee --format binary
|
||||
# Pipe to another program
|
||||
./audiotee | your_audio_processing_tool
|
||||
|
||||
# Redirect logs as well
|
||||
./audiotee > captured_audio.pcm 2> audiotee.log
|
||||
```
|
||||
|
||||
### 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 it's a behaviour worth being aware of.
|
||||
|
||||
```bash
|
||||
# Convert to 16kHz mono (useful for ASR services)
|
||||
./audiotee --sample-rate 16000
|
||||
@@ -60,6 +89,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.
|
||||
|
||||
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
|
||||
# Tap all system audio (default)
|
||||
./audiotee
|
||||
@@ -85,147 +116,33 @@ For now, only a subset of the `CATapDescription` (https://developer.apple.com/do
|
||||
./audiotee --chunk-duration 0.1
|
||||
```
|
||||
|
||||
## Output formats
|
||||
## Output
|
||||
|
||||
AudioTee supports two output formats optimised for different use cases:
|
||||
AudioTee writes raw PCM audio data directly to `stdout` in chunks. All logging, metadata, and status information is written to `stderr`, allowing for clean separation of audio data from program output.
|
||||
|
||||
### JSON format (`--format json` or auto in terminal)
|
||||
### Audio format
|
||||
|
||||
JSON messages to stdout, one per line. Audio data is base64-encoded for terminal safety.
|
||||
- **Format**: Raw PCM audio data
|
||||
- **Channels**: Mono (1 channel)
|
||||
- **Sample rate**: Matches your output device's sample rate by default (configurable)
|
||||
- **Bit depth**: 32-bit float by default, or 16-bit when sample rate conversion is performed
|
||||
- **Endianness**: Little-endian
|
||||
- **Chunk duration**: 200ms by default (configurable)
|
||||
|
||||
### Binary format (`--format binary` or auto when piped)
|
||||
### Logs and monitoring
|
||||
|
||||
JSON metadata lines followed by raw binary audio data. More efficient for piping to other processes.
|
||||
All program logs are written to `stderr` and can be captured separately:
|
||||
|
||||
## Protocol
|
||||
```bash
|
||||
# Capture audio and logs separately
|
||||
./audiotee > audio.pcm 2> audiotee.log
|
||||
|
||||
### Message types
|
||||
|
||||
All messages (except raw binary audio chunks) follow this envelope structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-03-21T15:30:45.123Z",
|
||||
"message_type": "...",
|
||||
"data": { ... }
|
||||
}
|
||||
# View logs in real-time while capturing audio
|
||||
./audiotee > audio.pcm 2>&1 | grep "AudioTee"
|
||||
```
|
||||
|
||||
#### 1. Metadata
|
||||
|
||||
Sent once at startup to describe the audio format:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-03-21T15:30:45.123Z",
|
||||
"message_type": "metadata",
|
||||
"data": {
|
||||
"sample_rate": 48000,
|
||||
"channels_per_frame": 1,
|
||||
"bits_per_channel": 32,
|
||||
"is_float": true,
|
||||
"capture_mode": "audio",
|
||||
"device_name": null,
|
||||
"device_uid": null,
|
||||
"encoding": "pcm_f32le"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Stream start
|
||||
|
||||
Indicates audio data will follow:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-03-21T15:30:45.123Z",
|
||||
"message_type": "stream_start",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Audio data
|
||||
|
||||
**JSON format:**
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-03-21T15:30:45.123Z",
|
||||
"message_type": "audio",
|
||||
"data": {
|
||||
"timestamp": "2024-03-21T15:30:45.123Z",
|
||||
"duration": 0.2,
|
||||
"peak_amplitude": 0.45,
|
||||
"audio_data": "base64_encoded_raw_audio..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Binary format:**
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-03-21T15:30:45.123Z",
|
||||
"message_type": "audio",
|
||||
"data": {
|
||||
"timestamp": "2024-03-21T15:30:45.123Z",
|
||||
"duration": 0.2,
|
||||
"peak_amplitude": 0.45,
|
||||
"audio_length": 9600
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
_Followed immediately by 9600 bytes of raw binary audio data_
|
||||
|
||||
#### 4. Stream stop
|
||||
|
||||
Sent when recording stops:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-03-21T15:30:45.123Z",
|
||||
"message_type": "stream_stop",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. Log messages
|
||||
|
||||
Info, error, and debug messages (useful for monitoring):
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-03-21T15:30:45.123Z",
|
||||
"message_type": "info",
|
||||
"data": {
|
||||
"message": "Starting AudioTee...",
|
||||
"context": { "output_format": "auto" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Consuming output
|
||||
|
||||
**JSON format:**
|
||||
|
||||
1. Parse each line as JSON using the envelope structure
|
||||
2. Use `metadata` message to understand the audio format
|
||||
3. For `audio` messages, decode `audio_data` from base64 to get raw PCM data
|
||||
4. Do something with each chunk of data
|
||||
|
||||
**Binary format:**
|
||||
|
||||
1. Parse JSON metadata lines using the envelope structure
|
||||
2. Use `metadata` message to understand the audio format
|
||||
3. For `audio` messages, read `audio_length` bytes of raw binary data after the JSON line
|
||||
4. Do something with each chunk of data
|
||||
|
||||
**Note**: binary is actually a mixed mode; JSON during boot, JSON packet header information preceding each binary chunk.
|
||||
|
||||
## Command Line options
|
||||
|
||||
- `--format, -f`: Output format (`json`, `binary`, `auto`) [default: `auto`]
|
||||
- `--include-processes`: Process IDs to tap (space-separated, empty = all processes)
|
||||
- `--exclude-processes`: Process IDs to exclude (space-separated, empty = none)
|
||||
- `--mute`: Mute processes being tapped
|
||||
@@ -235,7 +152,9 @@ Info, error, and debug messages (useful for monitoring):
|
||||
## Permissions
|
||||
|
||||
There is no provision in the code to pre-emptively check for the required `NSAudioCaptureUsageDescription` permission,
|
||||
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).
|
||||
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). Note that some terminal emulators like
|
||||
iTerm don't always prompt for these permissions (the macOS builtin terminal definitely does), so you
|
||||
might need to grant them ahead of time if audiotee looks like it's running but never records anything.
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
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
|
||||
}
|
||||
|
||||
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
-47
@@ -1,18 +1,22 @@
|
||||
import ArgumentParser
|
||||
import CoreAudio
|
||||
import Foundation
|
||||
|
||||
struct AudioTee: ParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
struct AudioTee {
|
||||
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",
|
||||
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
|
||||
@@ -20,10 +24,8 @@ struct AudioTee: ParsableCommand {
|
||||
|
||||
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 --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
|
||||
@@ -31,33 +33,58 @@ struct AudioTee: ParsableCommand {
|
||||
"""
|
||||
)
|
||||
|
||||
@Option(name: .shortAndLong, help: "Output format")
|
||||
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,
|
||||
// Configure arguments
|
||||
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)")
|
||||
var sampleRate: Double?
|
||||
parser.addOption(
|
||||
name: "chunk-duration", help: "Audio chunk duration in seconds", defaultValue: "0.2")
|
||||
|
||||
@Option(
|
||||
name: .long,
|
||||
help: "Audio chunk duration in seconds (default: 0.2)")
|
||||
var chunkDuration: Double = 0.2
|
||||
// Parse arguments
|
||||
do {
|
||||
try parser.parse()
|
||||
|
||||
var audioTee = AudioTee()
|
||||
|
||||
// Extract values
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +92,6 @@ struct AudioTee: ParsableCommand {
|
||||
setupSignalHandlers()
|
||||
|
||||
Logger.info("Starting AudioTee...")
|
||||
Logger.debug("Using output format: \(format)")
|
||||
|
||||
// Validate chunk duration
|
||||
guard chunkDuration > 0 && chunkDuration <= 5.0 else {
|
||||
@@ -106,7 +132,7 @@ struct AudioTee: ParsableCommand {
|
||||
throw ExitCode.failure
|
||||
}
|
||||
|
||||
let outputHandler = createOutputHandler(for: format)
|
||||
let outputHandler = BinaryAudioOutputHandler()
|
||||
let recorder = AudioRecorder(
|
||||
deviceID: deviceID, outputHandler: outputHandler, convertToSampleRate: sampleRate,
|
||||
chunkDuration: chunkDuration)
|
||||
@@ -135,17 +161,6 @@ struct AudioTee: ParsableCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private func createOutputHandler(for format: OutputFormat) -> AudioOutputHandler {
|
||||
switch format {
|
||||
case .json:
|
||||
return JSONAudioOutputHandler()
|
||||
case .binary:
|
||||
return BinaryAudioOutputHandler()
|
||||
case .auto:
|
||||
return AutoAudioOutputHandler()
|
||||
}
|
||||
}
|
||||
|
||||
private func convertProcessFlags() -> ([Int32], Bool) {
|
||||
if !includeProcesses.isEmpty {
|
||||
// Include specific processes only
|
||||
@@ -159,3 +174,27 @@ struct AudioTee: ParsableCommand {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for stderr output
|
||||
var standardError = FileHandle.standardError
|
||||
|
||||
extension FileHandle: 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,18 +0,0 @@
|
||||
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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -2,17 +2,64 @@ import CoreAudio
|
||||
import Foundation
|
||||
|
||||
public class AudioBuffer {
|
||||
private var buffer = Data()
|
||||
private let targetChunkDuration: Double
|
||||
private let streamFormat: AudioStreamBasicDescription
|
||||
private var buffer: [UInt8]
|
||||
private var writeIndex: Int = 0
|
||||
private var readIndex: Int = 0
|
||||
private var availableBytes: Int = 0
|
||||
private let maxBufferSize: Int
|
||||
|
||||
private let bytesPerChunk: Int
|
||||
private let chunkDuration: Double
|
||||
|
||||
public init(format: AudioStreamBasicDescription, chunkDuration: Double = 0.2) {
|
||||
self.streamFormat = format
|
||||
self.targetChunkDuration = chunkDuration
|
||||
|
||||
// Pre-calculate chunk parameters
|
||||
let bytesPerFrame = Int(format.mBytesPerFrame)
|
||||
let samplesPerChunk = Int(format.mSampleRate * chunkDuration)
|
||||
self.bytesPerChunk = samplesPerChunk * bytesPerFrame
|
||||
self.chunkDuration = Double(samplesPerChunk) / format.mSampleRate
|
||||
|
||||
// Calculate max buffer size to hold ~10 seconds of audio, way more than the maximum we allow
|
||||
let bytesPerSecond = Int(format.mSampleRate) * bytesPerFrame
|
||||
self.maxBufferSize = bytesPerSecond * 10
|
||||
|
||||
// Pre-allocated ring buffer
|
||||
self.buffer = Array(repeating: 0, count: maxBufferSize)
|
||||
}
|
||||
|
||||
public func append(_ data: Data) {
|
||||
buffer.append(data)
|
||||
guard availableBytes + data.count <= maxBufferSize else {
|
||||
Logger.error(
|
||||
"Audio buffer overflow",
|
||||
context: [
|
||||
"requested": String(data.count),
|
||||
"available": String(maxBufferSize - availableBytes),
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
data.withUnsafeBytes { bytes in
|
||||
let sourceBytes = bytes.bindMemory(to: UInt8.self)
|
||||
let dataSize = sourceBytes.count
|
||||
|
||||
// Check if we can copy in one block (no wrap-around)
|
||||
if writeIndex + dataSize <= maxBufferSize {
|
||||
// only one write needed
|
||||
buffer.replaceSubrange(writeIndex..<writeIndex + dataSize, with: sourceBytes)
|
||||
writeIndex = (writeIndex + dataSize) % maxBufferSize
|
||||
} else {
|
||||
// two writes needed due to wrap-around
|
||||
let firstChunkSize = maxBufferSize - writeIndex
|
||||
let secondChunkSize = dataSize - firstChunkSize
|
||||
|
||||
buffer.replaceSubrange(writeIndex..<maxBufferSize, with: sourceBytes.prefix(firstChunkSize))
|
||||
buffer.replaceSubrange(0..<secondChunkSize, with: sourceBytes.suffix(secondChunkSize))
|
||||
|
||||
writeIndex = secondChunkSize
|
||||
}
|
||||
}
|
||||
|
||||
availableBytes += data.count
|
||||
}
|
||||
|
||||
public func processChunks() -> [AudioPacket] {
|
||||
@@ -25,37 +72,36 @@ public class AudioBuffer {
|
||||
return packets
|
||||
}
|
||||
|
||||
public func flushRemaining() -> AudioPacket? {
|
||||
guard !buffer.isEmpty else { return nil }
|
||||
private func nextChunk() -> AudioPacket? {
|
||||
// Check if we have enough data for a complete chunk
|
||||
guard availableBytes >= bytesPerChunk else { return nil }
|
||||
|
||||
let packet = AudioPacket(
|
||||
timestamp: Date(),
|
||||
duration: 0.0, // Unknown duration for final chunk
|
||||
peakAmplitude: 0.0,
|
||||
rawAudioData: buffer
|
||||
)
|
||||
var chunkData = Data(capacity: bytesPerChunk)
|
||||
|
||||
buffer.removeAll()
|
||||
return packet
|
||||
// Check if we can copy in one block (no wrap-around)
|
||||
if readIndex + bytesPerChunk <= maxBufferSize {
|
||||
// one copy needed
|
||||
chunkData.append(contentsOf: buffer[readIndex..<readIndex + bytesPerChunk])
|
||||
readIndex = (readIndex + bytesPerChunk) % maxBufferSize
|
||||
} else {
|
||||
// two copies needed due to wrap-around
|
||||
let firstChunkSize = maxBufferSize - readIndex
|
||||
let secondChunkSize = bytesPerChunk - firstChunkSize
|
||||
|
||||
chunkData.append(contentsOf: buffer[readIndex..<maxBufferSize])
|
||||
chunkData.append(contentsOf: buffer[0..<secondChunkSize])
|
||||
|
||||
readIndex = secondChunkSize
|
||||
}
|
||||
|
||||
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)
|
||||
availableBytes -= bytesPerChunk
|
||||
|
||||
let packet = AudioPacket(
|
||||
timestamp: Date(),
|
||||
duration: Double(samplesPerChunk) / streamFormat.mSampleRate,
|
||||
peakAmplitude: 0.0, // No analysis in raw mode
|
||||
rawAudioData: Data(chunkData)
|
||||
duration: chunkDuration,
|
||||
rawAudioData: chunkData
|
||||
)
|
||||
|
||||
buffer.removeFirst(bytesPerChunk)
|
||||
return packet
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,11 +56,6 @@ public class AudioFormatConverter {
|
||||
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)
|
||||
@@ -124,7 +119,6 @@ public class AudioFormatConverter {
|
||||
return AudioPacket(
|
||||
timestamp: packet.timestamp,
|
||||
duration: packet.duration,
|
||||
peakAmplitude: packet.peakAmplitude,
|
||||
rawAudioData: outputData
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,42 @@ import Foundation
|
||||
|
||||
public class AudioFormatManager {
|
||||
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(
|
||||
selector: kAudioDevicePropertyStreamFormat,
|
||||
scope: kAudioDevicePropertyScopeInput)
|
||||
@@ -12,11 +48,37 @@ public class AudioFormatManager {
|
||||
let status = AudioObjectGetPropertyData(
|
||||
deviceID, &propertyAddress, 0, nil, &propertySize, &streamFormat)
|
||||
|
||||
guard status == noErr else {
|
||||
fatalError("Failed to get stream format: \(status)")
|
||||
if status == noErr {
|
||||
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 {
|
||||
|
||||
@@ -3,18 +3,15 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,8 @@ public class AudioRecorder {
|
||||
Logger.info("Audio device started successfully")
|
||||
}
|
||||
|
||||
// FIXME: note to self, what about installTap? Would require audio engine and a node?
|
||||
// Note to self, what about installTap? Would require audio engine and a node?
|
||||
// No; AudioEngine.installTap() can only fire as often as 100ms. too slow for us
|
||||
private func setupAndStartIOProc() {
|
||||
Logger.debug("Creating IO proc")
|
||||
var status = AudioDeviceCreateIOProcID(
|
||||
@@ -126,8 +127,8 @@ public class AudioRecorder {
|
||||
|
||||
func stopRecording() {
|
||||
// Send any remaining buffered audio, applying conversion if needed
|
||||
if let finalPacket = audioBuffer?.flushRemaining() {
|
||||
let processedPacket = converter?.transform(finalPacket) ?? finalPacket
|
||||
audioBuffer?.processChunks().forEach { packet in
|
||||
let processedPacket = converter?.transform(packet) ?? packet
|
||||
outputHandler.handleAudioPacket(processedPacket)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,6 @@ public class BinaryAudioOutputHandler: AudioOutputHandler {
|
||||
public init() {}
|
||||
|
||||
public func handleAudioPacket(_ packet: AudioPacket) {
|
||||
// Create metadata without the audio data
|
||||
let metadata = BinaryPacketHeader(from: packet)
|
||||
|
||||
// Write JSON metadata line
|
||||
Logger.writeMessage(.audio, data: metadata)
|
||||
|
||||
// Write raw binary audio data directly to stdout
|
||||
FileHandle.standardOutput.write(packet.rawAudioData)
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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 BinaryPacketHeader: 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
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,8 @@ public class Logger {
|
||||
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)!)
|
||||
FileHandle.standardError.write(jsonData)
|
||||
FileHandle.standardError.write("\n".data(using: .utf8)!)
|
||||
} catch {
|
||||
// TODO: handle at some point
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import ArgumentParser
|
||||
import AudioToolbox
|
||||
import Foundation
|
||||
|
||||
|
||||
BIN
Binary file not shown.
Reference in New Issue
Block a user