Browse Source

feat(opus): 添加 iOS Opus 解码支持并优化音频录制管理

- 新增 jl_opus iOS 插件,提供 Opus 文件解码功能
- 在 ble_service 中添加 Opus 文件解码方法,支持通过 MethodChannel 调用
- 优化音频录制文件管理,添加延迟停止机制避免数据截断
- 修复 agent_service 中音乐播放状态恢复逻辑
- 调整 opus_test 控制器以支持跨平台解码调用
newdev_shunjiawei
liwei1dao 9 months ago
parent
commit
9ee0b4fd70
  1. 34
      lib/modules/opus_test/controllers/opus_test_controller.dart
  2. 31
      local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift
  3. 74
      local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift
  4. 2
      local_plugins/ble_service/ios/ble_service/Sources/ble_service/RecordingFile.swift
  5. 163
      local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift
  6. 22
      local_plugins/jl_opus/ios/jl_opus/Package.swift
  7. 450
      local_plugins/jl_opus/ios/jl_opus/Sources/jl_opus/JlOpusPlugin.swift

34
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:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:just_audio/just_audio.dart'; import 'package:just_audio/just_audio.dart';
@ -42,8 +43,10 @@ class OpusTestController extends GetxController {
String? tempPcmPath; String? tempPcmPath;
// 杰理OPUS解码器 // 杰理OPUS解码器
late JlOpus jlOpus; JlOpus? jlOpus;
StreamSubscription? _eventSubscription; StreamSubscription? _eventSubscription;
static const MethodChannel _bleServiceChannel =
MethodChannel('com.yunqiinnovation.ble_service');
@override @override
void onInit() { void onInit() {
@ -56,16 +59,20 @@ class OpusTestController extends GetxController {
void onClose() { void onClose() {
player.dispose(); player.dispose();
_eventSubscription?.cancel(); _eventSubscription?.cancel();
jlOpus.dispose(); jlOpus?.dispose();
super.onClose(); super.onClose();
} }
// 初始化OPUS解码器 // 初始化OPUS解码器
Future<void> _initOpusDecoder() async { Future<void> _initOpusDecoder() async {
if (!Platform.isAndroid) {
return;
}
jlOpus = JlOpus(); jlOpus = JlOpus();
// 监听解码器事件 // 监听解码器事件
_eventSubscription = jlOpus.eventStream.listen((event) { _eventSubscription = jlOpus!.eventStream.listen((event) {
switch (event.event) { switch (event.event) {
case 'onStart': case 'onStart':
statusMessage.value = '开始${event.type == "file" ? "文件" : "流"}解码...'; statusMessage.value = '开始${event.type == "file" ? "文件" : "流"}解码...';
@ -86,12 +93,29 @@ class OpusTestController extends GetxController {
}); });
// 初始化OPUS解码器 // 初始化OPUS解码器
final initialized = await jlOpus.initOpusDecoder(); final initialized = await jlOpus!.initOpusDecoder();
if (!initialized) { if (!initialized) {
statusMessage.value = 'OPUS解码器初始化失败'; statusMessage.value = 'OPUS解码器初始化失败';
} }
} }
Future<String?> _decodeOpusFile(
String inPath, String outPath, OpusOption option) async {
if (Platform.isIOS) {
final result = await _bleServiceChannel.invokeMethod<String>(
'decodeOpusFile',
{
'inPath': inPath,
'outPath': outPath,
...option.toMap(),
},
);
return result;
}
return jlOpus?.decodeOpusFile(inPath, outPath, option);
}
// 请求必要权限 // 请求必要权限
Future<void> requestPermissions() async { Future<void> requestPermissions() async {
if (Platform.isAndroid) { 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) { if (pcmPath == null) {
statusMessage.value = '解码失败'; statusMessage.value = '解码失败';
isDecoding.value = false; isDecoding.value = false;

31
local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift

@ -510,10 +510,6 @@ class AgentServiceImpl: NSObject {
} }
} }
lastRecognitionMode = currentRecognitionMode lastRecognitionMode = currentRecognitionMode
if (lastRecognitionMode == "ble_wakeup" && mode != "ble_wakeup"){//之前是唤醒模式,现在不是唤醒模式
os_log("liwei------- 关闭唤醒通道", log: logger, type: .info)
BleService.shared.closeCodec()
}
stopRecognition() stopRecognition()
if isAiStreaming { if isAiStreaming {
isAiStreaming = false isAiStreaming = false
@ -527,7 +523,7 @@ class AgentServiceImpl: NSObject {
// 如果音乐正在播放,则暂停 // 如果音乐正在播放,则暂停
if MusicService.shared.isPlaying { if MusicService.shared.isPlaying {
MusicService.shared.pause() MusicService.shared.pause()
} }
// 设置当前识别模式 // 设置当前识别模式
@ -591,13 +587,13 @@ class AgentServiceImpl: NSObject {
let success = azureAsrHelper?.stopContinuousRecognition() ?? false let success = azureAsrHelper?.stopContinuousRecognition() ?? false
if success { if success {
// 识别结束后,立即停用 AudioSession,以便其他模块(如TTS)可以接管
// 这是解决“按住说话”后无法播报问题的关键
do { do {
try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) // let audioSession = AVAudioSession.sharedInstance()
os_log("Audio session deactivated successfully after recognition.", log: logger, type: .info) // 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 { } 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 { } else if isStartingRecognition {
// 启动尚未完成,先记录一次待停止请求,onSessionStarted 到来后立即 stop // 启动尚未完成,先记录一次待停止请求,onSessionStarted 到来后立即 stop
@ -2438,6 +2434,11 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback {
if (currentRecognitionMode == "ble_wakeup") { if (currentRecognitionMode == "ble_wakeup") {
audioPlayer?.playStopSound() audioPlayer?.playStopSound()
} }
if wasMusicPlayingBeforeRecognition {
wasMusicPlayingBeforeRecognition = false
MusicService.shared.resume()
}
} }
@ -2459,6 +2460,11 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback {
isStartingRecognition = false isStartingRecognition = false
stopRequestedDuringStart = false stopRequestedDuringStart = false
stopIdleCheck() stopIdleCheck()
if wasMusicPlayingBeforeRecognition {
wasMusicPlayingBeforeRecognition = false
MusicService.shared.resume()
}
} }
func onError(sessionid:String ,_ errorCode: Int, _ error: String) { func onError(sessionid:String ,_ errorCode: Int, _ error: String) {
@ -2469,6 +2475,11 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback {
isStartingRecognition = false isStartingRecognition = false
stopRequestedDuringStart = false stopRequestedDuringStart = false
stopIdleCheck() stopIdleCheck()
if wasMusicPlayingBeforeRecognition {
wasMusicPlayingBeforeRecognition = false
MusicService.shared.resume()
}
} }
// MARK: - isInterrupt 缓存方法 // MARK: - isInterrupt 缓存方法

74
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 recordingFile: RecordingFile?
private var pendingStopOpusRecording = false
private var stopOpusRecordingWorkItem: DispatchWorkItem?
private var lastOpusPacketReceivedAt: TimeInterval = 0
private var isScanning = false private var isScanning = false
private var scanTimer: Timer? private var scanTimer: Timer?
@ -471,6 +475,7 @@ private var cmdReplyType: UInt8 = 0
// 停止音频发送定时器 // 停止音频发送定时器
stopAudioSendTimer() stopAudioSendTimer()
stopOpusRecording()
// 清空音频缓冲区和队列 // 清空音频缓冲区和队列
audioQueueLock.lock() audioQueueLock.lock()
@ -743,9 +748,7 @@ private var cmdReplyType: UInt8 = 0
public func closeCodec() -> Bool { public func closeCodec() -> Bool {
os_log("关闭编解码并停止录制...", log: logger, type: .info) os_log("关闭编解码并停止录制...", log: logger, type: .info)
// 停止录制
recordingFile?.closeFile()
recordingFile = nil
// 停止音频发送定时器 // 停止音频发送定时器
stopAudioSendTimer() stopAudioSendTimer()
@ -756,6 +759,9 @@ private var cmdReplyType: UInt8 = 0
audioDataQueue.removeAll() audioDataQueue.removeAll()
audioQueueLock.unlock() audioQueueLock.unlock()
requestStopOpusRecording()
// 不停止Opus解码流,只发送命令通知设备关闭编解码 // 不停止Opus解码流,只发送命令通知设备关闭编解码
let paramData = Data([BleConst.CODEC_CONTROL_CLOSE, BleConst.AUDIO_CHANNEL_RIGHT]) let paramData = Data([BleConst.CODEC_CONTROL_CLOSE, BleConst.AUDIO_CHANNEL_RIGHT])
return sendCommand(BleConst.CMD_CONTROL_CODEC, data: paramData) return sendCommand(BleConst.CMD_CONTROL_CODEC, data: paramData)
@ -906,8 +912,69 @@ private var cmdReplyType: UInt8 = 0
return return
} }
lastOpusPacketReceivedAt = Date().timeIntervalSince1970
ensureOpusRecordingStarted(fileName: "耳机端").saveAudioData(data)
// 使用Opus处理器处理音频数据 // 使用Opus处理器处理音频数据
opusProcessor?.processAudioData(data) 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) updateConnectionState(BleConst.STATE_DISCONNECTED)
stopOpusRecording()
// 只有在非主动断开的情况下才重新连接 // 只有在非主动断开的情况下才重新连接
if !isManualDisconnect { if !isManualDisconnect {

2
local_plugins/ble_service/ios/ble_service/Sources/ble_service/RecordingFile.swift

@ -78,7 +78,7 @@ class RecordingFile {
do { do {
try self.fileHandle?.write(contentsOf: buffer) try self.fileHandle?.write(contentsOf: buffer)
self.totalBytesWritten += buffer.count 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 { } catch {
os_log("写入音频数据失败: %@", log: self.logger, type: .error, error.localizedDescription) os_log("写入音频数据失败: %@", log: self.logger, type: .error, error.localizedDescription)
} }

163
local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift

@ -2,6 +2,7 @@ import Flutter
import UIKit import UIKit
import CoreBluetooth import CoreBluetooth
import os import os
import opus
@available(iOS 13.0, *) @available(iOS 13.0, *)
@objc(BleServicePlugin) @objc(BleServicePlugin)
@ -172,6 +173,40 @@ public class SwiftBleServicePlugin: NSObject, FlutterPlugin {
// 移除这个方法调用,让iOS系统自然处理权限 // 移除这个方法调用,让iOS系统自然处理权限
result(FlutterMethodNotImplemented) 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: default:
result(FlutterMethodNotImplemented) 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<opus_int16>.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 // MARK: - BleService.Callback
@available(iOS 13.0, *) @available(iOS 13.0, *)
extension SwiftBleServicePlugin: BleService.Callback { extension SwiftBleServicePlugin: BleService.Callback {

22
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"
)
]
)

450
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..<end))
offset = end
}
if packets.isEmpty {
throw NSError(domain: "jl_opus", code: -6, userInfo: [NSLocalizedDescriptionKey: "未解析到任何Opus数据包"])
}
return packets
}
/// 按 2 字节小端长度前缀拆分 Opus 包
/// - Parameter inputData: 输入数据
/// - Returns: Opus 包数组
/// - Throws: 协议头解析失败时抛出 NSError
private func splitPacketsByLengthPrefix(inputData: Data) throws -> [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<opus_int16>.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)
}
}
}
Loading…
Cancel
Save