nls.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  1. /**
  2. * 实时语音识别(阿里云 NLS 版)—— 与旧厂商 SDK(`./index.ts`)**对外契约一致**:
  3. *
  4. * new ASR(appKey?, CONSTANTS?) · openMicroPhone() · startRecord() · openSocket()
  5. * · transform() · close() · clear() · 事件 `transform` / `error`
  6. *
  7. * 要点:
  8. * - 麦克风用 `navigator.mediaDevices.getUserMedia` 采集,PCM 以 16k / 16bit / 单声道推给网关
  9. * - **重采样按实际采样率算**;**定稿只认 `SentenceEnd`**(中间结果会重叠,不能拼)
  10. * - 凭据:**前端不持有任何阿里云凭据**,token + appkey 由后端网关下发(见 `nls-token.ts`)
  11. *
  12. * 协议细节见 `./nls-protocol.ts`。
  13. */
  14. import { BaseEvent } from "@/utils/base-event";
  15. import { fetchNlsToken, getNlsTokenAppkey } from "@/network/api/nls-token";
  16. import {
  17. NLS_FLUSH_TICK_MS,
  18. NLS_FRAME_MS,
  19. NLS_GATEWAY_URL,
  20. NLS_MAX_FRAMES_PER_TICK,
  21. NLS_SAMPLE_RATE,
  22. NLS_STATUS_SUCCESS,
  23. buildStartTranscription,
  24. buildStopTranscription,
  25. createNlsId,
  26. createNlsTranscriptState,
  27. describeNlsFailure,
  28. encodePcm16kMono,
  29. getNlsEventName,
  30. parseNlsMessage,
  31. reduceNlsTranscript,
  32. } from "./nls-protocol";
  33. /** 收尾时最多等网关多久(超过就用手上已有的文本收场,不让"停止"按钮卡住) */
  34. const STOP_TIMEOUT_MS = 3000;
  35. /** 建连/开始识别最多等多久 */
  36. const START_TIMEOUT_MS = 8000;
  37. export default class NlsAsr extends BaseEvent {
  38. /** 构造参数里的第一个仍是 appKey,但 NLS 版的 appkey 由端点下发,这里只作兜底 */
  39. appKey: string;
  40. CONSTANTS: MediaStreamConstraints;
  41. stream: MediaStream | null = null;
  42. audioContext: AudioContext | null = null;
  43. source: MediaStreamAudioSourceNode | null = null;
  44. processor: ScriptProcessorNode | null = null;
  45. /** 已定稿全文(`transform()` 的返回值来源) */
  46. private transcript = createNlsTranscriptState();
  47. private socket: WebSocket | null = null;
  48. private taskId = "";
  49. private appkey = "";
  50. private pendingAudio: Uint8Array[] = [];
  51. private sendTimer: number | null = null;
  52. private processing = false;
  53. private stopping = false;
  54. private startResolve: (() => void) | null = null;
  55. private startReject: ((error: Error) => void) | null = null;
  56. private stopResolve: (() => void) | null = null;
  57. constructor(appKey = "", CONSTANTS: MediaStreamConstraints = { audio: true, video: false }) {
  58. super();
  59. this.appKey = appKey;
  60. this.CONSTANTS = CONSTANTS;
  61. }
  62. /** 申请麦克风。失败抛 `Error`(调用方 catch 后 toast)——与旧实现的语义一致 */
  63. async openMicroPhone(): Promise<boolean> {
  64. try {
  65. this.stream = await navigator.mediaDevices.getUserMedia(this.CONSTANTS);
  66. } catch (error) {
  67. /* console.warn("[nls] 获取麦克风失败", error); */
  68. // 区分"用户拒绝"与"设备异常":前者要给可操作的提示
  69. if ((error as DOMException)?.name === "NotAllowedError") {
  70. throw new Error("麦克风权限被拒绝,请在浏览器或系统设置里允许后重试");
  71. }
  72. throw new Error("获取麦克风流失败,请检查麦克风是否正常工作");
  73. }
  74. return true;
  75. }
  76. /**
  77. * 开始采集音频。与旧实现一样**必须在 `openSocket()` 之前调用**。
  78. * 采集到的 PCM 先缓存,连上后再按实时节奏推给网关。
  79. */
  80. startRecord(): void {
  81. this.pendingAudio = [];
  82. this.transcript = createNlsTranscriptState();
  83. this.processing = true;
  84. this.stopping = false;
  85. if (!this.stream) {
  86. this._eventbus.emit("error", { message: "未获取到麦克风流" });
  87. return;
  88. }
  89. this.audioContext = new window.AudioContext();
  90. this.source = this.audioContext.createMediaStreamSource(this.stream);
  91. // ScriptProcessorNode 已废弃,但仓库的波形绘制依赖 audioContext/source,
  92. // 所以与旧实现共用同一套采集方式
  93. this.processor = this.audioContext.createScriptProcessor(0, 1, 1);
  94. this.processor.connect(this.audioContext.destination);
  95. this.source.connect(this.processor);
  96. const sampleRate = this.audioContext.sampleRate;
  97. let capturedChunks = 0;
  98. let silenceWarned = false;
  99. /* console.log(
  100. `[nls] 开始采集:AudioContext=${this.audioContext.state} 采样率=${sampleRate}Hz ` +
  101. `processor 缓冲=${this.processor.bufferSize}`
  102. ); */
  103. // 音轨自己的状态 —— 分辨「系统/硬件把麦克风静音了」与「接线有问题」的关键证据:
  104. // muted=true 时 getUserMedia 照样成功、回调照样触发,但采样全是 0。
  105. const track = this.stream.getAudioTracks?.()[0];
  106. if (track) {
  107. const settings = typeof track.getSettings === "function" ? track.getSettings() : {};
  108. /* console.log(
  109. `[nls] 音轨:label=${JSON.stringify(track.label)} muted=${track.muted} enabled=${track.enabled} ` +
  110. `state=${track.readyState} deviceId=${String(settings.deviceId || "").slice(0, 8)}… ` +
  111. `声道=${settings.channelCount ?? "?"}`
  112. ); */
  113. const reportTrackState = (tag: string) => {
  114. /* console.log(`[nls] ${tag}音轨状态:muted=${track.muted} enabled=${track.enabled} state=${track.readyState}`); */
  115. };
  116. window.setTimeout(() => reportTrackState("1.5s 后"), 1500);
  117. track.addEventListener?.("mute", () => reportTrackState("被静音时(mute 事件)"));
  118. track.addEventListener?.("unmute", () => reportTrackState("解除静音时(unmute 事件)"));
  119. } else {
  120. /* console.warn("[nls] ⚠️ 流里没有音轨(getUserMedia 拿到的不是音频流?)"); */
  121. }
  122. this.processor.onaudioprocess = (event: AudioProcessingEvent) => {
  123. if (!this.processing || !this.stream || !this.stream.active) return;
  124. const channel = event.inputBuffer.getChannelData(0);
  125. const encoded = encodePcm16kMono(channel, sampleRate);
  126. capturedChunks += 1;
  127. if (capturedChunks === 1) {
  128. /* console.log(`[nls] 收到第一块音频:输入 ${channel.length} 采样 → 编码后 ${encoded.length} 字节`); */
  129. }
  130. // 电平日志(每 ~2s 一条):峰值长期 ≈0 说明采集端没拿到声音,问题在设备/权限而不在协议
  131. if (capturedChunks % 50 === 1) {
  132. let peak = 0;
  133. let sum = 0;
  134. for (let i = 0; i < channel.length; i++) {
  135. const value = Math.abs(channel[i]);
  136. if (value > peak) peak = value;
  137. sum += value * value;
  138. }
  139. const rms = Math.sqrt(sum / channel.length);
  140. /* console.log(`[nls] 采集电平:峰值=${peak.toFixed(4)} RMS=${rms.toFixed(4)}`); */
  141. // 连续 3 秒全静音 → 把「当前用的是哪个输入设备」摊开说清楚
  142. // (虚拟设备 / 系统静音 / 被会议软件独占,都会是"能采集但电平恒为 0")
  143. if (peak === 0 && !silenceWarned) {
  144. silenceWarned = true;
  145. const deviceLabel = this.stream?.getAudioTracks?.()[0]?.label || "(未知设备)";
  146. /* console.warn(
  147. `[nls] ⚠️ 连续采集到全静音:当前输入设备是 ${JSON.stringify(deviceLabel)}。\n` +
  148. `[nls] 若它不是你正在说话的麦克风(常见的坑:Steam Streaming Microphone 这类**虚拟设备**、\n` +
  149. `[nls] 系统静音/隐私开关、被会议软件独占),请在 Windows「声音设置 → 输入」或\n` +
  150. `[nls] 浏览器「站点设置 → 麦克风」里换一个真实麦克风后重试。`
  151. ); */
  152. }
  153. }
  154. this.pendingAudio.push(encoded);
  155. };
  156. // AudioContext 有时会以 suspended 起步(那样 onaudioprocess 根本不会回调),主动 resume 一次
  157. if (this.audioContext.state === "suspended") {
  158. void this.audioContext.resume().catch(() => undefined);
  159. }
  160. window.setTimeout(() => {
  161. if (capturedChunks === 0 && this.processing) {
  162. /* console.warn(
  163. `[nls] ⚠️ 1.5s 内没有采集到任何音频(AudioContext=${this.audioContext?.state},` +
  164. `stream.active=${this.stream?.active})—— 麦克风可能被别的程序占用或未真正启动`
  165. ); */
  166. }
  167. }, 1500);
  168. }
  169. /** 取 token → 连网关 → 发 StartTranscription → 等 TranscriptionStarted */
  170. async openSocket(): Promise<void> {
  171. const { token } = await fetchNlsToken();
  172. // appkey 来自配置(见 getNlsAppkey);构造参数只作兜底
  173. this.appkey = getNlsTokenAppkey() || this.appKey;
  174. this.taskId = createNlsId();
  175. await new Promise<void>((resolve, reject) => {
  176. this.startResolve = resolve;
  177. this.startReject = reject;
  178. /** 诊断用:中间结果事件计数(日志里每 4 条打一条,避免刷屏) */
  179. let midEvents = 0;
  180. const url = `${NLS_GATEWAY_URL}?token=${encodeURIComponent(token)}`;
  181. const socket = new WebSocket(url);
  182. this.socket = socket;
  183. const startTimer = window.setTimeout(() => {
  184. reject(new Error("语音识别连接超时(未收到 TranscriptionStarted)"));
  185. this.closeSocket();
  186. }, START_TIMEOUT_MS);
  187. const settle = () => window.clearTimeout(startTimer);
  188. socket.onopen = () => {
  189. socket.send(buildStartTranscription({ appkey: this.appkey, taskId: this.taskId }));
  190. };
  191. socket.onmessage = (event: MessageEvent<string>) => {
  192. const message = parseNlsMessage(event.data);
  193. const name = getNlsEventName(message);
  194. if (!name) return;
  195. // 诊断日志:把网关下行事件打出来(ResultChanged 很密,每 4 条打一条),
  196. // 排查「进录音态但不出字」时能区分「音频没上去」和「服务端没回/回了错」
  197. if (name === "TaskFailed") {
  198. /* console.warn(`[nls] ← TaskFailed:${describeNlsFailure(message)}`); */
  199. } else if (name !== "TranscriptionResultChanged" && name !== "SentenceBegin") {
  200. /* console.log(`[nls] ← ${name}${message?.payload?.result ? ` ${JSON.stringify(message.payload.result)}` : ""}`); */
  201. } else if (name === "TranscriptionResultChanged") {
  202. midEvents += 1;
  203. if (midEvents % 4 === 1) {
  204. /* console.log(`[nls] ← 中间结果:${JSON.stringify(message?.payload?.result ?? "")}`); */
  205. }
  206. }
  207. if (name === "TranscriptionStarted") {
  208. settle();
  209. this.startFlushTimer();
  210. this.startResolve?.();
  211. return;
  212. }
  213. if (name === "TaskFailed") {
  214. const error = new Error(describeNlsFailure(message));
  215. settle();
  216. this._eventbus.emit("error", { message: error.message });
  217. this.startReject?.(error);
  218. return;
  219. }
  220. const { state, event: payload } = reduceNlsTranscript(this.transcript, message);
  221. this.transcript = state;
  222. if (payload) this._eventbus.emit("transform", payload);
  223. if (name === "TranscriptionCompleted") {
  224. this.stopResolve?.();
  225. }
  226. };
  227. socket.onerror = () => {
  228. settle();
  229. const error = new Error("语音识别连接出错");
  230. this._eventbus.emit("error", { message: error.message });
  231. this.startReject?.(error);
  232. };
  233. socket.onclose = () => {
  234. settle();
  235. // 还没开始就断了 → 让 openSocket 的 await 失败,别静默卡住
  236. this.startReject?.(new Error("语音识别连接已关闭"));
  237. this.stopResolve?.();
  238. };
  239. });
  240. }
  241. /**
  242. * 结束一轮:停麦克风 → 把余下的音频发完 → 发 StopTranscription → 等 TranscriptionCompleted。
  243. * 返回**累计定稿文本**(与旧实现的 `{text, command}` 形态一致)。
  244. */
  245. async transform(): Promise<{ text: string; command: string }> {
  246. this.stopping = true;
  247. this.processing = false;
  248. this.stopSendTimer();
  249. if (this.stream) {
  250. this.stream.getTracks().forEach((track) => track.stop());
  251. }
  252. this.source?.disconnect();
  253. this.processor?.disconnect();
  254. // 余下的音频尽快发完:用户此刻正在等最后一段文字,按 100ms 一帧的实时节奏发
  255. // 会让"停止后还要等"的体感更差 —— 这里用 20ms 一帧(≈5× 实时)
  256. const frames = this.pendingAudio;
  257. this.pendingAudio = [];
  258. for (const frame of frames) {
  259. if (this.socket?.readyState !== WebSocket.OPEN) break;
  260. this.socket.send(frame);
  261. await new Promise((r) => window.setTimeout(r, 20));
  262. }
  263. const finalText = this.transcript.finalText;
  264. if (this.socket?.readyState === WebSocket.OPEN) {
  265. this.socket.send(buildStopTranscription({ appkey: this.appkey, taskId: this.taskId }));
  266. await new Promise<void>((resolve) => {
  267. this.stopResolve = resolve;
  268. const timer = window.setTimeout(() => {
  269. this.stopResolve = null;
  270. resolve();
  271. }, STOP_TIMEOUT_MS);
  272. const original = this.stopResolve;
  273. this.stopResolve = () => {
  274. window.clearTimeout(timer);
  275. original?.();
  276. };
  277. });
  278. }
  279. return { text: this.transcript.finalText || finalText, command: "end" };
  280. }
  281. /** 停采集、收音频、断连接(壳组件在 `transform()` 之后调用) */
  282. close(): void {
  283. this.processing = false;
  284. this.stopping = false;
  285. this.pendingAudio = [];
  286. this.stopSendTimer();
  287. if (this.stream) {
  288. this.stream.getTracks().forEach((track) => track.stop());
  289. }
  290. this.closeSocket();
  291. }
  292. /** 清干净(断开音频节点、清事件监听)——调用后实例作废,调用方置 null */
  293. clear(): void {
  294. this.source?.disconnect();
  295. this.processor?.disconnect();
  296. this.audioContext?.close?.().catch(() => {
  297. /* 关闭失败无所谓:上下文本来就随页面回收 */
  298. });
  299. this.audioContext = null;
  300. this.source = null;
  301. this.processor = null;
  302. this.stream = null;
  303. this.socket = null;
  304. this.pendingAudio = [];
  305. this.allClear();
  306. }
  307. private closeSocket(): void {
  308. const socket = this.socket;
  309. this.socket = null;
  310. if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
  311. try {
  312. socket.close();
  313. } catch {
  314. /* ignore */
  315. }
  316. }
  317. }
  318. private stopSendTimer(): void {
  319. if (this.sendTimer !== null) {
  320. window.clearInterval(this.sendTimer);
  321. this.sendTimer = null;
  322. }
  323. }
  324. /**
  325. * 把缓存里的音频按帧推给网关:**平时按实时节奏,积压时最多 4× 追赶**
  326. * (连上网关前麦克风已在采集,只按 1× 排空那段积压永远排不掉,表现为"出字整体慢半拍";
  327. * 常量与说明见 nls-protocol.ts)。
  328. */
  329. private startFlushTimer(): void {
  330. this.stopSendTimer();
  331. const bytesPerFrame = (NLS_SAMPLE_RATE * 2 * NLS_FRAME_MS) / 1000; // 3200 字节 = 100ms
  332. let carry = new Uint8Array(0);
  333. let sentFrames = 0;
  334. let lastLoggedFrames = 0;
  335. let stallWarnedAt = 0;
  336. const flushStartedAt = Date.now();
  337. this.sendTimer = window.setInterval(() => {
  338. if (this.socket?.readyState !== WebSocket.OPEN) return;
  339. // 1) 把采集到的块**全部**并进 carry。
  340. // ⚠️ 合并**不设上限**:一旦按「carry + 下一块 ≤ 两帧」限制,浏览器给的 buffer
  341. // 比两帧大时就永远合并不进来,结果**一帧音频都发不出去**(界面看着仍在正常录音)。
  342. if (this.pendingAudio.length) {
  343. const pending = this.pendingAudio;
  344. this.pendingAudio = [];
  345. const merged = new Uint8Array(carry.length + pending.reduce((n, c) => n + c.length, 0));
  346. merged.set(carry, 0);
  347. let offset = carry.length;
  348. for (const chunk of pending) {
  349. merged.set(chunk, offset);
  350. offset += chunk.length;
  351. }
  352. carry = merged;
  353. }
  354. // 2) 按帧发(有积压时一个 tick 最多发 NLS_MAX_FRAMES_PER_TICK 帧)
  355. let sentThisTick = 0;
  356. while (carry.length >= bytesPerFrame && sentThisTick < NLS_MAX_FRAMES_PER_TICK) {
  357. if (this.socket?.readyState !== WebSocket.OPEN) return;
  358. this.socket.send(carry.subarray(0, bytesPerFrame));
  359. carry = carry.subarray(bytesPerFrame);
  360. sentThisTick += 1;
  361. sentFrames += 1;
  362. }
  363. if (sentFrames - lastLoggedFrames >= 20) {
  364. lastLoggedFrames = sentFrames;
  365. /* console.log(`[nls] 已推音频 ${Math.round((sentFrames * bytesPerFrame) / 32)}ms(${sentFrames} 帧)`); */
  366. }
  367. // 3) 诊断:录着音却 >=2 秒一帧都没发出去 → 明确报出来,别"静默不出声"
  368. if (this.processing && sentFrames === 0 && Date.now() - flushStartedAt > 2000 && Date.now() - stallWarnedAt > 5000) {
  369. stallWarnedAt = Date.now();
  370. /* console.warn(
  371. `[nls] ⚠️ 录音中但音频一帧都没发出去(carry=${carry.length}B,缓存块=${this.pendingAudio.length})` +
  372. ` —— 若持续如此,检查采集端是否真的在出数据(见上一条「采集开始」日志里的采样率)`
  373. ); */
  374. }
  375. }, NLS_FLUSH_TICK_MS);
  376. }
  377. }