feat(TUICallKit-Vue3): 迁入节拍器与练一练训练模块
之前误将训练模块加在 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>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user