之前误将训练模块加在 uniapp 项目,实际目标项目是 TUICallKit-Vue3 (甄养堂主小程序壳),整体迁入: - hooks/useMetronome.ts 节拍器(音频实例池+预热,无冷启动延迟) - hooks/useTrainingSession.ts 训练 Session 状态机 - hooks/useVoiceCoach.ts 语音教练队列 - hooks/useTrainingBgm.ts 训练/休息 BGM 切换+渐变 - pages/training/index.vue 练一练主页(器械跟练) - pages/training/metronome.vue 独立节拍器(老人友好版, 中央圆即开始/暂停按钮,纯 CSS 几何 icon,大字大按钮) - pages/training/components/exercise-anim.vue CSS 动作动画 - pages/training/exercises.ts 5 个器械动作预设 - static/training/audio/*.mp3 3 种 click 音色(MIT 协议) - scripts/generate-voice.mjs MiMo TTS 批量生成语音脚本 - pages.json 注册 pages/training/index 与 pages/training/metronome - 「我的」页加 DevTrainingEntry 悬浮入口,生产环境自动隐藏 Co-authored-by: Cursor <cursoragent@cursor.com>
116 lines
3.2 KiB
TypeScript
116 lines
3.2 KiB
TypeScript
import { ref, onUnmounted } from 'vue'
|
|
|
|
const BGM_BASE = '/static/training/bgm'
|
|
|
|
export type BgmMode = 'none' | 'training' | 'resting'
|
|
|
|
export interface UseTrainingBgmOptions {
|
|
trainingSrc?: string
|
|
restingSrc?: string
|
|
volume?: number
|
|
fadeMs?: number
|
|
}
|
|
|
|
export function useTrainingBgm(options: UseTrainingBgmOptions = {}) {
|
|
const {
|
|
trainingSrc = `${BGM_BASE}/train-light.mp3`,
|
|
restingSrc = `${BGM_BASE}/rest-meditation.mp3`,
|
|
volume = 0.6,
|
|
fadeMs = 600,
|
|
} = options
|
|
|
|
const mode = ref<BgmMode>('none')
|
|
const enabled = ref<boolean>(true)
|
|
|
|
let trainCtx: UniApp.InnerAudioContext | null = null
|
|
let restCtx: UniApp.InnerAudioContext | null = null
|
|
let fadeTimers: ReturnType<typeof setInterval>[] = []
|
|
|
|
const ensureCtx = () => {
|
|
if (!trainCtx) {
|
|
trainCtx = uni.createInnerAudioContext()
|
|
trainCtx.src = trainingSrc
|
|
trainCtx.loop = true
|
|
trainCtx.obeyMuteSwitch = false
|
|
trainCtx.volume = 0
|
|
}
|
|
if (!restCtx) {
|
|
restCtx = uni.createInnerAudioContext()
|
|
restCtx.src = restingSrc
|
|
restCtx.loop = true
|
|
restCtx.obeyMuteSwitch = false
|
|
restCtx.volume = 0
|
|
}
|
|
}
|
|
|
|
const fade = (
|
|
ctx: UniApp.InnerAudioContext,
|
|
from: number,
|
|
to: number,
|
|
duration = fadeMs,
|
|
) => {
|
|
const steps = 16
|
|
const stepMs = Math.max(16, duration / steps)
|
|
let i = 0
|
|
const t = setInterval(() => {
|
|
i++
|
|
const v = from + (to - from) * (i / steps)
|
|
ctx.volume = Math.max(0, Math.min(1, v))
|
|
if (i >= steps) {
|
|
clearInterval(t)
|
|
fadeTimers = fadeTimers.filter((x) => x !== t)
|
|
}
|
|
}, stepMs)
|
|
fadeTimers.push(t)
|
|
}
|
|
|
|
const switchTo = (target: BgmMode) => {
|
|
if (!enabled.value) return
|
|
if (mode.value === target) return
|
|
ensureCtx()
|
|
|
|
if (target === 'training' && trainCtx && restCtx) {
|
|
fade(restCtx, restCtx.volume, 0)
|
|
setTimeout(() => restCtx?.pause(), fadeMs)
|
|
trainCtx.play()
|
|
fade(trainCtx, 0, volume)
|
|
} else if (target === 'resting' && trainCtx && restCtx) {
|
|
fade(trainCtx, trainCtx.volume, 0)
|
|
setTimeout(() => trainCtx?.pause(), fadeMs)
|
|
restCtx.play()
|
|
fade(restCtx, 0, volume)
|
|
} else if (target === 'none') {
|
|
if (trainCtx) {
|
|
fade(trainCtx, trainCtx.volume, 0)
|
|
setTimeout(() => trainCtx?.pause(), fadeMs)
|
|
}
|
|
if (restCtx) {
|
|
fade(restCtx, restCtx.volume, 0)
|
|
setTimeout(() => restCtx?.pause(), fadeMs)
|
|
}
|
|
}
|
|
mode.value = target
|
|
}
|
|
|
|
const setEnabled = (v: boolean) => {
|
|
enabled.value = v
|
|
if (!v) switchTo('none')
|
|
}
|
|
|
|
onUnmounted(() => {
|
|
fadeTimers.forEach((t) => clearInterval(t))
|
|
fadeTimers = []
|
|
trainCtx?.destroy?.()
|
|
restCtx?.destroy?.()
|
|
trainCtx = null
|
|
restCtx = null
|
|
})
|
|
|
|
return {
|
|
mode,
|
|
enabled,
|
|
switchTo,
|
|
setEnabled,
|
|
}
|
|
}
|