diff --git a/uniapp/scripts/generate-voice.mjs b/uniapp/scripts/generate-voice.mjs deleted file mode 100644 index e34090c6..00000000 --- a/uniapp/scripts/generate-voice.mjs +++ /dev/null @@ -1,213 +0,0 @@ -#!/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/components/dev-training-entry/index.vue b/uniapp/src/components/dev-training-entry/index.vue deleted file mode 100644 index 487e52d1..00000000 --- a/uniapp/src/components/dev-training-entry/index.vue +++ /dev/null @@ -1,172 +0,0 @@ - - - - - diff --git a/uniapp/src/hooks/useMetronome.ts b/uniapp/src/hooks/useMetronome.ts deleted file mode 100644 index 8bbaa905..00000000 --- a/uniapp/src/hooks/useMetronome.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { ref, computed, onUnmounted } from 'vue' - -export interface MetronomeOptions { - initialBpm?: number - bpmMin?: number - bpmMax?: number - clickSrc?: string - accentSrc?: string - accentEvery?: number - poolSize?: number - onBeat?: (beatIndex: number, isAccent: boolean) => void -} - -/** - * 音频实例池:每个 click 用一个独立 InnerAudioContext,循环复用。 - * 解决两个问题: - * 1. seek+play 模式在某些设备上首拍冷启动延迟(100-300ms) - * 2. BPM 偏快时上一拍音频还没播完,下一拍 play 会被阻塞或丢失 - */ -class AudioPool { - private list: UniApp.InnerAudioContext[] = [] - private cursor = 0 - private warmedUp = false - - constructor( - private src: string, - private size: number, - ) {} - - private create() { - for (let i = 0; i < this.size; i++) { - const ctx = uni.createInnerAudioContext() - ctx.src = this.src - ctx.obeyMuteSwitch = false - ctx.autoplay = false - this.list.push(ctx) - } - } - - /** - * 预热:静音播放一次每个实例,让音频解码完成 - * 这样后续 play() 时就是热启动,没有冷启动延迟 - */ - warmUp() { - if (this.warmedUp) return - if (this.list.length === 0) this.create() - - this.list.forEach((ctx) => { - const originalVolume = ctx.volume - ctx.volume = 0 - try { - ctx.play() - setTimeout(() => { - try { - ctx.stop() - ctx.volume = originalVolume === 0 ? 1 : originalVolume - } catch (_) {} - }, 80) - } catch (_) {} - }) - this.warmedUp = true - } - - play() { - if (this.list.length === 0) { - this.create() - this.warmUp() - } - const ctx = this.list[this.cursor] - this.cursor = (this.cursor + 1) % this.list.length - try { - ctx.seek(0) - ctx.play() - } catch (_) { - ctx.play() - } - } - - destroy() { - this.list.forEach((ctx) => { - try { - ctx.destroy?.() - } catch (_) {} - }) - this.list = [] - this.warmedUp = false - } -} - -export function useMetronome(options: MetronomeOptions = {}) { - const { - initialBpm = 80, - bpmMin = 40, - bpmMax = 240, - clickSrc = '/static/training/audio/click.mp3', - accentSrc, - poolSize = 4, - onBeat, - } = options - - const bpm = ref(initialBpm) - const isPlaying = ref(false) - const beatIndex = ref(0) - const isAccent = ref(false) - const accentEveryRef = ref(options.accentEvery ?? 4) - - const intervalMs = computed(() => 60000 / bpm.value) - - let clickPool: AudioPool | null = null - let accentPool: AudioPool | null = null - let timer: ReturnType | null = null - let nextTickAt = 0 - - const ensureAudio = () => { - if (!clickPool) { - clickPool = new AudioPool(clickSrc, poolSize) - clickPool.warmUp() - } - if (accentSrc && !accentPool) { - accentPool = new AudioPool(accentSrc, Math.max(2, Math.ceil(poolSize / 2))) - accentPool.warmUp() - } - } - - const playSound = (accent: boolean) => { - if (accent && accentPool) { - accentPool.play() - } else if (clickPool) { - clickPool.play() - } - } - - const tick = () => { - if (!isPlaying.value) return - - const every = Math.max(1, accentEveryRef.value) - const accent = beatIndex.value % every === 0 - isAccent.value = accent - - playSound(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))) - } - - const setAccentEvery = (n: number) => { - accentEveryRef.value = Math.max(1, Math.min(16, Math.round(n))) - beatIndex.value = 0 - } - - /** - * 主动预加载(推荐在页面 onShow 时调用,让用户进页面就完成预热) - */ - const preload = () => { - ensureAudio() - } - - onUnmounted(() => { - stop() - clickPool?.destroy() - accentPool?.destroy() - clickPool = null - accentPool = null - }) - - return { - bpm, - isPlaying, - beatIndex, - isAccent, - intervalMs, - accentEvery: accentEveryRef, - start, - stop, - setBpm, - setAccentEvery, - preload, - } -} diff --git a/uniapp/src/hooks/useTrainingBgm.ts b/uniapp/src/hooks/useTrainingBgm.ts deleted file mode 100644 index 13dfc585..00000000 --- a/uniapp/src/hooks/useTrainingBgm.ts +++ /dev/null @@ -1,115 +0,0 @@ -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 deleted file mode 100644 index f33499b8..00000000 --- a/uniapp/src/hooks/useTrainingSession.ts +++ /dev/null @@ -1,133 +0,0 @@ -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 deleted file mode 100644 index c17c26d9..00000000 --- a/uniapp/src/hooks/useVoiceCoach.ts +++ /dev/null @@ -1,87 +0,0 @@ -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/training/components/exercise-anim.vue b/uniapp/src/pages/training/components/exercise-anim.vue deleted file mode 100644 index c53b4d77..00000000 --- a/uniapp/src/pages/training/components/exercise-anim.vue +++ /dev/null @@ -1,259 +0,0 @@ - - - - - diff --git a/uniapp/src/pages/training/exercises.ts b/uniapp/src/pages/training/exercises.ts deleted file mode 100644 index b72f9c0f..00000000 --- a/uniapp/src/pages/training/exercises.ts +++ /dev/null @@ -1,116 +0,0 @@ -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 deleted file mode 100644 index 36577310..00000000 --- a/uniapp/src/pages/training/index.vue +++ /dev/null @@ -1,610 +0,0 @@ - - - - - diff --git a/uniapp/src/pages/training/metronome.vue b/uniapp/src/pages/training/metronome.vue deleted file mode 100644 index 0e795eb3..00000000 --- a/uniapp/src/pages/training/metronome.vue +++ /dev/null @@ -1,381 +0,0 @@ - - - - - diff --git a/uniapp/src/static/training/README.md b/uniapp/src/static/training/README.md deleted file mode 100644 index f97b585d..00000000 --- a/uniapp/src/static/training/README.md +++ /dev/null @@ -1,105 +0,0 @@ -# 训练模块静态资源 - -本目录用于存放"练一练"功能的所有音频素材。 - -## 目录结构 - -``` -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 音 - -**已内置 3 种音色**(来自 [chrono-bump](https://github.com/johnnysn/chrono-bump) MIT 协议项目): - -| 文件 | 用途 | 大小 | -|------|------|------| -| `audio/click.mp3` | 默认 click(标准节拍器音) | 3.4 KB | -| `audio/click-soft.mp3` | 柔和音色 | 4.2 KB | -| `audio/click-wood.mp3` | 木鱼/原木质感(推荐用于重音 accent) | 4.6 KB | - -如果想换更好听的,可以去: -- [freesound.org](https://freesound.org) 搜 "click" / "tick"(注意 CC 协议) -- [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 即可。 diff --git a/uniapp/src/static/training/audio/click-soft.mp3 b/uniapp/src/static/training/audio/click-soft.mp3 deleted file mode 100644 index 5c70b921..00000000 Binary files a/uniapp/src/static/training/audio/click-soft.mp3 and /dev/null differ diff --git a/uniapp/src/static/training/audio/click-wood.mp3 b/uniapp/src/static/training/audio/click-wood.mp3 deleted file mode 100644 index a6127c85..00000000 Binary files a/uniapp/src/static/training/audio/click-wood.mp3 and /dev/null differ diff --git a/uniapp/src/static/training/audio/click.mp3 b/uniapp/src/static/training/audio/click.mp3 deleted file mode 100644 index a25fa2b0..00000000 Binary files a/uniapp/src/static/training/audio/click.mp3 and /dev/null differ