| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222 |
- /**
- * 阿里云 NLS「实时语音识别」的**协议层**(纯函数,不碰 DOM / WebSocket)。
- *
- * 单独抽出来的原因:① 帧结构与事件映射是这个改造里唯一"错了就静默不工作"的部分,
- * 抽成纯函数就能被 harness 直接测(`harness/tools/verify-nls-realtime.mjs`);
- * ② `src/asr/nls.ts` 只负责音频采集与连接,读起来更清楚。
- *
- * 契约来源:**实测**(`harness/tools/probe-nls-realtime.mjs` 打真实网关,
- * 逐帧看过原始载荷;文档侧要点见 progress.md 续 33)。两条踩过的坑:
- * 1. **每一条指令都要带 `appkey`** —— 只有 start 带时,stop 会回
- * `TaskFailed 40000002 Gateway:MESSAGE_INVALID:Missing message appkey!`
- * 2. 中间结果(`TranscriptionResultChanged`)**会被后续结果覆盖/重叠**,
- * 拼接全文只能用 `SentenceEnd` 的定稿结果
- */
- export const NLS_NAMESPACE = "SpeechTranscriber";
- /** 网关地址(上海地域;token 放在 query 上) */
- export const NLS_GATEWAY_URL = "wss://nls-gateway-cn-shanghai.aliyuncs.com/ws/v1";
- /** 音频按 16k / 16bit / 单声道送(与 `StartTranscription.payload` 里的声明一致) */
- export const NLS_SAMPLE_RATE = 16000;
- /** 一帧送多少毫秒的音频。100ms 接近实时,且不会把网关灌爆(文档要求按实时速率发) */
- export const NLS_FRAME_MS = 100;
- /**
- * 推流节拍与「追赶」上限。
- *
- * 为什么需要追赶:连上网关之前(取 token + 握手)麦克风已经在采集,这段音频先攒着;
- * 如果之后只按 **1× 实时** 排空,那段积压就**永远排不掉** —— 表现为"出字整体慢半拍",
- * 且不会自己恢复(旧厂商 SDK 是按 2× 排空的,所以它能追上)。
- *
- * 实测数据(`probe-nls-realtime.mjs`):按 1× 推时,客户端落后网关 **20~240ms**;
- * 积压来自握手时长(通常 0.2~1s)。这里的策略是**平时按实时、积压时最多 4× 追**
- * (4× 约等于 1s 积压用 330ms 排空;再快没有收益,且离"一次性灌入"越来越近)。
- */
- export const NLS_FLUSH_TICK_MS = 50;
- export const NLS_MAX_FRAMES_PER_TICK = 2;
- /** 排空速率(倍实时)。>1 才可能把握手期间攒下的积压排掉 */
- export const NLS_DRAIN_RATE = (NLS_MAX_FRAMES_PER_TICK * NLS_FRAME_MS) / NLS_FLUSH_TICK_MS;
- /** 32 位十六进制 ID(文档要求 message_id / task_id 的形态) */
- export const createNlsId = (): string => {
- const bytes = new Uint8Array(16);
- if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
- crypto.getRandomValues(bytes);
- } else {
- for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
- }
- return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
- };
- export interface NlsStartOptions {
- appkey: string;
- taskId?: string;
- messageId?: string;
- /**
- * 端点静音断句阈值(毫秒,网关允许 200~2000)。
- *
- * 默认 **500**(网关文档默认 800),实测(`probe-nls-realtime.mjs`):
- * 300ms 比 800ms 的**定稿早到约 300ms**,中间结果节奏不受它影响(恒 ~480ms)。
- * 取 500 是折中:说得快的人不会在自然停顿处被切断,说一句停一下的人能更快看到定稿。
- * 停顿被切句不影响文本完整性(后续句子会继续追加)。
- */
- maxSentenceSilence?: number;
- }
- /**
- * `StartTranscription` 帧。`enable_intermediate_result` 必须开,否则没有"边说边出字"。
- *
- * ⚠️ `task_id` **不能为空**:实测网关会回
- * `TaskFailed 40000002 Gateway:MESSAGE_INVALID:Invalid task id ''!` ——
- * 所以不传时这里自己生成一个(32 位十六进制),调用方拿返回值里的 task_id 复用到 stop 帧。
- */
- export const buildStartTranscription = (options: NlsStartOptions) =>
- JSON.stringify({
- header: {
- appkey: options.appkey,
- message_id: options.messageId || createNlsId(),
- task_id: options.taskId || createNlsId(),
- namespace: NLS_NAMESPACE,
- task: "start",
- name: "StartTranscription",
- },
- payload: {
- format: "pcm",
- sample_rate: NLS_SAMPLE_RATE,
- enable_intermediate_result: true,
- enable_punctuation_prediction: true,
- enable_inverse_text_normalization: true,
- enable_voice_detection: true,
- max_sentence_silence: options.maxSentenceSilence ?? 500,
- },
- });
- /** `StopTranscription` 帧 —— ⚠️ appkey 不能省(见文件头注释的坑 1) */
- export const buildStopTranscription = (options: { appkey: string; taskId: string; messageId?: string }) =>
- JSON.stringify({
- header: {
- appkey: options.appkey,
- message_id: options.messageId || createNlsId(),
- task_id: options.taskId,
- namespace: NLS_NAMESPACE,
- task: "stop",
- name: "StopTranscription",
- },
- });
- export interface NlsServerMessage {
- header?: { name?: string; status?: number; status_text?: string; message_id?: string; task_id?: string };
- payload?: { index?: number; time?: number; result?: string; confidence?: number; status?: number; message?: string };
- }
- /** 解析下行文本帧;不是合法 JSON 就返回 null(二进制帧等一律忽略) */
- export const parseNlsMessage = (raw: unknown): NlsServerMessage | null => {
- if (typeof raw !== "string" || !raw.trim()) return null;
- try {
- const parsed = JSON.parse(raw);
- return parsed && typeof parsed === "object" ? (parsed as NlsServerMessage) : null;
- } catch {
- return null;
- }
- };
- export const getNlsEventName = (message: NlsServerMessage | null): string => message?.header?.name || "";
- /** 网关给的成功码 */
- export const NLS_STATUS_SUCCESS = 20000000;
- /** 把网关的错误说成人话(用于 toast / 日志) */
- export const describeNlsFailure = (message: NlsServerMessage | null): string => {
- const status = message?.header?.status ?? message?.payload?.status;
- const text = message?.header?.status_text || message?.payload?.message || "";
- return `语音识别失败(${status ?? "未知"})${text ? `:${text}` : ""}`;
- };
- export interface NlsTranscriptState {
- /** 已经**定稿**的句子拼起来的全文 */
- finalText: string;
- }
- export const createNlsTranscriptState = (): NlsTranscriptState => ({ finalText: "" });
- export interface NlsTransformPayload {
- /** 与旧 SDK 的事件负载保持一致,壳组件不用改 */
- type: "mid" | "fin";
- current: string;
- transformText: string;
- command: "";
- }
- /**
- * 把一条下行事件折叠进识别状态,产出要发给界面的事件(没有可发内容时返回 null)。
- *
- * | 网关事件 | 产出 |
- * |---|---|
- * | `TranscriptionResultChanged` | `mid`:`transformText` = 已定稿全文 + 本句中间结果 |
- * | `SentenceEnd` | `fin`:把本句**定稿**追加进全文,`transformText` = 新全文 |
- * | `SentenceBegin` / 其它 | null(状态不变) |
- *
- * 注意 `SentenceEnd.payload.result` 是**整句**(实测:"青浦区高新技术企业奖励是多少钱?"),
- * 所以是追加而不是替换。
- */
- export const reduceNlsTranscript = (
- state: NlsTranscriptState,
- message: NlsServerMessage | null
- ): { state: NlsTranscriptState; event: NlsTransformPayload | null } => {
- const name = getNlsEventName(message);
- const result = String(message?.payload?.result ?? "");
- if (name === "TranscriptionResultChanged") {
- if (!result) return { state, event: null };
- return {
- state,
- event: { type: "mid", current: result, transformText: state.finalText + result, command: "" },
- };
- }
- if (name === "SentenceEnd") {
- if (!result) return { state, event: null };
- const next = { finalText: state.finalText + result };
- return { state: next, event: { type: "fin", current: result, transformText: next.finalText, command: "" } };
- }
- return { state, event: null };
- };
- /**
- * Float32 采样 → 16k / 16bit / 单声道 的小端 PCM 字节。
- *
- * ⚠️ 与旧 SDK 的 `to16kHZ()` 的区别:**采样率按实际传入的 `fromRate` 算**
- * (旧实现把输入写死成 44100,设备实际是 48000 时会送出"慢 8% 的 16k",
- * 见 `harness/docs/reference/asr-realtime.md` §9.1)—— 这里是新实现,不再沿用那个假设。
- */
- export const encodePcm16kMono = (input: Float32Array, fromRate: number): Uint8Array => {
- const rate = Number.isFinite(fromRate) && fromRate > 0 ? fromRate : NLS_SAMPLE_RATE;
- let samples: Float32Array = input;
- if (Math.round(rate) !== NLS_SAMPLE_RATE) {
- const count = Math.round((input.length * NLS_SAMPLE_RATE) / rate);
- const resampled = new Float32Array(count);
- const step = (input.length - 1) / Math.max(count - 1, 1);
- for (let i = 0; i < count; i++) {
- const pos = i * step;
- const before = Math.floor(pos);
- const after = Math.min(Math.ceil(pos), input.length - 1);
- const ratio = pos - before;
- resampled[i] = input[before] + (input[after] - input[before]) * ratio;
- }
- samples = resampled;
- }
- const bytes = new Uint8Array(samples.length * 2);
- const view = new DataView(bytes.buffer);
- for (let i = 0; i < samples.length; i++) {
- const clamped = Math.max(-1, Math.min(1, samples[i]));
- view.setInt16(i * 2, clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff, true);
- }
- return bytes;
- };
|