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