diff --git a/config.js b/config.js index 04ca65d..a58ff7b 100644 --- a/config.js +++ b/config.js @@ -4,18 +4,21 @@ export default { // baseUrl: 'http://192.168.3.29:8081', // baseUrl: 'http://10.213.6.207:19010/api', // 语音转文字 - // vioceBaseURl: 'wss://qd.zhaopinzao8dian.com/api/speech-recognition', - vioceBaseURl: 'wss://qd.zhaopinzao8dian.com/api/system/asr/connect', // 自定义 + // vioceBaseURl: 'wss://qd.zhaopinzao8dian.com/api/system/asr/connect', // 自定义 + vioceBaseURl: 'wss://fw.rc.qingdao.gov.cn/rgpp-api/api/system/asr/connect', // 内网 // 语音合成 speechSynthesis: 'wss://qd.zhaopinzao8dian.com/api/speech-synthesis', - speechSynthesis2: 'wss://resource.zhuoson.com/synthesis/', + speechSynthesis2: 'wss://resource.zhuoson.com/synthesis/', //直接替换即可 // indexedDB DBversion: 2, // 只使用本地缓寸的数据 OnlyUseCachedDB: false, // 素质测评URL Quality_assessment_URL: 'https://web1.isdapp.shandong.gov.cn/jmopen_files/unzip/49ee8533b31b46238906b31c27c5dfc9/zycpvhyjw/index.html#/pages/evaluation_record/evaluation_record?uuid=2', + // 职业指导 Career_guidance: 'https://web1.isdapp.shandong.gov.cn/jmopen_files/unzip/2626f6e3c899445db8639a873d172d73/zyzd/index.html', + // ai 模拟面试 + mock_interview: 'https://web1.isdapp.shandong.gov.cn/jmopen_files/unzip/08c660be20b74e15acd8763001db5fd1/szjx-rgzn-xnsc/#/pages/mine/interview/index', // 使用模拟定位 UsingSimulatedPositioning: true, // 应用信息 diff --git a/hook/piper-sdk.js b/hook/piper-sdk.js new file mode 100644 index 0000000..17ab55a --- /dev/null +++ b/hook/piper-sdk.js @@ -0,0 +1,214 @@ +/** + * PiperTTS SDK - 兼容移动端的流式语音合成客户端 + * 特性: + * 1. Web Audio API 实时调度,解决移动端不支持 MSE 的问题 + * 2. 头部注入 (Header Injection) 技术,解决分片解码错误 + * 3. 自动状态管理与事件回调 + */ +export class PiperTTS { + constructor(config = {}) { + this.baseUrl = config.baseUrl || 'http://localhost:5001'; + this.audioCtx = config.audioCtx || new(window.AudioContext || window.webkitAudioContext)(); + this.onStatus = config.onStatus || ((msg, type) => console.log(`[Piper] ${msg}`)); + this.onStart = config.onStart || (() => {}); + this.onEnd = config.onEnd || (() => {}); + + // 内部状态 + this.ws = null; + this.nextTime = 0; // 下一段音频的预定播放时间 + this.audioHeader = null; // 保存WAV/MP3头部 + this.chunkQueue = []; // 数据缓冲队列 + this.queueSize = 0; // 当前缓冲区字节数 + this.analyser = null; // 可视化分析器节点 + + // 配置参数 + this.flushThreshold = 8 * 1024; // 8KB 阈值 + } + + /** + * [重要] 初始化音频引擎 + * 必须在用户点击事件(click/touch)中调用一次,否则手机上没声音 + */ + async init() { + if (this.audioCtx.state === 'suspended') { + await this.audioCtx.resume(); + this.onStatus('音频引擎已激活', 'success'); + } + } + + /** + * 绑定可视化分析器 + * @param {AnalyserNode} analyserNode - Web Audio Analyser节点 + */ + attachVisualizer(analyserNode) { + this.analyser = analyserNode; + } + + /** + * 开始合成并播放 + * @param {string} text - 要合成的文本 + * @param {object} options - 可选参数 {speaker_id, noise_scale, etc.} + */ + speak(text, options = {}) { + if (!text) return; + + this.stop(); // 清理上一次播放 + this.onStatus('正在建立连接...', 'processing'); + + try { + const wsUrl = this.baseUrl.replace(/^http/, 'ws') + '/ws/synthesize'; + this.ws = new WebSocket(wsUrl); + this.ws.binaryType = 'arraybuffer'; + + this.ws.onopen = () => { + this.onStatus('连接成功,请求生成...', 'processing'); + // 初始化时间轴:当前时间 + 缓冲延迟 + this.nextTime = this.audioCtx.currentTime + 0.1; + this.onStart(); + + this.ws.send( + JSON.stringify({ + text: text, + speaker_id: options.speakerId || null, + length_scale: options.lengthScale || 1.0, + noise_scale: options.noiseScale || 0.667, + }) + ); + }; + + this.ws.onmessage = (event) => this._handleMessage(event); + + this.ws.onclose = async () => { + // 处理剩余残余数据 + if (this.chunkQueue.length > 0) { + await this._processQueue(true); + } + this.onStatus('播放结束', 'success'); + this.onEnd(); + }; + + this.ws.onerror = (err) => { + console.error(err); + this.onStatus('连接发生错误', 'error'); + }; + } catch (e) { + this.onStatus(`启动失败: ${e.message}`, 'error'); + } + } + + /** + * 停止播放并重置状态 + */ + stop() { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + // 重置缓冲 + this.chunkQueue = []; + this.queueSize = 0; + this.audioHeader = null; + // 注意:Web Audio API 很难"立即停止"已经在 flight 中的 node, + // 除非我们追踪所有的 sourceNode 并调用 .stop()。 + // 简单实现:suspend 再 resume 或者关闭 context (不推荐频繁关闭)。 + // 这里的 stop 主要停止数据接收。 + } + + // --- 内部私有方法 --- + + async _handleMessage(event) { + if (!(event.data instanceof ArrayBuffer)) return; + + const chunk = event.data; + + // 1. 捕获头部 (Header Injection 核心) + if (!this.audioHeader) { + // 截取前100字节作为通用头 + this.audioHeader = chunk.slice(0, 100); + } + + // 2. 入队 + this.chunkQueue.push(chunk); + this.queueSize += chunk.byteLength; + + // 3. 达到阈值则解码播放 + if (this.queueSize >= this.flushThreshold) { + await this._processQueue(); + } + } + + async _processQueue(isLast = false) { + if (this.chunkQueue.length === 0) return; + + // 1. 合并 Buffer + const rawData = new Uint8Array(this.queueSize); + let offset = 0; + for (const chunk of this.chunkQueue) { + rawData.set(new Uint8Array(chunk), offset); + offset += chunk.byteLength; + } + + // 清空队列 + this.chunkQueue = []; + this.queueSize = 0; + + try { + // 2. 构造带头部的 Buffer + let decodeTarget; + // 简单的头部检测逻辑,如果没有头,就拼上去 + if (this.audioHeader && !this._hasHeader(rawData)) { + const newBuffer = new Uint8Array(this.audioHeader.byteLength + rawData.byteLength); + newBuffer.set(new Uint8Array(this.audioHeader), 0); + newBuffer.set(rawData, this.audioHeader.byteLength); + decodeTarget = newBuffer.buffer; + } else { + decodeTarget = rawData.buffer; + } + + // 3. 解码 + const decodedBuffer = await this.audioCtx.decodeAudioData(decodeTarget); + + // 4. 播放调度 + this._scheduleBuffer(decodedBuffer); + } catch (err) { + // 解码失败处理:如果是中间数据,放回队列头部等待拼接 + if (!isLast) { + this.chunkQueue.unshift(rawData); + this.queueSize += rawData.byteLength; + } else { + console.warn('最后一段数据解码失败,丢弃', err); + } + } + } + + _scheduleBuffer(decodedBuffer) { + const source = this.audioCtx.createBufferSource(); + source.buffer = decodedBuffer; + + // 连接可视化 + if (this.analyser) { + source.connect(this.analyser); + this.analyser.connect(this.audioCtx.destination); + } else { + source.connect(this.audioCtx.destination); + } + + // 计算播放时间:如果发生卡顿,立即播放;否则无缝衔接 + const scheduleTime = Math.max(this.audioCtx.currentTime, this.nextTime); + source.start(scheduleTime); + + // 更新下一段的开始时间 + this.nextTime = scheduleTime + decodedBuffer.duration; + } + + _hasHeader(uint8Arr) { + if (uint8Arr.byteLength < 4) return false; + // Check "RIFF" (WAV) + if (uint8Arr[0] === 82 && uint8Arr[1] === 73 && uint8Arr[2] === 70) return true; + // Check "ID3" (MP3) + if (uint8Arr[0] === 73 && uint8Arr[1] === 68 && uint8Arr[2] === 51) return true; + // Check MP3 Sync Word (Simplify) + if (uint8Arr[0] === 0xff && (uint8Arr[1] & 0xe0) === 0xe0) return true; + return false; + } +} \ No newline at end of file diff --git a/hook/useTTSPlayer2.js b/hook/useTTSPlayer2.js index 131e9c7..0c77b42 100644 --- a/hook/useTTSPlayer2.js +++ b/hook/useTTSPlayer2.js @@ -1,285 +1,168 @@ import { ref, onUnmounted, - onMounted + onMounted, + watch } from 'vue' -// 如果是 uni-app 环境,保留这些导入;如果是纯 Web Vue3,可以移除 import { onHide, onUnload } from '@dcloudio/uni-app' import config from '@/config' +// 请确保 piper-sdk.js 已经正确 export class PiperTTS +import { + PiperTTS +} from './piper-sdk.js' -/** - * Piper TTS 播放钩子 (WebSocket MSE 流式版 - 含 cancelAudio) - * 依赖: 后端必须去除 MP3 ID3 标签 (-map_metadata -1) - */ export function useTTSPlayer() { - // 状态管理 + // UI 状态 const isSpeaking = ref(false) const isPaused = ref(false) const isLoading = ref(false) - // 核心对象 - let audio = null - let mediaSource = null - let sourceBuffer = null - let ws = null + // SDK 实例 + let piper = null - // 缓冲队列管理 - let bufferQueue = [] - let isAppending = false - let isStreamEnded = false + /** + * 初始化 SDK 实例 + * 每次 stop 后 piper 会被置空,这里会重新创建 + */ + const initPiper = () => { + if (piper) return - // 初始化 Audio 监听器 (只运行一次) - const initAudioElement = () => { - if (!audio && typeof window !== 'undefined') { - audio = new Audio() + let baseUrl = config.speechSynthesis2 || '' + baseUrl = baseUrl.replace(/\/$/, '') - // 错误监听 - audio.addEventListener('error', (e) => { - // 如果是手动停止导致的 error (src 被置空),忽略 - if (!audio.src) return - console.error('Audio Player Error:', e) + piper = new PiperTTS({ + baseUrl: baseUrl, + onStatus: (msg, type) => { + if (type === 'error') { + console.error('[TTS Error]', msg) + // 出错时不重置状态,交给用户手动处理或结束事件处理 + resetState() + } + }, + onStart: () => { + isLoading.value = false + isSpeaking.value = true + isPaused.value = false + }, + onEnd: () => { resetState() - }) - - // 播放结束监听 - audio.addEventListener('ended', () => { - resetState() - }) - } + } + }) } /** - * 核心朗读方法 (WebSocket) - * @param {string} text - 要朗读的文本 + * 核心朗读方法 */ const speak = async (text) => { if (!text) return - // 1. 提取文本 const processedText = extractSpeechText(text) if (!processedText) return - // 2. 彻底清理旧状态 - cancelAudio() - initAudioElement() + // 1. 【关键修改】先彻底停止并销毁旧实例 + // 这会断开 socket 并且 close AudioContext,确保上一个声音立即消失 + await stop() + // 2. 初始化新实例 (因为 stop() 把 piper 设为了 null) + initPiper() + + // 3. 更新 UI 为加载中 isLoading.value = true - isSpeaking.value = true isPaused.value = false - isStreamEnded = false + isSpeaking.value = true // 预先设为 true,防止按钮闪烁 - // 3. 检查环境 - if (!window.MediaSource || !window.WebSocket) { - console.error('当前环境不支持 MediaSource 或 WebSocket') + try { + // 4. 激活音频引擎 (移动端防静音关键) + await piper.init() + + // 5. 发送请求 + piper.speak(processedText, { + speakerId: 0, + noiseScale: 0.667, + lengthScale: 1.0 + }) + } catch (e) { + console.error('TTS Speak Error:', e) resetState() - return - } - - try { - // 4. 初始化 MSE - mediaSource = new MediaSource() - // 绑定 MSE 到 Audio - audio.src = URL.createObjectURL(mediaSource) - - // 监听 MSE 打开事件 - mediaSource.addEventListener('sourceopen', () => { - // 防止多次触发 - if (mediaSource.sourceBuffers.length > 0) return - startWebSocketStream(processedText) - }) - - // 尝试播放 (处理浏览器自动播放策略) - const playPromise = audio.play() - if (playPromise !== undefined) { - playPromise.catch(e => { - console.warn('自动播放被拦截 (需用户交互):', e) - // 保持 isSpeaking 为 true,UI 显示播放按钮,用户点击后调用 resume() 即可 - }) - } - - } catch (err) { - console.error('TTS Initialization Failed:', err) - cancelAudio() } } - // 启动 WebSocket 流程 - const startWebSocketStream = (text) => { - const mime = 'audio/mpeg' - - // 4.1 创建 SourceBuffer - try { - sourceBuffer = mediaSource.addSourceBuffer(mime) - sourceBuffer.addEventListener('updateend', () => { - isAppending = false - processQueue() - }) - } catch (e) { - console.error('SourceBuffer Create Failed:', e) - return - } - - // 4.2 计算 WebSocket 地址 - let baseUrl = config.speechSynthesis2 || '' - baseUrl = baseUrl.replace(/\/$/, '') - const wsUrl = baseUrl.replace(/^http/, 'ws') + '/ws/synthesize' - - // 4.3 建立连接 - ws = new WebSocket(wsUrl) - ws.binaryType = 'arraybuffer' // 关键 - - ws.onopen = () => { - // console.log('WS Open') - ws.send(JSON.stringify({ - text: text, - speaker_id: 0, - length_scale: 1.0, - noise_scale: 0.667 - })) - isLoading.value = false - } - - ws.onmessage = (event) => { - if (event.data instanceof ArrayBuffer) { - bufferQueue.push(event.data) - processQueue() - } - } - - ws.onerror = (e) => { - console.error('WS Error:', e) - cancelAudio() - } - - ws.onclose = () => { - // console.log('WS Closed') - isStreamEnded = true - // 检查是否需要结束 MSE 流 - checkEndOfStream() - } - } - - // 处理缓冲队列 - const processQueue = () => { - if (!sourceBuffer || sourceBuffer.updating || bufferQueue.length === 0) { - // 如果队列空了,且流已结束,尝试结束 MSE - if (bufferQueue.length === 0 && isStreamEnded && !sourceBuffer.updating) { - checkEndOfStream() - } - return - } - - isAppending = true - const chunk = bufferQueue.shift() - - try { - sourceBuffer.appendBuffer(chunk) - } catch (e) { - // console.error('AppendBuffer Error:', e) - isAppending = false - } - } - - // 结束 MSE 流 - const checkEndOfStream = () => { - if (mediaSource && mediaSource.readyState === 'open' && bufferQueue.length === 0 && !sourceBuffer - ?.updating) { - try { - mediaSource.endOfStream() - } catch (e) {} - } - } - - const pause = () => { - if (audio && !audio.paused) { - audio.pause() + /** + * 暂停 + */ + const pause = async () => { + if (piper && piper.audioCtx && piper.audioCtx.state === 'running') { + await piper.audioCtx.suspend() isPaused.value = true - isSpeaking.value = false } } - const resume = () => { - if (audio && audio.paused) { - audio.play() + /** + * 恢复 + */ + const resume = async () => { + if (piper && piper.audioCtx && piper.audioCtx.state === 'suspended') { + await piper.audioCtx.resume() isPaused.value = false isSpeaking.value = true } } - // === 新增/核心方法:取消并停止 === - const cancelAudio = () => { - // 1. 断开 WebSocket (停止数据接收) - if (ws) { - // 移除监听器防止报错 - ws.onclose = null - ws.onerror = null - ws.onmessage = null - ws.close() - ws = null - } + /** + * 停止并重置 (核打击模式) + */ + const stop = async () => { + if (piper) { + // 1. 断开 WebSocket + piper.stop() - // 2. 停止音频播放 - if (audio) { - audio.pause() - // 释放 Blob URL 内存 - if (audio.src) { - URL.revokeObjectURL(audio.src) - audio.removeAttribute('src') - } - audio.currentTime = 0 - } - - // 3. 清理 MSE 对象 - if (mediaSource) { - try { - if (mediaSource.readyState === 'open') { - mediaSource.endOfStream() + // 2. 【关键】关闭 AudioContext + // Web Audio API 中,已经 schedule 的 buffer 很难单独取消 + // 最直接的方法是关闭整个 Context + if (piper.audioCtx && piper.audioCtx.state !== 'closed') { + try { + await piper.audioCtx.close() + } catch (e) { + console.warn('AudioContext close failed', e) } - } catch (e) {} - mediaSource = null + } + + // 3. 销毁实例引用 + piper = null } - - sourceBuffer = null - bufferQueue = [] - isAppending = false - isStreamEnded = false - - // 4. 重置 UI 状态 resetState() } - // 只是重置 UI 变量的辅助函数 + // UI 状态重置 const resetState = () => { isSpeaking.value = false isPaused.value = false isLoading.value = false } - // 别名 stop -> cancelAudio (保持兼容性) - const stop = cancelAudio - // === 生命周期 === onMounted(() => { - initAudioElement() + // 预初始化可以不做,等到点击时再做,避免空闲占用 AudioContext 资源 + // initPiper() }) onUnmounted(() => { - cancelAudio() - audio = null + stop() }) - if (typeof onHide === 'function') onHide(cancelAudio) - if (typeof onUnload === 'function') onUnload(cancelAudio) + // Uniapp 生命周期 + if (typeof onHide === 'function') onHide(stop) + if (typeof onUnload === 'function') onUnload(stop) return { speak, pause, resume, stop, - cancelAudio, // 新增导出 + cancelAudio: stop, isSpeaking, isPaused, isLoading @@ -287,7 +170,7 @@ export function useTTSPlayer() { } /** - * 提取文本逻辑 + * 提取文本逻辑 (保持不变) */ function extractSpeechText(markdown) { if (!markdown || markdown.indexOf('job-json') === -1) { diff --git a/packageA/pages/selectDate/selectDate.vue b/packageA/pages/selectDate/selectDate.vue index 4770c4f..ab143f4 100644 --- a/packageA/pages/selectDate/selectDate.vue +++ b/packageA/pages/selectDate/selectDate.vue @@ -62,7 +62,6 @@ const pages = reactive({ const hasZphDateArray = ref([]); onLoad((options) => { - updateDateArray(); if (options.date) { current.value = { date: options?.date || null, diff --git a/packageA/pages/tiktok/tiktok.vue b/packageA/pages/tiktok/tiktok.vue index 7430bee..2b52e7e 100644 --- a/packageA/pages/tiktok/tiktok.vue +++ b/packageA/pages/tiktok/tiktok.vue @@ -95,7 +95,7 @@ const change = (e) => { position: absolute; left: 24rpx; right: 24rpx; - bottom: 30rpx; + bottom: calc( var(--window-bottom)); color: #fff; .title{ font-weight: 500; diff --git a/pages/chat/chat.vue b/pages/chat/chat.vue index 35f8b4b..109d74b 100644 --- a/pages/chat/chat.vue +++ b/pages/chat/chat.vue @@ -111,16 +111,16 @@ onHide(() => { paging.value?.handleTouchCancel(); if (isDrawerOpen.value) { isDrawerOpen.value = false; - // uni.showTabBar(); + uni.showTabBar(); } }); const toggleDrawer = () => { isDrawerOpen.value = !isDrawerOpen.value; if (isDrawerOpen.value) { - // uni.hideTabBar(); + uni.hideTabBar(); } else { - // uni.showTabBar(); + uni.showTabBar(); } }; diff --git a/pages/index/components/index-refactor.vue b/pages/index/components/index-refactor.vue index cec6cb9..00aaa40 100644 --- a/pages/index/components/index-refactor.vue +++ b/pages/index/components/index-refactor.vue @@ -402,7 +402,11 @@ const handleItemClick = (item) => { url: config.Career_guidance, }); break; - + case '模拟面试': + lightAppJssdk.navigation.hide({ + url: config.mock_interview, + }); + break; default: $api.msg('暂未开放'); }