CzRger 3 місяців тому
батько
коміт
c2367244d2

+ 121 - 0
src/utils/useSpeechToAudio.ts

@@ -0,0 +1,121 @@
+import { onUnmounted, reactive, ref } from 'vue'
+import { ElMessage } from 'element-plus'
+
+export default function useSpeechToAudio({
+  onEnd = () => {},
+  onSpeak = () => {},
+  timeout = 0,
+}) {
+  const state: any = reactive({
+    duration: 0,
+    mediaRecorder: null,
+    analyser: null,
+    timer: null,
+    dataArray: null,
+    animationId: null,
+    timeoutTimer: null,
+  })
+  const volume = ref(0)
+  const speak = async () => {
+    try {
+      // 请求麦克风权限
+      const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
+
+      const audioContext = new (window.AudioContext ||
+        window.webkitAudioContext)()
+      state.analyser = audioContext.createAnalyser()
+      state.analyser.fftSize = 256
+      const microphone = audioContext.createMediaStreamSource(stream)
+      microphone.connect(state.analyser)
+      state.dataArray = new Uint8Array(state.analyser.frequencyBinCount)
+
+      state.mediaRecorder = new MediaRecorder(stream)
+      const audioChunks: any = []
+      state.mediaRecorder.ondataavailable = (event) => {
+        audioChunks.push(event.data)
+      }
+      state.mediaRecorder.onstop = async () => {
+        cancelAnimationFrame(state.animationId)
+        clearInterval(state.timer)
+        state.timer = null
+        clearInterval(state.timeoutTimer)
+        state.timeoutTimer = null
+        const audioBlob = new Blob(audioChunks, { type: 'audio/mp3' })
+        // this.audioUrl = URL.createObjectURL(this.audioBlob)
+        stream.getTracks().forEach((track) => track.stop())
+        if (microphone) {
+          microphone.disconnect()
+        }
+        if (audioContext && audioContext.state !== 'closed') {
+          audioContext.close()
+        }
+        if (!audioBlob) {
+          ElMessage.error('没有可上传的录音文件')
+          return
+        }
+        try {
+          onEnd(audioBlob)
+        } catch (err) {
+          onEnd(null)
+          ElMessage.error('上传错误:' + err)
+        }
+      }
+      state.mediaRecorder.start()
+      updateVolume()
+      const startTime = Date.now()
+      state.duration = 0
+      volume.value = 0
+      onSpeak({ duration: state.duration })
+      // 更新录音时长
+      state.timer = setInterval(() => {
+        state.duration = Math.floor((Date.now() - startTime) / 1000)
+        onSpeak({ duration: state.duration })
+      }, 1000)
+      state.timeoutTimer = setInterval(() => {
+        console.log(1)
+        stop()
+      }, 1000 * timeout)
+    } catch (err: any) {
+      ElMessage.error('无法访问麦克风: ' + err.message)
+      console.error('录音错误:', err)
+    }
+  }
+  const stop = () => {
+    state.mediaRecorder?.stop()
+  }
+  // 更新音量显示
+  const updateVolume = () => {
+    if (!state.analyser) return
+
+    state.analyser.getByteFrequencyData(state.dataArray)
+
+    // 计算平均音量
+    let sum = 0
+    for (let i = 0; i < state.dataArray.length; i++) {
+      sum += state.dataArray[i]
+    }
+    const average = sum / state.dataArray.length
+
+    // 将音量映射到0-100范围
+    volume.value = Math.min(100, Math.max(0, Math.round(average / 2.55)))
+    console.error(volume.value)
+    if (volume.value > 10) {
+      clearInterval(state.timeoutTimer)
+      state.timeoutTimer = null
+    } else {
+      if (!state.timeoutTimer) {
+        state.timeoutTimer = setInterval(() => {
+          console.log(2)
+          stop()
+        }, 1000 * timeout)
+      }
+    }
+    // 继续动画
+    state.animationId = requestAnimationFrame(updateVolume)
+  }
+  return {
+    speak,
+    stop,
+    volume,
+  }
+}

+ 26 - 10
src/views/manage/app/index.vue

@@ -1,22 +1,38 @@
 <template>
   <div>
