You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
462 lines
13 KiB
462 lines
13 KiB
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();
|
|
}
|
|
}
|
|
|