From 275a7a550d445e86bf0a6c90a3177e0da40357b5 Mon Sep 17 00:00:00 2001 From: long <452591453@qq.com> Date: Mon, 25 May 2026 10:34:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(uniapp):=20=E6=96=B0=E5=A2=9E=E3=80=8C?= =?UTF-8?q?=E7=BB=83=E4=B8=80=E7=BB=83=E3=80=8D=E5=99=A8=E6=A2=B0=E8=B7=9F?= =?UTF-8?q?=E7=BB=83=E6=A8=A1=E5=9D=97=20MVP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 节拍器 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 --- uniapp/scripts/generate-voice.mjs | 213 +++++++ uniapp/src/hooks/useMetronome.ts | 104 ++++ uniapp/src/hooks/useTrainingBgm.ts | 115 ++++ uniapp/src/hooks/useTrainingSession.ts | 133 ++++ uniapp/src/hooks/useVoiceCoach.ts | 87 +++ uniapp/src/pages.json | 6 + .../training/components/exercise-anim.vue | 259 ++++++++ uniapp/src/pages/training/exercises.ts | 116 ++++ uniapp/src/pages/training/index.vue | 583 ++++++++++++++++++ uniapp/src/static/training/README.md | 100 +++ 10 files changed, 1716 insertions(+) create mode 100644 uniapp/scripts/generate-voice.mjs create mode 100644 uniapp/src/hooks/useMetronome.ts create mode 100644 uniapp/src/hooks/useTrainingBgm.ts create mode 100644 uniapp/src/hooks/useTrainingSession.ts create mode 100644 uniapp/src/hooks/useVoiceCoach.ts create mode 100644 uniapp/src/pages/training/components/exercise-anim.vue create mode 100644 uniapp/src/pages/training/exercises.ts create mode 100644 uniapp/src/pages/training/index.vue create mode 100644 uniapp/src/static/training/README.md diff --git a/uniapp/scripts/generate-voice.mjs b/uniapp/scripts/generate-voice.mjs new file mode 100644 index 00000000..e34090c6 --- /dev/null +++ b/uniapp/scripts/generate-voice.mjs @@ -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) +}) diff --git a/uniapp/src/hooks/useMetronome.ts b/uniapp/src/hooks/useMetronome.ts new file mode 100644 index 00000000..d128440f --- /dev/null +++ b/uniapp/src/hooks/useMetronome.ts @@ -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(initialBpm) + const isPlaying = ref(false) + const beatIndex = ref(0) + const isAccent = ref(false) + + const intervalMs = computed(() => 60000 / bpm.value) + + let audioCtx: UniApp.InnerAudioContext | null = null + let timer: ReturnType | 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, + } +} diff --git a/uniapp/src/hooks/useTrainingBgm.ts b/uniapp/src/hooks/useTrainingBgm.ts new file mode 100644 index 00000000..13dfc585 --- /dev/null +++ b/uniapp/src/hooks/useTrainingBgm.ts @@ -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('none') + const enabled = ref(true) + + let trainCtx: UniApp.InnerAudioContext | null = null + let restCtx: UniApp.InnerAudioContext | null = null + let fadeTimers: ReturnType[] = [] + + 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, + } +} diff --git a/uniapp/src/hooks/useTrainingSession.ts b/uniapp/src/hooks/useTrainingSession.ts new file mode 100644 index 00000000..f33499b8 --- /dev/null +++ b/uniapp/src/hooks/useTrainingSession.ts @@ -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('idle') + const currentSet = ref(0) + const currentRep = ref(0) + const restRemainingSec = ref(0) + const beatCounter = ref(0) + + let config: TrainingSessionConfig | null = null + let restTimer: ReturnType | 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, + } +} diff --git a/uniapp/src/hooks/useVoiceCoach.ts b/uniapp/src/hooks/useVoiceCoach.ts new file mode 100644 index 00000000..c17c26d9 --- /dev/null +++ b/uniapp/src/hooks/useVoiceCoach.ts @@ -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, + } +} diff --git a/uniapp/src/pages.json b/uniapp/src/pages.json index 58c29f1b..d02860f2 100644 --- a/uniapp/src/pages.json +++ b/uniapp/src/pages.json @@ -160,6 +160,12 @@ "navigationBarTitleText": "头像裁剪", "navigationBarBackgroundColor": "#000000" } + }, + { + "path": "pages/training/index", + "style": { + "navigationBarTitleText": "练一练" + } } ], "subPackages": [{ diff --git a/uniapp/src/pages/training/components/exercise-anim.vue b/uniapp/src/pages/training/components/exercise-anim.vue new file mode 100644 index 00000000..c53b4d77 --- /dev/null +++ b/uniapp/src/pages/training/components/exercise-anim.vue @@ -0,0 +1,259 @@ + + + + + diff --git a/uniapp/src/pages/training/exercises.ts b/uniapp/src/pages/training/exercises.ts new file mode 100644 index 00000000..b72f9c0f --- /dev/null +++ b/uniapp/src/pages/training/exercises.ts @@ -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) +} diff --git a/uniapp/src/pages/training/index.vue b/uniapp/src/pages/training/index.vue new file mode 100644 index 00000000..4dda44b9 --- /dev/null +++ b/uniapp/src/pages/training/index.vue @@ -0,0 +1,583 @@ + + + + + diff --git a/uniapp/src/static/training/README.md b/uniapp/src/static/training/README.md new file mode 100644 index 00000000..fd9f3c68 --- /dev/null +++ b/uniapp/src/static/training/README.md @@ -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 即可。