Files

159 lines
5.8 KiB
Swift

import AVFoundation
import Foundation
/// Microphone capture with on-the-fly conversion to whatever format the speech engine wants.
///
/// The tap runs on a real-time audio thread, so everything it touches lives behind
/// `nonisolated(unsafe)` and is only ever mutated from that one thread.
public final class AudioCapture: @unchecked Sendable {
public init() {}
private let engine = AVAudioEngine()
private nonisolated(unsafe) var converter: AVAudioConverter?
private nonisolated(unsafe) var outputFormat: AVAudioFormat?
private var isRunning = false
/// Called on the audio thread with each converted buffer.
private nonisolated(unsafe) var onBuffer: (@Sendable (AudioChunk) -> Void)?
/// Called on the audio thread with a 0…1 RMS level, for the HUD waveform.
private nonisolated(unsafe) var onLevel: (@Sendable (Float) -> Void)?
public func start(
outputFormat: AVAudioFormat,
onBuffer: @escaping @Sendable (AudioChunk) -> Void,
onLevel: @escaping @Sendable (Float) -> Void
) throws {
guard !isRunning else { return }
self.onBuffer = onBuffer
self.onLevel = onLevel
self.outputFormat = outputFormat
let input = engine.inputNode
let nativeFormat = input.outputFormat(forBus: 0)
converter = nativeFormat == outputFormat
? nil
: AVAudioConverter(from: nativeFormat, to: outputFormat)
input.removeTap(onBus: 0)
input.installTap(onBus: 0, bufferSize: 2048, format: nativeFormat) { [weak self] buffer, _ in
self?.handle(buffer)
}
engine.prepare()
try engine.start()
isRunning = true
Log.audio.info("capture started — native \(nativeFormat.sampleRate)Hz → engine \(outputFormat.sampleRate)Hz")
}
public func stop() {
guard isRunning else { return }
engine.inputNode.removeTap(onBus: 0)
engine.stop()
isRunning = false
converter = nil
onBuffer = nil
onLevel = nil
Log.audio.info("capture stopped")
}
// MARK: - Audio thread
private func handle(_ buffer: AVAudioPCMBuffer) {
onLevel?(Self.rms(of: buffer))
guard let outputFormat else { return }
// AVAudioEngine reuses the tap's buffer as soon as this returns, so the engine
// must never see it directly — copy when no conversion would otherwise allocate.
guard let converter else {
if let copy = Self.copy(buffer) {
onBuffer?(AudioChunk(buffer: copy))
}
return
}
// Output frame count scales with the sample-rate ratio; round up so we never clip.
let ratio = outputFormat.sampleRate / buffer.format.sampleRate
let capacity = AVAudioFrameCount((Double(buffer.frameLength) * ratio).rounded(.up)) + 64
guard let converted = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: capacity) else { return }
// The input block runs synchronously inside `convert`, on this thread.
nonisolated(unsafe) let input = buffer
let consumed = Latch()
var error: NSError?
let status = converter.convert(to: converted, error: &error) { _, outStatus in
guard !consumed.take() else {
outStatus.pointee = .noDataNow
return nil
}
outStatus.pointee = .haveData
return input
}
if let error {
Log.audio.error("conversion failed: \(error.localizedDescription)")
return
}
guard status != .error, converted.frameLength > 0 else { return }
onBuffer?(AudioChunk(buffer: converted))
}
/// Deep-copies a tap buffer into storage we own.
private static func copy(_ buffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? {
guard buffer.frameLength > 0,
let copy = AVAudioPCMBuffer(pcmFormat: buffer.format, frameCapacity: buffer.frameLength)
else { return nil }
copy.frameLength = buffer.frameLength
let channels = Int(buffer.format.channelCount)
let frames = Int(buffer.frameLength)
if let source = buffer.floatChannelData, let destination = copy.floatChannelData {
for channel in 0..<channels {
destination[channel].update(from: source[channel], count: frames)
}
} else if let source = buffer.int16ChannelData, let destination = copy.int16ChannelData {
for channel in 0..<channels {
destination[channel].update(from: source[channel], count: frames)
}
} else if let source = buffer.int32ChannelData, let destination = copy.int32ChannelData {
for channel in 0..<channels {
destination[channel].update(from: source[channel], count: frames)
}
} else {
return nil
}
return copy
}
/// One-shot flag. Only touched from the audio thread inside a synchronous call.
private final class Latch: @unchecked Sendable {
private var fired = false
/// - Returns: the value *before* this call, then latches to `true`.
func take() -> Bool {
defer { fired = true }
return fired
}
}
private static func rms(of buffer: AVAudioPCMBuffer) -> Float {
guard let channel = buffer.floatChannelData?[0] else { return 0 }
let count = Int(buffer.frameLength)
guard count > 0 else { return 0 }
var sum: Float = 0
for i in 0..<count {
let sample = channel[i]
sum += sample * sample
}
let rms = (sum / Float(count)).squareRoot()
// Map roughly -50…0 dBFS onto 0…1 so quiet speech still moves the meter.
let db = 20 * log10(max(rms, 1e-7))
return max(0, min(1, (db + 50) / 50))
}
}