/** * 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' let _ctx = null 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 _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 } /** * 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 url = await _fetchUrl(promptKey) if (!url) return const ctx = _getCtx() try { ctx.stop() ctx.src = url 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, stop, destroy, PROMPTS }