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,205 @@
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
|
||||
export interface MetronomeOptions {
|
||||
initialBpm?: number
|
||||
bpmMin?: number
|
||||
bpmMax?: number
|
||||
clickSrc?: string
|
||||
accentSrc?: string
|
||||
accentEvery?: number
|
||||
poolSize?: number
|
||||
onBeat?: (beatIndex: number, isAccent: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 音频实例池:每个 click 用一个独立 InnerAudioContext,循环复用。
|
||||
* 解决两个问题:
|
||||
* 1. seek+play 模式在某些设备上首拍冷启动延迟(100-300ms)
|
||||
* 2. BPM 偏快时上一拍音频还没播完,下一拍 play 会被阻塞或丢失
|
||||
*/
|
||||
class AudioPool {
|
||||
private list: UniApp.InnerAudioContext[] = []
|
||||
private cursor = 0
|
||||
private warmedUp = false
|
||||
|
||||
constructor(
|
||||
private src: string,
|
||||
private size: number,
|
||||
) {}
|
||||
|
||||
private create() {
|
||||
for (let i = 0; i < this.size; i++) {
|
||||
const ctx = uni.createInnerAudioContext()
|
||||
ctx.src = this.src
|
||||
ctx.obeyMuteSwitch = false
|
||||
ctx.autoplay = false
|
||||
this.list.push(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预热:静音播放一次每个实例,让音频解码完成
|
||||
* 这样后续 play() 时就是热启动,没有冷启动延迟
|
||||
*/
|
||||
warmUp() {
|
||||
if (this.warmedUp) return
|
||||
if (this.list.length === 0) this.create()
|
||||
|
||||
this.list.forEach((ctx) => {
|
||||
const originalVolume = ctx.volume
|
||||
ctx.volume = 0
|
||||
try {
|
||||
ctx.play()
|
||||
setTimeout(() => {
|
||||
try {
|
||||
ctx.stop()
|
||||
ctx.volume = originalVolume === 0 ? 1 : originalVolume
|
||||
} catch (_) {}
|
||||
}, 80)
|
||||
} catch (_) {}
|
||||
})
|
||||
this.warmedUp = true
|
||||
}
|
||||
|
||||
play() {
|
||||
if (this.list.length === 0) {
|
||||
this.create()
|
||||
this.warmUp()
|
||||
}
|
||||
const ctx = this.list[this.cursor]
|
||||
this.cursor = (this.cursor + 1) % this.list.length
|
||||
try {
|
||||
ctx.seek(0)
|
||||
ctx.play()
|
||||
} catch (_) {
|
||||
ctx.play()
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.list.forEach((ctx) => {
|
||||
try {
|
||||
ctx.destroy?.()
|
||||
} catch (_) {}
|
||||
})
|
||||
this.list = []
|
||||
this.warmedUp = false
|
||||
}
|
||||
}
|
||||
|
||||
export function useMetronome(options: MetronomeOptions = {}) {
|
||||
const {
|
||||
initialBpm = 80,
|
||||
bpmMin = 40,
|
||||
bpmMax = 240,
|
||||
clickSrc = '/static/training/audio/click.mp3',
|
||||
accentSrc,
|
||||
poolSize = 4,
|
||||
onBeat,
|
||||
} = options
|
||||
|
||||
const bpm = ref<number>(initialBpm)
|
||||
const isPlaying = ref<boolean>(false)
|
||||
const beatIndex = ref<number>(0)
|
||||
const isAccent = ref<boolean>(false)
|
||||
const accentEveryRef = ref<number>(options.accentEvery ?? 4)
|
||||
|
||||
const intervalMs = computed(() => 60000 / bpm.value)
|
||||
|
||||
let clickPool: AudioPool | null = null
|
||||
let accentPool: AudioPool | null = null
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let nextTickAt = 0
|
||||
|
||||
const ensureAudio = () => {
|
||||
if (!clickPool) {
|
||||
clickPool = new AudioPool(clickSrc, poolSize)
|
||||
clickPool.warmUp()
|
||||
}
|
||||
if (accentSrc && !accentPool) {
|
||||
accentPool = new AudioPool(accentSrc, Math.max(2, Math.ceil(poolSize / 2)))
|
||||
accentPool.warmUp()
|
||||
}
|
||||
}
|
||||
|
||||
const playSound = (accent: boolean) => {
|
||||
if (accent && accentPool) {
|
||||
accentPool.play()
|
||||
} else if (clickPool) {
|
||||
clickPool.play()
|
||||
}
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
if (!isPlaying.value) return
|
||||
|
||||
const every = Math.max(1, accentEveryRef.value)
|
||||
const accent = beatIndex.value % every === 0
|
||||
isAccent.value = accent
|
||||
|
||||
playSound(accent)
|
||||
onBeat?.(beatIndex.value, accent)
|
||||
beatIndex.value++
|
||||
|
||||
nextTickAt += intervalMs.value
|
||||
const drift = Date.now() - nextTickAt
|
||||
const nextDelay = Math.max(0, intervalMs.value - drift)
|
||||
|
||||
timer = setTimeout(tick, nextDelay)
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
if (isPlaying.value) return
|
||||
ensureAudio()
|
||||
isPlaying.value = true
|
||||
beatIndex.value = 0
|
||||
nextTickAt = Date.now()
|
||||
tick()
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
isPlaying.value = false
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
const setBpm = (val: number) => {
|
||||
bpm.value = Math.max(bpmMin, Math.min(bpmMax, Math.round(val)))
|
||||
}
|
||||
|
||||
const setAccentEvery = (n: number) => {
|
||||
accentEveryRef.value = Math.max(1, Math.min(16, Math.round(n)))
|
||||
beatIndex.value = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动预加载(推荐在页面 onShow 时调用,让用户进页面就完成预热)
|
||||
*/
|
||||
const preload = () => {
|
||||
ensureAudio()
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stop()
|
||||
clickPool?.destroy()
|
||||
accentPool?.destroy()
|
||||
clickPool = null
|
||||
accentPool = null
|
||||
})
|
||||
|
||||
return {
|
||||
bpm,
|
||||
isPlaying,
|
||||
beatIndex,
|
||||
isAccent,
|
||||
intervalMs,
|
||||
accentEvery: accentEveryRef,
|
||||
start,
|
||||
stop,
|
||||
setBpm,
|
||||
setAccentEvery,
|
||||
preload,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
|
||||
export type SessionPhase = 'idle' | 'training' | 'resting' | 'done'
|
||||
|
||||
export interface TrainingSessionConfig {
|
||||
sets: number
|
||||
reps: number
|
||||
restSec: number
|
||||
beatsPerRep?: number
|
||||
onRepComplete?: (currentRep: number, totalReps: number) => void
|
||||
onSetComplete?: (currentSet: number, totalSets: number) => void
|
||||
onEnterRest?: (restSec: number) => void
|
||||
onExitRest?: () => void
|
||||
onDone?: () => void
|
||||
onCountdownTick?: (remainingSec: number) => void
|
||||
}
|
||||
|
||||
export function useTrainingSession() {
|
||||
const phase = ref<SessionPhase>('idle')
|
||||
const currentSet = ref<number>(0)
|
||||
const currentRep = ref<number>(0)
|
||||
const restRemainingSec = ref<number>(0)
|
||||
const beatCounter = ref<number>(0)
|
||||
|
||||
let config: TrainingSessionConfig | null = null
|
||||
let restTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const totalReps = computed(() => config?.reps ?? 0)
|
||||
const totalSets = computed(() => config?.sets ?? 0)
|
||||
const beatsPerRep = computed(() => config?.beatsPerRep ?? 2)
|
||||
|
||||
const isTraining = computed(() => phase.value === 'training')
|
||||
const isResting = computed(() => phase.value === 'resting')
|
||||
const isDone = computed(() => phase.value === 'done')
|
||||
|
||||
const start = (cfg: TrainingSessionConfig) => {
|
||||
config = cfg
|
||||
phase.value = 'training'
|
||||
currentSet.value = 1
|
||||
currentRep.value = 0
|
||||
beatCounter.value = 0
|
||||
restRemainingSec.value = 0
|
||||
}
|
||||
|
||||
const onBeat = () => {
|
||||
if (phase.value !== 'training' || !config) return
|
||||
|
||||
beatCounter.value++
|
||||
|
||||
if (beatCounter.value % beatsPerRep.value !== 0) return
|
||||
|
||||
currentRep.value++
|
||||
config.onRepComplete?.(currentRep.value, totalReps.value)
|
||||
|
||||
if (currentRep.value >= totalReps.value) {
|
||||
config.onSetComplete?.(currentSet.value, totalSets.value)
|
||||
|
||||
if (currentSet.value >= totalSets.value) {
|
||||
phase.value = 'done'
|
||||
config.onDone?.()
|
||||
return
|
||||
}
|
||||
|
||||
enterRest()
|
||||
}
|
||||
}
|
||||
|
||||
const enterRest = () => {
|
||||
if (!config) return
|
||||
phase.value = 'resting'
|
||||
restRemainingSec.value = config.restSec
|
||||
config.onEnterRest?.(config.restSec)
|
||||
|
||||
restTimer = setInterval(() => {
|
||||
restRemainingSec.value--
|
||||
config?.onCountdownTick?.(restRemainingSec.value)
|
||||
if (restRemainingSec.value <= 0) {
|
||||
exitRest()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const exitRest = () => {
|
||||
if (restTimer) {
|
||||
clearInterval(restTimer)
|
||||
restTimer = null
|
||||
}
|
||||
if (!config) return
|
||||
|
||||
currentSet.value++
|
||||
currentRep.value = 0
|
||||
beatCounter.value = 0
|
||||
phase.value = 'training'
|
||||
config.onExitRest?.()
|
||||
}
|
||||
|
||||
const skipRest = () => {
|
||||
if (phase.value !== 'resting') return
|
||||
exitRest()
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
if (restTimer) {
|
||||
clearInterval(restTimer)
|
||||
restTimer = null
|
||||
}
|
||||
phase.value = 'idle'
|
||||
currentSet.value = 0
|
||||
currentRep.value = 0
|
||||
beatCounter.value = 0
|
||||
restRemainingSec.value = 0
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (restTimer) clearInterval(restTimer)
|
||||
})
|
||||
|
||||
return {
|
||||
phase,
|
||||
currentSet,
|
||||
currentRep,
|
||||
restRemainingSec,
|
||||
totalReps,
|
||||
totalSets,
|
||||
isTraining,
|
||||
isResting,
|
||||
isDone,
|
||||
start,
|
||||
stop,
|
||||
skipRest,
|
||||
onBeat,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { onUnmounted } from 'vue'
|
||||
|
||||
const VOICE_BASE = '/static/training/voice'
|
||||
|
||||
export type VoicePromptKey =
|
||||
| 'start'
|
||||
| 'ready'
|
||||
| 'rest'
|
||||
| 'next-set'
|
||||
| 'last-rep'
|
||||
| 'keep-it-up'
|
||||
| 'good-job'
|
||||
| 'breathe-in'
|
||||
| 'breathe-out'
|
||||
|
||||
export interface UseVoiceCoachOptions {
|
||||
enabled?: boolean
|
||||
volume?: number
|
||||
}
|
||||
|
||||
export function useVoiceCoach(options: UseVoiceCoachOptions = {}) {
|
||||
const { enabled = true, volume = 1 } = options
|
||||
|
||||
let ctx: UniApp.InnerAudioContext | null = null
|
||||
let queue: string[] = []
|
||||
let isPlayingQueue = false
|
||||
|
||||
const ensureCtx = () => {
|
||||
if (ctx) return
|
||||
ctx = uni.createInnerAudioContext()
|
||||
ctx.volume = volume
|
||||
ctx.obeyMuteSwitch = false
|
||||
ctx.onEnded(() => {
|
||||
playNextInQueue()
|
||||
})
|
||||
ctx.onError(() => {
|
||||
playNextInQueue()
|
||||
})
|
||||
}
|
||||
|
||||
const playNextInQueue = () => {
|
||||
if (!ctx || queue.length === 0) {
|
||||
isPlayingQueue = false
|
||||
return
|
||||
}
|
||||
const nextSrc = queue.shift()!
|
||||
ctx.src = nextSrc
|
||||
ctx.play()
|
||||
}
|
||||
|
||||
const enqueue = (src: string) => {
|
||||
if (!enabled) return
|
||||
ensureCtx()
|
||||
queue.push(src)
|
||||
if (!isPlayingQueue) {
|
||||
isPlayingQueue = true
|
||||
playNextInQueue()
|
||||
}
|
||||
}
|
||||
|
||||
const speakNumber = (n: number) => {
|
||||
if (n < 1 || n > 30) return
|
||||
enqueue(`${VOICE_BASE}/numbers/${n}.mp3`)
|
||||
}
|
||||
|
||||
const speakPrompt = (key: VoicePromptKey) => {
|
||||
enqueue(`${VOICE_BASE}/prompts/${key}.mp3`)
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
queue = []
|
||||
isPlayingQueue = false
|
||||
ctx?.stop()
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stop()
|
||||
ctx?.destroy?.()
|
||||
ctx = null
|
||||
})
|
||||
|
||||
return {
|
||||
speakNumber,
|
||||
speakPrompt,
|
||||
stop,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user