diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift index 251984c27..a80a0d7cd 100644 --- a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift +++ b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift @@ -347,7 +347,7 @@ class AgentServiceImpl: NSObject { return false } - azureAsrHelper?.pushAudioData(data: audioData) + azureAsrHelper?.audioStream?.saveAudioDataTo(data) return true } diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift index b0f4bcb02..ce81a449d 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift @@ -31,7 +31,13 @@ public class AzureAsrHelper: NSObject { private var isAutoDetectLanguage = false private var subscriptionKey = "" private var region = "" + // 音频处理 + private let audioQueue = DispatchQueue(label: "audio.processing.queue") + private var audioEngine: AVAudioEngine? + private var audioBuffer: Data? + // 录音文件处理 + private let recordFile = RecordFile.shared // 音频源配置 public enum AudioSourceType { /** 使用设备麦克风 */ @@ -45,6 +51,11 @@ public class AzureAsrHelper: NSObject { // 音频处理 private var externalAudioStream: ExternalAudioPullStream? + + private var audioStream: AudioStream? + + // 当前回调 + private weak var currentContinuousCallback: ContinuousRecognizeCallback? /** * 初始化Azure语音服务 @@ -106,28 +117,16 @@ public class AzureAsrHelper: NSObject { } // 创建识别器 - return setupRecognizer() + return true } catch { os_log("初始化失败: %{public}@", log: log, type: .error, error.localizedDescription) return false } } - - /** - * 向音频流写入音频数据 - * 仅当音频源设置为external时有效 - * - * @param data 音频数据字节数组 - */ - public func pushAudioData(data: Data) { - if audioSourceType != .external { - return - } - - // 使用拉流模式,将数据推入队列 - externalAudioStream?.pushAudio(data) + + private fun isRecognizerValid(): Boolean { + return recognizer != null } - /** * 开始连续语音识别 * @@ -136,7 +135,7 @@ public class AzureAsrHelper: NSObject { * @return 是否成功开始识别 */ public func startContinuousRecognition( - callback: ContinuousRecognizeCallback, + audioSourceType: AudioSourceType = .microphone ) -> Bool { guard speechConfig != nil else { @@ -147,22 +146,24 @@ public class AzureAsrHelper: NSObject { if _isContinuousRecognitionActive { return true } - + if (!isRecognizerValid()) { + Log.w(tag, "识别器已失效,正在重新创建...") + if (!setupRecognizer()) { + Log.e(tag, "重新创建识别器失败") + return false + } + } self.audioSourceType = audioSourceType - // 重置识别器 - if !setupRecognizer() { - callback.onError("重置识别器失败") - return false - } + do { // 设置各种事件监听 - setupEventListeners(callback: callback) + //setupEventListeners(callback: callback) // 启动音频处理 - startAudioProcessing() - + // startAudioProcessing() + audioStream!!.startAudioRecord() // 开始连续识别 try recognizer?.startContinuousRecognition() _isContinuousRecognitionActive = true @@ -201,7 +202,7 @@ public class AzureAsrHelper: NSObject { } if audioSourceType == .external { - pushAudioData(data: Data()) + //pushAudioData(data: Data()) } // 停止连续识别 @@ -266,6 +267,38 @@ public class AzureAsrHelper: NSObject { speechConfig = nil } } + // MARK: - 录音功能 + /// 开启录音 + func enableRecord(filePath: String) { + recordFile.stopRecord(isSave: false) + recordFile.creatingFiles(atPath: filePath) + } + + /// 移动文件 + func moveFile(sourcePath: String, destPath: String) -> Bool { + return recordFile.moveFile(from: sourcePath, to: destPath) + } + + /// 重命名文件 + func renameFile(at sourcePath: String, to newName: String) -> Bool { + return recordFile.renameFile(at: sourcePath, to: newName) + } + + /// 暂停录音 + func pauseRecord() { + recordFile.isPause = true + } + + /// 停止录音 + func stopRecord(isSave: Bool) { + recordFile.stopRecord(isSave: isSave) + } + + /// 输入外部音频数据 + func saveAudioDataTo(_ data: Data) { + guard audioSourceType == .external else { return } + writeQueue.offer(data.copyOf()) + } // MARK: - 私有方法 @@ -325,13 +358,32 @@ public class AzureAsrHelper: NSObject { externalAudioStream = nil } } + /** + * 设置麦克风流 - 使用推流方式 + */ + private func setupMicrophoneStream() { + do { + // 创建麦克风流对象 + audioStream = AudioStream() + + // 创建音频配置 + audioConfig = SPXAudioConfiguration(streamInput: audioStream!.pushAudioStream) + } catch { + os_log("设置麦克风流失败: %{public}@", log: log, type: .error, error.localizedDescription) + audioStream = nil + } + } /** * 设置事件监听器 */ private func setupEventListeners(callback: ContinuousRecognizeCallback) { guard let recognizer = recognizer else { return } - + // 重置识别器 + if !setupRecognizer() { + callback.onError("重置识别器失败") + return false + } // 识别中事件 recognizer.addRecognizingEventHandler { [weak self] (sender, event) in guard let self = self else { return } @@ -432,7 +484,171 @@ public class AzureAsrHelper: NSObject { } } - + /** + * 麦克风音频流处理 + */ + private class AudioStream: NSObject { + private var audioEngine: AVAudioEngine? + private var pushAudioStream: SPXPushAudioInputStream? + private var audioFile: AVAudioFile? + private var isRecording = false + private var recordFilePath: URL? + private let writeQueue = LinkedBlockingQueue() + override init() { + super.init() + + // 创建推流对象 + let format = SPXAudioStreamFormat(usingPCMWithSampleRate: 16000, bitsPerSample: 16, channels: 1)! + pushAudioStream = SPXPushAudioInputStream(audioFormat: format) + } + + /** + * 开始音频捕获 + */ + func startAudioRecord() { + do { + // 配置音频会话 + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetooth]) + try session.setActive(true) + + // 初始化音频引擎 + audioEngine = AVAudioEngine() + guard let engine = audioEngine else { return } + + // 获取输入节点 + let inputNode = engine.inputNode + let inputFormat = inputNode.outputFormat(forBus: 0) + + // 配置音频格式 + let recordingFormat = AVAudioFormat( + commonFormat: .pcmFormatInt16, + sampleRate: 16000, + channels: 1, + interleaved: true + )! + + // 安装Tap + inputNode.installTap( + onBus: 0, + bufferSize: 1024, + format: inputFormat + ) { [weak self] (buffer, time) in + self?.processAudioBuffer(buffer, format: recordingFormat) + } + + // 启动引擎 + try engine.start() + } catch { + os_log("音频捕获启动失败: %{public}@", type: .error, error.localizedDescription) + } + } + + /** + * 停止音频捕获 + */ + func stopCapture() { + audioEngine?.stop() + audioEngine?.inputNode.removeTap(onBus: 0) + audioEngine = nil + stopRecord(isSave: true) + } + + /** + * 处理音频缓冲区 + */ + private func processAudioBuffer(_ buffer: AVAudioPCMBuffer, format: AVAudioFormat) { + guard let converter = AVAudioConverter(from: buffer.format, to: format) else { return } + + // 设置音频配置 + switch audioSourceType { + case .microphone: + // 使用默认麦克风输入配置 + // 创建目标缓冲区 + let targetFrameCapacity = AVAudioFrameCount( + (Double(buffer.frameCapacity) * format.sampleRate / buffer.format.sampleRate + ) + guard let targetBuffer = AVAudioPCMBuffer( + pcmFormat: format, + frameCapacity: targetFrameCapacity + ) else { return } + + // 转换音频格式 + var error: NSError? + let inputBlock: AVAudioConverterInputBlock = { inNumPackets, outStatus in + outStatus.pointee = .haveData + return buffer + } + + converter.convert(to: targetBuffer, error: &error, withInputFrom: inputBlock) + + // 获取音频数据 + guard let int16Data = targetBuffer.int16ChannelData else { return } + let data = Data( + bytes: int16Data[0], + count: Int(targetBuffer.frameLength) * MemoryLayout.size + ) + + case .external: + // 使用拉流方式处理外部音频 + if (writeQueue.isNotEmpty()) { + let data = writeQueue.poll() + Log.d("tag", "写入数据: ${data?.size}") + bytesToWrite = data?.size ?: 0 + } + + } + + // 推送到Azure流 + pushAudioStream?.write(data) + + // 保存到录音文件 + + + // 触发音频回调 + if let callback = AzureAsrHelper.shared?.currentContinuousCallback { + callback.onAudio(data) + } + } + + /** + * 开启录音 + */ + func enableRecord(filePath: String) { + do { + recordFilePath = URL(fileURLWithPath: filePath) + + let settings: [String: Any] = [ + AVFormatIDKey: kAudioFormatLinearPCM, + AVSampleRateKey: 16000.0, + AVNumberOfChannelsKey: 1, + AVEncoderBitDepthHintKey: 16, + AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue + ] + + audioFile = try AVAudioFile( + forWriting: recordFilePath!, + settings: settings + ) + isRecording = true + } catch { + os_log("录音文件创建失败: %{public}@", type: .error, error.localizedDescription) + } + } + + /** + * 停止录音 + */ + func stopRecord(isSave: Bool) { + isRecording = false + audioFile = nil + + if !isSave, let path = recordFilePath { + try? FileManager.default.removeItem(at: path) + } + recordFilePath = nil + } + } /** * 外部音频拉流 * 实现PullAudioInputStreamCallback,将外部推送的音频数据转换为SDK可拉取的形式 diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift index 66e375647..7d2e55168 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift @@ -183,8 +183,8 @@ import os.log result(FlutterError(code: "INVALID_ARGUMENTS", message: "音频数据不能为空", details: nil)) return } - - azureAsrHelper.pushAudioData(data: audioBytes.data) + azureAsrHelper?.audioStream?.saveAudioDataTo(data: audioBytes.data) + result(true) default: diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/tools/RecordFile.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/tools/RecordFile.swift new file mode 100644 index 000000000..723e592d0 --- /dev/null +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/tools/RecordFile.swift @@ -0,0 +1,232 @@ +import Foundation + +class RecordFile { + static let shared = RecordFile() + + private var currentAudioFile: URL? + private var fileHandle: FileHandle? + private let writeQueue = DispatchQueue(label: "audio.write.queue") + private let writeGroup = DispatchGroup() + private var isWriting = false + private var dataBuffer = Data() + private var totalBytesWritten = 0 + private let sampleRate: UInt32 = 16000 + private var lastSavedFile: URL? + var isPause = false + var fileName = "" + + // 创建音频文件并初始化 WAV 头 + func creatingFiles(atPath path: String) { + guard fileHandle == nil else { return } + + let fileURL = URL(fileURLWithPath: path) + let directoryURL = fileURL.deletingLastPathComponent() + + // 创建目录 + do { + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + } catch { + print("创建目录失败: \(error.localizedDescription)") + return + } + + // 创建文件 + FileManager.default.createFile(atPath: path, contents: nil) + + do { + fileHandle = try FileHandle(forWritingTo: fileURL) + currentAudioFile = fileURL + + // 写入初始 WAV 头 + let header = generateWavHeader(dataLength: 0) + try fileHandle?.write(contentsOf: header) + totalBytesWritten = 0 + } catch { + print("文件初始化失败: \(error.localizedDescription)") + fileHandle = nil + currentAudioFile = nil + } + } + + // 异步保存音频数据 + func saveAudioDataToWav(_ data: Data) { + guard let _ = fileHandle, let _ = currentAudioFile else { return } + + writeQueue.async { [weak self] in + guard let self = self else { return } + self.dataBuffer.append(data) + self.totalBytesWritten += data.count + + if !self.isWriting { + self.isWriting = true + self.writeGroup.enter() + + defer { + self.isWriting = false + self.writeGroup.leave() + } + + while !self.dataBuffer.isEmpty { + let chunk: Data + if self.dataBuffer.count > 8192 { + chunk = self.dataBuffer.prefix(8192) + self.dataBuffer.removeFirst(8192) + } else { + chunk = self.dataBuffer + self.dataBuffer.removeAll() + } + + do { + try self.fileHandle?.seekToEnd() + self.fileHandle?.write(chunk) + } catch { + print("写入数据失败: \(error.localizedDescription)") + } + } + } + } + } + + // 生成 WAV 文件头 + private func generateWavHeader(dataLength: Int) -> Data { + let totalLength = 36 + dataLength + let byteRate = sampleRate * 2 + + var header = Data() + header.append("RIFF".data(using: .ascii)!) + header.append(Data(bytes: [ + UInt8(totalLength & 0xFF), + UInt8((totalLength >> 8) & 0xFF), + UInt8((totalLength >> 16) & 0xFF), + UInt8((totalLength >> 24) & 0xFF) + ])) + header.append("WAVE".data(using: .ascii)!) + header.append("fmt ".data(using: .ascii)!) + header.append(Data(bytes: [16, 0, 0, 0])) // PCM 头长度 + header.append(Data(bytes: [1, 0])) // PCM 格式 + header.append(Data(bytes: [1, 0])) // 单声道 + header.append(Data(bytes: [ + UInt8(sampleRate & 0xFF), + UInt8((sampleRate >> 8) & 0xFF), + UInt8((sampleRate >> 16) & 0xFF), + UInt8((sampleRate >> 24) & 0xFF) + ])) + header.append(Data(bytes: [ + UInt8(byteRate & 0xFF), + UInt8((byteRate >> 8) & 0xFF), + UInt8((byteRate >> 16) & 0xFF), + UInt8((byteRate >> 24) & 0xFF) + ])) + header.append(Data(bytes: [2, 0])) // 块对齐 + header.append(Data(bytes: [16, 0])) // 样本位数 + header.append("data".data(using: .ascii)!) + header.append(Data(bytes: [ + UInt8(dataLength & 0xFF), + UInt8((dataLength >> 8) & 0xFF), + UInt8((dataLength >> 16) & 0xFF), + UInt8((dataLength >> 24) & 0xFF) + ])) + + return header + } + + // 重命名文件 + func renameFile(at sourcePath: String, to newName: String) -> Bool { + let sourceURL = URL(fileURLWithPath: sourcePath) + + guard FileManager.default.fileExists(atPath: sourcePath) else { + print("重命名失败: 文件不存在 \(sourcePath)") + return false + } + + if let current = currentAudioFile, current == sourceURL { + print("重命名失败: 不能重命名正在录制的文件") + return false + } + + let newFileURL = sourceURL + .deletingLastPathComponent() + .appendingPathComponent(newName) + .appendingPathExtension("wav") + + do { + try FileManager.default.moveItem(at: sourceURL, to: newFileURL) + if lastSavedFile == sourceURL { + lastSavedFile = newFileURL + } + return true + } catch { + print("重命名异常: \(error.localizedDescription)") + return false + } + } + + // 移动文件 + func moveFile(from sourcePath: String, to destPath: String) -> Bool { + let sourceURL = URL(fileURLWithPath: sourcePath) + let destURL = URL(fileURLWithPath: destPath) + + guard FileManager.default.fileExists(atPath: sourcePath) else { + print("移动失败: 源文件不存在 \(sourcePath)") + return false + } + + let destDir = destURL.deletingLastPathComponent() + do { + try FileManager.default.createDirectory(at: destDir, withIntermediateDirectories: true) + } catch { + print("创建目录失败: \(error.localizedDescription)") + return false + } + + do { + try FileManager.default.moveItem(at: sourceURL, to: destURL) + if lastSavedFile == sourceURL { + lastSavedFile = destURL + } + return true + } catch { + print("移动文件异常: \(error.localizedDescription)") + return false + } + } + + // 关闭文件 + func closeFile(isSave: Bool) -> Bool { + defer { + fileHandle = nil + currentAudioFile = nil + dataBuffer.removeAll() + totalBytesWritten = 0 + } + + guard let fileURL = currentAudioFile, let handle = fileHandle else { + return false + } + + // 等待写入完成 + writeGroup.wait() + + do { + try handle.close() + + if !isSave || totalBytesWritten == 0 { + try FileManager.default.removeItem(at: fileURL) + return false + } + + // 更新 WAV 头 + let header = generateWavHeader(dataLength: totalBytesWritten) + let updateHandle = try FileHandle(forUpdating: fileURL) + try updateHandle.seek(toOffset: 0) + updateHandle.write(header) + try updateHandle.close() + + lastSavedFile = fileURL + return true + } catch { + print("关闭文件失败: \(error.localizedDescription)") + return false + } + } +} \ No newline at end of file