Kaynağa Gözat

添加语音识别

cc12458 10 ay önce
ebeveyn
işleme
799ff4daa9

+ 28 - 0
src/api/diagnosis.js

@@ -1,6 +1,7 @@
 import request from '@/utils/request.js'
 import {fromAnalysisModel} from "@/model/tongue-analysis.model";
 import {removeRedundantSemicolons} from '@/api/hack';
+import {generateUUID} from '@/utils';
 
 // 获取就诊病人信息
 export function getPatiiensMsg(data) {
@@ -40,6 +41,33 @@ export function getNLP_TemplateResult(text) {
     });
 }
 
+export function getASR_Token() {
+    try {
+        let token = localStorage.getItem('ASR_TOKEN');
+        if (token) {
+            const proof = JSON.parse(token);
+            if (Math.round(Date.now() / 1000) < proof.expireTime) return proof;
+        }
+    } catch (e) {}
+
+    return request({
+        url: `https://dev.hzliuzhi.com:62006/tcm_chat/asr/token`,
+        method: 'get',
+    }).then(res => {
+        if (res.code === 200) {
+            if (!res.data.appKey) res.data.appKey = `zF5fGsgjoAXnL7F3`;
+            localStorage.setItem('ASR_TOKEN', JSON.stringify(res.data));
+            return res.data;
+        }
+        localStorage.removeItem('ASR_TOKEN');
+        throw new Error(res.message);
+    });
+}
+
+export function connectASR_WebSocket(proof) {
+    return new WebSocket(`wss://nls-gateway.cn-shanghai.aliyuncs.com/ws/v1?token=${proof.token}`);
+}
+
 
 // 提交诊断页面数据
 export function addDiagnosisData(data) {

+ 275 - 0
src/components/SoundRecorder.vue

@@ -0,0 +1,275 @@
+<script>
+import {connectASR_WebSocket, getASR_Token} from '@/api/diagnosis';
+import {generateUUID} from '@/utils';
+
+let websocket;
+let dispose = () => {};
+
+let onAudioConnect = () => {};
+let onAudioCleanup = () => {};
+let onSocketCleanup = () => {};
+
+export default {
+  name: 'SoundRecorder',
+  props: {
+    enabled: {
+      type: Boolean,
+      default: false,
+    },
+  },
+  data() {
+    return {
+      taskId: '',
+
+      recording: false,
+      loading: false,
+      proof: {token: '', appKey: ''},
+    };
+  },
+  beforeMount() {
+    dispose = (event) => {
+      switch (event.type) {
+        case 'open':
+          return this.startTranscription(this.taskId);
+        case 'close': {
+          websocket = null;
+          this.recording = false;
+          this.loading = false;
+          break;
+        }
+        case 'message':
+          return this.analysis(JSON.parse(event.data));
+        case 'audioprocess':
+          return this.send(event.inputBuffer.getChannelData(0));
+      }
+      console.log(event, 'log:');
+    };
+  },
+  beforeDestroy() {
+    this.stop();
+    this.disconnect();
+  },
+  watch: {
+    enabled(value) {
+      if (!value) this.stop();
+    },
+  },
+  methods: {
+    handle() {
+      if (this.loading) return;
+      try { if (this.recording) this.stop(); else this.start(); } catch (e) {
+        this.$message.warning(`出错了,请重试`);
+        this.disconnect();
+        console.log('[sr-ars]', e);
+      }
+    },
+    async connect() {
+      if (!this.proof || !this.proof.token) this.proof = await getASR_Token();
+      websocket = await connectASR_WebSocket(this.proof);
+      websocket.addEventListener('open', dispose);
+      websocket.addEventListener('close', dispose);
+      websocket.addEventListener('message', dispose);
+      websocket.addEventListener('error', dispose);
+      onSocketCleanup = () => {
+        websocket.removeEventListener('open', dispose);
+        websocket.removeEventListener('close', dispose);
+        websocket.removeEventListener('message', dispose);
+        websocket.removeEventListener('error', dispose);
+        onSocketCleanup = () => {};
+      };
+    },
+    async disconnect() {
+      this.loading = false;
+      this.recording = false;
+      onSocketCleanup();
+    },
+    async start() {
+      if (onAudioCleanup) onAudioCleanup();
+
+      this.loading = true;
+      this.taskId = generateUUID();
+
+      const stream = await navigator.mediaDevices.getUserMedia({audio: true});
+      const context = new window.AudioContext({sampleRate: 16000});
+      const input = context.createMediaStreamSource(stream);
+      const processor = context.createScriptProcessor(2048, 1, 1);
+      processor.addEventListener('audioprocess', dispose);
+
+      onAudioCleanup = () => {
+        this.loading = false;
+        processor.removeEventListener('audioprocess', dispose);
+        processor.disconnect();
+        input.disconnect();
+        stream.getTracks().forEach(track => track.stop());
+        context.close();
+        onAudioCleanup = () => {};
+      };
+
+      onAudioConnect = () => {
+        this.loading = false;
+        input.connect(processor);
+        processor.connect(context.destination);
+        onAudioConnect = () => {};
+      };
+
+      if (!websocket || websocket.readyState !== WebSocket.OPEN) await this.connect();
+      else if (this.recording) onAudioConnect();
+    },
+    async stop() {
+      if (!websocket || websocket.readyState !== WebSocket.OPEN) return;
+      websocket.send(JSON.stringify({
+        header: {
+          appkey: this.proof.appKey,
+          namespace: 'SpeechTranscriber',
+          name: 'StopTranscription',
+          task_id: this.taskId,
+          message_id: generateUUID(),
+        },
+      }));
+    },
+
+    startTranscription(taskId = generateUUID()) {
+      if (!websocket || websocket.readyState !== WebSocket.OPEN) return;
+      websocket.send(JSON.stringify({
+        header: {
+          appkey: this.proof.appKey,
+          namespace: 'SpeechTranscriber',
+          name: 'StartTranscription',
+          task_id: taskId,
+          message_id: generateUUID(),
+        },
+        payload: {
+          'format': 'pcm',
+          'sample_rate': 16000,
+          'enable_intermediate_result': true,
+          'enable_punctuation_prediction': true,
+          'enable_inverse_text_normalization': true,
+        },
+      }));
+    },
+    send(data) {
+      if (!websocket || websocket.readyState !== WebSocket.OPEN) return;
+      const inputData16 = new Int16Array(data.length);
+      for (let i = 0; i < data.length; ++i) {
+        inputData16[i] = Math.max(-1, Math.min(1, data[i])) * 0x7FFF; // PCM 16-bit
+      }
+      websocket.send(inputData16.buffer);
+      console.log('[sr-ars] 发送音频数据块');
+    },
+    analysis(message) {
+      switch (message.header.name) {
+        case 'TranscriptionStarted': {
+          this.recording = true;
+          onAudioConnect();
+          break;
+        }
+        case 'TranscriptionCompleted': {
+          this.recording = false;
+          onAudioCleanup();
+          break;
+        }
+        case 'SentenceBegin': {
+          const {index, time} = message.payload;
+          console.log(`[sr-ars]:message: 一句话开始:[${index}(${time} - )]`);
+          this.$emit('begin');
+          break;
+        }
+        case 'SentenceEnd': {
+          const {index, time, result, begin_time} = message.payload;
+          console.log(`[sr-ars]:message: 一句话结束:${result} [${index}(${begin_time} - ${time})]`);
+          this.$emit('finish', result);
+          break;
+        }
+        case 'TranscriptionResultChanged': {
+          const {index, time, result} = message.payload;
+          console.log(`[sr-ars]:message: 识别结果变化:${result} [${index}(${time})]`);
+          this.$emit('change', result);
+          break;
+        }
+        default: {
+          console.log('[sr-ars]:message', message);
+        }
+      }
+    },
+  },
+};
+</script>
+
+<template>
+  <div style="z-index: 11;">
+    <slot>
+      <div class="sr-wrapper" :class="{recording, loading}" @click="handle()">
+        <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+
+          <circle v-if="loading" cx="12" cy="12" r="5" fill="none" stroke="white" stroke-width="1"></circle>
+          <rect v-if="!loading" x="9" y="6" :width="recording? 10 : 6" height="10" :rx="recording ? 5 : 3"
+                fill="white"/>
+          <path v-if="!loading"
+                d="M6.75 12.75V13C6.75 15.8995 9.1005 18.25 12 18.25C14.8995 18.25 17.25 15.8995 17.25 13V12.75"
+                stroke="white" stroke-width="1.5" stroke-linecap="round"/>
+        </svg>
+      </div>
+    </slot>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.sr-wrapper {
+  position: relative;
+  width: 24px;
+  height: 24px;
+  background: #FF3359;
+  border-radius: 50%;
+  transform: scale(1.5);
+  cursor: pointer;
+  box-shadow: 0 4px 50px #FF3359;
+
+  &.recording {
+    svg {
+      rect {
+        transform: translate(-2px, 1px);
+        animation: recording 2s infinite 0.1s;
+      }
+
+      path {
+        transform: scale(0.7);
+        opacity: 0;
+      }
+
+    }
+  }
+
+  &.loading {
+    cursor: not-allowed;
+
+    svg {
+      animation: loading-rotate 2s linear infinite;
+    }
+  }
+
+  svg {
+    rect {
+      stroke-width: 0;
+      stroke: white;
+      transition: all 0.2s ease;
+      transform-origin: center;
+    }
+
+    circle {
+      stroke-linecap: round;
+      animation: loading-dash 1.5s ease-in-out infinite;
+    }
+
+    path {
+      transition: all 0.2s ease;
+      transform-origin: center;
+    }
+  }
+
+  @keyframes recording {
+    50% {
+      stroke-width: 2;
+    }
+  }
+}
+</style>

+ 4 - 0
src/utils/index.js

@@ -32,3 +32,7 @@ export function deepClone (source) {
   })
   return targetObj
 }
