173 lines
5.6 KiB
JavaScript
173 lines
5.6 KiB
JavaScript
|
|
#!/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/M4A,≥10s)
|
|||
|
|
* --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) })
|