Browse Source

恢复opus解码解码,修复通话翻译解码错误的问题

newdev_shunjiawei
fdp 1 year ago
parent
commit
5c38d8e801
  1. 5
      android/settings.gradle.kts
  2. 6
      lib/modules/meeting/controllers/meeting_controller.dart
  3. 122
      lib/modules/meeting/views/meeting_view.dart
  4. 9
      lib/modules/opus_test/bindings/opus_test_binding.dart
  5. 462
      lib/modules/opus_test/controllers/opus_test_controller.dart
  6. 518
      lib/modules/opus_test/views/opus_test_view.dart
  7. 22
      lib/modules/settings/views/settings_view.dart
  8. 21
      lib/modules/translation/views/translation_view.dart
  9. 9
      lib/routes/app_pages.dart
  10. 33
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt
  11. 126
      local_plugins/jl_opus/README.md
  12. 62
      local_plugins/jl_opus/android/build.gradle.kts
  13. 1
      local_plugins/jl_opus/android/settings.gradle.kts
  14. 9
      local_plugins/jl_opus/android/src/main/AndroidManifest.xml
  15. 285
      local_plugins/jl_opus/android/src/main/kotlin/com/yunqiinnovation/jl_opus/JlOpusPlugin.kt
  16. BIN
      local_plugins/jl_opus/android/src/main/libs/jl_audio_decode_V2.0.0_20005.aar
  17. 214
      local_plugins/jl_opus/lib/jl_opus.dart
  18. 25
      local_plugins/jl_opus/pubspec.yaml
  19. 3
      pubspec.yaml

5
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")
project(":chat_api").projectDir = file("../local_plugins/chat_api/android")
project(":jl_opus").projectDir = file("../local_plugins/jl_opus/android")

6
lib/modules/meeting/controllers/meeting_controller.dart

@ -32,6 +32,7 @@ class MeetingController extends GetxController {
List<String> selected = [];
final appDocDir = getApplicationDocumentsDirectory();
//final appopus = getExternalStorageDirectories();
// final directory = Directory(
// '/storage/emulated/0/Android/data/com.yunqiinnovation.deepsound/files');
List<FileSystemEntity> 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}");

122
lib/modules/meeting/views/meeting_view.dart

@ -439,7 +439,7 @@ class MeetingView extends GetView<MeetingController> {
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<MeetingController> {
},
),
SizedBox(height: 8.w),
_importAudioItem(
_importAudioItem1(
context,
isDarkMode,
icon: Image.asset('assets/images/explore.png',
@ -465,7 +465,7 @@ class MeetingView extends GetView<MeetingController> {
},
),
SizedBox(height: 8.w),
_importAudioItem(
_importAudioItem1(
context,
isDarkMode,
icon: Row(
@ -556,65 +556,64 @@ class MeetingView extends GetView<MeetingController> {
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<MeetingController> {
);
}
// 选项框
// 选项框 (增加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<MeetingController> {
),
);
}
// 新增右侧弹窗
void _showRightDrawerDialog(BuildContext context, bool isDarkMode) {

9
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>(() => OpusTestController());
}
}

462
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 = <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();
}
}

518
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<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,
),
],
);
}
}

22
lib/modules/settings/views/settings_view.dart

@ -543,6 +543,28 @@ class SettingsView extends GetView<SettingsController> {
},
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

21
lib/modules/translation/views/translation_view.dart

@ -24,13 +24,18 @@ class TranslationView extends GetView<TranslationController> {
PreferredSizeWidget _buildAppBar(bool isDarkMode) {
// 在同一个类中添加辅助方法
PopupMenuItem<String> _buildPopupMenuItem(String value, String text) {
PopupMenuItem<String> _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<TranslationController> {
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, // 关键修改:让宽度自适应内容

9
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(),

33
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
}

126
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<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平台

62
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")
}

1
local_plugins/jl_opus/android/settings.gradle.kts

@ -0,0 +1 @@
rootProject.name = "jl_opus"

9
local_plugins/jl_opus/android/src/main/AndroidManifest.xml

@ -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>

285
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<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
}
}

BIN
local_plugins/jl_opus/android/src/main/libs/jl_audio_decode_V2.0.0_20005.aar

Binary file not shown.

214
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<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;
}
}
}

25
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

3
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

Loading…
Cancel
Save