+
+export function generateUUID() {
+  return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c => (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)).replace(/-/g, '');
+}

+ 16 - 4
src/views/diagnosis/Emr.vue

@@ -46,14 +46,18 @@
       </div>
     </el-dialog>
 
-    <el-dialog class="ai-dialog-wrapper" title="自然语言处理" :visible.sync="showAiDialog" center destroy-on-close :close-on-click-modal="false" :before-close="closeAiDialog">
-      <div style="height: 100%; overflow: auto;">
+    <el-dialog class="ai-dialog-wrapper" title="自然语言处理" :visible.sync="showAiDialog" center :close-on-click-modal="false" :before-close="closeAiDialog">
+      <div style="position: relative; height: 100%; overflow: hidden;">
         <el-input
             type="textarea" :readonly="aiLoading"
             :autosize="{ minRows: 4, maxRows: 8}"
             placeholder="请描述患者的病情"
             v-model="aiText">
         </el-input>
+        <SoundRecorder style="position: absolute; right: 12px; bottom: 12px;"
+                       :enabled="showAiDialog"
+                       @begin="aiTextBackups = aiText;" @change="aiText = aiTextBackups + $event;" @finish="aiText = aiTextBackups + $event;"
+        />
       </div>
       <div slot="footer" class="dialog-footer">
         <el-button :disabled="aiLoading" @click="closeAiDialog()">取 消</el-button>
