Files
gart/code/Hex-main/Hex/Clients/SuperFastCaptureController.swift
T

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)
}
}
}