之前误将训练模块加在 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>
88 lines
1.8 KiB
TypeScript
88 lines
1.8 KiB
TypeScript
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,
|
|
}
|
|
}
|