Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1eb465142 | |||
| 65c2d58c82 | |||
| 85975d6cc3 | |||
| 1cd2e83060 | |||
| 4600e34bfb | |||
| 08f0bc8f6c |
@@ -9,7 +9,6 @@ struct AudioTee {
|
|||||||
var stereo: Bool = false
|
var stereo: Bool = false
|
||||||
var sampleRate: Double?
|
var sampleRate: Double?
|
||||||
var chunkDuration: Double = 0.2
|
var chunkDuration: Double = 0.2
|
||||||
var flush: Bool = false
|
|
||||||
|
|
||||||
init() {}
|
init() {}
|
||||||
|
|
||||||
@@ -33,7 +32,6 @@ struct AudioTee {
|
|||||||
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
|
||||||
audiotee --mute # Mute processes being tapped
|
audiotee --mute # Mute processes being tapped
|
||||||
audiotee --flush # Flush stdout after each chunk
|
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,7 +43,6 @@ struct AudioTee {
|
|||||||
name: "exclude-processes", help: "Process IDs to exclude (space-separated)")
|
name: "exclude-processes", help: "Process IDs to exclude (space-separated)")
|
||||||
parser.addFlag(name: "mute", help: "Mute processes being tapped")
|
parser.addFlag(name: "mute", help: "Mute processes being tapped")
|
||||||
parser.addFlag(name: "stereo", help: "Records in stereo")
|
parser.addFlag(name: "stereo", help: "Records in stereo")
|
||||||
parser.addFlag(name: "flush", help: "Flush stdout after each audio chunk (reduces latency when piping)")
|
|
||||||
parser.addOption(
|
parser.addOption(
|
||||||
name: "sample-rate",
|
name: "sample-rate",
|
||||||
help: "Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)")
|
help: "Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)")
|
||||||
@@ -63,7 +60,6 @@ struct AudioTee {
|
|||||||
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")
|
||||||
audioTee.stereo = parser.getFlag("stereo")
|
audioTee.stereo = parser.getFlag("stereo")
|
||||||
audioTee.flush = parser.getFlag("flush")
|
|
||||||
audioTee.sampleRate = try parser.getOptionalValue("sample-rate", as: Double.self)
|
audioTee.sampleRate = try parser.getOptionalValue("sample-rate", as: Double.self)
|
||||||
audioTee.chunkDuration = try parser.getValue("chunk-duration", as: Double.self)
|
audioTee.chunkDuration = try parser.getValue("chunk-duration", as: Double.self)
|
||||||
|
|
||||||
@@ -99,11 +95,11 @@ struct AudioTee {
|
|||||||
func run() throws {
|
func run() throws {
|
||||||
setupSignalHandlers()
|
setupSignalHandlers()
|
||||||
|
|
||||||
Logger.info("Starting AudioTee...")
|
AudioTeeLogging.logger.info("Starting AudioTee...")
|
||||||
|
|
||||||
// Validate chunk duration
|
// Validate chunk duration
|
||||||
guard chunkDuration > 0 && chunkDuration <= 5.0 else {
|
guard chunkDuration > 0 && chunkDuration <= 5.0 else {
|
||||||
Logger.error(
|
AudioTeeLogging.logger.error(
|
||||||
"Invalid chunk duration",
|
"Invalid chunk duration",
|
||||||
context: ["chunk_duration": String(chunkDuration), "valid_range": "0.0 < duration <= 5.0"])
|
context: ["chunk_duration": String(chunkDuration), "valid_range": "0.0 < duration <= 5.0"])
|
||||||
throw ExitCode.failure
|
throw ExitCode.failure
|
||||||
@@ -123,7 +119,7 @@ struct AudioTee {
|
|||||||
do {
|
do {
|
||||||
try audioTapManager.setupAudioTap(with: tapConfig)
|
try audioTapManager.setupAudioTap(with: tapConfig)
|
||||||
} catch AudioTeeError.pidTranslationFailed(let failedPIDs) {
|
} catch AudioTeeError.pidTranslationFailed(let failedPIDs) {
|
||||||
Logger.error(
|
AudioTeeLogging.logger.error(
|
||||||
"Failed to translate process IDs to audio objects",
|
"Failed to translate process IDs to audio objects",
|
||||||
context: [
|
context: [
|
||||||
"failed_pids": failedPIDs.map(String.init).joined(separator: ", "),
|
"failed_pids": failedPIDs.map(String.init).joined(separator: ", "),
|
||||||
@@ -131,21 +127,21 @@ struct AudioTee {
|
|||||||
])
|
])
|
||||||
throw ExitCode.failure
|
throw ExitCode.failure
|
||||||
} catch {
|
} catch {
|
||||||
Logger.error(
|
AudioTeeLogging.logger.error(
|
||||||
"Failed to setup audio tap", context: ["error": String(describing: error)])
|
"Failed to setup audio tap", context: ["error": String(describing: error)])
|
||||||
throw ExitCode.failure
|
throw ExitCode.failure
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let deviceID = audioTapManager.getDeviceID() else {
|
guard let deviceID = audioTapManager.getDeviceID() else {
|
||||||
Logger.error("Failed to get device ID from audio tap manager")
|
AudioTeeLogging.logger.error("Failed to get device ID from audio tap manager")
|
||||||
throw ExitCode.failure
|
throw ExitCode.failure
|
||||||
}
|
}
|
||||||
|
|
||||||
let outputHandler = BinaryAudioOutputHandler(flushAfterWrite: flush)
|
let outputHandler = BinaryAudioOutputHandler()
|
||||||
let recorder = AudioRecorder(
|
let recorder = try AudioRecorder(
|
||||||
deviceID: deviceID, outputHandler: outputHandler, convertToSampleRate: sampleRate,
|
deviceID: deviceID, outputHandler: outputHandler, convertToSampleRate: sampleRate,
|
||||||
chunkDuration: chunkDuration)
|
chunkDuration: chunkDuration)
|
||||||
recorder.startRecording()
|
try recorder.startRecording()
|
||||||
|
|
||||||
// Run until the run loop is stopped (by signal handler)
|
// Run until the run loop is stopped (by signal handler)
|
||||||
while true {
|
while true {
|
||||||
@@ -155,17 +151,17 @@ struct AudioTee {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.info("Shutting down...")
|
AudioTeeLogging.logger.info("Shutting down...")
|
||||||
recorder.stopRecording()
|
recorder.stopRecording()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupSignalHandlers() {
|
private func setupSignalHandlers() {
|
||||||
signal(SIGINT) { _ in
|
signal(SIGINT) { _ in
|
||||||
Logger.info("Received SIGINT, initiating graceful shutdown...")
|
AudioTeeLogging.logger.info("Received SIGINT, initiating graceful shutdown...")
|
||||||
CFRunLoopStop(CFRunLoopGetMain())
|
CFRunLoopStop(CFRunLoopGetMain())
|
||||||
}
|
}
|
||||||
signal(SIGTERM) { _ in
|
signal(SIGTERM) { _ in
|
||||||
Logger.info("Received SIGTERM, initiating graceful shutdown...")
|
AudioTeeLogging.logger.info("Received SIGTERM, initiating graceful shutdown...")
|
||||||
CFRunLoopStop(CFRunLoopGetMain())
|
CFRunLoopStop(CFRunLoopGetMain())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import AudioTeeCore
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// CLI-specific output handler that writes raw PCM audio to stdout
|
||||||
|
/// and lifecycle messages to stderr via the logger.
|
||||||
|
class BinaryAudioOutputHandler: AudioOutputHandler {
|
||||||
|
private let fd = STDOUT_FILENO
|
||||||
|
|
||||||
|
func handleAudioData(_ pointer: UnsafeRawPointer, count: Int) {
|
||||||
|
var written = 0
|
||||||
|
while written < count {
|
||||||
|
let result = write(fd, pointer.advanced(by: written), count - written)
|
||||||
|
if result >= 0 {
|
||||||
|
written += result
|
||||||
|
} else if errno == EINTR {
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
break // EPIPE, EIO, etc — consumer gone or real error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleMetadata(_ metadata: AudioStreamMetadata) {
|
||||||
|
AudioTeeLogging.logger.writeMessage(.metadata, data: metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleStreamStart() {
|
||||||
|
AudioTeeLogging.logger.writeMessage(.streamStart, data: Optional<String>.none)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleStreamStop() {
|
||||||
|
AudioTeeLogging.logger.writeMessage(.streamStop, data: Optional<String>.none)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,105 +1,121 @@
|
|||||||
import CoreAudio
|
import CoreAudio
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// Ring buffer for accumulating raw audio data and extracting fixed-size chunks.
|
||||||
|
///
|
||||||
|
/// Uses a raw heap-allocated pointer rather than Swift Array to avoid
|
||||||
|
/// copy-on-write reference-count checks on every mutation. This buffer
|
||||||
|
/// lives on the real-time audio IO thread and is never shared, so COW
|
||||||
|
/// semantics are pure overhead.
|
||||||
public class AudioBuffer {
|
public class AudioBuffer {
|
||||||
private var buffer: [UInt8]
|
/// Raw heap-allocated ring buffer backing store.
|
||||||
|
private let buffer: UnsafeMutableRawPointer
|
||||||
|
/// Pre-allocated buffer for linearizing chunks that straddle the ring
|
||||||
|
/// buffer boundary. Avoids a heap allocation on the wrap-around path.
|
||||||
|
private let linearizationBuffer: UnsafeMutableRawPointer
|
||||||
private var writeIndex: Int = 0
|
private var writeIndex: Int = 0
|
||||||
private var readIndex: Int = 0
|
private var readIndex: Int = 0
|
||||||
private var availableBytes: Int = 0
|
private var availableBytes: Int = 0
|
||||||
private let maxBufferSize: Int
|
private let maxBufferSize: Int
|
||||||
|
|
||||||
private let bytesPerChunk: Int
|
public let bytesPerChunk: Int
|
||||||
private let chunkDuration: Double
|
|
||||||
|
|
||||||
public init(format: AudioStreamBasicDescription, chunkDuration: Double = 0.2) {
|
public init(format: AudioStreamBasicDescription, chunkDuration: Double = 0.2) {
|
||||||
|
|
||||||
// Pre-calculate chunk parameters
|
// Pre-calculate chunk parameters
|
||||||
let bytesPerFrame = Int(format.mBytesPerFrame)
|
let bytesPerFrame = Int(format.mBytesPerFrame)
|
||||||
let samplesPerChunk = Int(format.mSampleRate * chunkDuration)
|
let samplesPerChunk = Int(format.mSampleRate * chunkDuration)
|
||||||
self.bytesPerChunk = samplesPerChunk * bytesPerFrame
|
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
|
// Calculate max buffer size to hold ~10 seconds of audio (safety limit)
|
||||||
let bytesPerSecond = Int(format.mSampleRate) * bytesPerFrame
|
let bytesPerSecond = Int(format.mSampleRate) * bytesPerFrame
|
||||||
self.maxBufferSize = bytesPerSecond * 10
|
self.maxBufferSize = bytesPerSecond * 10
|
||||||
|
|
||||||
// Pre-allocated ring buffer
|
// Allocate raw memory. We use UnsafeMutableRawPointer instead of [UInt8]
|
||||||
self.buffer = Array(repeating: 0, count: maxBufferSize)
|
// to eliminate Swift Array's COW ref-count check on every write/read.
|
||||||
|
self.buffer = UnsafeMutableRawPointer.allocate(
|
||||||
|
byteCount: maxBufferSize,
|
||||||
|
alignment: MemoryLayout<UInt8>.alignment
|
||||||
|
)
|
||||||
|
buffer.initializeMemory(as: UInt8.self, repeating: 0, count: maxBufferSize)
|
||||||
|
|
||||||
|
self.linearizationBuffer = UnsafeMutableRawPointer.allocate(
|
||||||
|
byteCount: bytesPerChunk,
|
||||||
|
alignment: MemoryLayout<UInt8>.alignment
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func append(_ data: Data) {
|
deinit {
|
||||||
guard availableBytes + data.count <= maxBufferSize else {
|
buffer.deallocate()
|
||||||
Logger.error(
|
linearizationBuffer.deallocate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends audio data directly from a raw pointer into the ring buffer.
|
||||||
|
/// This is the fast path used by the IO proc callback: one memcpy from
|
||||||
|
/// the Core Audio buffer into our ring buffer, with no intermediate
|
||||||
|
/// Data allocation.
|
||||||
|
public func append(from source: UnsafeRawPointer, count: Int) {
|
||||||
|
guard count >= 0 else {
|
||||||
|
AudioTeeLogging.logger.error(
|
||||||
|
"Audio buffer append called with negative count",
|
||||||
|
context: ["count": String(count)])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard availableBytes + count <= maxBufferSize else {
|
||||||
|
AudioTeeLogging.logger.error(
|
||||||
"Audio buffer overflow",
|
"Audio buffer overflow",
|
||||||
context: [
|
context: [
|
||||||
"requested": String(data.count),
|
"requested": String(count),
|
||||||
"available": String(maxBufferSize - availableBytes),
|
"available": String(maxBufferSize - availableBytes),
|
||||||
])
|
])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data.withUnsafeBytes { bytes in
|
if writeIndex + count <= maxBufferSize {
|
||||||
let sourceBytes = bytes.bindMemory(to: UInt8.self)
|
// Single contiguous write — no wrap-around needed
|
||||||
let dataSize = sourceBytes.count
|
buffer.advanced(by: writeIndex).copyMemory(from: source, byteCount: count)
|
||||||
|
writeIndex = (writeIndex + count) % maxBufferSize
|
||||||
// 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 {
|
} else {
|
||||||
// two writes needed due to wrap-around
|
// Two writes needed due to wrap-around at the end of the ring buffer
|
||||||
let firstChunkSize = maxBufferSize - writeIndex
|
let firstChunkSize = maxBufferSize - writeIndex
|
||||||
let secondChunkSize = dataSize - firstChunkSize
|
let secondChunkSize = count - firstChunkSize
|
||||||
|
|
||||||
buffer.replaceSubrange(writeIndex..<maxBufferSize, with: sourceBytes.prefix(firstChunkSize))
|
buffer.advanced(by: writeIndex).copyMemory(from: source, byteCount: firstChunkSize)
|
||||||
buffer.replaceSubrange(0..<secondChunkSize, with: sourceBytes.suffix(secondChunkSize))
|
buffer.copyMemory(from: source.advanced(by: firstChunkSize), byteCount: secondChunkSize)
|
||||||
|
|
||||||
writeIndex = secondChunkSize
|
writeIndex = secondChunkSize
|
||||||
}
|
}
|
||||||
|
|
||||||
|
availableBytes += count
|
||||||
}
|
}
|
||||||
|
|
||||||
availableBytes += data.count
|
/// Calls `handler` once for each complete chunk available in the buffer.
|
||||||
}
|
/// The pointer passed to the handler is valid only for the duration of
|
||||||
|
/// that call. In the common (contiguous) case this points directly into
|
||||||
public func processChunks() -> [AudioPacket] {
|
/// the ring buffer — zero copies. In the wrap-around case the chunk is
|
||||||
var packets: [AudioPacket] = []
|
/// linearized into a pre-allocated scratch buffer — one memcpy, zero
|
||||||
|
/// heap allocations.
|
||||||
while let packet = nextChunk() {
|
public func processChunks(_ handler: (UnsafeRawPointer, Int) -> Void) {
|
||||||
packets.append(packet)
|
while availableBytes >= bytesPerChunk {
|
||||||
}
|
|
||||||
|
|
||||||
return packets
|
|
||||||
}
|
|
||||||
|
|
||||||
private func nextChunk() -> AudioPacket? {
|
|
||||||
// Check if we have enough data for a complete chunk
|
|
||||||
guard availableBytes >= bytesPerChunk else { return nil }
|
|
||||||
|
|
||||||
var chunkData = Data(capacity: bytesPerChunk)
|
|
||||||
|
|
||||||
// Check if we can copy in one block (no wrap-around)
|
|
||||||
if readIndex + bytesPerChunk <= maxBufferSize {
|
if readIndex + bytesPerChunk <= maxBufferSize {
|
||||||
// one copy needed
|
// Contiguous: point directly into the ring buffer
|
||||||
chunkData.append(contentsOf: buffer[readIndex..<readIndex + bytesPerChunk])
|
handler(buffer.advanced(by: readIndex), bytesPerChunk)
|
||||||
readIndex = (readIndex + bytesPerChunk) % maxBufferSize
|
readIndex = (readIndex + bytesPerChunk) % maxBufferSize
|
||||||
} else {
|
} else {
|
||||||
// two copies needed due to wrap-around
|
// Wrap-around: linearize into the pre-allocated scratch buffer
|
||||||
let firstChunkSize = maxBufferSize - readIndex
|
let firstChunkSize = maxBufferSize - readIndex
|
||||||
let secondChunkSize = bytesPerChunk - firstChunkSize
|
let secondChunkSize = bytesPerChunk - firstChunkSize
|
||||||
|
|
||||||
chunkData.append(contentsOf: buffer[readIndex..<maxBufferSize])
|
linearizationBuffer.copyMemory(
|
||||||
chunkData.append(contentsOf: buffer[0..<secondChunkSize])
|
from: buffer.advanced(by: readIndex), byteCount: firstChunkSize)
|
||||||
|
linearizationBuffer.advanced(by: firstChunkSize).copyMemory(
|
||||||
|
from: buffer, byteCount: secondChunkSize)
|
||||||
|
|
||||||
|
handler(linearizationBuffer, bytesPerChunk)
|
||||||
readIndex = secondChunkSize
|
readIndex = secondChunkSize
|
||||||
}
|
}
|
||||||
|
|
||||||
availableBytes -= bytesPerChunk
|
availableBytes -= bytesPerChunk
|
||||||
|
}
|
||||||
return AudioPacket(
|
|
||||||
timestamp: Date(),
|
|
||||||
duration: chunkDuration,
|
|
||||||
data: chunkData
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,22 @@ import AVFoundation
|
|||||||
import CoreAudio
|
import CoreAudio
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Simple audio format converter using AVFoundation
|
/// Audio format converter using AVFoundation's AVAudioConverter.
|
||||||
|
///
|
||||||
|
/// Pre-allocates input/output buffers on first use and reuses them across
|
||||||
|
/// transform() calls. This eliminates two AVAudioPCMBuffer heap allocations
|
||||||
|
/// per chunk — significant when chunks are small (50ms = 20 calls/sec).
|
||||||
public class AudioFormatConverter {
|
public class AudioFormatConverter {
|
||||||
private let avConverter: AVAudioConverter
|
private let avConverter: AVAudioConverter
|
||||||
private let sourceFormat: AVAudioFormat
|
private let sourceFormat: AVAudioFormat
|
||||||
private let targetFormat: AVAudioFormat
|
private let targetFormat: AVAudioFormat
|
||||||
|
|
||||||
|
/// Pre-allocated buffers reused across transform() calls. Lazily created
|
||||||
|
/// on first transform() since we need the actual input frame count to
|
||||||
|
/// size them correctly.
|
||||||
|
private var cachedInputBuffer: AVAudioPCMBuffer?
|
||||||
|
private var cachedOutputBuffer: AVAudioPCMBuffer?
|
||||||
|
|
||||||
public init(sourceFormat: AudioStreamBasicDescription, targetFormat: AudioStreamBasicDescription)
|
public init(sourceFormat: AudioStreamBasicDescription, targetFormat: AudioStreamBasicDescription)
|
||||||
throws
|
throws
|
||||||
{
|
{
|
||||||
@@ -28,7 +38,7 @@ public class AudioFormatConverter {
|
|||||||
self.targetFormat = targetAVFormat
|
self.targetFormat = targetAVFormat
|
||||||
self.avConverter = converter
|
self.avConverter = converter
|
||||||
|
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Audio converter created",
|
"Audio converter created",
|
||||||
context: [
|
context: [
|
||||||
"source_sample_rate": String(sourceAVFormat.sampleRate),
|
"source_sample_rate": String(sourceAVFormat.sampleRate),
|
||||||
@@ -39,7 +49,7 @@ public class AudioFormatConverter {
|
|||||||
|
|
||||||
// Warn about upsampling once during initialization
|
// Warn about upsampling once during initialization
|
||||||
if targetAVFormat.sampleRate > sourceAVFormat.sampleRate {
|
if targetAVFormat.sampleRate > sourceAVFormat.sampleRate {
|
||||||
Logger.info(
|
AudioTeeLogging.logger.info(
|
||||||
"Upsampling audio - this doesn't add frequency content above the original Nyquist limit",
|
"Upsampling audio - this doesn't add frequency content above the original Nyquist limit",
|
||||||
context: [
|
context: [
|
||||||
"source_rate": String(sourceAVFormat.sampleRate),
|
"source_rate": String(sourceAVFormat.sampleRate),
|
||||||
@@ -48,58 +58,108 @@ public class AudioFormatConverter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the target format as AudioStreamBasicDescription
|
/// The source format this converter reads from.
|
||||||
|
public var sourceFormatDescription: AudioStreamBasicDescription {
|
||||||
|
return sourceFormat.streamDescription.pointee
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The target format this converter produces.
|
||||||
public var targetFormatDescription: AudioStreamBasicDescription {
|
public var targetFormatDescription: AudioStreamBasicDescription {
|
||||||
return targetFormat.streamDescription.pointee
|
return targetFormat.streamDescription.pointee
|
||||||
}
|
}
|
||||||
|
|
||||||
public func transform(_ packet: AudioPacket) -> AudioPacket {
|
/// Returns pre-allocated input and output buffers sized for the given
|
||||||
let inputData = packet.data
|
/// input frame count. Allocates once on first call; reuses on subsequent
|
||||||
|
/// calls when capacity is sufficient. Re-allocates if a larger frame
|
||||||
|
/// count arrives (shouldn't happen with fixed chunk sizes, but handled
|
||||||
|
/// gracefully).
|
||||||
|
private func getBuffers(inputFrameCount: AVAudioFrameCount)
|
||||||
|
-> (input: AVAudioPCMBuffer, output: AVAudioPCMBuffer)?
|
||||||
|
{
|
||||||
|
// ceil() prevents float-to-int truncation from undersizing the buffer
|
||||||
|
// by one frame (e.g. 3199.9999 → 3199 instead of 3200).
|
||||||
|
let outputFrameCount = AVAudioFrameCount(
|
||||||
|
ceil(Double(inputFrameCount) * (targetFormat.sampleRate / sourceFormat.sampleRate))
|
||||||
|
)
|
||||||
|
|
||||||
// Calculate frame counts
|
// Reuse cached buffers if they have sufficient capacity
|
||||||
let inputFrameCount =
|
if let inputBuf = cachedInputBuffer,
|
||||||
inputData.count / Int(sourceFormat.streamDescription.pointee.mBytesPerFrame)
|
let outputBuf = cachedOutputBuffer,
|
||||||
let outputFrameCount = Int(
|
inputBuf.frameCapacity >= inputFrameCount,
|
||||||
Double(inputFrameCount) * (targetFormat.sampleRate / sourceFormat.sampleRate))
|
outputBuf.frameCapacity >= outputFrameCount
|
||||||
|
{
|
||||||
// Create input buffer
|
// Reset frame lengths for reuse — the underlying memory is retained,
|
||||||
guard
|
// we just tell AVAudioPCMBuffer how many frames are valid this time.
|
||||||
let inputBuffer = AVAudioPCMBuffer(
|
inputBuf.frameLength = 0
|
||||||
pcmFormat: sourceFormat, frameCapacity: AVAudioFrameCount(inputFrameCount))
|
outputBuf.frameLength = 0
|
||||||
else {
|
return (inputBuf, outputBuf)
|
||||||
Logger.error("Failed to create input buffer")
|
|
||||||
return packet
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy input data to buffer
|
// Allocate new buffers (first call, or unexpected capacity increase)
|
||||||
inputData.withUnsafeBytes { bytes in
|
guard
|
||||||
|
let inputBuf = AVAudioPCMBuffer(
|
||||||
|
pcmFormat: sourceFormat, frameCapacity: inputFrameCount)
|
||||||
|
else {
|
||||||
|
AudioTeeLogging.logger.error("Failed to create input buffer")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard
|
||||||
|
let outputBuf = AVAudioPCMBuffer(
|
||||||
|
pcmFormat: targetFormat, frameCapacity: outputFrameCount)
|
||||||
|
else {
|
||||||
|
AudioTeeLogging.logger.error("Failed to create output buffer")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache for reuse on subsequent calls
|
||||||
|
cachedInputBuffer = inputBuf
|
||||||
|
cachedOutputBuffer = outputBuf
|
||||||
|
|
||||||
|
AudioTeeLogging.logger.debug(
|
||||||
|
"Allocated converter buffers",
|
||||||
|
context: [
|
||||||
|
"input_frame_capacity": String(inputFrameCount),
|
||||||
|
"output_frame_capacity": String(outputFrameCount),
|
||||||
|
])
|
||||||
|
|
||||||
|
return (inputBuf, outputBuf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts audio data in-place through the pre-allocated converter buffers.
|
||||||
|
/// Calls `handler` with a pointer to the converted output, valid only for
|
||||||
|
/// the duration of that call. Returns false on failure (caller should
|
||||||
|
/// pass through the original data or drop it).
|
||||||
|
@discardableResult
|
||||||
|
public func transform(
|
||||||
|
from source: UnsafeRawPointer, count: Int,
|
||||||
|
handler: (UnsafeRawPointer, Int) -> Void
|
||||||
|
) -> Bool {
|
||||||
|
let bytesPerFrame = Int(sourceFormat.streamDescription.pointee.mBytesPerFrame)
|
||||||
|
let inputFrameCount = AVAudioFrameCount(count / bytesPerFrame)
|
||||||
|
|
||||||
|
guard let (inputBuffer, outputBuffer) = getBuffers(inputFrameCount: inputFrameCount) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy source data into the reusable input buffer
|
||||||
let dest = inputBuffer.audioBufferList.pointee.mBuffers.mData!
|
let dest = inputBuffer.audioBufferList.pointee.mBuffers.mData!
|
||||||
dest.copyMemory(from: bytes.baseAddress!, byteCount: inputData.count)
|
dest.copyMemory(from: source, byteCount: count)
|
||||||
}
|
inputBuffer.frameLength = inputFrameCount
|
||||||
inputBuffer.frameLength = AVAudioFrameCount(inputFrameCount)
|
|
||||||
|
|
||||||
// Create output buffer
|
// Perform conversion — we do NOT call avConverter.reset() between
|
||||||
guard
|
// calls because the resampler maintains internal state for continuity
|
||||||
let outputBuffer = AVAudioPCMBuffer(
|
// across chunks (avoiding discontinuity artifacts).
|
||||||
pcmFormat: targetFormat, frameCapacity: AVAudioFrameCount(outputFrameCount))
|
|
||||||
else {
|
|
||||||
Logger.error("Failed to create output buffer")
|
|
||||||
return packet
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform conversion - simpler approach
|
|
||||||
var error: NSError?
|
var error: NSError?
|
||||||
|
|
||||||
let status = avConverter.convert(to: outputBuffer, error: &error) {
|
let status = avConverter.convert(to: outputBuffer, error: &error) {
|
||||||
requestedPackets, outStatus in
|
requestedPackets, outStatus in
|
||||||
// Always provide our input buffer and let converter manage it
|
|
||||||
outStatus.pointee = .haveData
|
outStatus.pointee = .haveData
|
||||||
return inputBuffer
|
return inputBuffer
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if conversion produced output (regardless of status code)
|
|
||||||
guard outputBuffer.frameLength > 0 else {
|
guard outputBuffer.frameLength > 0 else {
|
||||||
Logger.error(
|
AudioTeeLogging.logger.error(
|
||||||
"Audio conversion produced no output",
|
"Audio conversion produced no output",
|
||||||
context: [
|
context: [
|
||||||
"status": String(describing: status),
|
"status": String(describing: status),
|
||||||
@@ -107,20 +167,13 @@ public class AudioFormatConverter {
|
|||||||
"input_frames": String(inputBuffer.frameLength),
|
"input_frames": String(inputBuffer.frameLength),
|
||||||
"output_capacity": String(outputBuffer.frameCapacity),
|
"output_capacity": String(outputBuffer.frameCapacity),
|
||||||
])
|
])
|
||||||
return packet
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract converted data
|
let outputCount = Int(
|
||||||
let outputData = Data(
|
outputBuffer.frameLength * targetFormat.streamDescription.pointee.mBytesPerFrame)
|
||||||
bytes: outputBuffer.audioBufferList.pointee.mBuffers.mData!,
|
handler(outputBuffer.audioBufferList.pointee.mBuffers.mData!, outputCount)
|
||||||
count: Int(outputBuffer.frameLength * targetFormat.streamDescription.pointee.mBytesPerFrame))
|
return true
|
||||||
|
|
||||||
// Return new packet with converted audio (keeping original metadata for simplicity)
|
|
||||||
return AudioPacket(
|
|
||||||
timestamp: packet.timestamp,
|
|
||||||
duration: packet.duration,
|
|
||||||
data: outputData
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func toSampleRate(
|
public static func toSampleRate(
|
||||||
|
|||||||
@@ -3,25 +3,26 @@ import CoreAudio
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class AudioFormatManager {
|
public class AudioFormatManager {
|
||||||
public static func getDeviceFormat(deviceID: AudioObjectID) -> AudioStreamBasicDescription {
|
public static func getDeviceFormat(deviceID: AudioObjectID) throws -> AudioStreamBasicDescription
|
||||||
|
{
|
||||||
// First, wait for the device to become alive/ready
|
// First, wait for the device to become alive/ready
|
||||||
let deviceReadyTimeout = 2.0 // 2 seconds max wait
|
let deviceReadyTimeout = 2.0 // 2 seconds max wait
|
||||||
let pollInterval = 0.1 // 100ms poll interval
|
let pollInterval = 0.1 // 100ms poll interval
|
||||||
let maxPolls = Int(deviceReadyTimeout / pollInterval)
|
let maxPolls = Int(deviceReadyTimeout / pollInterval)
|
||||||
|
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Waiting for audio device to become ready", context: ["device_id": String(deviceID)])
|
"Waiting for audio device to become ready", context: ["device_id": String(deviceID)])
|
||||||
|
|
||||||
// Poll device readiness
|
// Poll device readiness
|
||||||
for poll in 1...maxPolls {
|
for poll in 1...maxPolls {
|
||||||
if isAudioDeviceValid(deviceID) {
|
if isAudioDeviceValid(deviceID) {
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Audio device is ready", context: ["device_id": String(deviceID), "polls": String(poll)])
|
"Audio device is ready", context: ["device_id": String(deviceID), "polls": String(poll)])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if poll == maxPolls {
|
if poll == maxPolls {
|
||||||
Logger.info(
|
AudioTeeLogging.logger.info(
|
||||||
"Device did not become ready within timeout, proceeding anyway",
|
"Device did not become ready within timeout, proceeding anyway",
|
||||||
context: [
|
context: [
|
||||||
"device_id": String(deviceID),
|
"device_id": String(deviceID),
|
||||||
@@ -30,7 +31,7 @@ public class AudioFormatManager {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.info("------- not ready; retrying...")
|
AudioTeeLogging.logger.info("------- not ready; retrying...")
|
||||||
|
|
||||||
Thread.sleep(forTimeInterval: pollInterval)
|
Thread.sleep(forTimeInterval: pollInterval)
|
||||||
}
|
}
|
||||||
@@ -49,11 +50,12 @@ public class AudioFormatManager {
|
|||||||
deviceID, &propertyAddress, 0, nil, &propertySize, &streamFormat)
|
deviceID, &propertyAddress, 0, nil, &propertySize, &streamFormat)
|
||||||
|
|
||||||
if status == noErr {
|
if status == noErr {
|
||||||
Logger.debug("Successfully retrieved device format", context: ["attempt": String(attempt)])
|
AudioTeeLogging.logger.debug(
|
||||||
|
"Successfully retrieved device format", context: ["attempt": String(attempt)])
|
||||||
return streamFormat
|
return streamFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.info(
|
AudioTeeLogging.logger.info(
|
||||||
"------- Failed to get stream format after device ready check, retrying...",
|
"------- Failed to get stream format after device ready check, retrying...",
|
||||||
context: [
|
context: [
|
||||||
"attempt": String(attempt),
|
"attempt": String(attempt),
|
||||||
@@ -69,16 +71,14 @@ public class AudioFormatManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If all attempts failed after device readiness confirmation, this is a genuine error
|
// If all attempts failed after device readiness confirmation, this is a genuine error
|
||||||
Logger.error(
|
AudioTeeLogging.logger.error(
|
||||||
"Failed to get device format after device readiness check and retries",
|
"Failed to get device format after device readiness check and retries",
|
||||||
context: [
|
context: [
|
||||||
"device_id": String(deviceID),
|
"device_id": String(deviceID),
|
||||||
"device_was_ready": "true",
|
"device_was_ready": "true",
|
||||||
])
|
])
|
||||||
|
|
||||||
fatalError(
|
throw AudioTeeError.deviceFormatUnavailable(deviceID)
|
||||||
"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 {
|
||||||
@@ -94,14 +94,8 @@ public class AudioFormatManager {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func writeMetadata(for format: AudioStreamBasicDescription) {
|
|
||||||
let metadata = createMetadata(for: format)
|
|
||||||
Logger.writeMessage(.metadata, data: metadata)
|
|
||||||
Logger.writeMessage(.streamStart, data: Optional<String>.none)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func logFormatInfo(_ format: AudioStreamBasicDescription) {
|
public static func logFormatInfo(_ format: AudioStreamBasicDescription) {
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Using device's native format",
|
"Using device's native format",
|
||||||
context: [
|
context: [
|
||||||
"channels": String(format.mChannelsPerFrame),
|
"channels": String(format.mChannelsPerFrame),
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public struct AudioPacket {
|
|
||||||
public let timestamp: Date
|
|
||||||
public let duration: Double
|
|
||||||
public let data: Data
|
|
||||||
|
|
||||||
public init(
|
|
||||||
timestamp: Date,
|
|
||||||
duration: Double,
|
|
||||||
data: Data
|
|
||||||
) {
|
|
||||||
self.timestamp = timestamp
|
|
||||||
self.duration = duration
|
|
||||||
self.data = data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,15 +10,25 @@ public class AudioRecorder {
|
|||||||
private var outputHandler: AudioOutputHandler
|
private var outputHandler: AudioOutputHandler
|
||||||
private var converter: AudioFormatConverter?
|
private var converter: AudioFormatConverter?
|
||||||
|
|
||||||
|
/// The audio format this recorder produces (after any conversion).
|
||||||
|
public var outputFormat: AudioStreamBasicDescription {
|
||||||
|
return finalFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this recorder is performing sample rate conversion.
|
||||||
|
public var isConverting: Bool {
|
||||||
|
return converter != nil
|
||||||
|
}
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
deviceID: AudioObjectID, outputHandler: AudioOutputHandler, convertToSampleRate: Double? = nil,
|
deviceID: AudioObjectID, outputHandler: AudioOutputHandler, convertToSampleRate: Double? = nil,
|
||||||
chunkDuration: Double = 0.2
|
chunkDuration: Double = 0.2
|
||||||
) {
|
) throws {
|
||||||
self.deviceID = deviceID
|
self.deviceID = deviceID
|
||||||
self.outputHandler = outputHandler
|
self.outputHandler = outputHandler
|
||||||
|
|
||||||
// Get source format and set up conversion if requested
|
// Get source format and set up conversion if requested
|
||||||
let sourceFormat = AudioFormatManager.getDeviceFormat(deviceID: deviceID)
|
let sourceFormat = try AudioFormatManager.getDeviceFormat(deviceID: deviceID)
|
||||||
|
|
||||||
// Set up the audio buffer using source format and configurable chunk duration
|
// Set up the audio buffer using source format and configurable chunk duration
|
||||||
self.audioBuffer = AudioBuffer(format: sourceFormat, chunkDuration: chunkDuration)
|
self.audioBuffer = AudioBuffer(format: sourceFormat, chunkDuration: chunkDuration)
|
||||||
@@ -26,7 +36,8 @@ public class AudioRecorder {
|
|||||||
if let targetSampleRate = convertToSampleRate {
|
if let targetSampleRate = convertToSampleRate {
|
||||||
// Validate sample rate
|
// Validate sample rate
|
||||||
guard AudioFormatConverter.isValidSampleRate(targetSampleRate) else {
|
guard AudioFormatConverter.isValidSampleRate(targetSampleRate) else {
|
||||||
Logger.error("Invalid sample rate", context: ["sample_rate": String(targetSampleRate)])
|
AudioTeeLogging.logger.error(
|
||||||
|
"Invalid sample rate", context: ["sample_rate": String(targetSampleRate)])
|
||||||
self.converter = nil
|
self.converter = nil
|
||||||
self.finalFormat = sourceFormat
|
self.finalFormat = sourceFormat
|
||||||
return
|
return
|
||||||
@@ -36,10 +47,10 @@ public class AudioRecorder {
|
|||||||
let converter = try AudioFormatConverter.toSampleRate(targetSampleRate, from: sourceFormat)
|
let converter = try AudioFormatConverter.toSampleRate(targetSampleRate, from: sourceFormat)
|
||||||
self.converter = converter
|
self.converter = converter
|
||||||
self.finalFormat = converter.targetFormatDescription
|
self.finalFormat = converter.targetFormatDescription
|
||||||
Logger.info(
|
AudioTeeLogging.logger.info(
|
||||||
"Audio conversion enabled", context: ["target_sample_rate": String(targetSampleRate)])
|
"Audio conversion enabled", context: ["target_sample_rate": String(targetSampleRate)])
|
||||||
} catch {
|
} catch {
|
||||||
Logger.error(
|
AudioTeeLogging.logger.error(
|
||||||
"Failed to create audio converter, using original format",
|
"Failed to create audio converter, using original format",
|
||||||
context: ["error": String(describing: error)])
|
context: ["error": String(describing: error)])
|
||||||
self.converter = nil
|
self.converter = nil
|
||||||
@@ -51,8 +62,8 @@ public class AudioRecorder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func startRecording() {
|
public func startRecording() throws {
|
||||||
Logger.debug("Starting audio recording")
|
AudioTeeLogging.logger.debug("Starting audio recording")
|
||||||
|
|
||||||
// Log format info and send metadata for final format
|
// Log format info and send metadata for final format
|
||||||
AudioFormatManager.logFormatInfo(finalFormat)
|
AudioFormatManager.logFormatInfo(finalFormat)
|
||||||
@@ -60,15 +71,15 @@ public class AudioRecorder {
|
|||||||
outputHandler.handleMetadata(metadata)
|
outputHandler.handleMetadata(metadata)
|
||||||
outputHandler.handleStreamStart()
|
outputHandler.handleStreamStart()
|
||||||
|
|
||||||
setupAndStartIOProc()
|
try setupAndStartIOProc()
|
||||||
|
|
||||||
Logger.info("Audio device started successfully")
|
AudioTeeLogging.logger.info("Audio device started successfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// No; AudioEngine.installTap() can only fire as often as 100ms. too slow for us
|
||||||
private func setupAndStartIOProc() {
|
private func setupAndStartIOProc() throws {
|
||||||
Logger.debug("Creating IO proc")
|
AudioTeeLogging.logger.debug("Creating IO proc")
|
||||||
var status = AudioDeviceCreateIOProcID(
|
var status = AudioDeviceCreateIOProcID(
|
||||||
deviceID,
|
deviceID,
|
||||||
{
|
{
|
||||||
@@ -82,15 +93,15 @@ public class AudioRecorder {
|
|||||||
)
|
)
|
||||||
|
|
||||||
guard status == noErr else {
|
guard status == noErr else {
|
||||||
fatalError("Failed to create IO proc: \(status)")
|
throw AudioTeeError.ioProcCreationFailed(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.debug("Starting audio device")
|
AudioTeeLogging.logger.debug("Starting audio device")
|
||||||
status = AudioDeviceStart(deviceID, ioProcID)
|
status = AudioDeviceStart(deviceID, ioProcID)
|
||||||
|
|
||||||
if status != noErr {
|
if status != noErr {
|
||||||
cleanupIOProc()
|
cleanupIOProc()
|
||||||
fatalError("Failed to start audio device: \(status). Device ID: \(deviceID)")
|
throw AudioTeeError.deviceStartFailed(status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,14 +109,16 @@ public class AudioRecorder {
|
|||||||
let bufferList = inputData.pointee
|
let bufferList = inputData.pointee
|
||||||
let firstBuffer = bufferList.mBuffers
|
let firstBuffer = bufferList.mBuffers
|
||||||
|
|
||||||
guard firstBuffer.mData != nil && firstBuffer.mDataByteSize > 0 else {
|
guard let sourcePointer = firstBuffer.mData, firstBuffer.mDataByteSize > 0 else {
|
||||||
"Warning: Received empty audio buffer".print(to: .standardError)
|
AudioTeeLogging.logger.error("Received empty audio buffer")
|
||||||
return noErr
|
return noErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append raw audio data to buffer
|
// Copy directly from the Core Audio buffer into our ring buffer.
|
||||||
let audioData = Data(bytes: firstBuffer.mData!, count: Int(firstBuffer.mDataByteSize))
|
// This avoids creating an intermediate Data object (heap alloc + memcpy)
|
||||||
audioBuffer?.append(audioData)
|
// on every IO callback (~10ms). The pointer is valid for the duration
|
||||||
|
// of this callback, so this is safe.
|
||||||
|
audioBuffer?.append(from: sourcePointer, count: Int(firstBuffer.mDataByteSize))
|
||||||
|
|
||||||
processAudioBuffer()
|
processAudioBuffer()
|
||||||
|
|
||||||
@@ -119,10 +132,17 @@ public class AudioRecorder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func processAudioBuffer() {
|
private func processAudioBuffer() {
|
||||||
// Process and send complete chunks, applying conversion if needed
|
audioBuffer?.processChunks { pointer, count in
|
||||||
audioBuffer?.processChunks().forEach { packet in
|
if let converter = self.converter {
|
||||||
let processedPacket = converter?.transform(packet) ?? packet
|
if !converter.transform(from: pointer, count: count, handler: { outPtr, outCount in
|
||||||
outputHandler.handleAudioPacket(processedPacket)
|
self.outputHandler.handleAudioData(outPtr, count: outCount)
|
||||||
|
}) {
|
||||||
|
// Conversion failed — pass through unconverted audio
|
||||||
|
self.outputHandler.handleAudioData(pointer, count: count)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.outputHandler.handleAudioData(pointer, count: count)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ public class AudioTapManager {
|
|||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
Logger.debug("Cleaning up audio tap manager")
|
AudioTeeLogging.logger.debug("Cleaning up audio tap manager")
|
||||||
|
|
||||||
if let tapID = tapID {
|
if let tapID = tapID {
|
||||||
AudioHardwareDestroyProcessTap(tapID)
|
AudioHardwareDestroyProcessTap(tapID)
|
||||||
@@ -25,7 +25,7 @@ public class AudioTapManager {
|
|||||||
|
|
||||||
/// Sets up the audio tap and aggregate device
|
/// Sets up the audio tap and aggregate device
|
||||||
public func setupAudioTap(with config: TapConfiguration) throws {
|
public func setupAudioTap(with config: TapConfiguration) throws {
|
||||||
Logger.debug("Setting up audio tap manager")
|
AudioTeeLogging.logger.debug("Setting up audio tap manager")
|
||||||
|
|
||||||
tapID = try createSystemAudioTap(with: config)
|
tapID = try createSystemAudioTap(with: config)
|
||||||
deviceID = try createAggregateDevice()
|
deviceID = try createAggregateDevice()
|
||||||
@@ -36,7 +36,7 @@ public class AudioTapManager {
|
|||||||
|
|
||||||
try addTapToAggregateDevice(tapID: tapID, deviceID: deviceID)
|
try addTapToAggregateDevice(tapID: tapID, deviceID: deviceID)
|
||||||
|
|
||||||
Logger.debug("Audio tap manager setup complete")
|
AudioTeeLogging.logger.debug("Audio tap manager setup complete")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the aggregate device ID for recording
|
/// Returns the aggregate device ID for recording
|
||||||
@@ -45,7 +45,7 @@ public class AudioTapManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func createSystemAudioTap(with config: TapConfiguration) throws -> AudioObjectID {
|
private func createSystemAudioTap(with config: TapConfiguration) throws -> AudioObjectID {
|
||||||
Logger.debug("Creating tap description")
|
AudioTeeLogging.logger.debug("Creating tap description")
|
||||||
let description = CATapDescription()
|
let description = CATapDescription()
|
||||||
|
|
||||||
description.name = "audiotee-tap"
|
description.name = "audiotee-tap"
|
||||||
@@ -58,7 +58,7 @@ public class AudioTapManager {
|
|||||||
description.deviceUID = nil // system default
|
description.deviceUID = nil // system default
|
||||||
description.stream = 0 // first stream of output device
|
description.stream = 0 // first stream of output device
|
||||||
|
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Tap description configured",
|
"Tap description configured",
|
||||||
context: [
|
context: [
|
||||||
"name": description.name,
|
"name": description.name,
|
||||||
@@ -69,14 +69,15 @@ public class AudioTapManager {
|
|||||||
])
|
])
|
||||||
|
|
||||||
// Create the tap
|
// Create the tap
|
||||||
Logger.debug("Creating tap")
|
AudioTeeLogging.logger.debug("Creating tap")
|
||||||
var tapID = AudioObjectID(kAudioObjectUnknown)
|
var tapID = AudioObjectID(kAudioObjectUnknown)
|
||||||
let status = AudioHardwareCreateProcessTap(description, &tapID)
|
let status = AudioHardwareCreateProcessTap(description, &tapID)
|
||||||
|
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"AudioHardwareCreateProcessTap completed", context: ["status": String(status)])
|
"AudioHardwareCreateProcessTap completed", context: ["status": String(status)])
|
||||||
guard status == kAudioHardwareNoError else {
|
guard status == kAudioHardwareNoError else {
|
||||||
Logger.error("Failed to create audio tap", context: ["status": String(status)])
|
AudioTeeLogging.logger.error(
|
||||||
|
"Failed to create audio tap", context: ["status": String(status)])
|
||||||
throw AudioTeeError.tapCreationFailed(status)
|
throw AudioTeeError.tapCreationFailed(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +89,7 @@ public class AudioTapManager {
|
|||||||
tapID, &propertyAddress, 0, nil, &propertySize, &streamDescription)
|
tapID, &propertyAddress, 0, nil, &propertySize, &streamDescription)
|
||||||
|
|
||||||
if formatStatus == noErr {
|
if formatStatus == noErr {
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Tap format retrieved",
|
"Tap format retrieved",
|
||||||
context: [
|
context: [
|
||||||
"channels": String(streamDescription.mChannelsPerFrame),
|
"channels": String(streamDescription.mChannelsPerFrame),
|
||||||
@@ -115,7 +116,8 @@ public class AudioTapManager {
|
|||||||
let status = AudioHardwareCreateAggregateDevice(description as CFDictionary, &deviceID)
|
let status = AudioHardwareCreateAggregateDevice(description as CFDictionary, &deviceID)
|
||||||
|
|
||||||
guard status == kAudioHardwareNoError else {
|
guard status == kAudioHardwareNoError else {
|
||||||
Logger.error("Failed to create aggregate device", context: ["status": String(status)])
|
AudioTeeLogging.logger.error(
|
||||||
|
"Failed to create aggregate device", context: ["status": String(status)])
|
||||||
throw AudioTeeError.aggregateDeviceCreationFailed(status)
|
throw AudioTeeError.aggregateDeviceCreationFailed(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +144,7 @@ public class AudioTapManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard status == kAudioHardwareNoError else {
|
guard status == kAudioHardwareNoError else {
|
||||||
Logger.error(
|
AudioTeeLogging.logger.error(
|
||||||
"Failed to add tap to aggregate device", context: ["status": String(status)])
|
"Failed to add tap to aggregate device", context: ["status": String(status)])
|
||||||
throw AudioTeeError.tapAssignmentFailed(status)
|
throw AudioTeeError.tapAssignmentFailed(status)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import CoreAudio
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
// MARK: - Core AudioTee Errors
|
// MARK: - Core AudioTee Errors
|
||||||
@@ -8,6 +9,9 @@ public enum AudioTeeError: Error {
|
|||||||
case aggregateDeviceCreationFailed(OSStatus)
|
case aggregateDeviceCreationFailed(OSStatus)
|
||||||
case tapAssignmentFailed(OSStatus)
|
case tapAssignmentFailed(OSStatus)
|
||||||
case pidTranslationFailed([Int32])
|
case pidTranslationFailed([Int32])
|
||||||
|
case deviceFormatUnavailable(AudioObjectID)
|
||||||
|
case ioProcCreationFailed(OSStatus)
|
||||||
|
case deviceStartFailed(OSStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Audio Format Conversion Errors
|
// MARK: - Audio Format Conversion Errors
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import Foundation
|
|||||||
|
|
||||||
/// Protocol for handling audio output in different formats
|
/// Protocol for handling audio output in different formats
|
||||||
public protocol AudioOutputHandler {
|
public protocol AudioOutputHandler {
|
||||||
func handleAudioPacket(_ packet: AudioPacket)
|
/// Called with a pointer to raw PCM audio data. The pointer is only
|
||||||
|
/// valid for the duration of this call.
|
||||||
|
func handleAudioData(_ pointer: UnsafeRawPointer, count: Int)
|
||||||
func handleMetadata(_ metadata: AudioStreamMetadata)
|
func handleMetadata(_ metadata: AudioStreamMetadata)
|
||||||
func handleStreamStart()
|
func handleStreamStart()
|
||||||
func handleStreamStop()
|
func handleStreamStop()
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public class BinaryAudioOutputHandler: AudioOutputHandler {
|
|
||||||
private let flushAfterWrite: Bool
|
|
||||||
|
|
||||||
public init(flushAfterWrite: Bool = false) {
|
|
||||||
self.flushAfterWrite = flushAfterWrite
|
|
||||||
}
|
|
||||||
public func handleAudioPacket(_ packet: AudioPacket) {
|
|
||||||
// Write raw binary audio data directly to stdout
|
|
||||||
FileHandle.standardOutput.write(packet.data)
|
|
||||||
if flushAfterWrite {
|
|
||||||
fflush(stdout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Logging protocol
|
||||||
|
|
||||||
|
/// Protocol that library consumers implement to receive log output.
|
||||||
|
/// The library never writes to stderr directly — it calls through this.
|
||||||
|
public protocol AudioTeeLogger {
|
||||||
|
func debug(_ message: String, context: [String: String]?)
|
||||||
|
func info(_ message: String, context: [String: String]?)
|
||||||
|
func error(_ message: String, context: [String: String]?)
|
||||||
|
|
||||||
|
/// Called for structured lifecycle messages (metadata, stream_start, stream_stop).
|
||||||
|
/// Default implementation is a no-op — pure library consumers get metadata
|
||||||
|
/// via AudioOutputHandler instead.
|
||||||
|
func writeMessage<T: Codable>(_ type: MessageType, data: T?)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Defaults
|
||||||
|
|
||||||
|
extension AudioTeeLogger {
|
||||||
|
/// Library consumers typically don't need structured message output;
|
||||||
|
/// they receive metadata via the AudioOutputHandler protocol instead.
|
||||||
|
public func writeMessage<T: Codable>(_ type: MessageType, data: T?) {}
|
||||||
|
|
||||||
|
/// Convenience overloads so callers can omit context when it's nil.
|
||||||
|
public func debug(_ message: String) { debug(message, context: nil) }
|
||||||
|
public func info(_ message: String) { info(message, context: nil) }
|
||||||
|
public func error(_ message: String) { error(message, context: nil) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Global logging configuration
|
||||||
|
|
||||||
|
/// Global logger instance. Defaults to StderrJSONLogger (CLI behavior).
|
||||||
|
/// Library consumers can replace this before calling any AudioTeeCore API.
|
||||||
|
///
|
||||||
|
/// // Silence all logging:
|
||||||
|
/// AudioTeeLogging.logger = NullLogger()
|
||||||
|
///
|
||||||
|
/// // Custom logging:
|
||||||
|
/// AudioTeeLogging.logger = MyOSLogLogger()
|
||||||
|
///
|
||||||
|
public enum AudioTeeLogging {
|
||||||
|
nonisolated(unsafe) public static var logger: AudioTeeLogger = StderrJSONLogger()
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class Logger {
|
/// Default logger implementation that writes JSON messages to stderr.
|
||||||
nonisolated(unsafe) private static let dateFormatter: ISO8601DateFormatter = {
|
/// This is the CLI-appropriate logger; library consumers can replace it
|
||||||
|
/// via AudioTeeLogging.logger.
|
||||||
|
public class StderrJSONLogger: AudioTeeLogger {
|
||||||
|
private let dateFormatter: ISO8601DateFormatter = {
|
||||||
let formatter = ISO8601DateFormatter()
|
let formatter = ISO8601DateFormatter()
|
||||||
formatter.formatOptions = [
|
formatter.formatOptions = [
|
||||||
.withInternetDateTime,
|
.withInternetDateTime,
|
||||||
@@ -10,17 +13,22 @@ public class Logger {
|
|||||||
return formatter
|
return formatter
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private static let jsonEncoder: JSONEncoder = {
|
private let jsonEncoder: JSONEncoder = {
|
||||||
let encoder = JSONEncoder()
|
let encoder = JSONEncoder()
|
||||||
encoder.dateEncodingStrategy = .custom { date, encoder in
|
|
||||||
var container = encoder.singleValueContainer()
|
|
||||||
try container.encode(dateFormatter.string(from: date))
|
|
||||||
}
|
|
||||||
return encoder
|
return encoder
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Write any message with the unified envelope
|
public init() {
|
||||||
public static func writeMessage<T: Codable>(_ type: MessageType, data: T? = nil) {
|
// Configured in init because stored property initializers can't
|
||||||
|
// reference other instance properties (self.dateFormatter).
|
||||||
|
jsonEncoder.dateEncodingStrategy = .custom { [dateFormatter] date, encoder in
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
try container.encode(dateFormatter.string(from: date))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write any message with the unified envelope to stderr
|
||||||
|
public func writeMessage<T: Codable>(_ type: MessageType, data: T?) {
|
||||||
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)
|
||||||
@@ -32,17 +40,17 @@ public class Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convenience methods for different message types
|
// Convenience methods for different message types
|
||||||
public static func info(_ message: String, context: [String: String]? = nil) {
|
public func info(_ message: String, context: [String: String]? = nil) {
|
||||||
let logData = LogData(message: message, context: context)
|
let logData = LogData(message: message, context: context)
|
||||||
writeMessage(.info, data: logData)
|
writeMessage(.info, data: logData)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func error(_ message: String, context: [String: String]? = nil) {
|
public func error(_ message: String, context: [String: String]? = nil) {
|
||||||
let logData = LogData(message: message, context: context)
|
let logData = LogData(message: message, context: context)
|
||||||
writeMessage(.error, data: logData)
|
writeMessage(.error, data: logData)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func debug(_ message: String, context: [String: String]? = nil) {
|
public func debug(_ message: String, context: [String: String]? = nil) {
|
||||||
let logData = LogData(message: message, context: context)
|
let logData = LogData(message: message, context: context)
|
||||||
writeMessage(.debug, data: logData)
|
writeMessage(.debug, data: logData)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func isAudioDeviceValid(_ deviceID: AudioObjectID) -> Bool {
|
|||||||
|
|
||||||
let valid = status == kAudioHardwareNoError && isAlive == 1
|
let valid = status == kAudioHardwareNoError && isAlive == 1
|
||||||
|
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Checked device validity",
|
"Checked device validity",
|
||||||
context: [
|
context: [
|
||||||
"device_id": String(deviceID),
|
"device_id": String(deviceID),
|
||||||
@@ -63,7 +63,7 @@ func translatePIDsToProcessObjects(_ pids: [Int32]) throws -> [AudioObjectID] {
|
|||||||
|
|
||||||
if status == kAudioHardwareNoError && processObject != kAudioObjectUnknown {
|
if status == kAudioHardwareNoError && processObject != kAudioObjectUnknown {
|
||||||
processObjects.append(processObject)
|
processObjects.append(processObject)
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Translated PID to process object",
|
"Translated PID to process object",
|
||||||
context: [
|
context: [
|
||||||
"pid": String(pid),
|
"pid": String(pid),
|
||||||
@@ -71,7 +71,7 @@ func translatePIDsToProcessObjects(_ pids: [Int32]) throws -> [AudioObjectID] {
|
|||||||
])
|
])
|
||||||
} else {
|
} else {
|
||||||
failedPIDs.append(pid)
|
failedPIDs.append(pid)
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Failed to translate PID to process object",
|
"Failed to translate PID to process object",
|
||||||
context: [
|
context: [
|
||||||
"pid": String(pid),
|
"pid": String(pid),
|
||||||
@@ -87,11 +87,3 @@ func translatePIDsToProcessObjects(_ pids: [Int32]) throws -> [AudioObjectID] {
|
|||||||
|
|
||||||
return processObjects
|
return processObjects
|
||||||
}
|
}
|
||||||
|
|
||||||
extension String {
|
|
||||||
func print(to fileHandle: FileHandle) {
|
|
||||||
if let data = (self + "\n").data(using: .utf8) {
|
|
||||||
fileHandle.write(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import CoreAudio
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import AudioTeeCore
|
||||||
|
|
||||||
|
// CoreAudio defines its own AudioBuffer struct, which collides with ours.
|
||||||
|
// Explicit module qualification avoids ambiguity in tests that import both.
|
||||||
|
private typealias AudioBuffer = AudioTeeCore.AudioBuffer
|
||||||
|
|
||||||
|
final class AudioBufferTests: XCTestCase {
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
/// Creates a minimal AudioStreamBasicDescription for testing.
|
||||||
|
/// 16kHz, 16-bit, mono = 2 bytes per frame, 32000 bytes/sec.
|
||||||
|
private func makeFormat(
|
||||||
|
sampleRate: Double = 16000,
|
||||||
|
bytesPerFrame: UInt32 = 2,
|
||||||
|
bitsPerChannel: UInt32 = 16
|
||||||
|
) -> AudioStreamBasicDescription {
|
||||||
|
return AudioStreamBasicDescription(
|
||||||
|
mSampleRate: sampleRate,
|
||||||
|
mFormatID: kAudioFormatLinearPCM,
|
||||||
|
mFormatFlags: kAudioFormatFlagIsPacked | kAudioFormatFlagIsSignedInteger,
|
||||||
|
mBytesPerPacket: bytesPerFrame,
|
||||||
|
mFramesPerPacket: 1,
|
||||||
|
mBytesPerFrame: bytesPerFrame,
|
||||||
|
mChannelsPerFrame: 1,
|
||||||
|
mBitsPerChannel: bitsPerChannel,
|
||||||
|
mReserved: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a repeating byte pattern of the given length.
|
||||||
|
private func makeData(byte: UInt8, count: Int) -> Data {
|
||||||
|
return Data(repeating: byte, count: count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends Data to an AudioBuffer via the raw pointer path,
|
||||||
|
/// matching how processAudio() calls append(from:count:).
|
||||||
|
private func appendData(_ data: Data, to buffer: AudioBuffer) {
|
||||||
|
data.withUnsafeBytes { bytes in
|
||||||
|
buffer.append(from: bytes.baseAddress!, count: bytes.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collects chunks from the buffer as Data objects for test verification.
|
||||||
|
private func collectChunks(from buffer: AudioBuffer) -> [Data] {
|
||||||
|
var chunks: [Data] = []
|
||||||
|
buffer.processChunks { pointer, count in
|
||||||
|
chunks.append(Data(bytes: pointer, count: count))
|
||||||
|
}
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Basic append + processChunks
|
||||||
|
|
||||||
|
func testSingleChunkExtraction() {
|
||||||
|
// 16kHz, 2 bytes/frame, 0.1s chunk = 3200 bytes per chunk
|
||||||
|
let format = makeFormat()
|
||||||
|
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
|
||||||
|
let chunkSize = 3200 // 16000 * 0.1 * 2
|
||||||
|
|
||||||
|
let data = makeData(byte: 0xAB, count: chunkSize)
|
||||||
|
appendData(data, to: buffer)
|
||||||
|
|
||||||
|
let chunks = collectChunks(from: buffer)
|
||||||
|
XCTAssertEqual(chunks.count, 1)
|
||||||
|
XCTAssertEqual(chunks[0].count, chunkSize)
|
||||||
|
XCTAssertEqual(chunks[0], data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMultipleChunksExtracted() {
|
||||||
|
let format = makeFormat()
|
||||||
|
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
|
||||||
|
let chunkSize = 3200
|
||||||
|
|
||||||
|
// Append 2.5 chunks worth
|
||||||
|
appendData(makeData(byte: 0x01, count: chunkSize * 2 + chunkSize / 2), to: buffer)
|
||||||
|
|
||||||
|
let chunks = collectChunks(from: buffer)
|
||||||
|
// Should get 2 complete chunks, remainder stays in buffer
|
||||||
|
XCTAssertEqual(chunks.count, 2)
|
||||||
|
XCTAssertEqual(chunks[0].count, chunkSize)
|
||||||
|
XCTAssertEqual(chunks[1].count, chunkSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInsufficientDataReturnsNoChunks() {
|
||||||
|
let format = makeFormat()
|
||||||
|
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
|
||||||
|
let chunkSize = 3200
|
||||||
|
|
||||||
|
// Append less than one chunk
|
||||||
|
appendData(makeData(byte: 0xFF, count: chunkSize - 1), to: buffer)
|
||||||
|
|
||||||
|
let chunks = collectChunks(from: buffer)
|
||||||
|
XCTAssertEqual(chunks.count, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Wrap-around
|
||||||
|
|
||||||
|
func testWrapAroundWrite() {
|
||||||
|
// 8kHz, 2 bytes/frame, 0.3s chunks → chunkSize = 4800, maxBuffer = 160000.
|
||||||
|
// 160000 / 4800 = 33.33 — chunks do NOT divide evenly into the buffer,
|
||||||
|
// so after enough writes the writeIndex will straddle the boundary.
|
||||||
|
let format = makeFormat(sampleRate: 8000)
|
||||||
|
let buffer = AudioBuffer(format: format, chunkDuration: 0.3)
|
||||||
|
let chunkSize = 4800 // 8000 * 0.3 * 2
|
||||||
|
|
||||||
|
// Write 33 chunks (158400 bytes), drain them all.
|
||||||
|
// writeIndex = 158400, readIndex = 158400. 1600 bytes remain before boundary.
|
||||||
|
for _ in 0..<33 {
|
||||||
|
appendData(makeData(byte: 0x00, count: chunkSize), to: buffer)
|
||||||
|
}
|
||||||
|
let drained = collectChunks(from: buffer)
|
||||||
|
XCTAssertEqual(drained.count, 33)
|
||||||
|
|
||||||
|
// Next write of 4800 bytes starts at 158400. 158400 + 4800 = 163200 > 160000.
|
||||||
|
// This MUST take the wrap-around else branch in append():
|
||||||
|
// firstChunkSize = 160000 - 158400 = 1600
|
||||||
|
// secondChunkSize = 4800 - 1600 = 3200
|
||||||
|
// Verify by using distinct byte patterns for the portion before and after the boundary.
|
||||||
|
var wrappingData = Data()
|
||||||
|
wrappingData.append(makeData(byte: 0xAA, count: 1600)) // fills to boundary
|
||||||
|
wrappingData.append(makeData(byte: 0xBB, count: 3200)) // wraps to start
|
||||||
|
XCTAssertEqual(wrappingData.count, chunkSize)
|
||||||
|
appendData(wrappingData, to: buffer)
|
||||||
|
|
||||||
|
let chunks = collectChunks(from: buffer)
|
||||||
|
XCTAssertEqual(chunks.count, 1)
|
||||||
|
XCTAssertEqual(chunks[0], wrappingData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWrapAroundRead() {
|
||||||
|
// Same setup as above: position readIndex so that a chunk extraction
|
||||||
|
// straddles the ring buffer boundary, exercising the else branch in nextChunk().
|
||||||
|
let format = makeFormat(sampleRate: 8000)
|
||||||
|
let buffer = AudioBuffer(format: format, chunkDuration: 0.3)
|
||||||
|
let chunkSize = 4800
|
||||||
|
|
||||||
|
// Write and drain 33 chunks. Both indices land at 158400.
|
||||||
|
for _ in 0..<33 {
|
||||||
|
appendData(makeData(byte: 0x00, count: chunkSize), to: buffer)
|
||||||
|
}
|
||||||
|
_ = collectChunks(from: buffer)
|
||||||
|
|
||||||
|
// Write one chunk starting at 158400. The write itself wraps (tested above),
|
||||||
|
// but crucially the READ will also wrap: readIndex = 158400,
|
||||||
|
// 158400 + 4800 = 163200 > 160000 → else branch in nextChunk():
|
||||||
|
// firstChunkSize = 160000 - 158400 = 1600 (read from end of buffer)
|
||||||
|
// secondChunkSize = 4800 - 1600 = 3200 (read from start of buffer)
|
||||||
|
var crossBoundaryData = Data()
|
||||||
|
crossBoundaryData.append(makeData(byte: 0xCC, count: 1600))
|
||||||
|
crossBoundaryData.append(makeData(byte: 0xDD, count: 3200))
|
||||||
|
appendData(crossBoundaryData, to: buffer)
|
||||||
|
|
||||||
|
let chunks = collectChunks(from: buffer)
|
||||||
|
XCTAssertEqual(chunks.count, 1)
|
||||||
|
XCTAssertEqual(chunks[0], crossBoundaryData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Overflow guard
|
||||||
|
|
||||||
|
func testOverflowPreventsWrite() {
|
||||||
|
let format = makeFormat(sampleRate: 8000)
|
||||||
|
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
|
||||||
|
let maxBuffer = 160000
|
||||||
|
|
||||||
|
// Fill the buffer completely
|
||||||
|
appendData(makeData(byte: 0x01, count: maxBuffer), to: buffer)
|
||||||
|
|
||||||
|
// Try to append more — should be silently rejected (overflow guard)
|
||||||
|
appendData(makeData(byte: 0x02, count: 100), to: buffer)
|
||||||
|
|
||||||
|
// Drain and verify we only got the original data
|
||||||
|
let chunks = collectChunks(from: buffer)
|
||||||
|
let totalBytes = chunks.reduce(0) { $0 + $1.count }
|
||||||
|
XCTAssertEqual(totalBytes, maxBuffer)
|
||||||
|
|
||||||
|
// Every byte should be 0x01, not 0x02
|
||||||
|
for chunk in chunks {
|
||||||
|
XCTAssertTrue(chunk.allSatisfy { $0 == 0x01 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Incremental appends accumulate correctly
|
||||||
|
|
||||||
|
func testIncrementalAppendsThenChunk() {
|
||||||
|
let format = makeFormat()
|
||||||
|
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
|
||||||
|
let chunkSize = 3200
|
||||||
|
|
||||||
|
// Simulate many small IO callbacks building up to one chunk
|
||||||
|
let callbackSize = 320 // 10 callbacks to fill one chunk
|
||||||
|
for i in 0..<10 {
|
||||||
|
appendData(makeData(byte: UInt8(i), count: callbackSize), to: buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunks = collectChunks(from: buffer)
|
||||||
|
XCTAssertEqual(chunks.count, 1)
|
||||||
|
XCTAssertEqual(chunks[0].count, chunkSize)
|
||||||
|
|
||||||
|
// Verify the data is in the correct order
|
||||||
|
for i in 0..<10 {
|
||||||
|
let slice = chunks[0].subdata(in: (i * callbackSize)..<((i + 1) * callbackSize))
|
||||||
|
XCTAssertTrue(slice.allSatisfy { $0 == UInt8(i) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Chunk size
|
||||||
|
|
||||||
|
func testBytesPerChunkIsCorrect() {
|
||||||
|
let format = makeFormat()
|
||||||
|
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
|
||||||
|
|
||||||
|
// 16kHz * 0.1s * 2 bytes/frame = 3200
|
||||||
|
XCTAssertEqual(buffer.bytesPerChunk, 3200)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
@testable import AudioTeeCore
|
|
||||||
|
|
||||||
final class AudioPacketTests: XCTestCase {
|
|
||||||
func testPacketCreation() {
|
|
||||||
let timestamp = Date()
|
|
||||||
let duration = 1.0
|
|
||||||
let data = Data([0x01, 0x02, 0x03, 0x04])
|
|
||||||
|
|
||||||
let packet = AudioPacket(
|
|
||||||
timestamp: timestamp,
|
|
||||||
duration: duration,
|
|
||||||
data: data
|
|
||||||
)
|
|
||||||
|
|
||||||
XCTAssertEqual(packet.timestamp, timestamp)
|
|
||||||
XCTAssertEqual(packet.duration, duration)
|
|
||||||
XCTAssertEqual(packet.data, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPacketDataSize() {
|
|
||||||
let packet = AudioPacket(
|
|
||||||
timestamp: Date(),
|
|
||||||
duration: 0.5,
|
|
||||||
data: Data(repeating: 0xFF, count: 1024)
|
|
||||||
)
|
|
||||||
|
|
||||||
XCTAssertEqual(packet.data.count, 1024)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user