#!/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) })