index.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578
  1. import { BaseEvent } from "@/utils/base-event";
  2. import { isMobile } from "@/utils";
  3. /**
  4. * language:语种
  5. * zh_cn:中文(支持简单的英文识别)
  6. * en_us:英文
  7. * domain:应用领域
  8. * iat:日常用语
  9. * accent:方言
  10. * mandarin:中文普通话、其他语种
  11. * vad_eos:用于设置端点检测的静默时间,单位是毫秒。
  12. * ptt:(仅中文支持)是否开启标点符号添加
  13. * 1:开启(默认值)
  14. * 0:关闭
  15. * nunum:(中文普通话和日语支持)将返回结果的数字格式规则为阿拉伯数字格式,默认开启
  16. * 0:关闭
  17. * 1:开启
  18. */
  19. type AsrServerOptions = {
  20. language: string;
  21. domain: string;
  22. accent: string;
  23. vad_eos: number;
  24. dwa: string;
  25. };
  26. const DEFAULT_ASR_SERVER_OPTIONS: AsrServerOptions = {
  27. language: "zh-cn",
  28. domain: "iat",
  29. accent: "mandarin",
  30. vad_eos: 10000,
  31. dwa: "wpgs",
  32. };
  33. const isXunFei = globalThis.isXF;
  34. const closeCommand = isXunFei ? { data: { status: 2 } } : { command: "close" };
  35. const CHUNK_MULTIPLE = isXunFei ? 1 : 4;
  36. const CHUNK_LENGTH = 1280 * CHUNK_MULTIPLE;
  37. const CHECK_INTERVAL = isXunFei ? 40 : 80;
  38. /**
  39. *
  40. * @example 音频流 实时文本翻译
  41. * ```
  42. * let audioTextTransformer = new AudioTextTransformer();
  43. * await audioTextTransformer.openMicroPhone();
  44. * audioTextTramsformter.addEventListener("transform",(arg)=>{
  45. * console.log(`当前累计接受到的文本为:` ,arg.transformText)
  46. * })
  47. * await audioTextTramsformer.openSocket();
  48. *
  49. * // 关闭socket
  50. * audioTextTransformer.close()
  51. * ```
  52. *
  53. * @example 适用整段音频翻译,适用手机长按交互方式
  54. * ```
  55. * let audioTextTransformer = new AudioTextTransformer();
  56. * await audioTextTransformer.openMicroPhone();
  57. * await audioTextTransformer.openSocket();
  58. * let transformText = await audioTextTransformer.transform();
  59. * ```
  60. */
  61. export default class ASR extends BaseEvent {
  62. private socket: WebSocket | null = null;
  63. private _isTransformed: boolean = false;
  64. appKey: string;
  65. CONSTANTS: { [key: string]: any };
  66. audioData: number[] = [];
  67. audioContext: AudioContext | null = null;
  68. source: MediaStreamAudioSourceNode | null = null;
  69. processor: ScriptProcessorNode | null = null;
  70. stream: MediaStream | null = null;
  71. transformText: string = "";
  72. transformTextFix: number = 0;
  73. handleSendInterval: number | null = null;
  74. token: string = "";
  75. socketPromise: Promise<void> | null = null;
  76. asrServerOptions: AsrServerOptions;
  77. isPrivilegeGranted: boolean = false;
  78. processing: boolean = false;
  79. constructor(
  80. appKey: string,
  81. CONSTANTS = {
  82. audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false },
  83. video: false,
  84. },
  85. asrServerOptions: AsrServerOptions = DEFAULT_ASR_SERVER_OPTIONS
  86. ) {
  87. super();
  88. this.appKey = appKey;
  89. this.CONSTANTS = CONSTANTS;
  90. this.asrServerOptions = { ...DEFAULT_ASR_SERVER_OPTIONS, ...asrServerOptions };
  91. }
  92. private _init() {
  93. this.socket = null;
  94. this.audioData = [];
  95. this.transformText = "";
  96. this.transformTextFix = 0;
  97. this._isTransformed = false;
  98. }
  99. async fetchToken() {
  100. if (isXunFei) {
  101. const asr_socket = import.meta.env.VITE_XF_ASR;
  102. const url = `${asr_socket}/api/human_asr/v2/asr/gen_auth`;
  103. const response = await fetch(url, {
  104. method: "post",
  105. body: JSON.stringify({ app_key: this.appKey }),
  106. });
  107. const result = await response.json();
  108. if (result.err_code !== 0) {
  109. throw new Error(result.err_msg);
  110. }
  111. this.token = result.ret;
  112. } else {
  113. // this.token = globalThis.token;
  114. this.token =
  115. "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyaWQiOjkxLCJjb21wYW55X2lkIjo1OSwiZXhwIjoyMDUwOTkxNjU2LCJpYXQiOjE3MzU2MzE2NTYsImlzcyI6Imh1bWFuLWxhcmdlLXNjcmVlbiIsInNjb3BlIjoiIn0.PTR5i63VlosjiepcyV2_KAIQnT-ySFJjggOhqvG5l1Y";
  116. }
  117. }
  118. async openMicroPhone() {
  119. try {
  120. this.stream = await navigator.mediaDevices.getUserMedia(this.CONSTANTS);
  121. if (navigator.permissions) {
  122. navigator.permissions.query({ name: "microphone" }).then((permissionStatus) => {
  123. console.log("Microphone permission state:", permissionStatus.state);
  124. if (permissionStatus.state === "denied") {
  125. throw new Error("麦克风权限被拒绝,请在系统设置或浏览器设置中启用!");
  126. }
  127. });
  128. }
  129. } catch (e) {
  130. console.warn(e);
  131. throw new Error("获取麦克风流失败,请检查麦克风是否正常工作");
  132. return false;
  133. }
  134. if (!this.isPrivilegeGranted) {
  135. this.isPrivilegeGranted = true;
  136. return true;
  137. }
  138. return true;
  139. }
  140. startRecord() {
  141. this._init();
  142. this.audioContext = new window.AudioContext();
  143. if (!this.stream) {
  144. this._eventbus.emit("error", { message: "未获取到麦克风流" });
  145. return;
  146. }
  147. this.source = this.audioContext.createMediaStreamSource(this.stream);
  148. this.processor = this.audioContext.createScriptProcessor(0, 1, 1);
  149. this.processor.connect(this.audioContext.destination);
  150. this.source.connect(this.processor);
  151. this.processor.onaudioprocess = (audioProcessEvent) => {
  152. if (!this.stream || !this.stream.active) {
  153. return;
  154. }
  155. const inputData = audioProcessEvent.inputBuffer.getChannelData(0);
  156. const temp = to16kHZ(inputData);
  157. const data = to16BitPCM(temp);
  158. this.audioData.push(...data);
  159. };
  160. this.processing = true;
  161. this._isTransformed = false;
  162. }
  163. async openSocket(): Promise<void> {
  164. const mid = ({ text: str, command = "" }) => {
  165. const is_quit = command;
  166. if (isXunFei) {
  167. this.transformText = this.transformText.slice(0, this.transformTextFix) + str;
  168. } else {
  169. this.transformText = str;
  170. }
  171. console.log("mid ===========", this.transformText);
  172. this._eventbus.emit("transform", {
  173. type: "mid",
  174. current: str,
  175. transformText: this.transformText,
  176. command: is_quit ? "quit" : "",
  177. });
  178. };
  179. const fin = ({ text: str, command = "" }) => {
  180. this.transformTextFix = this.transformText.length;
  181. const is_quit = command;
  182. if (isXunFei) {
  183. this.transformText = this.transformText + str;
  184. } else {
  185. this.transformText = str;
  186. }
  187. console.log("fin ===========", this.transformText);
  188. this._eventbus.emit("transform", {
  189. type: "fin",
  190. current: str,
  191. transformText: this.transformText,
  192. command: is_quit ? "quit" : "",
  193. });
  194. };
  195. const breath = ({ text: str, command = "" }) => {
  196. const is_quit = command;
  197. if (isXunFei) {
  198. this.transformText = this.transformText.slice(0, this.transformTextFix) + str;
  199. } else {
  200. this.transformText = str;
  201. }
  202. this._eventbus.emit("transform", {
  203. type: "breath",
  204. current: str,
  205. transformText: this.transformText,
  206. command: is_quit ? "quit" : "",
  207. });
  208. };
  209. if (this.handleSendInterval) {
  210. clearInterval(this.handleSendInterval);
  211. }
  212. await this.fetchToken();
  213. this.socketPromise = new Promise((resolve, reject) => {
  214. let ws;
  215. const protocol = location.href.includes("https") ? "wss://" : "ws://";
  216. const domain = location.host;
  217. const asrSocket = isXunFei ? import.meta.env.VITE_XF_ASR : import.meta.env.VITE_ASR;
  218. let socketHost =
  219. asrSocket.startsWith("https") || asrSocket.startsWith("ws")
  220. ? new URL(asrSocket).host
  221. : domain + (asrSocket.startsWith("/") ? asrSocket : "");
  222. let url = "";
  223. if (isXunFei) {
  224. url = `${protocol}${socketHost}/api/human_asr/v2/asr?Authorization=${this.token}`;
  225. } else {
  226. const project = "qingpu";
  227. const engine = "aliyun_dashscope";
  228. url = `${protocol}${socketHost}/common/asr_hub?project=${project}&engine=${engine}`;
  229. url += `&is_long_connection=true`
  230. url += `&heartbeat=true`
  231. if (isMobile()) {
  232. const max_sentence_silence = 5000;
  233. url += `&max_sentence_silence=${max_sentence_silence}`;
  234. }
  235. }
  236. ws = new WebSocket(url);
  237. this.socket = ws;
  238. ws.onopen = () => {
  239. const params = {
  240. business: {
  241. language: this.asrServerOptions.language,
  242. domain: this.asrServerOptions.domain,
  243. accent: this.asrServerOptions.accent,
  244. vad_eos: this.asrServerOptions.vad_eos,
  245. dwa: this.asrServerOptions.dwa,
  246. },
  247. data: {
  248. status: 0,
  249. format: "audio/L16;rate=16000",
  250. encoding: "raw",
  251. audio: "",
  252. },
  253. };
  254. if (isXunFei) {
  255. ws.send(JSON.stringify(params));
  256. } else {
  257. const firstCommand = { card_id: this.asrServerOptions.card_id };
  258. ws.send(JSON.stringify(firstCommand));
  259. }
  260. if (this.processing) {
  261. this._handleSendAudio();
  262. } else {
  263. this.close();
  264. }
  265. resolve();
  266. };
  267. ws.onmessage = async (e: MessageEvent<string>) => {
  268. if (!e.data) {
  269. return;
  270. }
  271. const response = JSON.parse(e.data);
  272. if (isXunFei) {
  273. if (response.code == "10165") {
  274. await this.openSocket();
  275. return;
  276. } else if (response.code != "0") {
  277. console.warn(response);
  278. return;
  279. }
  280. const data = response.data.result;
  281. let str = "";
  282. const ws = data.ws;
  283. for (let i = 0; i < ws.length; i++) {
  284. str += ws[i].cw[0].w;
  285. }
  286. if (data.pgs) {
  287. if (data.pgs === "apd") {
  288. fin({ text: str });
  289. } else {
  290. mid({ text: str });
  291. }
  292. } else {
  293. fin({ text: str });
  294. }
  295. if (data.ls && this.audioData.length && !this._isTransformed) {
  296. await this.openSocket();
  297. }
  298. } else {
  299. const data = response;
  300. if (data.command === "start") {
  301. mid({ text: data.text, command: data.is_quit });
  302. } else if (data.command === "stop") {
  303. fin({ text: data.text, command: data.is_quit });
  304. }
  305. const commas = [
  306. "。",
  307. ",",
  308. "!",
  309. ",",
  310. "?",
  311. ";",
  312. ":",
  313. "、",
  314. "”",
  315. "’",
  316. ")",
  317. "》",
  318. "】",
  319. ];
  320. if (data.text && commas.includes(data.text[data.text.length - 1]) && data.is_sentence) {
  321. breath({ text: data.text, command: data.is_quit });
  322. }
  323. }
  324. };
  325. ws.onerror = (e) => {
  326. this._eventbus.emit("error", e);
  327. reject();
  328. };
  329. });
  330. return this.socketPromise;
  331. }
  332. private _handleSendAudio() {
  333. this._isTransformed = false;
  334. this.handleSendInterval = setInterval(() => {
  335. if (!isXunFei && this.audioData.length < CHUNK_LENGTH) {
  336. return;
  337. }
  338. const data = this.audioData.splice(0, CHUNK_LENGTH);
  339. if (
  340. !this.socket ||
  341. this.socket.readyState !== WebSocket.OPEN ||
  342. data.length === 0 ||
  343. this._isTransformed
  344. ) {
  345. return;
  346. }
  347. const buffer = new Int8Array(data);
  348. this.socket.send(buffer);
  349. }, CHECK_INTERVAL);
  350. }
  351. private _takeOverSendAudioData(): Promise<void> {
  352. return new Promise((resolve, reject) => {
  353. if (this.audioData.length === 0) {
  354. return resolve();
  355. }
  356. const handleSendInterval = setInterval(() => {
  357. if (this.audioData.length === 0) {
  358. clearInterval(handleSendInterval);
  359. return resolve();
  360. }
  361. if (!this.socket) {
  362. clearInterval(handleSendInterval);
  363. reject();
  364. return;
  365. }
  366. if (
  367. this.socket.readyState == WebSocket.CONNECTING ||
  368. this.socket.readyState == WebSocket.CLOSED ||
  369. this.socket.readyState == WebSocket.CLOSING
  370. ) {
  371. clearInterval(handleSendInterval);
  372. reject();
  373. return;
  374. }
  375. const data = this.audioData.splice(0, CHUNK_LENGTH);
  376. if (data.length === 0) {
  377. return;
  378. }
  379. const buffer = new Int8Array(data);
  380. this.socket.send(buffer);
  381. }, CHECK_INTERVAL) as unknown as number;
  382. });
  383. }
  384. /**
  385. * 停止录音,发送 end,等待讯飞关闭连接,完成所有文字转换
  386. */
  387. async transform(): Promise<{ text: string; command: string }> {
  388. this._isTransformed = true;
  389. if (this.stream) {
  390. // 断开麦克风的输入
  391. this.stream.getTracks().forEach((track) => track.stop());
  392. }
  393. this.source?.disconnect();
  394. this.processor?.disconnect();
  395. // 等待所有音频数据发送完毕
  396. clearInterval(this.handleSendInterval);
  397. this.handleSendInterval = null;
  398. let tryCount = 0;
  399. const MaxTryCount = 5;
  400. while (this.audioData.length) {
  401. if (
  402. this.socket &&
  403. this.socket.readyState !== WebSocket.OPEN &&
  404. this.socket.readyState !== WebSocket.CONNECTING
  405. ) {
  406. if (!isXunFei) {
  407. break;
  408. }
  409. try {
  410. if (this.audioData.length) {
  411. await this.openSocket();
  412. } else {
  413. // 没有音频数据,直接跳出循环
  414. break;
  415. }
  416. } catch (e) {
  417. tryCount += 1;
  418. // Socket 连接失败尝试, 超过5次无视剩余数据,直接跳出循环,并放回当前获得的文本
  419. if (MaxTryCount < tryCount) {
  420. break;
  421. }
  422. continue;
  423. }
  424. }
  425. try {
  426. await this.socketPromise;
  427. await this._takeOverSendAudioData();
  428. tryCount = 0;
  429. } catch (e) {
  430. console.warn(e);
  431. }
  432. }
  433. const socket = this.socket;
  434. const promise: Promise<{ text: string; command: string }> = new Promise((resolve) => {
  435. if (!socket || socket.readyState !== WebSocket.OPEN) {
  436. resolve({ text: this.transformText, command: "end" });
  437. this.close();
  438. return;
  439. }
  440. const onLastMessage = (e: MessageEvent<string>) => {
  441. if (!e.data) {
  442. return;
  443. }
  444. const response = JSON.parse(e.data);
  445. if (isXunFei) {
  446. if (response.code !== 0) {
  447. return;
  448. }
  449. const data = response.data.result;
  450. if (data.ls && this.audioData.length === 0 && this._isTransformed) {
  451. resolve({ text: this.transformText, command: "end" });
  452. this.close();
  453. }
  454. } else {
  455. const { text, command, is_sentence } = response;
  456. if (is_sentence && this.audioData.length == 0 && this._isTransformed) {
  457. resolve({ text, command });
  458. }
  459. this.close();
  460. }
  461. };
  462. const onClose = () => {
  463. socket?.removeEventListener("close", onClose);
  464. resolve({ text: this.transformText, command: "end" });
  465. this.close();
  466. };
  467. socket.addEventListener("message", onLastMessage);
  468. socket.addEventListener("close", onClose);
  469. });
  470. socket.send(JSON.stringify(closeCommand));
  471. return promise;
  472. }
  473. /**
  474. * 停止录音 并关闭Socket连接。
  475. */
  476. close() {
  477. this.audioData = [];
  478. if (this.handleSendInterval) {
  479. clearInterval(this.handleSendInterval);
  480. this.handleSendInterval = null;
  481. }
  482. this.processing = false;
  483. this._isTransformed = false;
  484. if (this.socket && this.socket.readyState === WebSocket.OPEN) {
  485. this.socket.send(JSON.stringify(closeCommand));
  486. this.clear();
  487. }
  488. this.stream?.getTracks().forEach((track) => track.stop());
  489. }
  490. /**
  491. * 清除Socket连接,事件
  492. */
  493. clear() {
  494. this.source?.disconnect();
  495. this.processor?.disconnect();
  496. if (
  497. isXunFei &&
  498. this.socket &&
  499. (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)
  500. ) {
  501. this.socket.close();
  502. }
  503. if (this.handleSendInterval) {
  504. clearInterval(this.handleSendInterval);
  505. }
  506. this._init();
  507. this.allClear();
  508. }
  509. }
  510. function to16kHZ(buffer: Float32Array): Float32Array {
  511. const data = new Float32Array(buffer);
  512. const fitCount = Math.round(data.length * (16000 / 44100));
  513. const newData = new Float32Array(fitCount);
  514. const springFactor = (data.length - 1) / (fitCount - 1);
  515. newData[0] = data[0];
  516. for (let i = 1; i < fitCount - 1; i++) {
  517. const tmp = i * springFactor;
  518. const before = Math.floor(tmp);
  519. const after = Math.ceil(tmp);
  520. const atPoint = tmp - before;
  521. newData[i] = data[before] + (data[after] - data[before]) * atPoint;
  522. }
  523. newData[fitCount - 1] = data[data.length - 1];
  524. return newData;
  525. }
  526. function to16BitPCM(input) {
  527. const dataLength = input.length * (16 / 8);
  528. const dataBuffer = new ArrayBuffer(dataLength);
  529. const dataView = new DataView(dataBuffer);
  530. let offset = 0;
  531. for (let i = 0; i < input.length; i++, offset += 2) {
  532. const s = Math.max(-1, Math.min(1, input[i]));
  533. dataView.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
  534. }
  535. return Array.from(new Int8Array(dataView.buffer));
  536. }