From e244771ee9debaf8f743020f1fa1ecdddc67ee9b Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Thu, 13 Aug 2026 14:25:47 +0800 Subject: [PATCH] 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. --- .../commands/src/commands/speech/recognize.ts | 1 + .../tests/e2e/speech-recognize.e2e.test.ts | 79 +++++++++++ packages/core/src/client/asr-routes.ts | 23 +++- packages/core/tests/asr-routes.test.ts | 25 ++++ packages/runtime/src/pipeline/steps/bl-api.ts | 1 + .../tests/speech-recognize-pipeline.test.ts | 130 ++++++++++++++++++ skills/bailian-gen/SKILL.md | 2 + 7 files changed, 256 insertions(+), 5 deletions(-) create mode 100644 packages/runtime/tests/speech-recognize-pipeline.test.ts diff --git a/packages/commands/src/commands/speech/recognize.ts b/packages/commands/src/commands/speech/recognize.ts index f6910fc..92b9842 100644 --- a/packages/commands/src/commands/speech/recognize.ts +++ b/packages/commands/src/commands/speech/recognize.ts @@ -248,6 +248,7 @@ async function handleSyncFlashMode( const response = await client.requestJson>({ path: route.path, method: "POST", + headers: { "X-DashScope-SSE": "disable" }, body, }); diff --git a/packages/commands/tests/e2e/speech-recognize.e2e.test.ts b/packages/commands/tests/e2e/speech-recognize.e2e.test.ts index 7ecf828..fcdac5c 100644 --- a/packages/commands/tests/e2e/speech-recognize.e2e.test.ts +++ b/packages/commands/tests/e2e/speech-recognize.e2e.test.ts @@ -1,4 +1,6 @@ import { readFileSync } from "node:fs"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; import { join } from "node:path"; import { describe, expect, test } from "vite-plus/test"; import { @@ -109,6 +111,83 @@ describe("e2e: speech recognize", () => { expect(exitCode).toBe(2); expect(stderr).toMatch(/realtime|WebSocket|unsupported/i); }); + + test("speech recognize flash 真实请求走 sync endpoint 并落盘 --out", async () => { + let requestPath = ""; + let requestBody: Record = {}; + let sseHeader: string | undefined; + const server = http.createServer((request, response) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => { + requestPath = request.url ?? ""; + requestBody = JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record; + sseHeader = request.headers["x-dashscope-sse"] as string | undefined; + response.writeHead(200, { "Content-Type": "application/json" }); + response.end( + JSON.stringify({ + output: { text: "flash recognition works" }, + request_id: "request-146", + }), + ); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as AddressInfo; + const outDir = makeE2eOutputDir("speech-recognize-flash-sync"); + const outPath = join(outDir, "result.json"); + + try { + const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "recognize", + "--model", + "fun-asr-flash-2026-06-15", + "--url", + "https://example.com/sample.wav", + "--api-key", + "sk-e2e-placeholder", + "--base-url", + `http://127.0.0.1:${address.port}`, + "--out", + outPath, + "--quiet", + ]); + + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain("flash recognition works"); + expect(requestPath).toBe("/api/v1/services/aigc/multimodal-generation/generation"); + expect(sseHeader).toBe("disable"); + expect(requestBody).toMatchObject({ + model: "fun-asr-flash-2026-06-15", + parameters: { format: "wav" }, + }); + expect(JSON.parse(readFileSync(outPath, "utf8"))).toMatchObject({ + output: { text: "flash recognition works" }, + request_id: "request-146", + }); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + test("speech recognize flash 多 --url 在发请求前报用法错误", async () => { + const { stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "recognize", + "--model", + "qwen-audio-3.0-asr-flash", + "--url", + "https://example.com/a.wav", + "--url", + "https://example.com/b.wav", + "--dry-run", + "--quiet", + ]); + + expect(exitCode).toBe(2); + expect(stderr).toMatch(/exactly one --url|sync Flash/i); + }); }); describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())( diff --git a/packages/core/src/client/asr-routes.ts b/packages/core/src/client/asr-routes.ts index 4abd741..ddf4ead 100644 --- a/packages/core/src/client/asr-routes.ts +++ b/packages/core/src/client/asr-routes.ts @@ -136,7 +136,15 @@ export function resolveAsrApi(model: string): AsrApiRoute { /** Infer audio container hint for input-audio Flash `parameters.format`. */ export function inferAudioFormatHint(audioUrl: string): string { - const pathPart = audioUrl.split("?")[0] ?? audioUrl; + // data URI:data:audio/mpeg;base64,... → mp3;data:audio/x-wav;... → wav + const dataType = /^data:audio\/([^;,]+)/i.exec(audioUrl)?.[1]?.toLowerCase(); + if (dataType) { + if (dataType === "mpeg") return "mp3"; + if (dataType === "x-wav" || dataType === "wave") return "wav"; + return dataType; + } + + const pathPart = audioUrl.split(/[?#]/, 1)[0] ?? audioUrl; const match = pathPart.match(/\.([a-zA-Z0-9]+)$/); const extension = match?.[1]?.toLowerCase(); if (!extension) return "wav"; @@ -232,7 +240,8 @@ export function buildAsrFlashRequest(opts: BuildAsrFlashRequestOpts): Record, @@ -245,10 +254,14 @@ export function extractAsrFlashText( if (typeof output.text === "string" && output.text.length > 0) { return output.text; } + const topSentence = output.sentence as Record | undefined; + if (typeof topSentence?.text === "string" && topSentence.text.length > 0) { + return topSentence.text; + } const nested = output.output as Record | undefined; - const sentence = nested?.sentence as Record | undefined; - if (typeof sentence?.text === "string") { - return sentence.text; + const nestedSentence = nested?.sentence as Record | undefined; + if (typeof nestedSentence?.text === "string") { + return nestedSentence.text; } return ""; } diff --git a/packages/core/tests/asr-routes.test.ts b/packages/core/tests/asr-routes.test.ts index 03c4ee0..dc2e021 100644 --- a/packages/core/tests/asr-routes.test.ts +++ b/packages/core/tests/asr-routes.test.ts @@ -92,6 +92,9 @@ test("inferAudioFormatHint reads extension from url", () => { expect(inferAudioFormatHint("oss://bucket/path/file.WAV")).toBe("wav"); expect(inferAudioFormatHint("https://example.com/a.mpeg?x=1")).toBe("mp3"); expect(inferAudioFormatHint("https://example.com/noext")).toBe("wav"); + expect(inferAudioFormatHint("data:audio/mpeg;base64,AAA")).toBe("mp3"); + expect(inferAudioFormatHint("data:audio/x-wav;base64,AAA")).toBe("wav"); + expect(inferAudioFormatHint("data:audio/ogg;codecs=opus;base64,AAA")).toBe("ogg"); }); test("buildAsrFlashRequest shapes qwen3 and input-audio bodies", () => { @@ -168,4 +171,26 @@ test("extractAsrFlashText reads qwen3 choices and input-audio text fields", () = "input-audio", ), ).toBe("Hello World"); + + expect( + extractAsrFlashText( + { + output: { + sentence: { text: "top-level sentence" }, + }, + }, + "input-audio", + ), + ).toBe("top-level sentence"); + + expect( + extractAsrFlashText( + { + output: { + output: { sentence: { text: "nested sentence" } }, + }, + }, + "input-audio", + ), + ).toBe("nested sentence"); }); diff --git a/packages/runtime/src/pipeline/steps/bl-api.ts b/packages/runtime/src/pipeline/steps/bl-api.ts index a3ea8ca..51c2a57 100644 --- a/packages/runtime/src/pipeline/steps/bl-api.ts +++ b/packages/runtime/src/pipeline/steps/bl-api.ts @@ -651,6 +651,7 @@ export async function speechRecognize( const response = await env.client.requestJson>({ path: route.path, method: "POST", + headers: { "X-DashScope-SSE": "disable" }, body, signal: ctx.signal, }); diff --git a/packages/runtime/tests/speech-recognize-pipeline.test.ts b/packages/runtime/tests/speech-recognize-pipeline.test.ts new file mode 100644 index 0000000..b779cc4 --- /dev/null +++ b/packages/runtime/tests/speech-recognize-pipeline.test.ts @@ -0,0 +1,130 @@ +import { expect, test } from "vite-plus/test"; +import type { Client } from "bailian-cli-core"; +import { PipelineError } from "../src/pipeline/errors.ts"; +import type { PipelineEnv } from "../src/pipeline/bl-config.ts"; +import { speechRecognize } from "../src/pipeline/steps/bl-api.ts"; +import type { StepContext } from "../src/pipeline/types.ts"; + +type CapturedRequest = { + path?: string; + method?: string; + headers?: Record; + body?: Record; + async?: boolean; +}; + +function makeEnv(requestJsonImpl?: (opts: CapturedRequest) => Promise): { + env: PipelineEnv; + captured: CapturedRequest[]; +} { + const captured: CapturedRequest[] = []; + const client = { + uploadFile: async (source: string) => source, + requestJson: async (opts: CapturedRequest) => { + captured.push(opts); + if (requestJsonImpl) return requestJsonImpl(opts); + return { output: { text: "ok" } }; + }, + } as unknown as Client; + + return { + env: { + client, + settings: { quiet: true, output: "json" } as PipelineEnv["settings"], + }, + captured, + }; +} + +function makeCtx(): StepContext { + return { dryRun: false, signal: new AbortController().signal }; +} + +test("pipeline speechRecognize routes input-audio flash to sync multimodal endpoint", async () => { + const { env, captured } = makeEnv(); + const result = (await speechRecognize( + env, + { + url: "https://example.com/a.wav", + model: "qwen-audio-3.0-asr-flash", + language: "en", + "vocabulary-id": "vocab-1", + }, + makeCtx(), + )) as { mode?: string; text?: string }; + + expect(result.mode).toBe("sync"); + expect(result.text).toBe("ok"); + expect(captured).toHaveLength(1); + expect(captured[0]?.path).toBe("/api/v1/services/aigc/multimodal-generation/generation"); + expect(captured[0]?.headers?.["X-DashScope-SSE"]).toBe("disable"); + expect(captured[0]?.body).toMatchObject({ + model: "qwen-audio-3.0-asr-flash", + parameters: { + format: "wav", + language_hints: ["en"], + vocabulary_id: "vocab-1", + }, + }); +}); + +test("pipeline speechRecognize maps qwen3-filetrans language to parameters.language", 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: "qwen3-asr-flash-filetrans", + language: "zh", + "poll-interval": 0, + }, + makeCtx(), + ); + + expect(captured[0]?.path).toBe("/api/v1/services/audio/asr/transcription"); + expect(captured[0]?.async).toBe(true); + expect(captured[0]?.body).toMatchObject({ + model: "qwen3-asr-flash-filetrans", + input: { file_url: "https://example.com/a.wav" }, + parameters: { language: "zh" }, + }); + expect( + (captured[0]?.body?.parameters as Record | undefined)?.language_hints, + ).toBeUndefined(); +}); + +test("pipeline speechRecognize rejects realtime models before requesting", async () => { + const { env, captured } = makeEnv(); + await expect( + speechRecognize( + env, + { url: "https://example.com/a.wav", model: "qwen3-asr-flash-realtime" }, + makeCtx(), + ), + ).rejects.toBeInstanceOf(PipelineError); + expect(captured).toHaveLength(0); +}); + +test("pipeline speechRecognize rejects multiple urls for sync flash", async () => { + const { env, captured } = makeEnv(); + await expect( + speechRecognize( + env, + { + url: ["https://example.com/a.wav", "https://example.com/b.wav"], + model: "fun-asr-flash-2026-06-15", + }, + makeCtx(), + ), + ).rejects.toBeInstanceOf(PipelineError); + expect(captured).toHaveLength(0); +}); diff --git a/skills/bailian-gen/SKILL.md b/skills/bailian-gen/SKILL.md index 233e99b..238185e 100644 --- a/skills/bailian-gen/SKILL.md +++ b/skills/bailian-gen/SKILL.md @@ -39,6 +39,8 @@ description: >- | A/V understanding (files the host can't play) | `bl omni --video` / `--audio` | `qwen3.5-omni-plus` | | Image/video describe (user names Bailian) | `bl vision describe` | `qwen-vl-max`; host-first for plain image Q&A | +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. + Flags, usage, and examples: see [`reference/`](reference/index.md) or `bl --help` — do not guess flags. ## Local files (mandatory)