diff --git a/README.md b/README.md index f937bc92..aacba39f 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ Once installed, just describe your task to your AI Agent — no need to assemble | ------------------------ | --------------------------------------------------------------------------------- | | Managed Agent | "Create a Managed Agent that can generate short-film storyboards and videos." | | Image & video generation | "Generate an image of a cat in a spacesuit on Mars, then turn it into a video." | +| Speech recognition | "Transcribe this audio; if proper nouns are wrong, add hot words and try again." | | Usage & quota | "Show my recent model usage, free-tier quota, and rate limits." | | Model selection | "Recommend a model for image understanding and customer support." | | About Bailian CLI | "Tell me what Bailian CLI can do for me, and suggest how to use it for my needs." | diff --git a/README.zh.md b/README.zh.md index 4679a553..446da2cf 100644 --- a/README.zh.md +++ b/README.zh.md @@ -114,6 +114,7 @@ irm https://bailian.aliyun.com/cli/install.ps1 | iex | ---------------- | ----------------------------------------------------------------------- | | Managed Agent | “帮我创建一个能够生成短片分镜和视频的 Managed Agent。” | | 图片和视频生成 | “生成一张穿着太空服的猫站在火星上的图片,再把它制作成一段视频。” | +| 语音识别 | “把这段音频转写成文字,专有名词识别不准的话帮我加上热词再试。” | | 用量与额度 | “查看最近的模型用量、免费额度和限流情况。” | | 模型选型 | “推荐一个适合图片理解和智能客服的模型。” | | 了解 Bailian CLI | “介绍一下 Bailian CLI 能帮我完成哪些任务,并根据我的需求推荐使用方式。” | diff --git a/packages/cli/README.md b/packages/cli/README.md index f937bc92..aacba39f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -115,6 +115,7 @@ Once installed, just describe your task to your AI Agent — no need to assemble | ------------------------ | --------------------------------------------------------------------------------- | | Managed Agent | "Create a Managed Agent that can generate short-film storyboards and videos." | | Image & video generation | "Generate an image of a cat in a spacesuit on Mars, then turn it into a video." | +| Speech recognition | "Transcribe this audio; if proper nouns are wrong, add hot words and try again." | | Usage & quota | "Show my recent model usage, free-tier quota, and rate limits." | | Model selection | "Recommend a model for image understanding and customer support." | | About Bailian CLI | "Tell me what Bailian CLI can do for me, and suggest how to use it for my needs." | diff --git a/packages/cli/README.zh.md b/packages/cli/README.zh.md index 4679a553..446da2cf 100644 --- a/packages/cli/README.zh.md +++ b/packages/cli/README.zh.md @@ -114,6 +114,7 @@ irm https://bailian.aliyun.com/cli/install.ps1 | iex | ---------------- | ----------------------------------------------------------------------- | | Managed Agent | “帮我创建一个能够生成短片分镜和视频的 Managed Agent。” | | 图片和视频生成 | “生成一张穿着太空服的猫站在火星上的图片,再把它制作成一段视频。” | +| 语音识别 | “把这段音频转写成文字,专有名词识别不准的话帮我加上热词再试。” | | 用量与额度 | “查看最近的模型用量、免费额度和限流情况。” | | 模型选型 | “推荐一个适合图片理解和智能客服的模型。” | | 了解 Bailian CLI | “介绍一下 Bailian CLI 能帮我完成哪些任务,并根据我的需求推荐使用方式。” | diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index c1ee7fb2..4b9bf786 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -70,6 +70,11 @@ import { searchWeb, speechSynthesize, speechRecognize, + speechVocabularyCreate, + speechVocabularyList, + speechVocabularyGet, + speechVocabularyUpdate, + speechVocabularyDelete, fileUpload, consoleCall, usageFree, @@ -286,6 +291,11 @@ export const commands: Record = { "search web": searchWeb, "speech synthesize": speechSynthesize, "speech recognize": speechRecognize, + "speech vocabulary create": speechVocabularyCreate, + "speech vocabulary list": speechVocabularyList, + "speech vocabulary get": speechVocabularyGet, + "speech vocabulary update": speechVocabularyUpdate, + "speech vocabulary delete": speechVocabularyDelete, "file upload": fileUpload, "console call": consoleCall, "usage free": usageFree, diff --git a/packages/commands/src/commands/speech/recognize.ts b/packages/commands/src/commands/speech/recognize.ts index ff1b4d2e..65b0cedd 100644 --- a/packages/commands/src/commands/speech/recognize.ts +++ b/packages/commands/src/commands/speech/recognize.ts @@ -14,9 +14,11 @@ import { speechRecognizePath, resolveAsrApi, buildAsrFlashRequest, + buildAsrContextMessages, buildAsyncAsrLanguageFields, collectAsrTranscriptionItems, extractAsrFlashText, + parseInstantVocabulary, type AsrApiRoute, type AsrFlashFamily, type OutputFormat, @@ -73,8 +75,30 @@ const RECOGNIZE_FLAGS = { type: "string", valueHint: "", description: { - "en-US": "Hot-word vocabulary ID for improved accuracy", - "zh-CN": "用于提升识别准确率的热词表 ID", + "en-US": + "Pre-built hot-word vocabulary ID (create it via `speech vocabulary create`). Its target_model must exactly match --model, otherwise it is silently ignored. Wider model support than --vocabulary, including Fun-ASR and Paraformer", + "zh-CN": + "预编译热词列表 ID(可用 `speech vocabulary create` 创建)。其 target_model 必须与 --model 完全一致,否则静默失效且无报错。支持模型比 --vocabulary 更广,含 Fun-ASR 与 Paraformer 系列", + }, + }, + vocabulary: { + type: "string", + valueHint: "", + description: { + "en-US": + "Instant hot words as JSON object of word→weight, e.g. '{\"Fendouzhe\":4}'. Weight 1-5 (4 recommended; higher values can hurt other words), 50 for super hot word. No pre-built vocabulary needed. Takes effect only on Qwen-Audio-3.0-ASR-Flash models", + "zh-CN": + "即时热词,JSON 对象「热词→权重」,例如 '{\"奋斗者\":4}'。权重 1-5(推荐 4,过高会拖累其他词),50 表示超级热词。无需预先创建热词表。仅 Qwen-Audio-3.0-ASR-Flash 系列模型生效", + }, + }, + context: { + type: "string", + valueHint: "", + description: { + "en-US": + "Context enhancement word list to improve accuracy on proper nouns; must contain the target words themselves (a topic description alone has little effect); max 400 chars. Takes effect only on Qwen-Audio-3.0-ASR-Flash and Fun-ASR-Flash models", + "zh-CN": + "上下文增强词表,提升专有名词准确率;须包含待识别的原词本身(只写主题描述效果有限),最长 400 字符。仅 Qwen-Audio-3.0-ASR-Flash 系列与 Fun-ASR-Flash 模型生效", }, }, channelId: { @@ -110,9 +134,11 @@ function assertSyncFlashFlagsAllowed( const unsupported: string[] = []; if (flags.diarization === true) unsupported.push("--diarization"); if (flags.speakerCount !== undefined) unsupported.push("--speaker-count"); - // qwen3 sync Flash does not use vocabulary_id; input-audio Flash (fun-asr-flash* / qwen-audio-*-asr-flash) does - if (flashFamily === "qwen3" && flags.vocabularyId !== undefined) { - unsupported.push("--vocabulary-id"); + // qwen3 sync Flash has no place for vocabulary_id / vocabulary / context in its body shape + if (flashFamily === "qwen3") { + if (flags.vocabularyId !== undefined) unsupported.push("--vocabulary-id"); + if (flags.vocabulary !== undefined) unsupported.push("--vocabulary"); + if (flags.context !== undefined) unsupported.push("--context"); } if (flags.channelId !== undefined) unsupported.push("--channel-id"); if (flags.async === true) unsupported.push("--async"); @@ -121,12 +147,34 @@ function assertSyncFlashFlagsAllowed( if (unsupported.length > 0) { throw new BailianError( `Model "${model}" uses sync Flash ASR and does not support: ${unsupported.join(", ")}.\n` + - `Hint: Use an async filetrans model (e.g. fun-asr, qwen3-asr-flash-filetrans) for those flags.`, + syncFlashUnsupportedHint(unsupported), ExitCode.USAGE, ); } } +/** Pick a hint that matches the rejected flags (vocab/context vs diarization/async/…). */ +function syncFlashUnsupportedHint(unsupported: string[]): string { + const vocabularyRelated = new Set(["--vocabulary", "--vocabulary-id", "--context"]); + const hasVocabularyRelated = unsupported.some((flag) => vocabularyRelated.has(flag)); + const hasOtherFlags = unsupported.some((flag) => !vocabularyRelated.has(flag)); + + if (hasVocabularyRelated && !hasOtherFlags) { + return ( + "Hint: Use qwen-audio-3.0-asr-flash (or an async filetrans model such as " + + "qwen-audio-3.0-asr-flash-filetrans) for vocabulary/context flags." + ); + } + if (hasVocabularyRelated && hasOtherFlags) { + return ( + "Hint: For vocabulary/context flags use qwen-audio-3.0-asr-flash or " + + "qwen-audio-3.0-asr-flash-filetrans; for the other flags use an async filetrans model " + + "(e.g. fun-asr)." + ); + } + return "Hint: Use an async filetrans model (e.g. fun-asr, qwen3-asr-flash-filetrans) for those flags."; +} + export default defineCommand({ description: { "en-US": "Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash)", @@ -141,6 +189,8 @@ export default defineCommand({ "--url https://example.com/meeting.wav --diarization --speaker-count 3", "--url https://example.com/audio.mp3 --language zh", "--url https://example.com/audio.mp3 --vocabulary-id vocab-abc123", + '--url https://example.com/audio.mp3 --model qwen-audio-3.0-asr-flash-filetrans --vocabulary \'{"奋斗者":4,"鲸落":4}\'', + '--url https://example.com/audio.mp3 --model qwen-audio-3.0-asr-flash-filetrans --context "奋斗者号 鲸落 深海勇士"', "--url https://example.com/audio.mp3 --out result.json", "--url https://example.com/audio.mp3 --async --quiet", "--url https://example.com/audio.mp3 --model qwen-audio-3.0-asr-flash --language en", @@ -198,6 +248,9 @@ export default defineCommand({ const format = detectOutputFormat(settings.output); + const vocabulary = + flags.vocabulary !== undefined ? parseInstantVocabulary(flags.vocabulary) : undefined; + // Auto-upload local files in parallel const resolvedUrls = await Promise.all(rawUrls.map((url) => ctx.client.uploadFile(url, model))); @@ -210,6 +263,7 @@ export default defineCommand({ model, route, resolvedUrls[0]!, + vocabulary, ); return; } @@ -223,16 +277,19 @@ export default defineCommand({ const body: DashScopeASRRequest = { model, - input: - route.asyncInputStyle === "file_url" + input: { + ...(route.asyncInputStyle === "file_url" ? { file_url: resolvedUrls[0]! } - : { file_urls: resolvedUrls }, + : { file_urls: resolvedUrls }), + ...(flags.context !== undefined ? { context: buildAsrContextMessages(flags.context) } : {}), + }, parameters: { channel_id: channelId !== undefined ? [channelId] : [0], ...languageFields, diarization_enabled: diarization ? true : undefined, speaker_count: speakerCount, vocabulary_id: vocabularyId, + vocabulary, }, }; @@ -260,6 +317,7 @@ async function handleSyncFlashMode( model: string, route: AsrApiRoute, audioUrl: string, + vocabulary: Record | undefined, ): Promise { const flashFamily = route.flashFamily as AsrFlashFamily; const body = buildAsrFlashRequest({ @@ -267,6 +325,8 @@ async function handleSyncFlashMode( audioUrl, language: flags.language, vocabularyId: flags.vocabularyId, + vocabulary, + context: flags.context, flashFamily, }); diff --git a/packages/commands/src/commands/speech/vocabulary/create.ts b/packages/commands/src/commands/speech/vocabulary/create.ts new file mode 100644 index 00000000..5c57888f --- /dev/null +++ b/packages/commands/src/commands/speech/vocabulary/create.ts @@ -0,0 +1,117 @@ +import { + defineCommand, + detectOutputFormat, + speechVocabularyPath, + buildVocabularyRequest, + createVocabulary, + type FlagsDef, + type ParsedFlags, +} from "bailian-cli-core"; +import { emitResult, emitBare } from "bailian-cli-runtime"; +import { + VOCABULARY_BODY_FLAGS, + VOCABULARY_LIMIT_NOTES, + validateVocabularySource, + readVocabularyEntries, +} from "./shared.ts"; + +const CREATE_FLAGS = { + model: { + type: "string", + valueHint: "", + description: { + "en-US": + "ASR model this vocabulary is built for (required). Must exactly match the --model passed to `speech recognize` later, otherwise the vocabulary is silently ignored", + "zh-CN": + "该热词表服务的 ASR 模型(必填)。必须与后续 `speech recognize` 的 --model 完全一致,否则热词表静默失效", + }, + required: true, + }, + prefix: { + type: "string", + valueHint: "", + description: { + "en-US": + "Custom vocabulary prefix (required). Digits and lowercase letters only, max 10 chars", + "zh-CN": "热词表自定义前缀(必填)。仅允许数字和小写字母,最长 10 个字符", + }, + required: true, + }, + ...VOCABULARY_BODY_FLAGS, +} satisfies FlagsDef; +type CreateFlags = ParsedFlags; + +export default defineCommand({ + description: { + "en-US": "Create a precompiled hot-word vocabulary for ASR", + "zh-CN": "创建用于语音识别的预编译热词表", + }, + auth: "apiKey", + usageArgs: "--model --prefix (--words | --words-file ) [flags]", + flags: CREATE_FLAGS, + notes: [ + { + "en-US": + "The --model must exactly match the --model used later with `speech recognize --vocabulary-id`; a mismatch causes silent failure with no error.", + "zh-CN": + "--model 必须与后续 `speech recognize --vocabulary-id` 使用的 --model 完全一致;不一致时热词表会静默失效且无报错。", + }, + ...VOCABULARY_LIMIT_NOTES, + ], + exampleArgs: [ + { + "en-US": '--model fun-asr --prefix demo --words \'{"Fendouzhe":4,"Jingluo":4}\'', + "zh-CN": '--model fun-asr --prefix demo --words \'{"奋斗者":4,"鲸落":4}\'', + }, + { + "en-US": + '--model paraformer-v2 --prefix demo --words \'[{"text":"Fendouzhe","weight":4,"lang":"zh"}]\'', + "zh-CN": + '--model paraformer-v2 --prefix demo --words \'[{"text":"奋斗者","weight":4,"lang":"zh"}]\'', + }, + { + "en-US": "--model fun-asr --prefix demo --words '{\"Fendouzhe\":4}' --lang zh", + "zh-CN": "--model fun-asr --prefix demo --words '{\"奋斗者\":4}' --lang zh", + }, + "--model fun-asr --prefix demo --words-file ./hotwords.json", + { + "en-US": "--model fun-asr --prefix demo --words '{\"Fendouzhe\":4}' --quiet", + "zh-CN": "--model fun-asr --prefix demo --words '{\"奋斗者\":4}' --quiet", + }, + ], + validate: (flags: CreateFlags) => validateVocabularySource(flags), + async run(ctx) { + const { settings, flags } = ctx; + const vocabulary = readVocabularyEntries(flags); + const format = detectOutputFormat(settings.output); + + const request = buildVocabularyRequest("create_vocabulary", { + target_model: flags.model, + prefix: flags.prefix, + vocabulary, + }); + + if (settings.dryRun) { + emitResult( + { + endpoint: ctx.client.url(speechVocabularyPath()), + request, + }, + format, + ); + return; + } + + const response = await createVocabulary(ctx.client, { + targetModel: flags.model, + prefix: flags.prefix, + vocabulary, + }); + + if (settings.quiet || format === "text") { + emitBare(response.output?.vocabulary_id ?? ""); + } else { + emitResult(response, format); + } + }, +}); diff --git a/packages/commands/src/commands/speech/vocabulary/delete.ts b/packages/commands/src/commands/speech/vocabulary/delete.ts new file mode 100644 index 00000000..54c26509 --- /dev/null +++ b/packages/commands/src/commands/speech/vocabulary/delete.ts @@ -0,0 +1,60 @@ +import { + defineCommand, + detectOutputFormat, + speechVocabularyPath, + buildVocabularyRequest, + deleteVocabulary, + type FlagsDef, + type ParsedFlags, +} from "bailian-cli-core"; +import { emitResult, emitBare } from "bailian-cli-runtime"; +import { VOCABULARY_ID_FLAG } from "./shared.ts"; + +const DELETE_FLAGS = { + ...VOCABULARY_ID_FLAG, +} satisfies FlagsDef; +type DeleteFlags = ParsedFlags; + +export default defineCommand({ + description: { + "en-US": "Delete a precompiled hot-word vocabulary", + "zh-CN": "删除预编译热词表", + }, + auth: "apiKey", + risk: { + level: "high", + message: { + "en-US": "This permanently deletes the specified hot-word vocabulary and cannot be undone.", + "zh-CN": "该操作会永久删除指定的热词表,且无法撤销。", + }, + }, + usageArgs: "--id ", + flags: DELETE_FLAGS, + exampleArgs: ["--id vocab-demo-xxx --dry-run", "--id vocab-demo-xxx --yes"], + async run(ctx) { + const { settings, flags } = ctx; + const vocabularyId = (flags as DeleteFlags).id; + const format = detectOutputFormat(settings.output); + + if (settings.dryRun) { + emitResult( + { + endpoint: ctx.client.url(speechVocabularyPath()), + request: buildVocabularyRequest("delete_vocabulary", { + vocabulary_id: vocabularyId, + }), + }, + format, + ); + return; + } + + const response = await deleteVocabulary(ctx.client, vocabularyId); + + if (settings.quiet || format === "text") { + emitBare(vocabularyId); + } else { + emitResult(response, format); + } + }, +}); diff --git a/packages/commands/src/commands/speech/vocabulary/get.ts b/packages/commands/src/commands/speech/vocabulary/get.ts new file mode 100644 index 00000000..89005e04 --- /dev/null +++ b/packages/commands/src/commands/speech/vocabulary/get.ts @@ -0,0 +1,79 @@ +import { + defineCommand, + detectOutputFormat, + speechVocabularyPath, + buildVocabularyRequest, + queryVocabulary, + type FlagsDef, + type ParsedFlags, +} from "bailian-cli-core"; +import { emitResult, emitBare } from "bailian-cli-runtime"; +import { VOCABULARY_ID_FLAG } from "./shared.ts"; + +const GET_FLAGS = { + ...VOCABULARY_ID_FLAG, +} satisfies FlagsDef; +type GetFlags = ParsedFlags; + +export default defineCommand({ + description: { + "en-US": "Get details of a precompiled hot-word vocabulary", + "zh-CN": "查看预编译热词表详情", + }, + auth: "apiKey", + usageArgs: "--id ", + flags: GET_FLAGS, + notes: [ + { + "en-US": + "Use this command to confirm target_model before calling `speech recognize --vocabulary-id`; a model mismatch causes silent failure.", + "zh-CN": + "调用 `speech recognize --vocabulary-id` 前请用本命令确认 target_model;模型不一致会导致静默失效。", + }, + ], + exampleArgs: ["--id vocab-demo-xxx", "--id vocab-demo-xxx --quiet"], + async run(ctx) { + const { settings, flags } = ctx; + const vocabularyId = (flags as GetFlags).id; + const format = detectOutputFormat(settings.output); + + if (settings.dryRun) { + emitResult( + { + endpoint: ctx.client.url(speechVocabularyPath()), + request: buildVocabularyRequest("query_vocabulary", { + vocabulary_id: vocabularyId, + }), + }, + format, + ); + return; + } + + const response = await queryVocabulary(ctx.client, vocabularyId); + const output = response.output; + + if (settings.quiet) { + emitBare(output?.target_model ?? ""); + return; + } + + if (format === "text") { + emitBare(`vocabulary_id: ${vocabularyId}`); + emitBare(`status: ${output?.status ?? ""}`); + emitBare(`target_model: ${output?.target_model ?? ""}`); + emitBare(`gmt_create: ${output?.gmt_create ?? ""}`); + emitBare(`gmt_modified: ${output?.gmt_modified ?? ""}`); + const entries = output?.vocabulary ?? []; + if (entries.length > 0) { + emitBare("vocabulary:"); + for (const entry of entries) { + const langPart = entry.lang ? ` lang=${entry.lang}` : ""; + emitBare(` ${entry.text} weight=${entry.weight}${langPart}`); + } + } + } else { + emitResult(response, format); + } + }, +}); diff --git a/packages/commands/src/commands/speech/vocabulary/list.ts b/packages/commands/src/commands/speech/vocabulary/list.ts new file mode 100644 index 00000000..224ca5ac --- /dev/null +++ b/packages/commands/src/commands/speech/vocabulary/list.ts @@ -0,0 +1,116 @@ +import { + defineCommand, + detectOutputFormat, + speechVocabularyPath, + buildVocabularyRequest, + listVocabularies, + type FlagsDef, + type ParsedFlags, +} from "bailian-cli-core"; +import { emitResult, emitBare } from "bailian-cli-runtime"; + +const LIST_FLAGS = { + prefix: { + type: "string", + valueHint: "", + description: { + "en-US": "Filter by vocabulary prefix", + "zh-CN": "按热词表前缀过滤", + }, + }, + page: { + type: "number", + valueHint: "", + description: { + "en-US": "Page number, 1-based (default: 1). Mapped to API page_index (0-based) as page - 1", + "zh-CN": "页码,从 1 开始(默认:1)。映射为 API 的 page_index(从 0 开始):page - 1", + }, + }, + pageSize: { + type: "number", + valueHint: "", + description: { + "en-US": "Results per page (default: 10)", + "zh-CN": "每页结果数(默认:10)", + }, + }, +} satisfies FlagsDef; +type ListFlags = ParsedFlags; + +export default defineCommand({ + description: { + "en-US": "List precompiled hot-word vocabularies", + "zh-CN": "列出预编译热词表", + }, + auth: "apiKey", + usageArgs: "[--prefix ] [--page ] [--page-size ]", + flags: LIST_FLAGS, + notes: [ + { + "en-US": + "List responses do not include target_model; use `speech vocabulary get` to inspect the model a vocabulary was built for.", + "zh-CN": "list 响应不含 target_model;要对齐模型请使用 `speech vocabulary get`。", + }, + { + "en-US": "Vocabularies with status UNDEPLOYED are silently ignored by ASR.", + "zh-CN": "status 为 UNDEPLOYED 的热词表会被 ASR 静默忽略。", + }, + ], + exampleArgs: ["", "--prefix demo", "--page 2 --page-size 20"], + validate: (flags: ListFlags) => { + if (flags.page !== undefined && flags.page < 1) { + return "--page must be >= 1."; + } + return undefined; + }, + async run(ctx) { + const { settings, flags } = ctx; + const format = detectOutputFormat(settings.output); + + const pageIndex = flags.page !== undefined ? flags.page - 1 : undefined; + const input: Record = {}; + if (flags.prefix !== undefined) input.prefix = flags.prefix; + if (pageIndex !== undefined) input.page_index = pageIndex; + if (flags.pageSize !== undefined) input.page_size = flags.pageSize; + + if (settings.dryRun) { + emitResult( + { + endpoint: ctx.client.url(speechVocabularyPath()), + request: buildVocabularyRequest("list_vocabulary", input), + }, + format, + ); + return; + } + + const response = await listVocabularies(ctx.client, { + prefix: flags.prefix, + pageIndex, + pageSize: flags.pageSize, + }); + + if (settings.quiet || format === "text") { + const items = response.output?.vocabulary_list ?? []; + if (items.length === 0) { + emitBare("No vocabularies found."); + } else { + let hasUndeployed = false; + for (const item of items) { + const id = item.vocabulary_id ?? ""; + const status = item.status ?? ""; + const modified = item.gmt_modified ?? ""; + if (status === "UNDEPLOYED") hasUndeployed = true; + emitBare(`[${id}] ${status} ${modified}`.trimEnd()); + } + if (hasUndeployed) { + emitBare( + "Note: UNDEPLOYED vocabularies are silently ignored by ASR. Use `speech vocabulary get` to inspect them.", + ); + } + } + } else { + emitResult(response, format); + } + }, +}); diff --git a/packages/commands/src/commands/speech/vocabulary/shared.ts b/packages/commands/src/commands/speech/vocabulary/shared.ts new file mode 100644 index 00000000..7029f015 --- /dev/null +++ b/packages/commands/src/commands/speech/vocabulary/shared.ts @@ -0,0 +1,103 @@ +import { + readTextFromPathOrStdin, + parseVocabularyEntries, + UsageError, + type FlagsDef, + type ParsedFlags, + type VocabularyEntry, +} from "bailian-cli-core"; + +/** Shared --id flag for get / update / delete. */ +export const VOCABULARY_ID_FLAG = { + id: { + type: "string", + valueHint: "", + description: { + "en-US": "Hot-word vocabulary ID (required)", + "zh-CN": "热词表 ID(必填)", + }, + required: true, + }, +} satisfies FlagsDef; + +/** Shared hot-word body flags for create / update. */ +export const VOCABULARY_BODY_FLAGS = { + words: { + type: "string", + valueHint: "", + description: { + "en-US": + "Hot words as JSON object of word→weight, e.g. '{\"Fendouzhe\":4}'; or the API entry array for per-entry lang. Weight 1-5 (4 recommended); when the vocabulary target_model is a Qwen-Audio-3.0-ASR-Flash series model, 50 is also allowed as super hot word. Or use --words-file", + "zh-CN": + "热词,JSON 对象「热词→权重」,例如 '{\"奋斗者\":4}';需要逐条指定语言时可传 API 的条目数组。权重 1-5(推荐 4);当热词表的 target_model 为 Qwen-Audio-3.0-ASR-Flash 系列时还可使用 50(超级热词)。也可使用 --words-file", + }, + }, + wordsFile: { + type: "string", + valueHint: "", + description: { + "en-US": "JSON file with the hot words (use - for stdin)", + "zh-CN": "包含热词的 JSON 文件(使用 - 从 stdin 读取)", + }, + }, + lang: { + type: "string", + valueHint: "", + description: { + "en-US": + "Language code applied to every hot word when using object form (optional; ignored for array form). Paraformer: zh/en/ja/yue/ko/de/fr/ru; Fun-ASR: zh/en/ja", + "zh-CN": + "对象形态时应用到所有热词的语言代码(选填;数组形态忽略)。Paraformer 支持 zh/en/ja/yue/ko/de/fr/ru;Fun-ASR 支持 zh/en/ja", + }, + }, +} satisfies FlagsDef; + +type VocabularySourceFlags = ParsedFlags; + +/** Cross-flag validation for --words / --words-file. */ +export function validateVocabularySource(flags: VocabularySourceFlags): string | undefined { + if (!flags.words && !flags.wordsFile) { + return "Provide --words or --words-file."; + } + if (flags.words && flags.wordsFile) { + return "Use either --words or --words-file, not both."; + } + if (!flags.words) { + return undefined; + } + try { + parseVocabularyEntries(flags.words, flags.lang); + } catch (error) { + if (error instanceof UsageError) { + return error.message; + } + throw error; + } + return undefined; +} + +/** Read and parse vocabulary entries from flag or file. */ +export function readVocabularyEntries(flags: VocabularySourceFlags): VocabularyEntry[] { + const raw = flags.wordsFile ? readTextFromPathOrStdin(flags.wordsFile) : (flags.words as string); + return parseVocabularyEntries(raw, flags.lang); +} + +/** Shared notes covering account limits and silent-failure pitfalls. */ +export const VOCABULARY_LIMIT_NOTES = [ + { + "en-US": + "Each account may have at most 10 vocabularies; updates must be at least 5 minutes apart. See improve-asr-accuracy for full limits.", + "zh-CN": "每个账号最多 10 个热词表;两次更新间隔至少 5 分钟。完整限制见 improve-asr-accuracy。", + }, + { + "en-US": + "Hot-word vocabularies are not supported in Singapore sub-workspaces; the server error is passed through as-is.", + "zh-CN": "新加坡子业务空间不支持热词表;服务端错误会原样透传。", + }, + { + "en-US": + "Weight 1-5 (4 recommended); when the vocabulary target_model is a Qwen-Audio-3.0-ASR-Flash series model, 50 is also allowed as super hot word.", + "zh-CN": + "权重 1-5(推荐 4);当热词表的 target_model 为 Qwen-Audio-3.0-ASR-Flash 系列时还可使用 50(超级热词)。", + }, +] as const; diff --git a/packages/commands/src/commands/speech/vocabulary/update.ts b/packages/commands/src/commands/speech/vocabulary/update.ts new file mode 100644 index 00000000..4612a945 --- /dev/null +++ b/packages/commands/src/commands/speech/vocabulary/update.ts @@ -0,0 +1,90 @@ +import { + defineCommand, + detectOutputFormat, + speechVocabularyPath, + buildVocabularyRequest, + updateVocabulary, + type FlagsDef, + type ParsedFlags, +} from "bailian-cli-core"; +import { emitResult, emitBare } from "bailian-cli-runtime"; +import { + VOCABULARY_ID_FLAG, + VOCABULARY_BODY_FLAGS, + VOCABULARY_LIMIT_NOTES, + validateVocabularySource, + readVocabularyEntries, +} from "./shared.ts"; + +const UPDATE_FLAGS = { + ...VOCABULARY_ID_FLAG, + ...VOCABULARY_BODY_FLAGS, +} satisfies FlagsDef; +type UpdateFlags = ParsedFlags; + +export default defineCommand({ + description: { + "en-US": "Replace the contents of a precompiled hot-word vocabulary", + "zh-CN": "完全替换预编译热词表的内容", + }, + auth: "apiKey", + risk: { + level: "high", + message: { + "en-US": + "This fully replaces all hot words in the vocabulary. Entries not listed will be discarded and cannot be undone.", + "zh-CN": "该操作会完全替换热词表中的全部词条。未列出的词将被丢弃,且无法撤销。", + }, + }, + usageArgs: "--id (--words | --words-file ) [flags]", + flags: UPDATE_FLAGS, + notes: [ + { + "en-US": + "update is a full replace, not an append. Prefer --dry-run first to preview the complete vocabulary that will be written.", + "zh-CN": "update 是完全替换,不是增量追加。建议先用 --dry-run 预览将要写入的完整词表。", + }, + ...VOCABULARY_LIMIT_NOTES, + ], + exampleArgs: [ + { + "en-US": "--id vocab-demo-xxx --words '{\"Fendouzhe\":4}' --dry-run", + "zh-CN": "--id vocab-demo-xxx --words '{\"奋斗者\":4}' --dry-run", + }, + { + "en-US": '--id vocab-demo-xxx --words \'{"Fendouzhe":4,"Jingluo":4}\' --yes', + "zh-CN": '--id vocab-demo-xxx --words \'{"奋斗者":4,"鲸落":4}\' --yes', + }, + ], + validate: (flags: UpdateFlags) => validateVocabularySource(flags), + async run(ctx) { + const { settings, flags } = ctx; + const vocabularyId = flags.id; + const vocabulary = readVocabularyEntries(flags); + const format = detectOutputFormat(settings.output); + + const request = buildVocabularyRequest("update_vocabulary", { + vocabulary_id: vocabularyId, + vocabulary, + }); + + if (settings.dryRun) { + emitResult( + { + endpoint: ctx.client.url(speechVocabularyPath()), + request, + }, + format, + ); + return; + } + + const response = await updateVocabulary(ctx.client, vocabularyId, vocabulary); + + if (settings.quiet || format === "text") { + emitBare(vocabularyId); + } else { + emitResult(response, format); + } + }, +}); diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts index 028f0ccb..91ddde1a 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -73,6 +73,11 @@ export { default as mcpTools } from "./commands/mcp/tools.ts"; export { default as searchWeb } from "./commands/search/web.ts"; export { default as speechSynthesize } from "./commands/speech/synthesize.ts"; export { default as speechRecognize } from "./commands/speech/recognize.ts"; +export { default as speechVocabularyCreate } from "./commands/speech/vocabulary/create.ts"; +export { default as speechVocabularyList } from "./commands/speech/vocabulary/list.ts"; +export { default as speechVocabularyGet } from "./commands/speech/vocabulary/get.ts"; +export { default as speechVocabularyUpdate } from "./commands/speech/vocabulary/update.ts"; +export { default as speechVocabularyDelete } from "./commands/speech/vocabulary/delete.ts"; export { default as fileUpload } from "./commands/file/upload.ts"; export { default as consoleCall } from "./commands/console/call.ts"; export { default as usageFree } from "./commands/usage/free.ts"; diff --git a/packages/commands/tests/e2e/speech-recognize.e2e.test.ts b/packages/commands/tests/e2e/speech-recognize.e2e.test.ts index 415f6bd7..82f2c566 100644 --- a/packages/commands/tests/e2e/speech-recognize.e2e.test.ts +++ b/packages/commands/tests/e2e/speech-recognize.e2e.test.ts @@ -40,11 +40,18 @@ describe("e2e: speech recognize", () => { language_hints?: string[]; language?: string; vocabulary_id?: string; + vocabulary?: Record; }; input?: { file_url?: string; file_urls?: string[]; - messages?: Array<{ content?: Array<{ type?: string }> }>; + context?: Array<{ + role?: string; + content?: Array<{ type?: string; text?: string }>; + }>; + messages?: Array<{ + content?: Array<{ type?: string; text?: string; input_audio?: { data?: string } }>; + }>; }; }; }>(stdout); @@ -120,6 +127,116 @@ describe("e2e: speech recognize", () => { expect(body.request?.input?.messages?.[0]?.content?.[0]?.type).toBe("input_audio"); }); + test("speech recognize async dry-run 注入 input.context 与 parameters.vocabulary", async () => { + const body = await runRecognizeDryRun([ + "--model", + "qwen-audio-3.0-asr-flash-filetrans", + "--url", + "https://example.com/audio.mp3", + "--vocabulary", + '{"奋斗者":4,"鲸落":4}', + "--context", + "奋斗者号 鲸落 深海勇士", + ]); + expect(body.mode).toBe("async"); + expect(body.request?.parameters?.vocabulary).toEqual({ 奋斗者: 4, 鲸落: 4 }); + expect(body.request?.input?.context).toEqual([ + { + role: "user", + content: [{ type: "input_text", text: "奋斗者号 鲸落 深海勇士" }], + }, + ]); + }); + + test("speech recognize sync input-audio dry-run 将 context 前置且 input_audio 在最后", async () => { + const body = await runRecognizeDryRun([ + "--model", + "qwen-audio-3.0-asr-flash", + "--url", + "https://example.com/audio.wav", + "--vocabulary", + '{"奋斗者":4}', + "--context", + "奋斗者号", + ]); + expect(body.mode).toBe("sync"); + expect(body.request?.parameters?.vocabulary).toEqual({ 奋斗者: 4 }); + const messages = body.request?.input?.messages ?? []; + expect(messages).toHaveLength(2); + expect(messages[0]?.content?.[0]).toMatchObject({ type: "input_text", text: "奋斗者号" }); + expect(messages[1]?.content?.[0]?.type).toBe("input_audio"); + }); + + test("speech recognize 非法 --vocabulary JSON 返回用法错误", async () => { + const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "recognize", + "--model", + "qwen-audio-3.0-asr-flash-filetrans", + "--url", + "https://example.com/a.wav", + "--vocabulary", + "{bad json", + "--dry-run", + "--quiet", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/not valid JSON|--vocabulary/i); + }); + + test("speech recognize 空 --vocabulary 返回用法错误", async () => { + const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "recognize", + "--model", + "qwen-audio-3.0-asr-flash-filetrans", + "--url", + "https://example.com/a.wav", + "--vocabulary", + "", + "--dry-run", + "--quiet", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/not valid JSON|--vocabulary/i); + }); + + test("speech recognize qwen3 sync 拒绝 --vocabulary", async () => { + const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "recognize", + "--model", + "qwen3-asr-flash", + "--url", + "https://example.com/a.wav", + "--vocabulary", + '{"奋斗者":4}', + "--dry-run", + "--quiet", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/--vocabulary|does not support/i); + expect(stderr).toMatch(/qwen-audio-3\.0-asr-flash|vocabulary\/context/i); + }); + + test("speech recognize sync Flash 拒绝 --diarization 时提示 async filetrans", async () => { + const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "recognize", + "--model", + "fun-asr-flash", + "--url", + "https://example.com/a.wav", + "--diarization", + "--dry-run", + "--quiet", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/--diarization|does not support/i); + expect(stderr).toMatch(/async filetrans|fun-asr/i); + expect(stderr).not.toMatch(/vocabulary\/context/i); + }); + test("speech recognize qwen3 filetrans dry-run 使用 file_url 与 language", async () => { const body = await runRecognizeDryRun([ "--model", @@ -366,5 +483,88 @@ describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())( const raw = readFileSync(asrJson, "utf8"); expect(raw.length).toBeGreaterThan(2); }, 300_000); + + test("【qwen-audio】synthesize → recognize 即时热词/上下文", async () => { + // 生造专名:无热词时常被听错;带 --vocabulary/--context 后应能正确召回。 + const hotwordScript = + "请把录音同步到听悟匣,并启动澜舟芯做摘要。听悟匣负责转写,澜舟芯负责归档。最后确认玄甲协议是否已开启。"; + const hotwords = ["听悟匣", "澜舟芯", "玄甲协议"] as const; + const vocabularyJson = '{"听悟匣":4,"澜舟芯":4,"玄甲协议":4}'; + const contextText = "听悟匣 澜舟芯 玄甲协议"; + + const missingHotwords = (text: string): string[] => { + const normalized = text.replace(/\s+/g, ""); + return hotwords.filter((word) => !normalized.includes(word)); + }; + + const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url)); + const outMp3 = join(outDir, "hotword-tts.mp3"); + const syn = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "synthesize", + "--model", + "cosyvoice-v3-flash", + "--voice", + "longxiaochun_v3", + "--text", + hotwordScript, + "--out", + outMp3, + "--output", + "json", + ]); + expect(syn.exitCode, syn.stderr).toBe(0); + const synBody = parseStdoutJson<{ audio_url?: string }>(syn.stdout); + const audioUrl = synBody.audio_url; + expect(audioUrl?.startsWith("http")).toBe(true); + + const baselineOut = join(outDir, "asr-baseline.json"); + const baseline = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "recognize", + "--model", + "qwen-audio-3.0-asr-flash", + "--url", + audioUrl!, + "--language", + "zh", + "--out", + baselineOut, + "--quiet", + ]); + expect(baseline.exitCode, baseline.stderr).toBe(0); + writeFileSync(join(outDir, "asr-baseline.txt"), baseline.stdout); + const baselineMissing = missingHotwords(baseline.stdout); + // soft:仅落盘对照,不 fail(无热词偶发也能认出专名) + writeFileSync( + join(outDir, "asr-baseline-missing.txt"), + baselineMissing.length > 0 ? baselineMissing.join("\n") + "\n" : "(none)\n", + ); + + const hotOut = join(outDir, "asr-hot.json"); + const hot = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "recognize", + "--model", + "qwen-audio-3.0-asr-flash", + "--url", + audioUrl!, + "--language", + "zh", + "--vocabulary", + vocabularyJson, + "--context", + contextText, + "--out", + hotOut, + "--quiet", + ]); + expect(hot.exitCode, hot.stderr).toBe(0); + writeFileSync(join(outDir, "asr-hot.txt"), hot.stdout); + expect( + missingHotwords(hot.stdout), + `expected hotwords in ASR text, got: ${hot.stdout.trim()}`, + ).toEqual([]); + }, 420_000); }, ); diff --git a/packages/commands/tests/e2e/speech-vocabulary.e2e.test.ts b/packages/commands/tests/e2e/speech-vocabulary.e2e.test.ts new file mode 100644 index 00000000..76941118 --- /dev/null +++ b/packages/commands/tests/e2e/speech-vocabulary.e2e.test.ts @@ -0,0 +1,551 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vite-plus/test"; +import { + e2eLabelFromMetaUrl, + isBailianE2EMediaEnabled, + isDashScopeE2EReady, + makeE2eOutputDir, + parseStdoutJson, + runCommandHelp, + runCommandE2e, +} from "./helpers.ts"; +import { SPEECH_ROUTES } from "./topic-routes.ts"; + +/** + * Speech vocabulary:help / dry-run / 确认闸门无密钥;真实 CRUD 需媒体 E2E + DashScope。 + */ + +const tempDirs: string[] = []; + +afterEach(() => { + for (const tempDir of tempDirs.splice(0)) { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +function makeTempJson(content: string): string { + const tempDir = mkdtempSync(join(tmpdir(), "bl-vocab-e2e-")); + tempDirs.push(tempDir); + const filePath = join(tempDir, "hotwords.json"); + writeFileSync(filePath, content); + return filePath; +} + +describe("e2e: speech vocabulary", () => { + test("speech vocabulary --help 列出 5 个子命令", async () => { + const { stderr, exitCode } = await runCommandHelp(SPEECH_ROUTES, [ + "speech", + "vocabulary", + "--help", + ]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/create/i); + expect(stderr).toMatch(/list/i); + expect(stderr).toMatch(/get/i); + expect(stderr).toMatch(/update/i); + expect(stderr).toMatch(/delete/i); + }); + + test("create --help 展示关键 flags 与静默失效 / weight 50 文案", async () => { + const { stderr, exitCode } = await runCommandHelp(SPEECH_ROUTES, [ + "speech", + "vocabulary", + "create", + "--help", + ]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/--model/i); + expect(stderr).toMatch(/--prefix/i); + expect(stderr).toMatch(/--words/i); + expect(stderr).toMatch(/--words-file/i); + expect(stderr).toMatch(/silently ignored|静默失效/i); + expect(stderr).toMatch(/50/); + }); + + test("delete --help 展示 Risk 与 --yes", async () => { + const { stderr, exitCode } = await runCommandHelp(SPEECH_ROUTES, [ + "speech", + "vocabulary", + "delete", + "--help", + ]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/--yes/i); + expect(stderr).toMatch(/Risk|风险/i); + }); + + test("update --help 展示 Risk、replace 语义与 --yes", async () => { + const { stderr, exitCode } = await runCommandHelp(SPEECH_ROUTES, [ + "speech", + "vocabulary", + "update", + "--help", + ]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/--yes/i); + expect(stderr).toMatch(/Risk|风险/i); + expect(stderr).toMatch(/replaces|替换/i); + }); + + test("create 缺少 --model 时退出为用法错误 (2)", async () => { + const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "vocabulary", + "create", + "--prefix", + "demo", + "--words", + '{"x":4}', + "--quiet", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/--model|Missing required/i); + }); + + test("create 缺少 --prefix 时退出为用法错误 (2)", async () => { + const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "vocabulary", + "create", + "--model", + "fun-asr", + "--words", + '{"x":4}', + "--quiet", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/--prefix|Missing required/i); + }); + + test("create 两个词表 flag 都不传时退出为用法错误 (2)", async () => { + const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "vocabulary", + "create", + "--model", + "fun-asr", + "--prefix", + "demo", + "--quiet", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/--words|--words-file/i); + }); + + test("create 同时传 --words 与 --words-file 时退出为用法错误 (2)", async () => { + const filePath = makeTempJson('{"x":4}'); + const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "vocabulary", + "create", + "--model", + "fun-asr", + "--prefix", + "demo", + "--words", + '{"x":4}', + "--words-file", + filePath, + "--quiet", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/either|--words|--words-file/i); + }); + + test("create 非法 JSON 时退出为用法错误 (2)", async () => { + const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "vocabulary", + "create", + "--model", + "fun-asr", + "--prefix", + "demo", + "--words", + "{bad json", + "--quiet", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/not valid JSON|JSON/i); + }); + + test("create 字符串权重时退出为用法错误 (2)", async () => { + const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "vocabulary", + "create", + "--model", + "fun-asr", + "--prefix", + "demo", + "--words", + '{"x":"4"}', + "--quiet", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/must be a number|number/i); + }); + + test("create 空词表 {} 时退出为用法错误 (2)", async () => { + const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "vocabulary", + "create", + "--model", + "fun-asr", + "--prefix", + "demo", + "--words", + "{}", + "--quiet", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/at least one|hot word/i); + }); + + test("get 缺少 --id 时退出为用法错误 (2)", async () => { + const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "vocabulary", + "get", + "--quiet", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/--id|Missing required/i); + }); + + test("delete 缺少 --id 时退出为用法错误 (2)", async () => { + const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "vocabulary", + "delete", + "--quiet", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/--id|Missing required/i); + }); + + test("create object 形态 --dry-run 输出 speech-biasing 信封", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "vocabulary", + "create", + "--model", + "fun-asr", + "--prefix", + "demo", + "--words", + '{"奋斗者":4,"鲸落":4}', + "--dry-run", + "--output", + "json", + "--quiet", + ]); + expect(exitCode, stderr).toBe(0); + const body = parseStdoutJson<{ + request?: { + model?: string; + input?: { + action?: string; + target_model?: string; + prefix?: string; + vocabulary?: Array<{ text?: string; weight?: number; lang?: string }>; + }; + }; + }>(stdout); + expect(body.request?.model).toBe("speech-biasing"); + expect(body.request?.input?.action).toBe("create_vocabulary"); + expect(body.request?.input?.target_model).toBe("fun-asr"); + expect(body.request?.input?.prefix).toBe("demo"); + expect(body.request?.input?.vocabulary).toEqual([ + { text: "奋斗者", weight: 4 }, + { text: "鲸落", weight: 4 }, + ]); + }); + + test("create array 形态 --dry-run 原样透传 lang", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "vocabulary", + "create", + "--model", + "fun-asr", + "--prefix", + "demo", + "--words", + '[{"text":"x","weight":4,"lang":"zh"}]', + "--dry-run", + "--output", + "json", + "--quiet", + ]); + expect(exitCode, stderr).toBe(0); + const body = parseStdoutJson<{ + request?: { + input?: { vocabulary?: Array<{ text?: string; weight?: number; lang?: string }> }; + }; + }>(stdout); + expect(body.request?.input?.vocabulary).toEqual([{ text: "x", weight: 4, lang: "zh" }]); + }); + + test("create object + --lang --dry-run 下发到每一条", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "vocabulary", + "create", + "--model", + "fun-asr", + "--prefix", + "demo", + "--words", + '{"x":4}', + "--lang", + "zh", + "--dry-run", + "--output", + "json", + "--quiet", + ]); + expect(exitCode, stderr).toBe(0); + const body = parseStdoutJson<{ + request?: { + input?: { vocabulary?: Array<{ text?: string; weight?: number; lang?: string }> }; + }; + }>(stdout); + expect(body.request?.input?.vocabulary).toEqual([{ text: "x", weight: 4, lang: "zh" }]); + }); + + test("create --words-file --dry-run 读取文件", async () => { + const filePath = makeTempJson('{"from-file":4}'); + const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "vocabulary", + "create", + "--model", + "fun-asr", + "--prefix", + "demo", + "--words-file", + filePath, + "--dry-run", + "--output", + "json", + "--quiet", + ]); + expect(exitCode, stderr).toBe(0); + const body = parseStdoutJson<{ + request?: { + input?: { vocabulary?: Array<{ text?: string; weight?: number }> }; + }; + }>(stdout); + expect(body.request?.input?.vocabulary).toEqual([{ text: "from-file", weight: 4 }]); + }); + + test("list --page 2 --dry-run 将 page_index 转为 1", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "vocabulary", + "list", + "--page", + "2", + "--dry-run", + "--output", + "json", + "--quiet", + ]); + expect(exitCode, stderr).toBe(0); + const body = parseStdoutJson<{ + request?: { input?: { action?: string; page_index?: number } }; + }>(stdout); + expect(body.request?.input?.action).toBe("list_vocabulary"); + expect(body.request?.input?.page_index).toBe(1); + }); + + test("get --dry-run 使用 query_vocabulary", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "vocabulary", + "get", + "--id", + "vocab-x", + "--dry-run", + "--output", + "json", + "--quiet", + ]); + expect(exitCode, stderr).toBe(0); + const body = parseStdoutJson<{ + request?: { input?: { action?: string; vocabulary_id?: string } }; + }>(stdout); + expect(body.request?.input?.action).toBe("query_vocabulary"); + expect(body.request?.input?.vocabulary_id).toBe("vocab-x"); + }); + + test("update --dry-run 无 --yes 也能预览", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "vocabulary", + "update", + "--id", + "vocab-x", + "--words", + '{"x":4}', + "--dry-run", + "--output", + "json", + "--quiet", + ]); + expect(exitCode, stderr).toBe(0); + const body = parseStdoutJson<{ + request?: { + input?: { + action?: string; + vocabulary_id?: string; + vocabulary?: Array<{ text?: string; weight?: number }>; + }; + }; + }>(stdout); + expect(body.request?.input?.action).toBe("update_vocabulary"); + expect(body.request?.input?.vocabulary_id).toBe("vocab-x"); + expect(body.request?.input?.vocabulary).toEqual([{ text: "x", weight: 4 }]); + }); + + test("delete --dry-run 无 --yes 也能预览", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "vocabulary", + "delete", + "--id", + "vocab-x", + "--dry-run", + "--output", + "json", + "--quiet", + ]); + expect(exitCode, stderr).toBe(0); + const body = parseStdoutJson<{ + request?: { input?: { action?: string; vocabulary_id?: string } }; + }>(stdout); + expect(body.request?.input?.action).toBe("delete_vocabulary"); + expect(body.request?.input?.vocabulary_id).toBe("vocab-x"); + }); +}); + +describe("e2e: speech vocabulary high-risk confirmation", () => { + test("delete 无 --yes 返回确认请求 (7)", async () => { + const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "vocabulary", + "delete", + "--id", + "vocab-x", + "--api-key", + "e2e-dummy-key", + "--output", + "json", + ]); + expect(exitCode).toBe(7); + expect(JSON.parse(stderr)).toMatchObject({ + error: { code: 7, type: "requires_confirmation" }, + }); + }); + + test("update 无 --yes 返回确认请求 (7)", async () => { + const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "vocabulary", + "update", + "--id", + "vocab-x", + "--words", + '{"x":4}', + "--api-key", + "e2e-dummy-key", + "--output", + "json", + ]); + expect(exitCode).toBe(7); + expect(JSON.parse(stderr)).toMatchObject({ + error: { code: 7, type: "requires_confirmation" }, + }); + }); +}); + +describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())( + "e2e: speech vocabulary(DashScope 媒体)", + () => { + test("create → list/get → delete 完整链路", async () => { + const outDir = makeE2eOutputDir(e2eLabelFromMetaUrl(import.meta.url)); + let vocabularyId = ""; + + try { + const created = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "vocabulary", + "create", + "--model", + "fun-asr", + "--prefix", + "blcli", + "--words", + '{"奋斗者":4}', + "--quiet", + ]); + expect(created.exitCode, created.stderr).toBe(0); + vocabularyId = created.stdout.trim(); + expect(vocabularyId.length).toBeGreaterThan(0); + writeFileSync(join(outDir, "vocabulary-id.txt"), vocabularyId + "\n"); + + const listed = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "vocabulary", + "list", + "--prefix", + "blcli", + "--output", + "json", + ]); + expect(listed.exitCode, listed.stderr).toBe(0); + const listBody = parseStdoutJson<{ + output?: { vocabulary_list?: Array<{ vocabulary_id?: string; status?: string }> }; + }>(listed.stdout); + const listedItem = listBody.output?.vocabulary_list?.find( + (item) => item.vocabulary_id === vocabularyId, + ); + expect(listedItem).toBeTruthy(); + expect(listedItem?.status).toBe("OK"); + + const got = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "vocabulary", + "get", + "--id", + vocabularyId, + "--output", + "json", + ]); + expect(got.exitCode, got.stderr).toBe(0); + const getBody = parseStdoutJson<{ + output?: { status?: string; target_model?: string }; + }>(got.stdout); + expect(getBody.output?.status).toBe("OK"); + expect(getBody.output?.target_model).toBe("fun-asr"); + } finally { + if (vocabularyId) { + const deleted = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "vocabulary", + "delete", + "--id", + vocabularyId, + "--yes", + "--quiet", + ]); + expect(deleted.exitCode, deleted.stderr).toBe(0); + } + } + }, 120_000); + }, +); diff --git a/packages/commands/tests/e2e/topic-routes.ts b/packages/commands/tests/e2e/topic-routes.ts index 957a0b8f..c262b8c2 100644 --- a/packages/commands/tests/e2e/topic-routes.ts +++ b/packages/commands/tests/e2e/topic-routes.ts @@ -72,6 +72,11 @@ export const VISION_ROUTES: E2eRouteExports = { export const SPEECH_ROUTES: E2eRouteExports = { "speech synthesize": "speechSynthesize", "speech recognize": "speechRecognize", + "speech vocabulary create": "speechVocabularyCreate", + "speech vocabulary list": "speechVocabularyList", + "speech vocabulary get": "speechVocabularyGet", + "speech vocabulary update": "speechVocabularyUpdate", + "speech vocabulary delete": "speechVocabularyDelete", }; export const MCP_ROUTES: E2eRouteExports = { diff --git a/packages/core/src/client/asr-routes.ts b/packages/core/src/client/asr-routes.ts index 0d982f1a..b8150d47 100644 --- a/packages/core/src/client/asr-routes.ts +++ b/packages/core/src/client/asr-routes.ts @@ -1,16 +1,20 @@ import { imageSyncPath, speechRecognizePath } from "./endpoints.ts"; +import type { AsrContextMessage } from "../types/api.ts"; + /** * DashScope ASR APIs differ by model family: * * - async file transcription (`.../audio/asr/transcription`): * fun-asr*, paraformer* (non-realtime), *-filetrans, sensevoice* - * language via `parameters.language_hints` + * language via `parameters.language_hints`; optional `input.context` / + * `parameters.vocabulary` (model-dependent effective range) * - sync multimodal (`.../aigc/multimodal-generation/generation`): * - qwen3: `{ content: [{ audio }] }` + optional `asr_options.language` - * (qwen3-asr-flash*) + * (qwen3-asr-flash*) — no vocabulary / context fields in this body shape * - input-audio: `{ type: input_audio, input_audio.data }` + - * `format`/`sample_rate` + optional `language_hints` + * `format`/`sample_rate` + optional `language_hints` / + * `vocabulary_id` / `vocabulary`; optional leading `input_text` for context * (fun-asr-flash*, qwen-audio-*-asr-flash*) * - realtime / streaming: WebSocket — not supported by `speech recognize` */ @@ -158,9 +162,18 @@ export interface BuildAsrFlashRequestOpts { language?: string; /** Precompiled hotword vocabulary ID; supported for input-audio Flash (fun-asr-flash* / qwen-audio-*-asr-flash). */ vocabularyId?: string; + /** Instant hot words (word → weight); input-audio Flash only (command layer rejects qwen3). */ + vocabulary?: Record; + /** Context enhancement text; prepended as input_text before input_audio. */ + context?: string; flashFamily: AsrFlashFamily; } +/** Wrap plain text as a single user context message for ASR. */ +export function buildAsrContextMessages(text: string): AsrContextMessage[] { + return [{ role: "user", content: [{ type: "input_text", text }] }]; +} + /** * Build language fields for async ASR routes. * qwen3-asr-flash-filetrans* → `language`; other async models → `language_hints`. @@ -178,10 +191,10 @@ export function buildAsyncAsrLanguageFields( /** Build a sync multimodal ASR request body for Flash models. */ export function buildAsrFlashRequest(opts: BuildAsrFlashRequestOpts): Record { - const { model, audioUrl, language, vocabularyId, flashFamily } = opts; + const { model, audioUrl, language, vocabularyId, vocabulary, context, flashFamily } = opts; if (flashFamily === "input-audio") { - // Match official Qwen-Audio / Fun-ASR-Flash docs: language_hints + vocabulary_id + // Match official Qwen-Audio / Fun-ASR-Flash docs: language_hints + vocabulary(_id) const parameters: Record = { format: inferAudioFormatHint(audioUrl), sample_rate: "16000", @@ -192,21 +205,28 @@ export function buildAsrFlashRequest(opts: BuildAsrFlashRequestOpts): Record> = []; + if (context) { + for (const message of buildAsrContextMessages(context)) { + messages.push(message as unknown as Record); + } + } + messages.push({ + role: "user", + content: [ + { + type: "input_audio", + input_audio: { data: audioUrl }, + }, + ], + }); return { model, - input: { - messages: [ - { - role: "user", - content: [ - { - type: "input_audio", - input_audio: { data: audioUrl }, - }, - ], - }, - ], - }, + input: { messages }, parameters, }; } diff --git a/packages/core/src/client/endpoints.ts b/packages/core/src/client/endpoints.ts index 8717c942..b2c81eb0 100644 --- a/packages/core/src/client/endpoints.ts +++ b/packages/core/src/client/endpoints.ts @@ -89,6 +89,11 @@ export function speechRecognizePath(): string { return "/api/v1/services/audio/asr/transcription"; } +// ---- Hot-word Vocabulary (ASR customization) ---- +export function speechVocabularyPath(): string { + return "/api/v1/services/audio/asr/customization"; +} + // ---- Memory Profile (DashScope v2) ---- export function profileSchemaPath(): string { return "/api/v2/apps/memory/profile_schemas"; diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index 0a387f1a..0ac944ff 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -21,6 +21,7 @@ export { responsesPath, speechRecognizePath, speechSynthesizePath, + speechVocabularyPath, taskPath, userProfilePath, videoGeneratePath, @@ -41,6 +42,7 @@ export { type ImageSizeProfile, } from "./image-routes.ts"; export { + buildAsrContextMessages, buildAsrFlashRequest, buildAsyncAsrLanguageFields, collectAsrTranscriptionItems, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6537723b..3d1b1569 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,6 +13,7 @@ export * from "./files/index.ts"; export * from "./dataset/index.ts"; export * from "./finetune/index.ts"; export * from "./deploy/index.ts"; +export * from "./speech/index.ts"; export * from "./types/index.ts"; export * from "./utils/index.ts"; export * from "./telemetry/index.ts"; diff --git a/packages/core/src/speech/index.ts b/packages/core/src/speech/index.ts new file mode 100644 index 00000000..1df342e8 --- /dev/null +++ b/packages/core/src/speech/index.ts @@ -0,0 +1,14 @@ +export { + SPEECH_BIASING_MODEL, + buildVocabularyRequest, + createVocabulary, + listVocabularies, + queryVocabulary, + updateVocabulary, + deleteVocabulary, + type VocabularyEntry, + type VocabularyListItem, + type VocabularyEnvelope, + type VocabularyRequest, +} from "./vocabulary.ts"; +export { parseInstantVocabulary, parseVocabularyEntries } from "./vocabulary-input.ts"; diff --git a/packages/core/src/speech/vocabulary-input.ts b/packages/core/src/speech/vocabulary-input.ts new file mode 100644 index 00000000..8c7ec889 --- /dev/null +++ b/packages/core/src/speech/vocabulary-input.ts @@ -0,0 +1,76 @@ +import { UsageError } from "../errors/base.ts"; +import type { VocabularyEntry } from "./vocabulary.ts"; + +/** Shared JSON decode + top-level shape guard for both hot-word flags. */ +function decodeVocabularyJson(raw: string, flagName: string): unknown { + try { + return JSON.parse(raw); + } catch (error) { + throw new UsageError(`${flagName} is not valid JSON — ${(error as Error).message}`); + } +} + +/** + * Instant hot words for `recognize --vocabulary`. + * The API field is a flat word→weight object, so an array is a usage error here. + */ +export function parseInstantVocabulary(raw: string): Record { + const parsed = decodeVocabularyJson(raw, "--vocabulary"); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new UsageError("--vocabulary must decode to a JSON object of word→weight."); + } + for (const [word, weight] of Object.entries(parsed)) { + if (typeof weight !== "number" || !Number.isFinite(weight)) { + throw new UsageError(`--vocabulary weight for "${word}" must be a number.`); + } + } + return parsed as Record; +} + +/** + * Vocabulary create/update --words. + * Accepts the same word→weight object as recognize, or the API entry array + * when per-entry lang is needed. Array form ignores the optional lang param. + */ +export function parseVocabularyEntries(raw: string, lang?: string): VocabularyEntry[] { + const flagName = "--words"; + const parsed = decodeVocabularyJson(raw, flagName); + let entries: VocabularyEntry[]; + if (Array.isArray(parsed)) { + entries = parsed.map((item, index) => { + if (!item || typeof item !== "object" || Array.isArray(item)) { + throw new UsageError( + `${flagName} entry #${index} must be an object with string "text" and number "weight".`, + ); + } + const entry = item as Partial; + if (typeof entry.text !== "string" || typeof entry.weight !== "number") { + throw new UsageError( + `${flagName} entry #${index} must have a string "text" and a number "weight".`, + ); + } + if (!Number.isFinite(entry.weight)) { + throw new UsageError(`${flagName} entry #${index} weight must be a finite number.`); + } + return { + text: entry.text, + weight: entry.weight, + ...(typeof entry.lang === "string" ? { lang: entry.lang } : {}), + }; + }); + } else if (!parsed || typeof parsed !== "object") { + throw new UsageError(`${flagName} must decode to a JSON object or array.`); + } else { + entries = []; + for (const [text, weight] of Object.entries(parsed)) { + if (typeof weight !== "number" || !Number.isFinite(weight)) { + throw new UsageError(`${flagName} weight for "${text}" must be a number.`); + } + entries.push({ text, weight, ...(lang ? { lang } : {}) }); + } + } + if (entries.length === 0) { + throw new UsageError(`${flagName} must contain at least one hot word.`); + } + return entries; +} diff --git a/packages/core/src/speech/vocabulary.ts b/packages/core/src/speech/vocabulary.ts new file mode 100644 index 00000000..a6794393 --- /dev/null +++ b/packages/core/src/speech/vocabulary.ts @@ -0,0 +1,128 @@ +/** + * Hot-word vocabulary HTTP API wrappers. + * + * Thin functions over `requestJson`. They return the parsed body verbatim + * (snake_case) so callers can decide how to surface fields. + */ +import { speechVocabularyPath } from "../client/endpoints.ts"; +import type { Client } from "../client/client.ts"; + +/** Fixed model id for the hot-word customization endpoint. */ +export const SPEECH_BIASING_MODEL = "speech-biasing"; + +export interface VocabularyEntry { + text: string; + weight: number; + lang?: string; +} + +export interface VocabularyListItem { + vocabulary_id?: string; + gmt_create?: string; + gmt_modified?: string; + /** OK | UNDEPLOYED — UNDEPLOYED vocabularies are silently ignored by ASR. */ + status?: string; +} + +export interface VocabularyEnvelope { + request_id?: string; + output?: T; + usage?: { count?: number }; +} + +export interface VocabularyRequest { + model: string; + input: Record; +} + +/** Shared request body builder for dry-run and live calls. */ +export function buildVocabularyRequest( + action: string, + input: Record, +): VocabularyRequest { + return { + model: SPEECH_BIASING_MODEL, + input: { action, ...input }, + }; +} + +async function callVocabularyApi( + client: Client, + action: string, + input: Record, + signal?: AbortSignal, +): Promise> { + return client.requestJson>({ + path: speechVocabularyPath(), + method: "POST", + body: buildVocabularyRequest(action, input), + signal, + }); +} + +export function createVocabulary( + client: Client, + params: { targetModel: string; prefix: string; vocabulary: VocabularyEntry[] }, + signal?: AbortSignal, +): Promise> { + return callVocabularyApi( + client, + "create_vocabulary", + { + target_model: params.targetModel, + prefix: params.prefix, + vocabulary: params.vocabulary, + }, + signal, + ); +} + +export function listVocabularies( + client: Client, + params: { prefix?: string; pageIndex?: number; pageSize?: number } = {}, + signal?: AbortSignal, +): Promise> { + const input: Record = {}; + if (params.prefix !== undefined) input.prefix = params.prefix; + if (params.pageIndex !== undefined) input.page_index = params.pageIndex; + if (params.pageSize !== undefined) input.page_size = params.pageSize; + return callVocabularyApi(client, "list_vocabulary", input, signal); +} + +export function queryVocabulary( + client: Client, + vocabularyId: string, + signal?: AbortSignal, +): Promise< + VocabularyEnvelope<{ + gmt_create?: string; + gmt_modified?: string; + status?: string; + target_model?: string; + vocabulary?: VocabularyEntry[]; + }> +> { + return callVocabularyApi(client, "query_vocabulary", { vocabulary_id: vocabularyId }, signal); +} + +export function updateVocabulary( + client: Client, + vocabularyId: string, + vocabulary: VocabularyEntry[], + signal?: AbortSignal, +): Promise>> { + return callVocabularyApi( + client, + "update_vocabulary", + { vocabulary_id: vocabularyId, vocabulary }, + signal, + ); +} + +export function deleteVocabulary( + client: Client, + vocabularyId: string, + signal?: AbortSignal, +): Promise>> { + return callVocabularyApi(client, "delete_vocabulary", { vocabulary_id: vocabularyId }, signal); +} diff --git a/packages/core/src/types/api.ts b/packages/core/src/types/api.ts index c6b05314..67e2f835 100644 --- a/packages/core/src/types/api.ts +++ b/packages/core/src/types/api.ts @@ -580,11 +580,19 @@ export interface DashScopeTTSStreamChunk { // ---- Speech Recognition / ASR (DashScope) ---- +/** Context-enhancement message for async ASR `input.context` / sync Flash `input.messages`. */ +export interface AsrContextMessage { + role: "user" | "assistant"; + content: Array<{ type: "input_text" | "text"; text: string }>; +} + export interface DashScopeASRRequest { model: string; input: { file_urls?: string[]; file_url?: string; + /** Context enhancement for async filetrans (array of chat-style messages). */ + context?: AsrContextMessage[]; }; parameters?: { channel_id?: number[]; @@ -595,6 +603,8 @@ export interface DashScopeASRRequest { diarization_enabled?: boolean; speaker_count?: number; vocabulary_id?: string; + /** Instant hot words (word → weight); takes effect on Qwen-Audio-3.0-ASR-Flash series. */ + vocabulary?: Record; }; } diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index cf240e3f..d9db6930 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -49,6 +49,7 @@ export type { ChatRequest, ChatResponse, ChatTool, + AsrContextMessage, DashScopeASRRequest, DashScopeASRTaskResult, DashScopeASRTranscriptionItem, diff --git a/packages/core/tests/asr-routes.test.ts b/packages/core/tests/asr-routes.test.ts index 7b27a1ec..8f4bdf78 100644 --- a/packages/core/tests/asr-routes.test.ts +++ b/packages/core/tests/asr-routes.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "vite-plus/test"; import { + buildAsrContextMessages, buildAsrFlashRequest, buildAsyncAsrLanguageFields, collectAsrTranscriptionItems, @@ -141,6 +142,47 @@ test("buildAsrFlashRequest shapes qwen3 and input-audio bodies", () => { }); }); +test("buildAsrContextMessages wraps plain text as a single user input_text message", () => { + expect(buildAsrContextMessages("奋斗者号 鲸落")).toEqual([ + { role: "user", content: [{ type: "input_text", text: "奋斗者号 鲸落" }] }, + ]); +}); + +test("buildAsrFlashRequest injects instant vocabulary and prepends context before input_audio", () => { + const body = buildAsrFlashRequest({ + model: "qwen-audio-3.0-asr-flash", + audioUrl: "https://example.com/a.wav", + vocabulary: { 奋斗者: 4, 鲸落: 4 }, + context: "奋斗者号 鲸落", + flashFamily: "input-audio", + }); + + expect(body).toEqual({ + model: "qwen-audio-3.0-asr-flash", + input: { + messages: [ + { + role: "user", + content: [{ type: "input_text", text: "奋斗者号 鲸落" }], + }, + { + role: "user", + content: [{ type: "input_audio", input_audio: { data: "https://example.com/a.wav" } }], + }, + ], + }, + parameters: { + format: "wav", + sample_rate: "16000", + vocabulary: { 奋斗者: 4, 鲸落: 4 }, + }, + }); + + const messages = (body.input as { messages: Array<{ content: Array<{ type?: string }> }> }) + .messages; + expect(messages[messages.length - 1]?.content?.[0]?.type).toBe("input_audio"); +}); + test("buildAsyncAsrLanguageFields maps language by async style", () => { expect(buildAsyncAsrLanguageFields("language_hints", "zh")).toEqual({ language_hints: ["zh"], diff --git a/packages/core/tests/speech-vocabulary-input.test.ts b/packages/core/tests/speech-vocabulary-input.test.ts new file mode 100644 index 00000000..7115d01f --- /dev/null +++ b/packages/core/tests/speech-vocabulary-input.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "vite-plus/test"; +import { + parseInstantVocabulary, + parseVocabularyEntries, + buildVocabularyRequest, + SPEECH_BIASING_MODEL, + UsageError, +} from "../src/index.ts"; + +describe("parseInstantVocabulary", () => { + test("接受 word→weight 对象", () => { + expect(parseInstantVocabulary('{"奋斗者":4,"鲸落":5}')).toEqual({ + 奋斗者: 4, + 鲸落: 5, + }); + }); + + test("拒绝数组", () => { + expect(() => parseInstantVocabulary('[{"text":"x","weight":4}]')).toThrow(UsageError); + expect(() => parseInstantVocabulary('[{"text":"x","weight":4}]')).toThrow(/object/i); + }); + + test("拒绝字符串权重", () => { + expect(() => parseInstantVocabulary('{"x":"4"}')).toThrow(UsageError); + expect(() => parseInstantVocabulary('{"x":"4"}')).toThrow(/must be a number/); + }); + + test("拒绝非有限数字权重(null)", () => { + expect(() => parseInstantVocabulary('{"x":null}')).toThrow(UsageError); + expect(() => parseInstantVocabulary('{"x":null}')).toThrow(/must be a number/); + }); + + test("拒绝 NaN / Infinity 字面量(非法 JSON)", () => { + expect(() => parseInstantVocabulary('{"x":NaN}')).toThrow(UsageError); + expect(() => parseInstantVocabulary('{"x":Infinity}')).toThrow(UsageError); + }); + + test("拒绝顶层 null", () => { + expect(() => parseInstantVocabulary("null")).toThrow(UsageError); + }); + + test("非法 JSON 抛 UsageError", () => { + expect(() => parseInstantVocabulary("{bad json")).toThrow(UsageError); + expect(() => parseInstantVocabulary("{bad json")).toThrow(/not valid JSON/); + }); +}); + +describe("parseVocabularyEntries", () => { + test("对象形态转为条目数组", () => { + expect(parseVocabularyEntries('{"奋斗者":4,"鲸落":5}')).toEqual([ + { text: "奋斗者", weight: 4 }, + { text: "鲸落", weight: 5 }, + ]); + }); + + test("对象形态叠加 --lang", () => { + expect(parseVocabularyEntries('{"奋斗者":4}', "zh")).toEqual([ + { text: "奋斗者", weight: 4, lang: "zh" }, + ]); + }); + + test("数组形态透传 lang", () => { + expect(parseVocabularyEntries('[{"text":"奋斗者","weight":4,"lang":"zh"}]')).toEqual([ + { text: "奋斗者", weight: 4, lang: "zh" }, + ]); + }); + + test("数组形态忽略第二参 lang", () => { + expect(parseVocabularyEntries('[{"text":"奋斗者","weight":4}]', "zh")).toEqual([ + { text: "奋斗者", weight: 4 }, + ]); + expect(parseVocabularyEntries('[{"text":"奋斗者","weight":4,"lang":"en"}]', "zh")).toEqual([ + { text: "奋斗者", weight: 4, lang: "en" }, + ]); + }); + + test("数组缺 text 或 weight 拒绝", () => { + expect(() => parseVocabularyEntries('[{"weight":4}]')).toThrow(UsageError); + expect(() => parseVocabularyEntries('[{"text":"x"}]')).toThrow(UsageError); + }); + + test("非 object 数组元素拒绝", () => { + expect(() => parseVocabularyEntries('["x"]')).toThrow(UsageError); + expect(() => parseVocabularyEntries("[null]")).toThrow(UsageError); + }); + + test("空对象 / 空数组拒绝", () => { + expect(() => parseVocabularyEntries("{}")).toThrow(UsageError); + expect(() => parseVocabularyEntries("{}")).toThrow(/at least one/); + expect(() => parseVocabularyEntries("[]")).toThrow(UsageError); + expect(() => parseVocabularyEntries("[]")).toThrow(/at least one/); + }); +}); + +describe("buildVocabularyRequest", () => { + test("固定 model 为 speech-biasing", () => { + const body = buildVocabularyRequest("create_vocabulary", { + target_model: "fun-asr", + prefix: "demo", + vocabulary: [{ text: "奋斗者", weight: 4 }], + }); + expect(body.model).toBe(SPEECH_BIASING_MODEL); + expect(body.model).toBe("speech-biasing"); + expect(body.input.action).toBe("create_vocabulary"); + }); +}); diff --git a/packages/runtime/src/pipeline/steps/bl-api.ts b/packages/runtime/src/pipeline/steps/bl-api.ts index 3bbaa05b..491470e9 100644 --- a/packages/runtime/src/pipeline/steps/bl-api.ts +++ b/packages/runtime/src/pipeline/steps/bl-api.ts @@ -12,6 +12,7 @@ import { speechRecognizePath, resolveAsrApi, buildAsrFlashRequest, + buildAsrContextMessages, buildAsyncAsrLanguageFields, collectAsrTranscriptionItems, extractAsrFlashText, @@ -563,6 +564,10 @@ export interface SpeechRecognizeInput { diarization?: boolean; "speaker-count"?: number; "vocabulary-id"?: string; + /** Instant hot words (already structured; no JSON string parse needed). */ + vocabulary?: Record; + /** Context enhancement plain text. */ + context?: string; "channel-id"?: number; "poll-interval"?: number; } @@ -602,9 +607,11 @@ export async function speechRecognize( const unsupportedFlags: string[] = []; if (input.diarization) unsupportedFlags.push("diarization"); if (input["speaker-count"] !== undefined) unsupportedFlags.push("speaker-count"); - // input-audio Flash supports vocabulary_id; qwen3 sync Flash does not - if (route.flashFamily === "qwen3" && input["vocabulary-id"] !== undefined) { - unsupportedFlags.push("vocabulary-id"); + // qwen3 sync Flash has no place for vocabulary_id / vocabulary / context in its body shape + if (route.flashFamily === "qwen3") { + if (input["vocabulary-id"] !== undefined) unsupportedFlags.push("vocabulary-id"); + if (input.vocabulary !== undefined) unsupportedFlags.push("vocabulary"); + if (input.context !== undefined) unsupportedFlags.push("context"); } if (input["channel-id"] !== undefined) unsupportedFlags.push("channel-id"); if (unsupportedFlags.length > 0) { @@ -648,6 +655,8 @@ export async function speechRecognize( audioUrl: fileUrls[0]!, language: input.language, vocabularyId: input["vocabulary-id"], + vocabulary: input.vocabulary, + context: input.context, flashFamily, }); const response = await env.client.requestJson>({ @@ -671,14 +680,19 @@ export async function speechRecognize( ); const body: DashScopeASRRequest = { model, - input: - route.asyncInputStyle === "file_url" ? { file_url: fileUrls[0]! } : { file_urls: fileUrls }, + input: { + ...(route.asyncInputStyle === "file_url" + ? { file_url: fileUrls[0]! } + : { file_urls: fileUrls }), + ...(input.context ? { context: buildAsrContextMessages(input.context) } : {}), + }, parameters: { channel_id: input["channel-id"] !== undefined ? [input["channel-id"]] : undefined, ...languageFields, diarization_enabled: input.diarization, speaker_count: input["speaker-count"], vocabulary_id: input["vocabulary-id"], + vocabulary: input.vocabulary, }, }; stripUndefined(body.parameters as Record); diff --git a/packages/runtime/tests/speech-recognize-pipeline.test.ts b/packages/runtime/tests/speech-recognize-pipeline.test.ts index 3ebef528..677424dd 100644 --- a/packages/runtime/tests/speech-recognize-pipeline.test.ts +++ b/packages/runtime/tests/speech-recognize-pipeline.test.ts @@ -68,6 +68,81 @@ test("pipeline speechRecognize routes input-audio flash to sync multimodal endpo }); }); +test("pipeline speechRecognize injects vocabulary and context on sync input-audio", async () => { + const { env, captured } = makeEnv(); + await speechRecognize( + env, + { + url: "https://example.com/a.wav", + model: "qwen-audio-3.0-asr-flash", + vocabulary: { 奋斗者: 4 }, + context: "奋斗者号", + }, + makeCtx(), + ); + + expect(captured[0]?.body).toMatchObject({ + parameters: { vocabulary: { 奋斗者: 4 } }, + input: { + messages: [ + { role: "user", content: [{ type: "input_text", text: "奋斗者号" }] }, + { + role: "user", + content: [{ type: "input_audio", input_audio: { data: "https://example.com/a.wav" } }], + }, + ], + }, + }); +}); + +test("pipeline speechRecognize injects vocabulary and context on async filetrans", async () => { + const { env, captured } = makeEnv(async (opts) => { + if (opts.async || opts.method === "POST") { + return { output: { task_id: "task-1", task_status: "PENDING" } }; + } + return { + output: { task_id: "task-1", task_status: "SUCCEEDED", results: [] }, + request_id: "r1", + }; + }); + + await speechRecognize( + env, + { + url: "https://example.com/a.wav", + model: "qwen-audio-3.0-asr-flash-filetrans", + vocabulary: { 鲸落: 4 }, + context: "鲸落 深海勇士", + "poll-interval": 0, + }, + makeCtx(), + ); + + expect(captured[0]?.body).toMatchObject({ + input: { + file_urls: ["https://example.com/a.wav"], + context: [{ role: "user", content: [{ type: "input_text", text: "鲸落 深海勇士" }] }], + }, + parameters: { vocabulary: { 鲸落: 4 } }, + }); +}); + +test("pipeline speechRecognize rejects vocabulary on qwen3 sync flash", async () => { + const { env, captured } = makeEnv(); + await expect( + speechRecognize( + env, + { + url: "https://example.com/a.wav", + model: "qwen3-asr-flash", + vocabulary: { 奋斗者: 4 }, + }, + makeCtx(), + ), + ).rejects.toBeInstanceOf(PipelineError); + expect(captured).toHaveLength(0); +}); + test("pipeline speechRecognize maps qwen3-filetrans language to parameters.language", async () => { const { env, captured } = makeEnv(async (opts) => { if (opts.async || opts.method === "POST") { diff --git a/skills/bailian-gen/SKILL.md b/skills/bailian-gen/SKILL.md index c0d47a35..d50b31f8 100644 --- a/skills/bailian-gen/SKILL.md +++ b/skills/bailian-gen/SKILL.md @@ -45,6 +45,11 @@ Unless the user explicitly specifies a model, omit `--model` and let the CLI use For ASR model selection, keep `fun-asr` (or other `*-filetrans`) for long recordings, repeated files, speaker diarization, or asynchronous task IDs. For one local or remote audio file up to about five minutes when the user asks for low-latency Flash models, use `--model fun-asr-flash-2026-06-15`, `--model qwen-audio-3.0-asr-flash`, or `--model qwen3-asr-flash`. Flash recognition is synchronous and accepts exactly one file per call. +To improve ASR accuracy with domain terms: + +- Prefer instant `--vocabulary` / `--context` on `bl speech recognize` when the model is Qwen-Audio-3.0-ASR-Flash series (and Fun-ASR-Flash for `--context` only) — no pre-built vocabulary needed. Start weights at 4 (do not default everything to 5). `--context` must list the target words themselves; a topic description alone has little effect. +- Use `bl speech vocabulary create` + `--vocabulary-id` for Fun-ASR / Paraformer, or whenever the same hot words must be reused across requests. The vocabulary `--model` must exactly match recognize `--model` (otherwise the vocabulary is silently ignored). Each account may have at most 10 vocabularies; delete unused ones. + Flags, usage, and examples: see [`reference/`](reference/index.md) or `bl --help` — do not guess flags. ## Local files (mandatory) @@ -68,6 +73,11 @@ bl vision describe --image ./photo.jpg --prompt "图里有什么?" bl vision describe --video ./clip.mp4 --prompt "总结视频内容" bl omni --message "Describe the video content" --video ./demo.mp4 --text-only bl speech synthesize --text "Hello, welcome to Bailian" --out hello.mp3 +bl speech recognize --url ./meeting.wav --model qwen-audio-3.0-asr-flash-filetrans \ + --vocabulary '{"奋斗者":4}' --context "奋斗者号" +VOCAB=$(bl speech vocabulary create --model fun-asr --prefix demo --words '{"奋斗者":4}' --quiet) +bl speech recognize --url ./meeting.wav --model fun-asr --vocabulary-id "$VOCAB" +bl speech vocabulary delete --id "$VOCAB" --yes ``` ## Output language diff --git a/skills/bailian-gen/reference/index.md b/skills/bailian-gen/reference/index.md index 39934d72..57bb3297 100644 --- a/skills/bailian-gen/reference/index.md +++ b/skills/bailian-gen/reference/index.md @@ -9,29 +9,34 @@ Use this index for the skill-scoped quick index and global flags. ## Quick index -| Command | Authentication | Description | Detail | -| ---------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------- | ---------------------- | -| `bl image edit` | API Key | Edit an existing image with text instructions (Qwen-Image / Wan 2.7) | [image.md](image.md) | -| `bl image generate` | API Key | Generate images (Qwen-Image / wan2.x) | [image.md](image.md) | -| `bl omni` | API Key | Multimodal chat with text + audio output (Qwen-Omni) | [omni.md](omni.md) | -| `bl speech recognize` | API Key | Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash) | [speech.md](speech.md) | -| `bl speech synthesize` | API Key | Synthesize speech from text | [speech.md](speech.md) | -| `bl video download` | API Key | Download a completed video by task ID | [video.md](video.md) | -| `bl video edit` | API Key | Edit a video with happyhorse-1.0-video-edit (style transfer, object replacement, etc.) | [video.md](video.md) | -| `bl video generate` | API Key | Generate a video from text or image (wan3.0-video / wan2.6-t2v / happyhorse-1.1-i2v) | [video.md](video.md) | -| `bl video ref` | API Key | Reference-to-video generation (wan3.0-video / happyhorse-1.1-r2v / wan2.6-r2v): multi-subject, multi-shot with voice | [video.md](video.md) | -| `bl video task get` | API Key | Query async task status | [video.md](video.md) | -| `bl vision describe` | API Key | Describe an image or video using Qwen-VL | [vision.md](vision.md) | +| Command | Authentication | Description | Detail | +| ----------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------- | ---------------------- | +| `bl image edit` | API Key | Edit an existing image with text instructions (Qwen-Image / Wan 2.7) | [image.md](image.md) | +| `bl image generate` | API Key | Generate images (Qwen-Image / wan2.x) | [image.md](image.md) | +| `bl omni` | API Key | Multimodal chat with text + audio output (Qwen-Omni) | [omni.md](omni.md) | +| `bl speech recognize` | API Key | Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash) | [speech.md](speech.md) | +| `bl speech synthesize` | API Key | Synthesize speech from text | [speech.md](speech.md) | +| `bl speech vocabulary create` | API Key | Create a precompiled hot-word vocabulary for ASR | [speech.md](speech.md) | +| `bl speech vocabulary delete` | API Key | Delete a precompiled hot-word vocabulary | [speech.md](speech.md) | +| `bl speech vocabulary get` | API Key | Get details of a precompiled hot-word vocabulary | [speech.md](speech.md) | +| `bl speech vocabulary list` | API Key | List precompiled hot-word vocabularies | [speech.md](speech.md) | +| `bl speech vocabulary update` | API Key | Replace the contents of a precompiled hot-word vocabulary | [speech.md](speech.md) | +| `bl video download` | API Key | Download a completed video by task ID | [video.md](video.md) | +| `bl video edit` | API Key | Edit a video with happyhorse-1.0-video-edit (style transfer, object replacement, etc.) | [video.md](video.md) | +| `bl video generate` | API Key | Generate a video from text or image (wan3.0-video / wan2.6-t2v / happyhorse-1.1-i2v) | [video.md](video.md) | +| `bl video ref` | API Key | Reference-to-video generation (wan3.0-video / happyhorse-1.1-r2v / wan2.6-r2v): multi-subject, multi-shot with voice | [video.md](video.md) | +| `bl video task get` | API Key | Query async task status | [video.md](video.md) | +| `bl vision describe` | API Key | Describe an image or video using Qwen-VL | [vision.md](vision.md) | ## By group -| Group | Commands | Reference | -| -------- | ------------------------------------------------- | ---------------------- | -| `image` | `edit`, `generate` | [image.md](image.md) | -| `omni` | `(root)` | [omni.md](omni.md) | -| `speech` | `recognize`, `synthesize` | [speech.md](speech.md) | -| `video` | `download`, `edit`, `generate`, `ref`, `task get` | [video.md](video.md) | -| `vision` | `describe` | [vision.md](vision.md) | +| Group | Commands | Reference | +| -------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------------------- | +| `image` | `edit`, `generate` | [image.md](image.md) | +| `omni` | `(root)` | [omni.md](omni.md) | +| `speech` | `recognize`, `synthesize`, `vocabulary create`, `vocabulary delete`, `vocabulary get`, `vocabulary list`, `vocabulary update` | [speech.md](speech.md) | +| `video` | `download`, `edit`, `generate`, `ref`, `task get` | [video.md](video.md) | +| `vision` | `describe` | [vision.md](vision.md) | ## Global flags diff --git a/skills/bailian-gen/reference/speech.md b/skills/bailian-gen/reference/speech.md index f6fd39aa..817faae1 100644 --- a/skills/bailian-gen/reference/speech.md +++ b/skills/bailian-gen/reference/speech.md @@ -7,10 +7,15 @@ Index: [index.md](index.md) ## Commands in this group -| Command | Authentication | Description | -| ---------------------- | -------------- | ----------------------------------------------------------------- | -| `bl speech recognize` | API Key | Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash) | -| `bl speech synthesize` | API Key | Synthesize speech from text | +| Command | Authentication | Description | +| ----------------------------- | -------------- | ----------------------------------------------------------------- | +| `bl speech recognize` | API Key | Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash) | +| `bl speech synthesize` | API Key | Synthesize speech from text | +| `bl speech vocabulary create` | API Key | Create a precompiled hot-word vocabulary for ASR | +| `bl speech vocabulary delete` | API Key | Delete a precompiled hot-word vocabulary | +| `bl speech vocabulary get` | API Key | Get details of a precompiled hot-word vocabulary | +| `bl speech vocabulary list` | API Key | List precompiled hot-word vocabularies | +| `bl speech vocabulary update` | API Key | Replace the contents of a precompiled hot-word vocabulary | ## Command details @@ -25,20 +30,22 @@ Index: [index.md](index.md) #### Flags -| Flag | Type | Required | Description | -| --------------------------- | ------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--url ` | array | yes | Audio file URL or local file path (repeatable, max 100) | -| `--model ` | string | no | Model ID (default: configured Profile ASR model, otherwise fun-asr). Async: fun-asr / _-filetrans / paraformer-_; sync: qwen3-asr-flash* / fun-asr-flash* / qwen-audio-\*-asr-flash | -| `--language ` | string | no | Language hint (e.g. zh, en, ja). Classic async/input-audio: language_hints; qwen3-filetrans: language; qwen3 sync: asr_options.language | -| `--diarization` | switch | no | Enable automatic speaker diarization | -| `--speaker-count ` | number | no | Expected number of speakers (requires --diarization) | -| `--vocabulary-id ` | string | no | Hot-word vocabulary ID for improved accuracy | -| `--channel-id ` | number | no | Audio channel ID (default: 0) | -| `--out ` | string | no | Save full transcription result to JSON file | -| `--async` | switch | no | Return async task id without waiting | -| `--poll-interval ` | number | no | Polling interval in seconds (default: 2) | -| `--api-key ` | string | no | API key | -| `--base-url ` | string | no | API base URL | +| Flag | Type | Required | Description | +| --------------------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--url ` | array | yes | Audio file URL or local file path (repeatable, max 100) | +| `--model ` | string | no | Model ID (default: configured Profile ASR model, otherwise fun-asr). Async: fun-asr / _-filetrans / paraformer-_; sync: qwen3-asr-flash* / fun-asr-flash* / qwen-audio-\*-asr-flash | +| `--language ` | string | no | Language hint (e.g. zh, en, ja). Classic async/input-audio: language_hints; qwen3-filetrans: language; qwen3 sync: asr_options.language | +| `--diarization` | switch | no | Enable automatic speaker diarization | +| `--speaker-count ` | number | no | Expected number of speakers (requires --diarization) | +| `--vocabulary-id ` | string | no | Pre-built hot-word vocabulary ID (create it via `speech vocabulary create`). Its target_model must exactly match --model, otherwise it is silently ignored. Wider model support than --vocabulary, including Fun-ASR and Paraformer | +| `--vocabulary ` | string | no | Instant hot words as JSON object of word→weight, e.g. '{"Fendouzhe":4}'. Weight 1-5 (4 recommended; higher values can hurt other words), 50 for super hot word. No pre-built vocabulary needed. Takes effect only on Qwen-Audio-3.0-ASR-Flash models | +| `--context ` | string | no | Context enhancement word list to improve accuracy on proper nouns; must contain the target words themselves (a topic description alone has little effect); max 400 chars. Takes effect only on Qwen-Audio-3.0-ASR-Flash and Fun-ASR-Flash models | +| `--channel-id ` | number | no | Audio channel ID (default: 0) | +| `--out ` | string | no | Save full transcription result to JSON file | +| `--async` | switch | no | Return async task id without waiting | +| `--poll-interval ` | number | no | Polling interval in seconds (default: 2) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | #### Examples @@ -62,6 +69,14 @@ bl speech recognize --url https://example.com/audio.mp3 --language zh bl speech recognize --url https://example.com/audio.mp3 --vocabulary-id vocab-abc123 ``` +```bash +bl speech recognize --url https://example.com/audio.mp3 --model qwen-audio-3.0-asr-flash-filetrans --vocabulary '{"奋斗者":4,"鲸落":4}' +``` + +```bash +bl speech recognize --url https://example.com/audio.mp3 --model qwen-audio-3.0-asr-flash-filetrans --context "奋斗者号 鲸落 深海勇士" +``` + ```bash bl speech recognize --url https://example.com/audio.mp3 --out result.json ``` @@ -148,3 +163,198 @@ bl speech synthesize --text "Hello" --voice --stream | afplay - ```bash bl speech synthesize --text "Hello" --voice --stream | ffplay -nodisp -autoexit -f s16le -ar 24000 -ac 1 - ``` + +### `bl speech vocabulary create` + +| Field | Value | +| ------------------ | --------------------------------------------------------------------------------------------------------------- | +| **Name** | `speech vocabulary create` | +| **Description** | Create a precompiled hot-word vocabulary for ASR | +| **Authentication** | API Key | +| **Usage** | `bl speech vocabulary create --model --prefix (--words \| --words-file ) [flags]` | + +#### Flags + +| Flag | Type | Required | Description | +| --------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `--model ` | string | yes | ASR model this vocabulary is built for (required). Must exactly match the --model passed to `speech recognize` later, otherwise the vocabulary is silently ignored | +| `--prefix ` | string | yes | Custom vocabulary prefix (required). Digits and lowercase letters only, max 10 chars | +| `--words ` | string | no | Hot words as JSON object of word→weight, e.g. '{"Fendouzhe":4}'; or the API entry array for per-entry lang. Weight 1-5 (4 recommended); when the vocabulary target_model is a Qwen-Audio-3.0-ASR-Flash series model, 50 is also allowed as super hot word. Or use --words-file | +| `--words-file ` | string | no | JSON file with the hot words (use - for stdin) | +| `--lang ` | string | no | Language code applied to every hot word when using object form (optional; ignored for array form). Paraformer: zh/en/ja/yue/ko/de/fr/ru; Fun-ASR: zh/en/ja | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- The --model must exactly match the --model used later with `speech recognize --vocabulary-id`; a mismatch causes silent failure with no error. +- Each account may have at most 10 vocabularies; updates must be at least 5 minutes apart. See improve-asr-accuracy for full limits. +- Hot-word vocabularies are not supported in Singapore sub-workspaces; the server error is passed through as-is. +- Weight 1-5 (4 recommended); when the vocabulary target_model is a Qwen-Audio-3.0-ASR-Flash series model, 50 is also allowed as super hot word. + +#### Examples + +```bash +bl speech vocabulary create --model fun-asr --prefix demo --words '{"Fendouzhe":4,"Jingluo":4}' +``` + +```bash +bl speech vocabulary create --model paraformer-v2 --prefix demo --words '[{"text":"Fendouzhe","weight":4,"lang":"zh"}]' +``` + +```bash +bl speech vocabulary create --model fun-asr --prefix demo --words '{"Fendouzhe":4}' --lang zh +``` + +```bash +bl speech vocabulary create --model fun-asr --prefix demo --words-file ./hotwords.json +``` + +```bash +bl speech vocabulary create --model fun-asr --prefix demo --words '{"Fendouzhe":4}' --quiet +``` + +### `bl speech vocabulary delete` + +| Field | Value | +| ------------------ | -------------------------------------------------------------------------------- | +| **Name** | `speech vocabulary delete` | +| **Description** | Delete a precompiled hot-word vocabulary | +| **Authentication** | API Key | +| **Usage** | `bl speech vocabulary delete --id ` | +| **Risk** | `high` | +| **Risk message** | This permanently deletes the specified hot-word vocabulary and cannot be undone. | + +> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope. + +#### Flags + +| Flag | Type | Required | Description | +| ------------------ | ------ | -------- | --------------------------------- | +| `--id ` | string | yes | Hot-word vocabulary ID (required) | +| `--yes` | switch | no | Confirm this high-risk operation | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Examples + +```bash +bl speech vocabulary delete --id vocab-demo-xxx --dry-run +``` + +```bash +# Only after explicit user confirmation: +bl speech vocabulary delete --id vocab-demo-xxx --yes +``` + +### `bl speech vocabulary get` + +| Field | Value | +| ------------------ | ------------------------------------------------ | +| **Name** | `speech vocabulary get` | +| **Description** | Get details of a precompiled hot-word vocabulary | +| **Authentication** | API Key | +| **Usage** | `bl speech vocabulary get --id ` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------ | ------ | -------- | --------------------------------- | +| `--id ` | string | yes | Hot-word vocabulary ID (required) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- Use this command to confirm target_model before calling `speech recognize --vocabulary-id`; a model mismatch causes silent failure. + +#### Examples + +```bash +bl speech vocabulary get --id vocab-demo-xxx +``` + +```bash +bl speech vocabulary get --id vocab-demo-xxx --quiet +``` + +### `bl speech vocabulary list` + +| Field | Value | +| ------------------ | ------------------------------------------------------------------------------ | +| **Name** | `speech vocabulary list` | +| **Description** | List precompiled hot-word vocabularies | +| **Authentication** | API Key | +| **Usage** | `bl speech vocabulary list [--prefix ] [--page ] [--page-size ]` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------- | ------ | -------- | --------------------------------------------------------------------------------- | +| `--prefix ` | string | no | Filter by vocabulary prefix | +| `--page ` | number | no | Page number, 1-based (default: 1). Mapped to API page_index (0-based) as page - 1 | +| `--page-size ` | number | no | Results per page (default: 10) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- List responses do not include target_model; use `speech vocabulary get` to inspect the model a vocabulary was built for. +- Vocabularies with status UNDEPLOYED are silently ignored by ASR. + +#### Examples + +```bash +bl speech vocabulary list +``` + +```bash +bl speech vocabulary list --prefix demo +``` + +```bash +bl speech vocabulary list --page 2 --page-size 20 +``` + +### `bl speech vocabulary update` + +| Field | Value | +| ------------------ | --------------------------------------------------------------------------------------------------------------- | +| **Name** | `speech vocabulary update` | +| **Description** | Replace the contents of a precompiled hot-word vocabulary | +| **Authentication** | API Key | +| **Usage** | `bl speech vocabulary update --id (--words \| --words-file ) [flags]` | +| **Risk** | `high` | +| **Risk message** | This fully replaces all hot words in the vocabulary. Entries not listed will be discarded and cannot be undone. | + +> **Agent safety:** Never add `--yes` automatically. On `type="requires_confirmation"`, stop and ask for explicit user confirmation of the same action and scope. + +#### Flags + +| Flag | Type | Required | Description | +| --------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `--id ` | string | yes | Hot-word vocabulary ID (required) | +| `--words ` | string | no | Hot words as JSON object of word→weight, e.g. '{"Fendouzhe":4}'; or the API entry array for per-entry lang. Weight 1-5 (4 recommended); when the vocabulary target_model is a Qwen-Audio-3.0-ASR-Flash series model, 50 is also allowed as super hot word. Or use --words-file | +| `--words-file ` | string | no | JSON file with the hot words (use - for stdin) | +| `--lang ` | string | no | Language code applied to every hot word when using object form (optional; ignored for array form). Paraformer: zh/en/ja/yue/ko/de/fr/ru; Fun-ASR: zh/en/ja | +| `--yes` | switch | no | Confirm this high-risk operation | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | + +#### Notes + +- update is a full replace, not an append. Prefer --dry-run first to preview the complete vocabulary that will be written. +- Each account may have at most 10 vocabularies; updates must be at least 5 minutes apart. See improve-asr-accuracy for full limits. +- Hot-word vocabularies are not supported in Singapore sub-workspaces; the server error is passed through as-is. +- Weight 1-5 (4 recommended); when the vocabulary target_model is a Qwen-Audio-3.0-ASR-Flash series model, 50 is also allowed as super hot word. + +#### Examples + +```bash +bl speech vocabulary update --id vocab-demo-xxx --words '{"Fendouzhe":4}' --dry-run +``` + +```bash +# Only after explicit user confirmation: +bl speech vocabulary update --id vocab-demo-xxx --words '{"Fendouzhe":4,"Jingluo":4}' --yes +```