From da7f7736c8ddc4f598ade7653a4d0259637e04ba Mon Sep 17 00:00:00 2001 From: wujingjing <gersonwu@qq.com> Date: 星期四, 03 四月 2025 15:44:17 +0800 Subject: [PATCH] 语音输入 --- src/components/chat/components/playBar/PlayBar.vue | 25 ++++++ src/components/chat/components/playBar/hook/useSpeech.ts | 169 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+), 0 deletions(-) diff --git a/src/components/chat/components/playBar/PlayBar.vue b/src/components/chat/components/playBar/PlayBar.vue index 94e7d97..bf7c726 100644 --- a/src/components/chat/components/playBar/PlayBar.vue +++ b/src/components/chat/components/playBar/PlayBar.vue @@ -146,6 +146,18 @@ </div> </el-tooltip> + <el-tooltip v-if="isSupportSpeech" placement="top" content="璇煶杈撳叆"> + <div class="cursor-pointer size-[24px] relative !z-10 rounded flex-center hover:bg-[#f2f2f2]" @click="speechClick"> + <div v-if="recordState.isRecording" class="cursor-pointer flex items-center space-x-[1px]"> + <div class="w-[2px] h-[6px] bg-[#0284ff] animate-[soundWave_1.5s_ease-in-out_infinite]"></div> + <div class="w-[2px] h-[9px] bg-[#0284ff] animate-[soundWave_1.5s_ease-in-out_infinite_0.1s]"></div> + <div class="w-[2px] h-[12px] bg-[#0284ff] animate-[soundWave_1.5s_ease-in-out_infinite_0.2s]"></div> + <div class="w-[2px] h-[9px] bg-[#0284ff] animate-[soundWave_1.5s_ease-in-out_infinite_0.3s]"></div> + <div class="w-[2px] h-[6px] bg-[#0284ff] animate-[soundWave_1.5s_ease-in-out_infinite_0.4s]"></div> + </div> + <span v-else class="ywifont ywicon-maikefeng !text-[20px]"></span> + </div> + </el-tooltip> <el-tooltip placement="top" content="鍋滄鐢熸垚" v-if="isTalking"> <div class="cursor-pointer !ml-0 size-[38px] bg-[#1d86ff] relative !z-10 rounded-full flex-center" link> <div @@ -207,6 +219,7 @@ import { newChatRoomClick, sidebarIsShow, toggleSidebar } from '/@/stores/chatRoom'; import MetricValuesPreview from './metricValues/MetricValuesPreview.vue'; +import { useSpeech } from './hook/useSpeech'; const emits = defineEmits(['sendClick', 'stopGenClick']); const props = defineProps({ @@ -246,6 +259,18 @@ commonPhraseRef.value.updatePhrase(); }; +const speechClick = () => { + if (recordState.isRecording) { + stopRecording(); + } else { + startRecording(); + } +}; + +const { isSupportSpeech, startRecording, stopRecording, recordState } = useSpeech({ + inputText: inputValue, +}); + //#region ====================== 甯哥敤璇姛鑳� ====================== const commonPhraseRef = useCompRef(CommonPhrases); // 甯哥敤璇姛鑳界偣鍑� diff --git a/src/components/chat/components/playBar/hook/useSpeech.ts b/src/components/chat/components/playBar/hook/useSpeech.ts new file mode 100644 index 0000000..bb6e02c --- /dev/null +++ b/src/components/chat/components/playBar/hook/useSpeech.ts @@ -0,0 +1,169 @@ +import axios from 'axios'; +import { ElMessage } from 'element-plus'; +import type { Ref } from 'vue'; +import { reactive, ref } from 'vue'; + +type UseSpeechProps = { + inputText: Ref<string>; +}; +export const useSpeech = (props: UseSpeechProps) => { + const { inputText } = props; + const isSupportSpeech = ref(!!navigator.mediaDevices); + const baiduSpeechConfig = { + format: 'wav', + rate: 16000, + channel: 1, + cuid: 'jVNmJvBApXOwDVb5aLKETdTjMK8bm3nI', + token: '', + dev_pid: 80001, + ak: 'aV5EwAw2hb80x8kVel8NUKfF', + sk: 'TKVhvcIa4rNskak0lfhPLINlxZWaCIUM', + }; + + const recordState = reactive({ + isRecording: false, + audioContext: null, + mediaRecorder: null, + audioChunks: [], + }); + + const getAccessToken = async () => { + try { + const response = await axios.post( + `https://wi.beng35.com/api/baidu-token/oauth/2.0/token?grant_type=client_credentials&client_id=${baiduSpeechConfig.ak}&client_secret=${baiduSpeechConfig.sk}` + ); + baiduSpeechConfig.token = response.data.access_token; + return response.data.access_token; + } catch (error) { + console.error('鑾峰彇 access token 澶辫触:', error); + ElMessage.error('鑾峰彇璇煶璇嗗埆鎺堟潈澶辫触'); + throw error; + } + }; + const startRecording = async () => { + try { + // 纭繚鏈� access token + if (!baiduSpeechConfig.token) { + await getAccessToken(); + } + + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + recordState.audioContext = new AudioContext({ + sampleRate: 16000, + latencyHint: 'interactive', + }); + + const source = recordState.audioContext.createMediaStreamSource(stream); + const processor = recordState.audioContext.createScriptProcessor(4096, 1, 1); + + // 瀛樺偍鍘熷闊抽鏁版嵁 + const audioData = []; + + processor.onaudioprocess = (e) => { + const inputData = e.inputBuffer.getChannelData(0); + // 灏� Float32Array 杞崲涓� Int16Array + const int16Data = new Int16Array(inputData.length); + for (let i = 0; i < inputData.length; i++) { + int16Data[i] = inputData[i] * 32767; + } + audioData.push(int16Data); + }; + + recordState.mediaRecorder = new MediaRecorder(stream); + recordState.audioChunks = []; + + recordState.mediaRecorder.ondataavailable = (event) => { + if (event.data.size > 0) { + recordState.audioChunks.push(event.data); + } + }; + + recordState.mediaRecorder.onstop = async () => { + // 鍚堝苟鎵�鏈夐煶棰戞暟鎹� + const totalLength = audioData.reduce((acc, curr) => acc + curr.length, 0); + const mergedData = new Int16Array(totalLength); + let offset = 0; + audioData.forEach((data) => { + mergedData.set(data, offset); + offset += data.length; + }); + + // 灏� Int16Array 杞崲涓� base64 + const buffer = mergedData.buffer; + + let base64Data = ''; + let len = 0; + // 鍒嗘壒澶勭悊澶у瀷鏁版嵁 + // 涓嶇劧 String.fromCharCode 鍜屽睍寮�杩愮畻绗�... 浼氱垎鏍� + const chunkSize = 0.1 * 1024 * 1024; // 0.5MB chunks + const uint8Array = new Uint8Array(buffer); + const chunks = []; + + for (let i = 0; i < uint8Array.length; i += chunkSize) { + const chunk = uint8Array.slice(i, i + chunkSize); + chunks.push(String.fromCharCode.apply(null, chunk)); + } + + base64Data = btoa(chunks.join('')); + len = mergedData.length * 2; + + try { + // 璋冪敤鐧惧害璇煶璇嗗埆API + const response = await fetch('https://wi.beng35.com/api/baidu-speech/pro_api', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + format: 'pcm', + rate: 16000, + channel: 1, + cuid: baiduSpeechConfig.cuid, + dev_pid: 80001, // 鏅�氳瘽璇嗗埆 + speech: base64Data, + len: len, // PCM 16浣�,姣忎釜閲囨牱鐐�2瀛楄妭 + token: baiduSpeechConfig.token, + }), + }); + + const result = await response.json(); + + if (result.err_no === 0 && result.result && result.result.length > 0) { + inputText.value = result.result[0]; + // showToast('璇煶璇嗗埆鎴愬姛'); + } else { + ElMessage.error('璇煶璇嗗埆澶辫触锛�' + result.err_msg); + } + } catch (error) { + console.error('璇煶璇嗗埆璇锋眰澶辫触:', error); + ElMessage.error('璇煶璇嗗埆璇锋眰澶辫触'); + } + }; + + recordState.mediaRecorder.start(); + recordState.isRecording = true; + source.connect(processor); + processor.connect(recordState.audioContext.destination); + } catch (error) { + console.error('褰曢煶澶辫触:', error); + ElMessage.error('褰曢煶澶辫触,璇锋鏌ラ害鍏嬮鏉冮檺'); + } + }; + + const stopRecording = () => { + if (recordState.mediaRecorder && recordState.isRecording) { + recordState.mediaRecorder.stop(); + recordState.isRecording = false; + if (recordState.audioContext) { + recordState.audioContext.close(); + } + } + }; + + return { + isSupportSpeech, + startRecording, + stopRecording, + recordState, + }; +}; -- Gitblit v1.9.3