Commit e939759f by ethanlamzs

修复wav音频文件在ios上的播放问题

1 parent b617e011
# 更新日志(CHANGELOG)
本文件记录每次功能迭代内容。
## 2026-11-05 音频播放稳定性修复(app-feature-2610-ios)
本次聚焦 `lib/services/player_service.dart`(H5 音频播放服务)的异常自愈重写,
解决线上 iOS 报 `WAVEAudioFile::OpenFromDataSource Failed` 后播放器永久卡死、
无法播放新音频的问题。对外方法签名、H5 消息结构(audioProgress/audioEnd)、
异常契约均保持不变。
### 问题根因(已核实 flutter_sound 9.30.0 源码)
- 插件 iOS 远程播放分支:native 异步下载后立即返回成功,下载回调忽略 NSError,
失败分支从不通知 Dart → `await startPlayer()` 永久悬挂且无任何异常;
- 悬挂持有插件内部锁,导致实例所有方法(stop/pause/resume/seek/close)全部死锁;
- 服务端音频格式标注混乱:URL/Content-Type 标 mp3,实际内容为 WAV、
MP3-in-WAV(wFormatTag=0x55)或 WAV 头长度字段损坏,均会导致 AVAudioPlayer 拒绝播放。
### 修复内容
1. **播放器异常自愈**:`startPlayer` 失败(抛异常/超时)→ 销毁重建播放器 →
用新实例重试一次 → 仍失败则复位为未初始化态并抛原始异常,
保证"底层报错后不影响下一次新播放";
2. **远程音频自主下载**:http(s) 音频先用 `dart:io HttpClient` 下载到本地临时文件再播放,
绕开插件远程分支缺陷(含幽灵播放隐患);下载带浏览器 UA、
Content-Type 校验(拦截防盗链/鉴权错误页)、空文件校验、半成品清理;
3. **全链路超时兜底**:下载/建连 10s、startPlayer/openPlayer 10s、
常规操作(暂停/恢复/进度/倍速/停止/关闭)3s,任何悬挂都能在有限时间内
放弃旧实例走重建,不再永久死锁;超时后旧实例接受泄漏;
4. **音频格式自动识别**:按文件头魔数(mp3/wav/ogg/flac/caf/m4a/aac)
修正临时文件扩展名,避免错误扩展名误导 AVAudioPlayer 选错解析器;
5. **WAV 头修复**:RIFF/data 长度字段与实际字节不符(流式占位 0xFFFFFFFF、
奇数未对齐等)时按实际字节重写规范头,正常文件不做修改;
6. **MP3-in-WAV 剥离**:识别 wFormatTag=0x55(MP3 数据套 WAV 容器,
AVAudioPlayer WAVE 解析器不支持)时剥离容器、另存为纯 MP3 播放;
7. **其他加固**:playId + 播放代次双重快照防陈旧 whenFinished 回调误杀新播放;
playAudio/stopAudio 串行锁防并发重建;临时文件生命周期管理
(换曲成功后删上一首、清理/复位时删当前);关键路径诊断日志
(下载状态/格式识别/WAV 头详情/失败文件头 16 字节)。
### 影响范围
- 仅修改 `lib/services/player_service.dart`,无新增依赖;
- Android/iOS 通用:远程音频统一走本地文件播放路径。
...@@ -171,6 +171,130 @@ class PlayerService { ...@@ -171,6 +171,130 @@ class PlayerService {
return ''; return '';
} }
/// 修复 WAV 文件头:部分服务端生成的 WAV 长度字段有误(流式占位 0xFFFFFFFF、
/// 长度与实际不符、奇数未对齐等),会导致 WAVEAudioFile::OpenFromDataSource Failed。
/// 此处按实际字节数重写 RIFF/data 长度字段,fmt chunk 原样保留;头部正常的文件不做任何修改
Future<void> _repairWavHeader(File file) async {
try {
final bytes = await file.readAsBytes();
if (bytes.length < 44) {
return;
}
String tag(int s, int e) => String.fromCharCodes(bytes.sublist(s, e));
if (tag(0, 4) != 'RIFF' || tag(8, 12) != 'WAVE') {
return;
}
int readU32(int off) => bytes[off] | (bytes[off + 1] << 8) | (bytes[off + 2] << 16) | (bytes[off + 3] << 24);
List<int> u32le(int v) => [v & 0xFF, (v >> 8) & 0xFF, (v >> 16) & 0xFF, (v >> 24) & 0xFF];
// 遍历 chunk,定位 fmt 与 data
List<int>? fmtChunk;
int? dataOffset;
int? dataDeclared;
bool broken = false;
int pos = 12;
while (pos + 8 <= bytes.length) {
final id = tag(pos, pos + 4);
final size = readU32(pos + 4);
if (id == 'fmt ') {
var end = pos + 8 + size;
if (end > bytes.length) {
end = bytes.length;
broken = true;
}
fmtChunk = bytes.sublist(pos, end);
} else if (id == 'data') {
dataOffset = pos + 8;
dataDeclared = size;
break;
}
// chunk 数据按偶数字节对齐
pos += 8 + size + (size % 2);
}
if (fmtChunk == null || dataOffset == null) {
return;
}
final payloadLen = bytes.length - dataOffset;
final riffDeclared = readU32(4);
// 头部长度全部与实际一致则无需修复
if (!broken && riffDeclared == bytes.length - 8 && dataDeclared == payloadLen) {
return;
}
// 重写为规范头:RIFF/WAVE + 原 fmt chunk + data + 实际长度(奇数补 0 对齐)
final pad = payloadLen % 2;
final total = 4 + fmtChunk.length + 8 + payloadLen + pad;
final out = <int>[
...'RIFF'.codeUnits,
...u32le(total),
...'WAVE'.codeUnits,
...fmtChunk,
...'data'.codeUnits,
...u32le(payloadLen),
];
out.addAll(bytes.sublist(dataOffset));
if (pad == 1) {
out.add(0);
}
await file.writeAsBytes(out);
print('WAV 头修复: riff $riffDeclared -> $total, data $dataDeclared -> $payloadLen');
} catch (e) {
print(e);
}
}
/// 识别"MP3 数据套 WAV 容器"的文件(wFormatTag=0x55):
/// 部分服务端把 MP3 流直接套上 WAV 头,而 AVAudioPlayer 的 WAVE 解析器
/// 不支持该格式(WAVEAudioFile::OpenFromDataSource Failed)。
/// 此处剥离 RIFF 容器、取出 MP3 数据另存为 .mp3;非该格式返回 null
Future<String?> _extractMp3FromWav(File wavFile) async {
try {
final bytes = await wavFile.readAsBytes();
if (bytes.length < 44) {
return null;
}
String tag(int s, int e) => String.fromCharCodes(bytes.sublist(s, e));
if (tag(0, 4) != 'RIFF' || tag(8, 12) != 'WAVE') {
return null;
}
int readU32(int off) => bytes[off] | (bytes[off + 1] << 8) | (bytes[off + 2] << 16) | (bytes[off + 3] << 24);
// 遍历 chunk,读取 fmt 的 wFormatTag 并定位 data
int? fmtTag;
int? dataOffset;
int pos = 12;
while (pos + 8 <= bytes.length) {
final id = tag(pos, pos + 4);
final size = readU32(pos + 4);
if (id == 'fmt ') {
if (pos + 10 <= bytes.length) {
fmtTag = bytes[pos + 8] | (bytes[pos + 9] << 8);
}
} else if (id == 'data') {
dataOffset = pos + 8;
break;
}
pos += 8 + size + (size % 2);
}
// 仅处理 MP3-in-WAV(wFormatTag = 0x55)
if (fmtTag != 0x55 || dataOffset == null || dataOffset >= bytes.length) {
return null;
}
final mp3File = File('${Directory.systemTemp.path}/appframe_audio_${DateTime.now().millisecondsSinceEpoch}.mp3');
await mp3File.writeAsBytes(bytes.sublist(dataOffset));
await wavFile.delete();
print('MP3-in-WAV 识别: wFormatTag=0x55, 已剥离 WAV 容器 -> ${mp3File.path} (${bytes.length - dataOffset} bytes)');
return mp3File.path;
} catch (e) {
print(e);
return null;
}
}
/// best-effort 删除文件,永不抛异常 /// best-effort 删除文件,永不抛异常
Future<void> _deleteFileQuietly(String? path) async { Future<void> _deleteFileQuietly(String? path) async {
if (path == null || path.isEmpty) { if (path == null || path.isEmpty) {
...@@ -229,6 +353,16 @@ class PlayerService { ...@@ -229,6 +353,16 @@ class PlayerService {
tempFile = await tempFile.rename(newPath); tempFile = await tempFile.rename(newPath);
print('格式识别: 实际内容为 $sniffedExt(原扩展名 "$curExt"),已重命名 -> $newPath'); print('格式识别: 实际内容为 $sniffedExt(原扩展名 "$curExt"),已重命名 -> $newPath');
} }
// WAV 容器处理:先识别"MP3 套 WAV 容器"(AVAudioPlayer 不支持)并剥离为纯 MP3,
// 否则做 WAV 头长度字段修复(对头部正常的文件不做任何修改)
if (sniffedExt == 'wav') {
final mp3Path = await _extractMp3FromWav(tempFile);
if (mp3Path != null) {
tempFile = File(mp3Path);
} else {
await _repairWavHeader(tempFile);
}
}
return tempFile.path; return tempFile.path;
} catch (e) { } catch (e) {
// 清理下载半成品 // 清理下载半成品
...@@ -407,6 +541,13 @@ class PlayerService { ...@@ -407,6 +541,13 @@ class PlayerService {
final head = await file.openRead(0, 16).first; final head = await file.openRead(0, 16).first;
final hex = head.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' '); final hex = head.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
print('下载文件诊断: size=$size bytes, 前16字节=$hex'); print('下载文件诊断: size=$size bytes, 前16字节=$hex');
// WAV 头详情(format: 1=PCM 3=FLOAT 6=A-law 7=μ-law 0xFFFE=EXTENSIBLE)
if (downloadedPath.endsWith('.wav') && size >= 36) {
final fmt = await file.openRead(20, 36).first;
int u16(int o) => fmt[o] | (fmt[o + 1] << 8);
int u32v(int o) => fmt[o] | (fmt[o + 1] << 8) | (fmt[o + 2] << 16) | (fmt[o + 3] << 24);
print('WAV 头详情: format=${u16(0)}, channels=${u16(2)}, sampleRate=${u32v(4)}, bits=${u16(14)}');
}
} catch (e3) { } catch (e3) {
print(e3); print(e3);
} }
......
Styling with Markdown is supported
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!