Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2040510e9e | |||
| 3449a9bb9c | |||
| ac0ae46cfa | |||
| 8c3ee0f4e7 | |||
| abaa019bd2 | |||
| 98e33b7bcb |
@@ -1,10 +1,19 @@
|
|||||||
# AudioTee
|
# AudioTee
|
||||||
|
|
||||||
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.
|
**⚠️ 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 yet configurable) and preserves your output device's sample rate (configurable via the `--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
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
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.
|
||||||
|
|
||||||
@@ -16,7 +25,7 @@ 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:
|
The following will start capturing audio output from all running programs and write raw PCM audio data to your terminal:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone git@github.com:makeusabrew/audiotee.git
|
git clone git@github.com:makeusabrew/audiotee.git
|
||||||
@@ -24,7 +33,17 @@ 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.
|
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
|
## Build
|
||||||
|
|
||||||
@@ -40,20 +59,23 @@ 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.
|
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
|
```bash
|
||||||
# Auto-detect output format (JSON in terminal, binary when piped)
|
# Write raw PCM audio to stdout (logs go to stderr)
|
||||||
./audiotee
|
./audiotee
|
||||||
|
|
||||||
# Always use JSON format (terminal-safe)
|
# Redirect audio to a file
|
||||||
./audiotee --format json
|
./audiotee > output.pcm
|
||||||
|
|
||||||
# Always use binary format (pipe-optimised)
|
# Pipe to another program
|
||||||
./audiotee --format binary
|
./audiotee | your_audio_processing_tool
|
||||||
|
|
||||||
|
# Redirect logs as well
|
||||||
|
./audiotee > captured_audio.pcm 2> audiotee.log
|
||||||
```
|
```
|
||||||
|
|
||||||
### Audio conversion
|
### Audio conversion
|
||||||
|
|
||||||
Note that performing sample rate conversion will also convert the output bit depth to
|
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.
|
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
|
```bash
|
||||||
# Convert to 16kHz mono (useful for ASR services)
|
# Convert to 16kHz mono (useful for ASR services)
|
||||||
@@ -94,147 +116,33 @@ Note that trying to include or exclude a PID which isn't currently playing audio
|
|||||||
./audiotee --chunk-duration 0.1
|
./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
|
# View logs in real-time while capturing audio
|
||||||
|
./audiotee > audio.pcm 2>&1 | grep "AudioTee"
|
||||||
All messages (except raw binary audio chunks) follow this envelope structure:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"timestamp": "2024-03-21T15:30:45.123Z",
|
|
||||||
"message_type": "...",
|
|
||||||
"data": { ... }
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 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
|
## Command Line options
|
||||||
|
|
||||||
- `--format, -f`: Output format (`json`, `binary`, `auto`) [default: `auto`]
|
|
||||||
- `--include-processes`: Process IDs to tap (space-separated, empty = all processes)
|
- `--include-processes`: Process IDs to tap (space-separated, empty = all processes)
|
||||||
- `--exclude-processes`: Process IDs to exclude (space-separated, empty = none)
|
- `--exclude-processes`: Process IDs to exclude (space-separated, empty = none)
|
||||||
- `--mute`: Mute processes being tapped
|
- `--mute`: Mute processes being tapped
|
||||||
@@ -244,7 +152,9 @@ 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,
|
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
|
## References
|
||||||
|
|
||||||
|
|||||||
@@ -189,11 +189,6 @@ class SimpleArgumentParser {
|
|||||||
throw ArgumentParserError.invalidValue(optionName, value)
|
throw ArgumentParserError.invalidValue(optionName, value)
|
||||||
}
|
}
|
||||||
return doubleValue as! T
|
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)
|
throw ArgumentParserError.invalidValue(optionName, value)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import CoreAudio
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct AudioTee {
|
struct AudioTee {
|
||||||
var format: OutputFormat = .auto
|
|
||||||
var includeProcesses: [Int32] = []
|
var includeProcesses: [Int32] = []
|
||||||
var excludeProcesses: [Int32] = []
|
var excludeProcesses: [Int32] = []
|
||||||
var mute: Bool = false
|
var mute: Bool = false
|
||||||
@@ -18,11 +17,6 @@ struct AudioTee {
|
|||||||
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.
|
||||||
|
|
||||||
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:
|
Process filtering:
|
||||||
• include-processes: Only tap specified process IDs (empty = all processes)
|
• include-processes: Only tap specified process IDs (empty = all processes)
|
||||||
• exclude-processes: Tap all processes except specified ones
|
• exclude-processes: Tap all processes except specified ones
|
||||||
@@ -30,10 +24,8 @@ struct AudioTee {
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
audiotee # Auto format, tap all processes
|
audiotee # Auto format, tap all processes
|
||||||
audiotee --format=json # Always use JSON format
|
audiotee --sample-rate 16000 # Convert to 16kHz mono for ASR
|
||||||
audiotee --format=binary # Always use binary format
|
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 # Only tap process 1234
|
||||||
audiotee --include-processes 1234 5678 9012 # Tap only these processes
|
audiotee --include-processes 1234 5678 9012 # Tap only these processes
|
||||||
audiotee --exclude-processes 1234 5678 # Tap everything except these
|
audiotee --exclude-processes 1234 5678 # Tap everything except these
|
||||||
@@ -42,7 +34,6 @@ struct AudioTee {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Configure arguments
|
// Configure arguments
|
||||||
parser.addOption(name: "format", shortName: "f", help: "Output format", defaultValue: "auto")
|
|
||||||
parser.addArrayOption(
|
parser.addArrayOption(
|
||||||
name: "include-processes",
|
name: "include-processes",
|
||||||
help: "Process IDs to include (space-separated, empty = all processes)")
|
help: "Process IDs to include (space-separated, empty = all processes)")
|
||||||
@@ -62,7 +53,6 @@ struct AudioTee {
|
|||||||
var audioTee = AudioTee()
|
var audioTee = AudioTee()
|
||||||
|
|
||||||
// Extract values
|
// Extract values
|
||||||
audioTee.format = try parser.getValue("format", as: OutputFormat.self)
|
|
||||||
audioTee.includeProcesses = try parser.getArrayValue("include-processes", as: Int32.self)
|
audioTee.includeProcesses = try parser.getArrayValue("include-processes", as: Int32.self)
|
||||||
audioTee.excludeProcesses = try parser.getArrayValue("exclude-processes", as: Int32.self)
|
audioTee.excludeProcesses = try parser.getArrayValue("exclude-processes", as: Int32.self)
|
||||||
audioTee.mute = parser.getFlag("mute")
|
audioTee.mute = parser.getFlag("mute")
|
||||||
@@ -102,7 +92,6 @@ struct AudioTee {
|
|||||||
setupSignalHandlers()
|
setupSignalHandlers()
|
||||||
|
|
||||||
Logger.info("Starting AudioTee...")
|
Logger.info("Starting AudioTee...")
|
||||||
Logger.debug("Using output format: \(format)")
|
|
||||||
|
|
||||||
// Validate chunk duration
|
// Validate chunk duration
|
||||||
guard chunkDuration > 0 && chunkDuration <= 5.0 else {
|
guard chunkDuration > 0 && chunkDuration <= 5.0 else {
|
||||||
@@ -143,7 +132,7 @@ struct AudioTee {
|
|||||||
throw ExitCode.failure
|
throw ExitCode.failure
|
||||||
}
|
}
|
||||||
|
|
||||||
let outputHandler = createOutputHandler(for: format)
|
let outputHandler = BinaryAudioOutputHandler()
|
||||||
let recorder = AudioRecorder(
|
let recorder = AudioRecorder(
|
||||||
deviceID: deviceID, outputHandler: outputHandler, convertToSampleRate: sampleRate,
|
deviceID: deviceID, outputHandler: outputHandler, convertToSampleRate: sampleRate,
|
||||||
chunkDuration: chunkDuration)
|
chunkDuration: chunkDuration)
|
||||||
@@ -172,17 +161,6 @@ struct AudioTee {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
private func convertProcessFlags() -> ([Int32], Bool) {
|
||||||
if !includeProcesses.isEmpty {
|
if !includeProcesses.isEmpty {
|
||||||
// Include specific processes only
|
// Include specific processes only
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
enum OutputFormat: String, CaseIterable {
|
|
||||||
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)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -29,9 +29,11 @@ public class AudioBuffer {
|
|||||||
|
|
||||||
public func append(_ data: Data) {
|
public func append(_ data: Data) {
|
||||||
guard availableBytes + data.count <= maxBufferSize else {
|
guard availableBytes + data.count <= maxBufferSize else {
|
||||||
Logger.error("Audio buffer overflow", context: [
|
Logger.error(
|
||||||
|
"Audio buffer overflow",
|
||||||
|
context: [
|
||||||
"requested": String(data.count),
|
"requested": String(data.count),
|
||||||
"available": String(maxBufferSize - availableBytes)
|
"available": String(maxBufferSize - availableBytes),
|
||||||
])
|
])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -97,7 +99,6 @@ public class AudioBuffer {
|
|||||||
let packet = AudioPacket(
|
let packet = AudioPacket(
|
||||||
timestamp: Date(),
|
timestamp: Date(),
|
||||||
duration: chunkDuration,
|
duration: chunkDuration,
|
||||||
peakAmplitude: 0.0,
|
|
||||||
rawAudioData: chunkData
|
rawAudioData: chunkData
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,6 @@ public class AudioFormatConverter {
|
|||||||
return AudioPacket(
|
return AudioPacket(
|
||||||
timestamp: packet.timestamp,
|
timestamp: packet.timestamp,
|
||||||
duration: packet.duration,
|
duration: packet.duration,
|
||||||
peakAmplitude: packet.peakAmplitude,
|
|
||||||
rawAudioData: outputData
|
rawAudioData: outputData
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,18 +3,15 @@ import Foundation
|
|||||||
public struct AudioPacket {
|
public struct AudioPacket {
|
||||||
public let timestamp: Date
|
public let timestamp: Date
|
||||||
public let duration: Double
|
public let duration: Double
|
||||||
public let peakAmplitude: Float // useful for level monitoring
|
|
||||||
public let rawAudioData: Data
|
public let rawAudioData: Data
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
duration: Double,
|
duration: Double,
|
||||||
peakAmplitude: Float,
|
|
||||||
rawAudioData: Data
|
rawAudioData: Data
|
||||||
) {
|
) {
|
||||||
self.timestamp = timestamp
|
self.timestamp = timestamp
|
||||||
self.duration = duration
|
self.duration = duration
|
||||||
self.peakAmplitude = peakAmplitude
|
|
||||||
self.rawAudioData = rawAudioData
|
self.rawAudioData = rawAudioData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 init() {}
|
||||||
|
|
||||||
public func handleAudioPacket(_ packet: AudioPacket) {
|
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
|
// Write raw binary audio data directly to stdout
|
||||||
FileHandle.standardOutput.write(packet.rawAudioData)
|
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)
|
let message = Message(type: type, data: data)
|
||||||
do {
|
do {
|
||||||
let jsonData = try jsonEncoder.encode(message)
|
let jsonData = try jsonEncoder.encode(message)
|
||||||
FileHandle.standardOutput.write(jsonData)
|
FileHandle.standardError.write(jsonData)
|
||||||
FileHandle.standardOutput.write("\n".data(using: .utf8)!)
|
FileHandle.standardError.write("\n".data(using: .utf8)!)
|
||||||
} catch {
|
} catch {
|
||||||
// TODO: handle at some point
|
// TODO: handle at some point
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
Binary file not shown.
Reference in New Issue
Block a user