Skip to content

Commit e45fdcf

Browse files
committed
fix(speech): add qwen-audio TTS voices and 404/411 hints
Token Plan defaults to qwen-audio TTS but had no local voice catalog. Also hint when the model is missing on the plan or the voice mismatches.
1 parent a0a0291 commit e45fdcf

5 files changed

Lines changed: 350 additions & 34 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { BailianError, DOCS_HOSTS } from "bailian-cli-core";
2+
import { VOICE_TTS_PAGE } from "bailian-cli-runtime";
3+
4+
const QWEN_AUDIO_TTS_VOICE_DOC = `${DOCS_HOSTS.cn}/qwen-audio-tts-voice-list`;
5+
6+
/** model in current endpoint/plan is not available (e.g. CosyVoice in Token Plan). */
7+
export function isTtsModelNotFound(error: unknown): boolean {
8+
if (!(error instanceof BailianError)) return false;
9+
const apiCode = error.api?.apiCode ?? "";
10+
if (/\bModelNotFound\b/i.test(apiCode)) return true;
11+
return /\bModelNotFound\b|model[^\n]{0,80}?\b(not\s+exist|does\s+not\s+exist|not\s+found)\b/i.test(
12+
error.message,
13+
);
14+
}
15+
16+
/** Voice mismatch (Engine error [411] as shown in the official documentation). */
17+
export function isTtsVoiceMismatch(error: unknown): boolean {
18+
if (!(error instanceof BailianError)) return false;
19+
return /Engine\s+error\s*\[411\]/i.test(error.message);
20+
}
21+
22+
function voiceDocsUrl(model: string): string {
23+
if (model.startsWith("qwen-audio-")) return QWEN_AUDIO_TTS_VOICE_DOC;
24+
return VOICE_TTS_PAGE;
25+
}
26+
27+
export function speechSynthesizeModelNotFoundHint(binName: string, model: string): string {
28+
return [
29+
`Model "${model}" may not be available on the current endpoint or plan.`,
30+
`Try the Profile default speech model, or check the Token Plan model list.`,
31+
`List built-in voices: \`${binName} speech synthesize --list-voices --model ${model}\`.`,
32+
].join(" ");
33+
}
34+
35+
export function speechSynthesizeVoiceMismatchHint(binName: string, model: string): string {
36+
return [
37+
`The voice may not match model "${model}".`,
38+
`Run \`${binName} speech synthesize --list-voices --model ${model}\` for built-in voices.`,
39+
`Official voice list: ${voiceDocsUrl(model)}`,
40+
].join(" ");
41+
}
42+
43+
/**
44+
* Keep the server message; attach a hint only for known TTS failure shapes.
45+
* Does not overwrite an existing hint.
46+
*/
47+
export function rethrowWithSpeechSynthesizeHint(
48+
error: unknown,
49+
options: { binName: string; model: string },
50+
): never {
51+
if (!(error instanceof BailianError) || error.hint) {
52+
throw error;
53+
}
54+
55+
const { binName, model } = options;
56+
let hint: string | undefined;
57+
if (isTtsModelNotFound(error)) {
58+
hint = speechSynthesizeModelNotFoundHint(binName, model);
59+
} else if (isTtsVoiceMismatch(error)) {
60+
hint = speechSynthesizeVoiceMismatchHint(binName, model);
61+
}
62+
63+
if (!hint) throw error;
64+
65+
// 透传原 cause,不把当前 BailianError 再套一层,避免 text/JSON 重复同一句 message
66+
throw new BailianError(error.message, error.exitCode, hint, {
67+
cause: error.cause,
68+
api: error.api,
69+
rawResponse: error.rawResponse,
70+
});
71+
}

packages/commands/src/commands/speech/synthesize.ts

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@ import { downloadFile } from "bailian-cli-runtime";
2424
import { runConcurrent, downloadParallel, getConcurrency } from "bailian-cli-runtime";
2525
import { emitResult, emitBare } from "bailian-cli-runtime";
2626
import { VOICE_TTS_PAGE } from "bailian-cli-runtime";
27+
import { rethrowWithSpeechSynthesizeHint } from "./synthesize-hint.ts";
2728

2829
const COSYVOICE_CLONE_DESIGN_DOC = `${DOCS_HOSTS.cn}/cosyvoice-clone-design-api`;
30+
const QWEN_AUDIO_TTS_VOICE_DOC = `${DOCS_HOSTS.cn}/qwen-audio-tts-voice-list`;
2931

