之前误将训练模块加在 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>
214 lines
6.2 KiB
JavaScript
214 lines
6.2 KiB
JavaScript
#!/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)
|
||
})
|