-    <div>{{ state.text }}</div>
-    <el-button @click="speak(state.text)">播放</el-button>
-    <el-button @click="stop">暂停</el-button>
+    <template v-if="state.isSpeak">
+      <el-button @click="stop">停止录音</el-button>
+    </template>
+    <template v-else>
+      <el-button @click="speak">开始录音</el-button>
+    </template>
+    <div class="w-full h-6 bg-[#fff] relative">
+      <div class="h-6 bg-[red] absolute" :style="{ width: volume + '%' }"></div>
+    </div>
   </div>
 </template>
 
 <script setup lang="ts">
-import { getCurrentInstance, reactive, ref } from 'vue'
+import { reactive } from 'vue'
+import { ElMessage } from 'element-plus'
+import useSpeechToAudio from '@/utils/useSpeechToAudio'
 
-import useTextToSpeech from '@/utils/useTextToSpeech'
-
-const { speak, stop } = useTextToSpeech()
-const emit = defineEmits([])
 const props = defineProps({})
-const { proxy }: any = getCurrentInstance()
 const state: any = reactive({
-  text: '这个实现提供了完整的朗读控制功能,包括开始、暂停、继续和停止,以及语速、音调和语音选择的自定义选项,并添加了朗读进度显示和当前朗读句子高亮功能。',
+  isSpeak: false,
+})
+const onEnd = (audio) => {
+  state.isSpeak = false
+  console.log(audio)
+}
+const onSpeak = ({ duration }) => {
+  state.isSpeak = true
+  // console.log('duration', duration)
+}
+const { speak, stop, volume } = useSpeechToAudio({
+  onEnd,
+  onSpeak,
+  timeout: 3,
 })
 </script>
 

+ 210 - 0
src/views/manage/app/index_audio.vue

