Files
video-create/.claude/skills/video-from-script/scripts/minimax-tts.js
lc 1e110219ff feat: MiniMax TTS集成、口播批量剪辑流水线、执黑先行二号账号
- 新增 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>
2026-05-24 20:05:56 +08:00

190 lines
6.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* MiniMax TTS — T2A v2 HTTP 语音合成
*
* 模块用法:
* const { synthesize } = require('./minimax-tts')
* const { filePath, duration } = await synthesize('你好', { voice: 'my_voice_id' })
*
* CLI 用法:
* node minimax-tts.js <input.json>
* input.json: { "segments": [{"id": 1, "text": "..."}], "voice": "voice_id", "output_dir": "./audio" }
*/
const https = require('https')
const fs = require('fs')
const path = require('path')
const { execFileSync } = require('child_process')
const CONFIG_PATH = path.join(__dirname, '..', '..', 'config.json')
function loadConfig() {
if (!fs.existsSync(CONFIG_PATH)) throw new Error(`config.json 不存在: ${CONFIG_PATH}`)
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'))
}
function resolveVoice(voice, config) {
if (!voice) return voice
const voices = config.minimaxVoices || {}
return voices[voice] || voice
}
function getAudioDuration(filePath) {
try {
const out = execFileSync('ffprobe', [
'-v', 'quiet', '-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1', filePath,
], { encoding: 'utf-8', timeout: 10000 })
return parseFloat(out.trim())
} catch {
const stat = fs.statSync(filePath)
return stat.size * 8 / 128000
}
}
/**
* 单段语音合成MiniMax T2A v2 HTTP非流式
* @param {string} text
* @param {object} options - { voice, model, outputDir, id, rate, apiKey }
* @returns {Promise<{filePath: string, duration: number}>}
*/
function synthesize(text, options = {}) {
return new Promise((resolve, reject) => {
const config = loadConfig()
const apiKey = options.apiKey || config.minimaxApiKey
if (!apiKey) { reject(new Error('minimaxApiKey 未配置,请在 config.json 中添加')); return }
const model = options.model || config.minimaxTtsModel || 'speech-02-hd'
const voiceId = resolveVoice(options.voice || config.minimaxTtsVoice, config)
if (!voiceId) { reject(new Error('未指定 voice_id请在 options.voice 或 config.minimaxTtsVoice 中配置')); return }
const outputDir = options.outputDir || './audio'
fs.mkdirSync(outputDir, { recursive: true })
text = text.trimEnd()
if (!/[。!?;,.!?…]$/.test(text)) text += '。'
const id = options.id || 1
const fileName = `seg_${String(id).padStart(3, '0')}.mp3`
const filePath = path.resolve(outputDir, fileName)
const voiceSetting = {
voice_id: voiceId,
speed: options.rate || 1.0,
vol: 1.0,
pitch: options.pitch ?? 0,
}
if (options.emotion) voiceSetting.emotion = options.emotion
const requestBody = {
model,
text,
stream: false,
voice_setting: voiceSetting,
audio_setting: {
sample_rate: 32000,
format: 'mp3',
channel: 1,
},
}
if (options.languageBoost) requestBody.language_boost = options.languageBoost
const body = JSON.stringify(requestBody)
const baseUrl = config.minimaxApiBase || 'https://api.yunwu.ai/minimax'
const url = new URL(`${baseUrl}/v1/t2a_v2`)
const reqOptions = {
hostname: url.hostname,
path: url.pathname,
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
}
let settled = false
const timer = setTimeout(() => {
if (!settled) { settled = true; req.destroy(); reject(new Error('MiniMax TTS 超时 (90s)')) }
}, 90000)
const req = https.request(reqOptions, (res) => {
let body = ''
res.on('data', chunk => { body += chunk.toString() })
res.on('end', () => {
clearTimeout(timer)
if (settled) return
settled = true
try {
const d = JSON.parse(body)
if (d.base_resp?.status_code && d.base_resp.status_code !== 0) {
reject(new Error(`MiniMax TTS 失败 [${d.base_resp.status_code}]: ${d.base_resp.status_msg}`))
return
}
const hex = d.data?.audio
if (!hex) { reject(new Error('MiniMax TTS 未返回音频')); return }
const audioBuffer = Buffer.from(hex, 'hex')
fs.writeFileSync(filePath, audioBuffer)
resolve({ filePath, duration: getAudioDuration(filePath) })
} catch (e) {
reject(new Error(`MiniMax TTS 解析响应失败: ${e.message}`))
}
})
res.on('error', (e) => {
clearTimeout(timer)
if (!settled) { settled = true; reject(e) }
})
})
req.on('error', (e) => {
clearTimeout(timer)
if (!settled) { settled = true; reject(new Error(`MiniMax TTS 请求失败: ${e.message}`)) }
})
req.write(body)
req.end()
})
}
async function synthesizeBatch(segments, options = {}) {
const results = []
for (const seg of segments) {
console.error(` 合成 #${seg.id}: ${seg.text.substring(0, 30)}...`)
try {
const { filePath, duration } = await synthesize(seg.text, { ...options, id: seg.id })
results.push({ id: seg.id, text: seg.text, audio: filePath, duration: Math.round(duration * 1000) / 1000 })
} catch (err) {
results.push({ id: seg.id, text: seg.text, audio: '', duration: 0, error: err.message })
}
await new Promise(r => setTimeout(r, 300))
}
return results
}
async function main() {
const inputJson = process.argv[2]
if (!inputJson) {
console.error('用法: node minimax-tts.js <input.json>')
console.error(JSON.stringify({ segments: [{ id: 1, text: '文案' }], voice: 'voice_id', output_dir: './audio' }, null, 2))
process.exit(1)
}
const input = JSON.parse(fs.readFileSync(inputJson, 'utf-8'))
const results = await synthesizeBatch(input.segments, { voice: input.voice, outputDir: input.output_dir || './audio' })
process.stdout.write(JSON.stringify({ segments: results }, null, 2) + '\n')
}
if (require.main === module) {
main().catch(err => { console.error('合成失败:', err.message); process.exit(1) })
}
module.exports = { synthesize, synthesizeBatch }