diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index cf7591c68..63e42d4b2 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -40,7 +40,7 @@ include(":classic_bluetooth") include(":agent_service") include(":speech") include(":chat_api") - +include(":jl_opus") // 设置azure_speech项目的路径 project(":azure_speech").projectDir = file("../local_plugins/azure_speech/android") project(":chat_storage").projectDir = file("../local_plugins/chat_storage/android") @@ -50,4 +50,5 @@ project(":ble_service").projectDir = file("../local_plugins/ble_service/android" project(":classic_bluetooth").projectDir = file("../local_plugins/classic_bluetooth/android") project(":agent_service").projectDir = file("../local_plugins/agent_service/android") project(":speech").projectDir = file("../local_plugins/speech/android") -project(":chat_api").projectDir = file("../local_plugins/chat_api/android") \ No newline at end of file +project(":chat_api").projectDir = file("../local_plugins/chat_api/android") +project(":jl_opus").projectDir = file("../local_plugins/jl_opus/android") \ No newline at end of file diff --git a/lib/modules/meeting/controllers/meeting_controller.dart b/lib/modules/meeting/controllers/meeting_controller.dart index f077169cc..13e88d22e 100644 --- a/lib/modules/meeting/controllers/meeting_controller.dart +++ b/lib/modules/meeting/controllers/meeting_controller.dart @@ -32,6 +32,7 @@ class MeetingController extends GetxController { List selected = []; final appDocDir = getApplicationDocumentsDirectory(); + //final appopus = getExternalStorageDirectories(); // final directory = Directory( // '/storage/emulated/0/Android/data/com.yunqiinnovation.deepsound/files'); List files = []; @@ -129,8 +130,9 @@ class MeetingController extends GetxController { } void loadTranslatopus() async { - final appDir = await appDocDir; // 先 await 获取目录 - final dir = Directory("${appDir.path}/OpusAudio"); + // final appDir = await appopus; // 先 await 获取目录 + final dir = + Directory("/data/user/0/com.yunqiinnovation.deepsound/files/OpusAudio"); print("appDocDir=${dir?.path}"); diff --git a/lib/modules/meeting/views/meeting_view.dart b/lib/modules/meeting/views/meeting_view.dart index 83414157b..a20cdd12a 100644 --- a/lib/modules/meeting/views/meeting_view.dart +++ b/lib/modules/meeting/views/meeting_view.dart @@ -439,7 +439,7 @@ class MeetingView extends GetView { padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.w), child: Column( children: [ - _importAudioItem( + _importAudioItem1( context, isDarkMode, icon: Image.asset('assets/images/explore.png', @@ -452,7 +452,7 @@ class MeetingView extends GetView { }, ), SizedBox(height: 8.w), - _importAudioItem( + _importAudioItem1( context, isDarkMode, icon: Image.asset('assets/images/explore.png', @@ -465,7 +465,7 @@ class MeetingView extends GetView { }, ), SizedBox(height: 8.w), - _importAudioItem( + _importAudioItem1( context, isDarkMode, icon: Row( @@ -556,65 +556,64 @@ class MeetingView extends GetView { padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.w), child: Column( children: [ + // 现场录音 - 始终可用 _importAudioItem( context, isDarkMode, icon: Image.asset('assets/images/explore.png', width: 32.w, height: 32.w), title: '现场录音', + enabled: true, // 始终可用 onTap: () async { Navigator.of(context).pop(); await Get.toNamed(Routes.meetingRecord, arguments: { 'audioTypes': 0, }); controller.addMeetingAudio(); - // TODO: 实现从文件导入逻辑 }, ), SizedBox(height: 8.w), + // 音、视频录音 - 根据控制器状态禁用 _importAudioItem( context, isDarkMode, icon: Image.asset('assets/images/explore.png', width: 32.w, height: 32.w), title: '音、视频录音', - onTap: () async { - Navigator.of(context).pop(); - await Get.toNamed(Routes.meetingRecord, arguments: { - 'audioTypes': 1, - }); - controller.addMeetingAudio(); - // TODO: 实现从相册导入逻辑 - }, + enabled: false, // 依赖控制器状态 + onTap: false + ? () async { + // 条件启用 + Navigator.of(context).pop(); + await Get.toNamed(Routes.meetingRecord, + arguments: { + 'audioTypes': 1, + }); + controller.addMeetingAudio(); + } + : null, // 禁用时置空onTap ), SizedBox(height: 8.w), + // 通话录音 - 根据控制器状态禁用 _importAudioItem( context, isDarkMode, icon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Image.asset('assets/images/explore.png', - width: 20.w, height: 20.w), - SizedBox(width: 4.w), - Image.asset('assets/images/explore.png', - width: 20.w, height: 20.w), - SizedBox(width: 4.w), - Image.asset('assets/images/explore.png', - width: 20.w, height: 20.w), - ], - ), + // ... 图标组合保持不变 + ), title: '通话录音', - onTap: () async { - Navigator.of(context).pop(); - - await Get.toNamed(Routes.meetingRecord, arguments: { - 'audioTypes': 2, - }); - controller.addMeetingAudio(); - - // TODO: 实现从其他APP导入逻辑 - }, + enabled: false, // 依赖控制器状态 + onTap: false + ? () async { + // 条件启用 + Navigator.of(context).pop(); + await Get.toNamed(Routes.meetingRecord, + arguments: { + 'audioTypes': 2, + }); + controller.addMeetingAudio(); + } + : null, // 禁用时置空onTap ), ], ), @@ -643,8 +642,60 @@ class MeetingView extends GetView { ); } -// 选项框 +// 选项框 (增加enabled参数) Widget _importAudioItem( + BuildContext context, + bool isDarkMode, { + required Widget icon, + required String title, + required bool enabled, // 新增启用状态参数 + required VoidCallback? onTap, // 改为可空回调 + }) { + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(12.w), + onTap: onTap, // 禁用时onTap为null + child: Container( + height: 52.w, + padding: EdgeInsets.symmetric(horizontal: 12.w), + decoration: BoxDecoration( + color: isDarkMode + ? Colors.white.withOpacity(enabled ? 0.03 : 0.01) // 调整禁用透明度 + : Colors.grey[50]!.withOpacity(enabled ? 1 : 0.5), + borderRadius: BorderRadius.circular(12.w), + ), + child: Row( + children: [ + // 图标添加透明度效果 + Opacity( + opacity: enabled ? 1.0 : 0.5, + child: icon, + ), + SizedBox(width: 14.w), + Expanded( + child: Text( + title, + style: TextStyle( + fontSize: 16.sp, + color: isDarkMode + ? Colors.white.withOpacity(enabled ? 1 : 0.5) + : Colors.black87.withOpacity(enabled ? 1 : 0.5), + ), + ), + ), + Icon(Icons.arrow_forward_ios, + size: 18.w, + color: Colors.grey.withOpacity(enabled ? 1 : 0.5)), + ], + ), + ), + ), + ); + } + +// 选项框 + Widget _importAudioItem1( BuildContext context, bool isDarkMode, { required Widget icon, @@ -684,7 +735,6 @@ class MeetingView extends GetView { ), ); } - // 新增右侧弹窗 void _showRightDrawerDialog(BuildContext context, bool isDarkMode) { diff --git a/lib/modules/opus_test/bindings/opus_test_binding.dart b/lib/modules/opus_test/bindings/opus_test_binding.dart new file mode 100644 index 000000000..c1e6a1aa0 --- /dev/null +++ b/lib/modules/opus_test/bindings/opus_test_binding.dart @@ -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()); + } +} \ No newline at end of file diff --git a/lib/modules/opus_test/controllers/opus_test_controller.dart b/lib/modules/opus_test/controllers/opus_test_controller.dart new file mode 100644 index 000000000..6297dee3a --- /dev/null +++ b/lib/modules/opus_test/controllers/opus_test_controller.dart @@ -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 = [].obs; + + // 扫描到的外部存储文件 + final externalFiles = [].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 _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 requestPermissions() async { + if (Platform.isAndroid) { + await Permission.storage.request(); + } + } + + // 加载外部存储文件 + Future 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 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 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 _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 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(); + } +} diff --git a/lib/modules/opus_test/views/opus_test_view.dart b/lib/modules/opus_test/views/opus_test_view.dart new file mode 100644 index 000000000..adecc15b6 --- /dev/null +++ b/lib/modules/opus_test/views/opus_test_view.dart @@ -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 { + 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( + value: controller.selectedSampleRate.value, + isExpanded: true, + dropdownColor: + isDarkMode ? Colors.grey[800] : Colors.white, + items: controller.sampleRateOptions.map((rate) { + return DropdownMenuItem( + 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( + 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( + 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, + ), + ], + ); + } +} diff --git a/lib/modules/settings/views/settings_view.dart b/lib/modules/settings/views/settings_view.dart index 195daf552..fc37c5bad 100644 --- a/lib/modules/settings/views/settings_view.dart +++ b/lib/modules/settings/views/settings_view.dart @@ -543,6 +543,28 @@ class SettingsView extends GetView { }, isDarkMode: isDarkMode, ), + Divider( + height: 1, + color: isDarkMode + ? Colors.white.withOpacity(0.1) + : Colors.grey[200]), + // BLE测试 + _buildSimpleNavigationSetting( + title: 'opus解码测试', + subtitle: '测试opus解码', + icon: Icons.bluetooth_searching, + iconBgColor: isDarkMode + ? Colors.green[900]!.withOpacity(0.3) + : Colors.green[100]!, + iconColor: + isDarkMode ? Colors.green[300]! : Colors.green[600]!, + titleColor: isDarkMode ? Colors.white : null, + subtitleColor: isDarkMode ? Colors.white70 : null, + onTap: () { + Get.toNamed(Routes.opusTest); + }, + isDarkMode: isDarkMode, + ), Divider( height: 1, color: isDarkMode diff --git a/lib/modules/translation/views/translation_view.dart b/lib/modules/translation/views/translation_view.dart index 01f3a1ec6..e8eaf3d3e 100644 --- a/lib/modules/translation/views/translation_view.dart +++ b/lib/modules/translation/views/translation_view.dart @@ -24,13 +24,18 @@ class TranslationView extends GetView { PreferredSizeWidget _buildAppBar(bool isDarkMode) { // 在同一个类中添加辅助方法 - PopupMenuItem _buildPopupMenuItem(String value, String text) { + PopupMenuItem _buildPopupMenuItem(String value, String text, + {bool enabled = true // 默认启用 + }) { return PopupMenuItem( value: value, + enabled: enabled, // 设置启用状态 child: Text( text, style: TextStyle( - color: isDarkMode ? Colors.white : Colors.black, + color: enabled + ? null // 使用默认颜色 + : Colors.grey, // 禁用时显示灰色 ), ), ); @@ -59,8 +64,16 @@ class TranslationView extends GetView { itemBuilder: (BuildContext context) => [ _buildPopupMenuItem('simultaneous', 'simultaneousTranslation'.tr), _buildPopupMenuItem('faceToFace', 'faceToFaceTranslation'.tr), - _buildPopupMenuItem('audioVideo', 'audioVideoTranslation'.tr), - _buildPopupMenuItem('call', 'callTranslation'.tr), + _buildPopupMenuItem( + 'audioVideo', + 'audioVideoTranslation'.tr, + enabled: true, // 根据连接状态启用/禁用 + ), + _buildPopupMenuItem( + 'call', + 'callTranslation'.tr, + enabled: true, // 根据连接状态启用/禁用 + ), ], child: Row( mainAxisSize: MainAxisSize.min, // 关键修改:让宽度自适应内容 diff --git a/lib/routes/app_pages.dart b/lib/routes/app_pages.dart index 4631245f0..66c5f2516 100644 --- a/lib/routes/app_pages.dart +++ b/lib/routes/app_pages.dart @@ -11,6 +11,8 @@ import '../modules/meeting/views/meeting_details_view.dart'; import '../modules/meeting/bindings/meeting_record_binding.dart'; import '../modules/meeting/views/meeting_record_view.dart'; import '../modules/meeting/views/meeting_view.dart'; +import '../modules/opus_test/bindings/opus_test_binding.dart'; +import '../modules/opus_test/views/opus_test_view.dart'; import '../modules/profile/views/profile_view.dart'; import '../modules/profile/bindings/profile_binding.dart'; import '../modules/chat/bindings/chat_binding.dart'; @@ -189,6 +191,13 @@ abstract class AppPages { transition: Transition.rightToLeft, transitionDuration: Duration(milliseconds: 250), ), + GetPage( + name: Routes.opusTest, + page: () => const OpusTestView(), + binding: OpusTestBinding(), + transition: Transition.rightToLeft, + transitionDuration: Duration(milliseconds: 250), + ), GetPage( name: Routes.agent, page: () => const AgentView(), diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt index 7e6141db4..55eb87911 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt @@ -1084,8 +1084,6 @@ else if(codecStatus == BleConst.CODEC_CONTROL_CLOSE) * 控制编解码 - 打开解码 */ fun openDecoder(): Boolean { - Log.i(TAG, "打开编码 (设备侧)...") - // val opusStarted = startOpusStreamDecoding( // hasHeader = false, // BLE传输的Opus数据通常无文件头 // channel = 1, // 单声道 @@ -1095,9 +1093,9 @@ else if(codecStatus == BleConst.CODEC_CONTROL_CLOSE) // // The library might expect this in a different unit or derive it. // // Given JlOpusPlugin.kt, packetSize refers to Opus encoded frame duration in ms. // ) + startOpusStreamDecoding(false, 1, 16000, 40) - - Log.i(TAG, "打开解码...") + Log.i(TAG, "打开解码0xA2") return sendCommand( BleConst.CMD_CONTROL_CODEC.toByte(), byteArrayOf( BleConst.CODEC_CONTROL_DECODE_ON.toByte(), @@ -1111,8 +1109,8 @@ else if(codecStatus == BleConst.CODEC_CONTROL_CLOSE) * 控制编解码 - 打开解码 */ fun openA2DPDecoder(): Boolean { - Log.i(TAG, "打开编码 (设备侧)...") - + Log.i(TAG, "打开编码 0xA1") + startOpusStreamDecoding(false, 1, 16000, 80) // Log.i(TAG, "打开解码...") return sendCommand( BleConst.CMD_CONTROL_CODEC.toByte(), byteArrayOf( @@ -1135,9 +1133,9 @@ else if(codecStatus == BleConst.CODEC_CONTROL_CLOSE) * 控制编解码 - 打开编码 */ fun openEncoder(): Boolean { - Log.i(TAG, "打开编码 (设备侧)...") - + Log.i(TAG, "打开编码0xB1") + startOpusStreamDecoding(false, 1, 16000, 40) // 目前仅发送命令通知设备开始编码。 return sendCommand( BleConst.CMD_CONTROL_CODEC.toByte(), @@ -1410,11 +1408,8 @@ else if(codecStatus == BleConst.CODEC_CONTROL_CLOSE) // 创建新的音频文件 val dateFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()) val timestamp = dateFormat.format(Date()) - val parentDir = File(context.filesDir, "OpusAudio") - if (!parentDir.exists()) { - parentDir.mkdirs() - } - val filePath = File(parentDir, "${fileName}_${timestamp}.opus").absolutePath + + val filePath = File(context.getExternalFilesDir(null), "${fileName}_${timestamp}.opus").absolutePath currentAudioFile = File(filePath) currentAudioFile?.createNewFile() // 追加音频数据到文件 @@ -1445,7 +1440,17 @@ else if(codecStatus == BleConst.CODEC_CONTROL_CLOSE) internal fun saveAudioDataToWav(buffer: ByteArray) { if (fos == null || currentAudioFile == null) return // 放入队列,由写线程写入 - writeQueue.offer(buffer.copyOf()) + // 1. 将字节数据转换为十六进制字符串 + // val hexData = buildString { + // buffer.forEachIndexed { index, byte -> + // append("%02X".format(byte)) + + // } + // } + + // 2. 写入十六进制字符串 + + writeQueue.offer(buffer) totalBytesWritten += buffer.size } diff --git a/local_plugins/jl_opus/README.md b/local_plugins/jl_opus/README.md new file mode 100644 index 000000000..124a1c56c --- /dev/null +++ b/local_plugins/jl_opus/README.md @@ -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 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 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 disposePlugin() async { + await jlOpus.dispose(); + print('插件资源已释放'); +} +``` + +## 注意事项 + +1. OPUS解码需要正确设置参数,特别是采样率和通道数必须与原始音频匹配 +2. 使用前请确保jl_audio_decode_V2.0.0_20005.aar包已添加到libs目录 +3. 本插件仅支持Android平台 \ No newline at end of file diff --git a/local_plugins/jl_opus/android/build.gradle.kts b/local_plugins/jl_opus/android/build.gradle.kts new file mode 100644 index 000000000..3c0b820e2 --- /dev/null +++ b/local_plugins/jl_opus/android/build.gradle.kts @@ -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") +} + diff --git a/local_plugins/jl_opus/android/settings.gradle.kts b/local_plugins/jl_opus/android/settings.gradle.kts new file mode 100644 index 000000000..453a0b3f4 --- /dev/null +++ b/local_plugins/jl_opus/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "jl_opus" \ No newline at end of file diff --git a/local_plugins/jl_opus/android/src/main/AndroidManifest.xml b/local_plugins/jl_opus/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..ee906a1ee --- /dev/null +++ b/local_plugins/jl_opus/android/src/main/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/local_plugins/jl_opus/android/src/main/kotlin/com/yunqiinnovation/jl_opus/JlOpusPlugin.kt b/local_plugins/jl_opus/android/src/main/kotlin/com/yunqiinnovation/jl_opus/JlOpusPlugin.kt new file mode 100644 index 000000000..a2c9ad16c --- /dev/null +++ b/local_plugins/jl_opus/android/src/main/kotlin/com/yunqiinnovation/jl_opus/JlOpusPlugin.kt @@ -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) { + 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("inPath") + val outPath = call.argument("outPath") + val hasHeader = call.argument("hasHeader") ?: false + val channel = call.argument("channel") ?: 1 + val sampleRate = call.argument("sampleRate") ?: 16000 + val packetSize = call.argument("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("hasHeader") ?: false + val channel = call.argument("channel") ?: 1 + val sampleRate = call.argument("sampleRate") ?: 16000 + val packetSize = call.argument("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("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 + } +} \ No newline at end of file diff --git a/local_plugins/jl_opus/android/src/main/libs/jl_audio_decode_V2.0.0_20005.aar b/local_plugins/jl_opus/android/src/main/libs/jl_audio_decode_V2.0.0_20005.aar new file mode 100644 index 000000000..0ec76c882 Binary files /dev/null and b/local_plugins/jl_opus/android/src/main/libs/jl_audio_decode_V2.0.0_20005.aar differ diff --git a/local_plugins/jl_opus/lib/jl_opus.dart b/local_plugins/jl_opus/lib/jl_opus.dart new file mode 100644 index 000000000..dc63a0cd3 --- /dev/null +++ b/local_plugins/jl_opus/lib/jl_opus.dart @@ -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 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 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? _eventStream; + + /// 获取事件流 + Stream 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 _decodedDataController = StreamController.broadcast(); + + /// 获取解码数据流 + Stream get decodedDataStream => _decodedDataController.stream; + + /// 构造函数,设置事件监听 + JlOpus() { + _setupEventListener(); + } + + /// 设置事件监听 + void _setupEventListener() { + eventStream.listen((event) { + if (event.event == 'onDecodeStream' && event.data != null) { + _decodedDataController.add(event.data!); + } + }); + } + + /// 初始化OPUS解码器 + Future initOpusDecoder() async { + try { + final result = await _methodChannel.invokeMethod('initOpusDecoder'); + return result ?? false; + } catch (e) { + return false; + } + } + + /// 解码OPUS文件 + /// + /// [inPath] 输入文件路径 + /// [outPath] 输出文件路径 + /// [option] 解码参数 + Future decodeOpusFile(String inPath, String outPath, OpusOption option) async { + try { + final result = await _methodChannel.invokeMethod( + 'decodeOpusFile', + { + 'inPath': inPath, + 'outPath': outPath, + ...option.toMap(), + }, + ); + return result; + } catch (e) { + rethrow; + } + } + + /// 开始解码OPUS数据流 + /// + /// [option] 解码参数 + Future startDecodeStream(OpusOption option) async { + try { + final result = await _methodChannel.invokeMethod( + 'startDecodeStream', + option.toMap(), + ); + return result ?? false; + } catch (e) { + rethrow; + } + } + + /// 停止解码OPUS数据流 + Future stopDecodeStream() async { + try { + final result = await _methodChannel.invokeMethod('stopDecodeStream'); + return result ?? false; + } catch (e) { + return false; + } + } + + /// 写入OPUS音频数据进行解码 + /// + /// [data] OPUS音频数据 + Future writeAudioStream(Uint8List data) async { + try { + final result = await _methodChannel.invokeMethod( + 'writeAudioStream', + {'data': data}, + ); + return result ?? false; + } catch (e) { + rethrow; + } + } + + /// 判断是否正在解码 + Future isDecoding() async { + try { + final result = await _methodChannel.invokeMethod('isDecoding'); + return result ?? false; + } catch (e) { + return false; + } + } + + /// 释放资源 + Future dispose() async { + try { + _decodedDataController.close(); + final result = await _methodChannel.invokeMethod('dispose'); + return result ?? false; + } catch (e) { + return false; + } + } +} diff --git a/local_plugins/jl_opus/pubspec.yaml b/local_plugins/jl_opus/pubspec.yaml new file mode 100644 index 000000000..3d553c10c --- /dev/null +++ b/local_plugins/jl_opus/pubspec.yaml @@ -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 diff --git a/pubspec.yaml b/pubspec.yaml index ccd4d7c49..662550b92 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -95,7 +95,8 @@ dependencies: path: local_plugins/location_service realtime: path: local_plugins/realtime - + jl_opus: + path: local_plugins/jl_opus test: path: local_plugins/test