#!/usr/bin/env node /** * MiniMax 音色克隆 * * 用法: * node minimax-voice-clone.js --file /path/to/audio.mp3 --voice-id my_voice_name * * 流程: * 1. 上传音频文件 → 获取 file_id * 2. 调用 voice_clone → 注册 voice_id * 3. 输出 voice_id(写入 config.json 的 minimaxVoices) * * 选项: * --file 待克隆的音频文件(MP3/WAV/M4A,≥10s) * --voice-id 自定义音色 ID(字母数字下划线,如 "snape_mm") * --name 音色中文名(写入 config,默认同 voice-id) * --model 模型(默认 speech-02-hd) * --save 自动写入 config.json 的 minimaxVoices */ const https = require('https') const fs = require('fs') const path = require('path') const CONFIG_PATH = path.join(__dirname, '..', '..', 'config.json') const BOUNDARY = `----FormBoundary${Date.now().toString(16)}` function loadConfig() { if (!fs.existsSync(CONFIG_PATH)) throw new Error(`config.json 不存在: ${CONFIG_PATH}`) return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')) } function httpsRequest(options, body) { return new Promise((resolve, reject) => { const req = https.request(options, (res) => { const chunks = [] res.on('data', c => chunks.push(c)) res.on('end', () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8'))) } catch (e) { reject(new Error(`响应解析失败: ${e.message}`)) } }) }) req.on('error', reject) if (body) req.write(body) req.end() }) } async function uploadFile(apiKey, filePath, baseUrl, purpose = 'voice_clone') { const fileBuffer = fs.readFileSync(filePath) const fileName = path.basename(filePath) const bodyParts = [ `--${BOUNDARY}\r\nContent-Disposition: form-data; name="purpose"\r\n\r\n${purpose}`, `--${BOUNDARY}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: application/octet-stream\r\n\r\n`, ] const body = Buffer.concat([ Buffer.from(bodyParts[0] + '\r\n'), Buffer.from(bodyParts[1]), fileBuffer, Buffer.from(`\r\n--${BOUNDARY}--\r\n`), ]) const url = new URL(`${baseUrl}/v1/files`) const result = await httpsRequest({ hostname: url.hostname, path: url.pathname, method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': `multipart/form-data; boundary=${BOUNDARY}`, 'Content-Length': body.length, }, }, body) if (result.base_resp?.status_code !== 0) { throw new Error(`上传失败 [${result.base_resp?.status_code}]: ${result.base_resp?.status_msg}`) } return result.file.file_id } async function cloneVoice(apiKey, fileId, voiceId, model, baseUrl) { const body = JSON.stringify({ file_id: fileId, voice_id: voiceId, text: '这是一段用于验证音色克隆效果的测试文本,请仔细聆听音色特征。', model, need_noise_reduction: true, need_volume_normalization: true, }) const url = new URL(`${baseUrl}/v1/voice_clone`) const result = await httpsRequest({ hostname: url.hostname, path: url.pathname, method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), }, }, body) if (result.base_resp?.status_code !== 0) { throw new Error(`克隆失败 [${result.base_resp?.status_code}]: ${result.base_resp?.status_msg}`) } return result } function parseArgs(argv) { const args = {} for (let i = 0; i < argv.length; i++) { if (argv[i].startsWith('--')) { const key = argv[i].slice(2) args[key] = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true } } return args } async function main() { const args = parseArgs(process.argv.slice(2)) if (!args.file || !args['voice-id']) { console.error('用法: node minimax-voice-clone.js --file <音频文件> --voice-id <自定义ID> [--name <中文名>] [--save]') console.error('示例: node minimax-voice-clone.js --file snape.mp3 --voice-id snape_mm --name 斯内普MM --save') process.exit(1) } const config = loadConfig() const apiKey = config.minimaxApiKey if (!apiKey) { console.error('config.json 中未配置 minimaxApiKey'); process.exit(1) } const filePath = path.resolve(args.file) if (!fs.existsSync(filePath)) { console.error(`文件不存在: ${filePath}`); process.exit(1) } const voiceId = args['voice-id'] const voiceName = args.name || voiceId const model = args.model || config.minimaxTtsModel || 'speech-2.8-hd' const baseUrl = config.minimaxApiBase || 'https://api.yunwu.ai/minimax' console.log(`\n[1/2] 上传音频文件: ${path.basename(filePath)}`) const fileId = await uploadFile(apiKey, filePath, baseUrl) console.log(` file_id: ${fileId}`) console.log(`[2/2] 克隆音色: ${voiceId} (模型: ${model})`) await cloneVoice(apiKey, fileId, voiceId, model, baseUrl) console.log(` 克隆成功!`) console.log(`\n✅ voice_id: ${voiceId}`) if (args.save) { const voices = config.minimaxVoices || {} voices[voiceName] = voiceId config.minimaxVoices = voices fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2)) console.log(` 已写入 config.json: minimaxVoices["${voiceName}"] = "${voiceId}"`) } else { console.log(`\n 手动写入 config.json:`) console.log(` "minimaxVoices": { "${voiceName}": "${voiceId}" }`) console.log(`\n 或运行时加 --save 参数自动写入`) } } main().catch(err => { console.error('\n❌ 失败:', err.message); process.exit(1) })