41 changed files with 3060 additions and 487 deletions
@ -1,94 +0,0 @@ |
|||||
# 代码简明规范 |
|
||||
## Dart / Flutter |
|
||||
1. 风格统一 |
|
||||
• 严格遵循 Effective Dart 格式与命名惯例。 |
|
||||
2. 日志与调试 |
|
||||
• 禁止 print;仅在异常场景调用 logger.e(...)。 |
|
||||
3. 业务逻辑 |
|
||||
• 默认使用 GetX(状态、路由、依赖注入等),避免手写 StatefulWidget。 |
|
||||
4. 异常处理 |
|
||||
• 不写重试逻辑,失败即返回错误。 |
|
||||
• 谨慎使用 try-catch;优先通过类型系统与空安全防御。 |
|
||||
5. 代码演进 |
|
||||
• 任何改动都应 “小步、安全”,杜绝一次性大改或过度设计。 |
|
||||
|
|
||||
## 原生层(Android / iOS) |
|
||||
1. 配置不可改 |
|
||||
• 禁止修改 SDK、NDK、JDK 与 iOS 构建配置。 |
|
||||
2. Kotlin DSL |
|
||||
• 原生实现一律采用 Kotlin + 协程,遵守 Kotlin 最佳实践。 |
|
||||
• 不写 Java 代码。 |
|
||||
|
|
||||
## 通用要求 |
|
||||
1. 不自动执行 |
|
||||
• 生成脚本/代码不得包含 flutter run 等执行指令。 |
|
||||
2. 并发优先级 |
|
||||
• 并发场景首选 Kotlin 协程;仅在 Flutter 端按需使用 Dart Future / Stream。 |
|
||||
3. 语言 |
|
||||
• 与用户交流、注释及文档一律使用中文。 |
|
||||
• 不要自己创建总结文档。 |
|
||||
|
|
||||
宗旨:保持极简,先跑通业务,再考虑优化。 |
|
||||
|
|
||||
|
|
||||
# 项目架构概述 |
|
||||
DeepSound是一个基于Flutter和GetX构建的多平台语音应用,具有语音合成、语音识别、语音聊天等功能。 |
|
||||
|
|
||||
## 总体架构 |
|
||||
- 采用GetX状态管理框架 |
|
||||
- 遵循模块化设计,各功能模块相对独立 |
|
||||
- 使用依赖注入实现组件解耦 |
|
||||
- 多语言支持,使用GetX国际化方案 |
|
||||
|
|
||||
## 主要目录结构 |
|
||||
- lib/core: 核心组件、工具和通用功能 |
|
||||
- lib/data: 数据模型、服务和提供者 |
|
||||
- lib/modules: 功能模块,按业务划分 |
|
||||
- lib/routes: 路由定义和管理 |
|
||||
- local_plugins: 本地插件,提供原生功能支持 |
|
||||
|
|
||||
# 核心模块说明 |
|
||||
## 语音处理 |
|
||||
- 火山语音服务: 提供语音识别和合成核心能力 |
|
||||
- Azure语音服务: 提供备选的语音识别和合成能力 |
|
||||
- 离线TTS支持: 在线语音合成失败时的备选方案 |
|
||||
|
|
||||
## 聊天功能 |
|
||||
- 基于OpenAI的对话功能 |
|
||||
- 支持语音输入、文字输入和语音输出 |
|
||||
- 支持多种角色和语音类型 |
|
||||
|
|
||||
## 用户系统 |
|
||||
- 支持多种登录方式:Firebase、Google、Facebook |
|
||||
- 用户资料管理和设置 |
|
||||
- 主题切换和语言设置 |
|
||||
|
|
||||
## 蓝牙配对 |
|
||||
- 支持蓝牙设备发现和连接 |
|
||||
- 提供简化的配对流程 |
|
||||
- 设备管理和状态监控 |
|
||||
|
|
||||
## 音频处理 |
|
||||
- 音频录制和播放 |
|
||||
- 支持波形显示 |
|
||||
- 音量控制和音频质量设置 |
|
||||
|
|
||||
# 技术栈 |
|
||||
- Flutter: UI框架 |
|
||||
- GetX: 状态管理和依赖注入 |
|
||||
- Firebase: 用户认证和后端服务 |
|
||||
- WebSocket: 实时通信 |
|
||||
- SQLite: 本地数据存储 |
|
||||
- 原生插件: 语音识别、合成和蓝牙功能 |
|
||||
|
|
||||
# 插件说明 |
|
||||
- jieli: 杰理蓝牙设备连接和控制插件 |
|
||||
- volcano_speech: 火山语音服务插件,提供语音识别和合成 |
|
||||
- azure_speech: 微软Azure语音服务插件 |
|
||||
- open_ai_service: OpenAI服务插件,提供AI对话能力 |
|
||||
- speech_to_text: 通用语音识别插件 |
|
||||
|
|
||||
# 环境配置 |
|
||||
- 环境变量通过.env文件管理 |
|
||||
- 支持不同环境的配置切换 |
|
||||
- 敏感信息不应直接硬编码在代码中 |
|
||||
@ -0,0 +1,15 @@ |
|||||
|
# 移除系统组件的日志调用 |
||||
|
-assumenosideeffects class android.util.Log { |
||||
|
public static int v(...); |
||||
|
public static int d(...); |
||||
|
public static int i(...); |
||||
|
} |
||||
|
|
||||
|
# 针对特定类的日志移除 |
||||
|
-assumenosideeffects class android.media.AudioTrack { |
||||
|
private static void log*(...); |
||||
|
} |
||||
|
|
||||
|
-assumenosideeffects class android.app.AppOpsManager { |
||||
|
private static void log*(...); |
||||
|
} |
||||
@ -1,5 +0,0 @@ |
|||||
package com.yunqiinnovation.deep_voice |
|
||||
|
|
||||
import io.flutter.embedding.android.FlutterActivity |
|
||||
|
|
||||
class MainActivity : FlutterActivity() |
|
||||
@ -0,0 +1,9 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import '../controllers/opus_test_controller.dart'; |
||||
|
|
||||
|
class OpusTestBinding extends Bindings { |
||||
|
@override |
||||
|
void dependencies() { |
||||
|
Get.lazyPut<OpusTestController>(() => OpusTestController()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,462 @@ |
|||||
|
import 'dart:async'; |
||||
|
import 'dart:io'; |
||||
|
import 'dart:typed_data'; |
||||
|
import 'package:file_picker/file_picker.dart'; |
||||
|
import 'package:flutter/foundation.dart'; |
||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:path_provider/path_provider.dart'; |
||||
|
import 'package:just_audio/just_audio.dart'; |
||||
|
import 'package:jl_opus/jl_opus.dart'; |
||||
|
import 'package:permission_handler/permission_handler.dart'; |
||||
|
|
||||
|
class OpusTestController extends GetxController { |
||||
|
// 选中的文件 |
||||
|
final selectedFiles = <PlatformFile>[].obs; |
||||
|
|
||||
|
// 扫描到的外部存储文件 |
||||
|
final externalFiles = <PlatformFile>[].obs; |
||||
|
// 是否携带协议头 |
||||
|
final hasHeader = false.obs; |
||||
|
|
||||
|
// 数据包长度 |
||||
|
final packetLength = '80'.obs; |
||||
|
|
||||
|
// 采样率选项 |
||||
|
final sampleRateOptions = [8000, 16000, 24000, 32000, 44100, 48000]; |
||||
|
final selectedSampleRate = 16000.obs; |
||||
|
|
||||
|
// 通道数量 |
||||
|
final isMono = true.obs; |
||||
|
|
||||
|
// 播放状态 |
||||
|
final isPlaying = false.obs; |
||||
|
final isDecoding = false.obs; |
||||
|
final statusMessage = ''.obs; |
||||
|
|
||||
|
// 播放器 |
||||
|
final player = AudioPlayer(); |
||||
|
|
||||
|
// 临时文件路径 |
||||
|
String? tempPcmPath; |
||||
|
|
||||
|
// 杰理OPUS解码器 |
||||
|
late JlOpus jlOpus; |
||||
|
StreamSubscription? _eventSubscription; |
||||
|
|
||||
|
@override |
||||
|
void onInit() { |
||||
|
super.onInit(); |
||||
|
requestPermissions(); |
||||
|
_initOpusDecoder(); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void onClose() { |
||||
|
player.dispose(); |
||||
|
_eventSubscription?.cancel(); |
||||
|
jlOpus.dispose(); |
||||
|
super.onClose(); |
||||
|
} |
||||
|
|
||||
|
// 初始化OPUS解码器 |
||||
|
Future<void> _initOpusDecoder() async { |
||||
|
jlOpus = JlOpus(); |
||||
|
|
||||
|
// 监听解码器事件 |
||||
|
_eventSubscription = jlOpus.eventStream.listen((event) { |
||||
|
switch (event.event) { |
||||
|
case 'onStart': |
||||
|
statusMessage.value = '开始${event.type == "file" ? "文件" : "流"}解码...'; |
||||
|
break; |
||||
|
case 'onComplete': |
||||
|
if (event.type == 'file') { |
||||
|
statusMessage.value = '解码完成: ${event.filePath}'; |
||||
|
} else { |
||||
|
statusMessage.value = '流解码完成'; |
||||
|
} |
||||
|
break; |
||||
|
case 'onError': |
||||
|
statusMessage.value = |
||||
|
'解码错误: [${event.errorCode}] ${event.errorMessage}'; |
||||
|
isDecoding.value = false; |
||||
|
break; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// 初始化OPUS解码器 |
||||
|
final initialized = await jlOpus.initOpusDecoder(); |
||||
|
if (!initialized) { |
||||
|
statusMessage.value = 'OPUS解码器初始化失败'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 请求必要权限 |
||||
|
Future<void> requestPermissions() async { |
||||
|
if (Platform.isAndroid) { |
||||
|
await Permission.storage.request(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 加载外部存储文件 |
||||
|
Future<void> loadExternalFiles() async { |
||||
|
try { |
||||
|
externalFiles.clear(); |
||||
|
statusMessage.value = '正在扫描外部存储...'; |
||||
|
|
||||
|
// 获取外部存储目录 |
||||
|
final directories = await getExternalStorageDirectories(); |
||||
|
if (directories == null || directories.isEmpty) { |
||||
|
statusMessage.value = '未找到外部存储目录'; |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 遍历所有外部存储目录 |
||||
|
for (final directory in directories) { |
||||
|
final dirPath = directory.path; |
||||
|
final dir = Directory(dirPath); |
||||
|
|
||||
|
// 检查目录是否存在 |
||||
|
if (await dir.exists()) { |
||||
|
// 递归列出所有文件 |
||||
|
final files = await dir.list(recursive: true).toList(); |
||||
|
|
||||
|
for (var entity in files) { |
||||
|
if (entity is File) { |
||||
|
final path = entity.path.toLowerCase(); |
||||
|
if (path.endsWith('.opus')) { |
||||
|
final fileSize = await entity.length(); |
||||
|
externalFiles.add(PlatformFile( |
||||
|
name: entity.path.split('/').last, |
||||
|
path: entity.path, |
||||
|
size: fileSize, |
||||
|
)); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
statusMessage.value = '扫描完成,找到 ${externalFiles.length} 个Opus文件'; |
||||
|
} catch (e) { |
||||
|
statusMessage.value = '扫描失败: $e'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 选择文件 |
||||
|
Future<void> pickFiles() async { |
||||
|
try { |
||||
|
final result = await FilePicker.platform.pickFiles( |
||||
|
type: FileType.custom, |
||||
|
allowedExtensions: ['opus'], |
||||
|
allowMultiple: false, |
||||
|
); |
||||
|
|
||||
|
if (result != null && result.files.isNotEmpty) { |
||||
|
selectedFiles.clear(); |
||||
|
selectedFiles.add(result.files.first); |
||||
|
statusMessage.value = '已选择文件:${result.files.first.name}'; |
||||
|
} |
||||
|
} catch (e) { |
||||
|
statusMessage.value = '选择文件失败: $e'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 从外部文件列表中选择文件 |
||||
|
void selectExternalFile(PlatformFile file) { |
||||
|
selectedFiles.clear(); |
||||
|
selectedFiles.add(file); |
||||
|
statusMessage.value = '已选择文件: ${file.name}'; |
||||
|
} |
||||
|
|
||||
|
// 切换通道模式 |
||||
|
void toggleChannel(bool isSingleChannel) { |
||||
|
isMono.value = isSingleChannel; |
||||
|
} |
||||
|
|
||||
|
// 设置采样率 |
||||
|
void setSampleRate(int rate) { |
||||
|
selectedSampleRate.value = rate; |
||||
|
} |
||||
|
|
||||
|
// 设置数据包长度 |
||||
|
void setPacketLength(String length) { |
||||
|
packetLength.value = length; |
||||
|
} |
||||
|
|
||||
|
// 切换是否携带协议头 |
||||
|
void toggleHeader(bool value) { |
||||
|
hasHeader.value = value; |
||||
|
} |
||||
|
|
||||
|
// 播放音频 |
||||
|
Future<void> playAudio() async { |
||||
|
if (isPlaying.value) { |
||||
|
await player.stop(); |
||||
|
isPlaying.value = false; |
||||
|
statusMessage.value = '已停止播放'; |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (selectedFiles.isEmpty) { |
||||
|
statusMessage.value = '请先选择Opus文件'; |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
isDecoding.value = true; |
||||
|
statusMessage.value = '正在解码...'; |
||||
|
|
||||
|
// 使用杰理OPUS库进行解码 |
||||
|
final inPath = selectedFiles.first.path!; |
||||
|
|
||||
|
// 创建WAV输出文件路径 |
||||
|
final tempDir = await getTemporaryDirectory(); |
||||
|
final wavFileName = |
||||
|
'decoded_opus_${DateTime.now().millisecondsSinceEpoch}.wav'; |
||||
|
final outPath = '${tempDir.path}/$wavFileName'; |
||||
|
|
||||
|
// 创建解码参数 |
||||
|
final option = OpusOption( |
||||
|
hasHeader: hasHeader.value, |
||||
|
channel: isMono.value ? 1 : 2, |
||||
|
sampleRate: selectedSampleRate.value, |
||||
|
packetSize: int.tryParse(packetLength.value) ?? 40, |
||||
|
); |
||||
|
|
||||
|
// 解码文件 |
||||
|
final pcmPath = await jlOpus.decodeOpusFile(inPath, outPath, option); |
||||
|
if (pcmPath == null) { |
||||
|
statusMessage.value = '解码失败'; |
||||
|
isDecoding.value = false; |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
tempPcmPath = pcmPath; |
||||
|
|
||||
|
// 将PCM文件转换为WAV文件 |
||||
|
final wavFile = await _convertPcmToWav( |
||||
|
pcmPath, |
||||
|
'${tempDir.path}/$wavFileName', |
||||
|
selectedSampleRate.value, |
||||
|
isMono.value ? 1 : 2); |
||||
|
|
||||
|
statusMessage.value = '解码完成,准备播放'; |
||||
|
|
||||
|
// 播放WAV文件 |
||||
|
await player.setFilePath(wavFile.path); |
||||
|
await player.play(); |
||||
|
isPlaying.value = true; |
||||
|
statusMessage.value = '正在播放解码后的音频'; |
||||
|
|
||||
|
// 监听播放完成 |
||||
|
player.playerStateStream.listen((state) { |
||||
|
if (state.processingState == ProcessingState.completed) { |
||||
|
isPlaying.value = false; |
||||
|
statusMessage.value = '播放完成'; |
||||
|
} |
||||
|
}); |
||||
|
} catch (e) { |
||||
|
statusMessage.value = '解码或播放失败: $e'; |
||||
|
} finally { |
||||
|
isDecoding.value = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 将PCM转换为WAV格式 |
||||
|
Future<File> _convertPcmToWav( |
||||
|
String pcmPath, String wavPath, int sampleRate, int channels) async { |
||||
|
final pcmFile = File(pcmPath); |
||||
|
final pcmData = await pcmFile.readAsBytes(); |
||||
|
|
||||
|
// 创建WAV文件 (44字节头部 + PCM数据) |
||||
|
final wavSize = 44 + pcmData.length; |
||||
|
final wavData = ByteData(wavSize); |
||||
|
|
||||
|
// 写入WAV头部 |
||||
|
_writeWavHeader(wavData, pcmData.length, sampleRate, channels); |
||||
|
|
||||
|
// 写入PCM数据 |
||||
|
for (int i = 0; i < pcmData.length; i++) { |
||||
|
wavData.setUint8(44 + i, pcmData[i]); |
||||
|
} |
||||
|
|
||||
|
// 保存为WAV文件 |
||||
|
final wavFile = File(wavPath); |
||||
|
await wavFile.writeAsBytes(wavData.buffer.asUint8List()); |
||||
|
|
||||
|
return wavFile; |
||||
|
} |
||||
|
|
||||
|
// 写入WAV头部到ByteData |
||||
|
void _writeWavHeader( |
||||
|
ByteData data, int pcmLength, int sampleRate, int channels) { |
||||
|
final bytesPerSample = 2; // 16位PCM |
||||
|
final byteRate = sampleRate * channels * bytesPerSample; |
||||
|
final blockAlign = channels * bytesPerSample; |
||||
|
|
||||
|
// "RIFF"标识 |
||||
|
data.setUint8(0, 0x52); // 'R' |
||||
|
data.setUint8(1, 0x49); // 'I' |
||||
|
data.setUint8(2, 0x46); // 'F' |
||||
|
data.setUint8(3, 0x46); // 'F' |
||||
|
|
||||
|
// 文件大小 (减去8个字节的RIFF头部和大小字段) |
||||
|
data.setUint32(4, 36 + pcmLength, Endian.little); |
||||
|
|
||||
|
// "WAVE"标识 |
||||
|
data.setUint8(8, 0x57); // 'W' |
||||
|
data.setUint8(9, 0x41); // 'A' |
||||
|
data.setUint8(10, 0x56); // 'V' |
||||
|
data.setUint8(11, 0x45); // 'E' |
||||
|
|
||||
|
// "fmt "子块 |
||||
|
data.setUint8(12, 0x66); // 'f' |
||||
|
data.setUint8(13, 0x6D); // 'm' |
||||
|
data.setUint8(14, 0x74); // 't' |
||||
|
data.setUint8(15, 0x20); // ' ' |
||||
|
|
||||
|
// 子块大小 (16 for PCM) |
||||
|
data.setUint32(16, 16, Endian.little); |
||||
|
|
||||
|
// 音频格式 (1 表示PCM) |
||||
|
data.setUint16(20, 1, Endian.little); |
||||
|
|
||||
|
// 通道数量 |
||||
|
data.setUint16(22, channels, Endian.little); |
||||
|
|
||||
|
// 采样率 |
||||
|
data.setUint32(24, sampleRate, Endian.little); |
||||
|
|
||||
|
// 字节率 = 采样率 * 通道数 * 每样本字节数 |
||||
|
data.setUint32(28, byteRate, Endian.little); |
||||
|
|
||||
|
// 块对齐 = 通道数 * 每样本字节数 |
||||
|
data.setUint16(32, blockAlign, Endian.little); |
||||
|
|
||||
|
// 每个样本位数 |
||||
|
data.setUint16(34, bytesPerSample * 8, Endian.little); |
||||
|
|
||||
|
// "data"子块 |
||||
|
data.setUint8(36, 0x64); // 'd' |
||||
|
data.setUint8(37, 0x61); // 'a' |
||||
|
data.setUint8(38, 0x74); // 't' |
||||
|
data.setUint8(39, 0x61); // 'a' |
||||
|
|
||||
|
// 数据大小 |
||||
|
data.setUint32(40, pcmLength, Endian.little); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// PCM音频源 |
||||
|
class PCMAudioSource extends StreamAudioSource { |
||||
|
final Uint8List pcmData; |
||||
|
final int sampleRate; |
||||
|
final int channels; |
||||
|
|
||||
|
PCMAudioSource({ |
||||
|
required this.pcmData, |
||||
|
this.sampleRate = 16000, |
||||
|
this.channels = 1, |
||||
|
}); |
||||
|
|
||||
|
@override |
||||
|
Future<StreamAudioResponse> request([int? start, int? end]) async { |
||||
|
start = start ?? 0; |
||||
|
end = end ?? pcmData.length; |
||||
|
|
||||
|
// 转换PCM到WAV格式 |
||||
|
final wavHeader = _createWavHeader(pcmData.length, sampleRate, channels); |
||||
|
final wavData = Uint8List(wavHeader.length + pcmData.length); |
||||
|
wavData.setRange(0, wavHeader.length, wavHeader); |
||||
|
wavData.setRange( |
||||
|
wavHeader.length, wavHeader.length + pcmData.length, pcmData); |
||||
|
|
||||
|
// 调整请求范围来适应WAV头部 |
||||
|
int adjustedStart = start; |
||||
|
int adjustedEnd = end; |
||||
|
|
||||
|
if (start < wavHeader.length) { |
||||
|
adjustedStart = start; |
||||
|
} else { |
||||
|
adjustedStart = start + wavHeader.length; |
||||
|
} |
||||
|
|
||||
|
if (end <= wavHeader.length) { |
||||
|
adjustedEnd = end; |
||||
|
} else { |
||||
|
adjustedEnd = end + wavHeader.length; |
||||
|
adjustedEnd = adjustedEnd > wavData.length ? wavData.length : adjustedEnd; |
||||
|
} |
||||
|
|
||||
|
return StreamAudioResponse( |
||||
|
sourceLength: wavData.length, |
||||
|
contentLength: adjustedEnd - adjustedStart, |
||||
|
offset: adjustedStart, |
||||
|
stream: Stream.value(wavData.sublist(adjustedStart, adjustedEnd)), |
||||
|
contentType: 'audio/wav', |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 创建WAV头部 |
||||
|
Uint8List _createWavHeader(int dataLength, int sampleRate, int channels) { |
||||
|
final bytesPerSample = 2; // 16位PCM |
||||
|
final byteRate = sampleRate * channels * bytesPerSample; |
||||
|
final blockAlign = channels * bytesPerSample; |
||||
|
|
||||
|
final header = ByteData(44); // WAV头部固定44字节 |
||||
|
|
||||
|
// "RIFF"标识 |
||||
|
header.setUint8(0, 0x52); // 'R' |
||||
|
header.setUint8(1, 0x49); // 'I' |
||||
|
header.setUint8(2, 0x46); // 'F' |
||||
|
header.setUint8(3, 0x46); // 'F' |
||||
|
|
||||
|
// 文件大小 (减去8个字节的RIFF头部和大小字段) |
||||
|
header.setUint32(4, 36 + dataLength, Endian.little); |
||||
|
|
||||
|
// "WAVE"标识 |
||||
|
header.setUint8(8, 0x57); // 'W' |
||||
|
header.setUint8(9, 0x41); // 'A' |
||||
|
header.setUint8(10, 0x56); // 'V' |
||||
|
header.setUint8(11, 0x45); // 'E' |
||||
|
|
||||
|
// "fmt "子块 |
||||
|
header.setUint8(12, 0x66); // 'f' |
||||
|
header.setUint8(13, 0x6D); // 'm' |
||||
|
header.setUint8(14, 0x74); // 't' |
||||
|
header.setUint8(15, 0x20); // ' ' |
||||
|
|
||||
|
// 子块大小 (16 for PCM) |
||||
|
header.setUint32(16, 16, Endian.little); |
||||
|
|
||||
|
// 音频格式 (1 表示PCM) |
||||
|
header.setUint16(20, 1, Endian.little); |
||||
|
|
||||
|
// 通道数量 |
||||
|
header.setUint16(22, channels, Endian.little); |
||||
|
|
||||
|
// 采样率 |
||||
|
header.setUint32(24, sampleRate, Endian.little); |
||||
|
|
||||
|
// 字节率 = 采样率 * 通道数 * 每样本字节数 |
||||
|
header.setUint32(28, byteRate, Endian.little); |
||||
|
|
||||
|
// 块对齐 = 通道数 * 每样本字节数 |
||||
|
header.setUint16(32, blockAlign, Endian.little); |
||||
|
|
||||
|
// 每个样本位数 |
||||
|
header.setUint16(34, bytesPerSample * 8, Endian.little); |
||||
|
|
||||
|
// "data"子块 |
||||
|
header.setUint8(36, 0x64); // 'd' |
||||
|
header.setUint8(37, 0x61); // 'a' |
||||
|
header.setUint8(38, 0x74); // 't' |
||||
|
header.setUint8(39, 0x61); // 'a' |
||||
|
|
||||
|
// 数据大小 |
||||
|
header.setUint32(40, dataLength, Endian.little); |
||||
|
|
||||
|
return header.buffer.asUint8List(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,518 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import '../controllers/opus_test_controller.dart'; |
||||
|
|
||||
|
class OpusTestView extends GetView<OpusTestController> { |
||||
|
const OpusTestView({Key? key}) : super(key: key); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
// 获取主题模式 |
||||
|
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
||||
|
|
||||
|
return DefaultTabController( |
||||
|
length: 2, |
||||
|
child: Scaffold( |
||||
|
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], |
||||
|
appBar: AppBar( |
||||
|
backgroundColor: isDarkMode ? Colors.black : Colors.white, |
||||
|
elevation: 0.5, |
||||
|
title: Text( |
||||
|
'OPUS解码', |
||||
|
style: TextStyle( |
||||
|
fontSize: 17.sp, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
), |
||||
|
), |
||||
|
centerTitle: true, |
||||
|
bottom: TabBar( |
||||
|
indicatorColor: Colors.blue, |
||||
|
indicatorWeight: 3, |
||||
|
labelColor: isDarkMode ? Colors.white : Colors.black, |
||||
|
unselectedLabelColor: isDarkMode ? Colors.grey : Colors.grey, |
||||
|
tabs: const [ |
||||
|
Tab(text: "解码"), |
||||
|
Tab(text: "输出"), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
body: TabBarView( |
||||
|
children: [ |
||||
|
_buildDecodeTab(isDarkMode), |
||||
|
_buildEncodeTab(isDarkMode), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 解码标签页 |
||||
|
Widget _buildDecodeTab(bool isDarkMode) { |
||||
|
return ListView( |
||||
|
padding: EdgeInsets.all(16.w), |
||||
|
children: [ |
||||
|
// 文件选择部分 |
||||
|
_buildSection( |
||||
|
title: '选择文件', |
||||
|
isDarkMode: isDarkMode, |
||||
|
child: Column( |
||||
|
children: [ |
||||
|
// 当前选中的文件 |
||||
|
Obx(() => controller.selectedFiles.isEmpty |
||||
|
? const SizedBox.shrink() |
||||
|
: ListView.builder( |
||||
|
shrinkWrap: true, |
||||
|
physics: const NeverScrollableScrollPhysics(), |
||||
|
itemCount: controller.selectedFiles.length, |
||||
|
itemBuilder: (context, index) { |
||||
|
final file = controller.selectedFiles[index]; |
||||
|
return ListTile( |
||||
|
title: Text( |
||||
|
file.name, |
||||
|
style: TextStyle( |
||||
|
color: |
||||
|
isDarkMode ? Colors.white70 : Colors.black87, |
||||
|
fontSize: 14.sp, |
||||
|
), |
||||
|
), |
||||
|
subtitle: Text( |
||||
|
'${(file.size / 1024).toStringAsFixed(2)} Kb', |
||||
|
style: TextStyle( |
||||
|
color: |
||||
|
isDarkMode ? Colors.white38 : Colors.black54, |
||||
|
fontSize: 12.sp, |
||||
|
), |
||||
|
), |
||||
|
trailing: IconButton( |
||||
|
icon: Icon(Icons.clear, color: Colors.red), |
||||
|
onPressed: () { |
||||
|
controller.selectedFiles.removeAt(index); |
||||
|
}, |
||||
|
), |
||||
|
); |
||||
|
}, |
||||
|
)), |
||||
|
|
||||
|
// 外部文件列表标题和刷新按钮 |
||||
|
Padding( |
||||
|
padding: EdgeInsets.symmetric(vertical: 8.h), |
||||
|
child: Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'外部存储文件', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
), |
||||
|
), |
||||
|
IconButton( |
||||
|
icon: Icon( |
||||
|
Icons.refresh, |
||||
|
color: isDarkMode ? Colors.blue[300] : Colors.blue, |
||||
|
), |
||||
|
onPressed: controller.loadExternalFiles, |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
// 外部文件列表 |
||||
|
Obx(() => controller.externalFiles.isEmpty |
||||
|
? Center( |
||||
|
child: Padding( |
||||
|
padding: EdgeInsets.all(16.h), |
||||
|
child: Text( |
||||
|
'未找到Opus文件', |
||||
|
style: TextStyle( |
||||
|
color: isDarkMode ? Colors.white54 : Colors.black54, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
) |
||||
|
: Container( |
||||
|
height: 200.h, |
||||
|
decoration: BoxDecoration( |
||||
|
border: Border.all( |
||||
|
color: isDarkMode |
||||
|
? Colors.grey[700]! |
||||
|
: Colors.grey[300]!, |
||||
|
), |
||||
|
borderRadius: BorderRadius.circular(8.r), |
||||
|
), |
||||
|
child: ListView.builder( |
||||
|
itemCount: controller.externalFiles.length, |
||||
|
itemBuilder: (context, index) { |
||||
|
final file = controller.externalFiles[index]; |
||||
|
return ListTile( |
||||
|
title: Text( |
||||
|
file.name, |
||||
|
style: TextStyle( |
||||
|
color: isDarkMode |
||||
|
? Colors.white70 |
||||
|
: Colors.black87, |
||||
|
fontSize: 14.sp, |
||||
|
), |
||||
|
overflow: TextOverflow.ellipsis, |
||||
|
), |
||||
|
subtitle: Text( |
||||
|
'${(file.size / 1024).toStringAsFixed(2)} Kb', |
||||
|
style: TextStyle( |
||||
|
color: isDarkMode |
||||
|
? Colors.white38 |
||||
|
: Colors.black54, |
||||
|
fontSize: 12.sp, |
||||
|
), |
||||
|
), |
||||
|
onTap: () { |
||||
|
controller.selectExternalFile(file); |
||||
|
}, |
||||
|
trailing: Icon( |
||||
|
Icons.arrow_forward, |
||||
|
color: |
||||
|
isDarkMode ? Colors.white54 : Colors.black54, |
||||
|
), |
||||
|
); |
||||
|
}, |
||||
|
), |
||||
|
)), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
SizedBox(height: 16.h), |
||||
|
|
||||
|
// 解码参数部分 |
||||
|
_buildSection( |
||||
|
title: '解码参数', |
||||
|
isDarkMode: isDarkMode, |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Row( |
||||
|
children: [ |
||||
|
Obx(() => Checkbox( |
||||
|
value: controller.hasHeader.value, |
||||
|
onChanged: (value) => controller.toggleHeader(value!), |
||||
|
activeColor: Colors.blue, |
||||
|
)), |
||||
|
Text( |
||||
|
'是否携带协议头', |
||||
|
style: TextStyle( |
||||
|
color: isDarkMode ? Colors.white70 : Colors.black87, |
||||
|
fontSize: 14.sp, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
// 数据包长度 |
||||
|
Row( |
||||
|
children: [ |
||||
|
Text( |
||||
|
'数据包长度', |
||||
|
style: TextStyle( |
||||
|
color: isDarkMode ? Colors.white70 : Colors.black87, |
||||
|
fontSize: 14.sp, |
||||
|
), |
||||
|
), |
||||
|
SizedBox(width: 16.w), |
||||
|
Expanded( |
||||
|
child: Obx(() => TextField( |
||||
|
controller: TextEditingController( |
||||
|
text: controller.packetLength.value), |
||||
|
onChanged: (value) => |
||||
|
controller.setPacketLength(value), |
||||
|
keyboardType: TextInputType.number, |
||||
|
style: TextStyle( |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
), |
||||
|
decoration: InputDecoration( |
||||
|
isDense: true, |
||||
|
suffix: Text( |
||||
|
'Byte', |
||||
|
style: TextStyle( |
||||
|
color: isDarkMode |
||||
|
? Colors.white60 |
||||
|
: Colors.black54, |
||||
|
fontSize: 12.sp, |
||||
|
), |
||||
|
), |
||||
|
enabledBorder: UnderlineInputBorder( |
||||
|
borderSide: BorderSide( |
||||
|
color: isDarkMode |
||||
|
? Colors.white30 |
||||
|
: Colors.black26, |
||||
|
), |
||||
|
), |
||||
|
focusedBorder: UnderlineInputBorder( |
||||
|
borderSide: BorderSide( |
||||
|
color: Colors.blue, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
)), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
SizedBox(height: 20.h), |
||||
|
|
||||
|
// 采样率 |
||||
|
Row( |
||||
|
children: [ |
||||
|
Text( |
||||
|
'采样率', |
||||
|
style: TextStyle( |
||||
|
color: isDarkMode ? Colors.white70 : Colors.black87, |
||||
|
fontSize: 14.sp, |
||||
|
), |
||||
|
), |
||||
|
SizedBox(width: 16.w), |
||||
|
Expanded( |
||||
|
child: Obx(() => DropdownButton<int>( |
||||
|
value: controller.selectedSampleRate.value, |
||||
|
isExpanded: true, |
||||
|
dropdownColor: |
||||
|
isDarkMode ? Colors.grey[800] : Colors.white, |
||||
|
items: controller.sampleRateOptions.map((rate) { |
||||
|
return DropdownMenuItem<int>( |
||||
|
value: rate, |
||||
|
child: Text( |
||||
|
rate.toString(), |
||||
|
style: TextStyle( |
||||
|
color: isDarkMode |
||||
|
? Colors.white |
||||
|
: Colors.black87, |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
}).toList(), |
||||
|
onChanged: (value) => |
||||
|
controller.setSampleRate(value!), |
||||
|
style: TextStyle( |
||||
|
color: isDarkMode ? Colors.white : Colors.black, |
||||
|
), |
||||
|
)), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
SizedBox(height: 20.h), |
||||
|
|
||||
|
// 通道数量 |
||||
|
Text( |
||||
|
'通道数量', |
||||
|
style: TextStyle( |
||||
|
color: isDarkMode ? Colors.white70 : Colors.black87, |
||||
|
fontSize: 14.sp, |
||||
|
), |
||||
|
), |
||||
|
Row( |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
Obx(() => Radio<bool>( |
||||
|
value: true, |
||||
|
groupValue: controller.isMono.value, |
||||
|
onChanged: (value) => |
||||
|
controller.toggleChannel(value!), |
||||
|
activeColor: Colors.blue, |
||||
|
)), |
||||
|
Text( |
||||
|
'单通道', |
||||
|
style: TextStyle( |
||||
|
color: isDarkMode ? Colors.white70 : Colors.black87, |
||||
|
fontSize: 14.sp, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
Expanded( |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
Obx(() => Radio<bool>( |
||||
|
value: false, |
||||
|
groupValue: controller.isMono.value, |
||||
|
onChanged: (value) => |
||||
|
controller.toggleChannel(value!), |
||||
|
activeColor: Colors.blue, |
||||
|
)), |
||||
|
Text( |
||||
|
'双通道', |
||||
|
style: TextStyle( |
||||
|
color: isDarkMode ? Colors.white70 : Colors.black87, |
||||
|
fontSize: 14.sp, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
SizedBox(height: 16.h), |
||||
|
|
||||
|
// 状态消息 |
||||
|
Obx(() => controller.statusMessage.value.isNotEmpty |
||||
|
? Container( |
||||
|
padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 16.w), |
||||
|
margin: EdgeInsets.only(bottom: 16.h), |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDarkMode |
||||
|
? Colors.blue.withOpacity(0.2) |
||||
|
: Colors.blue.withOpacity(0.1), |
||||
|
borderRadius: BorderRadius.circular(4.r), |
||||
|
border: Border.all( |
||||
|
color: isDarkMode |
||||
|
? Colors.blue.withOpacity(0.3) |
||||
|
: Colors.blue.withOpacity(0.2), |
||||
|
width: 1, |
||||
|
), |
||||
|
), |
||||
|
child: Text( |
||||
|
controller.statusMessage.value, |
||||
|
style: TextStyle( |
||||
|
color: isDarkMode ? Colors.blue[300] : Colors.blue[700], |
||||
|
fontSize: 12.sp, |
||||
|
), |
||||
|
), |
||||
|
) |
||||
|
: const SizedBox.shrink()), |
||||
|
|
||||
|
// 播放按钮 |
||||
|
Container( |
||||
|
margin: EdgeInsets.symmetric(vertical: 8.h), |
||||
|
child: ElevatedButton.icon( |
||||
|
icon: Obx(() => Icon( |
||||
|
controller.isPlaying.value ? Icons.stop : Icons.play_arrow, |
||||
|
color: Colors.white, |
||||
|
)), |
||||
|
label: Obx(() => Text( |
||||
|
controller.isPlaying.value ? '停止播放' : '播放音频', |
||||
|
style: TextStyle( |
||||
|
color: Colors.white, |
||||
|
fontSize: 16.sp, |
||||
|
), |
||||
|
)), |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: Colors.blue, |
||||
|
padding: EdgeInsets.symmetric(vertical: 16.h), |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(8.r), |
||||
|
), |
||||
|
), |
||||
|
onPressed: controller.isDecoding.value |
||||
|
? null |
||||
|
: () { |
||||
|
controller.playAudio(); |
||||
|
}, |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
// 解码按钮 |
||||
|
Container( |
||||
|
margin: EdgeInsets.symmetric(vertical: 8.h), |
||||
|
child: Obx(() => ElevatedButton( |
||||
|
onPressed: controller.selectedFiles.isEmpty || |
||||
|
controller.isDecoding.value |
||||
|
? null |
||||
|
: () { |
||||
|
controller.pickFiles(); |
||||
|
}, |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: Colors.blue, |
||||
|
padding: EdgeInsets.symmetric(vertical: 16.h), |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(8.r), |
||||
|
), |
||||
|
), |
||||
|
child: Text( |
||||
|
'开始解码', |
||||
|
style: TextStyle( |
||||
|
color: Colors.white, |
||||
|
fontSize: 16.sp, |
||||
|
), |
||||
|
), |
||||
|
)), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 编码标签页(暂未实现) |
||||
|
Widget _buildEncodeTab(bool isDarkMode) { |
||||
|
return Center( |
||||
|
child: Text( |
||||
|
'编码功能尚未实现', |
||||
|
style: TextStyle( |
||||
|
color: isDarkMode ? Colors.white54 : Colors.black54, |
||||
|
fontSize: 16.sp, |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 构建带标题的区段 |
||||
|
Widget _buildSection({ |
||||
|
required String title, |
||||
|
required Widget child, |
||||
|
required bool isDarkMode, |
||||
|
}) { |
||||
|
return Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Row( |
||||
|
children: [ |
||||
|
Text( |
||||
|
title, |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
color: isDarkMode ? Colors.white : Colors.black87, |
||||
|
), |
||||
|
), |
||||
|
SizedBox(width: 8.w), |
||||
|
Expanded( |
||||
|
child: Container( |
||||
|
height: 1, |
||||
|
color: isDarkMode ? Colors.white24 : Colors.black12, |
||||
|
), |
||||
|
), |
||||
|
if (title == '选择文件') |
||||
|
IconButton( |
||||
|
icon: Icon( |
||||
|
Icons.add_circle_outline, |
||||
|
color: isDarkMode ? Colors.blue[300] : Colors.blue, |
||||
|
), |
||||
|
onPressed: () { |
||||
|
controller.pickFiles(); |
||||
|
}, |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
Container( |
||||
|
padding: EdgeInsets.all(12.w), |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDarkMode ? Colors.white.withOpacity(0.05) : Colors.white, |
||||
|
borderRadius: BorderRadius.circular(8.r), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: isDarkMode |
||||
|
? Colors.black.withOpacity(0.2) |
||||
|
: Colors.black.withOpacity(0.03), |
||||
|
blurRadius: 4, |
||||
|
offset: Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: child, |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,12 @@ |
|||||
|
library bytedance_speech; |
||||
|
|
||||
|
import 'package:flutter/services.dart'; |
||||
|
|
||||
|
class BytedanceSpeech { |
||||
|
static const MethodChannel _channel = MethodChannel('bytedance_speech'); |
||||
|
|
||||
|
static Future<String?> getPlatformVersion() async { |
||||
|
final String? version = await _channel.invokeMethod('getPlatformVersion'); |
||||
|
return version; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,126 @@ |
|||||
|
# JL OPUS Plugin |
||||
|
|
||||
|
基于杰理音频编解码库的Flutter插件,提供OPUS音频格式的解码功能。 |
||||
|
|
||||
|
## 功能 |
||||
|
|
||||
|
- 支持OPUS文件解码为PCM格式 |
||||
|
- 支持OPUS数据流解码 |
||||
|
- 支持多种采样率和通道配置 |
||||
|
- 支持带协议头/不带协议头的OPUS数据 |
||||
|
|
||||
|
## 引入方式 |
||||
|
|
||||
|
在pubspec.yaml中添加依赖: |
||||
|
|
||||
|
```yaml |
||||
|
dependencies: |
||||
|
jl_opus: |
||||
|
path: ./local_plugins/jl_opus |
||||
|
``` |
||||
|
|
||||
|
## 使用方法 |
||||
|
|
||||
|
### 初始化插件 |
||||
|
|
||||
|
```dart |
||||
|
import 'package:jl_opus/jl_opus.dart'; |
||||
|
|
||||
|
final jlOpus = JlOpus(); |
||||
|
``` |
||||
|
|
||||
|
### 解码OPUS文件 |
||||
|
|
||||
|
```dart |
||||
|
Future<void> decodeOpusFile() async { |
||||
|
final inPath = '/path/to/input.opus'; |
||||
|
final outPath = '/path/to/output.pcm'; |
||||
|
|
||||
|
// 创建解码选项 |
||||
|
final option = OpusOption( |
||||
|
hasHeader: false, // 是否有协议头 |
||||
|
channel: 1, // 通道数:1=单声道,2=立体声 |
||||
|
sampleRate: 16000, // 采样率 |
||||
|
packetSize: 40, // 数据包大小(仅当hasHeader=false时生效) |
||||
|
); |
||||
|
|
||||
|
try { |
||||
|
// 开始解码文件 |
||||
|
final outFilePath = await jlOpus.decodeOpusFile(inPath, outPath, option); |
||||
|
print('解码成功: $outFilePath'); |
||||
|
} catch (e) { |
||||
|
print('解码失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 解码OPUS数据流 |
||||
|
|
||||
|
```dart |
||||
|
Future<void> decodeOpusStream() async { |
||||
|
// 监听解码后的PCM数据 |
||||
|
jlOpus.decodedDataStream.listen((pcmData) { |
||||
|
print('收到解码后的PCM数据: ${pcmData.length} 字节'); |
||||
|
// 处理解码后的PCM数据 |
||||
|
}); |
||||
|
|
||||
|
// 创建解码选项 |
||||
|
final option = OpusOption( |
||||
|
hasHeader: false, |
||||
|
channel: 1, |
||||
|
sampleRate: 16000, |
||||
|
packetSize: 40, |
||||
|
); |
||||
|
|
||||
|
// 开始解码数据流 |
||||
|
final success = await jlOpus.startDecodeStream(option); |
||||
|
if (success) { |
||||
|
print('开始解码数据流'); |
||||
|
|
||||
|
// 写入OPUS数据进行解码 |
||||
|
final opusData = Uint8List.fromList([/* OPUS数据 */]); |
||||
|
await jlOpus.writeAudioStream(opusData); |
||||
|
|
||||
|
// 数据处理完成后停止解码 |
||||
|
await jlOpus.stopDecodeStream(); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 监听事件 |
||||
|
|
||||
|
```dart |
||||
|
void listenToEvents() { |
||||
|
jlOpus.eventStream.listen((event) { |
||||
|
switch (event.event) { |
||||
|
case 'onStart': |
||||
|
print('开始${event.type == "file" ? "文件" : "流"}解码'); |
||||
|
break; |
||||
|
case 'onComplete': |
||||
|
print('完成${event.type == "file" ? "文件" : "流"}解码'); |
||||
|
if (event.filePath != null) { |
||||
|
print('输出文件: ${event.filePath}'); |
||||
|
} |
||||
|
break; |
||||
|
case 'onError': |
||||
|
print('解码错误: [${event.errorCode}] ${event.errorMessage}'); |
||||
|
break; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 资源释放 |
||||
|
|
||||
|
```dart |
||||
|
Future<void> disposePlugin() async { |
||||
|
await jlOpus.dispose(); |
||||
|
print('插件资源已释放'); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## 注意事项 |
||||
|
|
||||
|
1. OPUS解码需要正确设置参数,特别是采样率和通道数必须与原始音频匹配 |
||||
|
2. 使用前请确保jl_audio_decode_V2.0.0_20005.aar包已添加到libs目录 |
||||
|
3. 本插件仅支持Android平台 |
||||
@ -0,0 +1,62 @@ |
|||||
|
plugins { |
||||
|
// Android Library 插件 |
||||
|
id("com.android.library") |
||||
|
// Kotlin Android 插件 |
||||
|
id("org.jetbrains.kotlin.android") |
||||
|
id("com.github.johnrengelman.shadow") version "8.1.1" |
||||
|
} |
||||
|
|
||||
|
// 添加mavenLocal()仓库 |
||||
|
repositories { |
||||
|
mavenLocal() |
||||
|
google() |
||||
|
mavenCentral() |
||||
|
} |
||||
|
|
||||
|
android { |
||||
|
// 命名空间,对应你插件的包名(需与代码内包名保持一致) |
||||
|
namespace = "com.yunqiinnovation.jl_opus" |
||||
|
|
||||
|
// 目标 SDK 版本 |
||||
|
compileSdk = 33 |
||||
|
|
||||
|
defaultConfig { |
||||
|
// 最低 SDK 版本 |
||||
|
minSdk = 21 |
||||
|
targetSdk = 33 |
||||
|
} |
||||
|
|
||||
|
// Java 语言级别兼容配置 |
||||
|
compileOptions { |
||||
|
sourceCompatibility = JavaVersion.VERSION_11 |
||||
|
targetCompatibility = JavaVersion.VERSION_11 |
||||
|
} |
||||
|
|
||||
|
// Kotlin 语言级别 |
||||
|
kotlinOptions { |
||||
|
jvmTarget = "11" |
||||
|
} |
||||
|
|
||||
|
// 添加packaging配置,排除冲突文件 |
||||
|
packaging { |
||||
|
resources { |
||||
|
excludes.add("META-INF/DEPENDENCIES") |
||||
|
excludes.add("META-INF/LICENSE") |
||||
|
excludes.add("META-INF/LICENSE.txt") |
||||
|
excludes.add("META-INF/license.txt") |
||||
|
excludes.add("META-INF/NOTICE") |
||||
|
excludes.add("META-INF/NOTICE.txt") |
||||
|
excludes.add("META-INF/notice.txt") |
||||
|
excludes.add("META-INF/*.kotlin_module") |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
dependencies { |
||||
|
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.10") |
||||
|
implementation("androidx.annotation:annotation:1.5.0") |
||||
|
implementation("androidx.core:core-ktx:1.9.0") |
||||
|
// 使用从本地Maven仓库引用的AAR依赖 |
||||
|
implementation("com.jieli.audio:jl_audio_decode:2.0.0") |
||||
|
} |
||||
|
|
||||
@ -0,0 +1 @@ |
|||||
|
rootProject.name = "jl_opus" |
||||
@ -0,0 +1,9 @@ |
|||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" |
||||
|
package="com.yunqiinnovation.jl_opus"> |
||||
|
|
||||
|
<!-- 外部存储权限 --> |
||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> |
||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> |
||||
|
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" /> |
||||
|
</manifest> |
||||
@ -0,0 +1,285 @@ |
|||||
|
package com.yunqiinnovation.jl_opus |
||||
|
|
||||
|
import android.content.Context |
||||
|
import android.os.Handler |
||||
|
import android.os.Looper |
||||
|
import android.util.Log |
||||
|
import androidx.annotation.NonNull |
||||
|
import com.jieli.jl_audio_decode.opus.model.OpusOption |
||||
|
import com.jieli.jl_audio_decode.callback.OnDecodeStreamCallback |
||||
|
import com.jieli.jl_audio_decode.callback.OnStateCallback |
||||
|
import com.jieli.jl_audio_decode.opus.OpusManager |
||||
|
import com.jieli.jl_audio_decode.exceptions.OpusException |
||||
|
import io.flutter.embedding.engine.plugins.FlutterPlugin |
||||
|
import io.flutter.plugin.common.EventChannel |
||||
|
import io.flutter.plugin.common.MethodCall |
||||
|
import io.flutter.plugin.common.MethodChannel |
||||
|
import io.flutter.plugin.common.MethodChannel.MethodCallHandler |
||||
|
import io.flutter.plugin.common.MethodChannel.Result |
||||
|
import java.io.File |
||||
|
|
||||
|
/** JlOpusPlugin */ |
||||
|
class JlOpusPlugin: FlutterPlugin, MethodCallHandler { |
||||
|
private lateinit var channel: MethodChannel |
||||
|
private lateinit var eventChannel: EventChannel |
||||
|
private lateinit var context: Context |
||||
|
private var opusManager: OpusManager? = null |
||||
|
private var streamSink: EventChannel.EventSink? = null |
||||
|
|
||||
|
// 添加主线程Handler用于确保在主线程回调 |
||||
|
private val mainHandler = Handler(Looper.getMainLooper()) |
||||
|
|
||||
|
companion object { |
||||
|
private const val TAG = "JlOpusPlugin" |
||||
|
private const val CHANNEL_NAME = "com.yunqiinnovation.jl_opus" |
||||
|
private const val EVENT_CHANNEL_NAME = "com.yunqiinnovation.jl_opus/events" |
||||
|
} |
||||
|
|
||||
|
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { |
||||
|
context = flutterPluginBinding.applicationContext |
||||
|
channel = MethodChannel(flutterPluginBinding.binaryMessenger, CHANNEL_NAME) |
||||
|
channel.setMethodCallHandler(this) |
||||
|
|
||||
|
eventChannel = EventChannel(flutterPluginBinding.binaryMessenger, EVENT_CHANNEL_NAME) |
||||
|
eventChannel.setStreamHandler(object : EventChannel.StreamHandler { |
||||
|
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { |
||||
|
streamSink = events |
||||
|
} |
||||
|
|
||||
|
override fun onCancel(arguments: Any?) { |
||||
|
streamSink = null |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
|
||||
|
} |
||||
|
|
||||
|
// 在主线程上安全发送事件 |
||||
|
private fun sendEventOnMainThread(event: Map<String, Any?>) { |
||||
|
if (streamSink == null) return |
||||
|
|
||||
|
if (Looper.myLooper() == Looper.getMainLooper()) { |
||||
|
// 如果已经在主线程,直接发送 |
||||
|
streamSink?.success(event) |
||||
|
} else { |
||||
|
// 否则切换到主线程 |
||||
|
mainHandler.post { |
||||
|
streamSink?.success(event) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 在主线程上安全返回结果 |
||||
|
private fun sendResultOnMainThread(result: Result, value: Any?) { |
||||
|
if (Looper.myLooper() == Looper.getMainLooper()) { |
||||
|
// 如果已经在主线程,直接返回 |
||||
|
result.success(value) |
||||
|
} else { |
||||
|
// 否则切换到主线程 |
||||
|
mainHandler.post { |
||||
|
result.success(value) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 在主线程上安全返回错误 |
||||
|
private fun sendErrorOnMainThread(result: Result, errorCode: String, errorMessage: String, errorDetails: Any?) { |
||||
|
if (Looper.myLooper() == Looper.getMainLooper()) { |
||||
|
// 如果已经在主线程,直接返回 |
||||
|
result.error(errorCode, errorMessage, errorDetails) |
||||
|
} else { |
||||
|
// 否则切换到主线程 |
||||
|
mainHandler.post { |
||||
|
result.error(errorCode, errorMessage, errorDetails) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { |
||||
|
// if (opusManager == null) { |
||||
|
// result.error("OPUS_MANAGER_NULL", "OpusManager未初始化", null) |
||||
|
// return |
||||
|
// } |
||||
|
|
||||
|
when (call.method) { |
||||
|
"initOpusDecoder" -> { |
||||
|
// 初始化OpusManager |
||||
|
|
||||
|
opusManager = OpusManager() |
||||
|
Log.i("TAG", "OpusManager初始化成功") |
||||
|
result.success(true) |
||||
|
|
||||
|
|
||||
|
} |
||||
|
"decodeOpusFile" -> { |
||||
|
val inPath = call.argument<String>("inPath") |
||||
|
val outPath = call.argument<String>("outPath") |
||||
|
val hasHeader = call.argument<Boolean>("hasHeader") ?: false |
||||
|
val channel = call.argument<Int>("channel") ?: 1 |
||||
|
val sampleRate = call.argument<Int>("sampleRate") ?: 16000 |
||||
|
val packetSize = call.argument<Int>("packetSize") ?: 40 |
||||
|
|
||||
|
if (inPath == null || outPath == null) { |
||||
|
result.error("INVALID_ARGS", "输入或输出路径不能为空", null) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// 确保输入文件存在 |
||||
|
val inFile = File(inPath) |
||||
|
if (!inFile.exists()) { |
||||
|
result.error("FILE_NOT_FOUND", "输入文件不存在: $inPath", null) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// 创建解码参数 |
||||
|
val option = OpusOption() |
||||
|
.setHasHead(hasHeader) |
||||
|
.setChannel(channel) |
||||
|
.setSampleRate(sampleRate) |
||||
|
.setPacketSize(packetSize) |
||||
|
|
||||
|
Log.d(TAG, "开始解码文件, 参数: $option, 输入: $inPath, 输出: $outPath") |
||||
|
|
||||
|
opusManager?.decodeFile(inPath, outPath, option, object : OnStateCallback { |
||||
|
override fun onStart() { |
||||
|
Log.d(TAG, "文件解码开始") |
||||
|
sendEventOnMainThread(mapOf( |
||||
|
"event" to "onStart", |
||||
|
"type" to "file" |
||||
|
)) |
||||
|
} |
||||
|
|
||||
|
override fun onComplete(outFilePath: String?) { |
||||
|
Log.d(TAG, "文件解码完成: $outFilePath") |
||||
|
sendEventOnMainThread(mapOf( |
||||
|
"event" to "onComplete", |
||||
|
"type" to "file", |
||||
|
"filePath" to outFilePath |
||||
|
)) |
||||
|
sendResultOnMainThread(result, outFilePath) |
||||
|
} |
||||
|
|
||||
|
override fun onError(code: Int, message: String?) { |
||||
|
Log.e(TAG, "文件解码错误: [$code] $message") |
||||
|
sendEventOnMainThread(mapOf( |
||||
|
"event" to "onError", |
||||
|
"type" to "file", |
||||
|
"code" to code, |
||||
|
"message" to message |
||||
|
)) |
||||
|
sendErrorOnMainThread(result, "DECODE_ERROR", "解码出错: [$code] $message", null) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
"startDecodeStream" -> { |
||||
|
val hasHeader = call.argument<Boolean>("hasHeader") ?: false |
||||
|
val channel = call.argument<Int>("channel") ?: 1 |
||||
|
val sampleRate = call.argument<Int>("sampleRate") ?: 16000 |
||||
|
val packetSize = call.argument<Int>("packetSize") ?: 40 |
||||
|
|
||||
|
// 如果已经在解码流,先停止 |
||||
|
if (opusManager?.isDecodeStream == true) { |
||||
|
opusManager?.stopDecodeStream() |
||||
|
} |
||||
|
|
||||
|
// 创建解码参数 |
||||
|
val option = OpusOption() |
||||
|
.setHasHead(hasHeader) |
||||
|
.setChannel(channel) |
||||
|
.setSampleRate(sampleRate) |
||||
|
.setPacketSize(packetSize) |
||||
|
|
||||
|
Log.d(TAG, "开始解码数据流, 参数: $option") |
||||
|
|
||||
|
opusManager?.startDecodeStream(option, object : OnDecodeStreamCallback { |
||||
|
override fun onDecodeStream(data: ByteArray?) { |
||||
|
if (data != null) { |
||||
|
sendEventOnMainThread(mapOf( |
||||
|
"event" to "onDecodeStream", |
||||
|
"data" to data |
||||
|
)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun onStart() { |
||||
|
Log.d(TAG, "数据流解码开始") |
||||
|
sendEventOnMainThread(mapOf( |
||||
|
"event" to "onStart", |
||||
|
"type" to "stream" |
||||
|
)) |
||||
|
sendResultOnMainThread(result, true) |
||||
|
} |
||||
|
|
||||
|
override fun onComplete(outPath: String?) { |
||||
|
Log.d(TAG, "数据流解码完成: $outPath") |
||||
|
sendEventOnMainThread(mapOf( |
||||
|
"event" to "onComplete", |
||||
|
"type" to "stream" |
||||
|
)) |
||||
|
} |
||||
|
|
||||
|
override fun onError(code: Int, message: String?) { |
||||
|
Log.e(TAG, "数据流解码错误: [$code] $message") |
||||
|
sendEventOnMainThread(mapOf( |
||||
|
"event" to "onError", |
||||
|
"type" to "stream", |
||||
|
"code" to code, |
||||
|
"message" to message |
||||
|
)) |
||||
|
sendErrorOnMainThread(result, "DECODE_STREAM_ERROR", "数据流解码出错: [$code] $message", null) |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
"stopDecodeStream" -> { |
||||
|
if (opusManager?.isDecodeStream == true) { |
||||
|
opusManager?.stopDecodeStream() |
||||
|
result.success(true) |
||||
|
} else { |
||||
|
result.success(false) |
||||
|
} |
||||
|
} |
||||
|
"writeAudioStream" -> { |
||||
|
val data = call.argument<ByteArray>("data") |
||||
|
if (data == null) { |
||||
|
result.error("INVALID_ARGS", "音频数据不能为空", null) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
if (opusManager?.isDecodeStream == true) { |
||||
|
opusManager?.writeAudioStream(data) |
||||
|
result.success(true) |
||||
|
} else { |
||||
|
result.error("NOT_DECODING", "当前没有处于解码状态", null) |
||||
|
} |
||||
|
} |
||||
|
"isDecoding" -> { |
||||
|
result.success(opusManager?.isDecodeStream == true) |
||||
|
} |
||||
|
"dispose" -> { |
||||
|
opusManager?.let { |
||||
|
if (it.isDecodeStream) { |
||||
|
it.stopDecodeStream() |
||||
|
} |
||||
|
it.release() |
||||
|
} |
||||
|
opusManager = null |
||||
|
result.success(true) |
||||
|
} |
||||
|
else -> { |
||||
|
result.notImplemented() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { |
||||
|
channel.setMethodCallHandler(null) |
||||
|
eventChannel.setStreamHandler(null) |
||||
|
opusManager?.let { |
||||
|
if (it.isDecodeStream) { |
||||
|
it.stopDecodeStream() |
||||
|
} |
||||
|
it.release() |
||||
|
} |
||||
|
opusManager = null |
||||
|
} |
||||
|
} |
||||
Binary file not shown.
@ -0,0 +1,214 @@ |
|||||
|
import 'dart:async'; |
||||
|
import 'dart:typed_data'; |
||||
|
import 'package:flutter/services.dart'; |
||||
|
|
||||
|
/// OPUS解码参数选项 |
||||
|
class OpusOption { |
||||
|
/// 是否具有协议头 |
||||
|
final bool hasHeader; |
||||
|
|
||||
|
/// 通道数。取值范围:[1, 2] |
||||
|
final int channel; |
||||
|
|
||||
|
/// 采样率。参考值:{8000, 16000, 24000, 32000, 44100, 48000} |
||||
|
final int sampleRate; |
||||
|
|
||||
|
/// 数据包长度。仅hasHeader = false时生效。 |
||||
|
final int packetSize; |
||||
|
|
||||
|
/// 构造函数 |
||||
|
OpusOption({ |
||||
|
this.hasHeader = false, |
||||
|
this.channel = 1, |
||||
|
this.sampleRate = 16000, |
||||
|
this.packetSize = 40, |
||||
|
}); |
||||
|
|
||||
|
/// 转换为参数Map |
||||
|
Map<String, dynamic> toMap() { |
||||
|
return { |
||||
|
'hasHeader': hasHeader, |
||||
|
'channel': channel, |
||||
|
'sampleRate': sampleRate, |
||||
|
'packetSize': packetSize, |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 杰理OPUS编解码器事件 |
||||
|
class OpusEvent { |
||||
|
/// 事件类型 |
||||
|
final String event; |
||||
|
|
||||
|
/// 数据类型 (file或stream) |
||||
|
final String? type; |
||||
|
|
||||
|
/// 解码后的数据 (仅在流解码时有效) |
||||
|
final Uint8List? data; |
||||
|
|
||||
|
/// 输出文件路径 (仅在文件解码完成时有效) |
||||
|
final String? filePath; |
||||
|
|
||||
|
/// 错误码 (仅在发生错误时有效) |
||||
|
final int? errorCode; |
||||
|
|
||||
|
/// 错误信息 (仅在发生错误时有效) |
||||
|
final String? errorMessage; |
||||
|
|
||||
|
/// 构造函数 |
||||
|
OpusEvent({ |
||||
|
required this.event, |
||||
|
this.type, |
||||
|
this.data, |
||||
|
this.filePath, |
||||
|
this.errorCode, |
||||
|
this.errorMessage, |
||||
|
}); |
||||
|
|
||||
|
/// 从Map构造对象 |
||||
|
factory OpusEvent.fromMap(Map<dynamic, dynamic> map) { |
||||
|
return OpusEvent( |
||||
|
event: map['event'], |
||||
|
type: map['type'], |
||||
|
data: map['data'], |
||||
|
filePath: map['filePath'], |
||||
|
errorCode: map['code'], |
||||
|
errorMessage: map['message'], |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 杰理OPUS编解码插件 |
||||
|
class JlOpus { |
||||
|
static const MethodChannel _methodChannel = MethodChannel('com.yunqiinnovation.jl_opus'); |
||||
|
static const EventChannel _eventChannel = EventChannel('com.yunqiinnovation.jl_opus/events'); |
||||
|
|
||||
|
/// 事件流 |
||||
|
Stream<OpusEvent>? _eventStream; |
||||
|
|
||||
|
/// 获取事件流 |
||||
|
Stream<OpusEvent> get eventStream { |
||||
|
_eventStream ??= _eventChannel.receiveBroadcastStream().map((event) { |
||||
|
if (event is Map) { |
||||
|
return OpusEvent.fromMap(event); |
||||
|
} |
||||
|
throw PlatformException( |
||||
|
code: 'INVALID_EVENT', |
||||
|
message: '无效的事件数据', |
||||
|
); |
||||
|
}); |
||||
|
return _eventStream!; |
||||
|
} |
||||
|
|
||||
|
/// 解码后的数据流 |
||||
|
final StreamController<Uint8List> _decodedDataController = StreamController<Uint8List>.broadcast(); |
||||
|
|
||||
|
/// 获取解码数据流 |
||||
|
Stream<Uint8List> get decodedDataStream => _decodedDataController.stream; |
||||
|
|
||||
|
/// 构造函数,设置事件监听 |
||||
|
JlOpus() { |
||||
|
_setupEventListener(); |
||||
|
} |
||||
|
|
||||
|
/// 设置事件监听 |
||||
|
void _setupEventListener() { |
||||
|
eventStream.listen((event) { |
||||
|
if (event.event == 'onDecodeStream' && event.data != null) { |
||||
|
_decodedDataController.add(event.data!); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/// 初始化OPUS解码器 |
||||
|
Future<bool> initOpusDecoder() async { |
||||
|
try { |
||||
|
final result = await _methodChannel.invokeMethod<bool>('initOpusDecoder'); |
||||
|
return result ?? false; |
||||
|
} catch (e) { |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 解码OPUS文件 |
||||
|
/// |
||||
|
/// [inPath] 输入文件路径 |
||||
|
/// [outPath] 输出文件路径 |
||||
|
/// [option] 解码参数 |
||||
|
Future<String?> decodeOpusFile(String inPath, String outPath, OpusOption option) async { |
||||
|
try { |
||||
|
final result = await _methodChannel.invokeMethod<String>( |
||||
|
'decodeOpusFile', |
||||
|
{ |
||||
|
'inPath': inPath, |
||||
|
'outPath': outPath, |
||||
|
...option.toMap(), |
||||
|
}, |
||||
|
); |
||||
|
return result; |
||||
|
} catch (e) { |
||||
|
rethrow; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 开始解码OPUS数据流 |
||||
|
/// |
||||
|
/// [option] 解码参数 |
||||
|
Future<bool> startDecodeStream(OpusOption option) async { |
||||
|
try { |
||||
|
final result = await _methodChannel.invokeMethod<bool>( |
||||
|
'startDecodeStream', |
||||
|
option.toMap(), |
||||
|
); |
||||
|
return result ?? false; |
||||
|
} catch (e) { |
||||
|
rethrow; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 停止解码OPUS数据流 |
||||
|
Future<bool> stopDecodeStream() async { |
||||
|
try { |
||||
|
final result = await _methodChannel.invokeMethod<bool>('stopDecodeStream'); |
||||
|
return result ?? false; |
||||
|
} catch (e) { |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 写入OPUS音频数据进行解码 |
||||
|
/// |
||||
|
/// [data] OPUS音频数据 |
||||
|
Future<bool> writeAudioStream(Uint8List data) async { |
||||
|
try { |
||||
|
final result = await _methodChannel.invokeMethod<bool>( |
||||
|
'writeAudioStream', |
||||
|
{'data': data}, |
||||
|
); |
||||
|
return result ?? false; |
||||
|
} catch (e) { |
||||
|
rethrow; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 判断是否正在解码 |
||||
|
Future<bool> isDecoding() async { |
||||
|
try { |
||||
|
final result = await _methodChannel.invokeMethod<bool>('isDecoding'); |
||||
|
return result ?? false; |
||||
|
} catch (e) { |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 释放资源 |
||||
|
Future<bool> dispose() async { |
||||
|
try { |
||||
|
_decodedDataController.close(); |
||||
|
final result = await _methodChannel.invokeMethod<bool>('dispose'); |
||||
|
return result ?? false; |
||||
|
} catch (e) { |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,25 @@ |
|||||
|
name: jl_opus |
||||
|
description: 杰理OPUS编解码库Flutter插件 |
||||
|
version: 0.0.1 |
||||
|
homepage: |
||||
|
|
||||
|
environment: |
||||
|
sdk: ">=2.12.0 <3.0.0" |
||||
|
flutter: ">=2.0.0" |
||||
|
|
||||
|
dependencies: |
||||
|
flutter: |
||||
|
sdk: flutter |
||||
|
path_provider: ^2.0.0 |
||||
|
permission_handler: ^11.4.0 |
||||
|
|
||||
|
dev_dependencies: |
||||
|
flutter_test: |
||||
|
sdk: flutter |
||||
|
|
||||
|
flutter: |
||||
|
plugin: |
||||
|
platforms: |
||||
|
android: |
||||
|
package: com.yunqiinnovation.jl_opus |
||||
|
pluginClass: JlOpusPlugin |
||||
Loading…
Reference in new issue