3032
interface VoiceEntry {
3133
voice: string;
@@ -34,6 +36,32 @@ interface VoiceEntry {
3436
lang: string;
3537
}
3638

39+
// qwen-audio-3.0-tts-plus system voices (official docs list only these two)
40+
const QWEN_AUDIO_30_TTS_PLUS_VOICES: VoiceEntry[] = [
41+
{ voice: "longanlingxin", name: "龙安灵心", desc: "知心温暖音", lang: "中文/英文" },
42+
{ voice: "longanlufeng", name: "龙安鲁风", desc: "明亮开朗音", lang: "中文/英文" },
43+
];
44+
45+
// qwen-audio-3.0-tts-flash system voices
46+
const QWEN_AUDIO_30_TTS_FLASH_VOICES: VoiceEntry[] = [
47+
// Social companion (premium Chinese)
48+
{ voice: "longanfengyue", name: "龙安风悦", desc: "自然亲切音", lang: "中文/英文" },
49+
{ voice: "longanyuanfei", name: "龙安元妃", desc: "高傲妃子音", lang: "中文/英文" },
50+
{ voice: "longanlingxi", name: "龙安灵希", desc: "可爱甜美音", lang: "中文/英文" },
51+
{ voice: "longanxiaoxin", name: "龙安小昕", desc: "亲切活泼音", lang: "中文/英文" },
52+
{ voice: "longanhuan_v3.6", name: "龙安欢", desc: "欢脱元气女", lang: "中文/英文" },
53+
// Kids / toys (premium children)
54+
{ voice: "longjielidou_v3.6", name: "龙杰力豆", desc: "天真男童", lang: "中文/英文" },
55+
{ voice: "longpaopao_v3.6", name: "龙泡泡", desc: "软糯可爱音", lang: "中文/英文" },
56+
// Character / game (premium Chinese)
57+
{ voice: "longhuohuo_v3.6", name: "龙火火", desc: "顽皮少年音", lang: "中文/英文" },
58+
{ voice: "longchuanshu_v3.6", name: "龙川叔", desc: "川普大叔音", lang: "中文/英文" },
59+
// Social companion / assistant (premium English)
60+
{ voice: "loongmary", name: "loongmary", desc: "温暖英音", lang: "英文" },
61+
{ voice: "loongeva_v3.6", name: "loongeva", desc: "高智美音", lang: "英文" },
62+
{ voice: "loongjohn", name: "loongJohn", desc: "沉稳亲切美音", lang: "英文" },
63+
];
64+
3765
// cosyvoice-v3-flash system voices
3866
const COSYVOICE_V3_FLASH_VOICES: VoiceEntry[] = [
3967
// 社交陪伴
@@ -111,18 +139,27 @@ const COSYVOICE_V3_FLASH_VOICES: VoiceEntry[] = [
111139
];
112140

113141
const MODEL_VOICES: Record<string, VoiceEntry[]> = {
142+
"qwen-audio-3.0-tts-plus": QWEN_AUDIO_30_TTS_PLUS_VOICES,
143+
"qwen-audio-3.0-tts-flash": QWEN_AUDIO_30_TTS_FLASH_VOICES,
114144
"cosyvoice-v3-flash": COSYVOICE_V3_FLASH_VOICES,
115145
"cosyvoice-v3-plus": COSYVOICE_V3_FLASH_VOICES,
116146
"cosyvoice-v3.5-flash": [],
117147
"cosyvoice-v3.5-plus": [],
118148
"cosyvoice-v2": [],
119149
};
120150

151+
/** Official voice-list docs URL for the model family. */
152+
function voiceDocsUrl(model: string): string {
153+
if (model.startsWith("qwen-audio-")) return QWEN_AUDIO_TTS_VOICE_DOC;
154+
return VOICE_TTS_PAGE;
155+
}
156+
121157
function printVoiceList(model: string): void {
122158
const voices = MODEL_VOICES[model];
159+
const docsUrl = voiceDocsUrl(model);
123160
if (!voices) {
124161
process.stdout.write(`No built-in voice list available for model: ${model}\n`);
125-
process.stdout.write(`Browse voices in the console: ${VOICE_TTS_PAGE}\n`);
162+
process.stdout.write(`See official voice list: ${docsUrl}\n`);
126163
return;
127164
}
128165
if (voices.length === 0) {
@@ -138,11 +175,13 @@ function printVoiceList(model: string): void {
138175
`${col("VOICE ID", 26)} ${col("NAME", 10)} ${col("DESCRIPTION", 16)} LANGUAGE\n`,
139176
);
140177
process.stdout.write(`${"-".repeat(26)} ${"-".repeat(10)} ${"-".repeat(16)} ${"-".repeat(12)}\n`);
141-
for (const v of voices) {
142-
process.stdout.write(`${col(v.voice, 26)} ${col(v.name, 10)} ${col(v.desc, 16)} ${v.lang}\n`);
178+
for (const entry of voices) {
179+
process.stdout.write(
180+
`${col(entry.voice, 26)} ${col(entry.name, 10)} ${col(entry.desc, 16)} ${entry.lang}\n`,
181+
);
143182
}
144183
process.stdout.write(`\nTotal: ${voices.length} voices\n`);
145-
process.stdout.write(`Preview and browse more voices in the console: \n${VOICE_TTS_PAGE}\n`);
184+
process.stdout.write(`Preview and browse more voices: \n${docsUrl}\n`);
146185
}
147186

148187
const SYNTHESIZE_FLAGS = {
@@ -177,9 +216,9 @@ const SYNTHESIZE_FLAGS = {
177216
valueHint: "<voice>",
178217
description: {
179218
"en-US":
180-
"Voice ID. Use --list-voices to see built-in voices for cosyvoice-v3-flash; for v3.5-flash provide a clone/design voice ID",
219+
"Voice ID. Use --list-voices for the selected model (e.g. qwen-audio-3.0-tts-plus/flash, cosyvoice-v3-flash); for v3.5-flash provide a clone/design voice ID",
181220
"zh-CN":
182-
"音色 ID。使用 --list-voices 查看 cosyvoice-v3-flash 的内置音色;使用 v3.5-flash 时需提供复刻/设计音色 ID",
221+
"音色 ID。使用 --list-voices 查看所选模型的内置音色(如 qwen-audio-3.0-tts-plus/flash、cosyvoice-v3-flash;使用 v3.5-flash 时需提供复刻/设计音色 ID",
183222
},
184223
},
185224
listVoices: {
@@ -290,10 +329,11 @@ export default defineCommand({
290329
usageArgs: "--text <text> [flags]",
291330
flags: SYNTHESIZE_FLAGS,
292331
exampleArgs: [
332+
"--list-voices --model qwen-audio-3.0-tts-plus",
293333
"--list-voices --model cosyvoice-v3-flash",
294334
{
295-
"en-US": '--text "Hello, I am Qwen" --voice <voice_id>',
296-
"zh-CN": '--text "你好,我是通义千问" --voice <voice_id>',
335+
"en-US": '--text "Hello, I am Qwen" --model qwen-audio-3.0-tts-plus --voice longanlingxin',
336+
"zh-CN": '--text "你好,我是通义千问" --model qwen-audio-3.0-tts-plus --voice longanlingxin',
297337
},
298338
{
299339
"en-US": '--text "Hello world" --voice <voice_id> --language en',
@@ -398,10 +438,17 @@ export default defineCommand({
398438
process.stderr.write(`[Model: ${model}] [Voice: ${voice}]\n`);
399439
}
400440

401-
if (useStream) {
402-
await handleStreamMode(ctx.client, settings, body, flags, format);
403-
} else {
404-
await handleNonStreamMode(ctx.client, settings, body, flags, format);
441+
try {
442+
if (useStream) {
443+
await handleStreamMode(ctx.client, settings, body, flags, format);
444+
} else {
445+
await handleNonStreamMode(ctx.client, settings, body, flags, format);
446+
}
447+
} catch (error) {
448+
rethrowWithSpeechSynthesizeHint(error, {
449+
binName: ctx.identity.binName,
450+
model,
451+
});
405452
}
406453
},
407454
});

packages/commands/tests/e2e/speech-list-voices.e2e.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,33 @@ describe("e2e: speech list-voices", () => {
2626
expect(exitCode, stderr).toBe(0);
2727
expect(stderr).toMatch(/recognize|--url|audio|model/i);
2828
});
29+
30+
// --list-voices reads the local catalog; no API key needed
31+
test("【qwen-audio-3.0-tts-plus】获取音色列表", async () => {
32+
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
33+
"speech",
34+
"synthesize",
35+
"--list-voices",
36+
"--model",
37+
"qwen-audio-3.0-tts-plus",
38+
]);
39+
expect(exitCode, stderr).toBe(0);
40+
expect(stdout).toContain("longanlingxin");
41+
expect(stdout).toContain("longanlufeng");
42+
});
43+
44+
test("【qwen-audio-3.0-tts-flash】获取音色列表", async () => {
45+
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
46+
"speech",
47+
"synthesize",
48+
"--list-voices",
49+
"--model",
50+
"qwen-audio-3.0-tts-flash",
51+
]);
52+
expect(exitCode, stderr).toBe(0);
53+
expect(stdout).toContain("longanfengyue");
54+
expect(stdout).toContain("loongjohn");
55+
});
2956
});
3057

3158
describe.skipIf(!isDashScopeE2EReady())("e2e: speech list-voices", () => {

0 commit comments

Comments
 (0)