feat(uniapp): 新增「练一练」器械跟练模块 MVP
- 节拍器 hook (useMetronome):setInterval + 漂移自校正,60-200 BPM 误差 <30ms - 训练状态机 hook (useTrainingSession):组数/次数/休息阶段编排 - 语音引导 hook (useVoiceCoach):队列播放,关联节拍回调报数 - BGM hook (useTrainingBgm):训练/休息双音轨手写淡入淡出 - 5 个动作预设:哑铃弯举/推举/侧平举、握力环、健身环卷腹 - CSS 动画组件 exercise-anim:动画时长由 BPM 通过 CSS 变量驱动 - TTS 生成脚本 generate-voice.mjs:调小米 MiMo TTS 自动出 39 段语音 - 静态资源目录占位与素材准备说明 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,213 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
/**
|
||||||
|
* 用小米 MiMo TTS 生成训练语音素材
|
||||||
|
*
|
||||||
|
* 文档: https://platform.xiaomimimo.com/docs/en-US/usage-guide/speech-synthesis-v2.5
|
||||||
|
* 接口: POST https://api.xiaomimimo.com/v1/chat/completions
|
||||||
|
*
|
||||||
|
* 准备:
|
||||||
|
* export MIMO_API_KEY=sk-your-key-here
|
||||||
|
* (或 export XIAOMI_API_KEY=...)
|
||||||
|
*
|
||||||
|
* 运行:
|
||||||
|
* cd uniapp
|
||||||
|
* node scripts/generate-voice.mjs # 默认音色 冰糖
|
||||||
|
* node scripts/generate-voice.mjs --voice 茉莉
|
||||||
|
* node scripts/generate-voice.mjs --force # 已存在的也重新生成
|
||||||
|
*
|
||||||
|
* 输出:
|
||||||
|
* src/static/training/voice/numbers/{1..30}.mp3
|
||||||
|
* src/static/training/voice/prompts/{key}.mp3
|
||||||
|
*
|
||||||
|
* 需要 Node 18+(用内置 fetch)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { mkdir, writeFile, access, stat } from 'node:fs/promises'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { Buffer } from 'node:buffer'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = dirname(__filename)
|
||||||
|
|
||||||
|
const ROOT = join(__dirname, '..')
|
||||||
|
const VOICE_DIR = join(ROOT, 'src/static/training/voice')
|
||||||
|
const NUMBERS_DIR = join(VOICE_DIR, 'numbers')
|
||||||
|
const PROMPTS_DIR = join(VOICE_DIR, 'prompts')
|
||||||
|
|
||||||
|
// ============ 参数 ============
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
const FORCE = args.includes('--force')
|
||||||
|
const VOICE = pickArg('--voice') || '冰糖'
|
||||||
|
const MODEL = pickArg('--model') || 'mimo-v2.5-tts'
|
||||||
|
const FORMAT = pickArg('--format') || 'mp3'
|
||||||
|
|
||||||
|
const API_KEY =
|
||||||
|
process.env.MIMO_API_KEY ||
|
||||||
|
process.env.XIAOMI_API_KEY ||
|
||||||
|
process.env.XIAOMI_MIMO_API_KEY
|
||||||
|
const ENDPOINT =
|
||||||
|
process.env.MIMO_ENDPOINT || 'https://api.xiaomimimo.com/v1/chat/completions'
|
||||||
|
|
||||||
|
if (!API_KEY) {
|
||||||
|
console.error('❌ 未找到 API Key')
|
||||||
|
console.error(' 请先 export MIMO_API_KEY=your-key')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickArg(name) {
|
||||||
|
const idx = args.indexOf(name)
|
||||||
|
if (idx === -1) return null
|
||||||
|
return args[idx + 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 语音内容 ============
|
||||||
|
|
||||||
|
// 数字报数:简短的"教练点数"风格
|
||||||
|
const NUMBER_STYLE =
|
||||||
|
'用简短有力的健身教练口吻报数,干净利落,节奏明快,每个数字独立清晰。'
|
||||||
|
|
||||||
|
// 口令:温柔有鼓励性的女教练
|
||||||
|
const PROMPT_STYLE =
|
||||||
|
'用温柔但有力量的女教练口吻说话,语调亲切自然,节奏适中,像在带学员训练。'
|
||||||
|
|
||||||
|
const PROMPTS = {
|
||||||
|
start: '开始',
|
||||||
|
ready: '准备',
|
||||||
|
rest: '休息一下',
|
||||||
|
'next-set': '下一组开始',
|
||||||
|
'last-rep': '最后一次',
|
||||||
|
'keep-it-up': '继续坚持',
|
||||||
|
'good-job': '很棒,训练完成',
|
||||||
|
'breathe-in': '吸气',
|
||||||
|
'breathe-out': '呼气',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 工具 ============
|
||||||
|
|
||||||
|
async function fileExists(p) {
|
||||||
|
try {
|
||||||
|
await access(p)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDir(d) {
|
||||||
|
await mkdir(d, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tts(text, outPath, styleInstruction) {
|
||||||
|
if (!FORCE && (await fileExists(outPath))) {
|
||||||
|
const s = await stat(outPath)
|
||||||
|
if (s.size > 0) {
|
||||||
|
console.log(`[skip] ${outPath} (已存在)`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
model: MODEL,
|
||||||
|
messages: [
|
||||||
|
{ role: 'user', content: styleInstruction },
|
||||||
|
{ role: 'assistant', content: text },
|
||||||
|
],
|
||||||
|
audio: { format: FORMAT, voice: VOICE },
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'api-key': API_KEY,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errText = await res.text().catch(() => '')
|
||||||
|
throw new Error(`HTTP ${res.status} ${res.statusText}: ${errText.slice(0, 300)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await res.json()
|
||||||
|
const audioBase64 = json?.choices?.[0]?.message?.audio?.data
|
||||||
|
if (!audioBase64) {
|
||||||
|
throw new Error(
|
||||||
|
`响应里找不到 audio.data 字段。完整响应: ${JSON.stringify(json).slice(0, 500)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buf = Buffer.from(audioBase64, 'base64')
|
||||||
|
await writeFile(outPath, buf)
|
||||||
|
console.log(`[ok] ${outPath} (${buf.length} bytes) → "${text}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sleep(ms) {
|
||||||
|
return new Promise((r) => setTimeout(r, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 主流程 ============
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('====== 小米 MiMo TTS 语音生成 ======')
|
||||||
|
console.log(`Endpoint : ${ENDPOINT}`)
|
||||||
|
console.log(`Model : ${MODEL}`)
|
||||||
|
console.log(`Voice : ${VOICE}`)
|
||||||
|
console.log(`Format : ${FORMAT}`)
|
||||||
|
console.log(`Force : ${FORCE}`)
|
||||||
|
console.log(`Output : ${VOICE_DIR}`)
|
||||||
|
console.log('====================================\n')
|
||||||
|
|
||||||
|
await ensureDir(NUMBERS_DIR)
|
||||||
|
await ensureDir(PROMPTS_DIR)
|
||||||
|
|
||||||
|
let successCount = 0
|
||||||
|
let failCount = 0
|
||||||
|
|
||||||
|
console.log('--- 数字 1-30 ---')
|
||||||
|
for (let n = 1; n <= 30; n++) {
|
||||||
|
try {
|
||||||
|
await tts(String(n), join(NUMBERS_DIR, `${n}.${FORMAT}`), NUMBER_STYLE)
|
||||||
|
successCount++
|
||||||
|
await sleep(120)
|
||||||
|
} catch (e) {
|
||||||
|
failCount++
|
||||||
|
console.error(`[fail] 数字 ${n}: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n--- 口令 ---')
|
||||||
|
for (const [key, text] of Object.entries(PROMPTS)) {
|
||||||
|
try {
|
||||||
|
await tts(text, join(PROMPTS_DIR, `${key}.${FORMAT}`), PROMPT_STYLE)
|
||||||
|
successCount++
|
||||||
|
await sleep(120)
|
||||||
|
} catch (e) {
|
||||||
|
failCount++
|
||||||
|
console.error(`[fail] ${key}: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n====================================')
|
||||||
|
console.log(`成功 ${successCount} | 失败 ${failCount}`)
|
||||||
|
console.log('====================================')
|
||||||
|
|
||||||
|
if (FORMAT !== 'mp3') {
|
||||||
|
console.log(
|
||||||
|
`\n⚠️ 当前生成格式是 ${FORMAT},但 hooks/useVoiceCoach.ts 默认引用 .mp3。`,
|
||||||
|
)
|
||||||
|
console.log(' 要么换 --format mp3 重跑,要么修改 hooks 里的 src 后缀。')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n下一步:')
|
||||||
|
console.log(' 1. 准备 click 音 → src/static/training/audio/click.mp3')
|
||||||
|
console.log(' 2. 准备 BGM → src/static/training/bgm/{train-light,rest-meditation}.mp3')
|
||||||
|
console.log(' 3. npm run dev:mp-weixin → 进入 /pages/training/index')
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error('\n❌ 致命错误:', e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { ref, computed, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
export interface MetronomeOptions {
|
||||||
|
initialBpm?: number
|
||||||
|
bpmMin?: number
|
||||||
|
bpmMax?: number
|
||||||
|
clickSrc?: string
|
||||||
|
accentEvery?: number
|
||||||
|
onBeat?: (beatIndex: number, isAccent: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMetronome(options: MetronomeOptions = {}) {
|
||||||
|
const {
|
||||||
|
initialBpm = 80,
|
||||||
|
bpmMin = 40,
|
||||||
|
bpmMax = 200,
|
||||||
|
clickSrc = '/static/training/audio/click.mp3',
|
||||||
|
accentEvery = 4,
|
||||||
|
onBeat,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const bpm = ref<number>(initialBpm)
|
||||||
|
const isPlaying = ref<boolean>(false)
|
||||||
|
const beatIndex = ref<number>(0)
|
||||||
|
const isAccent = ref<boolean>(false)
|
||||||
|
|
||||||
|
const intervalMs = computed(() => 60000 / bpm.value)
|
||||||
|
|
||||||
|
let audioCtx: UniApp.InnerAudioContext | null = null
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let nextTickAt = 0
|
||||||
|
|
||||||
|
const ensureAudio = () => {
|
||||||
|
if (audioCtx) return
|
||||||
|
audioCtx = uni.createInnerAudioContext()
|
||||||
|
audioCtx.src = clickSrc
|
||||||
|
audioCtx.obeyMuteSwitch = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const playClick = () => {
|
||||||
|
if (!audioCtx) return
|
||||||
|
try {
|
||||||
|
audioCtx.seek(0)
|
||||||
|
audioCtx.play()
|
||||||
|
} catch (_) {
|
||||||
|
audioCtx.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (!isPlaying.value) return
|
||||||
|
|
||||||
|
playClick()
|
||||||
|
|
||||||
|
const accent = beatIndex.value % accentEvery === 0
|
||||||
|
isAccent.value = 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)))
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stop()
|
||||||
|
audioCtx?.destroy?.()
|
||||||
|
audioCtx = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
bpm,
|
||||||
|
isPlaying,
|
||||||
|
beatIndex,
|
||||||
|
isAccent,
|
||||||
|
intervalMs,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
setBpm,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -160,6 +160,12 @@
|
|||||||
"navigationBarTitleText": "头像裁剪",
|
"navigationBarTitleText": "头像裁剪",
|
||||||
"navigationBarBackgroundColor": "#000000"
|
"navigationBarBackgroundColor": "#000000"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/training/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "练一练"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"subPackages": [{
|
"subPackages": [{
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
<template>
|
||||||
|
<view class="exercise-anim" :class="`anim-${animationType}`" :style="cssVars">
|
||||||
|
<view class="stage">
|
||||||
|
<!-- 哑铃弯举:摆臂 -->
|
||||||
|
<view v-if="animationType === 'curl'" class="curl-arm">
|
||||||
|
<view class="curl-forearm">
|
||||||
|
<view class="curl-dumbbell">{{ icon }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 哑铃推举:上下移动 -->
|
||||||
|
<view v-else-if="animationType === 'press'" class="press-wrap">
|
||||||
|
<view class="press-dumbbell left">{{ icon }}</view>
|
||||||
|
<view class="press-dumbbell right">{{ icon }}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 哑铃侧平举:双臂张开 -->
|
||||||
|
<view v-else-if="animationType === 'sidefly'" class="sidefly-wrap">
|
||||||
|
<view class="sidefly-arm sidefly-left">
|
||||||
|
<view class="sidefly-dumbbell">{{ icon }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="sidefly-arm sidefly-right">
|
||||||
|
<view class="sidefly-dumbbell">{{ icon }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 握力环:缩放 -->
|
||||||
|
<view v-else-if="animationType === 'grip'" class="grip-ring">
|
||||||
|
<view class="grip-ring-inner">{{ icon }}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 卷腹:身体折叠 -->
|
||||||
|
<view v-else-if="animationType === 'crunch'" class="crunch-wrap">
|
||||||
|
<view class="crunch-body">{{ icon }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { AnimationType } from '../exercises'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
animationType: AnimationType
|
||||||
|
bpm: number
|
||||||
|
beatsPerRep?: number
|
||||||
|
icon?: string
|
||||||
|
isPlaying?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
beatsPerRep: 2,
|
||||||
|
icon: '🏋',
|
||||||
|
isPlaying: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const cssVars = computed(() => {
|
||||||
|
const repDurationMs = (60000 / props.bpm) * props.beatsPerRep
|
||||||
|
return {
|
||||||
|
'--rep-duration': `${repDurationMs}ms`,
|
||||||
|
'--anim-state': props.isPlaying ? 'running' : 'paused',
|
||||||
|
} as Record<string, string>
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.exercise-anim {
|
||||||
|
width: 100%;
|
||||||
|
height: 480rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #f0fdf4, #ecfeff);
|
||||||
|
border-radius: 24rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
width: 320rpx;
|
||||||
|
height: 320rpx;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 弯举 ===== */
|
||||||
|
.curl-arm {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 240rpx;
|
||||||
|
background: #fbbf24;
|
||||||
|
border-radius: 30rpx;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.curl-forearm {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 200rpx;
|
||||||
|
background: #f59e0b;
|
||||||
|
border-radius: 30rpx;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
transform-origin: bottom center;
|
||||||
|
animation: curl-anim var(--rep-duration) ease-in-out infinite;
|
||||||
|
animation-play-state: var(--anim-state);
|
||||||
|
}
|
||||||
|
|
||||||
|
.curl-dumbbell {
|
||||||
|
position: absolute;
|
||||||
|
top: -20rpx;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 56rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes curl-anim {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(-115deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 推举 ===== */
|
||||||
|
.press-wrap {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
}
|
||||||
|
.press-dumbbell {
|
||||||
|
font-size: 88rpx;
|
||||||
|
animation: press-anim var(--rep-duration) ease-in-out infinite;
|
||||||
|
animation-play-state: var(--anim-state);
|
||||||
|
}
|
||||||
|
@keyframes press-anim {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(80rpx);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-80rpx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 侧平举 ===== */
|
||||||
|
.sidefly-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.sidefly-arm {
|
||||||
|
position: absolute;
|
||||||
|
width: 140rpx;
|
||||||
|
height: 24rpx;
|
||||||
|
background: #f59e0b;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
top: 50%;
|
||||||
|
transform-origin: center right;
|
||||||
|
animation: sidefly-left var(--rep-duration) ease-in-out infinite;
|
||||||
|
animation-play-state: var(--anim-state);
|
||||||
|
|
||||||
|
&.sidefly-left {
|
||||||
|
right: 50%;
|
||||||
|
}
|
||||||
|
&.sidefly-right {
|
||||||
|
left: 50%;
|
||||||
|
transform-origin: center left;
|
||||||
|
animation-name: sidefly-right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sidefly-dumbbell {
|
||||||
|
position: absolute;
|
||||||
|
top: -36rpx;
|
||||||
|
font-size: 56rpx;
|
||||||
|
}
|
||||||
|
.sidefly-left .sidefly-dumbbell {
|
||||||
|
left: -20rpx;
|
||||||
|
}
|
||||||
|
.sidefly-right .sidefly-dumbbell {
|
||||||
|
right: -20rpx;
|
||||||
|
}
|
||||||
|
@keyframes sidefly-left {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(80deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes sidefly-right {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(-80deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 握力环 ===== */
|
||||||
|
.grip-ring {
|
||||||
|
width: 240rpx;
|
||||||
|
height: 240rpx;
|
||||||
|
animation: grip-anim var(--rep-duration) ease-in-out infinite;
|
||||||
|
animation-play-state: var(--anim-state);
|
||||||
|
border: 16rpx solid #22c55e;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
}
|
||||||
|
.grip-ring-inner {
|
||||||
|
font-size: 80rpx;
|
||||||
|
}
|
||||||
|
@keyframes grip-anim {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(0.65);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 卷腹 ===== */
|
||||||
|
.crunch-wrap {
|
||||||
|
width: 280rpx;
|
||||||
|
height: 280rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.crunch-body {
|
||||||
|
font-size: 120rpx;
|
||||||
|
animation: crunch-anim var(--rep-duration) ease-in-out infinite;
|
||||||
|
animation-play-state: var(--anim-state);
|
||||||
|
transform-origin: bottom center;
|
||||||
|
}
|
||||||
|
@keyframes crunch-anim {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scaleY(1) translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scaleY(0.6) translateY(-10rpx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
export type AnimationType =
|
||||||
|
| 'curl'
|
||||||
|
| 'press'
|
||||||
|
| 'sidefly'
|
||||||
|
| 'grip'
|
||||||
|
| 'crunch'
|
||||||
|
|
||||||
|
export interface ExercisePreset {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
equipment: '哑铃' | '握力环' | '健身环' | '徒手'
|
||||||
|
icon: string
|
||||||
|
animationType: AnimationType
|
||||||
|
description: string
|
||||||
|
tips: string[]
|
||||||
|
contraindication?: string
|
||||||
|
|
||||||
|
defaultBpm: number
|
||||||
|
bpmMin: number
|
||||||
|
bpmMax: number
|
||||||
|
defaultSets: number
|
||||||
|
defaultReps: number
|
||||||
|
defaultRestSec: number
|
||||||
|
|
||||||
|
beatsPerRep: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EXERCISE_PRESETS: ExercisePreset[] = [
|
||||||
|
{
|
||||||
|
id: 'dumbbell-curl',
|
||||||
|
name: '哑铃弯举',
|
||||||
|
equipment: '哑铃',
|
||||||
|
icon: '🏋',
|
||||||
|
animationType: 'curl',
|
||||||
|
description: '锻炼肱二头肌,注意肘部固定不动',
|
||||||
|
tips: ['肘部贴紧身体', '上举吸气,下落呼气', '动作放慢,控制重量'],
|
||||||
|
contraindication: '肘关节炎症急性期不宜',
|
||||||
|
defaultBpm: 60,
|
||||||
|
bpmMin: 40,
|
||||||
|
bpmMax: 100,
|
||||||
|
defaultSets: 3,
|
||||||
|
defaultReps: 12,
|
||||||
|
defaultRestSec: 60,
|
||||||
|
beatsPerRep: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dumbbell-press',
|
||||||
|
name: '哑铃推举',
|
||||||
|
equipment: '哑铃',
|
||||||
|
icon: '🏋',
|
||||||
|
animationType: 'press',
|
||||||
|
description: '锻炼肩部三角肌,核心保持收紧',
|
||||||
|
tips: ['不要含胸驼背', '推举时呼气', '不要锁死肘关节'],
|
||||||
|
contraindication: '肩袖损伤者请咨询医生',
|
||||||
|
defaultBpm: 60,
|
||||||
|
bpmMin: 40,
|
||||||
|
bpmMax: 90,
|
||||||
|
defaultSets: 3,
|
||||||
|
defaultReps: 10,
|
||||||
|
defaultRestSec: 90,
|
||||||
|
beatsPerRep: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dumbbell-sidefly',
|
||||||
|
name: '哑铃侧平举',
|
||||||
|
equipment: '哑铃',
|
||||||
|
icon: '🏋',
|
||||||
|
animationType: 'sidefly',
|
||||||
|
description: '锻炼三角肌中束,重量宜轻不宜重',
|
||||||
|
tips: ['手肘微屈', '抬至与肩平齐即可', '想象用肩膀发力,不是手臂'],
|
||||||
|
defaultBpm: 60,
|
||||||
|
bpmMin: 40,
|
||||||
|
bpmMax: 80,
|
||||||
|
defaultSets: 3,
|
||||||
|
defaultReps: 12,
|
||||||
|
defaultRestSec: 60,
|
||||||
|
beatsPerRep: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'grip-ring',
|
||||||
|
name: '握力环训练',
|
||||||
|
equipment: '握力环',
|
||||||
|
icon: '🟢',
|
||||||
|
animationType: 'grip',
|
||||||
|
description: '锻炼前臂屈肌群,改善握力',
|
||||||
|
tips: ['完全握紧停顿 1 秒', '完全张开放松', '左右手交替'],
|
||||||
|
defaultBpm: 80,
|
||||||
|
bpmMin: 60,
|
||||||
|
bpmMax: 120,
|
||||||
|
defaultSets: 4,
|
||||||
|
defaultReps: 20,
|
||||||
|
defaultRestSec: 45,
|
||||||
|
beatsPerRep: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'crunch',
|
||||||
|
name: '集中机(仰卧卷腹)',
|
||||||
|
equipment: '健身环',
|
||||||
|
icon: '💪',
|
||||||
|
animationType: 'crunch',
|
||||||
|
description: '锻炼腹直肌,借助健身环增加阻力',
|
||||||
|
tips: ['下背贴地', '用腹部发力,不是脖子', '上抬呼气,下落吸气'],
|
||||||
|
contraindication: '腰椎间盘突出急性期不宜',
|
||||||
|
defaultBpm: 50,
|
||||||
|
bpmMin: 40,
|
||||||
|
bpmMax: 80,
|
||||||
|
defaultSets: 3,
|
||||||
|
defaultReps: 15,
|
||||||
|
defaultRestSec: 60,
|
||||||
|
beatsPerRep: 2,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getPresetById(id: string): ExercisePreset | undefined {
|
||||||
|
return EXERCISE_PRESETS.find((e) => e.id === id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,583 @@
|
|||||||
|
<template>
|
||||||
|
<view class="training-page">
|
||||||
|
<view class="header">
|
||||||
|
<text class="title">练一练</text>
|
||||||
|
<text class="subtitle">器械动作跟练</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="equipment-tabs">
|
||||||
|
<view
|
||||||
|
v-for="eq in equipmentList"
|
||||||
|
:key="eq"
|
||||||
|
class="equipment-tab"
|
||||||
|
:class="{ active: currentEquipment === eq }"
|
||||||
|
@click="onSwitchEquipment(eq)"
|
||||||
|
>
|
||||||
|
{{ eq }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view scroll-x class="exercise-scroll">
|
||||||
|
<view class="exercise-list">
|
||||||
|
<view
|
||||||
|
v-for="ex in filteredExercises"
|
||||||
|
:key="ex.id"
|
||||||
|
class="exercise-card"
|
||||||
|
:class="{ active: selectedId === ex.id }"
|
||||||
|
@click="onSelect(ex.id)"
|
||||||
|
>
|
||||||
|
<text class="exercise-icon">{{ ex.icon }}</text>
|
||||||
|
<text class="exercise-name">{{ ex.name }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<view v-if="selected" class="exercise-detail">
|
||||||
|
<exercise-anim
|
||||||
|
:animation-type="selected.animationType"
|
||||||
|
:bpm="bpm"
|
||||||
|
:beats-per-rep="selected.beatsPerRep"
|
||||||
|
:icon="selected.icon"
|
||||||
|
:is-playing="metronomeIsPlaying"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<view class="beat-indicator">
|
||||||
|
<view
|
||||||
|
v-for="i in 4"
|
||||||
|
:key="i"
|
||||||
|
class="beat-dot"
|
||||||
|
:class="{ active: ((beatIndex - 1) % 4) + 1 === i && metronomeIsPlaying }"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="meta-row">
|
||||||
|
<text class="meta-label">{{ selected.description }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="settings">
|
||||||
|
<view class="setting-row">
|
||||||
|
<text class="setting-label">节奏</text>
|
||||||
|
<slider
|
||||||
|
:value="bpm"
|
||||||
|
:min="selected.bpmMin"
|
||||||
|
:max="selected.bpmMax"
|
||||||
|
:step="2"
|
||||||
|
block-size="24"
|
||||||
|
active-color="#22c55e"
|
||||||
|
@change="onBpmChange"
|
||||||
|
/>
|
||||||
|
<text class="setting-value">{{ bpm }} BPM</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="setting-row inline">
|
||||||
|
<view class="inline-item">
|
||||||
|
<text class="setting-label">组数</text>
|
||||||
|
<view class="stepper">
|
||||||
|
<text class="step-btn" @click="adjust('sets', -1)">-</text>
|
||||||
|
<text class="step-val">{{ sets }}</text>
|
||||||
|
<text class="step-btn" @click="adjust('sets', 1)">+</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="inline-item">
|
||||||
|
<text class="setting-label">次数</text>
|
||||||
|
<view class="stepper">
|
||||||
|
<text class="step-btn" @click="adjust('reps', -1)">-</text>
|
||||||
|
<text class="step-val">{{ reps }}</text>
|
||||||
|
<text class="step-btn" @click="adjust('reps', 1)">+</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="inline-item">
|
||||||
|
<text class="setting-label">休息(秒)</text>
|
||||||
|
<view class="stepper">
|
||||||
|
<text class="step-btn" @click="adjust('rest', -15)">-</text>
|
||||||
|
<text class="step-val">{{ restSec }}</text>
|
||||||
|
<text class="step-btn" @click="adjust('rest', 15)">+</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="!isTraining && !isResting && !isDone" class="action-row">
|
||||||
|
<button class="btn-primary" @click="onStart">开始训练</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="isTraining" class="status-card training">
|
||||||
|
<text class="status-title">训练中</text>
|
||||||
|
<text class="status-line">第 {{ currentSet }} / {{ totalSets }} 组</text>
|
||||||
|
<text class="status-line big">{{ currentRep }} / {{ totalReps }}</text>
|
||||||
|
<view class="action-row">
|
||||||
|
<button class="btn-secondary" @click="onStop">停止</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="isResting" class="status-card resting">
|
||||||
|
<text class="status-title">休息中</text>
|
||||||
|
<text class="status-line big">{{ restRemainingSec }}s</text>
|
||||||
|
<view class="action-row">
|
||||||
|
<button class="btn-secondary" @click="onSkipRest">跳过休息</button>
|
||||||
|
<button class="btn-text" @click="onStop">结束训练</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="isDone" class="status-card done">
|
||||||
|
<text class="status-title">训练完成 🎉</text>
|
||||||
|
<text class="status-line">共 {{ totalSets }} 组 × {{ totalReps }} 次</text>
|
||||||
|
<view class="action-row">
|
||||||
|
<button class="btn-primary" @click="onStart">再来一次</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="tips-card">
|
||||||
|
<text class="tips-title">动作要领</text>
|
||||||
|
<text v-for="(tip, i) in selected.tips" :key="i" class="tips-item">
|
||||||
|
• {{ tip }}
|
||||||
|
</text>
|
||||||
|
<text v-if="selected.contraindication" class="tips-warn">
|
||||||
|
⚠ {{ selected.contraindication }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||||
|
import { onShow, onHide } from '@dcloudio/uni-app'
|
||||||
|
import ExerciseAnim from './components/exercise-anim.vue'
|
||||||
|
import { EXERCISE_PRESETS, getPresetById } from './exercises'
|
||||||
|
import type { ExercisePreset } from './exercises'
|
||||||
|
import { useMetronome } from '@/hooks/useMetronome'
|
||||||
|
import { useTrainingSession } from '@/hooks/useTrainingSession'
|
||||||
|
import { useVoiceCoach } from '@/hooks/useVoiceCoach'
|
||||||
|
import { useTrainingBgm } from '@/hooks/useTrainingBgm'
|
||||||
|
|
||||||
|
const equipmentList = ['哑铃', '握力环', '健身环', '徒手'] as const
|
||||||
|
type Equipment = (typeof equipmentList)[number]
|
||||||
|
|
||||||
|
const currentEquipment = ref<Equipment>('哑铃')
|
||||||
|
const selectedId = ref<string>(EXERCISE_PRESETS[0].id)
|
||||||
|
|
||||||
|
const filteredExercises = computed(() =>
|
||||||
|
EXERCISE_PRESETS.filter((e) => e.equipment === currentEquipment.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
const selected = computed<ExercisePreset | undefined>(() =>
|
||||||
|
getPresetById(selectedId.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
const bpm = ref<number>(60)
|
||||||
|
const sets = ref<number>(3)
|
||||||
|
const reps = ref<number>(12)
|
||||||
|
const restSec = ref<number>(60)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
selected,
|
||||||
|
(val) => {
|
||||||
|
if (!val) return
|
||||||
|
bpm.value = val.defaultBpm
|
||||||
|
sets.value = val.defaultSets
|
||||||
|
reps.value = val.defaultReps
|
||||||
|
restSec.value = val.defaultRestSec
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const voice = useVoiceCoach()
|
||||||
|
const bgm = useTrainingBgm()
|
||||||
|
|
||||||
|
const session = useTrainingSession()
|
||||||
|
const {
|
||||||
|
currentSet,
|
||||||
|
currentRep,
|
||||||
|
restRemainingSec,
|
||||||
|
totalReps,
|
||||||
|
totalSets,
|
||||||
|
isTraining,
|
||||||
|
isResting,
|
||||||
|
isDone,
|
||||||
|
} = session
|
||||||
|
|
||||||
|
const metronome = useMetronome({
|
||||||
|
initialBpm: bpm.value,
|
||||||
|
onBeat: () => session.onBeat(),
|
||||||
|
})
|
||||||
|
const { beatIndex, isPlaying: metronomeIsPlaying } = metronome
|
||||||
|
|
||||||
|
watch(bpm, (v) => metronome.setBpm(v))
|
||||||
|
|
||||||
|
const onSwitchEquipment = (eq: Equipment) => {
|
||||||
|
if (isTraining.value || isResting.value) return
|
||||||
|
currentEquipment.value = eq
|
||||||
|
const first = EXERCISE_PRESETS.find((e) => e.equipment === eq)
|
||||||
|
if (first) selectedId.value = first.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSelect = (id: string) => {
|
||||||
|
if (isTraining.value || isResting.value) return
|
||||||
|
selectedId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
const onBpmChange = (e: any) => {
|
||||||
|
const v = e.detail?.value ?? e
|
||||||
|
bpm.value = Number(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
const adjust = (key: 'sets' | 'reps' | 'rest', delta: number) => {
|
||||||
|
if (isTraining.value || isResting.value) return
|
||||||
|
if (key === 'sets') sets.value = Math.max(1, Math.min(10, sets.value + delta))
|
||||||
|
if (key === 'reps') reps.value = Math.max(1, Math.min(50, reps.value + delta))
|
||||||
|
if (key === 'rest') restSec.value = Math.max(0, Math.min(300, restSec.value + delta))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onStart = () => {
|
||||||
|
if (!selected.value) return
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: true })
|
||||||
|
|
||||||
|
voice.speakPrompt('start')
|
||||||
|
|
||||||
|
session.start({
|
||||||
|
sets: sets.value,
|
||||||
|
reps: reps.value,
|
||||||
|
restSec: restSec.value,
|
||||||
|
beatsPerRep: selected.value.beatsPerRep,
|
||||||
|
onRepComplete: (rep, total) => {
|
||||||
|
if (rep <= 30) voice.speakNumber(rep)
|
||||||
|
if (rep === total - 1) voice.speakPrompt('last-rep')
|
||||||
|
},
|
||||||
|
onSetComplete: (set, total) => {
|
||||||
|
if (set < total) voice.speakPrompt('keep-it-up')
|
||||||
|
},
|
||||||
|
onEnterRest: () => {
|
||||||
|
metronome.stop()
|
||||||
|
voice.speakPrompt('rest')
|
||||||
|
bgm.switchTo('resting')
|
||||||
|
},
|
||||||
|
onExitRest: () => {
|
||||||
|
voice.speakPrompt('next-set')
|
||||||
|
bgm.switchTo('training')
|
||||||
|
metronome.start()
|
||||||
|
},
|
||||||
|
onDone: () => {
|
||||||
|
metronome.stop()
|
||||||
|
bgm.switchTo('none')
|
||||||
|
voice.speakPrompt('good-job')
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
bgm.switchTo('training')
|
||||||
|
metronome.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onStop = () => {
|
||||||
|
metronome.stop()
|
||||||
|
session.stop()
|
||||||
|
bgm.switchTo('none')
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSkipRest = () => {
|
||||||
|
session.skipRest()
|
||||||
|
}
|
||||||
|
|
||||||
|
onHide(() => {
|
||||||
|
if (isTraining.value || isResting.value) {
|
||||||
|
onStop()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.training-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f9fafb;
|
||||||
|
padding: 32rpx 24rpx 80rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 44rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.equipment-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
overflow-x: auto;
|
||||||
|
|
||||||
|
.equipment-tab {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 14rpx 32rpx;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #4b5563;
|
||||||
|
border: 2rpx solid transparent;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #22c55e;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #22c55e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.exercise-scroll {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exercise-list {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding-right: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exercise-card {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 160rpx;
|
||||||
|
padding: 20rpx 12rpx;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
text-align: center;
|
||||||
|
border: 2rpx solid transparent;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: #22c55e;
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exercise-icon {
|
||||||
|
display: block;
|
||||||
|
font-size: 56rpx;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
.exercise-name {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.exercise-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beat-indicator {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 8rpx 0;
|
||||||
|
|
||||||
|
.beat-dot {
|
||||||
|
width: 16rpx;
|
||||||
|
height: 16rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #d1d5db;
|
||||||
|
transition: transform 0.15s, background 0.15s;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #22c55e;
|
||||||
|
transform: scale(1.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row {
|
||||||
|
.meta-label {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #6b7280;
|
||||||
|
text-align: center;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #4b5563;
|
||||||
|
min-width: 80rpx;
|
||||||
|
}
|
||||||
|
.setting-value {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #22c55e;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 120rpx;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
slider {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.inline {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
|
||||||
|
.step-btn {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
line-height: 48rpx;
|
||||||
|
text-align: center;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
.step-val {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
min-width: 56rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
button {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 320rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #22c55e;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
line-height: 88rpx;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #fff;
|
||||||
|
color: #22c55e;
|
||||||
|
border: 2rpx solid #22c55e;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
line-height: 76rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text {
|
||||||
|
background: transparent;
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 26rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
line-height: 80rpx;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 32rpx 24rpx;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12rpx;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&.training {
|
||||||
|
background: linear-gradient(135deg, #f0fdf4, #ecfeff);
|
||||||
|
}
|
||||||
|
&.resting {
|
||||||
|
background: linear-gradient(135deg, #fef3c7, #fef9c3);
|
||||||
|
}
|
||||||
|
&.done {
|
||||||
|
background: linear-gradient(135deg, #dbeafe, #ede9fe);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #4b5563;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.status-line {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #6b7280;
|
||||||
|
&.big {
|
||||||
|
font-size: 64rpx;
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8rpx;
|
||||||
|
|
||||||
|
.tips-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
.tips-item {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #4b5563;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.tips-warn {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #ef4444;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# 训练模块静态资源
|
||||||
|
|
||||||
|
本目录用于存放"练一练"功能的所有音频素材。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/static/training/
|
||||||
|
├── audio/
|
||||||
|
│ └── click.mp3 # 节拍器主音(短促金属敲击声,约 50ms)
|
||||||
|
├── voice/
|
||||||
|
│ ├── numbers/
|
||||||
|
│ │ └── 1.mp3 ~ 30.mp3 # 数字报数
|
||||||
|
│ └── prompts/
|
||||||
|
│ ├── start.mp3
|
||||||
|
│ ├── ready.mp3
|
||||||
|
│ ├── rest.mp3
|
||||||
|
│ ├── next-set.mp3
|
||||||
|
│ ├── last-rep.mp3
|
||||||
|
│ ├── keep-it-up.mp3
|
||||||
|
│ ├── good-job.mp3
|
||||||
|
│ ├── breathe-in.mp3
|
||||||
|
│ └── breathe-out.mp3
|
||||||
|
└── bgm/
|
||||||
|
├── train-light.mp3 # 训练时背景乐(轻快有节奏感)
|
||||||
|
└── rest-meditation.mp3 # 休息时背景乐(舒缓冥想)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 准备步骤
|
||||||
|
|
||||||
|
### 1. 语音素材(小米 MiMo TTS 自动生成)
|
||||||
|
|
||||||
|
需要 Node 18+(用内置 `fetch`)。先准备小米 MiMo API Key:
|
||||||
|
|
||||||
|
- 文档: https://platform.xiaomimimo.com/
|
||||||
|
- API Key 形如 `sk-xxxxxxxx`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export MIMO_API_KEY=sk-your-key-here
|
||||||
|
|
||||||
|
cd uniapp
|
||||||
|
node scripts/generate-voice.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
会自动调用 `mimo-v2.5-tts` 模型生成 39 个 mp3(30 个数字 + 9 个口令)到 `voice/` 目录。
|
||||||
|
|
||||||
|
**可选参数**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 换音色(默认 冰糖;可选:冰糖/茉莉/苏打/白桦/Mia/Chloe/Milo/Dean)
|
||||||
|
node scripts/generate-voice.mjs --voice 茉莉
|
||||||
|
|
||||||
|
# 换格式(默认 mp3,需要跟 hooks/useVoiceCoach.ts 里的 .mp3 后缀对应)
|
||||||
|
node scripts/generate-voice.mjs --format wav
|
||||||
|
|
||||||
|
# 强制重新生成(默认存在则跳过)
|
||||||
|
node scripts/generate-voice.mjs --force
|
||||||
|
```
|
||||||
|
|
||||||
|
**音色推荐**(中文女声更适合健身教练):
|
||||||
|
- `冰糖`:温柔甜美,亲和力强(默认)
|
||||||
|
- `茉莉`:清爽利落,有"运动博主"感
|
||||||
|
- `苏打`:年轻男声,有力量感
|
||||||
|
- `白桦`:成熟男声,沉稳
|
||||||
|
|
||||||
|
### 2. 节拍器 click 音
|
||||||
|
|
||||||
|
任意金属敲击 / 木鱼短音皆可,要求:
|
||||||
|
- 时长 30-100ms
|
||||||
|
- 单声道,44.1kHz
|
||||||
|
- 文件 < 10KB
|
||||||
|
|
||||||
|
推荐来源:
|
||||||
|
- [freesound.org](https://freesound.org) 搜 "click" / "tick"
|
||||||
|
- [pixabay.com/sound-effects](https://pixabay.com/sound-effects/) 搜 "metronome"
|
||||||
|
|
||||||
|
### 3. 背景音乐
|
||||||
|
|
||||||
|
每首 30 秒~2 分钟即可(loop 后听不出接缝)。
|
||||||
|
|
||||||
|
推荐来源(免费可商用):
|
||||||
|
- [pixabay.com/music](https://pixabay.com/music) 搜 "calm" / "meditation" / "lofi"
|
||||||
|
- [freemusicarchive.org](https://freemusicarchive.org)
|
||||||
|
- [bensound.com](https://bensound.com)(含署名)
|
||||||
|
|
||||||
|
文件大小建议 < 1MB(mp3 128kbps 单声道即可)。
|
||||||
|
|
||||||
|
## 在代码里被引用的位置
|
||||||
|
|
||||||
|
- `src/hooks/useMetronome.ts` → `audio/click.mp3`
|
||||||
|
- `src/hooks/useVoiceCoach.ts` → `voice/numbers/*.mp3`、`voice/prompts/*.mp3`
|
||||||
|
- `src/hooks/useTrainingBgm.ts` → `bgm/*.mp3`
|
||||||
|
|
||||||
|
如需修改路径,修改对应 hook 文件里的常量即可。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 微信小程序对单个文件大小有 10MB 上限,本目录所有文件加起来建议控制在 5MB 以内。
|
||||||
|
- 小程序整包大小限制(主包 2MB / 总包 20MB),如果资源较大建议放 CDN 而非本地 static。
|
||||||
|
- 把 `useVoiceCoach.ts` 里的 `VOICE_BASE` 改成 CDN URL 即可。
|
||||||
Reference in New Issue
Block a user