feat(TUICallKit-Vue3): 迁入节拍器与练一练训练模块
之前误将训练模块加在 uniapp 项目,实际目标项目是 TUICallKit-Vue3 (甄养堂主小程序壳),整体迁入: - hooks/useMetronome.ts 节拍器(音频实例池+预热,无冷启动延迟) - hooks/useTrainingSession.ts 训练 Session 状态机 - hooks/useVoiceCoach.ts 语音教练队列 - hooks/useTrainingBgm.ts 训练/休息 BGM 切换+渐变 - pages/training/index.vue 练一练主页(器械跟练) - pages/training/metronome.vue 独立节拍器(老人友好版, 中央圆即开始/暂停按钮,纯 CSS 几何 icon,大字大按钮) - pages/training/components/exercise-anim.vue CSS 动作动画 - pages/training/exercises.ts 5 个器械动作预设 - static/training/audio/*.mp3 3 种 click 音色(MIT 协议) - scripts/generate-voice.mjs MiMo TTS 批量生成语音脚本 - pages.json 注册 pages/training/index 与 pages/training/metronome - 「我的」页加 DevTrainingEntry 悬浮入口,生产环境自动隐藏 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)
|
||||
})
|
||||
Reference in New Issue
Block a user