229 lines
8.2 KiB
JavaScript
229 lines
8.2 KiB
JavaScript
/**
|
|
* 使用腾讯云语音合成 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);
|