528 lines
17 KiB
Swift
Executable File
528 lines
17 KiB
Swift
Executable File
import AVFoundation
|
|
import Foundation
|
|
import HexCore
|
|
|
|
private final class FloatRingBuffer {
|
|
private let lock = NSLock()
|
|
private var buffer: [Float]
|
|
private var writeIndex = 0
|
|
private var validSampleCount = 0
|
|
|
|
init(capacity: Int) {
|
|
buffer = Array(repeating: 0, count: max(1, capacity))
|
|
}
|
|
|
|
func append(_ samples: UnsafeBufferPointer<Float>) {
|
|
guard !samples.isEmpty else { return }
|
|
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
|
|
for sample in samples {
|
|
buffer[writeIndex] = sample
|
|
writeIndex = (writeIndex + 1) % buffer.count
|
|
}
|
|
|
|
validSampleCount = min(buffer.count, validSampleCount + samples.count)
|
|
}
|
|
|
|
func recentSamples(count requestedCount: Int) -> [Float] {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
|
|
let sampleCount = min(max(0, requestedCount), validSampleCount)
|
|
guard sampleCount > 0 else { return [] }
|
|
|
|
let startIndex = (writeIndex - sampleCount + buffer.count) % buffer.count
|
|
if startIndex + sampleCount <= buffer.count {
|
|
return Array(buffer[startIndex ..< startIndex + sampleCount])
|
|
}
|
|
|
|
let firstChunk = Array(buffer[startIndex ..< buffer.count])
|
|
let secondChunk = Array(buffer[0 ..< (sampleCount - firstChunk.count)])
|
|
return firstChunk + secondChunk
|
|
}
|
|
|
|
func clear() {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
|
|
writeIndex = 0
|
|
validSampleCount = 0
|
|
}
|
|
}
|
|
|
|
private struct SuperFastCaptureConstants {
|
|
static let sampleRate: Double = 16_000
|
|
static let ringBufferDuration: TimeInterval = 1.0
|
|
static let defaultPreRollDuration: TimeInterval = 0.45
|
|
static let tapBufferSize: AVAudioFrameCount = 2_048
|
|
static let fallbackStopGracePeriod: TimeInterval = 0.05
|
|
static let minimumStopGracePeriod: TimeInterval = 0.02
|
|
static let maximumStopGracePeriod: TimeInterval = 0.08
|
|
static let stopGraceSafetyMargin: TimeInterval = 0.008
|
|
static let callbackTimingWindowSize = 8
|
|
}
|
|
|
|
enum CaptureRecordingMode: String {
|
|
case standard = "standard"
|
|
case superFast = "super-fast"
|
|
|
|
var preRollDuration: TimeInterval {
|
|
switch self {
|
|
case .standard:
|
|
0
|
|
case .superFast:
|
|
SuperFastCaptureConstants.defaultPreRollDuration
|
|
}
|
|
}
|
|
|
|
var keepsWarmBuffer: Bool {
|
|
self == .superFast
|
|
}
|
|
}
|
|
|
|
final class SuperFastCaptureController {
|
|
enum FinishRecordingResult {
|
|
case captured(URL)
|
|
case failed(RecordingFailure)
|
|
case idle
|
|
}
|
|
|
|
struct StopTimingEstimate {
|
|
let gracePeriod: TimeInterval
|
|
let callbackInterval: TimeInterval
|
|
let bufferDuration: TimeInterval
|
|
}
|
|
|
|
private struct ActiveRecording {
|
|
let url: URL
|
|
let file: AVAudioFile
|
|
let requestedAt: Date
|
|
let prependedDuration: TimeInterval
|
|
var didLogFirstBuffer: Bool
|
|
}
|
|
|
|
private let logger = HexLog.recording
|
|
private let processingQueue = DispatchQueue(label: "com.kitlangton.Hex.SuperFastCapture")
|
|
private let meterContinuation: AsyncStream<Meter>.Continuation
|
|
private let ringBuffer = FloatRingBuffer(
|
|
capacity: Int(SuperFastCaptureConstants.sampleRate * SuperFastCaptureConstants.ringBufferDuration)
|
|
)
|
|
private let targetFormat = AVAudioFormat(
|
|
commonFormat: .pcmFormatFloat32,
|
|
sampleRate: SuperFastCaptureConstants.sampleRate,
|
|
channels: 1,
|
|
interleaved: false
|
|
)!
|
|
|
|
private var engine: AVAudioEngine?
|
|
private var converter: AVAudioConverter?
|
|
private var configurationChangeObserver: NSObjectProtocol?
|
|
private var activeRecording: ActiveRecording?
|
|
private var captureGeneration = 0
|
|
private var recordingFailure: RecordingFailure?
|
|
private var keepWarmBuffer = false
|
|
private var lastProcessedBufferAt: Date?
|
|
private var recentCallbackIntervals: [TimeInterval] = []
|
|
private var recentBufferDurations: [TimeInterval] = []
|
|
private let onEngineConfigurationChange: @Sendable (Int) -> Void
|
|
|
|
init(
|
|
meterContinuation: AsyncStream<Meter>.Continuation,
|
|
onEngineConfigurationChange: @escaping @Sendable (Int) -> Void
|
|
) {
|
|
self.meterContinuation = meterContinuation
|
|
self.onEngineConfigurationChange = onEngineConfigurationChange
|
|
}
|
|
|
|
deinit {
|
|
stop()
|
|
}
|
|
|
|
var isRunning: Bool {
|
|
engine?.isRunning == true
|
|
}
|
|
|
|
var isRecording: Bool {
|
|
processingQueue.sync { activeRecording != nil }
|
|
}
|
|
|
|
var stopTimingEstimate: StopTimingEstimate {
|
|
processingQueue.sync {
|
|
let callbackInterval = recentCallbackIntervals.max() ?? 0
|
|
let bufferDuration = recentBufferDurations.max() ?? 0
|
|
let observedCadence = max(callbackInterval, bufferDuration)
|
|
let gracePeriod = min(
|
|
max(
|
|
observedCadence > 0
|
|
? observedCadence + SuperFastCaptureConstants.stopGraceSafetyMargin
|
|
: SuperFastCaptureConstants.fallbackStopGracePeriod,
|
|
SuperFastCaptureConstants.minimumStopGracePeriod
|
|
),
|
|
SuperFastCaptureConstants.maximumStopGracePeriod
|
|
)
|
|
return StopTimingEstimate(
|
|
gracePeriod: gracePeriod,
|
|
callbackInterval: callbackInterval,
|
|
bufferDuration: bufferDuration
|
|
)
|
|
}
|
|
}
|
|
|
|
func startIfNeeded(reason: String = "unknown", keepWarmBuffer: Bool = false) throws {
|
|
processingQueue.sync {
|
|
let didDisableWarmBuffer = self.keepWarmBuffer && !keepWarmBuffer
|
|
self.keepWarmBuffer = keepWarmBuffer
|
|
if didDisableWarmBuffer, activeRecording == nil {
|
|
ringBuffer.clear()
|
|
}
|
|
}
|
|
|
|
if engine?.isRunning == true {
|
|
logger.debug("Capture engine already armed reason=\(reason)")
|
|
return
|
|
}
|
|
|
|
stop(reason: "restart-before-arm")
|
|
try armEngine(reason: reason)
|
|
}
|
|
|
|
/// Tears down and recreates the engine while keeping the active recording file open, so
|
|
/// capture resumes onto the same file after a device/route change mid-recording
|
|
/// (#251, #252, #218, #226). The ring buffer, timing metrics, and active recording survive;
|
|
/// only the engine, tap, and converter are rebuilt.
|
|
func restartPreservingRecording(reason: String) throws {
|
|
logger.notice("Restarting capture engine preserving active recording reason=\(reason)")
|
|
detachEngine()
|
|
try armEngine(reason: reason)
|
|
}
|
|
|
|
private func armEngine(reason: String) throws {
|
|
let engine = AVAudioEngine()
|
|
let inputNode = engine.inputNode
|
|
let inputFormat = inputNode.inputFormat(forBus: 0)
|
|
guard let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else {
|
|
throw NSError(
|
|
domain: "SuperFastCapture",
|
|
code: -1,
|
|
userInfo: [NSLocalizedDescriptionKey: "Unable to create the capture engine audio converter."]
|
|
)
|
|
}
|
|
if inputFormat.channelCount > 1 {
|
|
converter.channelMap = [NSNumber(value: 0)]
|
|
}
|
|
|
|
let generation = processingQueue.sync {
|
|
captureGeneration += 1
|
|
self.converter = converter
|
|
recordingFailure = nil
|
|
return captureGeneration
|
|
}
|
|
|
|
inputNode.installTap(onBus: 0, bufferSize: SuperFastCaptureConstants.tapBufferSize, format: inputFormat) {
|
|
[weak self] buffer, _ in
|
|
self?.enqueue(buffer, generation: generation)
|
|
}
|
|
|
|
engine.prepare()
|
|
do {
|
|
try engine.start()
|
|
} catch {
|
|
inputNode.removeTap(onBus: 0)
|
|
processingQueue.sync {
|
|
captureGeneration += 1
|
|
self.converter = nil
|
|
}
|
|
throw error
|
|
}
|
|
self.engine = engine
|
|
configurationChangeObserver = NotificationCenter.default.addObserver(
|
|
forName: .AVAudioEngineConfigurationChange,
|
|
object: engine,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
self?.handleConfigurationChange(generation: generation)
|
|
}
|
|
logger.notice(
|
|
"Capture engine armed reason=\(reason) sampleRate=\(String(format: "%.0f", inputFormat.sampleRate))Hz channels=\(inputFormat.channelCount) ringBuffer=\(String(format: "%.2f", SuperFastCaptureConstants.ringBufferDuration))s defaultPreRoll=\(String(format: "%.2f", SuperFastCaptureConstants.defaultPreRollDuration))s"
|
|
)
|
|
}
|
|
|
|
func stop(reason: String = "unknown") {
|
|
if engine != nil {
|
|
logger.notice("Capture engine stopped reason=\(reason)")
|
|
}
|
|
detachEngine(clearingRecordingState: true)
|
|
}
|
|
|
|
/// Removes the tap, observer, converter, and engine. Bumps the capture generation so
|
|
/// in-flight tap callbacks from the old engine are ignored. Recording state (active file,
|
|
/// ring buffer, timing metrics) is preserved unless `clearingRecordingState` is set, which
|
|
/// is what lets restartPreservingRecording resume capture onto the same file.
|
|
private func detachEngine(clearingRecordingState: Bool = false) {
|
|
if let inputNode = engine?.inputNode {
|
|
inputNode.removeTap(onBus: 0)
|
|
}
|
|
if let configurationChangeObserver {
|
|
NotificationCenter.default.removeObserver(configurationChangeObserver)
|
|
self.configurationChangeObserver = nil
|
|
}
|
|
processingQueue.sync {
|
|
captureGeneration += 1
|
|
converter = nil
|
|
if clearingRecordingState {
|
|
activeRecording = nil
|
|
recordingFailure = nil
|
|
ringBuffer.clear()
|
|
lastProcessedBufferAt = nil
|
|
recentCallbackIntervals.removeAll(keepingCapacity: false)
|
|
recentBufferDurations.removeAll(keepingCapacity: false)
|
|
}
|
|
}
|
|
engine?.stop()
|
|
engine = nil
|
|
}
|
|
|
|
private func handleConfigurationChange(generation: Int) {
|
|
guard processingQueue.sync(execute: { Self.shouldProcessCallback(callbackGeneration: generation, currentGeneration: captureGeneration) }) else {
|
|
return
|
|
}
|
|
logger.notice("Capture engine configuration changed")
|
|
onEngineConfigurationChange(generation)
|
|
}
|
|
|
|
static func shouldProcessCallback(callbackGeneration: Int, currentGeneration: Int) -> Bool {
|
|
callbackGeneration == currentGeneration
|
|
}
|
|
|
|
func isCurrentGeneration(_ generation: Int) -> Bool {
|
|
processingQueue.sync { generation == captureGeneration }
|
|
}
|
|
|
|
func beginRecording(to url: URL, requestedAt: Date = Date(), mode: CaptureRecordingMode) throws {
|
|
try startIfNeeded(reason: "begin-recording", keepWarmBuffer: mode.keepsWarmBuffer)
|
|
|
|
var startError: Error?
|
|
processingQueue.sync {
|
|
do {
|
|
recordingFailure = nil
|
|
let file = try AVAudioFile(
|
|
forWriting: url,
|
|
settings: [
|
|
AVFormatIDKey: Int(kAudioFormatLinearPCM),
|
|
AVSampleRateKey: SuperFastCaptureConstants.sampleRate,
|
|
AVNumberOfChannelsKey: 1,
|
|
AVLinearPCMBitDepthKey: 32,
|
|
AVLinearPCMIsFloatKey: true,
|
|
AVLinearPCMIsBigEndianKey: false,
|
|
AVLinearPCMIsNonInterleaved: true,
|
|
],
|
|
commonFormat: .pcmFormatFloat32,
|
|
interleaved: false
|
|
)
|
|
|
|
let preRollDuration = mode.preRollDuration
|
|
let preRollFrameCount = Int(preRollDuration * SuperFastCaptureConstants.sampleRate)
|
|
let preRollSamples = ringBuffer.recentSamples(count: preRollFrameCount)
|
|
let prependedDuration = Double(preRollSamples.count) / SuperFastCaptureConstants.sampleRate
|
|
if !preRollSamples.isEmpty {
|
|
try write(samples: preRollSamples, to: file)
|
|
}
|
|
|
|
logger.notice(
|
|
"Capture engine recording file opened prepended=\(String(format: "%.3f", prependedDuration))s requestedPreRoll=\(String(format: "%.3f", preRollDuration))s"
|
|
)
|
|
activeRecording = ActiveRecording(
|
|
url: url,
|
|
file: file,
|
|
requestedAt: requestedAt,
|
|
prependedDuration: prependedDuration,
|
|
didLogFirstBuffer: false
|
|
)
|
|
} catch {
|
|
startError = error
|
|
}
|
|
}
|
|
|
|
if let startError {
|
|
throw startError
|
|
}
|
|
}
|
|
|
|
func finishRecording(clearBuffer: Bool = true) -> FinishRecordingResult {
|
|
processingQueue.sync {
|
|
let result: FinishRecordingResult
|
|
if let recordingFailure {
|
|
result = .failed(recordingFailure)
|
|
} else if let url = activeRecording?.url {
|
|
result = .captured(url)
|
|
} else {
|
|
result = .idle
|
|
}
|
|
activeRecording = nil
|
|
recordingFailure = nil
|
|
if clearBuffer {
|
|
ringBuffer.clear()
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
|
|
private func enqueue(_ buffer: AVAudioPCMBuffer, generation: Int) {
|
|
guard let copy = clone(buffer) else { return }
|
|
processingQueue.async { [weak self] in
|
|
self?.process(copy, generation: generation)
|
|
}
|
|
}
|
|
|
|
private func process(_ buffer: AVAudioPCMBuffer, generation: Int) {
|
|
guard Self.shouldProcessCallback(callbackGeneration: generation, currentGeneration: captureGeneration) else {
|
|
return
|
|
}
|
|
let now = Date()
|
|
if let lastProcessedBufferAt {
|
|
appendRecentMetric(now.timeIntervalSince(lastProcessedBufferAt), to: &recentCallbackIntervals)
|
|
}
|
|
lastProcessedBufferAt = now
|
|
appendRecentMetric(Double(buffer.frameLength) / buffer.format.sampleRate, to: &recentBufferDurations)
|
|
|
|
guard let converted = convert(buffer),
|
|
converted.frameLength > 0,
|
|
let samples = converted.floatChannelData?[0]
|
|
else {
|
|
return
|
|
}
|
|
|
|
let sampleCount = Int(converted.frameLength)
|
|
if keepWarmBuffer, activeRecording == nil {
|
|
ringBuffer.append(UnsafeBufferPointer(start: samples, count: sampleCount))
|
|
}
|
|
|
|
if activeRecording != nil {
|
|
meterContinuation.yield(meter(for: samples, count: sampleCount))
|
|
}
|
|
|
|
guard var recording = activeRecording else { return }
|
|
if !recording.didLogFirstBuffer {
|
|
let timeToFirstBuffer = Date().timeIntervalSince(recording.requestedAt)
|
|
logger.notice(
|
|
"Capture engine first buffer latency=\(String(format: "%.3f", timeToFirstBuffer))s prepended=\(String(format: "%.3f", recording.prependedDuration))s frames=\(sampleCount)"
|
|
)
|
|
recording.didLogFirstBuffer = true
|
|
activeRecording = recording
|
|
}
|
|
|
|
do {
|
|
try recording.file.write(from: converted)
|
|
} catch {
|
|
logger.error("Failed to write capture engine audio: \(error.localizedDescription)")
|
|
activeRecording = nil
|
|
recordingFailure = .captureWriteFailed(error.localizedDescription)
|
|
FileManager.default.removeItemIfExists(at: recording.url)
|
|
}
|
|
}
|
|
|
|
private func convert(_ inputBuffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? {
|
|
guard let converter else { return nil }
|
|
|
|
let sampleRateRatio = targetFormat.sampleRate / inputBuffer.format.sampleRate
|
|
let frameCapacity = AVAudioFrameCount(
|
|
max(1, (Double(inputBuffer.frameLength) * sampleRateRatio).rounded(.up) + 32)
|
|
)
|
|
|
|
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: frameCapacity) else {
|
|
return nil
|
|
}
|
|
|
|
var error: NSError?
|
|
var consumedInput = false
|
|
let status = converter.convert(to: outputBuffer, error: &error) { _, outStatus in
|
|
if consumedInput {
|
|
outStatus.pointee = .noDataNow
|
|
return nil
|
|
}
|
|
|
|
consumedInput = true
|
|
outStatus.pointee = .haveData
|
|
return inputBuffer
|
|
}
|
|
|
|
if let error {
|
|
logger.error("Failed to convert capture engine audio: \(error.localizedDescription)")
|
|
return nil
|
|
}
|
|
|
|
switch status {
|
|
case .haveData, .inputRanDry, .endOfStream:
|
|
return outputBuffer.frameLength > 0 ? outputBuffer : nil
|
|
case .error:
|
|
return nil
|
|
@unknown default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func write(samples: [Float], to file: AVAudioFile) throws {
|
|
guard !samples.isEmpty,
|
|
let buffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: AVAudioFrameCount(samples.count)),
|
|
let channelData = buffer.floatChannelData?[0]
|
|
else {
|
|
return
|
|
}
|
|
|
|
buffer.frameLength = AVAudioFrameCount(samples.count)
|
|
samples.withUnsafeBufferPointer { sampleBuffer in
|
|
guard let baseAddress = sampleBuffer.baseAddress else { return }
|
|
channelData.update(from: baseAddress, count: sampleBuffer.count)
|
|
}
|
|
try file.write(from: buffer)
|
|
}
|
|
|
|
private func meter(for samples: UnsafePointer<Float>, count: Int) -> Meter {
|
|
guard count > 0 else {
|
|
return Meter(averagePower: 0, peakPower: 0)
|
|
}
|
|
|
|
var sumOfSquares: Float = 0
|
|
var peak: Float = 0
|
|
for index in 0 ..< count {
|
|
let sample = samples[index]
|
|
let magnitude = abs(sample)
|
|
sumOfSquares += sample * sample
|
|
peak = max(peak, magnitude)
|
|
}
|
|
|
|
let rms = sqrt(sumOfSquares / Float(count))
|
|
return Meter(averagePower: Double(rms), peakPower: Double(peak))
|
|
}
|
|
|
|
private func clone(_ buffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? {
|
|
guard let copy = AVAudioPCMBuffer(pcmFormat: buffer.format, frameCapacity: buffer.frameLength) else {
|
|
return nil
|
|
}
|
|
|
|
copy.frameLength = buffer.frameLength
|
|
|
|
let sourceBuffers = UnsafeMutableAudioBufferListPointer(buffer.mutableAudioBufferList)
|
|
let destinationBuffers = UnsafeMutableAudioBufferListPointer(copy.mutableAudioBufferList)
|
|
for index in sourceBuffers.indices {
|
|
let source = sourceBuffers[index]
|
|
let destination = destinationBuffers[index]
|
|
guard let sourceData = source.mData, let destinationData = destination.mData else { continue }
|
|
memcpy(destinationData, sourceData, Int(source.mDataByteSize))
|
|
destinationBuffers[index].mDataByteSize = source.mDataByteSize
|
|
}
|
|
|
|
return copy
|
|
}
|
|
|
|
private func appendRecentMetric(_ value: TimeInterval, to metrics: inout [TimeInterval]) {
|
|
guard value.isFinite, value > 0 else { return }
|
|
metrics.append(value)
|
|
if metrics.count > SuperFastCaptureConstants.callbackTimingWindowSize {
|
|
metrics.removeFirst(metrics.count - SuperFastCaptureConstants.callbackTimingWindowSize)
|
|
}
|
|
}
|
|
}
|