feat(mobile): expand TTS voices and align App SDK

This commit is contained in:
2026-07-26 17:05:43 +08:00
parent 94ee29f59c
commit f57e0a53c6
18 changed files with 1171 additions and 419 deletions
+2
View File
@@ -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');