Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 116 additions & 2 deletions packages/commands/src/commands/speech/recognize.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { writeFileSync } from "fs";
import { extname } from "node:path";
import {
BailianError,
defineCommand,
Expand All @@ -11,6 +12,7 @@ import {
type DashScopeAsyncResponse,
stripUndefined,
taskPath,
speechRecognizeFlashPath,
speechRecognizePath,
type OutputFormat,
type FlagsDef,
Expand All @@ -24,7 +26,7 @@ const RECOGNIZE_FLAGS = {
url: {
type: "array",
valueHint: "<url>",
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: "<model>", description: "Model ID (default: fun-asr)" },
Expand Down Expand Up @@ -55,6 +57,32 @@ const RECOGNIZE_FLAGS = {
} satisfies FlagsDef;
type RecognizeFlags = ParsedFlags<typeof RECOGNIZE_FLAGS>;

interface DashScopeFlashASRResponse {
output?: {
text?: string;
sentence?: { text?: string };
output?: { sentence?: { text?: string } };
};
request_id?: string;
usage?: Record<string, unknown>;
}

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",
Expand All @@ -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;
Expand All @@ -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<string, unknown>);
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;
Expand All @@ -116,7 +197,7 @@ export default defineCommand({
stripUndefined(body.parameters as Record<string, unknown>);

if (settings.dryRun) {
emitResult({ request: body, mode: "async" }, format);
emitResult({ request: body, mode: "async", path: speechRecognizePath() }, format);
return;
}

Expand All @@ -128,6 +209,39 @@ export default defineCommand({
},
});

async function handleSyncFlashMode(
client: Client,
settings: Settings,
path: string,
body: Record<string, unknown>,
flags: RecognizeFlags,
format: OutputFormat,
): Promise<void> {
const response = await client.requestJson<DashScopeFlashASRResponse>({
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,
Expand Down
136 changes: 136 additions & 0 deletions packages/commands/tests/e2e/speech-recognize.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<string, unknown> = {};
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<string, unknown>;
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<void>((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<void>((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())(
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/client/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export {
memorySearchPath,
mcpWebSearchPath,
profileSchemaPath,
speechRecognizeFlashPath,
speechRecognizePath,
speechSynthesizePath,
taskPath,
Expand Down
2 changes: 2 additions & 0 deletions skills/bailian-gen/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command> --help` — do not guess flags.

## Local files (mandatory)
Expand Down
36 changes: 22 additions & 14 deletions skills/bailian-gen/reference/speech.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,20 @@ Index: [index.md](index.md)

#### Flags

| Flag | Type | Required | Description |
| --------------------------- | ------ | -------- | ------------------------------------------------------- |
| `--url <url>` | array | yes | Audio file URL or local file path (repeatable, max 100) |
| `--model <model>` | string | no | Model ID (default: fun-asr) |
| `--language <lang>` | string | no | Language hint (e.g. zh, en, ja) |
| `--diarization` | switch | no | Enable automatic speaker diarization |
| `--speaker-count <n>` | number | no | Expected number of speakers (requires --diarization) |
| `--vocabulary-id <id>` | string | no | Hot-word vocabulary ID for improved accuracy |
| `--channel-id <n>` | number | no | Audio channel ID (default: 0) |
| `--out <path>` | string | no | Save full transcription result to JSON file |
| `--async` | switch | no | Return async task id without waiting |
| `--poll-interval <seconds>` | number | no | Polling interval in seconds (default: 2) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
| Flag | Type | Required | Description |
| --------------------------- | ------ | -------- | -------------------------------------------------------------- |
| `--url <url>` | array | yes | Audio URL or local path (repeatable for async models, max 100) |
| `--model <model>` | string | no | Model ID (default: fun-asr) |
| `--language <lang>` | string | no | Language hint (e.g. zh, en, ja) |
| `--diarization` | switch | no | Enable automatic speaker diarization |
| `--speaker-count <n>` | number | no | Expected number of speakers (requires --diarization) |
| `--vocabulary-id <id>` | string | no | Hot-word vocabulary ID for improved accuracy |
| `--channel-id <n>` | number | no | Audio channel ID (default: 0) |
| `--out <path>` | string | no | Save full transcription result to JSON file |
| `--async` | switch | no | Return async task id without waiting |
| `--poll-interval <seconds>` | number | no | Polling interval in seconds (default: 2) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |

#### Examples

Expand Down Expand Up @@ -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 |
Expand Down