/** * 使用腾讯云语音合成 TTS 为汉字介绍生成语音 * 读取 char_introduce.json,将每个 introduce 文本合成语音保存到 data/voice 目录 */ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const https = require('https'); require('dotenv').config({ path: path.join(__dirname, '../.env') }); const SECRET_ID = process.env.TENCENT_SECRET_ID; const SECRET_KEY = process.env.TENCENT_SECRET_KEY; const HOST = 'tts.tencentcloudapi.com'; const SERVICE = 'tts'; const REGION = 'ap-guangzhou'; const ACTION = 'TextToVoice'; const VERSION = '2019-08-23'; // 音色:101001 女声标准(免费),101002 男声标准 const VOICE_TYPE = 101015; const CODEC = 'mp3'; const SAMPLE_RATE = 16000; // 生成语音的汉字数量限制,设为 0 或 null 表示不限制(生成全部) const MAX_CHAR_COUNT = null; // 已生成语音记录文件路径(相对于 data/char) const VOICE_RECORD_FILE = 'voice_generated.json'; function sha256(message, secret = '', encoding = 'hex') { const hmac = secret ? crypto.createHmac('sha256', secret) : crypto.createHash('sha256'); return hmac.update(message).digest(encoding); } function getDate(timestamp) { const date = new Date(timestamp * 1000); const year = date.getUTCFullYear(); const month = ('0' + (date.getUTCMonth() + 1)).slice(-2); const day = ('0' + date.getUTCDate()).slice(-2); return `${year}-${month}-${day}`; } function generateSignature(secretId, secretKey, host, payload, timestamp) { const date = getDate(timestamp); const httpRequestMethod = 'POST'; const canonicalUri = '/'; const canonicalQueryString = ''; const contentType = 'application/json'; const canonicalHeaders = `content-type:${contentType}\nhost:${host}\nx-tc-action:${ACTION.toLowerCase()}\n`; const signedHeaders = 'content-type;host;x-tc-action'; const hashedRequestPayload = sha256(payload); const canonicalRequest = `${httpRequestMethod}\n${canonicalUri}\n${canonicalQueryString}\n${canonicalHeaders}\n${signedHeaders}\n${hashedRequestPayload}`; const algorithm = 'TC3-HMAC-SHA256'; const credentialScope = `${date}/${SERVICE}/tc3_request`; const hashedCanonicalRequest = sha256(canonicalRequest); const stringToSign = `${algorithm}\n${timestamp}\n${credentialScope}\n${hashedCanonicalRequest}`; const secretDate = sha256(date, 'TC3' + secretKey, 'buffer'); const secretService = sha256(SERVICE, secretDate, 'buffer'); const secretSigning = sha256('tc3_request', secretService, 'buffer'); const signature = sha256(stringToSign, secretSigning); const authorization = `${algorithm} Credential=${secretId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; return authorization; } function textToVoice(text) { return new Promise((resolve, reject) => { const timestamp = Math.floor(Date.now() / 1000); const sessionId = `voice-${timestamp}-${Math.random().toString(36).slice(2, 10)}`; const payload = JSON.stringify({ Text: text, SessionId: sessionId, VoiceType: VOICE_TYPE, PrimaryLanguage: 1, SampleRate: SAMPLE_RATE, Codec: CODEC, Speed: 0, }); const authorization = generateSignature(SECRET_ID, SECRET_KEY, HOST, payload, timestamp); const options = { hostname: HOST, port: 443, path: '/', method: 'POST', headers: { 'Content-Type': 'application/json', Host: HOST, 'X-TC-Action': ACTION, 'X-TC-Version': VERSION, 'X-TC-Timestamp': timestamp.toString(), 'X-TC-Region': REGION, Authorization: authorization, }, }; const req = https.request(options, (res) => { let data = ''; res.on('data', (chunk) => (data += chunk)); res.on('end', () => { try { const result = JSON.parse(data); if (result.Response && result.Response.Audio) { resolve(Buffer.from(result.Response.Audio, 'base64')); } else if (result.Response && result.Response.Error) { reject(new Error(result.Response.Error.Message)); } else { reject(new Error('Unknown API response: ' + data)); } } catch (e) { reject(e); } }); }); req.on('error', reject); req.write(payload); req.end(); }); } function getSafeFilename(char) { // 汉字转 Unicode 编码作为文件名,避免特殊字符问题 const code = char.codePointAt(0).toString(16); return `${char}_${code}.${CODEC}`; } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function loadVoiceRecord(charDir, voiceDir) { const recordPath = path.join(charDir, VOICE_RECORD_FILE); let chars = []; if (fs.existsSync(recordPath)) { try { const data = JSON.parse(fs.readFileSync(recordPath, 'utf-8')); chars = data.chars || []; } catch (e) { chars = []; } } // 如果记录为空,从 voiceDir 扫描已有音频文件初始化 if (chars.length === 0 && fs.existsSync(voiceDir)) { const files = fs.readdirSync(voiceDir).filter((f) => f.endsWith(`.${CODEC}`)); chars = files .map((f) => { const name = path.basename(f, `.${CODEC}`); return name.split('_')[0]; }) .filter(Boolean); if (chars.length > 0) { saveVoiceRecord(charDir, chars); console.log(`从已有文件初始化记录,识别到 ${chars.length} 个已生成语音`); } } return { chars }; } function saveVoiceRecord(charDir, chars) { const recordPath = path.join(charDir, VOICE_RECORD_FILE); fs.writeFileSync( recordPath, JSON.stringify({ chars, updatedAt: new Date().toISOString() }), 'utf-8' ); } async function main() { if (!SECRET_ID || !SECRET_KEY) { console.error('请在 .env 文件中配置 TENCENT_SECRET_ID 和 TENCENT_SECRET_KEY'); process.exit(1); } const charDir = path.join(__dirname, '../data/char'); const introducePath = path.join(charDir, 'char_introduce.json'); const voiceDir = path.join(__dirname, '../data/voice'); if (!fs.existsSync(introducePath)) { console.error('未找到 char_introduce.json,请先运行 generate-introduce 脚本'); process.exit(1); } if (!fs.existsSync(voiceDir)) { fs.mkdirSync(voiceDir, { recursive: true }); } const fullList = JSON.parse(fs.readFileSync(introducePath, 'utf-8')); const list = MAX_CHAR_COUNT ? fullList.slice(0, MAX_CHAR_COUNT) : fullList; const { chars: generatedChars } = loadVoiceRecord(charDir, voiceDir); const generatedSet = new Set(generatedChars); const toGenerate = list.filter((item) => !generatedSet.has(item.char)); console.log( `共有 ${list.length} 个汉字待处理${MAX_CHAR_COUNT ? `(限制前 ${MAX_CHAR_COUNT} 个)` : ''},其中 ${generatedChars.length} 个已生成,${toGenerate.length} 个待生成` ); const DELAY_MS = 200; // 请求间隔,避免限流 for (let i = 0; i < toGenerate.length; i++) { const { char, introduce } = toGenerate[i]; const filename = getSafeFilename(char); const filepath = path.join(voiceDir, filename); try { const audioBuffer = await textToVoice(introduce); fs.writeFileSync(filepath, audioBuffer); generatedChars.push(char); saveVoiceRecord(charDir, generatedChars); console.log(`[${i + 1}/${toGenerate.length}] 完成: ${char} -> ${filename}`); } catch (error) { console.error(`[${i + 1}/${toGenerate.length}] 失败: ${char} - ${error.message}`); } if (i < toGenerate.length - 1) { await delay(DELAY_MS); } } console.log(`\n语音文件已保存到: ${voiceDir}`); } main().catch(console.error);