@@ -71,9 +75,11 @@ import uploadFile from "@/components/UploadFile.vue";
 import PriviewEdit from "../business/components/PriviewEdit.vue";
 
 import {formatPicture, toPicture} from "@/utils/picture";
+import SoundRecorder from '@/components/SoundRecorder.vue';
 
 export default {
   components: {
+    SoundRecorder,
     uploadFile,
     PriviewEdit
   },
@@ -82,6 +88,7 @@ export default {
       showAiDialog: false,
       aiLoading: false,
       aiText: '',
+      aiTextBackups: '',
       isSkip: true,
       isEdit: false, // 是否修改
       activeId: "",
@@ -173,8 +180,10 @@ export default {
         this.aiLoading = true;
         try {
           const result = await getNLP_TemplateResult(this.aiText.trim());
-          if (result) this.showMsgToTem(result);
-          else throw { message: `` }
+          if (result) {
+            this.showMsgToTem(result);
+            this.aiText = ``;
+          } else throw { message: `` }
         } catch (e) {
           if (e.message) this.$message.error(e.message);
           this.aiLoading = false;
@@ -1240,6 +1249,9 @@ export default {
   .el-textarea, ::v-deep textarea {
     max-height: 45vh;
   }
+  ::v-deep textarea {
+    padding-bottom: 24px;
+  }
 }
 
 </style>