/** * 阿里云 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; };