Skip to content

Commit ae0c2c1

Browse files
committed
fix(speech): handle qwen3-filetrans singular result.transcription_url
Normalize async ASR transcription items so waiting mode downloads text and --out works without changing shared media task types.
1 parent bd91e9d commit ae0c2c1

9 files changed

Lines changed: 304 additions & 22 deletions

File tree

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
resolveAsrApi,
1616
buildAsrFlashRequest,
1717
buildAsyncAsrLanguageFields,
18+
collectAsrTranscriptionItems,
1819
extractAsrFlashText,
1920
type AsrApiRoute,
2021
type AsrFlashFamily,
@@ -79,7 +80,7 @@ function assertSyncFlashFlagsAllowed(
7980
const unsupported: string[] = [];
8081
if (flags.diarization === true) unsupported.push("--diarization");
8182
if (flags.speakerCount !== undefined) unsupported.push("--speaker-count");
82-
// qwen3 sync Flash 不走 vocabulary_idinput-audio Flashfun-asr-flash* / qwen-audio-*-asr-flash)官方支持
83+
// qwen3 sync Flash does not use vocabulary_id; input-audio Flash (fun-asr-flash* / qwen-audio-*-asr-flash) does
8384
if (flashFamily === "qwen3" && flags.vocabularyId !== undefined) {
8485
unsupported.push("--vocabulary-id");
8586
}
@@ -308,7 +309,7 @@ async function handleAsyncMode(
308309
},
309310
});
310311

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

313314
if (results.length === 0) {
314315
emitResult({ task_id: taskId, status: result.output.task_status }, format);

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

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ describe("e2e: speech recognize", () => {
9797
});
9898

9999
test("speech recognize realtime 模型报用法错误", async () => {
100-
// 使用 --dry-run:跳过 auth,避免 CI 无密钥时先以 AUTH(3) 退出
100+
// Use --dry-run to skip auth so CI without API keys still hits USAGE(2)
101101
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
102102
"speech",
103103
"recognize",
@@ -188,6 +188,87 @@ describe("e2e: speech recognize", () => {
188188
expect(exitCode).toBe(2);
189189
expect(stderr).toMatch(/exactly one --url|sync Flash/i);
190190
});
191+
192+
test("speech recognize qwen3-filetrans 轮询成功后下载 result.transcription_url", async () => {
193+
const server = http.createServer((request, response) => {
194+
const url = request.url ?? "";
195+
const chunks: Buffer[] = [];
196+
request.on("data", (chunk: Buffer) => chunks.push(chunk));
197+
request.on("end", () => {
198+
response.writeHead(200, { "Content-Type": "application/json" });
199+
if (url.startsWith("/api/v1/services/audio/asr/transcription")) {
200+
response.end(
201+
JSON.stringify({
202+
output: { task_id: "task-qwen3", task_status: "PENDING" },
203+
request_id: "req-submit",
204+
}),
205+
);
206+
return;
207+
}
208+
if (url.startsWith("/api/v1/tasks/")) {
209+
const address = server.address() as AddressInfo;
210+
response.end(
211+
JSON.stringify({
212+
output: {
213+
task_id: "task-qwen3",
214+
task_status: "SUCCEEDED",
215+
result: {
216+
transcription_url: `http://127.0.0.1:${address.port}/transcription.json`,
217+
},
218+
},
219+
request_id: "req-poll",
220+
}),
221+
);
222+
return;
223+
}
224+
if (url.startsWith("/transcription.json")) {
225+
response.end(
226+
JSON.stringify({
227+
file_url: "https://example.com/a.wav",
228+
transcripts: [{ text: "你好世界", sentences: [{ text: "你好世界" }] }],
229+
}),
230+
);
231+
return;
232+
}
233+
response.writeHead(404);
234+
response.end(JSON.stringify({ message: `unexpected path: ${url}` }));
235+
});
236+
});
237+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
238+
const address = server.address() as AddressInfo;
239+
const outDir = makeE2eOutputDir("speech-recognize-qwen3-filetrans");
240+
const outPath = join(outDir, "result.json");
241+
242+
try {
243+
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
244+
"speech",
245+
"recognize",
246+
"--model",
247+
"qwen3-asr-flash-filetrans",
248+
"--url",
249+
"https://example.com/a.wav",
250+
"--language",
251+
"zh",
252+
"--api-key",
253+
"sk-e2e-placeholder",
254+
"--base-url",
255+
`http://127.0.0.1:${address.port}`,
256+
"--poll-interval",
257+
"1",
258+
"--out",
259+
outPath,
260+
"--quiet",
261+
]);
262+
263+
expect(exitCode, stderr).toBe(0);
264+
expect(stdout).toContain("你好世界");
265+
expect(JSON.parse(readFileSync(outPath, "utf8"))).toMatchObject({
266+
transcripts: [{ text: "你好世界" }],
267+
});
268+
} finally {
269+
await new Promise<void>((resolve) => server.close(() => resolve()));
270+
}
271+
});
191272
});
192273

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

