Skip to content

Commit daefc09

Browse files
Merge pull request #149 from modelstudioai/fix/fixed_issue_146
fix: support sync-flash and qwen3-filetrans ASR models in speech recognize
2 parents 94f9dbb + ae0c2c1 commit daefc09

12 files changed

Lines changed: 1374 additions & 66 deletions

File tree

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

Lines changed: 162 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ import {
1212
stripUndefined,
1313
taskPath,
1414
speechRecognizePath,
15+
resolveAsrApi,
16+
buildAsrFlashRequest,
17+
buildAsyncAsrLanguageFields,
18+
collectAsrTranscriptionItems,
19+
extractAsrFlashText,
20+
type AsrApiRoute,
21+
type AsrFlashFamily,
1522
type OutputFormat,
1623
type FlagsDef,
1724
type ParsedFlags,
@@ -27,8 +34,18 @@ const RECOGNIZE_FLAGS = {
2734
description: "Audio file URL or local file path (repeatable, max 100)",
2835
required: true,
2936
},
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)" },
37+
model: {
38+
type: "string",
39+
valueHint: "<model>",
40+
description:
41+
"Model ID (default: fun-asr). Async: fun-asr / *-filetrans / paraformer-*; sync: qwen3-asr-flash* / fun-asr-flash* / qwen-audio-*-asr-flash",
42+
},
43+
language: {
44+
type: "string",
45+
valueHint: "<lang>",
46+
description:
47+
"Language hint (e.g. zh, en, ja). Classic async/input-audio: language_hints; qwen3-filetrans: language; qwen3 sync: asr_options.language",
48+
},
3249
diarization: { type: "switch", description: "Enable automatic speaker diarization" },
3350
speakerCount: {
3451
type: "number",
@@ -55,8 +72,33 @@ const RECOGNIZE_FLAGS = {
5572
} satisfies FlagsDef;
5673
type RecognizeFlags = ParsedFlags<typeof RECOGNIZE_FLAGS>;
5774

75+
function assertSyncFlashFlagsAllowed(
76+
flags: RecognizeFlags,
77+
model: string,
78+
flashFamily: AsrFlashFamily,
79+
): void {
80+
const unsupported: string[] = [];
81+
if (flags.diarization === true) unsupported.push("--diarization");
82+
if (flags.speakerCount !== undefined) unsupported.push("--speaker-count");
83+
// qwen3 sync Flash does not use vocabulary_id; input-audio Flash (fun-asr-flash* / qwen-audio-*-asr-flash) does
84+
if (flashFamily === "qwen3" && flags.vocabularyId !== undefined) {
85+
unsupported.push("--vocabulary-id");
86+
}
87+
if (flags.channelId !== undefined) unsupported.push("--channel-id");
88+
if (flags.async === true) unsupported.push("--async");
89+
if (flags.pollInterval !== undefined) unsupported.push("--poll-interval");
90+
91+
if (unsupported.length > 0) {
92+
throw new BailianError(
93+
`Model "${model}" uses sync Flash ASR and does not support: ${unsupported.join(", ")}.\n` +
94+
`Hint: Use an async filetrans model (e.g. fun-asr, qwen3-asr-flash-filetrans) for those flags.`,
95+
ExitCode.USAGE,
96+
);
97+
}
98+
}
99+
58100
export default defineCommand({
59-
description: "Recognize speech from audio files (FunAudio-ASR)",
101+
description: "Recognize speech from audio files (FunAudio-ASR / Qwen-ASR Flash)",
60102
auth: "apiKey",
61103
usageArgs: "--url <audio-url> [flags]",
62104
flags: RECOGNIZE_FLAGS,
@@ -68,6 +110,7 @@ export default defineCommand({
68110
"--url https://example.com/audio.mp3 --vocabulary-id vocab-abc123",
69111
"--url https://example.com/audio.mp3 --out result.json",
70112
"--url https://example.com/audio.mp3 --async --quiet",
113+
"--url https://example.com/audio.mp3 --model qwen-audio-3.0-asr-flash --language en",
71114
],
72115
async run(ctx) {
73116
const { settings, flags } = ctx;
@@ -90,22 +133,70 @@ export default defineCommand({
90133
}
91134

92135
const model = flags.model || "fun-asr";
136+
const route = resolveAsrApi(model);
137+
if (route.kind === "unsupported") {
138+
throw new BailianError(
139+
route.unsupportedReason ?? `Unsupported ASR model: ${model}`,
140+
ExitCode.USAGE,
141+
);
142+
}
143+
144+
if (route.kind === "sync-flash") {
145+
assertSyncFlashFlagsAllowed(flags, model, route.flashFamily!);
146+
if (rawUrls.length !== 1) {
147+
throw new BailianError(
148+
`Model "${model}" is a sync Flash ASR model and accepts exactly one --url (got ${rawUrls.length}).\n` +
149+
`Hint: Pass a single audio URL, or use an async filetrans model for batch files.`,
150+
ExitCode.USAGE,
151+
);
152+
}
153+
}
154+
if (
155+
route.kind === "async-filetrans" &&
156+
route.asyncInputStyle === "file_url" &&
157+
rawUrls.length !== 1
158+
) {
159+
throw new BailianError(
160+
`Model "${model}" accepts exactly one --url (got ${rawUrls.length}).\n` +
161+
"Hint: qwen3-asr-flash-filetrans* requires a single file_url.",
162+
ExitCode.USAGE,
163+
);
164+
}
165+
93166
const format = detectOutputFormat(settings.output);
94167

95168
// Auto-upload local files in parallel
96-
const resolvedUrls = await Promise.all(rawUrls.map((u) => ctx.client.uploadFile(u, model)));
169+
const resolvedUrls = await Promise.all(rawUrls.map((url) => ctx.client.uploadFile(url, model)));
170+
171+
if (route.kind === "sync-flash") {
172+
await handleSyncFlashMode(
173+
ctx.client,
174+
settings,
175+
flags,
176+
format,
177+
model,
178+
route,
179+
resolvedUrls[0]!,
180+
);
181+
return;
182+
}
183+
97184
const channelId = flags.channelId;
98-
const language = flags.language;
99185
const vocabularyId = flags.vocabularyId;
186+
const languageFields = buildAsyncAsrLanguageFields(
187+
route.asyncLanguageStyle ?? "language_hints",
188+
flags.language,
189+
);
100190

101191
const body: DashScopeASRRequest = {
102192
model,
103-
input: {
104-
file_urls: resolvedUrls,
105-
},
193+
input:
194+
route.asyncInputStyle === "file_url"
195+
? { file_url: resolvedUrls[0]! }
196+
: { file_urls: resolvedUrls },
106197
parameters: {
107198
channel_id: channelId !== undefined ? [channelId] : [0],
108-
language_hints: language ? [language] : undefined,
199+
...languageFields,
109200
diarization_enabled: diarization ? true : undefined,
110201
speaker_count: speakerCount,
111202
vocabulary_id: vocabularyId,
@@ -116,7 +207,7 @@ export default defineCommand({
116207
stripUndefined(body.parameters as Record<string, unknown>);
117208

118209
if (settings.dryRun) {
119-
emitResult({ request: body, mode: "async" }, format);
210+
emitResult({ request: body, mode: "async", path: speechRecognizePath() }, format);
120211
return;
121212
}
122213

@@ -128,6 +219,55 @@ export default defineCommand({
128219
},
129220
});
130221

222+
async function handleSyncFlashMode(
223+
client: Client,
224+
settings: Settings,
225+
flags: RecognizeFlags,
226+
format: OutputFormat,
227+
model: string,
228+
route: AsrApiRoute,
229+
audioUrl: string,
230+
): Promise<void> {
231+
const flashFamily = route.flashFamily as AsrFlashFamily;
232+
const body = buildAsrFlashRequest({
233+
model,
234+
audioUrl,
235+
language: flags.language,
236+
vocabularyId: flags.vocabularyId,
237+
flashFamily,
238+
});
239+
240+
if (settings.dryRun) {
241+
emitResult({ request: body, mode: "sync", path: route.path }, format);
242+
return;
243+
}
244+
245+
if (!settings.quiet) {
246+
process.stderr.write(`[Model: ${model}] [Mode: sync] [Files: 1]\n`);
247+
}
248+
249+
const response = await client.requestJson<Record<string, unknown>>({
250+
path: route.path,
251+
method: "POST",
252+
headers: { "X-DashScope-SSE": "disable" },
253+
body,
254+
});
255+
256+
const text = extractAsrFlashText(response, flashFamily);
257+
if (text) {
258+
process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
259+
} else {
260+
emitBare(JSON.stringify(response));
261+
}
262+
263+
if (flags.out) {
264+
writeFileSync(flags.out, JSON.stringify(response, null, 2) + "\n");
265+
if (!settings.quiet) {
266+
process.stderr.write(`Full result saved to: ${flags.out}\n`);
267+
}
268+
}
269+
}
270+
131271
async function handleAsyncMode(
132272
client: Client,
133273
settings: Settings,
@@ -160,16 +300,16 @@ async function handleAsyncMode(
160300
url: pollUrl,
161301
intervalSec: pollInterval,
162302
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;
303+
isComplete: (data) => (data as DashScopeASRTaskResult).output.task_status === "SUCCEEDED",
304+
isFailed: (data) => (data as DashScopeASRTaskResult).output.task_status === "FAILED",
305+
getStatus: (data) => (data as DashScopeASRTaskResult).output.task_status,
306+
getErrorMessage: (data) => {
307+
const output = (data as DashScopeASRTaskResult).output;
308+
return (output as unknown as Record<string, unknown>).message as string | undefined;
169309
},
170310
});
171311

172-
const results = result.output.results ?? [];
312+
const results = collectAsrTranscriptionItems(result.output);
173313

174314
if (results.length === 0) {
175315
emitResult({ task_id: taskId, status: result.output.task_status }, format);
@@ -179,12 +319,14 @@ async function handleAsyncMode(
179319
// Collect all transcription data for --out
180320
const allTransData: Record<string, unknown>[] = [];
181321

182-
for (let i = 0; i < results.length; i++) {
183-
const subResult = results[i]!;
322+
for (let index = 0; index < results.length; index++) {
323+
const subResult = results[index]!;
184324
const isMulti = fileCount > 1;
185325

186326
if (isMulti) {
187-
process.stdout.write(`=== [${i + 1}/${results.length}] ${subResult.file_url ?? ""} ===\n`);
327+
process.stdout.write(
328+
`=== [${index + 1}/${results.length}] ${subResult.file_url ?? ""} ===\n`,
329+
);
188330
}
189331

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

0 commit comments

Comments
 (0)