- 新增 minimax-tts.js 和 minimax-voice-clone.js 脚本 - 新增口播批量剪辑流水线 (口播_assemble.js, 口播_pipeline.js) - 更新 video-from-script 各阶段脚本 (kling, images, tts, videos) - 新增执黑先行二号-风格延伸账号 - 更新执黑先行 account.json 配置 - 替换 ugc_product_seeding 参考图 - 更新 CLAUDE.md 和依赖配置 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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) })
|