From e45fdcfa86549f08445123b694dee9c227a28350 Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Thu, 17 Sep 2026 16:36:55 +0800 Subject: [PATCH 1/3] 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. --- .../src/commands/speech/synthesize-hint.ts | 71 ++++++++ .../src/commands/speech/synthesize.ts | 71 ++++++-- .../tests/e2e/speech-list-voices.e2e.test.ts | 27 +++ .../tests/speech-synthesize-hint.test.ts | 167 ++++++++++++++++++ skills/bailian-gen/reference/speech.md | 48 ++--- 5 files changed, 350 insertions(+), 34 deletions(-) create mode 100644 packages/commands/src/commands/speech/synthesize-hint.ts create mode 100644 packages/commands/tests/speech-synthesize-hint.test.ts diff --git a/packages/commands/src/commands/speech/synthesize-hint.ts b/packages/commands/src/commands/speech/synthesize-hint.ts new file mode 100644 index 00000000..495d1841 --- /dev/null +++ b/packages/commands/src/commands/speech/synthesize-hint.ts @@ -0,0 +1,71 @@ +import { BailianError, DOCS_HOSTS } from "bailian-cli-core"; +import { VOICE_TTS_PAGE } from "bailian-cli-runtime"; + +const QWEN_AUDIO_TTS_VOICE_DOC = `${DOCS_HOSTS.cn}/qwen-audio-tts-voice-list`; + +/** model in current endpoint/plan is not available (e.g. CosyVoice in Token Plan). */ +export function isTtsModelNotFound(error: unknown): boolean { + if (!(error instanceof BailianError)) return false; + const apiCode = error.api?.apiCode ?? ""; + if (/\bModelNotFound\b/i.test(apiCode)) return true; + return /\bModelNotFound\b|model[^\n]{0,80}?\b(not\s+exist|does\s+not\s+exist|not\s+found)\b/i.test( + error.message, + ); +} + +/** Voice mismatch (Engine error [411] as shown in the official documentation). */ +export function isTtsVoiceMismatch(error: unknown): boolean { + if (!(error instanceof BailianError)) return false; + return /Engine\s+error\s*\[411\]/i.test(error.message); +} + +function voiceDocsUrl(model: string): string { + if (model.startsWith("qwen-audio-")) return QWEN_AUDIO_TTS_VOICE_DOC; + return VOICE_TTS_PAGE; +} + +export function speechSynthesizeModelNotFoundHint(binName: string, model: string): string { + return [ + `Model "${model}" may not be available on the current endpoint or plan.`, + `Try the Profile default speech model, or check the Token Plan model list.`, + `List built-in voices: \`${binName} speech synthesize --list-voices --model ${model}\`.`, + ].join(" "); +} + +export function speechSynthesizeVoiceMismatchHint(binName: string, model: string): string { + return [ + `The voice may not match model "${model}".`, + `Run \`${binName} speech synthesize --list-voices --model ${model}\` for built-in voices.`, + `Official voice list: ${voiceDocsUrl(model)}`, + ].join(" "); +} + +/** + * Keep the server message; attach a hint only for known TTS failure shapes. + * Does not overwrite an existing hint. + */ +export function rethrowWithSpeechSynthesizeHint( + error: unknown, + options: { binName: string; model: string }, +): never { + if (!(error instanceof BailianError) || error.hint) { + throw error; + } + + const { binName, model } = options; + let hint: string | undefined; + if (isTtsModelNotFound(error)) { + hint = speechSynthesizeModelNotFoundHint(binName, model); + } else if (isTtsVoiceMismatch(error)) { + hint = speechSynthesizeVoiceMismatchHint(binName, model); + } + + if (!hint) throw error; + + // 透传原 cause,不把当前 BailianError 再套一层,避免 text/JSON 重复同一句 message + throw new BailianError(error.message, error.exitCode, hint, { + cause: error.cause, + api: error.api, + rawResponse: error.rawResponse, + }); +} diff --git a/packages/commands/src/commands/speech/synthesize.ts b/packages/commands/src/commands/speech/synthesize.ts index b6a5d178..602487ab 100644 --- a/packages/commands/src/commands/speech/synthesize.ts +++ b/packages/commands/src/commands/speech/synthesize.ts @@ -24,8 +24,10 @@ import { downloadFile } from "bailian-cli-runtime"; import { runConcurrent, downloadParallel, getConcurrency } from "bailian-cli-runtime"; import { emitResult, emitBare } from "bailian-cli-runtime"; import { VOICE_TTS_PAGE } from "bailian-cli-runtime"; +import { rethrowWithSpeechSynthesizeHint } from "./synthesize-hint.ts"; const COSYVOICE_CLONE_DESIGN_DOC = `${DOCS_HOSTS.cn}/cosyvoice-clone-design-api`; +const QWEN_AUDIO_TTS_VOICE_DOC = `${DOCS_HOSTS.cn}/qwen-audio-tts-voice-list`; interface VoiceEntry { voice: string; @@ -34,6 +36,32 @@ interface VoiceEntry { lang: string; } +// qwen-audio-3.0-tts-plus system voices (official docs list only these two) +const QWEN_AUDIO_30_TTS_PLUS_VOICES: VoiceEntry[] = [ + { voice: "longanlingxin", name: "龙安灵心", desc: "知心温暖音", lang: "中文/英文" }, + { voice: "longanlufeng", name: "龙安鲁风", desc: "明亮开朗音", lang: "中文/英文" }, +]; + +// qwen-audio-3.0-tts-flash system voices +const QWEN_AUDIO_30_TTS_FLASH_VOICES: VoiceEntry[] = [ + // Social companion (premium Chinese) + { voice: "longanfengyue", name: "龙安风悦", desc: "自然亲切音", lang: "中文/英文" }, + { voice: "longanyuanfei", name: "龙安元妃", desc: "高傲妃子音", lang: "中文/英文" }, + { voice: "longanlingxi", name: "龙安灵希", desc: "可爱甜美音", lang: "中文/英文" }, + { voice: "longanxiaoxin", name: "龙安小昕", desc: "亲切活泼音", lang: "中文/英文" }, + { voice: "longanhuan_v3.6", name: "龙安欢", desc: "欢脱元气女", lang: "中文/英文" }, + // Kids / toys (premium children) + { voice: "longjielidou_v3.6", name: "龙杰力豆", desc: "天真男童", lang: "中文/英文" }, + { voice: "longpaopao_v3.6", name: "龙泡泡", desc: "软糯可爱音", lang: "中文/英文" }, + // Character / game (premium Chinese) + { voice: "longhuohuo_v3.6", name: "龙火火", desc: "顽皮少年音", lang: "中文/英文" }, + { voice: "longchuanshu_v3.6", name: "龙川叔", desc: "川普大叔音", lang: "中文/英文" }, + // Social companion / assistant (premium English) + { voice: "loongmary", name: "loongmary", desc: "温暖英音", lang: "英文" }, + { voice: "loongeva_v3.6", name: "loongeva", desc: "高智美音", lang: "英文" }, + { voice: "loongjohn", name: "loongJohn", desc: "沉稳亲切美音", lang: "英文" }, +]; + // cosyvoice-v3-flash system voices const COSYVOICE_V3_FLASH_VOICES: VoiceEntry[] = [ // 社交陪伴 @@ -111,6 +139,8 @@ const COSYVOICE_V3_FLASH_VOICES: VoiceEntry[] = [ ]; const MODEL_VOICES: Record = { + "qwen-audio-3.0-tts-plus": QWEN_AUDIO_30_TTS_PLUS_VOICES, + "qwen-audio-3.0-tts-flash": QWEN_AUDIO_30_TTS_FLASH_VOICES, "cosyvoice-v3-flash": COSYVOICE_V3_FLASH_VOICES, "cosyvoice-v3-plus": COSYVOICE_V3_FLASH_VOICES, "cosyvoice-v3.5-flash": [], @@ -118,11 +148,18 @@ const MODEL_VOICES: Record = { "cosyvoice-v2": [], }; +/** Official voice-list docs URL for the model family. */ +function voiceDocsUrl(model: string): string { + if (model.startsWith("qwen-audio-")) return QWEN_AUDIO_TTS_VOICE_DOC; + return VOICE_TTS_PAGE; +} + function printVoiceList(model: string): void { const voices = MODEL_VOICES[model]; + const docsUrl = voiceDocsUrl(model); if (!voices) { process.stdout.write(`No built-in voice list available for model: ${model}\n`); - process.stdout.write(`Browse voices in the console: ${VOICE_TTS_PAGE}\n`); + process.stdout.write(`See official voice list: ${docsUrl}\n`); return; } if (voices.length === 0) { @@ -138,11 +175,13 @@ function printVoiceList(model: string): void { `${col("VOICE ID", 26)} ${col("NAME", 10)} ${col("DESCRIPTION", 16)} LANGUAGE\n`, ); process.stdout.write(`${"-".repeat(26)} ${"-".repeat(10)} ${"-".repeat(16)} ${"-".repeat(12)}\n`); - for (const v of voices) { - process.stdout.write(`${col(v.voice, 26)} ${col(v.name, 10)} ${col(v.desc, 16)} ${v.lang}\n`); + for (const entry of voices) { + process.stdout.write( + `${col(entry.voice, 26)} ${col(entry.name, 10)} ${col(entry.desc, 16)} ${entry.lang}\n`, + ); } process.stdout.write(`\nTotal: ${voices.length} voices\n`); - process.stdout.write(`Preview and browse more voices in the console: \n${VOICE_TTS_PAGE}\n`); + process.stdout.write(`Preview and browse more voices: \n${docsUrl}\n`); } const SYNTHESIZE_FLAGS = { @@ -177,9 +216,9 @@ const SYNTHESIZE_FLAGS = { valueHint: "", description: { "en-US": - "Voice ID. Use --list-voices to see built-in voices for cosyvoice-v3-flash; for v3.5-flash provide a clone/design voice ID", + "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", "zh-CN": - "音色 ID。使用 --list-voices 查看 cosyvoice-v3-flash 的内置音色;使用 v3.5-flash 时需提供复刻/设计音色 ID", + "音色 ID。使用 --list-voices 查看所选模型的内置音色(如 qwen-audio-3.0-tts-plus/flash、cosyvoice-v3-flash);使用 v3.5-flash 时需提供复刻/设计音色 ID", }, }, listVoices: { @@ -290,10 +329,11 @@ export default defineCommand({ usageArgs: "--text [flags]", flags: SYNTHESIZE_FLAGS, exampleArgs: [ + "--list-voices --model qwen-audio-3.0-tts-plus", "--list-voices --model cosyvoice-v3-flash", { - "en-US": '--text "Hello, I am Qwen" --voice ', - "zh-CN": '--text "你好,我是通义千问" --voice ', + "en-US": '--text "Hello, I am Qwen" --model qwen-audio-3.0-tts-plus --voice longanlingxin', + "zh-CN": '--text "你好,我是通义千问" --model qwen-audio-3.0-tts-plus --voice longanlingxin', }, { "en-US": '--text "Hello world" --voice --language en', @@ -398,10 +438,17 @@ export default defineCommand({ process.stderr.write(`[Model: ${model}] [Voice: ${voice}]\n`); } - if (useStream) { - await handleStreamMode(ctx.client, settings, body, flags, format); - } else { - await handleNonStreamMode(ctx.client, settings, body, flags, format); + try { + if (useStream) { + await handleStreamMode(ctx.client, settings, body, flags, format); + } else { + await handleNonStreamMode(ctx.client, settings, body, flags, format); + } + } catch (error) { + rethrowWithSpeechSynthesizeHint(error, { + binName: ctx.identity.binName, + model, + }); } }, }); diff --git a/packages/commands/tests/e2e/speech-list-voices.e2e.test.ts b/packages/commands/tests/e2e/speech-list-voices.e2e.test.ts index 7d74d071..2460903c 100644 --- a/packages/commands/tests/e2e/speech-list-voices.e2e.test.ts +++ b/packages/commands/tests/e2e/speech-list-voices.e2e.test.ts @@ -26,6 +26,33 @@ describe("e2e: speech list-voices", () => { expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/recognize|--url|audio|model/i); }); + + // --list-voices reads the local catalog; no API key needed + test("【qwen-audio-3.0-tts-plus】获取音色列表", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "synthesize", + "--list-voices", + "--model", + "qwen-audio-3.0-tts-plus", + ]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain("longanlingxin"); + expect(stdout).toContain("longanlufeng"); + }); + + test("【qwen-audio-3.0-tts-flash】获取音色列表", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "synthesize", + "--list-voices", + "--model", + "qwen-audio-3.0-tts-flash", + ]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain("longanfengyue"); + expect(stdout).toContain("loongjohn"); + }); }); describe.skipIf(!isDashScopeE2EReady())("e2e: speech list-voices", () => { diff --git a/packages/commands/tests/speech-synthesize-hint.test.ts b/packages/commands/tests/speech-synthesize-hint.test.ts new file mode 100644 index 00000000..e9175aad --- /dev/null +++ b/packages/commands/tests/speech-synthesize-hint.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from "vite-plus/test"; +import { BailianError, ExitCode } from "bailian-cli-core"; +import { + isTtsModelNotFound, + isTtsVoiceMismatch, + rethrowWithSpeechSynthesizeHint, + speechSynthesizeModelNotFoundHint, + speechSynthesizeVoiceMismatchHint, +} from "../src/commands/speech/synthesize-hint.ts"; + +describe("speech-synthesize-hint", () => { + test("detects ModelNotFound / model not exist", () => { + expect( + isTtsModelNotFound( + new BailianError("Model not exist", ExitCode.GENERAL, undefined, { + api: { httpStatus: 404 }, + }), + ), + ).toBe(true); + expect( + isTtsModelNotFound( + new BailianError("Unknown model", ExitCode.GENERAL, undefined, { + api: { httpStatus: 404, apiCode: "ModelNotFound" }, + }), + ), + ).toBe(true); + expect( + isTtsModelNotFound(new BailianError("model 'cosyvoice-v3-flash' not found or not supported")), + ).toBe(true); + expect(isTtsModelNotFound(new BailianError("rate limit exceeded"))).toBe(false); + }); + + test("does not treat bare 404 or capability errors as model-not-found", () => { + expect( + isTtsModelNotFound( + new BailianError("Not Found", ExitCode.GENERAL, undefined, { api: { httpStatus: 404 } }), + ), + ).toBe(false); + expect(isTtsModelNotFound(new BailianError("this model is not supported for SSML"))).toBe( + false, + ); + expect( + isTtsModelNotFound(new BailianError("Download failed: HTTP 404", ExitCode.GENERAL)), + ).toBe(false); + }); + + test("detects Engine error [411]", () => { + expect( + isTtsVoiceMismatch( + new BailianError( + "[cosyvoice:]Engine error [411]: TTS speak operation failed", + ExitCode.GENERAL, + undefined, + { api: { httpStatus: 400, apiCode: "InvalidParameter" } }, + ), + ), + ).toBe(true); + expect(isTtsVoiceMismatch(new BailianError("InvalidParameter: bad format"))).toBe(false); + }); + + test("does not treat other CosyVoice 400s as voice mismatch", () => { + expect( + isTtsVoiceMismatch( + new BailianError("[cosyvoice:] invalid sample rate", ExitCode.GENERAL, undefined, { + api: { httpStatus: 400, apiCode: "InvalidParameter" }, + }), + ), + ).toBe(false); + expect( + isTtsVoiceMismatch( + new BailianError( + "InvalidParameter: this voice does not support instruction", + ExitCode.GENERAL, + undefined, + { + api: { httpStatus: 400, apiCode: "InvalidParameter" }, + }, + ), + ), + ).toBe(false); + expect( + isTtsVoiceMismatch( + new BailianError("TTS speak operation failed", ExitCode.GENERAL, undefined, { + api: { httpStatus: 400, apiCode: "InvalidParameter" }, + }), + ), + ).toBe(false); + }); + + test("404 hint points to list-voices --model and plan guidance", () => { + const hint = speechSynthesizeModelNotFoundHint("bl", "cosyvoice-v3-flash"); + expect(hint).toContain("cosyvoice-v3-flash"); + expect(hint).toContain("bl speech synthesize --list-voices --model cosyvoice-v3-flash"); + expect(hint).toMatch(/endpoint or plan/i); + }); + + test("411 hint points to model list-voices and docs", () => { + const hint = speechSynthesizeVoiceMismatchHint("bl", "qwen-audio-3.0-tts-plus"); + expect(hint).toContain("qwen-audio-3.0-tts-plus"); + expect(hint).toContain("--list-voices --model qwen-audio-3.0-tts-plus"); + expect(hint).toContain("qwen-audio-tts-voice-list"); + }); + + test("on match keeps server message and attaches hint without nesting BailianError as cause", () => { + const original = new BailianError( + "[cosyvoice:]Engine error [411]: TTS speak operation failed", + ExitCode.GENERAL, + undefined, + { api: { httpStatus: 400, apiCode: "InvalidParameter" } }, + ); + try { + rethrowWithSpeechSynthesizeHint(original, { + binName: "bl", + model: "qwen-audio-3.0-tts-plus", + }); + expect.unreachable(); + } catch (error) { + expect(error).toBeInstanceOf(BailianError); + const wrapped = error as BailianError; + expect(wrapped.message).toBe(original.message); + expect(wrapped.exitCode).toBe(ExitCode.GENERAL); + expect(wrapped.hint).toMatch(/list-voices/); + expect(wrapped.api?.httpStatus).toBe(400); + expect(wrapped.cause).toBeUndefined(); + expect(wrapped.toJSON().error).not.toHaveProperty("cause"); + } + + const rootCause = new Error("ECONNRESET"); + const withCause = new BailianError( + "[cosyvoice:]Engine error [411]: TTS speak operation failed", + ExitCode.GENERAL, + undefined, + { cause: rootCause, api: { httpStatus: 400, apiCode: "InvalidParameter" } }, + ); + try { + rethrowWithSpeechSynthesizeHint(withCause, { + binName: "bl", + model: "qwen-audio-3.0-tts-plus", + }); + expect.unreachable(); + } catch (error) { + const wrapped = error as BailianError; + expect(wrapped.cause).toBe(rootCause); + expect(wrapped.cause).not.toBe(withCause); + } + }); + + test("preserves existing hint or non-target errors", () => { + const withHint = new BailianError("Model not exist", ExitCode.GENERAL, "keep me", { + api: { httpStatus: 404 }, + }); + try { + rethrowWithSpeechSynthesizeHint(withHint, { binName: "bl", model: "x" }); + expect.unreachable(); + } catch (error) { + expect(error).toBe(withHint); + } + + const other = new BailianError("quota exceeded", ExitCode.GENERAL); + try { + rethrowWithSpeechSynthesizeHint(other, { binName: "bl", model: "x" }); + expect.unreachable(); + } catch (error) { + expect(error).toBe(other); + } + }); +}); diff --git a/skills/bailian-gen/reference/speech.md b/skills/bailian-gen/reference/speech.md index 817faae1..324e8021 100644 --- a/skills/bailian-gen/reference/speech.md +++ b/skills/bailian-gen/reference/speech.md @@ -100,36 +100,40 @@ bl speech recognize --url https://example.com/audio.mp3 --model qwen-audio-3.0-a #### Flags -| Flag | Type | Required | Description | -| -------------------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------- | -| `--text ` | string | no | Text to synthesize into speech (or use --text-file) | -| `--text-file ` | string | no | Read text from a file instead of --text | -| `--model ` | string | no | Model ID (default: configured Profile TTS model, otherwise cosyvoice-v3-flash). System voices vary by model | -| `--voice ` | string | no | Voice ID. Use --list-voices to see built-in voices for cosyvoice-v3-flash; for v3.5-flash provide a clone/design voice ID | -| `--list-voices` | switch | no | List built-in system voices for the selected model and exit (console link shown in output) | -| `--format ` | string | no | Audio format: mp3, pcm, wav, opus (default: mp3; streaming default: pcm) | -| `--sample-rate ` | string | no | Audio sample rate in Hz (e.g. 24000) | -| `--volume ` | string | no | Volume 0-100 (default: 50) | -| `--rate ` | string | no | Speech rate 0.5-2.0 (default: 1.0) | -| `--pitch ` | string | no | Pitch multiplier 0.5-2.0 (default: 1.0) | -| `--seed ` | string | no | Random seed 0-65535 for reproducible synthesis | -| `--language ` | string | no | Language hint (e.g. zh, en, ja, ko, fr, de) | -| `--instruction ` | string | no | Natural language instruction to control speech style (e.g. "Use a gentle tone") | -| `--enable-ssml` | switch | no | Enable SSML markup parsing in input text | -| `--out ` | string | no | Save audio to file (default: auto-generate in temp dir) | -| `--stream` | switch | no | Stream raw PCM audio to stdout (pipe to player) | -| `--concurrent ` | number | no | Run N parallel requests (default: 1) | -| `--api-key ` | string | no | API key | -| `--base-url ` | string | no | API base URL | +| Flag | Type | Required | Description | +| -------------------------------- | ------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--text ` | string | no | Text to synthesize into speech (or use --text-file) | +| `--text-file ` | string | no | Read text from a file instead of --text | +| `--model ` | string | no | Model ID (default: configured Profile TTS model, otherwise cosyvoice-v3-flash). System voices vary by model | +| `--voice ` | string | no | 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 | +| `--list-voices` | switch | no | List built-in system voices for the selected model and exit (console link shown in output) | +| `--format ` | string | no | Audio format: mp3, pcm, wav, opus (default: mp3; streaming default: pcm) | +| `--sample-rate ` | string | no | Audio sample rate in Hz (e.g. 24000) | +| `--volume ` | string | no | Volume 0-100 (default: 50) | +| `--rate ` | string | no | Speech rate 0.5-2.0 (default: 1.0) | +| `--pitch ` | string | no | Pitch multiplier 0.5-2.0 (default: 1.0) | +| `--seed ` | string | no | Random seed 0-65535 for reproducible synthesis | +| `--language ` | string | no | Language hint (e.g. zh, en, ja, ko, fr, de) | +| `--instruction ` | string | no | Natural language instruction to control speech style (e.g. "Use a gentle tone") | +| `--enable-ssml` | switch | no | Enable SSML markup parsing in input text | +| `--out ` | string | no | Save audio to file (default: auto-generate in temp dir) | +| `--stream` | switch | no | Stream raw PCM audio to stdout (pipe to player) | +| `--concurrent ` | number | no | Run N parallel requests (default: 1) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | #### Examples +```bash +bl speech synthesize --list-voices --model qwen-audio-3.0-tts-plus +``` + ```bash bl speech synthesize --list-voices --model cosyvoice-v3-flash ``` ```bash -bl speech synthesize --text "Hello, I am Qwen" --voice +bl speech synthesize --text "Hello, I am Qwen" --model qwen-audio-3.0-tts-plus --voice longanlingxin ``` ```bash From 0e00151f2bd811756dea1d64a5e9d60d87e1601b Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Thu, 17 Sep 2026 16:55:01 +0800 Subject: [PATCH 2/3] fix(e2e): pass placeholder API key for qwen --list-voices tests authStage still requires a key before run(), even though the catalog is local. --- packages/commands/tests/e2e/speech-list-voices.e2e.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/commands/tests/e2e/speech-list-voices.e2e.test.ts b/packages/commands/tests/e2e/speech-list-voices.e2e.test.ts index 2460903c..1d6758a4 100644 --- a/packages/commands/tests/e2e/speech-list-voices.e2e.test.ts +++ b/packages/commands/tests/e2e/speech-list-voices.e2e.test.ts @@ -27,7 +27,7 @@ describe("e2e: speech list-voices", () => { expect(stderr).toMatch(/recognize|--url|audio|model/i); }); - // --list-voices reads the local catalog; no API key needed + // --list-voices 只读本地目录,但 auth: apiKey 在 run() 前仍要求凭证 test("【qwen-audio-3.0-tts-plus】获取音色列表", async () => { const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ "speech", @@ -35,6 +35,8 @@ describe("e2e: speech list-voices", () => { "--list-voices", "--model", "qwen-audio-3.0-tts-plus", + "--api-key", + "sk-e2e-placeholder", ]); expect(exitCode, stderr).toBe(0); expect(stdout).toContain("longanlingxin"); @@ -48,6 +50,8 @@ describe("e2e: speech list-voices", () => { "--list-voices", "--model", "qwen-audio-3.0-tts-flash", + "--api-key", + "sk-e2e-placeholder", ]); expect(exitCode, stderr).toBe(0); expect(stdout).toContain("longanfengyue"); From 1bcf84c40ec7e73aa083e9aa07335ecbd30e1563 Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Fri, 18 Sep 2026 11:19:22 +0800 Subject: [PATCH 3/3] fix(speech): drop server-error TTS hints; cover token-plan --list-voices --- .../src/commands/speech/synthesize-hint.ts | 71 -------- .../src/commands/speech/synthesize.ts | 16 +- .../tests/e2e/speech-list-voices.e2e.test.ts | 33 +++- .../tests/speech-synthesize-hint.test.ts | 167 ------------------ 4 files changed, 36 insertions(+), 251 deletions(-) delete mode 100644 packages/commands/src/commands/speech/synthesize-hint.ts delete mode 100644 packages/commands/tests/speech-synthesize-hint.test.ts diff --git a/packages/commands/src/commands/speech/synthesize-hint.ts b/packages/commands/src/commands/speech/synthesize-hint.ts deleted file mode 100644 index 495d1841..00000000 --- a/packages/commands/src/commands/speech/synthesize-hint.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { BailianError, DOCS_HOSTS } from "bailian-cli-core"; -import { VOICE_TTS_PAGE } from "bailian-cli-runtime"; - -const QWEN_AUDIO_TTS_VOICE_DOC = `${DOCS_HOSTS.cn}/qwen-audio-tts-voice-list`; - -/** model in current endpoint/plan is not available (e.g. CosyVoice in Token Plan). */ -export function isTtsModelNotFound(error: unknown): boolean { - if (!(error instanceof BailianError)) return false; - const apiCode = error.api?.apiCode ?? ""; - if (/\bModelNotFound\b/i.test(apiCode)) return true; - return /\bModelNotFound\b|model[^\n]{0,80}?\b(not\s+exist|does\s+not\s+exist|not\s+found)\b/i.test( - error.message, - ); -} - -/** Voice mismatch (Engine error [411] as shown in the official documentation). */ -export function isTtsVoiceMismatch(error: unknown): boolean { - if (!(error instanceof BailianError)) return false; - return /Engine\s+error\s*\[411\]/i.test(error.message); -} - -function voiceDocsUrl(model: string): string { - if (model.startsWith("qwen-audio-")) return QWEN_AUDIO_TTS_VOICE_DOC; - return VOICE_TTS_PAGE; -} - -export function speechSynthesizeModelNotFoundHint(binName: string, model: string): string { - return [ - `Model "${model}" may not be available on the current endpoint or plan.`, - `Try the Profile default speech model, or check the Token Plan model list.`, - `List built-in voices: \`${binName} speech synthesize --list-voices --model ${model}\`.`, - ].join(" "); -} - -export function speechSynthesizeVoiceMismatchHint(binName: string, model: string): string { - return [ - `The voice may not match model "${model}".`, - `Run \`${binName} speech synthesize --list-voices --model ${model}\` for built-in voices.`, - `Official voice list: ${voiceDocsUrl(model)}`, - ].join(" "); -} - -/** - * Keep the server message; attach a hint only for known TTS failure shapes. - * Does not overwrite an existing hint. - */ -export function rethrowWithSpeechSynthesizeHint( - error: unknown, - options: { binName: string; model: string }, -): never { - if (!(error instanceof BailianError) || error.hint) { - throw error; - } - - const { binName, model } = options; - let hint: string | undefined; - if (isTtsModelNotFound(error)) { - hint = speechSynthesizeModelNotFoundHint(binName, model); - } else if (isTtsVoiceMismatch(error)) { - hint = speechSynthesizeVoiceMismatchHint(binName, model); - } - - if (!hint) throw error; - - // 透传原 cause,不把当前 BailianError 再套一层,避免 text/JSON 重复同一句 message - throw new BailianError(error.message, error.exitCode, hint, { - cause: error.cause, - api: error.api, - rawResponse: error.rawResponse, - }); -} diff --git a/packages/commands/src/commands/speech/synthesize.ts b/packages/commands/src/commands/speech/synthesize.ts index 602487ab..023709ea 100644 --- a/packages/commands/src/commands/speech/synthesize.ts +++ b/packages/commands/src/commands/speech/synthesize.ts @@ -24,7 +24,6 @@ import { downloadFile } from "bailian-cli-runtime"; import { runConcurrent, downloadParallel, getConcurrency } from "bailian-cli-runtime"; import { emitResult, emitBare } from "bailian-cli-runtime"; import { VOICE_TTS_PAGE } from "bailian-cli-runtime"; -import { rethrowWithSpeechSynthesizeHint } from "./synthesize-hint.ts"; const COSYVOICE_CLONE_DESIGN_DOC = `${DOCS_HOSTS.cn}/cosyvoice-clone-design-api`; const QWEN_AUDIO_TTS_VOICE_DOC = `${DOCS_HOSTS.cn}/qwen-audio-tts-voice-list`; @@ -438,17 +437,10 @@ export default defineCommand({ process.stderr.write(`[Model: ${model}] [Voice: ${voice}]\n`); } - try { - if (useStream) { - await handleStreamMode(ctx.client, settings, body, flags, format); - } else { - await handleNonStreamMode(ctx.client, settings, body, flags, format); - } - } catch (error) { - rethrowWithSpeechSynthesizeHint(error, { - binName: ctx.identity.binName, - model, - }); + if (useStream) { + await handleStreamMode(ctx.client, settings, body, flags, format); + } else { + await handleNonStreamMode(ctx.client, settings, body, flags, format); } }, }); diff --git a/packages/commands/tests/e2e/speech-list-voices.e2e.test.ts b/packages/commands/tests/e2e/speech-list-voices.e2e.test.ts index 1d6758a4..94d4ab79 100644 --- a/packages/commands/tests/e2e/speech-list-voices.e2e.test.ts +++ b/packages/commands/tests/e2e/speech-list-voices.e2e.test.ts @@ -1,5 +1,7 @@ +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; import { describe, expect, test } from "vite-plus/test"; -import { isDashScopeE2EReady, runCommandHelp, runCommandE2e } from "./helpers.ts"; +import { isDashScopeE2EReady, makeE2eOutputDir, runCommandHelp, runCommandE2e } from "./helpers.ts"; import { SPEECH_ROUTES } from "./topic-routes.ts"; /** @@ -57,6 +59,35 @@ describe("e2e: speech list-voices", () => { expect(stdout).toContain("longanfengyue"); expect(stdout).toContain("loongjohn"); }); + + // 激活 token-plan 后不传 --model,应打出默认 qwen-audio plus 音色 + test("Token Plan 未显式传 --model 时 --list-voices 使用默认 qwen-audio TTS", async () => { + const configDir = makeE2eOutputDir("speech-list-voices-token-plan-default"); + writeFileSync( + join(configDir, "config.json"), + JSON.stringify({ + "token-plan": { + api_key: "sk-sp-e2e-placeholder", + base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com", + default_speech_model: "qwen-audio-3.0-tts-plus", + }, + }), + ); + + const { stdout, stderr, exitCode } = await runCommandE2e( + SPEECH_ROUTES, + ["speech", "synthesize", "--config", "token-plan", "--list-voices"], + { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + }, + ); + + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain("longanlingxin"); + expect(stdout).toContain("longanlufeng"); + }); }); describe.skipIf(!isDashScopeE2EReady())("e2e: speech list-voices", () => { diff --git a/packages/commands/tests/speech-synthesize-hint.test.ts b/packages/commands/tests/speech-synthesize-hint.test.ts deleted file mode 100644 index e9175aad..00000000 --- a/packages/commands/tests/speech-synthesize-hint.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { describe, expect, test } from "vite-plus/test"; -import { BailianError, ExitCode } from "bailian-cli-core"; -import { - isTtsModelNotFound, - isTtsVoiceMismatch, - rethrowWithSpeechSynthesizeHint, - speechSynthesizeModelNotFoundHint, - speechSynthesizeVoiceMismatchHint, -} from "../src/commands/speech/synthesize-hint.ts"; - -describe("speech-synthesize-hint", () => { - test("detects ModelNotFound / model not exist", () => { - expect( - isTtsModelNotFound( - new BailianError("Model not exist", ExitCode.GENERAL, undefined, { - api: { httpStatus: 404 }, - }), - ), - ).toBe(true); - expect( - isTtsModelNotFound( - new BailianError("Unknown model", ExitCode.GENERAL, undefined, { - api: { httpStatus: 404, apiCode: "ModelNotFound" }, - }), - ), - ).toBe(true); - expect( - isTtsModelNotFound(new BailianError("model 'cosyvoice-v3-flash' not found or not supported")), - ).toBe(true); - expect(isTtsModelNotFound(new BailianError("rate limit exceeded"))).toBe(false); - }); - - test("does not treat bare 404 or capability errors as model-not-found", () => { - expect( - isTtsModelNotFound( - new BailianError("Not Found", ExitCode.GENERAL, undefined, { api: { httpStatus: 404 } }), - ), - ).toBe(false); - expect(isTtsModelNotFound(new BailianError("this model is not supported for SSML"))).toBe( - false, - ); - expect( - isTtsModelNotFound(new BailianError("Download failed: HTTP 404", ExitCode.GENERAL)), - ).toBe(false); - }); - - test("detects Engine error [411]", () => { - expect( - isTtsVoiceMismatch( - new BailianError( - "[cosyvoice:]Engine error [411]: TTS speak operation failed", - ExitCode.GENERAL, - undefined, - { api: { httpStatus: 400, apiCode: "InvalidParameter" } }, - ), - ), - ).toBe(true); - expect(isTtsVoiceMismatch(new BailianError("InvalidParameter: bad format"))).toBe(false); - }); - - test("does not treat other CosyVoice 400s as voice mismatch", () => { - expect( - isTtsVoiceMismatch( - new BailianError("[cosyvoice:] invalid sample rate", ExitCode.GENERAL, undefined, { - api: { httpStatus: 400, apiCode: "InvalidParameter" }, - }), - ), - ).toBe(false); - expect( - isTtsVoiceMismatch( - new BailianError( - "InvalidParameter: this voice does not support instruction", - ExitCode.GENERAL, - undefined, - { - api: { httpStatus: 400, apiCode: "InvalidParameter" }, - }, - ), - ), - ).toBe(false); - expect( - isTtsVoiceMismatch( - new BailianError("TTS speak operation failed", ExitCode.GENERAL, undefined, { - api: { httpStatus: 400, apiCode: "InvalidParameter" }, - }), - ), - ).toBe(false); - }); - - test("404 hint points to list-voices --model and plan guidance", () => { - const hint = speechSynthesizeModelNotFoundHint("bl", "cosyvoice-v3-flash"); - expect(hint).toContain("cosyvoice-v3-flash"); - expect(hint).toContain("bl speech synthesize --list-voices --model cosyvoice-v3-flash"); - expect(hint).toMatch(/endpoint or plan/i); - }); - - test("411 hint points to model list-voices and docs", () => { - const hint = speechSynthesizeVoiceMismatchHint("bl", "qwen-audio-3.0-tts-plus"); - expect(hint).toContain("qwen-audio-3.0-tts-plus"); - expect(hint).toContain("--list-voices --model qwen-audio-3.0-tts-plus"); - expect(hint).toContain("qwen-audio-tts-voice-list"); - }); - - test("on match keeps server message and attaches hint without nesting BailianError as cause", () => { - const original = new BailianError( - "[cosyvoice:]Engine error [411]: TTS speak operation failed", - ExitCode.GENERAL, - undefined, - { api: { httpStatus: 400, apiCode: "InvalidParameter" } }, - ); - try { - rethrowWithSpeechSynthesizeHint(original, { - binName: "bl", - model: "qwen-audio-3.0-tts-plus", - }); - expect.unreachable(); - } catch (error) { - expect(error).toBeInstanceOf(BailianError); - const wrapped = error as BailianError; - expect(wrapped.message).toBe(original.message); - expect(wrapped.exitCode).toBe(ExitCode.GENERAL); - expect(wrapped.hint).toMatch(/list-voices/); - expect(wrapped.api?.httpStatus).toBe(400); - expect(wrapped.cause).toBeUndefined(); - expect(wrapped.toJSON().error).not.toHaveProperty("cause"); - } - - const rootCause = new Error("ECONNRESET"); - const withCause = new BailianError( - "[cosyvoice:]Engine error [411]: TTS speak operation failed", - ExitCode.GENERAL, - undefined, - { cause: rootCause, api: { httpStatus: 400, apiCode: "InvalidParameter" } }, - ); - try { - rethrowWithSpeechSynthesizeHint(withCause, { - binName: "bl", - model: "qwen-audio-3.0-tts-plus", - }); - expect.unreachable(); - } catch (error) { - const wrapped = error as BailianError; - expect(wrapped.cause).toBe(rootCause); - expect(wrapped.cause).not.toBe(withCause); - } - }); - - test("preserves existing hint or non-target errors", () => { - const withHint = new BailianError("Model not exist", ExitCode.GENERAL, "keep me", { - api: { httpStatus: 404 }, - }); - try { - rethrowWithSpeechSynthesizeHint(withHint, { binName: "bl", model: "x" }); - expect.unreachable(); - } catch (error) { - expect(error).toBe(withHint); - } - - const other = new BailianError("quota exceeded", ExitCode.GENERAL); - try { - rethrowWithSpeechSynthesizeHint(other, { binName: "bl", model: "x" }); - expect.unreachable(); - } catch (error) { - expect(error).toBe(other); - } - }); -});