30 changed files with 2626 additions and 709 deletions
File diff suppressed because it is too large
@ -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