update
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user