/** * Voice prompt utility for the timer. * * 4 fixed, polished prompts live in `cloudfunctions/tts`. We call that * cloud function with a key, get back an audio URL and permanent fileID, * and play it via createInnerAudioContext. * * Two-level caching so TTS synthesis happens at most once per prompt, * ever (not once per app launch): * 1. In-memory `_urlCache` — avoids cloud calls within a single session * 2. Persistent `_fileIDCache` in wx storage — survives app restarts; * we pass the permanent fileID back to the cloud function so it can * call getTempFileURL directly instead of re-synthesizing. * * If the cloud function is unconfigured (no TTS credentials) and returns * `success: false`, we fall back to a silent no-op — better than spamming * the user with toast errors mid-plank. */ const config = require('../config') const PROMPTS = { start: '训练已开始', halfway: '已完成一半啦,坚持就是胜利!', last30: '最后30秒,保持呼吸,稳住姿势!', last10: '最后10秒,再加把劲!', goal: '目标达成,继续挑战!', complete: '太棒了!今天的目标已完成,继续加油!' } const FILEID_CACHE_KEY = 'tts_fileid_cache' const LOCAL_CACHE_KEY = 'tts_local_cache' // { cacheKey: savedFilePath } - 持久本地文件,跨重启 let _ctx = null let _playSeq = 0 // play 序列号,丢弃过期播放避免乱序 let _localCache = null // { cacheKey: savedFilePath } - persisted, lazy-loaded const _urlCache = {} // { promptKey: audioUrl } — per-session, volatile let _fileIDCache = null // { promptKey: fileID } — persisted, lazy-loaded const _loadFileIDCache = () => { if (_fileIDCache) return try { _fileIDCache = wx.getStorageSync(FILEID_CACHE_KEY) || {} } catch (e) { _fileIDCache = {} } } const _saveFileIDCache = () => { try { wx.setStorageSync(FILEID_CACHE_KEY, _fileIDCache) } catch (e) {} } const _loadLocalCache = () => { if (_localCache) return try { _localCache = wx.getStorageSync(LOCAL_CACHE_KEY) || {} } catch (e) { _localCache = {} } } const _saveLocalCache = () => { try { wx.setStorageSync(LOCAL_CACHE_KEY, _localCache) } catch (e) {} } // 本地文件可能被微信清理,播放前校验存在性 const _fileExists = (path) => { if (!path) return false try { wx.getFileSystemManager().accessSync(path) return true } catch (e) { return false } } // 下载网络音频并保存为持久本地文件,返回 savedFilePath;失败返回 null const _downloadAndSave = (url) => new Promise((resolve) => { wx.downloadFile({ url, success: (res) => { if (res.statusCode === 200 && res.tempFilePath) { try { resolve(wx.getFileSystemManager().saveFileSync(res.tempFilePath)) } catch (e) { resolve(null) } } else { resolve(null) } }, fail: () => resolve(null) }) }) const _getCtx = () => { if (!_ctx) { _ctx = wx.createInnerAudioContext() // Play even when the device is in silent mode — we want the user to // actually hear training cues during a session. _ctx.obeyMuteSwitch = false _ctx.onError((err) => { console.log('[voice] audio error:', err.errCode, err.errMsg) }) } return _ctx } /** * Resolve the TTS params (voiceType/volume/speed) for the user's chosen * voice gender from config.js. Cached fileIDs are keyed on these params, * so changing them (or switching gender) auto-invalidates stale audio. */ const _getTtsParams = () => { const s = wx.getStorageSync('user_settings') || {} const gender = s.voiceGender === 'male' ? 'male' : 'female' const params = (config.tts && config.tts[gender]) || config.tts.female return { voiceType: params.voiceType, volume: params.volume, speed: params.speed } } const _fetchUrl = async (promptKey) => { const { voiceType, volume, speed } = _getTtsParams() // Key the cache on the full param set so changing voiceType/volume/speed // (or switching gender) automatically re-synthesizes instead of serving // stale audio. No manual cache clearing needed. const cacheKey = `${promptKey}:${voiceType}:${volume}:${speed}` // 1. In-memory cache: same-session instant return if (_urlCache[cacheKey]) return _urlCache[cacheKey] // 2. Persistent fileID cache: pass to cloud function for instant getTempFileURL _loadFileIDCache() const cachedFileID = _fileIDCache[cacheKey] || null try { const res = await wx.cloud.callFunction({ name: 'tts', data: { promptKey, fileID: cachedFileID, voiceType, volume, speed }, // Timeout: 30s for first synthesis (cold start), <1s for cached config: { timeout: 30000 } }) if (res && res.result && res.result.success && res.result.audioUrl) { _urlCache[cacheKey] = res.result.audioUrl // Persist the fileID if we got a new one (first synthesis or // re-synthesis after cache invalidation). if (res.result.fileID && res.result.fileID !== cachedFileID) { _fileIDCache[cacheKey] = res.result.fileID _saveFileIDCache() } return res.result.audioUrl } } catch (e) { // cloud function not deployed or other error - silent fallback } return null } /** * Resolve a playable src for a prompt, preferring a persisted local file * so playback is instant after the first download (across restarts). * Layers: local file cache -> cloud audioUrl -> download & save locally. */ const _resolveSrc = async (promptKey) => { const { voiceType, volume, speed } = _getTtsParams() const cacheKey = `${promptKey}:${voiceType}:${volume}:${speed}` // 1. 本地文件缓存(持久,跨重启) - 命中即出声 _loadLocalCache() if (_fileExists(_localCache[cacheKey])) return _localCache[cacheKey] // 2. 取 audioUrl(fileID 缓存 / TTS 合成) const url = await _fetchUrl(promptKey) if (!url) return null // 3. 下载并存本地,下次完全跳过云函数与下载 const saved = await _downloadAndSave(url) if (saved) { _localCache[cacheKey] = saved _saveLocalCache() return saved } // 下载/保存失败,退回用临时 url 直接播 return url } /** * 后台预载多段语音(解析+下载到本地),首次播放即可命中本地缓存。 * 已缓存条目是 no-op,可重复调用。语音引导开启时在页面 onLoad 调用。 */ const preload = (keys) => { if (!Array.isArray(keys)) keys = Object.keys(PROMPTS) keys.forEach((k) => { if (PROMPTS[k]) _resolveSrc(k).catch(() => {}) }) } /** * Play a prompt. Returns a Promise that resolves when playback starts * (or immediately if there's no audio available). */ const play = async (promptKey) => { if (!PROMPTS[promptKey]) return const seq = ++_playSeq const src = await _resolveSrc(promptKey) if (!src) return // 期间若有新的 play 调用,放弃本次,避免旧语音截断新语音 if (seq !== _playSeq) return const ctx = _getCtx() try { ctx.stop() ctx.src = src ctx.play() } catch (e) { /* ignore playback errors */ } } /** * Stop any currently-playing voice prompt. Call this when the user * pauses/stops/finishes training to avoid stray audio. */ const stop = () => { if (_ctx) { try { _ctx.stop() } catch (e) { /* ignore */ } } } /** * Tear down the audio context. Call on page unload. */ const destroy = () => { if (_ctx) { try { _ctx.destroy() } catch (e) { /* ignore */ } _ctx = null } } module.exports = { play, preload, stop, destroy, PROMPTS }