Files
video-create/.claude/skills/video-from-script/scripts/minimax-voice-clone.js

173 lines
5.6 KiB
JavaScript
Raw Normal View History

#!/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 <path> 待克隆的音频文件MP3/WAV/M4A10s
* --voice-id <name> 自定义音色 ID字母数字下划线 "snape_mm"
* --name <name> 音色中文名写入 config默认同 voice-id
* --model <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) })