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.
761 lines
22 KiB
761 lines
22 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:flutter/services.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';
|
|
import 'package:share_plus/share_plus.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解码器
|
|
JlOpus? jlOpus;
|
|
StreamSubscription? _eventSubscription;
|
|
static const MethodChannel _bleServiceChannel =
|
|
MethodChannel('com.yunqiinnovation.ble_service');
|
|
|
|
@override
|
|
void onInit() {
|
|
super.onInit();
|
|
requestPermissions();
|
|
_initOpusDecoder();
|
|
}
|
|
|
|
@override
|
|
void onClose() {
|
|
player.dispose();
|
|
_eventSubscription?.cancel();
|
|
jlOpus?.dispose();
|
|
super.onClose();
|
|
}
|
|
|
|
// 初始化OPUS解码器
|
|
Future<void> _initOpusDecoder() async {
|
|
if (!Platform.isAndroid) {
|
|
return;
|
|
}
|
|
|
|
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<String?> _decodeOpusFile(
|
|
String inPath, String outPath, OpusOption option) async {
|
|
if (Platform.isIOS) {
|
|
final result = await _bleServiceChannel.invokeMethod<String>(
|
|
'decodeOpusFile',
|
|
{
|
|
'inPath': inPath,
|
|
'outPath': outPath,
|
|
...option.toMap(),
|
|
},
|
|
);
|
|
return result;
|
|
}
|
|
|
|
return jlOpus?.decodeOpusFile(inPath, outPath, option);
|
|
}
|
|
|
|
// 请求必要权限
|
|
Future<void> requestPermissions() async {
|
|
if (Platform.isAndroid) {
|
|
await Permission.storage.request();
|
|
}
|
|
}
|
|
|
|
// 加载外部存储文件
|
|
Future<void> loadExternalFiles() async {
|
|
try {
|
|
externalFiles.clear();
|
|
statusMessage.value = '正在扫描存储...';
|
|
print('正在扫描存储...');
|
|
|
|
List<Directory> directories = [];
|
|
|
|
if (Platform.isAndroid) {
|
|
// Android: 使用外部存储目录
|
|
final androidDirs = await getExternalStorageDirectories();
|
|
if (androidDirs != null && androidDirs.isNotEmpty) {
|
|
directories.addAll(androidDirs);
|
|
}
|
|
} else if (Platform.isIOS) {
|
|
// iOS: 使用Documents目录
|
|
final iosDir = await getApplicationDocumentsDirectory();
|
|
directories.add(iosDir);
|
|
}
|
|
|
|
if (directories.isEmpty) {
|
|
statusMessage.value = '未找到存储目录';
|
|
return;
|
|
}
|
|
|
|
print('存储目录: $directories');
|
|
|
|
// 遍历所有存储目录
|
|
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();
|
|
// 翻译落地音频:.wav(编码前原始)+ .opus(编码后实际下发);保留 .txt 兼容
|
|
if (path.endsWith('.opus') ||
|
|
path.endsWith('.wav') ||
|
|
path.endsWith('.txt')) {
|
|
final fileSize = await entity.length();
|
|
externalFiles.add(PlatformFile(
|
|
name: entity.path.split('/').last,
|
|
path: entity.path,
|
|
size: fileSize,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 翻译音频靠文件名(翻译-左/右-序号)排序,按句顺序排列,方便顺序试听
|
|
externalFiles.sort((a, b) => a.name.compareTo(b.name));
|
|
|
|
statusMessage.value = '扫描完成,找到 ${externalFiles.length} 个音频文件';
|
|
} 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);
|
|
// 翻译落地的 OPUS 由 OpusManager 默认编码(带 head / mono / 16k),
|
|
// 自动套上对应解码参数,直接点播放即可还原,方便和同名 .wav 对比。
|
|
if (file.name.startsWith('翻译-') && file.name.toLowerCase().endsWith('.opus')) {
|
|
hasHeader.value = true;
|
|
isMono.value = true;
|
|
selectedSampleRate.value = 16000;
|
|
}
|
|
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 = '请先选择文件';
|
|
return;
|
|
}
|
|
|
|
final selectedPath = selectedFiles.first.path!;
|
|
// WAV(编码前原始音频)可直接播放,无需 OPUS 解码
|
|
if (selectedPath.toLowerCase().endsWith('.wav')) {
|
|
try {
|
|
statusMessage.value = '正在播放 WAV 原始音频';
|
|
await player.setFilePath(selectedPath);
|
|
await player.play();
|
|
isPlaying.value = true;
|
|
player.playerStateStream.listen((state) {
|
|
if (state.processingState == ProcessingState.completed) {
|
|
isPlaying.value = false;
|
|
statusMessage.value = '播放完成';
|
|
}
|
|
});
|
|
} catch (e) {
|
|
statusMessage.value = '播放WAV失败: $e';
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
isDecoding.value = true;
|
|
statusMessage.value = '正在解码...';
|
|
|
|
// 使用杰理OPUS库进行解码
|
|
final inPath = selectedPath;
|
|
|
|
// 创建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 _decodeOpusFile(inPath, outPath, option);
|
|
if (pcmPath == null) {
|
|
statusMessage.value = '解码失败';
|
|
isDecoding.value = false;
|
|
return;
|
|
}
|
|
print("pcmPath: $pcmPath");
|
|
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);
|
|
}
|
|
|
|
/// 删除外部文件
|
|
Future<void> deleteExternalFile(PlatformFile file) async {
|
|
try {
|
|
final fileToDelete = File(file.path!);
|
|
if (await fileToDelete.exists()) {
|
|
await fileToDelete.delete();
|
|
externalFiles.removeWhere((f) => f.path == file.path);
|
|
|
|
// 如果删除的是当前选中的文件,清空选择
|
|
if (selectedFiles.isNotEmpty && selectedFiles.first.path == file.path) {
|
|
selectedFiles.clear();
|
|
}
|
|
|
|
statusMessage.value = '文件已删除: ${file.name}';
|
|
} else {
|
|
statusMessage.value = '文件不存在: ${file.name}';
|
|
}
|
|
} catch (e) {
|
|
statusMessage.value = '删除文件失败: $e';
|
|
}
|
|
}
|
|
|
|
/// 一键清理:删除当前列表里扫描到的全部文件(清空历史落地音频)
|
|
Future<void> clearAllFiles() async {
|
|
final total = externalFiles.length;
|
|
if (total == 0) {
|
|
statusMessage.value = '没有可清理的文件';
|
|
return;
|
|
}
|
|
int deleted = 0;
|
|
int failed = 0;
|
|
// 复制一份遍历,避免边删边改 observable 列表
|
|
for (final file in externalFiles.toList()) {
|
|
try {
|
|
final f = File(file.path!);
|
|
if (await f.exists()) await f.delete();
|
|
deleted++;
|
|
} catch (_) {
|
|
failed++;
|
|
}
|
|
}
|
|
externalFiles.clear();
|
|
selectedFiles.clear();
|
|
if (isPlaying.value) {
|
|
await player.stop();
|
|
isPlaying.value = false;
|
|
}
|
|
statusMessage.value = '已清理 $deleted 个文件${failed > 0 ? ',$failed 个失败' : ''}';
|
|
}
|
|
|
|
/// 清理前的确认弹窗
|
|
void confirmClearAll(BuildContext context) {
|
|
final total = externalFiles.length;
|
|
if (total == 0) {
|
|
statusMessage.value = '没有可清理的文件';
|
|
return;
|
|
}
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
backgroundColor: Get.isDarkMode ? Colors.grey[900] : Colors.white,
|
|
title: Text(
|
|
'清理全部文件',
|
|
style: TextStyle(color: Get.isDarkMode ? Colors.white : Colors.black),
|
|
),
|
|
content: Text(
|
|
'将删除列表中全部 $total 个文件,此操作不可撤销。',
|
|
style:
|
|
TextStyle(color: Get.isDarkMode ? Colors.white70 : Colors.black87),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('取消'),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
clearAllFiles();
|
|
},
|
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
|
child: const Text('全部清理'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 分享外部文件
|
|
Future<void> shareExternalFile(PlatformFile file) async {
|
|
try {
|
|
if (file.path != null) {
|
|
final fileToShare = File(file.path!);
|
|
if (await fileToShare.exists()) {
|
|
await Share.shareXFiles(
|
|
[XFile(file.path!)],
|
|
text: '分享Opus音频文件: ${file.name}',
|
|
);
|
|
statusMessage.value = '正在分享文件: ${file.name}';
|
|
} else {
|
|
statusMessage.value = '文件不存在: ${file.name}';
|
|
}
|
|
}
|
|
} catch (e) {
|
|
statusMessage.value = '分享文件失败: $e';
|
|
}
|
|
}
|
|
|
|
/// 显示文件操作底部弹窗
|
|
void showFileActionSheet(BuildContext context, PlatformFile file) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
backgroundColor: Get.isDarkMode ? Colors.grey[900] : Colors.white,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
|
),
|
|
builder: (context) => Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 20),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// 文件信息
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.audiotrack,
|
|
color: Get.isDarkMode ? Colors.white70 : Colors.black87,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
file.name,
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w500,
|
|
color: Get.isDarkMode ? Colors.white : Colors.black,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
Text(
|
|
'${(file.size / 1024).toStringAsFixed(2)} KB',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: Get.isDarkMode
|
|
? Colors.white60
|
|
: Colors.black54,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Divider(),
|
|
// 分享选项
|
|
ListTile(
|
|
leading: const Icon(Icons.share, color: Colors.blue),
|
|
title: const Text('分享文件'),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
shareExternalFile(file);
|
|
},
|
|
),
|
|
// 删除选项
|
|
ListTile(
|
|
leading: const Icon(Icons.delete, color: Colors.red),
|
|
title: const Text('删除文件', style: TextStyle(color: Colors.red)),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_showDeleteConfirmDialog(context, file);
|
|
},
|
|
),
|
|
// 取消选项
|
|
ListTile(
|
|
leading: const Icon(Icons.cancel),
|
|
title: const Text('取消'),
|
|
onTap: () => Navigator.pop(context),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 显示删除确认对话框
|
|
void _showDeleteConfirmDialog(BuildContext context, PlatformFile file) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
backgroundColor: Get.isDarkMode ? Colors.grey[900] : Colors.white,
|
|
title: Text(
|
|
'确认删除',
|
|
style: TextStyle(
|
|
color: Get.isDarkMode ? Colors.white : Colors.black,
|
|
),
|
|
),
|
|
content: Text(
|
|
'确定要删除文件 "${file.name}" 吗?\n此操作不可撤销。',
|
|
style: TextStyle(
|
|
color: Get.isDarkMode ? Colors.white70 : Colors.black87,
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('取消'),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
deleteExternalFile(file);
|
|
},
|
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
|
child: const Text('删除'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
}
|
|
|