diff --git a/packages/commands/src/commands/speech/recognize.ts b/packages/commands/src/commands/speech/recognize.ts index 5af606bb..bb25ff8a 100644 --- a/packages/commands/src/commands/speech/recognize.ts +++ b/packages/commands/src/commands/speech/recognize.ts @@ -1,4 +1,5 @@ import { writeFileSync } from "fs"; +import { extname } from "node:path"; import { BailianError, defineCommand, @@ -11,6 +12,7 @@ import { type DashScopeAsyncResponse, stripUndefined, taskPath, + speechRecognizeFlashPath, speechRecognizePath, type OutputFormat, type FlagsDef, @@ -24,7 +26,7 @@ const RECOGNIZE_FLAGS = { url: { type: "array", valueHint: "", - description: "Audio file URL or local file path (repeatable, max 100)", + description: "Audio URL or local path (repeatable for async models, max 100)", required: true, }, model: { type: "string", valueHint: "", description: "Model ID (default: fun-asr)" }, @@ -55,6 +57,32 @@ const RECOGNIZE_FLAGS = { } satisfies FlagsDef; type RecognizeFlags = ParsedFlags; +interface DashScopeFlashASRResponse { + output?: { + text?: string; + sentence?: { text?: string }; + output?: { sentence?: { text?: string } }; + }; + request_id?: string; + usage?: Record; +} + +function isSynchronousFlashModel(model: string): boolean { + return /^(?:fun-asr-flash|qwen-audio-3\.0-asr-flash)(?:-\d{4}-\d{2}-\d{2})?$/.test(model); +} + +function inferAudioFormat(source: string): string { + const dataType = /^data:audio\/([^;,]+)/i.exec(source)?.[1]?.toLowerCase(); + if (dataType) { + if (dataType === "mpeg") return "mp3"; + if (dataType === "x-wav") return "wav"; + return dataType; + } + + const pathPart = source.split(/[?#]/, 1)[0] ?? source; + return extname(pathPart).slice(1).toLowerCase() || "wav"; +} + export default defineCommand({ description: "Recognize speech from audio files (FunAudio-ASR)", auth: "apiKey", @@ -68,6 +96,8 @@ export default defineCommand({ "--url https://example.com/audio.mp3 --vocabulary-id vocab-abc123", "--url https://example.com/audio.mp3 --out result.json", "--url https://example.com/audio.mp3 --async --quiet", + "--model fun-asr-flash-2026-06-15 --url ./meeting.wav --language zh", + "--model qwen-audio-3.0-asr-flash --url ./meeting.wav --out result.json", ], async run(ctx) { const { settings, flags } = ctx; @@ -92,6 +122,57 @@ export default defineCommand({ const model = flags.model || "fun-asr"; const format = detectOutputFormat(settings.output); + if (isSynchronousFlashModel(model)) { + if (rawUrls.length !== 1) { + throw new BailianError( + `${model} accepts exactly one audio file per request.`, + ExitCode.USAGE, + ); + } + if ( + diarization || + flags.channelId !== undefined || + flags.async || + flags.pollInterval !== undefined + ) { + throw new BailianError( + `${model} uses synchronous recognition and does not support --diarization, --channel-id, --async, or --poll-interval. Use fun-asr or a filetrans model for those options.`, + ExitCode.USAGE, + ); + } + + const resolvedUrl = await ctx.client.uploadFile(rawUrls[0]!, model); + const body = { + model, + input: { + messages: [ + { + role: "user", + content: [{ type: "input_audio", input_audio: { data: resolvedUrl } }], + }, + ], + }, + parameters: { + format: inferAudioFormat(rawUrls[0]!), + language_hints: flags.language ? [flags.language] : undefined, + vocabulary_id: flags.vocabularyId, + }, + }; + stripUndefined(body.parameters as Record); + const path = speechRecognizeFlashPath(); + + if (settings.dryRun) { + emitResult({ request: body, mode: "sync", path }, format); + return; + } + + if (!settings.quiet) { + process.stderr.write(`[Model: ${model}] [Mode: sync] [Files: 1]\n`); + } + await handleSyncFlashMode(ctx.client, settings, path, body, flags, format); + return; + } + // Auto-upload local files in parallel const resolvedUrls = await Promise.all(rawUrls.map((u) => ctx.client.uploadFile(u, model))); const channelId = flags.channelId; @@ -116,7 +197,7 @@ export default defineCommand({ stripUndefined(body.parameters as Record); if (settings.dryRun) { - emitResult({ request: body, mode: "async" }, format); + emitResult({ request: body, mode: "async", path: speechRecognizePath() }, format); return; } @@ -128,6 +209,39 @@ export default defineCommand({ }, }); +async function handleSyncFlashMode( + client: Client, + settings: Settings, + path: string, + body: Record, + flags: RecognizeFlags, + format: OutputFormat, +): Promise { + const response = await client.requestJson({ + path, + method: "POST", + headers: { "X-DashScope-SSE": "disable" }, + body, + }); + + const text = + response.output?.text ?? + response.output?.sentence?.text ?? + response.output?.output?.sentence?.text; + if (text) { + emitBare(text); + } else { + emitResult(response, format); + } + + if (flags.out) { + writeFileSync(flags.out, JSON.stringify(response, null, 2) + "\n"); + if (!settings.quiet) { + process.stderr.write(`Full result saved to: ${flags.out}\n`); + } + } +} + async function handleAsyncMode( client: Client, settings: Settings, diff --git a/packages/commands/tests/e2e/speech-recognize.e2e.test.ts b/packages/commands/tests/e2e/speech-recognize.e2e.test.ts index 926af0ef..056b0b85 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 { @@ -25,6 +27,140 @@ describe("e2e: speech recognize", () => { expect(exitCode, stderr).toBe(0); expect(stderr).toMatch(/recognize|--url|model|audio/i); }); + + test.each(["fun-asr-flash-2026-06-15", "qwen-audio-3.0-asr-flash"])( + "%s dry-run uses the synchronous multimodal endpoint", + async (model) => { + const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "recognize", + "--model", + model, + "--url", + "https://example.com/sample.mp3", + "--language", + "en", + "--dry-run", + "--output", + "json", + ]); + + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ + mode?: string; + path?: string; + request?: { + input?: { + messages?: Array<{ + content?: Array<{ input_audio?: { data?: string } }>; + }>; + }; + parameters?: { format?: string; language_hints?: string[] }; + }; + }>(stdout); + expect(data.mode).toBe("sync"); + expect(data.path).toBe("/api/v1/services/aigc/multimodal-generation/generation"); + expect(data.request?.input?.messages?.[0]?.content?.[0]?.input_audio?.data).toBe( + "https://example.com/sample.mp3", + ); + expect(data.request?.parameters).toMatchObject({ format: "mp3", language_hints: ["en"] }); + }, + ); + + test("fun-asr dry-run keeps the asynchronous transcription endpoint", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(SPEECH_ROUTES, [ + "speech", + "recognize", + "--model", + "fun-asr", + "--url", + "https://example.com/sample.mp3", + "--dry-run", + "--output", + "json", + ]); + + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ mode?: string; path?: string }>(stdout); + expect(data.mode).toBe("async"); + expect(data.path).toBe("/api/v1/services/audio/asr/transcription"); + }); + + test("flash recognition posts to the sync endpoint and saves the complete response", 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("flash recognition rejects multiple files before making a request", 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", + ]); + + expect(exitCode).toBe(2); + expect(stderr).toContain("accepts exactly one audio file per request"); + }); }); describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())( diff --git a/packages/core/src/client/endpoints.ts b/packages/core/src/client/endpoints.ts index 119c7bda..9e9f662f 100644 --- a/packages/core/src/client/endpoints.ts +++ b/packages/core/src/client/endpoints.ts @@ -69,6 +69,11 @@ export function speechRecognizePath(): string { return "/api/v1/services/audio/asr/transcription"; } +/** Synchronous HTTP endpoint used by Fun-ASR-Flash and Qwen-Audio ASR Flash. */ +export function speechRecognizeFlashPath(): string { + return "/api/v1/services/aigc/multimodal-generation/generation"; +} + // ---- Memory Profile (DashScope v2) ---- export function profileSchemaPath(): string { return "/api/v2/apps/memory/profile_schemas"; diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index 31bd04a7..ba2ddead 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -14,6 +14,7 @@ export { memorySearchPath, mcpWebSearchPath, profileSchemaPath, + speechRecognizeFlashPath, speechRecognizePath, speechSynthesizePath, taskPath, diff --git a/skills/bailian-gen/SKILL.md b/skills/bailian-gen/SKILL.md index 14297c2f..691d9b50 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` for long recordings, repeated files, speaker diarization, or asynchronous task IDs. For one local or remote audio file up to five minutes, use `--model fun-asr-flash-2026-06-15` or `--model qwen-audio-3.0-asr-flash` when the user requests the corresponding low-latency model. 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) diff --git a/skills/bailian-gen/reference/speech.md b/skills/bailian-gen/reference/speech.md index 5a23db62..af29c846 100644 --- a/skills/bailian-gen/reference/speech.md +++ b/skills/bailian-gen/reference/speech.md @@ -24,20 +24,20 @@ Index: [index.md](index.md) #### Flags -| Flag | Type | Required | Description | -| --------------------------- | ------ | -------- | ------------------------------------------------------- | -| `--url ` | array | yes | Audio file URL or local file path (repeatable, max 100) | -| `--model ` | string | no | Model ID (default: fun-asr) | -| `--language ` | string | no | Language hint (e.g. zh, en, ja) | -| `--diarization` | switch | no | Enable automatic speaker diarization | -| `--speaker-count ` | number | no | Expected number of speakers (requires --diarization) | -| `--vocabulary-id ` | string | no | Hot-word vocabulary ID for improved accuracy | -| `--channel-id ` | number | no | Audio channel ID (default: 0) | -| `--out ` | string | no | Save full transcription result to JSON file | -| `--async` | switch | no | Return async task id without waiting | -| `--poll-interval ` | number | no | Polling interval in seconds (default: 2) | -| `--api-key ` | string | no | API key | -| `--base-url ` | string | no | API base URL | +| Flag | Type | Required | Description | +| --------------------------- | ------ | -------- | -------------------------------------------------------------- | +| `--url ` | array | yes | Audio URL or local path (repeatable for async models, max 100) | +| `--model ` | string | no | Model ID (default: fun-asr) | +| `--language ` | string | no | Language hint (e.g. zh, en, ja) | +| `--diarization` | switch | no | Enable automatic speaker diarization | +| `--speaker-count ` | number | no | Expected number of speakers (requires --diarization) | +| `--vocabulary-id ` | string | no | Hot-word vocabulary ID for improved accuracy | +| `--channel-id ` | number | no | Audio channel ID (default: 0) | +| `--out ` | string | no | Save full transcription result to JSON file | +| `--async` | switch | no | Return async task id without waiting | +| `--poll-interval ` | number | no | Polling interval in seconds (default: 2) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | #### Examples @@ -69,6 +69,14 @@ bl speech recognize --url https://example.com/audio.mp3 --out result.json bl speech recognize --url https://example.com/audio.mp3 --async --quiet ``` +```bash +bl speech recognize --model fun-asr-flash-2026-06-15 --url ./meeting.wav --language zh +``` + +```bash +bl speech recognize --model qwen-audio-3.0-asr-flash --url ./meeting.wav --out result.json +``` + ### `bl speech synthesize` | Field | Value |