This commit is contained in:
2026-05-29 18:17:44 +08:00
parent c589bfa647
commit 132a17b946
15 changed files with 285 additions and 2175 deletions
@@ -9,6 +9,7 @@ export interface MetronomeOptions {
accentEvery?: number
poolSize?: number
volume?: number // 节拍音音量 0-1
silent?: boolean // 静音模式:只驱动节拍回调,不发声(如握力环只需视觉节拍)
onBeat?: (beatIndex: number, isAccent: boolean) => void
}
@@ -119,6 +120,7 @@ export function useMetronome(options: MetronomeOptions = {}) {
accentSrc,
poolSize = 4,
volume = 1,
silent = false,
onBeat,
} = options
@@ -136,6 +138,7 @@ export function useMetronome(options: MetronomeOptions = {}) {
let nextTickAt = 0
const ensureAudio = () => {
if (silent) return
if (!clickPool) {
clickPool = new AudioPool(clickSrc, poolSize, volume)
clickPool.warmUp()
-32
View File
@@ -1,32 +0,0 @@
/**
* TTS 语音鼓励 Hook
* 用于在捏碎特效触发时播放语音鼓励
*
* 当前状态:预留接口,待集成 MiMo TTS
* TODO: 集成 MiMo TTS SDK
*/
export function useTTS() {
/**
* 播放语音鼓励
* 随机播放:"加油"、"太棒了"、"继续"、"很好"等
*
* 实现计划:
* 1. 引入 MiMo TTS SDK
* 2. 配置语音库(鼓励短语列表)
* 3. 随机选择短语并播放
*/
const playEncouragement = () => {
// TODO: 集成 MiMo TTS
// 示例实现:
// const phrases = ['加油', '太棒了', '继续', '很好', '坚持']
// const phrase = phrases[Math.floor(Math.random() * phrases.length)]
// mimoTTS.speak(phrase)
console.log('[TTS] 播放语音鼓励(待实现 MiMo TTS 集成)')
}
return {
playEncouragement
}
}
@@ -1,115 +0,0 @@
import { ref, onUnmounted } from 'vue'
const BGM_BASE = '/training/static/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,
}
}
@@ -1,210 +0,0 @@
import { ref, computed, onUnmounted } from 'vue'
import { calculateCalories, getFoodComparison } from '../utils/calorie'
import type { FoodComparison } from '../utils/calorie'
export type SessionPhase = 'idle' | 'training' | 'resting' | 'done'
export type CrushItemType = 'egg' | 'walnut' | 'can' | 'balloon'
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
onCrushTrigger?: (itemType: CrushItemType) => void
}
export interface TrainingStats {
totalSets: number
totalReps: number
durationSeconds: number
calories: number
foodComparison: FoodComparison
}
// 捏碎特效配置
const CRUSH_TRIGGER_INTERVAL_MIN = 8 // 最小触发间隔
const CRUSH_TRIGGER_INTERVAL_MAX = 12 // 最大触发间隔
const CRUSH_ITEMS: CrushItemType[] = ['egg', 'walnut', 'can', 'balloon']
// 随机触发判断(平均每 8-12 次触发一次)
function shouldTriggerCrush(): boolean {
// 使用随机间隔的平均值:(8 + 12) / 2 = 10
const avgInterval = (CRUSH_TRIGGER_INTERVAL_MIN + CRUSH_TRIGGER_INTERVAL_MAX) / 2
return Math.random() < (1 / avgInterval)
}
// 随机选择物品
function randomCrushItem(): CrushItemType {
return CRUSH_ITEMS[Math.floor(Math.random() * CRUSH_ITEMS.length)]
}
export function useTrainingSession(bpm?: number) {
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
let crushTimers: ReturnType<typeof setTimeout>[] = [] // 存储所有捏碎特效的 setTimeout
let currentBpm = bpm ?? 80
let startTime = 0 // 训练开始时间戳
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 setBpm = (newBpm: number) => {
currentBpm = newBpm
}
const start = (cfg: TrainingSessionConfig) => {
config = cfg
phase.value = 'training'
currentSet.value = 1
currentRep.value = 0
beatCounter.value = 0
restRemainingSec.value = 0
startTime = Date.now() // 记录开始时间
}
// 获取训练统计数据
const getStats = (): TrainingStats => {
const durationSeconds = Math.floor((Date.now() - startTime) / 1000)
const actualTotalReps = (config?.sets ?? 0) * (config?.reps ?? 0)
const calories = calculateCalories({
totalReps: actualTotalReps,
bpm: currentBpm,
userWeight: 60, // 默认 60kg
})
return {
totalSets: config?.sets ?? 0,
totalReps: actualTotalReps,
durationSeconds,
calories,
foodComparison: getFoodComparison(calories),
}
}
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 (shouldTriggerCrush() && config.onCrushTrigger) {
const item = randomCrushItem()
// 延迟到动画峰值(50%)触发
const repDurationMs = (60000 / currentBpm) * beatsPerRep.value
const timer = setTimeout(() => {
config?.onCrushTrigger?.(item)
}, repDurationMs / 2)
crushTimers.push(timer)
}
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
}
// 清理所有待触发的捏碎特效
crushTimers.forEach(timer => clearTimeout(timer))
crushTimers = []
phase.value = 'idle'
currentSet.value = 0
currentRep.value = 0
beatCounter.value = 0
restRemainingSec.value = 0
}
onUnmounted(() => {
if (restTimer) clearInterval(restTimer)
crushTimers.forEach(timer => clearTimeout(timer))
crushTimers = []
})
return {
phase,
currentSet,
currentRep,
restRemainingSec,
totalReps,
totalSets,
isTraining,
isResting,
isDone,
start,
stop,
skipRest,
onBeat,
setBpm,
getStats,
}
}
@@ -1,87 +0,0 @@
import { onUnmounted } from 'vue'
const VOICE_BASE = '/training/static/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,
}
}