Skip to content

Commit e244771

Browse files
committed
test(speech): harden flash ASR contract coverage and docs
Add SSE disable header, data-URI format inference, broader response text parsing, HTTP contract e2e, pipeline routing tests, and ASR model selection guidance in bailian-gen.
1 parent 9379da7 commit e244771

7 files changed

Lines changed: 256 additions & 5 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ async function handleSyncFlashMode(
248248
const response = await client.requestJson<Record<string, unknown>>({
249249
path: route.path,
250250
method: "POST",
251+
headers: { "X-DashScope-SSE": "disable" },
251252
body,
252253
});
253254

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

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { readFileSync } from "node:fs";
2+
import http from "node:http";
3+
import type { AddressInfo } from "node:net";
24
import { join } from "node:path";
35
import { describe, expect, test } from "vite-plus/test";
46
import {
@@ -109,6 +111,83 @@ describe("e2e: speech recognize", () => {
109111
expect(exitCode).toBe(2);
110112
expect(stderr).toMatch(/realtime|WebSocket|unsupported/i);
111113
});
114+
115+
test("speech recognize flash 真实请求走 sync endpoint 并落盘 --out", async () => {
116+
let requestPath = "";
117+
let requestBody: Record<string, unknown> = {};
118+
let sseHeader: string | undefined;
119+
const server = http.createServer((request, response) => {
120+
const chunks: Buffer[] = [];
121+
request.on("data", (chunk: Buffer) => chunks.push(chunk));
122+
request.on("end", () => {
123+
requestPath = request.url ?? "";
124+
requestBody = JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, unknown>;
125+
sseHeader = request.headers["x-dashscope-sse"] as string | undefined;
126+
response.writeHead(200, { "Content-Type": "application/json" });
127+
response.end(
128+
JSON.stringify({
129+
output: { text: "flash recognition works" },
130+
request_id: "request-146",
131+
}),
132+
);
133+
});
134+
});
135+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
136+
const address = server.address() as AddressInfo;
137+
const outDir = makeE2eOutputDir("speech-recognize-flash-sync");
138+
const outPath = join(outDir, "result.json");
139+
140+
try {
141+
const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
142+
"speech",
143+
"recognize",
144+
"--model",
145+
"fun-asr-flash-2026-06-15",
146+
"--url",
147+
"https://example.com/sample.wav",
148+
"--api-key",
149+
"sk-e2e-placeholder",
150+
"--base-url",
151+
`http://127.0.0.1:${address.port}`,
152+
"--out",
153+
outPath,
154+
"--quiet",
155+
]);
156+
157+
expect(exitCode, stderr).toBe(0);
158+
expect(stdout).toContain("flash recognition works");
159+
expect(requestPath).toBe("/api/v1/services/aigc/multimodal-generation/generation");
160+
expect(sseHeader).toBe("disable");
161+
expect(requestBody).toMatchObject({
162+
model: "fun-asr-flash-2026-06-15",
163+
parameters: { format: "wav" },
164+
});
165+
expect(JSON.parse(readFileSync(outPath, "utf8"))).toMatchObject({
166+
output: { text: "flash recognition works" },
167+
request_id: "request-146",
168+
});
169+
} finally {
170+
await new Promise<void>((resolve) => server.close(() => resolve()));
171+
}
172+
});
173+
174+
test("speech recognize flash 多 --url 在发请求前报用法错误", async () => {
175+
const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [
176+
"speech",
177+
"recognize",
178+
"--model",
179+
"qwen-audio-3.0-asr-flash",
180+
"--url",
181+
"https://example.com/a.wav",
182+
"--url",
183+
"https://example.com/b.wav",
184+
"--dry-run",
185+
"--quiet",
186+
]);
187+
188+
expect(exitCode).toBe(2);
189+
expect(stderr).toMatch(/exactly one --url|sync Flash/i);
190+
});
112191
});
113192

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

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

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,15 @@ 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-
const pathPart = audioUrl.split("?")[0] ?? audioUrl;
139+
// data URI:data:audio/mpeg;base64,... → mp3;data:audio/x-wav;... → wav
140+
const dataType = /^data:audio\/([^;,]+)/i.exec(audioUrl)?.[1]?.toLowerCase();
141+
if (dataType) {
142+
if (dataType === "mpeg") return "mp3";
143+
if (dataType === "x-wav" || dataType === "wave") return "wav";
144+
return dataType;
145+
}
146+
147+
const pathPart = audioUrl.split(/[?#]/, 1)[0] ?? audioUrl;
140148
const match = pathPart.match(/\.([a-zA-Z0-9]+)$/);
141149
const extension = match?.[1]?.toLowerCase();
142150
if (!extension) return "wav";
@@ -232,7 +240,8 @@ export function buildAsrFlashRequest(opts: BuildAsrFlashRequestOpts): Record<str
232240

233241
/**
234242
* Extract recognition text from a sync Flash ASR response.
235-
* Qwen3 uses choices[].message.content; input-audio Flash uses output.text.
243+
* Qwen3 uses choices[].message.content; input-audio Flash uses output.text /
244+
* output.sentence.text / output.output.sentence.text.
236245
*/
237246
export function extractAsrFlashText(
238247
response: Record<string, unknown>,
@@ -245,10 +254,14 @@ export function extractAsrFlashText(
245254
if (typeof output.text === "string" && output.text.length > 0) {
246255
return output.text;
247256
}
257+
const topSentence = output.sentence as Record<string, unknown> | undefined;
258+
if (typeof topSentence?.text === "string" && topSentence.text.length > 0) {
259+
return topSentence.text;
260+
}
248261
const nested = output.output as Record<string, unknown> | undefined;
249-
const sentence = nested?.sentence as Record<string, unknown> | undefined;
250-
if (typeof sentence?.text === "string") {
251-
return sentence.text;
262+
const nestedSentence = nested?.sentence as Record<string, unknown> | undefined;
263+
if (typeof nestedSentence?.text === "string") {
264+
return nestedSentence.text;
252265
}
253266
return "";
254267
}

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ test("inferAudioFormatHint reads extension from url", () => {
9292
expect(inferAudioFormatHint("oss://bucket/path/file.WAV")).toBe("wav");
9393
expect(inferAudioFormatHint("https://example.com/a.mpeg?x=1")).toBe("mp3");
9494
expect(inferAudioFormatHint("https://example.com/noext")).toBe("wav");
95+
expect(inferAudioFormatHint("data:audio/mpeg;base64,AAA")).toBe("mp3");
96+
expect(inferAudioFormatHint("data:audio/x-wav;base64,AAA")).toBe("wav");
97+
expect(inferAudioFormatHint("data:audio/ogg;codecs=opus;base64,AAA")).toBe("ogg");
9598
});
9699

97100
test("buildAsrFlashRequest shapes qwen3 and input-audio bodies", () => {
@@ -168,4 +171,26 @@ test("extractAsrFlashText reads qwen3 choices and input-audio text fields", () =
168171
"input-audio",
169172
),
170173
).toBe("Hello World");
174+
175+
expect(
176+
extractAsrFlashText(
177+
{
178+
output: {
179+
sentence: { text: "top-level sentence" },
180+
},
181+
},
182+
"input-audio",
183+
),
184+
).toBe("top-level sentence");
185+
186+
expect(
187+
extractAsrFlashText(
188+
{
189+
output: {
190+
output: { sentence: { text: "nested sentence" } },
191+
},
192+
},
193+
"input-audio",
194+
),
195+
).toBe("nested sentence");
171196
});

packages/runtime/src/pipeline/steps/bl-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,7 @@ export async function speechRecognize(
651651
const response = await env.client.requestJson<Record<string, unknown>>({
652652
path: route.path,
653653
method: "POST",
654+
headers: { "X-DashScope-SSE": "disable" },
654655
body,
655656
signal: ctx.signal,
656657
});
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { expect, test } from "vite-plus/test";
2+
import type { Client } from "bailian-cli-core";
3+
import { PipelineError } from "../src/pipeline/errors.ts";
4+
import type { PipelineEnv } from "../src/pipeline/bl-config.ts";
5+
import { speechRecognize } from "../src/pipeline/steps/bl-api.ts";
6+
import type { StepContext } from "../src/pipeline/types.ts";
7+
8+
type CapturedRequest = {
9+
path?: string;
10+
method?: string;
11+
headers?: Record<string, string>;
12+
body?: Record<string, unknown>;
13+
async?: boolean;
14+
};
15+
16+
function makeEnv(requestJsonImpl?: (opts: CapturedRequest) => Promise<unknown>): {
17+
env: PipelineEnv;
18+
captured: CapturedRequest[];
19+
} {
20+
const captured: CapturedRequest[] = [];
21+
const client = {
22+
uploadFile: async (source: string) => source,
23+
requestJson: async (opts: CapturedRequest) => {
24+
captured.push(opts);
25+
if (requestJsonImpl) return requestJsonImpl(opts);
26+
return { output: { text: "ok" } };
27+
},
28+
} as unknown as Client;
29+
30+
return {
31+
env: {
32+
client,
33+
settings: { quiet: true, output: "json" } as PipelineEnv["settings"],
34+
},
35+
captured,
36+
};
37+
}
38+
39+
function makeCtx(): StepContext {
40+
return { dryRun: false, signal: new AbortController().signal };
41+
}
42+
43+
test("pipeline speechRecognize routes input-audio flash to sync multimodal endpoint", async () => {
44+
const { env, captured } = makeEnv();
45+
const result = (await speechRecognize(
46+
env,
47+
{
48+
url: "https://example.com/a.wav",
49+
model: "qwen-audio-3.0-asr-flash",
50+
language: "en",
51+
"vocabulary-id": "vocab-1",
52+
},
53+
makeCtx(),
54+
)) as { mode?: string; text?: string };
55+
56+
expect(result.mode).toBe("sync");
57+
expect(result.text).toBe("ok");
58+
expect(captured).toHaveLength(1);
59+
expect(captured[0]?.path).toBe("/api/v1/services/aigc/multimodal-generation/generation");
60+
expect(captured[0]?.headers?.["X-DashScope-SSE"]).toBe("disable");
61+
expect(captured[0]?.body).toMatchObject({
62+
model: "qwen-audio-3.0-asr-flash",
63+
parameters: {
64+
format: "wav",
65+
language_hints: ["en"],
66+
vocabulary_id: "vocab-1",
67+
},
68+
});
69+
});
70+
71+
test("pipeline speechRecognize maps qwen3-filetrans language to parameters.language", async () => {
72+
const { env, captured } = makeEnv(async (opts) => {
73+
if (opts.async || opts.method === "POST") {
74+
return { output: { task_id: "task-1", task_status: "PENDING" } };
75+
}
76+
return {
77+
output: { task_id: "task-1", task_status: "SUCCEEDED", results: [] },
78+
request_id: "r1",
79+
};
80+
});
81+
82+
await speechRecognize(
83+
env,
84+
{
85+
url: "https://example.com/a.wav",
86+
model: "qwen3-asr-flash-filetrans",
87+
language: "zh",
88+
"poll-interval": 0,
89+
},
90+
makeCtx(),
91+
);
92+
93+
expect(captured[0]?.path).toBe("/api/v1/services/audio/asr/transcription");
94+
expect(captured[0]?.async).toBe(true);
95+
expect(captured[0]?.body).toMatchObject({
96+
model: "qwen3-asr-flash-filetrans",
97+
input: { file_url: "https://example.com/a.wav" },
98+
parameters: { language: "zh" },
99+
});
100+
expect(
101+
(captured[0]?.body?.parameters as Record<string, unknown> | undefined)?.language_hints,
102+
).toBeUndefined();
103+
});
104+
105+
test("pipeline speechRecognize rejects realtime models before requesting", async () => {
106+
const { env, captured } = makeEnv();
107+
await expect(
108+
speechRecognize(
109+
env,
110+
{ url: "https://example.com/a.wav", model: "qwen3-asr-flash-realtime" },
111+
makeCtx(),
112+
),
113+
).rejects.toBeInstanceOf(PipelineError);
114+
expect(captured).toHaveLength(0);
115+
});
116+
117+
test("pipeline speechRecognize rejects multiple urls for sync flash", async () => {
118+
const { env, captured } = makeEnv();
119+
await expect(
120+
speechRecognize(
121+
env,
122+
{
123+
url: ["https://example.com/a.wav", "https://example.com/b.wav"],
124+
model: "fun-asr-flash-2026-06-15",
125+
},
126+
makeCtx(),
127+
),
128+
).rejects.toBeInstanceOf(PipelineError);
129+
expect(captured).toHaveLength(0);
130+
});

skills/bailian-gen/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ description: >-
3939
| A/V understanding (files the host can't play) | `bl omni --video` / `--audio` | `qwen3.5-omni-plus` |
4040
| Image/video describe (user names Bailian) | `bl vision describe` | `qwen-vl-max`; host-first for plain image Q&A |
4141

42+
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.
43+
4244
Flags, usage, and examples: see [`reference/`](reference/index.md) or `bl <command> --help` — do not guess flags.
4345

4446
## Local files (mandatory)

0 commit comments

Comments
 (0)