nls-protocol.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. /**
  2. * 阿里云 NLS「实时语音识别」的**协议层**(纯函数,不碰 DOM / WebSocket)。
  3. *
  4. * 单独抽出来的原因:① 帧结构与事件映射是这个改造里唯一"错了就静默不工作"的部分,
  5. * 抽成纯函数就能被 harness 直接测(`harness/tools/verify-nls-realtime.mjs`);
  6. * ② `src/asr/nls.ts` 只负责音频采集与连接,读起来更清楚。
  7. *
  8. * 契约来源:**实测**(`harness/tools/probe-nls-realtime.mjs` 打真实网关,
  9. * 逐帧看过原始载荷;文档侧要点见 progress.md 续 33)。两条踩过的坑:
  10. * 1. **每一条指令都要带 `appkey`** —— 只有 start 带时,stop 会回
  11. * `TaskFailed 40000002 Gateway:MESSAGE_INVALID:Missing message appkey!`
  12. * 2. 中间结果(`TranscriptionResultChanged`)**会被后续结果覆盖/重叠**,
  13. * 拼接全文只能用 `SentenceEnd` 的定稿结果
  14. */
  15. export const NLS_NAMESPACE = "SpeechTranscriber";
  16. /** 网关地址(上海地域;token 放在 query 上) */
  17. export const NLS_GATEWAY_URL = "wss://nls-gateway-cn-shanghai.aliyuncs.com/ws/v1";
  18. /** 音频按 16k / 16bit / 单声道送(与 `StartTranscription.payload` 里的声明一致) */
  19. export const NLS_SAMPLE_RATE = 16000;
  20. /** 一帧送多少毫秒的音频。100ms 接近实时,且不会把网关灌爆(文档要求按实时速率发) */
  21. export const NLS_FRAME_MS = 100;
  22. /**
  23. * 推流节拍与「追赶」上限。
  24. *
  25. * 为什么需要追赶:连上网关之前(取 token + 握手)麦克风已经在采集,这段音频先攒着;
  26. * 如果之后只按 **1× 实时** 排空,那段积压就**永远排不掉** —— 表现为"出字整体慢半拍",
  27. * 且不会自己恢复(旧厂商 SDK 是按 2× 排空的,所以它能追上)。
  28. *
  29. * 实测数据(`probe-nls-realtime.mjs`):按 1× 推时,客户端落后网关 **20~240ms**;
  30. * 积压来自握手时长(通常 0.2~1s)。这里的策略是**平时按实时、积压时最多 4× 追**
  31. * (4× 约等于 1s 积压用 330ms 排空;再快没有收益,且离"一次性灌入"越来越近)。
  32. */
  33. export const NLS_FLUSH_TICK_MS = 50;
  34. export const NLS_MAX_FRAMES_PER_TICK = 2;
  35. /** 排空速率(倍实时)。>1 才可能把握手期间攒下的积压排掉 */
  36. export const NLS_DRAIN_RATE = (NLS_MAX_FRAMES_PER_TICK * NLS_FRAME_MS) / NLS_FLUSH_TICK_MS;
  37. /** 32 位十六进制 ID(文档要求 message_id / task_id 的形态) */
  38. export const createNlsId = (): string => {
  39. const bytes = new Uint8Array(16);
  40. if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
  41. crypto.getRandomValues(bytes);
  42. } else {
  43. for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
  44. }
  45. return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
  46. };
  47. export interface NlsStartOptions {
  48. appkey: string;
  49. taskId?: string;
  50. messageId?: string;
  51. /**
  52. * 端点静音断句阈值(毫秒,网关允许 200~2000)。
  53. *
  54. * 默认 **500**(网关文档默认 800),实测(`probe-nls-realtime.mjs`):
  55. * 300ms 比 800ms 的**定稿早到约 300ms**,中间结果节奏不受它影响(恒 ~480ms)。
  56. * 取 500 是折中:说得快的人不会在自然停顿处被切断,说一句停一下的人能更快看到定稿。
  57. * 停顿被切句不影响文本完整性(后续句子会继续追加)。
  58. */
  59. maxSentenceSilence?: number;
  60. }
  61. /**
  62. * `StartTranscription` 帧。`enable_intermediate_result` 必须开,否则没有"边说边出字"。
  63. *
  64. * ⚠️ `task_id` **不能为空**:实测网关会回
  65. * `TaskFailed 40000002 Gateway:MESSAGE_INVALID:Invalid task id ''!` ——
  66. * 所以不传时这里自己生成一个(32 位十六进制),调用方拿返回值里的 task_id 复用到 stop 帧。
  67. */
  68. export const buildStartTranscription = (options: NlsStartOptions) =>
  69. JSON.stringify({
  70. header: {
  71. appkey: options.appkey,
  72. message_id: options.messageId || createNlsId(),
  73. task_id: options.taskId || createNlsId(),
  74. namespace: NLS_NAMESPACE,
  75. task: "start",
  76. name: "StartTranscription",
  77. },
  78. payload: {
  79. format: "pcm",
  80. sample_rate: NLS_SAMPLE_RATE,
  81. enable_intermediate_result: true,
  82. enable_punctuation_prediction: true,
  83. enable_inverse_text_normalization: true,
  84. enable_voice_detection: true,
  85. max_sentence_silence: options.maxSentenceSilence ?? 500,
  86. },
  87. });
  88. /** `StopTranscription` 帧 —— ⚠️ appkey 不能省(见文件头注释的坑 1) */
  89. export const buildStopTranscription = (options: { appkey: string; taskId: string; messageId?: string }) =>
  90. JSON.stringify({
  91. header: {
  92. appkey: options.appkey,
  93. message_id: options.messageId || createNlsId(),
  94. task_id: options.taskId,
  95. namespace: NLS_NAMESPACE,
  96. task: "stop",
  97. name: "StopTranscription",
  98. },
  99. });
  100. export interface NlsServerMessage {
  101. header?: { name?: string; status?: number; status_text?: string; message_id?: string; task_id?: string };
  102. payload?: { index?: number; time?: number; result?: string; confidence?: number; status?: number; message?: string };
  103. }
  104. /** 解析下行文本帧;不是合法 JSON 就返回 null(二进制帧等一律忽略) */
  105. export const parseNlsMessage = (raw: unknown): NlsServerMessage | null => {
  106. if (typeof raw !== "string" || !raw.trim()) return null;
  107. try {
  108. const parsed = JSON.parse(raw);
  109. return parsed && typeof parsed === "object" ? (parsed as NlsServerMessage) : null;
  110. } catch {
  111. return null;
  112. }
  113. };
  114. export const getNlsEventName = (message: NlsServerMessage | null): string => message?.header?.name || "";
  115. /** 网关给的成功码 */
  116. export const NLS_STATUS_SUCCESS = 20000000;
  117. /** 把网关的错误说成人话(用于 toast / 日志) */
  118. export const describeNlsFailure = (message: NlsServerMessage | null): string => {
  119. const status = message?.header?.status ?? message?.payload?.status;
  120. const text = message?.header?.status_text || message?.payload?.message || "";
  121. return `语音识别失败(${status ?? "未知"})${text ? `:${text}` : ""}`;
  122. };
  123. export interface NlsTranscriptState {
  124. /** 已经**定稿**的句子拼起来的全文 */
  125. finalText: string;
  126. }
  127. export const createNlsTranscriptState = (): NlsTranscriptState => ({ finalText: "" });
  128. export interface NlsTransformPayload {
  129. /** 与旧 SDK 的事件负载保持一致,壳组件不用改 */
  130. type: "mid" | "fin";
  131. current: string;
  132. transformText: string;
  133. command: "";
  134. }
  135. /**
  136. * 把一条下行事件折叠进识别状态,产出要发给界面的事件(没有可发内容时返回 null)。
  137. *
  138. * | 网关事件 | 产出 |
  139. * |---|---|
  140. * | `TranscriptionResultChanged` | `mid`:`transformText` = 已定稿全文 + 本句中间结果 |
  141. * | `SentenceEnd` | `fin`:把本句**定稿**追加进全文,`transformText` = 新全文 |
  142. * | `SentenceBegin` / 其它 | null(状态不变) |
  143. *
  144. * 注意 `SentenceEnd.payload.result` 是**整句**(实测:"青浦区高新技术企业奖励是多少钱?"),
  145. * 所以是追加而不是替换。
  146. */
  147. export const reduceNlsTranscript = (
  148. state: NlsTranscriptState,
  149. message: NlsServerMessage | null
  150. ): { state: NlsTranscriptState; event: NlsTransformPayload | null } => {
  151. const name = getNlsEventName(message);
  152. const result = String(message?.payload?.result ?? "");
  153. if (name === "TranscriptionResultChanged") {
  154. if (!result) return { state, event: null };
  155. return {
  156. state,
  157. event: { type: "mid", current: result, transformText: state.finalText + result, command: "" },
  158. };
  159. }
  160. if (name === "SentenceEnd") {
  161. if (!result) return { state, event: null };
  162. const next = { finalText: state.finalText + result };
  163. return { state: next, event: { type: "fin", current: result, transformText: next.finalText, command: "" } };
  164. }
  165. return { state, event: null };
  166. };
  167. /**
  168. * Float32 采样 → 16k / 16bit / 单声道 的小端 PCM 字节。
  169. *
  170. * ⚠️ 与旧 SDK 的 `to16kHZ()` 的区别:**采样率按实际传入的 `fromRate` 算**
  171. * (旧实现把输入写死成 44100,设备实际是 48000 时会送出"慢 8% 的 16k",
  172. * 见 `harness/docs/reference/asr-realtime.md` §9.1)—— 这里是新实现,不再沿用那个假设。
  173. */
  174. export const encodePcm16kMono = (input: Float32Array, fromRate: number): Uint8Array => {
  175. const rate = Number.isFinite(fromRate) && fromRate > 0 ? fromRate : NLS_SAMPLE_RATE;
  176. let samples: Float32Array = input;
  177. if (Math.round(rate) !== NLS_SAMPLE_RATE) {
  178. const count = Math.round((input.length * NLS_SAMPLE_RATE) / rate);
  179. const resampled = new Float32Array(count);
  180. const step = (input.length - 1) / Math.max(count - 1, 1);
  181. for (let i = 0; i < count; i++) {
  182. const pos = i * step;
  183. const before = Math.floor(pos);
  184. const after = Math.min(Math.ceil(pos), input.length - 1);
  185. const ratio = pos - before;
  186. resampled[i] = input[before] + (input[after] - input[before]) * ratio;
  187. }
  188. samples = resampled;
  189. }
  190. const bytes = new Uint8Array(samples.length * 2);
  191. const view = new DataView(bytes.buffer);
  192. for (let i = 0; i < samples.length; i++) {
  193. const clamped = Math.max(-1, Math.min(1, samples[i]));
  194. view.setInt16(i * 2, clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff, true);
  195. }
  196. return bytes;
  197. };