mirror of
https://github.com/modelscope/FunASR.git
synced 2026-10-12 02:19:18 +00:00
docs: harden JavaScript integration recipes and response contracts
Signed-off-by: zhifu gao <[email protected]>
This commit is contained in:
@@ -1,65 +1,101 @@
|
||||
# JavaScript and TypeScript Recipes for the FunASR OpenAI-Compatible API
|
||||
|
||||
Use this guide when `funasr-server` is already running and you want to connect Node.js, TypeScript services, Next.js route handlers, or other JavaScript agent workflows to local speech recognition.
|
||||
Use these multipart HTTP recipes for Node.js, TypeScript services and a native Web API forwarding function. The repository's example `server.py` and packaged `funasr-server` are different implementations, not interchangeable defaults or full OpenAI API compatibility guarantees.
|
||||
|
||||
The example has **no built-in authentication or upload limit**. Keep local checks on loopback. Before sharing, configure TLS, gateway authentication, upload/time/rate limits, private health/model/schema access and audio/transcript retention using the [security guide](SECURITY.md). A placeholder API key is not authentication.
|
||||
|
||||
## Preflight
|
||||
|
||||
Start the API server first:
|
||||
Prepare the checkout and Python environment using the [example README](README.md#quick-start). Its source revision is not a lock for dependencies or weights, nor a clean-install claim. From that checkout's root, with `.venv` already prepared, start one local example server:
|
||||
|
||||
```bash
|
||||
cd examples/openai_api
|
||||
python server.py --model sensevoice --device cuda --port 8000
|
||||
source ../../.venv/bin/activate
|
||||
python server.py --host 127.0.0.1 --model sensevoice --device cpu --port 8000
|
||||
```
|
||||
|
||||
For offline multi-speaker files, start the isolated MOSS service with
|
||||
`funasr-server --model moss-transcribe-diarize --device cuda:0`, set
|
||||
`FUNASR_MODEL=moss-transcribe-diarize`, and request `verbose_json`. See the
|
||||
[MOSS deployment guide](../../docs/moss_transcribe_diarize.md).
|
||||
If already running, skip startup. After preparing CUDA dependencies, replace the CPU command with `python server.py --host 127.0.0.1 --model sensevoice --device cuda --port 8000`; do not occupy the same port twice. The separate packaged route and its defaults are covered by [Agent integration](../../docs/agent_integration.md).
|
||||
|
||||
Then verify the service from another terminal:
|
||||
For offline multi-speaker files, first prepare the isolated third-party service in the [MOSS deployment guide](../../docs/moss_transcribe_diarize.md), including its GPU/file-duration limits and private binding. Then set `FUNASR_MODEL=moss-transcribe-diarize` for the JS clients and request `verbose_json`. MOSS provides native anonymous recording-local labels, not verified identities; do not add external VAD or `spk=true`. A client alias alone does not prepare that environment.
|
||||
|
||||
In a second terminal on the same host, enter the same checkout's `examples/openai_api` directory and activate the same environment. Wait for model loading before checking the local service:
|
||||
|
||||
```bash
|
||||
python smoke_test.py --base-url http://localhost:8000
|
||||
source ../../.venv/bin/activate
|
||||
curl -fsS http://127.0.0.1:8000/health
|
||||
curl -fsS http://127.0.0.1:8000/v1/models
|
||||
curl -fsS http://127.0.0.1:8000/openapi.json
|
||||
python smoke_test.py --base-url http://127.0.0.1:8000 --model sensevoice
|
||||
```
|
||||
|
||||
This optional smoke script downloads a public Chinese `sample.wav` if missing. It is not a multilingual accuracy benchmark; health/schema checks alone do not verify transcription. The Python script reads `MODEL`/`BASE_URL` or explicit flags, not `FUNASR_MODEL`. To check MOSS, change its `--model` as well, against the prepared MOSS service.
|
||||
|
||||
SDK base URLs include `/v1`; direct health checks do not:
|
||||
|
||||
```text
|
||||
OpenAI SDK baseURL: http://localhost:8000/v1
|
||||
Health endpoint: http://localhost:8000/health
|
||||
Transcription URL: http://localhost:8000/v1/audio/transcriptions
|
||||
OpenAI SDK baseURL: http://127.0.0.1:8000/v1
|
||||
Health endpoint: http://127.0.0.1:8000/health
|
||||
Transcription URL: http://127.0.0.1:8000/v1/audio/transcriptions
|
||||
```
|
||||
|
||||
Always send an explicit model. Check the deployed `/v1/models` and `/openapi.json`: for example, `paraformer-en` is an example-server alias, not a built-in packaged alias. For a packaged custom checkpoint configured by `--model-path` and `--hub`, request `model="custom"`; an arbitrary checkpoint ID is not an example-server alias. `whisper-1` is a compatibility alias for the startup model, not a Whisper checkpoint. See the [client response contract](CLIENTS.md#response-formats):
|
||||
|
||||
- `verbose_json` selects a format, not timestamp generation or diarization. The example only converts `sentence_info`, otherwise `segments=[]`; the packaged service can supply coarse text-based intervals. Segment times are seconds, not SDK milliseconds. Labels may be missing/null, strings or numbers, not speaker identities or verified alignment.
|
||||
- The example's `duration` is elapsed `generate()` time, excluding initial loading, not audio duration; `language` is the submitted hint or `auto`, and it includes `model`. The packaged verbose response uses audio duration (possibly 0 on metadata failure), can use backend language detection, includes `task`/segment `id`/`words`, and omits top-level `model`.
|
||||
- HTTP display text strips SenseVoice rich tags, without dedicated emotion/event fields. SDK `timestamp`, `timestamps`, `ctc_timestamps`, `use_itn`, hotwords and raw arrays are not extra example HTTP form fields. `spk=true` is packaged-only for non-native speaker processing, subject to dependencies; it is not enabled by these snippets. Base Nano does not imply the separate MLT checkpoint's language coverage.
|
||||
|
||||
## OpenAI JavaScript SDK
|
||||
|
||||
Install the official JavaScript SDK:
|
||||
Use Node.js 22 for these recipes and pin the separate HTTP client to `[email protected]`. This dependency selection is not a claim that every Node/SDK/framework version works or that an acoustic model was validated. In your own writable Node client project directory, separate from the Python server checkout, install the SDK:
|
||||
|
||||
```bash
|
||||
npm install openai
|
||||
npm install openai@7.10.0
|
||||
```
|
||||
|
||||
Create `transcribe.mjs`:
|
||||
Create `transcribe.mjs` in that client directory. Supply an existing WAV file as `meeting.wav` (or an absolute path) when running it. `.mjs` selects ESM and permits top-level await; the client does not create/download the audio file.
|
||||
|
||||
```javascript
|
||||
import OpenAI from "openai";
|
||||
import { createReadStream } from "node:fs";
|
||||
import OpenAI, { toStreamingFile } from "openai";
|
||||
import { open } from "node:fs/promises";
|
||||
import { basename } from "node:path";
|
||||
import { finished } from "node:stream/promises";
|
||||
|
||||
const audioPath = process.argv[2] ?? "sample.wav";
|
||||
|
||||
const client = new OpenAI({
|
||||
baseURL: process.env.FUNASR_OPENAI_BASE_URL ?? "http://localhost:8000/v1",
|
||||
baseURL: process.env.FUNASR_OPENAI_BASE_URL ?? "http://127.0.0.1:8000/v1",
|
||||
apiKey: process.env.OPENAI_API_KEY ?? "local-development",
|
||||
timeout: 120_000,
|
||||
maxRetries: 0,
|
||||
});
|
||||
|
||||
const result = await client.audio.transcriptions.create({
|
||||
model: process.env.FUNASR_MODEL ?? "sensevoice",
|
||||
file: createReadStream(audioPath),
|
||||
response_format: "verbose_json",
|
||||
});
|
||||
try {
|
||||
const handle = await open(audioPath, "r");
|
||||
try {
|
||||
const file = handle.createReadStream();
|
||||
// Observe stream errors even if the SDK rejects before consuming the file.
|
||||
const closed = finished(file).catch(() => {});
|
||||
try {
|
||||
const result = await client.audio.transcriptions.create({
|
||||
model: process.env.FUNASR_MODEL ?? "sensevoice",
|
||||
file: toStreamingFile(file, basename(audioPath)),
|
||||
response_format: "verbose_json",
|
||||
});
|
||||
|
||||
console.log(result.text);
|
||||
for (const segment of result.segments ?? []) {
|
||||
console.log(`${segment.start}s-${segment.end}s`, segment.text);
|
||||
console.log(result.text);
|
||||
for (const segment of result.segments ?? []) {
|
||||
console.log(`${segment.start}s-${segment.end}s`, segment.text);
|
||||
}
|
||||
} finally {
|
||||
file.destroy();
|
||||
await closed;
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch {
|
||||
console.error("FunASR transcription failed");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -69,79 +105,136 @@ Run it:
|
||||
node transcribe.mjs meeting.wav
|
||||
```
|
||||
|
||||
Most OpenAI-compatible SDKs require an API key value even when the local FunASR server does not check it. Use any placeholder for local development, then add real authentication at your gateway if the service is shared.
|
||||
The SDK needs an API key value even when local FunASR does not check it. A placeholder is only for an unprotected local endpoint; use real gateway credentials via server-side configuration when shared. The examples disable SDK retries to avoid repeating expensive requests. Choose timeouts for your admitted audio duration, and do not send CLI stdout (which contains the transcript) to public logs.
|
||||
|
||||
Both SDK examples open the file before constructing the upload, stream from that same handle, and close it even if the SDK rejects before reading. `toStreamingFile` preserves the basename for lazy multipart encoding without buffering the entire file. The `finished` rejection handler observes stream errors during cleanup; it does not turn a failed SDK request into a successful transcription.
|
||||
|
||||
## Built-in fetch without an SDK
|
||||
|
||||
Node.js 18+ includes `fetch`, `FormData`, and `Blob`, so you can call the API without third-party dependencies:
|
||||
The same Node.js 22 environment supplies `fetch`, `FormData` and `Blob`; this alternative needs no OpenAI SDK. Save it as `transcribe-fetch.mjs` in the client directory. The WAV example buffers the whole local file, so apply size/admission limits before using it in a shared worker. Do not set a multipart `Content-Type` header manually: FormData supplies the boundary.
|
||||
|
||||
```javascript
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { basename } from "node:path";
|
||||
|
||||
const baseUrl = process.env.FUNASR_BASE_URL ?? "http://localhost:8000";
|
||||
const baseUrl = (process.env.FUNASR_BASE_URL ?? "http://127.0.0.1:8000").replace(/\/+$/, "");
|
||||
const audioPath = process.argv[2] ?? "sample.wav";
|
||||
const audio = await readFile(audioPath);
|
||||
const signal = AbortSignal.timeout(120_000);
|
||||
|
||||
const form = new FormData();
|
||||
form.append("file", new Blob([audio], { type: "audio/wav" }), basename(audioPath));
|
||||
form.append("model", process.env.FUNASR_MODEL ?? "sensevoice");
|
||||
form.append("response_format", "verbose_json");
|
||||
|
||||
const response = await fetch(`${baseUrl}/v1/audio/transcriptions`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`FunASR request failed: ${response.status} ${await response.text()}`);
|
||||
try {
|
||||
const audio = await readFile(audioPath);
|
||||
const form = new FormData();
|
||||
form.append("file", new Blob([audio], { type: "audio/wav" }), basename(audioPath));
|
||||
form.append("model", process.env.FUNASR_MODEL ?? "sensevoice");
|
||||
form.append("response_format", "verbose_json");
|
||||
const headers = new Headers();
|
||||
if (process.env.FUNASR_API_KEY) {
|
||||
headers.set("Authorization", `Bearer ${process.env.FUNASR_API_KEY}`);
|
||||
}
|
||||
const response = await fetch(`${baseUrl}/v1/audio/transcriptions`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: form,
|
||||
redirect: "error",
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
console.error(`FunASR HTTP error ${response.status}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
const result = await response.json();
|
||||
if (!result || typeof result.text !== "string") throw new Error("Invalid response");
|
||||
console.log(result.text);
|
||||
}
|
||||
} catch {
|
||||
console.error(signal.aborted ? "FunASR request timed out" : "FunASR request failed");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log(result.text);
|
||||
```
|
||||
|
||||
Use this pattern for queue workers, webhook workers, scheduled jobs, and small internal services.
|
||||
Run from the client directory:
|
||||
|
||||
```bash
|
||||
node transcribe-fetch.mjs meeting.wav
|
||||
```
|
||||
|
||||
Use `FUNASR_BASE_URL` without `/v1`, unlike the SDK's `FUNASR_OPENAI_BASE_URL`. `FUNASR_API_KEY` is an optional bearer credential for your gateway, not built-in FunASR authentication. The fixed 120-second fetch signal stays active through `response.json()`; it is not an upload/download byte limit. Redirects are rejected rather than followed to another host. Errors print only a generic message/status, not raw upstream bodies; this is not a production logging or retry policy.
|
||||
|
||||
## TypeScript helper
|
||||
|
||||
- Place this helper in `funasr-client.ts` in an existing TypeScript project with the same OpenAI dependency. Do not run a `.ts` file as if it were the standalone `.mjs` command.
|
||||
- The compiler dependency selection is `[email protected]` and `@types/[email protected]`, with Node.js 22.13.1. Configure Node module resolution (`module`/`moduleResolution`: `NodeNext`), an ES2022 target and `ES2022`/`DOM` libraries as appropriate for your project. These settings do not certify a Next.js build or deployment.
|
||||
- This small application-owned subset is not runtime validation; the final cast does not validate server JSON or establish precise alignment. Optional fields must be checked before use, including absent/null speaker labels.
|
||||
|
||||
```typescript
|
||||
import OpenAI from "openai";
|
||||
import { createReadStream } from "node:fs";
|
||||
import OpenAI, { toStreamingFile } from "openai";
|
||||
import { open } from "node:fs/promises";
|
||||
import { basename } from "node:path";
|
||||
import { finished } from "node:stream/promises";
|
||||
|
||||
export interface FunASRTranscript {
|
||||
text: string;
|
||||
segments?: Array<{ start: number; end: number; text: string; speaker?: number }>;
|
||||
segments?: Array<{ start: number; end: number; text: string; speaker?: string | number | null }>;
|
||||
language?: string;
|
||||
duration?: number;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
const client = new OpenAI({
|
||||
baseURL: process.env.FUNASR_OPENAI_BASE_URL ?? "http://localhost:8000/v1",
|
||||
baseURL: process.env.FUNASR_OPENAI_BASE_URL ?? "http://127.0.0.1:8000/v1",
|
||||
apiKey: process.env.OPENAI_API_KEY ?? "local-development",
|
||||
timeout: 120_000,
|
||||
maxRetries: 0,
|
||||
});
|
||||
|
||||
export async function transcribeWithFunASR(audioPath: string): Promise<FunASRTranscript> {
|
||||
const result = await client.audio.transcriptions.create({
|
||||
model: process.env.FUNASR_MODEL ?? "sensevoice",
|
||||
file: createReadStream(audioPath),
|
||||
response_format: "verbose_json",
|
||||
});
|
||||
const handle = await open(audioPath, "r");
|
||||
try {
|
||||
const file = handle.createReadStream();
|
||||
// Observe stream errors even if the SDK rejects before consuming the file.
|
||||
const closed = finished(file).catch(() => {});
|
||||
try {
|
||||
const result = await client.audio.transcriptions.create({
|
||||
model: process.env.FUNASR_MODEL ?? "sensevoice",
|
||||
file: toStreamingFile(file, basename(audioPath)),
|
||||
response_format: "verbose_json",
|
||||
});
|
||||
|
||||
return result as FunASRTranscript;
|
||||
return result as FunASRTranscript;
|
||||
} finally {
|
||||
file.destroy();
|
||||
await closed;
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Keep the return type small and application-owned. FunASR can return richer metadata over time, and your application can opt into only the fields it needs.
|
||||
Keep the return type small and application-owned. The caller must handle rejection without exposing SDK error details to users. Fields not used by the application may be omitted from this interface; their service semantics remain those of the deployed endpoint.
|
||||
|
||||
## Next.js route handler
|
||||
|
||||
Proxy browser uploads through your backend so you can enforce authentication, file-size limits, and audit logs before audio reaches FunASR.
|
||||
- **Placement and scope:** In an existing Next.js App Router application, use `app/api/transcribe/route.ts`. This function uses native `Request`/`Response` and declares the Node runtime; standalone TypeScript/Web API checks are not a Next.js project build or deployment certification.
|
||||
- **Missing controls:** Authentication, upload byte limits, rate limits and privacy/audit policy are **not implemented**. Enforce ingress limits before `request.formData()` buffers the request, and protect the route before sharing. A proxy is not an authentication layer; no Next.js installation or authentication framework is included here.
|
||||
- **Timeout scope:** The 120-second upstream timeout does not bound incoming upload parsing or payload size.
|
||||
- **Fixed configuration:** Set `FUNASR_UPSTREAM_URL` to the operator-controlled transcription endpoint and `FUNASR_MODEL` to its alias in server-only environment configuration. The same-host default is loopback; containers need an intentionally reachable private service/gateway. Optional `FUNASR_API_KEY` supplies a bearer credential. Do not use `NEXT_PUBLIC_` variables or accept a target/model/credential from the uploaded form.
|
||||
|
||||
```typescript
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const UPSTREAM_URL = process.env.FUNASR_UPSTREAM_URL ?? "http://127.0.0.1:8000/v1/audio/transcriptions";
|
||||
const UPSTREAM_MODEL = process.env.FUNASR_MODEL ?? "sensevoice";
|
||||
const API_KEY = process.env.FUNASR_API_KEY;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const incoming = await request.formData();
|
||||
let incoming: FormData;
|
||||
try {
|
||||
incoming = await request.formData();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid multipart upload" }, { status: 400 });
|
||||
}
|
||||
const file = incoming.get("file");
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
@@ -150,36 +243,58 @@ export async function POST(request: Request) {
|
||||
|
||||
const upstream = new FormData();
|
||||
upstream.append("file", file, file.name || "audio.wav");
|
||||
upstream.append("model", "sensevoice");
|
||||
upstream.append("model", UPSTREAM_MODEL);
|
||||
upstream.append("response_format", "verbose_json");
|
||||
const headers = new Headers();
|
||||
if (API_KEY) headers.set("Authorization", `Bearer ${API_KEY}`);
|
||||
const signal = AbortSignal.timeout(120_000);
|
||||
|
||||
const response = await fetch("http://funasr-api:8000/v1/audio/transcriptions", {
|
||||
method: "POST",
|
||||
body: upstream,
|
||||
});
|
||||
|
||||
const body = await response.json();
|
||||
return Response.json(body, { status: response.status });
|
||||
try {
|
||||
const response = await fetch(UPSTREAM_URL, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: upstream,
|
||||
redirect: "error",
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
const status = response.status >= 400 && response.status <= 599 ? response.status : 502;
|
||||
return Response.json({ error: "Upstream transcription failed" }, { status });
|
||||
}
|
||||
if (!(response.headers.get("content-type") ?? "").toLowerCase().includes("application/json")) {
|
||||
await response.body?.cancel();
|
||||
return Response.json({ error: "Invalid upstream response" }, { status: 502 });
|
||||
}
|
||||
const body = await response.json();
|
||||
if (!body || typeof body.text !== "string") {
|
||||
return Response.json({ error: "Invalid upstream response" }, { status: 502 });
|
||||
}
|
||||
return Response.json(body);
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: signal.aborted ? "Upstream timed out" : "Upstream request failed" },
|
||||
{ status: signal.aborted ? 504 : 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In Docker Compose or Kubernetes, replace `funasr-api` with the service name reachable from your web backend. Avoid sending browser traffic directly to an unauthenticated FunASR endpoint on a public network.
|
||||
The handler rejects malformed uploads with 400, rejects redirects, and keeps the timeout active through JSON body reading. Upstream 400–599 errors retain their status but use a generic body; other unexpected statuses/non-JSON responses become 502, timeout becomes 504, and valid transcript JSON is returned as 200. It does not expose upstream HTML/error text. This is still a forwarding sketch, not a complete schema validator, bounded-response downloader or hardened production service.
|
||||
|
||||
## Production checklist
|
||||
|
||||
- Put TLS, authentication, upload-size limits, and rate limits in front of the API.
|
||||
- Set request timeouts based on maximum audio duration; long recordings need longer HTTP timeouts.
|
||||
- Log audio duration, model alias, response format, latency, and upstream error text.
|
||||
- Log approved operational metadata such as model alias, response format, latency and error category. Distinguish audio duration from processing time; do not log raw upstream bodies, credentials, audio or transcripts by default.
|
||||
- Run `GET /health` and `GET /v1/models` during readiness checks before accepting user uploads.
|
||||
- Keep audio upload handling on the server side for browser applications.
|
||||
- Pin `openai` package versions in production services and retest after SDK upgrades.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Fix |
|
||||
|---|---|
|
||||
| SDK reports a missing API key | Pass any placeholder `apiKey` for local development. |
|
||||
| 404 from SDK calls | Use `baseURL=http://localhost:8000/v1`; direct endpoint calls use `http://localhost:8000`. |
|
||||
| `unknown model` | Call `/v1/models` and use one of the returned aliases. |
|
||||
| Browser upload fails with CORS or auth errors | Send uploads to your backend first, then proxy to FunASR. |
|
||||
| Request times out | Increase SDK or fetch timeouts, or split very long audio. |
|
||||
- **SDK reports a missing API key:** Pass any placeholder `apiKey` for local development. This applies only to an unprotected local endpoint; configure real gateway credentials when shared.
|
||||
- **404 from SDK calls:** Use `baseURL=http://localhost:8000/v1`; direct endpoint calls use `http://localhost:8000`. The local recipes above use the explicit loopback address `127.0.0.1`.
|
||||
- **`unknown model`:** Call `/v1/models` and use one of the returned aliases. Also check which server implementation is running.
|
||||
- **Browser upload fails with CORS or auth errors:** Send uploads to your backend first, then proxy to FunASR. Protect that backend and check actual gateway credentials and network routing rather than disabling protection.
|
||||
- **Request times out:** Increase SDK or fetch timeouts, or split very long audio. Reassess admitted audio duration and update the actual timeout configuration; do not merely claim a larger limit in prose.
|
||||
|
||||
@@ -1,65 +1,101 @@
|
||||
# FunASR OpenAI 兼容 API JavaScript/TypeScript 接入配方
|
||||
|
||||
当 `funasr-server` 已经启动,而你希望把 Node.js、TypeScript 服务、Next.js route handler 或 JavaScript Agent 工作流接入本地语音识别时,可以按这份指南复制代码。
|
||||
这些 multipart HTTP 配方适用于 Node.js、TypeScript 服务和原生 Web API 转发函数。仓库示例 `server.py` 与打包 `funasr-server` 是不同实现,不能互换默认值,也不是完整 OpenAI API 兼容保证。
|
||||
|
||||
示例服务**没有内置鉴权或上传大小限制**。本地检查只使用 loopback。共享前按[安全指南](SECURITY_zh.md)配置 TLS、网关鉴权、上传/时限/速率限制、health/model/schema 私有访问和音频/转写保留策略。占位 API key 不提供鉴权。
|
||||
|
||||
## 预检查
|
||||
|
||||
先启动 API 服务:
|
||||
先按[示例 README](README_zh.md#快速开始)准备 checkout 和 Python 环境。其中源码 revision 不是依赖或权重锁定,也不是全新安装成功声明。从该 checkout 根目录开始,假定 `.venv` 已准备好,启动一个本地示例服务:
|
||||
|
||||
```bash
|
||||
cd examples/openai_api
|
||||
python server.py --model sensevoice --device cuda --port 8000
|
||||
source ../../.venv/bin/activate
|
||||
python server.py --host 127.0.0.1 --model sensevoice --device cpu --port 8000
|
||||
```
|
||||
|
||||
处理离线多人音频时,使用
|
||||
`funasr-server --model moss-transcribe-diarize --device cuda:0` 启动独立服务,设置
|
||||
`FUNASR_MODEL=moss-transcribe-diarize` 并请求 `verbose_json`。详见
|
||||
[MOSS 部署指南](../../docs/moss_transcribe_diarize_zh.md)。
|
||||
如果服务已经运行,跳过启动。准备好 CUDA 依赖后,可将 CPU 命令替换为 `python server.py --host 127.0.0.1 --model sensevoice --device cuda --port 8000`,不要重复占用同一端口。另一条打包服务路线及默认值见 [Agent 集成指南](../../docs/agent_integration_zh.md)。
|
||||
|
||||
在另一个终端验证服务:
|
||||
处理离线多人音频时,先按 [MOSS 部署指南](../../docs/moss_transcribe_diarize_zh.md)准备独立第三方服务,包括 GPU/文件时长边界与私有监听。然后为 JS 客户端设置 `FUNASR_MODEL=moss-transcribe-diarize` 并请求 `verbose_json`。MOSS 提供原生、录音内匿名标签,不是已验证身份;不要添加外部 VAD 或 `spk=true`。仅设置客户端别名不会完成环境准备。
|
||||
|
||||
在同一主机的第二个终端进入同一 checkout 的 `examples/openai_api` 目录,激活同一环境。等待模型加载后检查本地服务:
|
||||
|
||||
```bash
|
||||
python smoke_test.py --base-url http://localhost:8000
|
||||
source ../../.venv/bin/activate
|
||||
curl -fsS http://127.0.0.1:8000/health
|
||||
curl -fsS http://127.0.0.1:8000/v1/models
|
||||
curl -fsS http://127.0.0.1:8000/openapi.json
|
||||
python smoke_test.py --base-url http://127.0.0.1:8000 --model sensevoice
|
||||
```
|
||||
|
||||
这个可选 smoke 脚本会在缺少 `sample.wav` 时下载公开中文样例。它不是多语言准确率基准,health/schema 检查本身也不验证转写。Python 脚本读取 `MODEL`/`BASE_URL` 或显式参数,不读取 `FUNASR_MODEL`。检查 MOSS 时,也要修改脚本的 `--model` 并指向已准备好的 MOSS 服务。
|
||||
|
||||
SDK base URL 需要包含 `/v1`,直接健康检查不需要:
|
||||
|
||||
```text
|
||||
OpenAI SDK baseURL: http://localhost:8000/v1
|
||||
健康检查: http://localhost:8000/health
|
||||
转写接口: http://localhost:8000/v1/audio/transcriptions
|
||||
OpenAI SDK baseURL: http://127.0.0.1:8000/v1
|
||||
健康检查: http://127.0.0.1:8000/health
|
||||
转写接口: http://127.0.0.1:8000/v1/audio/transcriptions
|
||||
```
|
||||
|
||||
始终显式传入模型。检查已部署的 `/v1/models` 与 `/openapi.json`,例如 `paraformer-en` 是示例服务别名,不是打包服务内置别名。打包服务通过 `--model-path` 和 `--hub` 配置自定义 checkpoint 后,请求使用 `model="custom"`;任意 checkpoint ID 不是示例服务别名。`whisper-1` 是启动模型的兼容别名,不是 Whisper checkpoint。参见[客户端响应契约](CLIENTS.md#response-formats):
|
||||
|
||||
- `verbose_json` 选择格式,不生成时间戳或开启说话人分离。示例只转换 `sentence_info`,否则为 `segments=[]`;打包服务可能提供基于文本的粗粒度区间。片段时间单位是秒,不是 SDK 毫秒。标签可缺失/为 null、字符串或数字,不是说话人身份或已验证对齐。
|
||||
- 示例的 `duration` 是 `generate()` 耗时,不含初次加载,不是音频时长;`language` 是提交的提示或 `auto`,并含 `model`。打包 verbose 响应使用音频时长(元数据读取失败时可能为 0),可采用后端语言检测,含 `task`/片段 `id`/`words`,没有顶层 `model`。
|
||||
- HTTP 展示文本会剥离 SenseVoice 富标签,没有独立情绪/事件字段。SDK 的 `timestamp`、`timestamps`、`ctc_timestamps`、`use_itn`、hotwords 和原始数组不是额外示例 HTTP 表单字段。`spk=true` 仅属于打包服务的非原生说话人流程,依赖相关环境,这些示例没有开启它。基础 Nano 不代表独立 MLT checkpoint 的语言覆盖。
|
||||
|
||||
## OpenAI JavaScript SDK
|
||||
|
||||
安装官方 JavaScript SDK:
|
||||
这些配方使用 Node.js 22,将独立 HTTP 客户端固定为 `[email protected]`。此依赖选择不代表所有 Node/SDK/框架版本均兼容,也不代表已验证声学模型。请在自己的可写 Node 客户端项目目录安装 SDK,与 Python 服务 checkout 分开:
|
||||
|
||||
```bash
|
||||
npm install openai
|
||||
npm install openai@7.10.0
|
||||
```
|
||||
|
||||
创建 `transcribe.mjs`:
|
||||
在该客户端目录创建 `transcribe.mjs`。运行时提供已有 WAV 文件 `meeting.wav`(或绝对路径)。`.mjs` 选择 ESM 并允许顶层 await;客户端不会创建或下载音频文件。
|
||||
|
||||
```javascript
|
||||
import OpenAI from "openai";
|
||||
import { createReadStream } from "node:fs";
|
||||
import OpenAI, { toStreamingFile } from "openai";
|
||||
import { open } from "node:fs/promises";
|
||||
import { basename } from "node:path";
|
||||
import { finished } from "node:stream/promises";
|
||||
|
||||
const audioPath = process.argv[2] ?? "sample.wav";
|
||||
|
||||
const client = new OpenAI({
|
||||
baseURL: process.env.FUNASR_OPENAI_BASE_URL ?? "http://localhost:8000/v1",
|
||||
baseURL: process.env.FUNASR_OPENAI_BASE_URL ?? "http://127.0.0.1:8000/v1",
|
||||
apiKey: process.env.OPENAI_API_KEY ?? "local-development",
|
||||
timeout: 120_000,
|
||||
maxRetries: 0,
|
||||
});
|
||||
|
||||
const result = await client.audio.transcriptions.create({
|
||||
model: process.env.FUNASR_MODEL ?? "sensevoice",
|
||||
file: createReadStream(audioPath),
|
||||
response_format: "verbose_json",
|
||||
});
|
||||
try {
|
||||
const handle = await open(audioPath, "r");
|
||||
try {
|
||||
const file = handle.createReadStream();
|
||||
// Observe stream errors even if the SDK rejects before consuming the file.
|
||||
const closed = finished(file).catch(() => {});
|
||||
try {
|
||||
const result = await client.audio.transcriptions.create({
|
||||
model: process.env.FUNASR_MODEL ?? "sensevoice",
|
||||
file: toStreamingFile(file, basename(audioPath)),
|
||||
response_format: "verbose_json",
|
||||
});
|
||||
|
||||
console.log(result.text);
|
||||
for (const segment of result.segments ?? []) {
|
||||
console.log(`${segment.start}s-${segment.end}s`, segment.text);
|
||||
console.log(result.text);
|
||||
for (const segment of result.segments ?? []) {
|
||||
console.log(`${segment.start}s-${segment.end}s`, segment.text);
|
||||
}
|
||||
} finally {
|
||||
file.destroy();
|
||||
await closed;
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch {
|
||||
console.error("FunASR transcription failed");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -69,79 +105,136 @@ for (const segment of result.segments ?? []) {
|
||||
node transcribe.mjs meeting.wav
|
||||
```
|
||||
|
||||
多数 OpenAI 兼容 SDK 即使在本地服务不校验密钥时,也要求传入一个 API key。开发环境可以使用任意占位值;如果服务被多人共享,请在网关层增加真实鉴权。
|
||||
即使本地 FunASR 不校验密钥,SDK 仍需要 API key 值。占位值仅用于未保护的本地端点;共享时通过服务端配置提供真实网关凭据。示例关闭 SDK 重试,避免重复执行昂贵请求。按允许的音频时长选择超时;不要把含转写内容的 CLI stdout 收集到公开日志。
|
||||
|
||||
两个 SDK 示例均先打开文件,再从同一个句柄构造上传流;即使 SDK 在读取前拒绝,也会关闭句柄。`toStreamingFile` 为惰性 multipart 编码保留 basename,无需把整个文件缓存在内存。`finished` 的拒绝处理器用于观察清理期间的流错误,不会把失败的 SDK 请求变成成功转写。
|
||||
|
||||
## 不依赖 SDK 的内置 fetch 写法
|
||||
|
||||
Node.js 18+ 内置 `fetch`、`FormData` 和 `Blob`,可以不安装第三方依赖直接调用接口:
|
||||
同一 Node.js 22 环境提供 `fetch`、`FormData` 和 `Blob`,此替代方案不需要 OpenAI SDK。在客户端目录保存为 `transcribe-fetch.mjs`。WAV 示例会将整个本地文件读入内存,用于共享 worker 前应先做大小/准入限制。不要手动设置 multipart `Content-Type` header,由 FormData 提供 boundary。
|
||||
|
||||
```javascript
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { basename } from "node:path";
|
||||
|
||||
const baseUrl = process.env.FUNASR_BASE_URL ?? "http://localhost:8000";
|
||||
const baseUrl = (process.env.FUNASR_BASE_URL ?? "http://127.0.0.1:8000").replace(/\/+$/, "");
|
||||
const audioPath = process.argv[2] ?? "sample.wav";
|
||||
const audio = await readFile(audioPath);
|
||||
const signal = AbortSignal.timeout(120_000);
|
||||
|
||||
const form = new FormData();
|
||||
form.append("file", new Blob([audio], { type: "audio/wav" }), basename(audioPath));
|
||||
form.append("model", process.env.FUNASR_MODEL ?? "sensevoice");
|
||||
form.append("response_format", "verbose_json");
|
||||
|
||||
const response = await fetch(`${baseUrl}/v1/audio/transcriptions`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`FunASR request failed: ${response.status} ${await response.text()}`);
|
||||
try {
|
||||
const audio = await readFile(audioPath);
|
||||
const form = new FormData();
|
||||
form.append("file", new Blob([audio], { type: "audio/wav" }), basename(audioPath));
|
||||
form.append("model", process.env.FUNASR_MODEL ?? "sensevoice");
|
||||
form.append("response_format", "verbose_json");
|
||||
const headers = new Headers();
|
||||
if (process.env.FUNASR_API_KEY) {
|
||||
headers.set("Authorization", `Bearer ${process.env.FUNASR_API_KEY}`);
|
||||
}
|
||||
const response = await fetch(`${baseUrl}/v1/audio/transcriptions`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: form,
|
||||
redirect: "error",
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
console.error(`FunASR HTTP error ${response.status}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
const result = await response.json();
|
||||
if (!result || typeof result.text !== "string") throw new Error("Invalid response");
|
||||
console.log(result.text);
|
||||
}
|
||||
} catch {
|
||||
console.error(signal.aborted ? "FunASR request timed out" : "FunASR request failed");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log(result.text);
|
||||
```
|
||||
|
||||
这个模式适合队列 worker、webhook worker、定时任务和小型内部服务。
|
||||
从客户端目录运行:
|
||||
|
||||
```bash
|
||||
node transcribe-fetch.mjs meeting.wav
|
||||
```
|
||||
|
||||
`FUNASR_BASE_URL` 不带 `/v1`,与 SDK 的 `FUNASR_OPENAI_BASE_URL` 不同。`FUNASR_API_KEY` 是可选网关 bearer 凭据,不是 FunASR 内置鉴权。固定 120 秒 fetch 信号持续作用到 `response.json()` 完成,但不是上传/下载字节限制。重定向会被拒绝,不跟随到其他主机。错误仅打印通用消息/状态,不打印原始上游响应体;这不是生产日志或重试策略。
|
||||
|
||||
## TypeScript helper
|
||||
|
||||
- 在已配置 TypeScript 且使用同一 OpenAI 依赖的项目中,将 helper 放入 `funasr-client.ts`。不要把 `.ts` 当作独立 `.mjs` 命令运行。
|
||||
- 编译器依赖选择为 `[email protected]` 和 `@types/[email protected]`,配合 Node.js 22.13.1。按项目配置 Node 模块解析(`module`/`moduleResolution` 为 `NodeNext`)、ES2022 target 与 `ES2022`/`DOM` libraries。这些设置不认证 Next.js build 或部署。
|
||||
- 这个由应用维护的小型字段子集不是运行时校验,末尾类型断言不会验证服务端 JSON,也不证明精确对齐。使用前要检查可选字段,包括缺失/为 null 的说话人标签。
|
||||
|
||||
```typescript
|
||||
import OpenAI from "openai";
|
||||
import { createReadStream } from "node:fs";
|
||||
import OpenAI, { toStreamingFile } from "openai";
|
||||
import { open } from "node:fs/promises";
|
||||
import { basename } from "node:path";
|
||||
import { finished } from "node:stream/promises";
|
||||
|
||||
export interface FunASRTranscript {
|
||||
text: string;
|
||||
segments?: Array<{ start: number; end: number; text: string; speaker?: number }>;
|
||||
segments?: Array<{ start: number; end: number; text: string; speaker?: string | number | null }>;
|
||||
language?: string;
|
||||
duration?: number;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
const client = new OpenAI({
|
||||
baseURL: process.env.FUNASR_OPENAI_BASE_URL ?? "http://localhost:8000/v1",
|
||||
baseURL: process.env.FUNASR_OPENAI_BASE_URL ?? "http://127.0.0.1:8000/v1",
|
||||
apiKey: process.env.OPENAI_API_KEY ?? "local-development",
|
||||
timeout: 120_000,
|
||||
maxRetries: 0,
|
||||
});
|
||||
|
||||
export async function transcribeWithFunASR(audioPath: string): Promise<FunASRTranscript> {
|
||||
const result = await client.audio.transcriptions.create({
|
||||
model: process.env.FUNASR_MODEL ?? "sensevoice",
|
||||
file: createReadStream(audioPath),
|
||||
response_format: "verbose_json",
|
||||
});
|
||||
const handle = await open(audioPath, "r");
|
||||
try {
|
||||
const file = handle.createReadStream();
|
||||
// Observe stream errors even if the SDK rejects before consuming the file.
|
||||
const closed = finished(file).catch(() => {});
|
||||
try {
|
||||
const result = await client.audio.transcriptions.create({
|
||||
model: process.env.FUNASR_MODEL ?? "sensevoice",
|
||||
file: toStreamingFile(file, basename(audioPath)),
|
||||
response_format: "verbose_json",
|
||||
});
|
||||
|
||||
return result as FunASRTranscript;
|
||||
return result as FunASRTranscript;
|
||||
} finally {
|
||||
file.destroy();
|
||||
await closed;
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
建议在应用侧维护一个小而稳定的返回类型。FunASR 后续可能返回更丰富的元数据,业务代码只需要消费自己关心的字段。
|
||||
建议在应用侧维护小而稳定的返回类型。调用方必须处理 rejection,不向用户暴露 SDK 错误详情。接口可不包含应用未使用的字段,但字段含义仍由实际部署端点决定。
|
||||
|
||||
## Next.js route handler
|
||||
|
||||
浏览器上传建议先进入自己的后端,再由后端转发到 FunASR,这样可以统一做鉴权、文件大小限制和审计日志。
|
||||
- **放置位置与范围:** 对于已有 Next.js App Router 应用,使用 `app/api/transcribe/route.ts`。函数使用原生 `Request`/`Response` 并声明 Node runtime;独立 TypeScript/Web API 检查不是 Next.js 项目 build 或部署认证。
|
||||
- **缺失控制:** 鉴权、上传字节限制、限流与隐私/审计策略均**未实现**。应在 `request.formData()` 缓冲请求前通过入口限制上传,并在共享前保护路由。代理不是鉴权层,这里不包含 Next.js 安装或认证框架。
|
||||
- **超时范围:** 120 秒上游超时不限制入站上传解析或 payload 大小。
|
||||
- **固定配置:** 通过仅服务端可用的环境配置,把 `FUNASR_UPSTREAM_URL` 设为运维人员控制的转写端点,`FUNASR_MODEL` 设为对应别名。同主机默认值为 loopback,容器需明确配置可达的私有服务/网关。可选 `FUNASR_API_KEY` 提供 bearer 凭据。不要使用 `NEXT_PUBLIC_` 变量,也不要从上传表单接收 target/model/credential。
|
||||
|
||||
```typescript
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const UPSTREAM_URL = process.env.FUNASR_UPSTREAM_URL ?? "http://127.0.0.1:8000/v1/audio/transcriptions";
|
||||
const UPSTREAM_MODEL = process.env.FUNASR_MODEL ?? "sensevoice";
|
||||
const API_KEY = process.env.FUNASR_API_KEY;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const incoming = await request.formData();
|
||||
let incoming: FormData;
|
||||
try {
|
||||
incoming = await request.formData();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid multipart upload" }, { status: 400 });
|
||||
}
|
||||
const file = incoming.get("file");
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
@@ -150,36 +243,58 @@ export async function POST(request: Request) {
|
||||
|
||||
const upstream = new FormData();
|
||||
upstream.append("file", file, file.name || "audio.wav");
|
||||
upstream.append("model", "sensevoice");
|
||||
upstream.append("model", UPSTREAM_MODEL);
|
||||
upstream.append("response_format", "verbose_json");
|
||||
const headers = new Headers();
|
||||
if (API_KEY) headers.set("Authorization", `Bearer ${API_KEY}`);
|
||||
const signal = AbortSignal.timeout(120_000);
|
||||
|
||||
const response = await fetch("http://funasr-api:8000/v1/audio/transcriptions", {
|
||||
method: "POST",
|
||||
body: upstream,
|
||||
});
|
||||
|
||||
const body = await response.json();
|
||||
return Response.json(body, { status: response.status });
|
||||
try {
|
||||
const response = await fetch(UPSTREAM_URL, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: upstream,
|
||||
redirect: "error",
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
const status = response.status >= 400 && response.status <= 599 ? response.status : 502;
|
||||
return Response.json({ error: "Upstream transcription failed" }, { status });
|
||||
}
|
||||
if (!(response.headers.get("content-type") ?? "").toLowerCase().includes("application/json")) {
|
||||
await response.body?.cancel();
|
||||
return Response.json({ error: "Invalid upstream response" }, { status: 502 });
|
||||
}
|
||||
const body = await response.json();
|
||||
if (!body || typeof body.text !== "string") {
|
||||
return Response.json({ error: "Invalid upstream response" }, { status: 502 });
|
||||
}
|
||||
return Response.json(body);
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: signal.aborted ? "Upstream timed out" : "Upstream request failed" },
|
||||
{ status: signal.aborted ? 504 : 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
在 Docker Compose 或 Kubernetes 中,把 `funasr-api` 换成 Web 后端能访问到的 service name。不要把未鉴权的 FunASR 接口直接暴露给公网浏览器。
|
||||
此 handler 对格式错误上传返回 400,拒绝重定向,超时覆盖 JSON 响应体读取。上游 400–599 错误保留状态但使用通用响应体;其他非预期状态/非 JSON 响应转为 502,超时为 504,有效转写 JSON 以 200 返回。不暴露上游 HTML/错误文本。它仍是转发 sketch,不是完整 schema 校验器、有界响应下载器或加固的生产服务。
|
||||
|
||||
## 生产检查清单
|
||||
|
||||
- 在 API 前增加 TLS、鉴权、上传大小限制和限流。
|
||||
- 根据最大音频时长设置请求超时;长录音需要更长的 HTTP timeout。
|
||||
- 记录音频时长、模型别名、响应格式、延迟和上游错误文本。
|
||||
- 只记录经批准的运行元数据,如模型别名、响应格式、延迟和错误类别。区分音频时长与处理耗时,不默认记录原始上游响应体、凭据、音频或转写。
|
||||
- 接收用户上传前,先用 `GET /health` 和 `GET /v1/models` 做就绪检查。
|
||||
- 浏览器应用应把音频上传处理留在服务端。
|
||||
- 生产服务固定 `openai` 包版本,并在 SDK 升级后重新测试。
|
||||
|
||||
## 故障排查
|
||||
|
||||
| 现象 | 处理方式 |
|
||||
|---|---|
|
||||
| SDK 提示缺少 API key | 本地开发传入任意占位 `apiKey`。 |
|
||||
| SDK 调用返回 404 | SDK 使用 `baseURL=http://localhost:8000/v1`;直接端点调用使用 `http://localhost:8000`。 |
|
||||
| `unknown model` | 调用 `/v1/models`,使用返回的模型别名。 |
|
||||
| 浏览器上传遇到 CORS 或鉴权错误 | 先上传到自己的后端,再由后端代理到 FunASR。 |
|
||||
| 请求超时 | 增加 SDK 或 fetch 超时,或切分超长音频。 |
|
||||
- **SDK 提示缺少 API key:** 本地开发传入任意占位 `apiKey`。仅适用于未保护的本地端点,共享时应配置真实网关凭据。
|
||||
- **SDK 调用返回 404:** SDK 使用 `baseURL=http://localhost:8000/v1`;直接端点调用使用 `http://localhost:8000`。上面的本地配方使用显式 loopback 地址 `127.0.0.1`。
|
||||
- **`unknown model`:** 调用 `/v1/models`,使用返回的模型别名。同时检查实际运行的服务实现。
|
||||
- **浏览器上传遇到 CORS 或鉴权错误:** 先上传到自己的后端,再由后端代理到 FunASR。应保护后端并核验真实网关凭据和网络路由,不要通过关闭防护解决问题。
|
||||
- **请求超时:** 增加 SDK 或 fetch 超时,或切分超长音频。重新评估允许的音频时长并修改实际超时配置,不要只在文字中声称增加时限。
|
||||
|
||||
@@ -18,6 +18,7 @@ READMES = [EXAMPLE / name for name in ("README.md", "README_zh.md", "README_ja.m
|
||||
DOCS = [EXAMPLE / "CLIENTS.md", *READMES]
|
||||
PACKAGED = ROOT / "funasr/bin/_server_app.py"
|
||||
WORKFLOW_DOCS = [EXAMPLE / name for name in ("WORKFLOWS.md", "WORKFLOWS_zh.md")]
|
||||
JAVASCRIPT_DOCS = [EXAMPLE / name for name in ("JAVASCRIPT.md", "JAVASCRIPT_zh.md")]
|
||||
|
||||
# Frozen from the two pre-edit workflow guides, not translated from one language.
|
||||
WORKFLOW_HEADINGS = {
|
||||
@@ -518,5 +519,171 @@ class WorkflowDocsContract(unittest.TestCase):
|
||||
self.assertTrue(all(handle.closed for handle in handles))
|
||||
|
||||
|
||||
class JavaScriptDocsContract(unittest.TestCase):
|
||||
"""Source/text contracts only; actual JS execution and TS checking are separate."""
|
||||
|
||||
def test_javascript_preserves_headings_and_resolves_guide_links(self):
|
||||
legacy = [
|
||||
"JavaScript and TypeScript Recipes for the FunASR OpenAI-Compatible API|Preflight|OpenAI JavaScript SDK|Built-in fetch without an SDK|TypeScript helper|Next.js route handler|Production checklist|Troubleshooting",
|
||||
"FunASR OpenAI 兼容 API JavaScript/TypeScript 接入配方|预检查|OpenAI JavaScript SDK|不依赖 SDK 的内置 fetch 写法|TypeScript helper|Next.js route handler|生产检查清单|故障排查",
|
||||
]
|
||||
for path, expected in zip(JAVASCRIPT_DOCS, legacy):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
prose = re.sub(r"^```[^\n]*\n.*?^```[^\n]*$", "", text, flags=re.M | re.S)
|
||||
self.assertEqual(re.findall(r"(?m)^#{1,6} (.+)$", prose), expected.split("|"))
|
||||
self.assertEqual([len(level) for level in re.findall(r"(?m)^(#{1,6}) ", prose)], [1] + [2] * 7)
|
||||
suffix = "_zh" if path.stem.endswith("_zh") else ""
|
||||
links = re.findall(r"\[[^\]]+\]\(([^)]+)\)", text)
|
||||
self.assertIn(f"../../docs/moss_transcribe_diarize{suffix}.md", links)
|
||||
for link in links:
|
||||
parts = urlsplit(link)
|
||||
if parts.scheme or parts.netloc:
|
||||
continue
|
||||
target = (path.parent / unquote(parts.path)).resolve()
|
||||
self.assertIn(ROOT.resolve(), target.parents)
|
||||
self.assertNotIn(".mcp-tasks", target.parts)
|
||||
self.assertTrue(target.is_file(), link)
|
||||
if parts.fragment:
|
||||
self.assertIn(unquote(parts.fragment), headings(target.read_text(encoding="utf-8")), link)
|
||||
|
||||
def test_javascript_startup_and_smoke_use_explicit_source_options(self):
|
||||
for path in JAVASCRIPT_DOCS:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
prefix = text.split("```bash", 1)[0]
|
||||
suffix = "_zh" if path.stem.endswith("_zh") else ""
|
||||
self.assertIn(f"](README{suffix}.md", prefix)
|
||||
self.assertIn(f"](SECURITY{suffix}.md)", prefix)
|
||||
startup = blocks(text, "bash")[0]
|
||||
self.assertIn("cd examples/openai_api", startup)
|
||||
self.assertIn("source ../../.venv/bin/activate", startup)
|
||||
found = set()
|
||||
for code in blocks(text, "bash"):
|
||||
parsed = subprocess.run(["bash", "-n"], input=code, text=True, capture_output=True)
|
||||
self.assertEqual(parsed.returncode, 0, parsed.stderr)
|
||||
for line in code.replace("\\\n", " ").splitlines():
|
||||
args = shlex.split(line, comments=True)
|
||||
if len(args) < 2 or args[0] != "python":
|
||||
continue
|
||||
self.assertIn(args[1], ("server.py", "smoke_test.py"))
|
||||
found.add(args[1])
|
||||
options = {ast.literal_eval(arg) for node in ast.walk(tree(EXAMPLE / args[1]))
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr == "add_argument" for arg in node.args
|
||||
if isinstance(arg, ast.Constant) and isinstance(arg.value, str)}
|
||||
self.assertLessEqual({arg for arg in args[2:] if arg.startswith("--")}, options)
|
||||
self.assertIn("--model", args)
|
||||
self.assertEqual(args[args.index("--model") + 1], "sensevoice")
|
||||
if args[1] == "server.py":
|
||||
self.assertIn("--host", args)
|
||||
self.assertEqual(args[args.index("--host") + 1], "127.0.0.1")
|
||||
self.assertEqual(args[args.index("--device") + 1], "cpu")
|
||||
else:
|
||||
self.assertEqual(args[args.index("--base-url") + 1], "http://127.0.0.1:8000")
|
||||
self.assertEqual(found, {"server.py", "smoke_test.py"})
|
||||
for endpoint in ("/health", "/v1/models", "/openapi.json"):
|
||||
self.assertIn(endpoint, text)
|
||||
|
||||
def test_javascript_examples_keep_bilingual_code_and_runnable_module_entries(self):
|
||||
texts = [path.read_text(encoding="utf-8") for path in JAVASCRIPT_DOCS]
|
||||
for language in ("javascript", "typescript"):
|
||||
examples = [blocks(text, language) for text in texts]
|
||||
self.assertEqual([len(items) for items in examples], [2, 2])
|
||||
self.assertEqual(examples[0], examples[1])
|
||||
for text in texts:
|
||||
shell = "\n".join(blocks(text, "bash"))
|
||||
self.assertIn("node transcribe.mjs meeting.wav", shell)
|
||||
self.assertIn("node transcribe-fetch.mjs meeting.wav", shell)
|
||||
self.assertRegex(shell, r"npm install openai@\d+\.\d+\.\d+")
|
||||
self.assertIn("app/api/transcribe/route.ts", text)
|
||||
self.assertIn("funasr-client.ts", text)
|
||||
for code in blocks(text, "javascript") + blocks(text, "typescript"):
|
||||
self.assertNotRegex(code, r"[\"']Content-Type[\"']\s*:\s*[\"']multipart/form-data")
|
||||
|
||||
def test_javascript_sdk_stream_lifecycle_is_owned_by_the_caller(self):
|
||||
for path in JAVASCRIPT_DOCS:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
for code in (blocks(text, "javascript")[0], blocks(text, "typescript")[0]):
|
||||
with self.subTest(path=path.name, helper="typescript" if "interface" in code else "javascript"):
|
||||
self.assertIn('from "node:fs/promises"', code)
|
||||
self.assertIn('from "node:stream/promises"', code)
|
||||
self.assertRegex(code, r'await open\(audioPath, ["\']r["\']\)')
|
||||
self.assertIn("handle.createReadStream()", code)
|
||||
self.assertRegex(code, r"finished\(file\)\.catch\(")
|
||||
self.assertIn("toStreamingFile(file, basename(audioPath))", code)
|
||||
self.assertLess(code.index("finished(file)"), code.index("client.audio.transcriptions.create"))
|
||||
self.assertRegex(code, r"finally\s*\{\s*file\.destroy\(\);\s*await closed;")
|
||||
self.assertRegex(code, r"finally\s*\{\s*await handle\.close\(\);")
|
||||
self.assertNotIn("createReadStream(audioPath)", code)
|
||||
self.assertNotRegex(code, r"\b(?:access|existsSync)\(audioPath")
|
||||
|
||||
def test_javascript_speaker_union_covers_actual_serialized_values(self):
|
||||
functions = [function(PACKAGED, name) for name in
|
||||
("resolve_transcription_language", "build_openai_verbose_json")]
|
||||
namespace = {}
|
||||
exec(compile(ast.Module(body=functions, type_ignores=[]), str(PACKAGED), "exec"), namespace)
|
||||
kinds = set()
|
||||
for speaker in (0, "SPK0", "S01"):
|
||||
result = {"text": "hello", "segments": [{"start": 0, "end": 1, "text": "hello", "speaker": speaker}]}
|
||||
actual = namespace["build_openai_verbose_json"](result)["segments"][0]["speaker"]
|
||||
kinds.add("number" if isinstance(actual, int) else "string")
|
||||
handler = function(EXAMPLE / "server.py", "transcribe")
|
||||
speaker_value = next(node.values[node.keys.index(key)] for node in ast.walk(handler)
|
||||
if isinstance(node, ast.Dict) for key in node.keys
|
||||
if isinstance(key, ast.Constant) and key.value == "speaker")
|
||||
self.assertIsNone(eval(compile(ast.Expression(speaker_value), "example-speaker", "eval"), {"seg": {}}))
|
||||
kinds.add("null")
|
||||
for path in JAVASCRIPT_DOCS:
|
||||
helper = blocks(path.read_text(encoding="utf-8"), "typescript")[0]
|
||||
match = re.search(r"speaker\?\s*:\s*([^;}]+)", helper)
|
||||
self.assertIsNotNone(match)
|
||||
self.assertEqual({value.strip() for value in match.group(1).split("|")}, kinds)
|
||||
|
||||
def test_javascript_guides_explain_response_and_forwarding_boundaries(self):
|
||||
for path in JAVASCRIPT_DOCS:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
self.assertIn("](CLIENTS.md#response-formats)", text)
|
||||
for marker in ("sentence_info", "segments=[]", "spk=true", "ctc_timestamps", "use_itn", "hotwords", "Node.js 22"):
|
||||
self.assertIn(marker, text)
|
||||
before_route = text.split("## Next.js route handler", 1)[1].split("```typescript", 1)[0]
|
||||
markers = ("not implemented", "authentication", "upload", "build") if path.name == "JAVASCRIPT.md" else (
|
||||
"未实现", "鉴权", "上传", "build")
|
||||
for marker in markers:
|
||||
self.assertIn(marker, before_route)
|
||||
|
||||
def test_javascript_native_route_has_fixed_upstream_and_bounded_error_contract(self):
|
||||
for path in JAVASCRIPT_DOCS:
|
||||
code = blocks(path.read_text(encoding="utf-8"), "typescript")[1]
|
||||
self.assertRegex(code, r"export const runtime\s*=\s*[\"']nodejs[\"']")
|
||||
self.assertIn("process.env.FUNASR_UPSTREAM_URL", code)
|
||||
self.assertIn("process.env.FUNASR_MODEL", code)
|
||||
self.assertNotIn("NEXT_PUBLIC_", code)
|
||||
self.assertNotRegex(code, r'incoming\.get\([\"\'](?:model|target|url)[\"\']\)')
|
||||
self.assertRegex(code, r"try\s*\{\s*incoming = await request\.formData\(\)")
|
||||
self.assertRegex(code, r"AbortSignal\.timeout\(120_?000\)")
|
||||
self.assertRegex(code, r'redirect:\s*[\"\']error[\"\']')
|
||||
self.assertRegex(code, r"\bsignal\s*[,}]")
|
||||
self.assertRegex(code, r"response\.status\s*>=\s*400")
|
||||
self.assertRegex(code, r"response\.status\s*<=\s*599")
|
||||
self.assertIn("await response.json()", code)
|
||||
self.assertIn("signal.aborted", code)
|
||||
self.assertNotIn("clearTimeout", code)
|
||||
self.assertNotIn("await response.text()", code)
|
||||
self.assertNotIn("error.message", code)
|
||||
self.assertNotIn("from \"next/", code)
|
||||
|
||||
def test_javascript_fetch_cli_does_not_log_raw_upstream_errors(self):
|
||||
for path in JAVASCRIPT_DOCS:
|
||||
code = blocks(path.read_text(encoding="utf-8"), "javascript")[1]
|
||||
self.assertRegex(code, r"AbortSignal\.timeout\(120_?000\)")
|
||||
self.assertRegex(code, r'redirect:\s*[\"\']error[\"\']')
|
||||
self.assertIn("process.exitCode = 1", code)
|
||||
self.assertNotIn("await response.text()", code)
|
||||
self.assertNotIn("error.message", code)
|
||||
fields = re.findall(r'form\.append\([\"\']([^\"\']+)[\"\']', code)
|
||||
self.assertEqual(fields, ["file", "model", "response_format"])
|
||||
allowed = set(form_defaults(function(EXAMPLE / "server.py", "transcribe"))) | {"file"}
|
||||
self.assertLessEqual(set(fields), allowed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user