diff --git a/lib/modules/opus_test/controllers/opus_test_controller.dart b/lib/modules/opus_test/controllers/opus_test_controller.dart index b22cc4b5e..c93f2bac4 100644 --- a/lib/modules/opus_test/controllers/opus_test_controller.dart +++ b/lib/modules/opus_test/controllers/opus_test_controller.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:path_provider/path_provider.dart'; import 'package:just_audio/just_audio.dart'; @@ -42,8 +43,10 @@ class OpusTestController extends GetxController { String? tempPcmPath; // 杰理OPUS解码器 - late JlOpus jlOpus; + JlOpus? jlOpus; StreamSubscription? _eventSubscription; + static const MethodChannel _bleServiceChannel = + MethodChannel('com.yunqiinnovation.ble_service'); @override void onInit() { @@ -56,16 +59,20 @@ class OpusTestController extends GetxController { void onClose() { player.dispose(); _eventSubscription?.cancel(); - jlOpus.dispose(); + jlOpus?.dispose(); super.onClose(); } // 初始化OPUS解码器 Future _initOpusDecoder() async { + if (!Platform.isAndroid) { + return; + } + jlOpus = JlOpus(); // 监听解码器事件 - _eventSubscription = jlOpus.eventStream.listen((event) { + _eventSubscription = jlOpus!.eventStream.listen((event) { switch (event.event) { case 'onStart': statusMessage.value = '开始${event.type == "file" ? "文件" : "流"}解码...'; @@ -86,12 +93,29 @@ class OpusTestController extends GetxController { }); // 初始化OPUS解码器 - final initialized = await jlOpus.initOpusDecoder(); + final initialized = await jlOpus!.initOpusDecoder(); if (!initialized) { statusMessage.value = 'OPUS解码器初始化失败'; } } + Future _decodeOpusFile( + String inPath, String outPath, OpusOption option) async { + if (Platform.isIOS) { + final result = await _bleServiceChannel.invokeMethod( + 'decodeOpusFile', + { + 'inPath': inPath, + 'outPath': outPath, + ...option.toMap(), + }, + ); + return result; + } + + return jlOpus?.decodeOpusFile(inPath, outPath, option); + } + // 请求必要权限 Future requestPermissions() async { if (Platform.isAndroid) { @@ -241,7 +265,7 @@ class OpusTestController extends GetxController { ); // 解码文件 - final pcmPath = await jlOpus.decodeOpusFile(inPath, outPath, option); + final pcmPath = await _decodeOpusFile(inPath, outPath, option); if (pcmPath == null) { statusMessage.value = '解码失败'; isDecoding.value = false; 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 8e3bf9d4a..6b6e90a2b 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 @@ -510,10 +510,6 @@ class AgentServiceImpl: NSObject { } } lastRecognitionMode = currentRecognitionMode - if (lastRecognitionMode == "ble_wakeup" && mode != "ble_wakeup"){//之前是唤醒模式,现在不是唤醒模式 - os_log("liwei------- 关闭唤醒通道", log: logger, type: .info) - BleService.shared.closeCodec() - } stopRecognition() if isAiStreaming { isAiStreaming = false @@ -527,7 +523,7 @@ class AgentServiceImpl: NSObject { // 如果音乐正在播放,则暂停 if MusicService.shared.isPlaying { - MusicService.shared.pause() + MusicService.shared.pause() } // 设置当前识别模式 @@ -591,13 +587,13 @@ class AgentServiceImpl: NSObject { let success = azureAsrHelper?.stopContinuousRecognition() ?? false if success { - // 识别结束后,立即停用 AudioSession,以便其他模块(如TTS)可以接管 - // 这是解决“按住说话”后无法播报问题的关键 do { - try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) - os_log("Audio session deactivated successfully after recognition.", log: logger, type: .info) + // let audioSession = AVAudioSession.sharedInstance() + // try audioSession.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers, .allowBluetoothA2DP]) + // try audioSession.setActive(true) + os_log("Audio session switched to playback successfully after recognition.", log: logger, type: .info) } catch { - os_log("Failed to deactivate audio session: %{public}@", log: logger, type: .error, error.localizedDescription) + os_log("Failed to switch audio session to playback: %{public}@", log: logger, type: .error, error.localizedDescription) } } else if isStartingRecognition { // 启动尚未完成,先记录一次待停止请求,onSessionStarted 到来后立即 stop @@ -2438,6 +2434,11 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback { if (currentRecognitionMode == "ble_wakeup") { audioPlayer?.playStopSound() } + + if wasMusicPlayingBeforeRecognition { + wasMusicPlayingBeforeRecognition = false + MusicService.shared.resume() + } } @@ -2459,6 +2460,11 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback { isStartingRecognition = false stopRequestedDuringStart = false stopIdleCheck() + + if wasMusicPlayingBeforeRecognition { + wasMusicPlayingBeforeRecognition = false + MusicService.shared.resume() + } } func onError(sessionid:String ,_ errorCode: Int, _ error: String) { @@ -2469,6 +2475,11 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback { isStartingRecognition = false stopRequestedDuringStart = false stopIdleCheck() + + if wasMusicPlayingBeforeRecognition { + wasMusicPlayingBeforeRecognition = false + MusicService.shared.resume() + } } // MARK: - isInterrupt 缓存方法 diff --git a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift index ad0326ce2..0691e1e88 100644 --- a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift +++ b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift @@ -55,6 +55,10 @@ public class BleService: NSObject { // 添加录制文件管理器 private var recordingFile: RecordingFile? + + private var pendingStopOpusRecording = false + private var stopOpusRecordingWorkItem: DispatchWorkItem? + private var lastOpusPacketReceivedAt: TimeInterval = 0 private var isScanning = false private var scanTimer: Timer? @@ -471,6 +475,7 @@ private var cmdReplyType: UInt8 = 0 // 停止音频发送定时器 stopAudioSendTimer() + stopOpusRecording() // 清空音频缓冲区和队列 audioQueueLock.lock() @@ -743,9 +748,7 @@ private var cmdReplyType: UInt8 = 0 public func closeCodec() -> Bool { os_log("关闭编解码并停止录制...", log: logger, type: .info) - // 停止录制 - recordingFile?.closeFile() - recordingFile = nil + // 停止音频发送定时器 stopAudioSendTimer() @@ -756,6 +759,9 @@ private var cmdReplyType: UInt8 = 0 audioDataQueue.removeAll() audioQueueLock.unlock() + + requestStopOpusRecording() + // 不停止Opus解码流,只发送命令通知设备关闭编解码 let paramData = Data([BleConst.CODEC_CONTROL_CLOSE, BleConst.AUDIO_CHANNEL_RIGHT]) return sendCommand(BleConst.CMD_CONTROL_CODEC, data: paramData) @@ -906,8 +912,69 @@ private var cmdReplyType: UInt8 = 0 return } + lastOpusPacketReceivedAt = Date().timeIntervalSince1970 + + ensureOpusRecordingStarted(fileName: "耳机端").saveAudioData(data) + // 使用Opus处理器处理音频数据 opusProcessor?.processAudioData(data) + + if pendingStopOpusRecording { + requestStopOpusRecording() + } + } + + /// 确保 Opus 文件已创建并处于可写状态 + /// - Parameter fileName: 文件名前缀(不含时间戳与扩展名) + /// - Returns: 可用于追加写入的 RecordingFile 实例 + /// - Throws: 不抛出;若创建文件失败将导致后续写入无效 + private func ensureOpusRecordingStarted(fileName: String) -> RecordingFile { + if recordingFile == nil { + let recorder = RecordingFile() + recorder.fileName = fileName + recordingFile = recorder + } else if (recordingFile?.fileName.isEmpty ?? true) { + recordingFile?.fileName = fileName + } + + if recordingFile?.isRecording() != true { + recordingFile?.createFile() + } + + return recordingFile! + } + + /// 停止并释放 Opus 落盘资源 + /// - Returns: 无 + /// - Throws: 不抛出 + private func stopOpusRecording() { + stopOpusRecordingWorkItem?.cancel() + stopOpusRecordingWorkItem = nil + pendingStopOpusRecording = false + recordingFile?.closeFile() + recordingFile = nil + } + + /// 请求在短暂静默后停止 Opus 落盘,避免截断尾包导致文件不完整 + /// - Parameters: + /// - graceSeconds: 等待静默的时间窗口 + /// - Returns: 无 + /// - Throws: 不抛出 + private func requestStopOpusRecording(graceSeconds: TimeInterval = 1.0) { + pendingStopOpusRecording = true + + stopOpusRecordingWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + guard let self = self else { return } + let now = Date().timeIntervalSince1970 + if now - self.lastOpusPacketReceivedAt >= graceSeconds { + self.stopOpusRecording() + } else { + self.requestStopOpusRecording(graceSeconds: graceSeconds) + } + } + stopOpusRecordingWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + graceSeconds, execute: workItem) } /// 处理接收到的响应数据 @@ -1786,6 +1853,7 @@ extension BleService: CBCentralManagerDelegate { } updateConnectionState(BleConst.STATE_DISCONNECTED) + stopOpusRecording() // 只有在非主动断开的情况下才重新连接 if !isManualDisconnect { diff --git a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/RecordingFile.swift b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/RecordingFile.swift index 6b5824dfc..1942bcc0c 100644 --- a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/RecordingFile.swift +++ b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/RecordingFile.swift @@ -78,7 +78,7 @@ class RecordingFile { do { try self.fileHandle?.write(contentsOf: buffer) self.totalBytesWritten += buffer.count - os_log("写入音频数据: %d bytes, 总计: %d bytes", log: self.logger, type: .debug, buffer.count, self.totalBytesWritten) + // os_log("写入音频数据: %d bytes, 总计: %d bytes", log: self.logger, type: .debug, buffer.count, self.totalBytesWritten) } catch { os_log("写入音频数据失败: %@", log: self.logger, type: .error, error.localizedDescription) } diff --git a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift index c75f8bf5d..ca6086159 100644 --- a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift +++ b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift @@ -2,6 +2,7 @@ import Flutter import UIKit import CoreBluetooth import os +import opus @available(iOS 13.0, *) @objc(BleServicePlugin) @@ -172,6 +173,40 @@ public class SwiftBleServicePlugin: NSObject, FlutterPlugin { // 移除这个方法调用,让iOS系统自然处理权限 result(FlutterMethodNotImplemented) + case "decodeOpusFile": + guard let args = call.arguments as? [String: Any], + let inPath = args["inPath"] as? String, + let outPath = args["outPath"] as? String else { + result(FlutterError(code: "INVALID_ARGS", message: "缺少必要参数", details: nil)) + return + } + + let hasHeader = (args["hasHeader"] as? Bool) ?? false + let channel = Int32((args["channel"] as? Int) ?? 1) + let sampleRate = Int32((args["sampleRate"] as? Int) ?? 16000) + let packetSize = (args["packetSize"] as? Int) ?? 40 + + DispatchQueue.global(qos: .userInitiated).async { + do { + let output = try OpusFileDecoder.decodeFileToPcm( + inPath: inPath, + outPath: outPath, + hasHeader: hasHeader, + channel: channel, + sampleRate: sampleRate, + packetSize: packetSize + ) + + DispatchQueue.main.async { + result(output) + } + } catch { + DispatchQueue.main.async { + result(FlutterError(code: "DECODE_ERROR", message: "解码失败: \(error)", details: nil)) + } + } + } + default: result(FlutterMethodNotImplemented) } @@ -187,6 +222,134 @@ public class SwiftBleServicePlugin: NSObject, FlutterPlugin { } } +@available(iOS 13.0, *) +private enum OpusFileDecoder { + /// 将 Opus 文件(按固定 packetSize 切分的裸包流)解码为 PCM,并写入 outPath + /// - Parameters: + /// - inPath: 输入 Opus 文件路径 + /// - outPath: 输出 PCM 文件路径 + /// - hasHeader: 是否包含协议头(当前仅支持 false) + /// - channel: 通道数(1 或 2) + /// - sampleRate: 采样率 + /// - packetSize: 固定包长(若不确定,会与 40/80 一起自动尝试) + /// - Returns: 输出文件路径(等于 outPath) + /// - Throws: 参数不合法、文件读写失败、或解码输出为空时抛出 + static func decodeFileToPcm( + inPath: String, + outPath: String, + hasHeader: Bool, + channel: Int32, + sampleRate: Int32, + packetSize: Int + ) throws -> String { + if hasHeader { + throw NSError(domain: "ble_service", code: -10, userInfo: [NSLocalizedDescriptionKey: "暂不支持带协议头的 Opus 文件解码"]) + } + if channel != 1 && channel != 2 { + throw NSError(domain: "ble_service", code: -11, userInfo: [NSLocalizedDescriptionKey: "channel 仅支持 1 或 2"]) + } + if sampleRate <= 0 { + throw NSError(domain: "ble_service", code: -12, userInfo: [NSLocalizedDescriptionKey: "sampleRate 必须大于 0"]) + } + + let inputData = try Data(contentsOf: URL(fileURLWithPath: inPath)) + if inputData.isEmpty { + throw NSError(domain: "ble_service", code: -13, userInfo: [NSLocalizedDescriptionKey: "输入文件为空"]) + } + + let candidates = Array(Set([packetSize, 40, 80].filter { $0 > 0 })) + var best = Data() + var lastError: Error? + + for size in candidates { + do { + let pcm = try decodeFixedPacketSize( + inputData: inputData, + packetSize: size, + sampleRate: sampleRate, + channels: channel + ) + if pcm.count > best.count { + best = pcm + } + } catch { + lastError = error + } + } + + if best.isEmpty { + throw lastError ?? NSError(domain: "ble_service", code: -14, userInfo: [NSLocalizedDescriptionKey: "解码输出为空"]) + } + + try best.write(to: URL(fileURLWithPath: outPath), options: .atomic) + return outPath + } + + /// 按固定 packetSize 切分输入数据并逐包解码为 PCM + /// - Parameters: + /// - inputData: Opus 裸包流(无容器) + /// - packetSize: 固定包长 + /// - sampleRate: 采样率 + /// - channels: 通道数 + /// - Returns: PCM 数据(16-bit little-endian) + /// - Throws: 解码器创建失败或输出为空时抛出 + private static func decodeFixedPacketSize( + inputData: Data, + packetSize: Int, + sampleRate: Int32, + channels: Int32 + ) throws -> Data { + if packetSize <= 0 { + throw NSError(domain: "ble_service", code: -20, userInfo: [NSLocalizedDescriptionKey: "packetSize 必须大于 0"]) + } + + var errorCode: Int32 = 0 + guard let decoder = opus_decoder_create(sampleRate, channels, &errorCode), errorCode == OPUS_OK else { + throw NSError(domain: "ble_service", code: Int(errorCode), userInfo: [NSLocalizedDescriptionKey: "Opus 解码器创建失败: \(errorCode)"]) + } + defer { opus_decoder_destroy(decoder) } + + let maxFrameSize: Int32 = 5760 + var pcmBuffer = [opus_int16](repeating: 0, count: Int(maxFrameSize) * Int(channels)) + var output = Data() + var offset = 0 + + while offset + packetSize <= inputData.count { + let packet = inputData.subdata(in: offset..<(offset + packetSize)) + offset += packetSize + + let decodedSamples: opus_int32 = packet.withUnsafeBytes { packetPtr in + guard let packetBase = packetPtr.bindMemory(to: UInt8.self).baseAddress else { + return opus_int32(-1) + } + return opus_decode( + decoder, + packetBase, + opus_int32(packet.count), + &pcmBuffer, + maxFrameSize, + 0 + ) + } + + if decodedSamples <= 0 { + continue + } + + let bytesCount = Int(decodedSamples) * Int(channels) * MemoryLayout.size + if bytesCount > 0 { + output.append(pcmBuffer.withUnsafeBytes { Data($0.prefix(bytesCount)) }) + } + } + + if output.isEmpty { + throw NSError(domain: "ble_service", code: -21, userInfo: [NSLocalizedDescriptionKey: "解码输出为空(packetSize=\(packetSize))"]) + } + + return output + } +} + // MARK: - BleService.Callback @available(iOS 13.0, *) extension SwiftBleServicePlugin: BleService.Callback { diff --git a/local_plugins/jl_opus/ios/jl_opus/Package.swift b/local_plugins/jl_opus/ios/jl_opus/Package.swift new file mode 100644 index 000000000..ab0c6c8e8 --- /dev/null +++ b/local_plugins/jl_opus/ios/jl_opus/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "jl_opus", + platforms: [.iOS("16.0")], + products: [ + .library(name: "jl-opus", targets: ["jl_opus"]) + ], + dependencies: [ + .package(path: "../../../ble_service/ios/ble_service") + ], + targets: [ + .target( + name: "jl_opus", + dependencies: [ + .product(name: "ble-service", package: "ble_service") + ], + path: "Sources/jl_opus" + ) + ] +) diff --git a/local_plugins/jl_opus/ios/jl_opus/Sources/jl_opus/JlOpusPlugin.swift b/local_plugins/jl_opus/ios/jl_opus/Sources/jl_opus/JlOpusPlugin.swift new file mode 100644 index 000000000..09734dab1 --- /dev/null +++ b/local_plugins/jl_opus/ios/jl_opus/Sources/jl_opus/JlOpusPlugin.swift @@ -0,0 +1,450 @@ +import Flutter +import Foundation +import opus + +@objc(JlOpusPlugin) +public class JlOpusPlugin: NSObject, FlutterPlugin, FlutterStreamHandler { + private var eventSink: FlutterEventSink? + private var isInitialized: Bool = false + + private var isDecodingStream: Bool = false + private var streamHasHeader: Bool = false + private var streamChannels: Int32 = 1 + private var streamSampleRate: Int32 = 16000 + private var streamDecoder: OpaquePointer? + + private let decodeQueue = DispatchQueue(label: "com.yunqiinnovation.jl_opus.decode", qos: .userInitiated) + + /// Flutter 插件注册入口 + /// - Parameter registrar: FlutterPluginRegistrar + /// - Returns: 无 + /// - Throws: 不抛出 + public static func register(with registrar: FlutterPluginRegistrar) { + let methodChannel = FlutterMethodChannel( + name: "com.yunqiinnovation.jl_opus", + binaryMessenger: registrar.messenger() + ) + + let eventChannel = FlutterEventChannel( + name: "com.yunqiinnovation.jl_opus/events", + binaryMessenger: registrar.messenger() + ) + + let instance = JlOpusPlugin() + registrar.addMethodCallDelegate(instance, channel: methodChannel) + eventChannel.setStreamHandler(instance) + } + + /// 处理 Flutter 侧方法调用 + /// - Parameters: + /// - call: FlutterMethodCall + /// - result: FlutterResult + /// - Returns: 无 + /// - Throws: 不抛出(错误通过 FlutterError 返回) + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "initOpusDecoder": + isInitialized = true + result(true) + + case "decodeOpusFile": + decodeOpusFile(arguments: call.arguments, result: result) + + case "startDecodeStream": + startDecodeStream(arguments: call.arguments, result: result) + + case "stopDecodeStream": + stopDecodeStream(result: result) + + case "writeAudioStream": + writeAudioStream(arguments: call.arguments, result: result) + + case "isDecoding": + result(isDecodingStream) + + case "dispose": + dispose(result: result) + + default: + result(FlutterMethodNotImplemented) + } + } + + /// 事件通道开始监听 + /// - Parameters: + /// - arguments: Flutter 传入参数 + /// - eventSink: 事件回调 + /// - Returns: FlutterError?(通常为 nil) + /// - Throws: 不抛出 + public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + eventSink = events + return nil + } + + /// 事件通道取消监听 + /// - Parameter arguments: Flutter 传入参数 + /// - Returns: FlutterError?(通常为 nil) + /// - Throws: 不抛出 + public func onCancel(withArguments arguments: Any?) -> FlutterError? { + eventSink = nil + return nil + } + + /// 解码 Opus 文件为 PCM 文件(16-bit little-endian 原始 PCM) + /// - Parameters: + /// - arguments: Flutter 传入参数(需包含 inPath/outPath/hasHeader/channel/sampleRate/packetSize) + /// - result: FlutterResult,成功返回 outPath,失败返回 FlutterError + /// - Returns: 无 + /// - Throws: 不直接抛出;内部错误会通过 FlutterError 与事件通道 onError 返回 + private func decodeOpusFile(arguments: Any?, result: @escaping FlutterResult) { + guard isInitialized else { + result(FlutterError(code: "NOT_INITIALIZED", message: "Opus解码器未初始化", details: nil)) + return + } + + guard let args = arguments as? [String: Any], + let inPath = args["inPath"] as? String, + let outPath = args["outPath"] as? String else { + result(FlutterError(code: "INVALID_ARGS", message: "输入或输出路径不能为空", details: nil)) + return + } + + let hasHeader = (args["hasHeader"] as? Bool) ?? false + let channel = Int32((args["channel"] as? Int) ?? 1) + let sampleRate = Int32((args["sampleRate"] as? Int) ?? 16000) + let packetSize = (args["packetSize"] as? Int) ?? 40 + + sendEvent([ + "event": "onStart", + "type": "file" + ]) + + decodeQueue.async { [weak self] in + guard let self = self else { return } + + do { + let inputUrl = URL(fileURLWithPath: inPath) + let outputUrl = URL(fileURLWithPath: outPath) + + let inputData = try Data(contentsOf: inputUrl) + if inputData.isEmpty { + throw NSError(domain: "jl_opus", code: -2, userInfo: [NSLocalizedDescriptionKey: "输入文件为空"]) + } + + let packets = try self.splitPackets(inputData: inputData, hasHeader: hasHeader, packetSize: packetSize) + let pcmData = try self.decodePacketsToPcm(packets: packets, sampleRate: sampleRate, channels: channel) + + try self.ensureParentDirectoryExists(for: outputUrl) + try pcmData.write(to: outputUrl, options: .atomic) + + self.sendEvent([ + "event": "onComplete", + "type": "file", + "filePath": outPath + ]) + + DispatchQueue.main.async { + result(outPath) + } + } catch { + let message = error.localizedDescription + self.sendEvent([ + "event": "onError", + "type": "file", + "code": -1, + "message": message + ]) + + DispatchQueue.main.async { + result(FlutterError(code: "DECODE_ERROR", message: "解码出错: \(message)", details: nil)) + } + } + } + } + + /// 开始 Opus 数据流解码 + /// - Parameters: + /// - arguments: Flutter 传入参数(需包含 hasHeader/channel/sampleRate) + /// - result: FlutterResult,成功返回 true,失败返回 FlutterError + /// - Returns: 无 + /// - Throws: 不抛出;失败通过 FlutterError/事件 onError 返回 + private func startDecodeStream(arguments: Any?, result: @escaping FlutterResult) { + guard isInitialized else { + result(FlutterError(code: "NOT_INITIALIZED", message: "Opus解码器未初始化", details: nil)) + return + } + + guard let args = arguments as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGS", message: "缺少解码参数", details: nil)) + return + } + + let hasHeader = (args["hasHeader"] as? Bool) ?? false + let channel = Int32((args["channel"] as? Int) ?? 1) + let sampleRate = Int32((args["sampleRate"] as? Int) ?? 16000) + + stopDecodeStreamInternal() + + var error: Int32 = 0 + guard let decoder = opus_decoder_create(sampleRate, channel, &error), error == OPUS_OK else { + sendEvent([ + "event": "onError", + "type": "stream", + "code": Int(error), + "message": "Opus解码器创建失败: \(error)" + ]) + result(FlutterError(code: "DECODE_STREAM_ERROR", message: "数据流解码器创建失败", details: nil)) + return + } + + streamHasHeader = hasHeader + streamChannels = channel + streamSampleRate = sampleRate + streamDecoder = decoder + isDecodingStream = true + + sendEvent([ + "event": "onStart", + "type": "stream" + ]) + result(true) + } + + /// 停止 Opus 数据流解码 + /// - Parameter result: FlutterResult,已停止返回 true,未处于解码返回 false + /// - Returns: 无 + /// - Throws: 不抛出 + private func stopDecodeStream(result: @escaping FlutterResult) { + if isDecodingStream { + stopDecodeStreamInternal() + result(true) + } else { + result(false) + } + } + + /// 停止并释放当前数据流解码器(内部方法) + /// - Returns: 无 + /// - Throws: 不抛出 + private func stopDecodeStreamInternal() { + if let decoder = streamDecoder { + opus_decoder_destroy(decoder) + } + streamDecoder = nil + isDecodingStream = false + } + + /// 写入 Opus 数据到解码流,并通过事件通道返回 PCM 数据 + /// - Parameters: + /// - arguments: Flutter 传入参数(需包含 data) + /// - result: FlutterResult,成功返回 true;不在解码状态返回 FlutterError + /// - Returns: 无 + /// - Throws: 不直接抛出;解码错误通过事件 onError 返回 + private func writeAudioStream(arguments: Any?, result: @escaping FlutterResult) { + guard isDecodingStream, let decoder = streamDecoder else { + result(FlutterError(code: "NOT_DECODING", message: "当前没有处于解码状态", details: nil)) + return + } + + guard let args = arguments as? [String: Any], + let data = args["data"] as? FlutterStandardTypedData else { + result(FlutterError(code: "INVALID_ARGS", message: "音频数据不能为空", details: nil)) + return + } + + let opusData = data.data + if opusData.isEmpty { + result(true) + return + } + + let hasHeader = streamHasHeader + let channels = streamChannels + let sampleRate = streamSampleRate + + decodeQueue.async { [weak self] in + guard let self = self else { return } + + do { + let packets: [Data] + if hasHeader { + packets = try self.splitPacketsByLengthPrefix(inputData: opusData) + } else { + packets = [opusData] + } + + for packet in packets { + let pcm = try self.decodeSinglePacketToPcm(decoder: decoder, packet: packet, sampleRate: sampleRate, channels: channels) + if !pcm.isEmpty { + self.sendEvent([ + "event": "onDecodeStream", + "data": FlutterStandardTypedData(bytes: pcm) + ]) + } + } + } catch { + let message = error.localizedDescription + self.sendEvent([ + "event": "onError", + "type": "stream", + "code": -1, + "message": message + ]) + } + } + + result(true) + } + + /// 释放插件资源 + /// - Parameter result: FlutterResult + /// - Returns: 无 + /// - Throws: 不抛出 + private func dispose(result: @escaping FlutterResult) { + stopDecodeStreamInternal() + isInitialized = false + result(true) + } + + /// 拆分输入文件数据为 Opus 包列表 + /// - Parameters: + /// - inputData: 输入文件二进制数据 + /// - hasHeader: 是否包含 2 字节小端长度前缀 + /// - packetSize: 无协议头时按固定长度切包 + /// - Returns: Opus 包数组 + /// - Throws: 参数错误或解析失败时抛出 NSError + private func splitPackets(inputData: Data, hasHeader: Bool, packetSize: Int) throws -> [Data] { + if hasHeader { + return try splitPacketsByLengthPrefix(inputData: inputData) + } + + guard packetSize > 0 else { + throw NSError(domain: "jl_opus", code: -5, userInfo: [NSLocalizedDescriptionKey: "packetSize 必须大于 0"]) + } + + var packets: [Data] = [] + var offset = 0 + while offset < inputData.count { + let end = min(offset + packetSize, inputData.count) + packets.append(inputData.subdata(in: offset.. [Data] { + var packets: [Data] = [] + var cursor = 0 + while cursor + 2 <= inputData.count { + let lo = Int(inputData[cursor]) + let hi = Int(inputData[cursor + 1]) << 8 + let length = lo | hi + cursor += 2 + + if length <= 0 || cursor + length > inputData.count { + throw NSError(domain: "jl_opus", code: -3, userInfo: [NSLocalizedDescriptionKey: "协议头解析失败"]) + } + + packets.append(inputData.subdata(in: cursor..<(cursor + length))) + cursor += length + } + + if packets.isEmpty { + throw NSError(domain: "jl_opus", code: -4, userInfo: [NSLocalizedDescriptionKey: "未解析到任何Opus数据包"]) + } + + return packets + } + + /// 将 Opus 包列表解码为 PCM 数据 + /// - Parameters: + /// - packets: Opus 包数组 + /// - sampleRate: 采样率 + /// - channels: 通道数 + /// - Returns: PCM 数据(16-bit little-endian) + /// - Throws: 解码器创建失败或任意包解码失败时抛出 NSError + private func decodePacketsToPcm(packets: [Data], sampleRate: Int32, channels: Int32) throws -> Data { + var error: Int32 = 0 + guard let decoder = opus_decoder_create(sampleRate, channels, &error), error == OPUS_OK else { + throw NSError(domain: "jl_opus", code: Int(error), userInfo: [NSLocalizedDescriptionKey: "Opus解码器创建失败: \(error)"]) + } + defer { opus_decoder_destroy(decoder) } + + var pcmOutput = Data() + for packet in packets { + let pcmChunk = try decodeSinglePacketToPcm(decoder: decoder, packet: packet, sampleRate: sampleRate, channels: channels) + pcmOutput.append(pcmChunk) + } + return pcmOutput + } + + /// 解码单个 Opus 包为 PCM + /// - Parameters: + /// - decoder: Opus 解码器指针 + /// - packet: 单个 Opus 包 + /// - sampleRate: 采样率 + /// - channels: 通道数 + /// - Returns: PCM 数据(16-bit little-endian) + /// - Throws: 解码失败时抛出 NSError + private func decodeSinglePacketToPcm(decoder: OpaquePointer, packet: Data, sampleRate: Int32, channels: Int32) throws -> Data { + let maxSamplesPerChannel = Int(sampleRate * 120 / 1000) + var pcmBuffer = [opus_int16](repeating: 0, count: maxSamplesPerChannel * Int(channels)) + + let decodedSamples: Int32 = packet.withUnsafeBytes { packetPtr in + guard let inPtr = packetPtr.bindMemory(to: UInt8.self).baseAddress else { + return OPUS_BAD_ARG + } + + return opus_decode( + decoder, + inPtr, + Int32(packet.count), + &pcmBuffer, + Int32(maxSamplesPerChannel), + 0 + ) + } + + if decodedSamples < 0 { + throw NSError(domain: "jl_opus", code: Int(decodedSamples), userInfo: [NSLocalizedDescriptionKey: "Opus解码失败: \(decodedSamples)"]) + } + + let sampleCount = Int(decodedSamples) * Int(channels) + return pcmBuffer.withUnsafeBytes { rawPtr in + Data(rawPtr.bindMemory(to: UInt8.self).prefix(sampleCount * MemoryLayout.size)) + } + } + + /// 确保输出路径的父目录存在 + /// - Parameter url: 输出文件 URL + /// - Returns: 无 + /// - Throws: 创建目录失败时抛出错误 + private func ensureParentDirectoryExists(for url: URL) throws { + let dir = url.deletingLastPathComponent() + var isDirectory: ObjCBool = false + if FileManager.default.fileExists(atPath: dir.path, isDirectory: &isDirectory), isDirectory.boolValue { + return + } + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil) + } + + /// 通过事件通道向 Flutter 发送事件 + /// - Parameter event: 事件字典 + /// - Returns: 无 + /// - Throws: 不抛出 + private func sendEvent(_ event: [String: Any]) { + guard let sink = eventSink else { return } + DispatchQueue.main.async { + sink(event) + } + } +}