perf(voice): 语音本地文件缓存 + 预加载,消除播放延迟

- 新增本地文件缓存层:downloadFile + saveFileSync 存持久本地文件,跨重启命中即出声
- _resolveSrc 三层优先:本地文件 -> 云函数 audioUrl -> 下载存本地
- 新增 preload(),timer 页 onLoad 后台预载全部语音
- 文件被清理自动回退重下载,下载失败回退临时 url

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
liucheng
2026-07-25 14:21:04 +08:00
parent e682f07892
commit 16924d31db
2 changed files with 90 additions and 3 deletions
+3
View File
@@ -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()
+87 -3
View File
@@ -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 }