Files
prop-ai-hr/mobile-uni/tests/speech-audio-source.test.mjs
admin 94ee29f59c fix(speech): write native TTS temp file as binary and clean it up
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 无法审阅。
2026-07-26 12:10:50 +08:00

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');
});