perf(voice): 语音本地文件缓存 + 预加载,消除播放延迟
- 新增本地文件缓存层:downloadFile + saveFileSync 存持久本地文件,跨重启命中即出声 - _resolveSrc 三层优先:本地文件 -> 云函数 audioUrl -> 下载存本地 - 新增 preload(),timer 页 onLoad 后台预载全部语音 - 文件被清理自动回退重下载,下载失败回退临时 url Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,9 @@ Page({
|
|||||||
onLoad(options) {
|
onLoad(options) {
|
||||||
themeMod.applyThemeToPage(this)
|
themeMod.applyThemeToPage(this)
|
||||||
this._init(options)
|
this._init(options)
|
||||||
|
// 预载语音到本地缓存,训练开始时即可即时播放(仅语音引导开启时)
|
||||||
|
const s = storage.getSettings()
|
||||||
|
if (s.voiceGuide !== false) voice.preload()
|
||||||
// countUp: 数字从 0 滚到目标秒数
|
// countUp: 数字从 0 滚到目标秒数
|
||||||
const target = this.data.duration
|
const target = this.data.duration
|
||||||
if (this._countUpTask) this._countUpTask.cancel()
|
if (this._countUpTask) this._countUpTask.cancel()
|
||||||
|
|||||||
+87
-3
@@ -29,8 +29,10 @@ const PROMPTS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FILEID_CACHE_KEY = 'tts_fileid_cache'
|
const FILEID_CACHE_KEY = 'tts_fileid_cache'
|
||||||
|
const LOCAL_CACHE_KEY = 'tts_local_cache' // { cacheKey: savedFilePath } - 持久本地文件,跨重启
|
||||||
|
|
||||||
let _ctx = null
|
let _ctx = null
|
||||||
|
let _localCache = null // { cacheKey: savedFilePath } - persisted, lazy-loaded
|
||||||
const _urlCache = {} // { promptKey: audioUrl } — per-session, volatile
|
const _urlCache = {} // { promptKey: audioUrl } — per-session, volatile
|
||||||
let _fileIDCache = null // { promptKey: fileID } — persisted, lazy-loaded
|
let _fileIDCache = null // { promptKey: fileID } — persisted, lazy-loaded
|
||||||
|
|
||||||
@@ -47,6 +49,49 @@ const _saveFileIDCache = () => {
|
|||||||
try { wx.setStorageSync(FILEID_CACHE_KEY, _fileIDCache) } catch (e) {}
|
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 = () => {
|
const _getCtx = () => {
|
||||||
if (!_ctx) {
|
if (!_ctx) {
|
||||||
_ctx = wx.createInnerAudioContext()
|
_ctx = wx.createInnerAudioContext()
|
||||||
@@ -114,14 +159,53 @@ const _fetchUrl = async (promptKey) => {
|
|||||||
return null
|
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
|
* Play a prompt. Returns a Promise that resolves when playback starts
|
||||||
* (or immediately if there's no audio available).
|
* (or immediately if there's no audio available).
|
||||||
*/
|
*/
|
||||||
const play = async (promptKey) => {
|
const play = async (promptKey) => {
|
||||||
if (!PROMPTS[promptKey]) return
|
if (!PROMPTS[promptKey]) return
|
||||||
const url = await _fetchUrl(promptKey)
|
const src = await _resolveSrc(promptKey)
|
||||||
if (!url) return
|
if (!src) return
|
||||||
const ctx = _getCtx()
|
const ctx = _getCtx()
|
||||||
try {
|
try {
|
||||||
ctx.stop()
|
ctx.stop()
|
||||||
@@ -150,4 +234,4 @@ const destroy = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { play, stop, destroy, PROMPTS }
|
module.exports = { play, preload, stop, destroy, PROMPTS }
|
||||||
|
|||||||
Reference in New Issue
Block a user