|
|
|
@ -206,49 +206,55 @@ public class MicrophoneCapture: NSObject { |
|
|
|
} |
|
|
|
|
|
|
|
// 检查麦克风权限 |
|
|
|
let permission: AVAudioSession.RecordPermission |
|
|
|
switch audioSession.recordPermission { |
|
|
|
case .granted: |
|
|
|
do { |
|
|
|
try audioSession.setCategory(.record, |
|
|
|
mode: .default, |
|
|
|
options: []) |
|
|
|
if let inputs = audioSession.availableInputs, |
|
|
|
let builtInMic = inputs.first(where: { $0.portType == .builtInMic }) { |
|
|
|
try? audioSession.setPreferredInput(builtInMic) |
|
|
|
} |
|
|
|
try audioSession.setActive(true) |
|
|
|
} catch { |
|
|
|
throw error |
|
|
|
} |
|
|
|
try setupAudioEngine() |
|
|
|
permission = .granted |
|
|
|
case .denied: |
|
|
|
throw NSError(domain: "麦克风权限被拒绝", code: 0) |
|
|
|
permission = .denied |
|
|
|
case .undetermined: |
|
|
|
let semaphore = DispatchSemaphore(value: 0) |
|
|
|
var grantedResult = false |
|
|
|
audioSession.requestRecordPermission { granted in |
|
|
|
if granted { |
|
|
|
do { |
|
|
|
do { |
|
|
|
try audioSession.setCategory(.record, |
|
|
|
mode: .default, |
|
|
|
options: []) |
|
|
|
if let inputs = audioSession.availableInputs, |
|
|
|
let builtInMic = inputs.first(where: { $0.portType == .builtInMic }) { |
|
|
|
try? audioSession.setPreferredInput(builtInMic) |
|
|
|
} |
|
|
|
try audioSession.setActive(true) |
|
|
|
} catch { |
|
|
|
print("音频会话配置失败: \(error.localizedDescription)") |
|
|
|
return |
|
|
|
} |
|
|
|
try self.setupAudioEngine() |
|
|
|
} catch { |
|
|
|
print("音频引擎设置失败: \(error.localizedDescription)") |
|
|
|
} |
|
|
|
} |
|
|
|
grantedResult = granted |
|
|
|
semaphore.signal() |
|
|
|
} |
|
|
|
let waitResult = semaphore.wait(timeout: .now() + 60) |
|
|
|
if waitResult == .timedOut { |
|
|
|
throw NSError(domain: "麦克风权限请求超时", code: 2) |
|
|
|
} |
|
|
|
permission = grantedResult ? .granted : .denied |
|
|
|
@unknown default: |
|
|
|
throw NSError(domain: "未知权限状态", code: 1) |
|
|
|
} |
|
|
|
|
|
|
|
guard permission == .granted else { |
|
|
|
throw NSError(domain: "麦克风权限被拒绝", code: 0) |
|
|
|
} |
|
|
|
|
|
|
|
do { |
|
|
|
print("startCapture: 激活前 category=\(audioSession.category.rawValue) mode=\(audioSession.mode.rawValue) options=\(audioSession.categoryOptions)") |
|
|
|
print("startCapture: 激活前 sampleRate=\(audioSession.sampleRate) ioBuffer=\(audioSession.ioBufferDuration)") |
|
|
|
print("startCapture: 激活前 route inputs=\(audioSession.currentRoute.inputs.map { $0.portType.rawValue }) outputs=\(audioSession.currentRoute.outputs.map { $0.portType.rawValue })") |
|
|
|
if let inputs = audioSession.availableInputs, |
|
|
|
let builtInMic = inputs.first(where: { $0.portType == .builtInMic }) { |
|
|
|
try? audioSession.setPreferredInput(builtInMic) |
|
|
|
} |
|
|
|
try audioSession.setActive(true) |
|
|
|
print("startCapture: 激活后 category=\(audioSession.category.rawValue) mode=\(audioSession.mode.rawValue) options=\(audioSession.categoryOptions)") |
|
|
|
print("startCapture: 激活后 sampleRate=\(audioSession.sampleRate) ioBuffer=\(audioSession.ioBufferDuration)") |
|
|
|
print("startCapture: 激活后 route inputs=\(audioSession.currentRoute.inputs.map { $0.portType.rawValue }) outputs=\(audioSession.currentRoute.outputs.map { $0.portType.rawValue })") |
|
|
|
} catch { |
|
|
|
throw error |
|
|
|
} |
|
|
|
isCapturing = true |
|
|
|
do { |
|
|
|
try setupAudioEngine() |
|
|
|
} catch { |
|
|
|
isCapturing = false |
|
|
|
cleanupAudioEngine() |
|
|
|
throw error |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
@ -299,36 +305,51 @@ public class MicrophoneCapture: NSObject { |
|
|
|
if let audioSession = audioSession { |
|
|
|
sampleRate = audioSession.sampleRate |
|
|
|
} |
|
|
|
|
|
|
|
// 设置音频格式 |
|
|
|
let inputFormat = audioInputNode.inputFormat(forBus: 0) |
|
|
|
|
|
|
|
// iOS 13+ 启用语音处理 |
|
|
|
|
|
|
|
// iOS 13+ 启用语音处理(可能在部分路由/模式下失败,失败不应阻断录音启动) |
|
|
|
if #available(iOS 13.0, *) { |
|
|
|
try audioInputNode.setVoiceProcessingEnabled(true) |
|
|
|
do { |
|
|
|
try audioInputNode.setVoiceProcessingEnabled(true) |
|
|
|
} catch { |
|
|
|
if let nsError = error as NSError? { |
|
|
|
print("启用语音处理失败: domain=\(nsError.domain) code=\(nsError.code) desc=\(nsError.localizedDescription)") |
|
|
|
} else { |
|
|
|
print("启用语音处理失败: \(error.localizedDescription)") |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// 检查输入格式是否符合Microsoft要求 |
|
|
|
let needsConversion = !isMicrosoftCompatibleFormat(inputFormat) |
|
|
|
|
|
|
|
// 需要格式转换 |
|
|
|
guard let targetFormat = audioFormat, |
|
|
|
let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else { |
|
|
|
|
|
|
|
// 等待并获取有效的输入格式:在路由/类别切换瞬间可能出现 0Hz,直接 installTap 会触发系统断言崩溃 |
|
|
|
guard let inputFormat = waitForValidInputFormat(inputNode: audioInputNode, maxAttempts: 10, retryIntervalSeconds: 0.03) else { |
|
|
|
let currentSession = AVAudioSession.sharedInstance() |
|
|
|
let inputs = currentSession.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",") |
|
|
|
let outputs = currentSession.currentRoute.outputs.map { $0.portType.rawValue }.joined(separator: ",") |
|
|
|
throw NSError(domain: "AudioSetup", code: 3, userInfo: [ |
|
|
|
NSLocalizedDescriptionKey: "输入音频格式无效(sampleRate/channelCount),可能处于路由或类别切换中", |
|
|
|
"sessionCategory": currentSession.category.rawValue, |
|
|
|
"sessionMode": currentSession.mode.rawValue, |
|
|
|
"sessionSampleRate": currentSession.sampleRate, |
|
|
|
"routeInputs": inputs, |
|
|
|
"routeOutputs": outputs |
|
|
|
]) |
|
|
|
} |
|
|
|
|
|
|
|
// 需要格式转换(输出固定 16k/16bit/mono PCM,匹配 PushStream 默认/常用配置) |
|
|
|
guard let targetFormat = audioFormat else { |
|
|
|
throw NSError(domain: "AudioSetup", code: 2, userInfo: [NSLocalizedDescriptionKey: "目标音频格式为空"]) |
|
|
|
} |
|
|
|
guard let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else { |
|
|
|
throw NSError(domain: "AudioSetup", code: 2, userInfo: [NSLocalizedDescriptionKey: "音频格式转换器创建失败"]) |
|
|
|
} |
|
|
|
|
|
|
|
print("输入格式不符合Microsoft要求,进行格式转换") |
|
|
|
print("输入格式: \(inputFormat.sampleRate)Hz, \(inputFormat.commonFormat.rawValue)") |
|
|
|
print("目标格式: \(targetFormat.sampleRate)Hz, \(targetFormat.commonFormat.rawValue)") |
|
|
|
|
|
|
|
// 添加tap进行格式转换 |
|
|
|
|
|
|
|
print("输入格式: \(inputFormat.sampleRate)Hz, \(inputFormat.commonFormat.rawValue), ch=\(inputFormat.channelCount), interleaved=\(inputFormat.isInterleaved)") |
|
|
|
print("目标格式: \(targetFormat.sampleRate)Hz, \(targetFormat.commonFormat.rawValue), ch=\(targetFormat.channelCount), interleaved=\(targetFormat.isInterleaved)") |
|
|
|
|
|
|
|
audioInputNode.installTap(onBus: 0, |
|
|
|
bufferSize: 1024, |
|
|
|
format: inputFormat) { [weak self] buffer, when in |
|
|
|
|
|
|
|
bufferSize: 1024, |
|
|
|
format: inputFormat) { [weak self] buffer, _ in |
|
|
|
guard let strongSelf = self else { return } |
|
|
|
|
|
|
|
// 修复:添加安全检查,避免强制解包崩溃 |
|
|
|
|
|
|
|
guard let convertedBuffer = AVAudioPCMBuffer( |
|
|
|
pcmFormat: targetFormat, |
|
|
|
frameCapacity: AVAudioFrameCount( |
|
|
|
@ -338,36 +359,59 @@ public class MicrophoneCapture: NSObject { |
|
|
|
print("创建转换缓冲区失败") |
|
|
|
return |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
var error: NSError? |
|
|
|
// 执行音频格式转换 |
|
|
|
let status = converter.convert( |
|
|
|
to: convertedBuffer, |
|
|
|
error: &error, |
|
|
|
withInputFrom: { inNumPackets, outStatus in |
|
|
|
withInputFrom: { _, outStatus in |
|
|
|
outStatus.pointee = .haveData |
|
|
|
return buffer |
|
|
|
} |
|
|
|
) |
|
|
|
|
|
|
|
// 转换成功且无错误 |
|
|
|
|
|
|
|
if status == .haveData, error == nil { |
|
|
|
let data = strongSelf.audioBufferToData(convertedBuffer) |
|
|
|
strongSelf.audioDataHandler?(data) |
|
|
|
} else if let error = error { |
|
|
|
print("音频格式转换失败: \(error.localizedDescription)") |
|
|
|
print("音频格式转换失败: domain=\(error.domain) code=\(error.code) desc=\(error.localizedDescription)") |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
audioEngine.prepare() |
|
|
|
// 启动引擎 |
|
|
|
do { |
|
|
|
try audioEngine.start() |
|
|
|
isCapturing = true |
|
|
|
} catch { |
|
|
|
print("音频引擎启动失败: \(error.localizedDescription)") |
|
|
|
if let nsError = error as NSError? { |
|
|
|
print("音频引擎启动失败: domain=\(nsError.domain) code=\(nsError.code) desc=\(nsError.localizedDescription)") |
|
|
|
} else { |
|
|
|
print("音频引擎启动失败: \(error.localizedDescription)") |
|
|
|
} |
|
|
|
throw error |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 等待并获取有效的输入音频格式 |
|
|
|
* - 参数: |
|
|
|
* - inputNode: 输入节点(通常为 AVAudioEngine.inputNode) |
|
|
|
* - maxAttempts: 最大重试次数 |
|
|
|
* - retryIntervalSeconds: 每次重试间隔(秒) |
|
|
|
* - 返回值: 若在重试窗口内拿到有效格式则返回 AVAudioFormat,否则返回 nil |
|
|
|
* - 异常: 无(内部不抛出异常) |
|
|
|
*/ |
|
|
|
private func waitForValidInputFormat(inputNode: AVAudioInputNode, maxAttempts: Int, retryIntervalSeconds: TimeInterval) -> AVAudioFormat? { |
|
|
|
for attempt in 1...maxAttempts { |
|
|
|
let format = inputNode.inputFormat(forBus: 0) |
|
|
|
if format.sampleRate > 0, format.channelCount > 0 { |
|
|
|
return format |
|
|
|
} |
|
|
|
print("输入格式无效,等待重试 attempt=\(attempt) sampleRate=\(format.sampleRate) ch=\(format.channelCount)") |
|
|
|
Thread.sleep(forTimeInterval: retryIntervalSeconds) |
|
|
|
} |
|
|
|
return nil |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 安全清理音频引擎资源 |
|
|
|
@ -401,6 +445,15 @@ public class MicrophoneCapture: NSObject { |
|
|
|
count: dataLength |
|
|
|
) |
|
|
|
} |
|
|
|
// Handle 32-bit integer format |
|
|
|
else if let int32Data = buffer.int32ChannelData { |
|
|
|
let int32Buffer = int32Data.pointee |
|
|
|
var int16Array = [Int16](repeating: 0, count: frameLength) |
|
|
|
for i in 0..<frameLength { |
|
|
|
int16Array[i] = Int16(max(Int32(Int16.min), min(Int32(Int16.max), int32Buffer[i] >> 16))) |
|
|
|
} |
|
|
|
return Data(bytes: int16Array, count: dataLength) |
|
|
|
} |
|
|
|
// Handle float format |
|
|
|
else if let floatData = buffer.floatChannelData { |
|
|
|
var int16Array = [Int16](repeating: 0, count: frameLength) |
|
|
|
|