Files
OSGKeyboard/OpenLessKeyboard/Services/AudioCaptureService.swift
T
rocky 07067ca6c5 feat: initial release v0.1.0
- Custom Keyboard Extension with push-to-talk UI
- iOS 26 SpeechAnalyzer + DictationTranscriber (iOS 18 SF fallback)
- OpenAI-compatible LLM client (4 built-in providers + custom)
- 3-page onboarding flow + provider config UI
- App Group shared storage for cross-process config
- 8s LLM timeout with raw-transcript fallback
- App Store privacy manifests for both targets
- SwiftLint + XcodeGen + GitHub Actions CI
- Unit tests (4/4 passing)
2026-06-17 23:08:58 +08:00

174 lines
5.9 KiB
Swift

// AudioCaptureService.swift
// OSGKeyboard · Keyboard Extension
//
// Captures microphone audio at 16 kHz mono Float32 using AVAudioEngine.
// Exposes an AsyncStream<AVAudioPCMBuffer> that ASR services can consume.
import Foundation
@preconcurrency import AVFoundation
public actor AudioCaptureService {
public enum CaptureError: Error {
case sessionConfigFailed(Error)
case engineStartFailed(Error)
case noInputNode
}
private let engine = AVAudioEngine()
private let converter = AVAudioConverter(
from: AVAudioFormat(
commonFormat: .pcmFormatFloat32,
sampleRate: 48000,
channels: 1,
interleaved: false
)!,
to: AVAudioFormat(
commonFormat: .pcmFormatFloat32,
sampleRate: 16_000,
channels: 1,
interleaved: false
)!
)
private var continuation: AsyncStream<AudioBufferSnapshot>.Continuation?
private var isRunning = false
public init() {}
public func start() -> AsyncStream<AudioBufferSnapshot> {
AsyncStream { continuation in
self.continuation = continuation
do {
try configureSession()
try attachTap()
try engine.start()
isRunning = true
} catch {
continuation.finish()
self.continuation = nil
isRunning = false
}
}
}
public func stop() {
guard isRunning else { return }
engine.inputNode.removeTap(onBus: 0)
engine.stop()
continuation?.finish()
continuation = nil
isRunning = false
}
private func configureSession() throws {
#if canImport(UIKit)
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(
.playAndRecord,
mode: .measurement,
options: [.duckOthers, .defaultToSpeaker, .allowBluetooth]
)
try session.setActive(true, options: .notifyOthersOnDeactivation)
} catch {
throw CaptureError.sessionConfigFailed(error)
}
#endif
}
private func attachTap() throws {
let input = engine.inputNode
let hardwareFormat = input.outputFormat(forBus: 0)
guard hardwareFormat.sampleRate > 0 else {
throw CaptureError.noInputNode
}
let bufferSize: AVAudioFrameCount = 4096
input.installTap(onBus: 0, bufferSize: bufferSize, format: hardwareFormat) { [weak self] buffer, _ in
guard let self else { return }
// Downsample to 16 kHz mono Float32
let target = AVAudioFormat(
commonFormat: .pcmFormatFloat32,
sampleRate: 16_000,
channels: 1,
interleaved: false
)!
let outFrames = AVAudioFrameCount(
Double(buffer.frameLength) * 16_000.0 / hardwareFormat.sampleRate
)
guard let outBuffer = AVAudioPCMBuffer(pcmFormat: target, frameCapacity: outFrames) else {
return
}
var error: NSError?
let status = self.converter?.convert(to: outBuffer, error: &error) { _, outStatus in
outStatus.pointee = .haveData
return buffer
}
if status == .haveData, error == nil {
// AVAudioPCMBuffer is not Sendable; copy the raw float data
// into a Sendable wrapper so we can hand it to the actor.
let copy = AudioBufferSnapshot(buffer: outBuffer)
Task { await self.deliver(copy) }
}
}
}
private func deliver(_ snapshot: AudioBufferSnapshot) {
guard isRunning else { return }
continuation?.yield(snapshot)
}
}
/// Sendable wrapper around a Float32 audio buffer's raw samples.
/// We re-decode on the consumer side to avoid AVAudioPCMBuffer's non-Sendable
/// type crossing the actor boundary.
public struct AudioBufferSnapshot: Sendable {
public let samples: [Float]
public let sampleRate: Double
public init(buffer: AVAudioPCMBuffer) {
guard let channelData = buffer.floatChannelData else {
self.samples = []
self.sampleRate = 16_000
return
}
let n = Int(buffer.frameLength)
var copy = [Float](repeating: 0, count: n)
memcpy(&copy, channelData[0], n * MemoryLayout<Float>.size)
self.samples = copy
self.sampleRate = buffer.format.sampleRate
}
}
public extension AsyncStream where Element == AudioBufferSnapshot {
/// Convenience: convert snapshots to AVAudioPCMBuffer 16kHz mono Float32.
func toAVAudioBuffers() -> AsyncStream<AVAudioPCMBuffer> {
let format = AVAudioFormat(
commonFormat: .pcmFormatFloat32,
sampleRate: 16_000,
channels: 1,
interleaved: false
)!
let snapshots = self
return AsyncStream<AVAudioPCMBuffer> { continuation in
Task {
for await snap in snapshots {
guard !snap.samples.isEmpty,
let pcm = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(snap.samples.count))
else { continue }
pcm.frameLength = AVAudioFrameCount(snap.samples.count)
if let dst = pcm.floatChannelData?[0] {
snap.samples.withUnsafeBufferPointer { src in
if let base = src.baseAddress {
memcpy(dst, base, snap.samples.count * MemoryLayout<Float>.size)
}
}
}
continuation.yield(pcm)
}
continuation.finish()
}
}
}
}