packages/core/src/client/asr-routes.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ export function resolveAsrApi(model: string): AsrApiRoute {
136136

137137
/** Infer audio container hint for input-audio Flash `parameters.format`. */
138138
export function inferAudioFormatHint(audioUrl: string): string {
139-
// data URIdata:audio/mpeg;base64,... → mp3data:audio/x-wav;... → wav
139+
// data URI: data:audio/mpeg;base64,... → mp3; data:audio/x-wav;... → wav
140140
const dataType = /^data:audio\/([^;,]+)/i.exec(audioUrl)?.[1]?.toLowerCase();
141141
if (dataType) {
142142
if (dataType === "mpeg") return "mp3";
@@ -156,14 +156,14 @@ export interface BuildAsrFlashRequestOpts {
156156
model: string;
157157
audioUrl: string;
158158
language?: string;
159-
/** 预编译热词 ID;仅 input-audio Flashfun-asr-flash* / qwen-audio-*-asr-flash)官方支持 */
159+
/** Precompiled hotword vocabulary ID; supported for input-audio Flash (fun-asr-flash* / qwen-audio-*-asr-flash). */
160160
vocabularyId?: string;
161161
flashFamily: AsrFlashFamily;
162162
}
163163

164164
/**
165-
* 按异步路由的 language 字段风格构造语种参数。
166-
* qwen3-asr-flash-filetrans* → `language`;其余异步模型 → `language_hints`
165+
* Build language fields for async ASR routes.
166+
* qwen3-asr-flash-filetrans* → `language`; other async models → `language_hints`.
167167
*/
168168
export function buildAsyncAsrLanguageFields(
169169
languageStyle: "language_hints" | "language",
@@ -181,7 +181,7 @@ export function buildAsrFlashRequest(opts: BuildAsrFlashRequestOpts): Record<str
181181
const { model, audioUrl, language, vocabularyId, flashFamily } = opts;
182182

183183
if (flashFamily === "input-audio") {
184-
// 与官方 Qwen-Audio / Fun-ASR-Flash 文档一致:语种走 language_hints,热词走 vocabulary_id
184+
// Match official Qwen-Audio / Fun-ASR-Flash docs: language_hints + vocabulary_id
185185
const parameters: Record<string, unknown> = {
186186
format: inferAudioFormatHint(audioUrl),
187187
sample_rate: "16000",
@@ -294,3 +294,34 @@ export function extractAsrFlashText(
294294
}
295295
return texts.join("");
296296
}
297+
298+
/**
299+
* Normalize async ASR task transcription items:
300+
* - classic models: `output.results[]`
301+
* - qwen3-asr-flash-filetrans*: `output.result.transcription_url`
302+
*/
303+
export function collectAsrTranscriptionItems(output: {
304+
results?: Array<{
305+
file_url?: string;
306+
transcription_url?: string;
307+
subtask_status?: string;
308+
code?: string;
309+
message?: string;
310+
}>;
311+
result?: { transcription_url?: string };
312+
}): Array<{
313+
file_url?: string;
314+
transcription_url?: string;
315+
subtask_status?: string;
316+
code?: string;
317+
message?: string;
318+
}> {
319+
if (output.results && output.results.length > 0) {
320+
return output.results;
321+
}
322+
const transcriptionUrl = output.result?.transcription_url;
323+
if (typeof transcriptionUrl === "string" && transcriptionUrl.length > 0) {
324+
return [{ transcription_url: transcriptionUrl, subtask_status: "SUCCEEDED" }];
325+
}
326+
return [];
327+
}

packages/core/src/client/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export {
3737
export {
3838
buildAsrFlashRequest,
3939
buildAsyncAsrLanguageFields,
40+
collectAsrTranscriptionItems,
4041
extractAsrFlashText,
4142
inferAudioFormatHint,
4243
resolveAsrApi,

packages/core/src/types/api.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -538,32 +538,41 @@ export interface DashScopeASRRequest {
538538
};
539539
parameters?: {
540540
channel_id?: number[];
541-
/** fun-asr / paraformer / qwen-audio filetrans 等经典异步模型 */
541+
/** Classic async models (fun-asr / paraformer / qwen-audio filetrans, etc.) */
542542
language_hints?: string[];
543-
/** qwen3-asr-flash-filetrans* 使用单数字段 language */
543+
/** qwen3-asr-flash-filetrans* uses singular `language` */
544544
language?: string;
545545
diarization_enabled?: boolean;
546546
speaker_count?: number;
547547
vocabulary_id?: string;
548548
};
549549
}
550550

551+
export interface DashScopeASRTranscriptionItem {
552+
file_url?: string;
553+
transcription_url?: string;
554+
subtask_status?: string;
555+
code?: string;
556+
message?: string;
557+
}
558+
551559
export interface DashScopeASRTaskResult {
552560
output: {
553561
task_id: string;
554562
task_status: "PENDING" | "RUNNING" | "SUCCEEDED" | "FAILED" | "UNKNOWN";
555-
results?: Array<{
556-
file_url?: string;
563+
/** Multi-file async results (fun-asr / paraformer / qwen-audio filetrans, etc.) */
564+
results?: DashScopeASRTranscriptionItem[];
565+
/** Singular result returned by qwen3-asr-flash-filetrans* on success */
566+
result?: {
557567
transcription_url?: string;
558-
subtask_status?: string;
559-
code?: string;
560-
message?: string;
561-
}>;
568+
};
562569
task_metrics?: {
563570
TOTAL: number;
564571
SUCCEEDED: number;
565572
FAILED: number;
566573
};
574+
code?: string;
575+
message?: string;
567576
};
568577
usage?: Record<string, unknown>;
569578
request_id: string;

packages/core/src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export type {
4848
ChatTool,
4949
DashScopeASRRequest,
5050
DashScopeASRTaskResult,
51+
DashScopeASRTranscriptionItem,
5152
DashScopeAsyncResponse,
5253
DashScopeImageRequest,
5354
DashScopeImageSyncResponse,

packages/core/tests/asr-routes.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { expect, test } from "vite-plus/test";
22
import {
33
buildAsrFlashRequest,
44
buildAsyncAsrLanguageFields,
5+
collectAsrTranscriptionItems,
56
extractAsrFlashText,
67
inferAudioFormatHint,
78
resolveAsrApi,
@@ -194,3 +195,19 @@ test("extractAsrFlashText reads qwen3 choices and input-audio text fields", () =
194195
),
195196
).toBe("nested sentence");
196197
});
198+
199+
test("collectAsrTranscriptionItems prefers results[] then singular result", () => {
200+
expect(
201+
collectAsrTranscriptionItems({
202+
results: [{ transcription_url: "https://example.com/a.json", file_url: "https://a.wav" }],
203+
}),
204+
).toEqual([{ transcription_url: "https://example.com/a.json", file_url: "https://a.wav" }]);
205+
206+
expect(
207+
collectAsrTranscriptionItems({
208+
result: { transcription_url: "https://example.com/qwen3.json" },
209+
}),
210+
).toEqual([{ transcription_url: "https://example.com/qwen3.json", subtask_status: "SUCCEEDED" }]);
211+
212+
expect(collectAsrTranscriptionItems({})).toEqual([]);
213+
});

0 commit comments

Comments
 (0)