Browse Source
- 新增 jl_opus iOS 插件,提供 Opus 文件解码功能 - 在 ble_service 中添加 Opus 文件解码方法,支持通过 MethodChannel 调用 - 优化音频录制文件管理,添加延迟停止机制避免数据截断 - 修复 agent_service 中音乐播放状态恢复逻辑 - 调整 opus_test 控制器以支持跨平台解码调用newdev_shunjiawei
7 changed files with 757 additions and 19 deletions
@ -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" |
||||
|
) |
||||
|
] |
||||
|
) |
||||
@ -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…
Reference in new issue