Files
prop-ai-hr/mobile-uni/tests/speech-audio-source.test.mjs
T
admin ab3af277c1 fix(speech): convert base64 TTS data URI before playback per platform
后端 TTS 以 data: URI 返回 base64 mp3(生产实证为合法 LAME mp3),但
App-Plus 的 InnerAudioContext 不支持 data: URI,iOS Safari/微信 webview
也常静默失败,统一报"语音播放失败"。播放前按平台转换:H5 转 Blob URL,
App 端经 plus.io 落 _doc/ 临时文件后播放 file:// 路径,播完清理;Blob 不可
用时退回原始 data: URI。竞态守卫与缓存语义保持不变,158 单测全绿。
2026-07-25 20:37:30 +08:00

120 lines
4.0 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').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);
});
test('App 环境:base64 落临时文件后返回 file:// 播放路径', async () => {
const writes = [];
const plus = {
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$/);
onFile({
fullPath: `_doc/${name}`,
createWriter: (onWriter) => onWriter({
onwriteend: null,
onerror: null,
write(blob) {
writes.push(blob);
this.onwriteend();
}
})
});
}
});
},
convertLocalFileSystemURL: (path) => `file:///storage/emulated/0/${path}`
}
};
const runtime = await loadModule({ plus });
const fileUrl = await runtime.writeNativeTempAudio(MP3_DATA_URI);
assert.match(fileUrl, /^file:\/\/\/storage\/emulated\/0\/_doc\/aihr-tts-\d+\.mp3$/);
assert.equal(writes.length, 1);
assert.equal(writes[0].type, 'audio/mp3');
assert.equal(runtime.hasNativeAudioFileSupport(), true);
});
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');
});