主包超出 2MB 上限无法上传发布版,把训练相关全部移到独立分包。
目录调整:
- pages/training/* → training/pages/*
- hooks/use{Metronome,VoiceCoach,TrainingSession,TrainingBgm}.ts
→ training/hooks/*
- static/training/* → training/static/*
- 主包 hooks/ 目录已清空,所有 use* 都被分包独占
引用路径更新:
- 分包内 hook import 用相对路径 ../hooks/xxx,不依赖 @/ alias
- 资源路径 /static/training/audio/click.mp3
→ /training/static/audio/click.mp3
- useMetronome 默认 clickSrc / useVoiceCoach VOICE_BASE
/ useTrainingBgm BGM_BASE 全部对齐新路径
pages.json:
- 主包 pages 移除 training/index 和 training/metronome
- subPackages 新增 root="training",含 pages/index 和 pages/metronome
- 跟现有 TUIKit / doctor 分包风格一致
调用方:
- components/dev-training-entry navigateTo URL 更新成
/training/pages/metronome (此组件留在主包,主包跳分包是合法操作)
文档/工具同步:
- training/static/README.md 路径示例改为 training/static/...
- scripts/generate-voice.mjs VOICE_DIR 与提示输出对齐新路径
Git mv 检测到的全是 rename(R),历史完整保留。
分包总体 80K(pages 44K + hooks 20K + static 16K),主包瘦身明显。
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 # 已存在的也重新生成
|
||
*
|
||
* 输出(分包):
|
||
* training/static/voice/numbers/{1..30}.mp3
|
||
* training/static/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, 'training/static/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 音 → training/static/audio/click.mp3')
|
||
console.log(' 2. 准备 BGM → training/static/bgm/{train-light,rest-meditation}.mp3')
|
||
console.log(' 3. 编译运行 → 进入 /training/pages/index')
|
||
}
|
||
|
||
main().catch((e) => {
|
||
console.error('\n❌ 致命错误:', e)
|
||
process.exit(1)
|
||
})
|