feat(mobile): expand TTS voices and align App SDK
This commit is contained in:
@@ -7,6 +7,7 @@ import { dirname, resolve } from 'node:path';
|
||||
|
||||
const mobileRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const readJson = async (path) => JSON.parse(await readFile(new URL(path, import.meta.url), 'utf8'));
|
||||
const hbuilderx515AppCompiler = '3.0.0-alpha-5010220260604001';
|
||||
|
||||
test('App 图标与启动图映射均可由预检脚本验证', () => {
|
||||
const output = execFileSync(process.execPath, ['scripts/verify-app-assets.mjs'], {
|
||||
@@ -24,6 +25,7 @@ test('App 平台编译器已声明且与其他 uni 包同版本,避免 build:a
|
||||
const appPlus = pkg.dependencies?.['@dcloudio/uni-app-plus'];
|
||||
|
||||
assert.ok(appPlus, '缺少 @dcloudio/uni-app-plus,build:app 会静默产出 H5 空壳');
|
||||
assert.equal(appPlus, hbuilderx515AppCompiler, 'App 编译链必须与当前 HBuilderX 5.15 调试基座匹配');
|
||||
assert.equal(appPlus, pkg.dependencies['@dcloudio/uni-app']);
|
||||
assert.equal(appPlus, pkg.devDependencies['@dcloudio/vite-plugin-uni']);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
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 () => {
|
||||
const source = await readFile(new URL('../src/services/mpeg-audio-duration.ts', import.meta.url), 'utf8');
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }
|
||||
});
|
||||
const runtimeModule = { exports: {} };
|
||||
vm.runInNewContext(compiled.outputText, { exports: runtimeModule.exports, module: runtimeModule });
|
||||
return runtimeModule.exports;
|
||||
};
|
||||
|
||||
/**
|
||||
* 造一个 Layer III 帧头。默认参数对应后端 TTS 实际产物:
|
||||
* MPEG2 / 24 kHz / 160 kbps / 单声道,帧长 480 字节、每帧 576 采样 = 24ms。
|
||||
*/
|
||||
const frame = ({ mpeg1 = false, bitrateIndex = 14, sampleRateIndex = 1, padding = 0 } = {}) => {
|
||||
const versionBits = mpeg1 ? 3 : 2;
|
||||
const header = Uint8Array.from([
|
||||
0xff,
|
||||
0xe0 | (versionBits << 3) | (1 << 1), // layer III, 无 CRC
|
||||
(bitrateIndex << 4) | (sampleRateIndex << 2) | (padding << 1),
|
||||
0xc0 // 单声道
|
||||
]);
|
||||
const samples = mpeg1 ? 1152 : 576;
|
||||
const bitrate = mpeg1
|
||||
? [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0][bitrateIndex]
|
||||
: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0][bitrateIndex];
|
||||
const sampleRate = (mpeg1 ? [44100, 48000, 32000] : [22050, 24000, 16000])[sampleRateIndex];
|
||||
const length = Math.floor((samples / 8) * bitrate * 1000 / sampleRate) + padding;
|
||||
// free-format(bitrate 0)算出的帧长为 0,仍要放得下 4 字节帧头供解析器判定。
|
||||
const body = new Uint8Array(Math.max(4, length));
|
||||
body.set(header);
|
||||
return body;
|
||||
};
|
||||
|
||||
const concat = (...chunks) => {
|
||||
const total = chunks.reduce((sum, c) => sum + c.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let at = 0;
|
||||
for (const c of chunks) {
|
||||
out.set(c, at);
|
||||
at += c.length;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/** ID3v2 头:尺寸为 4 个 synchsafe 字节,必须被跳过才能对上首帧。 */
|
||||
const id3v2 = (payloadSize) => {
|
||||
const head = new Uint8Array(10 + payloadSize);
|
||||
head.set([0x49, 0x44, 0x33, 0x03, 0x00, 0x00]);
|
||||
head[6] = (payloadSize >> 21) & 0x7f;
|
||||
head[7] = (payloadSize >> 14) & 0x7f;
|
||||
head[8] = (payloadSize >> 7) & 0x7f;
|
||||
head[9] = payloadSize & 0x7f;
|
||||
return head;
|
||||
};
|
||||
|
||||
test('累加帧数得出时长', async () => {
|
||||
const { estimateMpegAudioDuration } = await loadModule();
|
||||
const bytes = concat(...Array.from({ length: 10 }, () => frame()));
|
||||
assert.equal(Number(estimateMpegAudioDuration(bytes).toFixed(3)), 0.24);
|
||||
});
|
||||
|
||||
test('跳过 ID3v2 标签,不把标签字节当帧', async () => {
|
||||
const { estimateMpegAudioDuration } = await loadModule();
|
||||
// 标签内塞入 0xff,若未按尺寸跳过就会被误判成帧头。
|
||||
const tag = id3v2(81);
|
||||
tag.fill(0xff, 10);
|
||||
const bytes = concat(tag, ...Array.from({ length: 171 }, () => frame()));
|
||||
assert.equal(Number(estimateMpegAudioDuration(bytes).toFixed(3)), 4.104);
|
||||
});
|
||||
|
||||
test('VBR:逐帧累加而非按首帧比特率外推', async () => {
|
||||
const { estimateMpegAudioDuration } = await loadModule();
|
||||
// 帧长不同但每帧采样数相同,时长只取决于帧数。
|
||||
const bytes = concat(frame({ bitrateIndex: 14 }), frame({ bitrateIndex: 6 }), frame({ bitrateIndex: 1 }));
|
||||
assert.equal(Number(estimateMpegAudioDuration(bytes).toFixed(3)), 0.072);
|
||||
});
|
||||
|
||||
test('MPEG1 每帧 1152 采样', async () => {
|
||||
const { estimateMpegAudioDuration } = await loadModule();
|
||||
const bytes = concat(...Array.from({ length: 5 }, () => frame({ mpeg1: true, bitrateIndex: 10, sampleRateIndex: 0 })));
|
||||
assert.equal(Number(estimateMpegAudioDuration(bytes).toFixed(4)), 0.1306);
|
||||
});
|
||||
|
||||
test('padding 位计入帧长,否则后续帧全部错位', async () => {
|
||||
const { estimateMpegAudioDuration } = await loadModule();
|
||||
const bytes = concat(frame({ padding: 1 }), frame(), frame());
|
||||
assert.equal(Number(estimateMpegAudioDuration(bytes).toFixed(3)), 0.072);
|
||||
});
|
||||
|
||||
test('尾部非帧数据(如 ID3v1)不虚增时长', async () => {
|
||||
const { estimateMpegAudioDuration } = await loadModule();
|
||||
const tail = new Uint8Array(128).fill(0xff);
|
||||
const bytes = concat(frame(), frame(), tail);
|
||||
assert.equal(Number(estimateMpegAudioDuration(bytes).toFixed(3)), 0.048);
|
||||
});
|
||||
|
||||
test('没有任何合法帧时返回 null', async () => {
|
||||
const { estimateMpegAudioDuration } = await loadModule();
|
||||
assert.equal(estimateMpegAudioDuration(new Uint8Array(0)), null);
|
||||
assert.equal(estimateMpegAudioDuration(Uint8Array.from([0x00, 0x01, 0x02])), null);
|
||||
// free-format(bitrateIndex 0)无法据此算帧长,应判为不可解析。
|
||||
assert.equal(estimateMpegAudioDuration(frame({ bitrateIndex: 0 })), null);
|
||||
});
|
||||
@@ -26,10 +26,11 @@ const preferenceRuntime = async (storage = new Map()) => {
|
||||
test('播报音色按租户和账号隔离保存,非法值回退跟随场景', async () => {
|
||||
const storage = new Map();
|
||||
const runtime = await preferenceRuntime(storage);
|
||||
const extendedVoice = 'qwen-audio-3.0-tts-flash-longyingmuyu';
|
||||
|
||||
assert.equal(runtime.getTtsVoicePreference('000000', '13800000000'), '');
|
||||
assert.equal(runtime.setTtsVoicePreference('000000', '13800000000', 'longanhuan_v3.6'), 'longanhuan_v3.6');
|
||||
assert.equal(runtime.getTtsVoicePreference('000000', '13800000000'), 'longanhuan_v3.6');
|
||||
assert.equal(runtime.setTtsVoicePreference('000000', '13800000000', extendedVoice), extendedVoice);
|
||||
assert.equal(runtime.getTtsVoicePreference('000000', '13800000000'), extendedVoice);
|
||||
assert.equal(runtime.getTtsVoicePreference('000000', '13900000000'), '');
|
||||
assert.equal(runtime.setTtsVoicePreference('000000', '13800000000', 'untrusted-voice'), '');
|
||||
assert.equal(runtime.setTtsDialectPreference('000000', '13800000000', 'shanghai'), 'shanghai');
|
||||
|
||||
Reference in New Issue
Block a user