nls.ts 18 KB

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