Initial commit of Edison Voice menu-bar dictation app
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user