251 lines
7.8 KiB
Swift
251 lines
7.8 KiB
Swift
import AVFoundation
|
|
import Foundation
|
|
import Observation
|
|
|
|
struct AudioCaptureSnapshot: Sendable {
|
|
let pcm: [Float]
|
|
let sampleRate: Double
|
|
let channelCount: Int
|
|
let duration: TimeInterval
|
|
}
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class AudioCaptureService {
|
|
private static let preferredSampleRate: Double = 16_000
|
|
private static let maxStoredSeconds: Double = 30
|
|
|
|
var permissionStatus: AppPermissionStatus = .notDetermined
|
|
var isCapturing = false
|
|
var sampleRate: Double = 0
|
|
var channelCount: Int = 0
|
|
var capturedSampleCount = 0
|
|
var bufferedSampleCount = 0
|
|
var duration: TimeInterval = 0
|
|
var peakLevel: Float = 0
|
|
var statusText = ""
|
|
var lastErrorMessage: String?
|
|
|
|
@ObservationIgnored private let audioEngine = AVAudioEngine()
|
|
@ObservationIgnored private let audioSession = AVAudioSession.sharedInstance()
|
|
@ObservationIgnored private let pcmLock = NSLock()
|
|
@ObservationIgnored private var rollingPCM: [Float] = []
|
|
|
|
init() {
|
|
refreshPermissionStatus()
|
|
}
|
|
|
|
func refreshPermissionStatus() {
|
|
switch audioSession.recordPermission {
|
|
case .granted:
|
|
permissionStatus = .granted
|
|
case .denied:
|
|
permissionStatus = .denied
|
|
case .undetermined:
|
|
permissionStatus = .notDetermined
|
|
@unknown default:
|
|
permissionStatus = .restricted
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
func toggleCapture() async -> Bool {
|
|
if isCapturing {
|
|
stopCapture()
|
|
return true
|
|
} else {
|
|
return await startCapture()
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
func startCapture() async -> Bool {
|
|
refreshPermissionStatus()
|
|
if permissionStatus == .notDetermined {
|
|
let granted = await requestPermission()
|
|
guard granted else {
|
|
lastErrorMessage = "麦克风权限未授予,无法开始录音。"
|
|
return false
|
|
}
|
|
}
|
|
|
|
guard permissionStatus.isGranted else {
|
|
lastErrorMessage = "麦克风权限不可用,请到系统设置中开启。"
|
|
return false
|
|
}
|
|
|
|
guard !isCapturing else { return true }
|
|
|
|
resetCaptureState()
|
|
lastErrorMessage = nil
|
|
statusText = "准备录音..."
|
|
|
|
do {
|
|
try audioSession.setCategory(
|
|
.playAndRecord,
|
|
mode: .measurement,
|
|
options: [.defaultToSpeaker, .allowBluetooth]
|
|
)
|
|
try audioSession.setPreferredSampleRate(Self.preferredSampleRate)
|
|
try audioSession.setActive(true)
|
|
|
|
let inputNode = audioEngine.inputNode
|
|
let inputFormat = inputNode.outputFormat(forBus: 0)
|
|
sampleRate = inputFormat.sampleRate
|
|
channelCount = Int(inputFormat.channelCount)
|
|
|
|
inputNode.removeTap(onBus: 0)
|
|
inputNode.installTap(onBus: 0, bufferSize: 1024, format: inputFormat) { [weak self] buffer, _ in
|
|
self?.handleIncomingPCM(buffer)
|
|
}
|
|
|
|
audioEngine.prepare()
|
|
try audioEngine.start()
|
|
|
|
isCapturing = true
|
|
updateStatusText()
|
|
return true
|
|
} catch {
|
|
stopCapture(deactivateSession: false)
|
|
lastErrorMessage = "启动录音失败:\(error.localizedDescription)"
|
|
statusText = lastErrorMessage ?? ""
|
|
return false
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
func stopCapture(deactivateSession: Bool = true) -> AudioCaptureSnapshot? {
|
|
audioEngine.inputNode.removeTap(onBus: 0)
|
|
audioEngine.stop()
|
|
if deactivateSession {
|
|
try? audioSession.setActive(false, options: [.notifyOthersOnDeactivation])
|
|
}
|
|
|
|
let snapshot = latestSnapshot()
|
|
isCapturing = false
|
|
peakLevel = 0
|
|
|
|
if let snapshot, snapshot.duration > 0 {
|
|
statusText = String(
|
|
format: "已录制 %.1f 秒音频(%.0f Hz,%d 声道),可以直接发送给模型。",
|
|
snapshot.duration,
|
|
snapshot.sampleRate,
|
|
snapshot.channelCount
|
|
)
|
|
} else if lastErrorMessage == nil {
|
|
statusText = ""
|
|
}
|
|
|
|
return snapshot
|
|
}
|
|
|
|
func clearStatus() {
|
|
statusText = ""
|
|
lastErrorMessage = nil
|
|
}
|
|
|
|
func consumeLatestSnapshot() -> AudioCaptureSnapshot? {
|
|
let snapshot = latestSnapshot()
|
|
resetCaptureState()
|
|
clearStatus()
|
|
return snapshot
|
|
}
|
|
|
|
func latestSnapshot() -> AudioCaptureSnapshot? {
|
|
pcmLock.lock()
|
|
let pcm = rollingPCM
|
|
pcmLock.unlock()
|
|
|
|
guard !pcm.isEmpty, sampleRate > 0 else { return nil }
|
|
return AudioCaptureSnapshot(
|
|
pcm: pcm,
|
|
sampleRate: sampleRate,
|
|
channelCount: 1,
|
|
duration: Double(pcm.count) / sampleRate
|
|
)
|
|
}
|
|
|
|
private func requestPermission() async -> Bool {
|
|
let granted = await withCheckedContinuation { continuation in
|
|
audioSession.requestRecordPermission { granted in
|
|
continuation.resume(returning: granted)
|
|
}
|
|
}
|
|
refreshPermissionStatus()
|
|
return granted
|
|
}
|
|
|
|
private func resetCaptureState() {
|
|
pcmLock.lock()
|
|
rollingPCM.removeAll(keepingCapacity: true)
|
|
pcmLock.unlock()
|
|
|
|
capturedSampleCount = 0
|
|
bufferedSampleCount = 0
|
|
duration = 0
|
|
peakLevel = 0
|
|
sampleRate = 0
|
|
channelCount = 0
|
|
}
|
|
|
|
private func handleIncomingPCM(_ buffer: AVAudioPCMBuffer) {
|
|
let frameCount = Int(buffer.frameLength)
|
|
guard frameCount > 0 else { return }
|
|
let formatChannelCount = Int(buffer.format.channelCount)
|
|
guard let channelData = buffer.floatChannelData else { return }
|
|
|
|
let samples: [Float]
|
|
if formatChannelCount <= 1 {
|
|
samples = Array(UnsafeBufferPointer(start: channelData[0], count: frameCount))
|
|
} else {
|
|
var mixed = Array(repeating: Float.zero, count: frameCount)
|
|
let scale = 1.0 / Float(formatChannelCount)
|
|
for channel in 0 ..< formatChannelCount {
|
|
let channelSamples = UnsafeBufferPointer(start: channelData[channel], count: frameCount)
|
|
for index in 0 ..< frameCount {
|
|
mixed[index] += channelSamples[index] * scale
|
|
}
|
|
}
|
|
samples = mixed
|
|
}
|
|
let rms = sqrt(samples.reduce(0) { $0 + ($1 * $1) } / Float(max(frameCount, 1)))
|
|
let peak = samples.map { abs($0) }.max() ?? 0
|
|
|
|
pcmLock.lock()
|
|
rollingPCM.append(contentsOf: samples)
|
|
let maxStoredSamples = Int(max(sampleRate, Self.preferredSampleRate) * Self.maxStoredSeconds)
|
|
if rollingPCM.count > maxStoredSamples {
|
|
rollingPCM.removeFirst(rollingPCM.count - maxStoredSamples)
|
|
}
|
|
let bufferedCount = rollingPCM.count
|
|
pcmLock.unlock()
|
|
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
capturedSampleCount += frameCount
|
|
bufferedSampleCount = bufferedCount
|
|
if sampleRate == 0 {
|
|
sampleRate = buffer.format.sampleRate
|
|
}
|
|
if channelCount == 0 {
|
|
channelCount = 1
|
|
}
|
|
if sampleRate > 0 {
|
|
duration = Double(capturedSampleCount) / sampleRate
|
|
}
|
|
peakLevel = max(rms, peak)
|
|
updateStatusText()
|
|
}
|
|
}
|
|
|
|
private func updateStatusText() {
|
|
guard isCapturing else { return }
|
|
statusText = String(
|
|
format: "录音中 %.1f 秒 · %.0f Hz · 缓冲 %.1f 秒 PCM",
|
|
duration,
|
|
max(sampleRate, Self.preferredSampleRate),
|
|
sampleRate > 0 ? Double(bufferedSampleCount) / sampleRate : 0
|
|
)
|
|
}
|
|
}
|