test: add qwen tts adapter and voice preference reproducers

This commit is contained in:
2026-07-23 22:21:52 +08:00
parent bf82ff9691
commit 39ff6f6ceb
2 changed files with 95 additions and 0 deletions
@@ -60,4 +60,44 @@ class AihrSpeechServiceTest {
assertThrows(IllegalArgumentException.class, () -> AihrSpeechService.buildRequestBody(
mapper, runtime, "测试", null, invalid));
}
@Test
void buildsDashScopeQwenTtsRequestAndNormalizesWorkspaceEndpoint() {
ObjectMapper mapper = new ObjectMapper();
SpeechModel runtime = new SpeechModel(
"dashscope",
"qwen-audio-3.0-tts-flash",
"",
""
);
var body = AihrSpeechService.buildDashScopeRequestBody(
mapper, runtime, "请先确认业主的诉求。",
new VoiceProfile("mentor", null, 0.88, "warm", "mandarin"));
assertEquals("qwen-audio-3.0-tts-flash", body.path("model").asText());
assertEquals("请先确认业主的诉求。", body.path("input").path("text").asText());
assertEquals("longanhuan_v3.6", body.path("input").path("voice").asText());
assertEquals("mp3", body.path("input").path("format").asText());
assertEquals(24000, body.path("input").path("sample_rate").asInt());
assertEquals(0.88, body.path("input").path("rate").asDouble());
assertEquals(
"https://ws-example.cn-beijing.maas.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer",
AihrSpeechService.dashScopeSynthesisUri("https://ws-example.cn-beijing.maas.aliyuncs.com/compatible-mode/v1").toString()
);
}
@Test
void extractsDashScopeAudioUrlOnlyWhenPresent() throws Exception {
ObjectMapper mapper = new ObjectMapper();
String response = """
{"output":{"audio":{"url":"http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/audio.mp3?sig=ok"}}}
""";
assertEquals(
"http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/audio.mp3?sig=ok",
AihrSpeechService.dashScopeAudioUrl(mapper, response).orElseThrow()
);
assertTrue(AihrSpeechService.dashScopeAudioUrl(mapper, "{\"output\":{}}").isEmpty());
}
}
@@ -0,0 +1,55 @@
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 source = (path) => readFile(new URL(path, import.meta.url), 'utf8');
const preferenceRuntime = async (storage = new Map()) => {
const service = await source('../src/services/tts-voice-preference.ts');
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,
uni: {
getStorageSync: (key) => storage.get(key),
setStorageSync: (key, value) => storage.set(key, value)
}
});
return runtimeModule.exports;
};
test('播报音色按租户和账号隔离保存,非法值回退跟随场景', async () => {
const storage = new Map();
const runtime = await preferenceRuntime(storage);
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.getTtsVoicePreference('000000', '13900000000'), '');
assert.equal(runtime.setTtsVoicePreference('000000', '13800000000', 'untrusted-voice'), '');
});
test('全局选择只覆盖 voice,保留场景角色、情绪、语速和方言', async () => {
const runtime = await preferenceRuntime();
const profile = { role: 'customer', emotion: 'intense', speed: 1.12, dialect: 'sichuanese' };
assert.deepEqual(
runtime.applyTtsVoicePreference(profile, 'longanhuan_v3.6'),
{ ...profile, voice: 'longanhuan_v3.6' }
);
assert.deepEqual(runtime.applyTtsVoicePreference(profile, ''), profile);
});
test('语音播放链路在合成前统一注入全局音色,且缓存键包含处理后的 profile', async () => {
const sourceText = await source('../src/services/speech.ts');
assert.match(sourceText, /applyTtsVoicePreference/);
assert.match(sourceText, /getTtsVoicePreference/);
assert.match(sourceText, /cacheKey\(value, effectiveProfile, practiceContext\)/);
assert.match(sourceText, /synthesizeSpeech\(value, effectiveProfile, practiceContext\)/);
});