From 02fef1700bb9de3aeff8f3d64655e62bafd12fa1 Mon Sep 17 00:00:00 2001 From: Apcallover <1503963513@qq.com> Date: Sat, 20 Dec 2025 14:51:03 +0800 Subject: [PATCH] =?UTF-8?q?flat:=20=E5=AF=B9=E6=8E=A5=E4=B8=80=E4=BD=93?= =?UTF-8?q?=E6=9C=BA=E8=AF=AD=E9=9F=B3=E6=92=AD=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hook/useRealtimeRecorder.js | 3 +- hook/useTTS.js | 21 ++ hook/useTTSPlayer-all-in-one.js | 224 ++++++++++++++++++ hook/{useTTSPlayer.js => useTTSPlayer-web.js} | 0 pages/chat/components/ai-paging.vue | 3 +- 5 files changed, 247 insertions(+), 4 deletions(-) create mode 100644 hook/useTTS.js create mode 100644 hook/useTTSPlayer-all-in-one.js rename hook/{useTTSPlayer.js => useTTSPlayer-web.js} (100%) diff --git a/hook/useRealtimeRecorder.js b/hook/useRealtimeRecorder.js index 4901226..d38ee81 100644 --- a/hook/useRealtimeRecorder.js +++ b/hook/useRealtimeRecorder.js @@ -4,10 +4,9 @@ import { } from 'vue' import { $api -} from '../common/globalFunction'; // 你的请求封装 +} from '../common/globalFunction'; import config from '@/config' -// 开源 export function useAudioRecorder() { // --- 状态定义 --- const isRecording = ref(false) diff --git a/hook/useTTS.js b/hook/useTTS.js new file mode 100644 index 0000000..4dd7740 --- /dev/null +++ b/hook/useTTS.js @@ -0,0 +1,21 @@ +import { + useTTSPlayer as useWebTTS +} from '@/hook/useTTSPlayer-web.js' +import { + useTTSPlayer as useHardwareTTS +} from '@/hook/useTTSPlayer-all-in-one.js' +import { + isY9MachineType +} from '../common/globalFunction'; + +/** + * 智能 TTS 适配器 Hook + * 自动判断环境并返回对应的播放器实现 + */ +export function useTTSPlayer() { + if (isY9MachineType()) { + return useHardwareTTS() + } else { + return useWebTTS() + } +} \ No newline at end of file diff --git a/hook/useTTSPlayer-all-in-one.js b/hook/useTTSPlayer-all-in-one.js new file mode 100644 index 0000000..cca5e8f --- /dev/null +++ b/hook/useTTSPlayer-all-in-one.js @@ -0,0 +1,224 @@ +import { + ref, + onUnmounted +} from 'vue' +import { + onHide, + onUnload +} from '@dcloudio/uni-app' + +/** + * 一体机 TTS 播放器 Hook (修复版) + */ +export function useTTSPlayer() { + // UI 状态 + const isSpeaking = ref(false) // 是否正在交互 + const isPaused = ref(false) // 是否处于暂停状态 + const isLoading = ref(false) // 是否正在加载 + + // 记录最后一次播放的文本 + const lastText = ref('') + + /** + * 封装 hh.call 通用调用 + */ + const callBridge = (params) => { + return new Promise((resolve) => { + if (typeof window !== 'undefined' && window.hh && window.hh.call) { + // 打印日志方便调试 + console.log('[TTS Bridge Send]:', params) + window.hh.call("ampeHHCommunication", params, (res) => { + console.log('[TTS Bridge Res]:', res) + resolve(res) + }) + } else { + console.warn('当前环境不支持 hh.call,模拟成功') + resolve({ + success: true + }) + } + }) + } + + /** + * 生成随机 TaskId + */ + const generateTaskId = () => { + return 'task_' + Date.now() + '_' + Math.floor(Math.random() * 1000) + } + + /** + * 停止 (中断) - 内部使用 + */ + const executeStop = async () => { + const params = { + "action": "speechStop", + "event": "open", + "taskId": generateTaskId() + } + await callBridge(params) + } + + /** + * 核心朗读方法 + */ + const speak = async (text) => { + if (!text) return + + const processedText = extractSpeechText(text) + if (!processedText) return + + // 1. 立即更新记忆文本 (确保即使后续失败,resume也能读到新的) + lastText.value = processedText + + isLoading.value = true + + try { + // 2.【关键修复】先强制停止上一次播报 + // 虽然 speechQueue:1 理论上可以打断,但显式停止更稳健 + await executeStop() + + // 3. 构造播放参数 + const params = { + "action": "speech", + "event": "open", + "taskId": generateTaskId(), + "params": { + "text": processedText, + "pitch": 1.0, + "rate": 1.0, + "speechQueue": 1 // 1表示打断 + } + } + + // 4. 发送播放指令 + await callBridge(params) + + // 5. 更新状态 + isSpeaking.value = true + isPaused.value = false + } catch (e) { + console.error('TTS Speak Error:', e) + resetState() + } finally { + isLoading.value = false + } + } + + /** + * 暂停 + */ + const pause = async () => { + if (!isSpeaking.value || isPaused.value) return + try { + await executeStop() + isPaused.value = true + // 注意:不要设置 isSpeaking = false,因为逻辑上只是暂停 + } catch (e) { + console.error("Pause failed:", e) + } + } + + /** + * 恢复 (由于硬件限制,实际上是从头播放当前文本) + */ + const resume = async () => { + if (!isPaused.value) return + + // 如果有缓存的文本,重新触发 speak + if (lastText.value) { + console.log('[TTS Resume] Re-speaking text:', lastText.value.substring(0, 20) + '...') + // 调用 speak 会自动处理 stop 和状态更新 + await speak(lastText.value) + } else { + isPaused.value = false + } + } + + /** + * 切换 播放/暂停 + * 注意:如果是切换了新文章,请直接调用 speak(newText),不要调用 togglePlay + */ + const togglePlay = () => { + // 如果当前是暂停状态,则恢复 + if (isPaused.value) { + resume() + } + // 如果当前正在播放,则暂停 + else if (isSpeaking.value) { + pause() + } + // 如果既没播放也没暂停(比如刚进页面),需要业务层调用 speak 启动,这里无法自动推断文本 + } + + /** + * 停止并重置 + */ + const stop = async () => { + await executeStop() + resetState() + } + + const resetState = () => { + isSpeaking.value = false + isPaused.value = false + isLoading.value = false + } + + // === 生命周期管理 === + onUnmounted(() => stop()) + if (typeof onHide === 'function') onHide(() => stop()) + if (typeof onUnload === 'function') onUnload(() => stop()) + + return { + speak, + pause, + resume, + togglePlay, + stop, + cancelAudio: stop, + isSpeaking, + isPaused, + isLoading + } +} + +/** + * 文本提取工具函数 (保持不变) + */ +function extractSpeechText(markdown) { + if (!markdown || typeof markdown !== 'string') return ''; // 增加类型安全检查 + if (markdown.indexOf('job-json') === -1) { + return markdown; + } + const jobRegex = /``` job-json\s*({[\s\S]*?})\s*```/g; + const jobs = []; + let match; + let lastJobEndIndex = 0; + let firstJobStartIndex = -1; + while ((match = jobRegex.exec(markdown)) !== null) { + const jobStr = match[1]; + try { + const job = JSON.parse(jobStr); + jobs.push(job); + if (firstJobStartIndex === -1) { + firstJobStartIndex = match.index; + } + lastJobEndIndex = jobRegex.lastIndex; + } catch (e) { + console.warn('JSON 解析失败', e); + } + } + const guideText = firstJobStartIndex > 0 ? + markdown.slice(0, firstJobStartIndex).trim() : ''; + const endingText = lastJobEndIndex < markdown.length ? + markdown.slice(lastJobEndIndex).trim() : ''; + const jobTexts = jobs.map((job, index) => { + return `第 ${index + 1} 个岗位,岗位名称是:${job.jobTitle},公司是:${job.companyName},薪资:${job.salary},地点:${job.location},学历要求:${job.education},经验要求:${job.experience}。`; + }); + const finalTextParts = []; + if (guideText) finalTextParts.push(guideText); + finalTextParts.push(...jobTexts); + if (endingText) finalTextParts.push(endingText); + return finalTextParts.join('\n'); +} \ No newline at end of file diff --git a/hook/useTTSPlayer.js b/hook/useTTSPlayer-web.js similarity index 100% rename from hook/useTTSPlayer.js rename to hook/useTTSPlayer-web.js diff --git a/pages/chat/components/ai-paging.vue b/pages/chat/components/ai-paging.vue index 901838b..4c66fd8 100644 --- a/pages/chat/components/ai-paging.vue +++ b/pages/chat/components/ai-paging.vue @@ -271,9 +271,8 @@ import FileIcon from './fileIcon.vue'; import FileText from './fileText.vue'; import useScreenStore from '@/stores/useScreenStore' const screenStore = useScreenStore(); -// 系统功能hook和阿里云hook import { useAudioRecorder } from '@/hook/useRealtimeRecorder.js'; -import { useTTSPlayer } from '@/hook/useTTSPlayer.js'; +import { useTTSPlayer } from '@/hook/useTTS.js'; // 全局 const { $api, navTo, throttle } = inject('globalFunction'); const emit = defineEmits(['onConfirm']);