@@ -0,0 +1,210 @@
+<template>
+  <div class="audio">
+    <template v-if="state.isStart">
+      <el-tooltip content="停止语音输入" placement="top">
+        <div class="audio-main-voice __hover" @click="onStop">
+          <div v-for="item in 4" ref="ref_bars" />
+        </div>
+      </el-tooltip>
+    </template>
+    <template v-else>
+      <el-tooltip content="语音输入" placement="top">
+        <div class="audio-main __hover" @click="onStart">
+          <img src="@/assets/images/logo.png" />
+        </div>
+      </el-tooltip>
+    </template>
+    <template v-if="state.isStart">
+      <div class="duration">
+        {{ durationCpt }}
+      </div>
+    </template>
+  </div>
+</template>
+
+<script setup lang="ts">
+import {
+  computed,
+  getCurrentInstance,
+  onMounted,
+  reactive,
+  ref,
+  watch,
+} from 'vue'
+import { ElMessage } from 'element-plus'
+// import {audioToText} from "@/views/smart-ask-answer/assistant-2/dify/share";
+
+const emit = defineEmits(['onLoading', 'onAudio'])
+const props = defineProps({})
+const state: any = reactive({
+  isStart: false,
+  duration: 0,
+  mediaRecorder: null,
+  audioBlob: null,
+  timer: null,
+  analyser: null,
+  animationId: null,
+  phase: 0,
+})
+const ref_bars = ref()
+const durationCpt = computed(() => {
+  const minutes = Math.floor(state.duration / 60)
+  const seconds = Math.floor(state.duration % 60)
+  return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
+})
+const onStart = async () => {
+  state.isStart = true
+  try {
+    // 请求麦克风权限
+    const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
+
+    const audioContext = new (window.AudioContext ||
+      window.webkitAudioContext)()
+    state.analyser = audioContext.createAnalyser()
+    state.analyser.fftSize = 256
+    const microphone = audioContext.createMediaStreamSource(stream)
+    microphone.connect(state.analyser)
+    updateVolumeBars()
+
+    state.mediaRecorder = new MediaRecorder(stream)
+    const audioChunks: any = []
+    state.mediaRecorder.ondataavailable = (event) => {
+      audioChunks.push(event.data)
+    }
+    state.mediaRecorder.onstop = async () => {
+      clearInterval(state.timer)
+      state.audioBlob = new Blob(audioChunks, { type: 'audio/mp3' })
+      // this.audioUrl = URL.createObjectURL(this.audioBlob)
+      stream.getTracks().forEach((track) => track.stop())
+      if (microphone) {
+        microphone.disconnect()
+      }
+      if (audioContext && audioContext.state !== 'closed') {
+        audioContext.close()
+      }
+      cancelAnimationFrame(state.animationId)
+      // 重置柱状图
+      ref_bars.value.forEach((bar) => {
+        bar.style.height = '4px'
+      })
+      if (!state.audioBlob) {
+        ElMessage.error('没有可上传的录音文件')
+        return
+      }
+      try {
+        const formData = new FormData()
+        formData.append('file', state.audioBlob)
+        console.log(state.audioBlob)
+        // const audioResponse = await audioToText(`/installed-apps/${import.meta.env.VITE_DIFY_APPID}/audio-to-text`, false, formData)
+        // emit('onAudio', audioResponse.text)
+      } catch (err) {
+        emit('onAudio', '')
+        ElMessage.error('上传错误:' + err)
+      } finally {
+        emit('onAudio', '')
+      }
+    }
+
+    state.mediaRecorder.start()
+    const startTime = Date.now()
+    state.duration = 0
+    // 更新录音时长
+    state.timer = setInterval(() => {
+      state.duration = Math.floor((Date.now() - startTime) / 1000)
+    }, 1000)
+  } catch (err: any) {
+    ElMessage.error('无法访问麦克风: ' + err.message)
+    console.error('录音错误:', err)
+  }
+}
+const onStop = async () => {
+  emit('onLoading')
+  state.isStart = false
+  if (state.mediaRecorder) {
+    state.mediaRecorder.stop()
+  }
+}
+const updateVolumeBars = () => {
+  if (!state.isStart) return
+
+  const array = new Uint8Array(state.analyser.frequencyBinCount)
+  state.analyser.getByteFrequencyData(array)
+  let sum = 0
+  for (let i = 0; i < array.length; i++) {
+    sum += array[i]
+  }
+
+  const average = sum / array.length
+  const baseVolume = Math.min(1, average / 70)
+
+  // 更新相位
+  state.phase += 0.2
+  if (state.phase > Math.PI * 2) state.phase -= Math.PI * 2
+
+  // 更新每个柱子的高度
+  ref_bars.value.forEach((bar, index) => {
+    // 每个柱子有轻微相位差
+    const barPhase = state.phase + (index * Math.PI) / 3
+    // 波浪因子 (0.5-1.5范围)
+    const waveFactor = 0.8 + Math.sin(barPhase) * 0.2
+
+    // 基础高度 + 音量影响 * 波浪因子
+    const height =
+      4 +
+      baseVolume *
+        (index > 0 && index < ref_bars.value.length - 1 ? 15 : 5) *
+        waveFactor
+    bar.style.height = `${height}px`
+  })
+
+  state.animationId = requestAnimationFrame(updateVolumeBars)
+}
+const calculateBarLevels = (volume) => {
+  // 根据音量计算4个柱子的高度比例
+  // 无声音时全部为0,有声音时从低到高依次点亮
+  const thresholds = [0.25, 0.5, 0.75, 1.0]
+  return thresholds.map((t) =>
+    Math.max(0, Math.min(1, (volume - (t - 0.25)) * 4)),
+  )
+}
+onMounted(() => {})
+</script>
+
+<style lang="scss" scoped>
+.audio {
+  display: flex;
+  align-items: center;
+  .audio-main,
+  .audio-main-voice {
+    width: 32px;
+    height: 32px;
+    border-radius: 8px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+  .audio-main {
+    &:hover {
+      background-color: rgba(0, 0, 0, 0.04);
+    }
+  }
+  .audio-main-voice {
+    background-color: rgba(0, 87, 255, 0.1);
+    gap: 2px;
+    > div {
+      width: 2px;
+      height: 4px;
+      background-color: #06f;
+      transition: height 0.15s ease-out;
+      &:nth-child(2) {
+        margin-right: 1px;
+      }
+    }
+  }
+  .duration {
+    color: #4f4f4f;
+    font-size: 14px;
+    margin-left: 6px;
+  }
+}
+</style>