From 16924d31dbaa160f4dc05bc46e6691d0314d494d Mon Sep 17 00:00:00 2001 From: liucheng Date: Sat, 25 Jul 2026 14:21:04 +0800 Subject: [PATCH] =?UTF-8?q?perf(voice):=20=E8=AF=AD=E9=9F=B3=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E6=96=87=E4=BB=B6=E7=BC=93=E5=AD=98=20+=20=E9=A2=84?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD,=E6=B6=88=E9=99=A4=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E5=BB=B6=E8=BF=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增本地文件缓存层:downloadFile + saveFileSync 存持久本地文件,跨重启命中即出声 - _resolveSrc 三层优先:本地文件 -> 云函数 audioUrl -> 下载存本地 - 新增 preload(),timer 页 onLoad 后台预载全部语音 - 文件被清理自动回退重下载,下载失败回退临时 url Co-Authored-By: Claude --- pages/timer/timer.js | 3 ++ utils/voice.js | 90 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/pages/timer/timer.js b/pages/timer/timer.js index 964c7d2..f064c7b 100644 --- a/pages/timer/timer.js +++ b/pages/timer/timer.js @@ -40,6 +40,9 @@ Page({ onLoad(options) { themeMod.applyThemeToPage(this) this._init(options) + // 预载语音到本地缓存,训练开始时即可即时播放(仅语音引导开启时) + const s = storage.getSettings() + if (s.voiceGuide !== false) voice.preload() // countUp: 数字从 0 滚到目标秒数 const target = this.data.duration if (this._countUpTask) this._countUpTask.cancel() diff --git a/utils/voice.js b/utils/voice.js index 784361d..04dad07 100644 --- a/utils/voice.js +++ b/utils/voice.js @@ -29,8 +29,10 @@ const PROMPTS = { } const FILEID_CACHE_KEY = 'tts_fileid_cache' +const LOCAL_CACHE_KEY = 'tts_local_cache' // { cacheKey: savedFilePath } - 持久本地文件,跨重启 let _ctx = null +let _localCache = null // { cacheKey: savedFilePath } - persisted, lazy-loaded const _urlCache = {} // { promptKey: audioUrl } — per-session, volatile let _fileIDCache = null // { promptKey: fileID } — persisted, lazy-loaded @@ -47,6 +49,49 @@ 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() @@ -114,14 +159,53 @@ const _fetchUrl = async (promptKey) => { 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 url = await _fetchUrl(promptKey) - if (!url) return + const src = await _resolveSrc(promptKey) + if (!src) return const ctx = _getCtx() try { ctx.stop() @@ -150,4 +234,4 @@ const destroy = () => { } } -module.exports = { play, stop, destroy, PROMPTS } +module.exports = { play, preload, stop, destroy, PROMPTS }