App-Plus 的 plus.io FileWriter 只接受字符串:write 写文本,writeAsBinary 收
base64。原先传 Blob 会被原生桥接序列化成空内容,落地 0 字节且照样回调
onwriteend,InnerAudioContext 静默播完、不报错,表现为"界面正常但没有声音"。
改用 writeAsBinary 并在 onwriteend 里复核 size > 0,让写盘失败能被暴露。
清理侧同一处存在文件泄漏:播放要 convertLocalFileSystemURL 转出的 file://
绝对路径,而 resolveLocalFileSystemURL 删除只认 `_doc/xxx.mp3` 相对路径。
原先只回传前者,拿它去 resolve 解析不到,remove() 从不执行,且 catch 静默,
临时 mp3 逐句堆积。改为返回 { playbackUrl, plusPath } 两个路径,清理失败加告警。
真机验证(Android 10 / io.dcloud.HBuilder):落盘 80251 字节,ID3+LAME 头,
MPEG layer III 160 kbps 24 kHz 单声道,播完文件自动删除。
顺带把测试里 MP3 头字面量的真实 NUL 字节改为 \x00 转义(运行时字节不变),
此前 git 将该测试文件判为二进制,diff 无法审阅。
169 lines
6.2 KiB
JavaScript
169 lines
6.2 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { readFile } from 'node:fs/promises';
|
|
import test from 'node:test';
|
|
import vm from 'node:vm';
|
|
import ts from 'typescript';
|
|
|
|
const loadModule = async (sandbox = {}) => {
|
|
const service = await readFile(new URL('../src/services/speech-audio-source.ts', import.meta.url), 'utf8');
|
|
const compiled = ts.transpileModule(service, {
|
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }
|
|
});
|
|
const runtimeModule = { exports: {} };
|
|
vm.runInNewContext(compiled.outputText, {
|
|
exports: runtimeModule.exports,
|
|
module: runtimeModule,
|
|
atob: (value) => Buffer.from(value, 'base64').toString('binary'),
|
|
Blob,
|
|
URL,
|
|
Date,
|
|
...sandbox
|
|
});
|
|
return runtimeModule.exports;
|
|
};
|
|
|
|
// 'ID3' 是 mp3 文件头标识,base64 为 SUQz
|
|
const MP3_DATA_URI = `data:audio/mp3;base64,${Buffer.from('ID3\x04\x00\x00').toString('base64')}`;
|
|
|
|
test('识别音频 data: URI,拒绝 http URL 与非音频 data URI', async () => {
|
|
const runtime = await loadModule();
|
|
|
|
assert.equal(runtime.isDataAudioUri(MP3_DATA_URI), true);
|
|
assert.equal(runtime.isDataAudioUri('data:audio/mpeg;base64,SUQz'), true);
|
|
assert.equal(runtime.isDataAudioUri('https://example.com/a.mp3'), false);
|
|
assert.equal(runtime.isDataAudioUri('data:image/png;base64,SUQz'), false);
|
|
assert.equal(runtime.isDataAudioUri(''), false);
|
|
});
|
|
|
|
test('解码 data: URI 为字节,保留 mime 类型', async () => {
|
|
const runtime = await loadModule();
|
|
|
|
const decoded = runtime.decodeDataAudioUri(MP3_DATA_URI);
|
|
|
|
assert.equal(decoded.mime, 'audio/mp3');
|
|
assert.deepEqual(Array.from(decoded.bytes.slice(0, 3)), [0x49, 0x44, 0x33]);
|
|
assert.equal(runtime.decodeDataAudioUri('data:audio/mp3;base64,!!!'), null);
|
|
assert.equal(runtime.decodeDataAudioUri('not-a-data-uri'), null);
|
|
});
|
|
|
|
test('H5 环境转换为 Blob URL 并可回收', async () => {
|
|
const created = [];
|
|
const fakeURL = {
|
|
createObjectURL: (blob) => {
|
|
created.push(blob);
|
|
return 'blob:mock-1';
|
|
}
|
|
};
|
|
const runtime = await loadModule({ URL: fakeURL });
|
|
|
|
const blobUrl = runtime.dataUriToBlobUrl(MP3_DATA_URI);
|
|
|
|
assert.equal(blobUrl, 'blob:mock-1');
|
|
assert.equal(created.length, 1);
|
|
assert.equal(created[0].type, 'audio/mp3');
|
|
assert.equal(created[0].size, 6);
|
|
});
|
|
|
|
// plus.io 的 FileWriter 只接受字符串。此前 mock 收 Blob 并断言 blob.type,
|
|
// 把错误契约固化成"通过":真机 writeAsBinary 从未被调用,落地 0 字节而单测全绿。
|
|
// 因此这里断言写入的是 base64 字符串本身,不是任何对象。
|
|
const nativePlus = (bytesWritten = (data) => Buffer.from(data, 'base64').length, writes = []) => ({
|
|
io: {
|
|
resolveLocalFileSystemURL: (path, ok) => {
|
|
assert.equal(path, '_doc/');
|
|
ok({
|
|
getFile: (name, flags, onFile) => {
|
|
assert.equal(flags.create, true);
|
|
assert.match(name, /^aihr-tts-\d+\.mp3$/);
|
|
let written = '';
|
|
onFile({
|
|
fullPath: `_doc/${name}`,
|
|
file: (onMeta) => onMeta({ size: bytesWritten(written) }),
|
|
createWriter: (onWriter) => onWriter({
|
|
onwriteend: null,
|
|
onerror: null,
|
|
writeAsBinary(data) {
|
|
written = data;
|
|
writes.push(data);
|
|
this.onwriteend();
|
|
}
|
|
})
|
|
});
|
|
}
|
|
});
|
|
},
|
|
convertLocalFileSystemURL: (path) => `file:///storage/emulated/0/${path}`
|
|
}
|
|
});
|
|
|
|
test('App 环境:base64 经 writeAsBinary 落盘后返回 file:// 播放路径', async () => {
|
|
const writes = [];
|
|
const runtime = await loadModule({ plus: nativePlus(undefined, writes) });
|
|
|
|
const temp = await runtime.writeNativeTempAudio(MP3_DATA_URI);
|
|
|
|
assert.match(temp.playbackUrl, /^file:\/\/\/storage\/emulated\/0\/_doc\/aihr-tts-\d+\.mp3$/);
|
|
// 清理必须拿相对路径:传 file:// 绝对路径 resolveLocalFileSystemURL 解析不到,文件会堆积。
|
|
assert.match(temp.plusPath, /^_doc\/aihr-tts-\d+\.mp3$/);
|
|
assert.equal(writes.length, 1);
|
|
assert.equal(typeof writes[0], 'string');
|
|
assert.equal(writes[0], MP3_DATA_URI.split(',')[1]);
|
|
assert.equal(runtime.hasNativeAudioFileSupport(), true);
|
|
});
|
|
|
|
test('App 环境:落盘 0 字节判为失败,不返回可播放路径', async () => {
|
|
// 生产真机曾写出 9 个 0 字节 mp3:onwriteend 正常回调、路径正常返回,
|
|
// InnerAudioContext 播空文件不报错,表现为界面正常但无声。必须在此拦掉。
|
|
const runtime = await loadModule({ plus: nativePlus(() => 0) });
|
|
|
|
assert.equal(await runtime.writeNativeTempAudio(MP3_DATA_URI), null);
|
|
});
|
|
|
|
test('清理临时音频:用相对路径解析并真的调用 remove()', async () => {
|
|
const resolved = [];
|
|
let removed = 0;
|
|
const runtime = await loadModule({
|
|
plus: {
|
|
io: {
|
|
resolveLocalFileSystemURL: (path, ok) => {
|
|
resolved.push(path);
|
|
ok({ remove: () => { removed += 1; } });
|
|
},
|
|
convertLocalFileSystemURL: (path) => `file:///storage/emulated/0/${path}`
|
|
}
|
|
}
|
|
});
|
|
|
|
runtime.removeNativeTempAudio('_doc/aihr-tts-123.mp3');
|
|
|
|
assert.deepEqual(resolved, ['_doc/aihr-tts-123.mp3']);
|
|
assert.equal(removed, 1);
|
|
});
|
|
|
|
test('提取 base64 载荷,拒绝空载荷与非音频 data URI', async () => {
|
|
const runtime = await loadModule();
|
|
|
|
const payload = runtime.extractAudioBase64(MP3_DATA_URI);
|
|
assert.equal(payload.mime, 'audio/mp3');
|
|
assert.equal(payload.base64, MP3_DATA_URI.split(',')[1]);
|
|
assert.equal(runtime.extractAudioBase64('data:image/png;base64,SUQz'), null);
|
|
assert.equal(runtime.extractAudioBase64('not-a-data-uri'), null);
|
|
});
|
|
|
|
test('App 环境:写盘失败或无 plus 环境时返回 null,清理尽力而为', async () => {
|
|
const failingPlus = {
|
|
io: {
|
|
resolveLocalFileSystemURL: (path, ok, fail) => fail(new Error('io error')),
|
|
convertLocalFileSystemURL: (path) => path
|
|
}
|
|
};
|
|
const runtime = await loadModule({ plus: failingPlus });
|
|
|
|
assert.equal(await runtime.writeNativeTempAudio(MP3_DATA_URI), null);
|
|
|
|
const webRuntime = await loadModule();
|
|
assert.equal(webRuntime.hasNativeAudioFileSupport(), false);
|
|
assert.equal(await webRuntime.writeNativeTempAudio(MP3_DATA_URI), null);
|
|
webRuntime.removeNativeTempAudio('file:///nonexistent.mp3');
|
|
});
|