Skip to content

Commit 241de61

Browse files
committed
fix: support sync-flash and qwen3-filetrans ASR models in speech recognize
- Add asr-routes.ts with resolveAsrApi() to route models to the correct DashScope endpoint instead of always hitting asr/transcription - Async filetrans: fun-asr / paraformer / *-filetrans → file_urls (plural) - Async filetrans (qwen3): qwen3-asr-flash-filetrans* → file_url (singular) - Sync flash (input-audio): fun-asr-flash* / qwen-audio-*-asr-flash → multimodal-generation - Sync flash (qwen3): qwen3-asr-flash* → multimodal-generation + asr_options - Realtime/streaming models now give a clear USAGE error instead of a confusing server-side "url error" - Propagate same routing logic to pipeline speechRecognize step - Add table-driven unit tests and dry-run e2e assertions Fixes #146
1 parent 2389681 commit 241de61

9 files changed

Lines changed: 740 additions & 50 deletions

File tree

packages/commands/src/commands/speech/recognize.ts

Lines changed: 145 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ import {
1212
stripUndefined,
1313
taskPath,
1414
speechRecognizePath,
15+
resolveAsrApi,
16+
buildAsrFlashRequest,
17+
extractAsrFlashText,
18+
type AsrApiRoute,
19+
type AsrFlashFamily,
1520
type OutputFormat,
1621
type FlagsDef,
1722
type ParsedFlags,
@@ -27,8 +32,18 @@ const RECOGNIZE_FLAGS = {
2732
description: "Audio file URL or local file path (repeatable, max 100)",
2833
required: true,
2934
},
30-
model: { type: "string", valueHint: "<model>", description: "Model ID (default: fun-asr)" },
31-
language: { type: "string", valueHint: "<lang>", description: "Language hint (e.g. zh, en, ja)" },
35+
model: {
36+
type: "string",
37+
valueHint: "<model>",
38+
description:
39+
"Model ID (default: fun-asr). Async: fun-asr / *-filetrans / paraformer-*; sync: qwen3-asr-flash* / fun-asr-flash* / qwen-audio-*-asr-flash",
40+
},
41+
language: {
42+
type: "string",
43+
valueHint: "<lang>",
44+
description:
45+
"Language hint (e.g. zh, en, ja). Async & input-audio sync: language_hints; qwen3 sync: asr_options.language",
46+
},
3247
diarization: { type: "switch", description: "Enable automatic speaker diarization" },
3348
speakerCount: {
3449
type: "number",
@@ -55,8 +70,26 @@ const RECOGNIZE_FLAGS = {
5570
} satisfies FlagsDef;
5671
type RecognizeFlags = ParsedFlags<typeof RECOGNIZE_FLAGS>;
5772

73+
function assertSyncFlashFlagsAllowed(flags: RecognizeFlags, model: string): void {
74+
const unsupported: string[] = [];
75+
if (flags.diarization === true) unsupported.push("--diarization");
76+
if (flags.speakerCount !== undefined) unsupported.push("--speaker-count");
77+
if (flags.vocabularyId !== undefined) unsupported.push("--vocabulary-id");
78+
if (flags.channelId !== undefined) unsupported.push("--channel-id");
79+
if (flags.async === true) unsupported.push("--async");
80+
if (flags.pollInterval !== undefined) unsupported.push("--poll-interval");
81+
82+
if (unsupported.length > 0) {
83+
throw new BailianError(
84+
`Model "${model}" uses sync Flash ASR and does not support: ${unsupported.join(", ")}.\n` +
85+
`Hint: Use an async filetrans model (e.g. fun-asr, qwen3-asr-flash-filetrans) for those flags.`,
86+
ExitCode.USAGE,
87+
);
88+
}
89+
}
90+
5891
export default defineCommand({
59-
description: "Recognize speech from audio files (FunAudio-ASR)",
92+
description: "Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash)",
6093
auth: "apiKey",
6194
usageArgs: "--url <audio-url> [flags]",
6295
flags: RECOGNIZE_FLAGS,
@@ -68,6 +101,7 @@ export default defineCommand({
68101
"--url https://example.com/audio.mp3 --vocabulary-id vocab-abc123",
69102
"--url https://example.com/audio.mp3 --out result.json",
70103
"--url https://example.com/audio.mp3 --async --quiet",
104+
"--url https://example.com/audio.mp3 --model qwen-audio-3.0-asr-flash --language en",
71105
],
72106
async run(ctx) {
73107
const { settings, flags } = ctx;
@@ -90,19 +124,64 @@ export default defineCommand({
90124
}
91125

92126
const model = flags.model || "fun-asr";
127+
const route = resolveAsrApi(model);
128+
if (route.kind === "unsupported") {
129+
throw new BailianError(
130+
route.unsupportedReason ?? `Unsupported ASR model: ${model}`,
131+
ExitCode.USAGE,
132+
);
133+
}
134+
135+
if (route.kind === "sync-flash") {
136+
assertSyncFlashFlagsAllowed(flags, model);
137+
if (rawUrls.length !== 1) {
138+
throw new BailianError(
139+
`Model "${model}" is a sync Flash ASR model and accepts exactly one --url (got ${rawUrls.length}).\n` +
140+
`Hint: Pass a single audio URL, or use an async filetrans model for batch files.`,
141+
ExitCode.USAGE,
142+
);
143+
}
144+
}
145+
if (
146+
route.kind === "async-filetrans" &&
147+
route.asyncInputStyle === "file_url" &&
148+
rawUrls.length !== 1
149+
) {
150+
throw new BailianError(
151+
`Model "${model}" accepts exactly one --url (got ${rawUrls.length}).\n` +
152+
"Hint: qwen3-asr-flash-filetrans* requires a single file_url.",
153+
ExitCode.USAGE,
154+
);
155+
}
156+
93157
const format = detectOutputFormat(settings.output);
94158

95159
// Auto-upload local files in parallel
96-
const resolvedUrls = await Promise.all(rawUrls.map((u) => ctx.client.uploadFile(u, model)));
160+
const resolvedUrls = await Promise.all(rawUrls.map((url) => ctx.client.uploadFile(url, model)));
161+
162+
if (route.kind === "sync-flash") {
163+
await handleSyncFlashMode(
164+
ctx.client,
165+
settings,
166+
flags,
167+
format,
168+
model,
169+
route,
170+
resolvedUrls[0]!,
171+
);
172+
return;
173+
}
174+
97175
const channelId = flags.channelId;
98176
const language = flags.language;
99177
const vocabularyId = flags.vocabularyId;
100178

101179
const body: DashScopeASRRequest = {
102180
model,
103-
input: {
104-
file_urls: resolvedUrls,
105-
},
181+
input:
182+
route.asyncInputStyle === "file_url"
183+
? { file_url: resolvedUrls[0]! }
184+
: { file_urls: resolvedUrls },
106185
parameters: {
107186
channel_id: channelId !== undefined ? [channelId] : [0],
108187
language_hints: language ? [language] : undefined,
@@ -116,7 +195,7 @@ export default defineCommand({
116195
stripUndefined(body.parameters as Record<string, unknown>);
117196

118197
if (settings.dryRun) {
119-
emitResult({ request: body, mode: "async" }, format);
198+
emitResult({ request: body, mode: "async", path: speechRecognizePath() }, format);
120199
return;
121200
}
122201

@@ -128,6 +207,53 @@ export default defineCommand({
128207
},
129208
});
130209

210+
async function handleSyncFlashMode(
211+
client: Client,
212+
settings: Settings,
213+
flags: RecognizeFlags,
214+
format: OutputFormat,
215+
model: string,
216+
route: AsrApiRoute,
217+
audioUrl: string,
218+
): Promise<void> {
219+
const flashFamily = route.flashFamily as AsrFlashFamily;
220+
const body = buildAsrFlashRequest({
221+
model,
222+
audioUrl,
223+
language: flags.language,
224+
flashFamily,
225+
});
226+
227+
if (settings.dryRun) {
228+
emitResult({ request: body, mode: "sync", path: route.path }, format);
229+
return;
230+
}
231+
232+
if (!settings.quiet) {
233+
process.stderr.write(`[Model: ${model}] [Mode: sync] [Files: 1]\n`);
234+
}
235+
236+
const response = await client.requestJson<Record<string, unknown>>({
237+
path: route.path,
238+
method: "POST",
239+
body,
240+
});
241+
242+
const text = extractAsrFlashText(response, flashFamily);
243+
if (text) {
244+
process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
245+
} else {
246+
emitBare(JSON.stringify(response));
247+
}
248+
249+
if (flags.out) {
250+
writeFileSync(flags.out, JSON.stringify(response, null, 2) + "\n");
251+
if (!settings.quiet) {
252+
process.stderr.write(`Full result saved to: ${flags.out}\n`);
253+
}
254+
}
255+
}
256+
131257
async function handleAsyncMode(
132258
client: Client,
133259
settings: Settings,
@@ -160,12 +286,12 @@ async function handleAsyncMode(
160286
url: pollUrl,
161287
intervalSec: pollInterval,
162288
timeoutSec: settings.timeout,
163-
isComplete: (d) => (d as DashScopeASRTaskResult).output.task_status === "SUCCEEDED",
164-
isFailed: (d) => (d as DashScopeASRTaskResult).output.task_status === "FAILED",
165-
getStatus: (d) => (d as DashScopeASRTaskResult).output.task_status,
166-
getErrorMessage: (d) => {
167-
const o = (d as DashScopeASRTaskResult).output;
168-
return (o as unknown as Record<string, unknown>).message as string | undefined;
289+
isComplete: (data) => (data as DashScopeASRTaskResult).output.task_status === "SUCCEEDED",
290+
isFailed: (data) => (data as DashScopeASRTaskResult).output.task_status === "FAILED",
291+
getStatus: (data) => (data as DashScopeASRTaskResult).output.task_status,
292+
getErrorMessage: (data) => {
293+
const output = (data as DashScopeASRTaskResult).output;
294+
return (output as unknown as Record<string, unknown>).message as string | undefined;
169295
},
170296
});
171297

@@ -179,12 +305,14 @@ async function handleAsyncMode(
179305
// Collect all transcription data for --out
180306
const allTransData: Record<string, unknown>[] = [];
181307

182-
for (let i = 0; i < results.length; i++) {
183-
const subResult = results[i]!;
308+
for (let index = 0; index < results.length; index++) {
309+
const subResult = results[index]!;
184310
const isMulti = fileCount > 1;
185311

186312
if (isMulti) {
187-
process.stdout.write(`=== [${i + 1}/${results.length}] ${subResult.file_url ?? ""} ===\n`);
313+
process.stdout.write(
314+
`=== [${index + 1}/${results.length}] ${subResult.file_url ?? ""} ===\n`,
315+
);
188316
}
189317

190318
if (subResult.subtask_status === "FAILED") {

packages/commands/tests/e2e/speech-recognize.e2e.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,32 @@ import { SPEECH_ROUTES } from "./topic-routes.ts";
1616
*/
1717

1818
describe("e2e: speech recognize", () => {
19+
async function runRecognizeDryRun(args: string[]) {
20+
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
21+
"speech",
22+
"recognize",
23+
...args,
24+
"--dry-run",
25+
"--output",
26+
"json",
27+
"--quiet",
28+
]);
29+
expect(exitCode, stderr).toBe(0);
30+
return parseStdoutJson<{
31+
mode?: string;
32+
path?: string;
33+
request?: {
34+
model?: string;
35+
parameters?: { format?: string; language_hints?: string[] };
36+
input?: {
37+
file_url?: string;
38+
file_urls?: string[];
39+
messages?: Array<{ content?: Array<{ type?: string }> }>;
40+
};
41+
};
42+
}>(stdout);
43+
}
44+
1945
test("speech recognize --help 正常退出", async () => {
2046
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
2147
"speech",
@@ -25,6 +51,50 @@ describe("e2e: speech recognize", () => {
2551
expect(exitCode, stderr).toBe(0);
2652
expect(stderr).toMatch(/recognize|--url|model|audio/i);
2753
});
54+
55+
test("speech recognize sync-flash dry-run 走 multimodal-generation", async () => {
56+
const body = await runRecognizeDryRun([
57+
"--model",
58+
"qwen-audio-3.0-asr-flash",
59+
"--url",
60+
"https://dashscope.oss-cn-beijing.aliyuncs.com/samples/audio/paraformer/hello_world_female2.wav",
61+
"--language",
62+
"en",
63+
]);
64+
expect(body.mode).toBe("sync");
65+
expect(body.path).toBe("/api/v1/services/aigc/multimodal-generation/generation");
66+
expect(body.request?.model).toBe("qwen-audio-3.0-asr-flash");
67+
expect(body.request?.parameters?.format).toBe("wav");
68+
expect(body.request?.parameters?.language_hints).toEqual(["en"]);
69+
expect(body.request?.input?.messages?.[0]?.content?.[0]?.type).toBe("input_audio");
70+
});
71+
72+
test("speech recognize qwen3 filetrans dry-run 使用 file_url 单数字段", async () => {
73+
const body = await runRecognizeDryRun([
74+
"--model",
75+
"qwen3-asr-flash-filetrans",
76+
"--url",
77+
"https://dashscope.oss-cn-beijing.aliyuncs.com/samples/audio/paraformer/hello_world_female2.wav",
78+
]);
79+
expect(body.mode).toBe("async");
80+
expect(body.path).toBe("/api/v1/services/audio/asr/transcription");
81+
expect(body.request?.input?.file_url?.startsWith("https://")).toBe(true);
82+
expect(body.request?.input?.file_urls).toBeUndefined();
83+
});
84+
85+
test("speech recognize realtime 模型报用法错误", async () => {
86+
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
87+
"speech",
88+
"recognize",
89+
"--model",
90+
"qwen3-asr-flash-realtime",
91+
"--url",
92+
"https://example.com/a.wav",
93+
"--quiet",
94+
]);
95+
expect(exitCode).toBe(2);
96+
expect(stderr).toMatch(/realtime|WebSocket|unsupported/i);
97+
});
2898
});
2999

30100
describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(

0 commit comments

Comments
 (0)