|
@@ -1,210 +1,41 @@
|
|
|
<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>
|
|
|
+ <div>
|
|
|
+ <template v-if="state.isSpeak">
|
|
|
+ <el-button @click="stop">停止录音</el-button>
|
|
|
</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>
|
|
|
+ <el-button @click="speak">开始录音</el-button>
|
|
|
</template>
|
|
|
+
|
|
|
+ {{ volume }}
|
|
|
+ <div class="relative h-6 w-full bg-[#fff]">
|
|
|
+ <div class="absolute h-6 bg-[red]" :style="{ width: volume + '%' }"></div>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
-import {
|
|
|
- computed,
|
|
|
- getCurrentInstance,
|
|
|
- onMounted,
|
|
|
- reactive,
|
|
|
- ref,
|
|
|
- watch,
|
|
|
-} from 'vue'
|
|
|
+import { reactive } from 'vue'
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
-// import {audioToText} from "@/views/smart-ask-answer/assistant-2/dify/share";
|
|
|
+import useSpeechToAudio from '@/utils/useSpeechToAudio'
|
|
|
|
|
|
-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,
|
|
|
+ isSpeak: false,
|
|
|
})
|
|
|
-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 onEnd = (audio) => {
|
|
|
+ state.isSpeak = false
|
|
|
+ console.log(audio)
|
|
|
}
|
|
|
-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)),
|
|
|
- )
|
|
|
+const onSpeak = ({ duration }) => {
|
|
|
+ state.isSpeak = true
|
|
|
+ // console.log('duration', duration)
|
|
|
}
|
|
|
-onMounted(() => {})
|
|
|
+const { speak, stop, volume } = useSpeechToAudio({
|
|
|
+ onEnd,
|
|
|
+ onSpeak,
|
|
|
+ timeout: 3,
|
|
|
+})
|
|
|
</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>
|
|
|
+<style lang="scss" scoped></style>
|