feat: 生成语音v2版本,兼容多音字问题

This commit is contained in:
R524809
2026-02-11 15:38:05 +08:00
parent 3569961115
commit 2f8a837ed8
5 changed files with 455 additions and 66 deletions
@@ -0,0 +1,397 @@
/**
* 使用腾讯云语音合成 TTS 为汉字介绍生成语音(v2)
* 读取 char_introduce_v2.json,按单音字/多音字取第一条 [pinyin, meaning, introduce]
* 用 SSML <phoneme> 仅标注当前字的读音,生成语音保存到 data/voice,命名:汉字_拼音.mp3
*/
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';
const VOICE_TYPE = 101015;
const CODEC = 'mp3';
const SAMPLE_RATE = 16000;
/** 只处理前 N 个汉字,便于调试;设为 0 或 null 表示不限制 */
const MAX_CHAR_COUNT = 10;
/** 已生成记录文件(存放在 data/temp) */
const VOICE_RECORD_FILE_V2 = 'voice_generated_v2.json';
const SKIP_KEYS = new Set(['_schema', '_verified_before']);
// 带声调字母 -> 无声调字母
const TONE_TO_VOWEL = {
ā: 'a',
á: 'a',
ǎ: 'a',
à: 'a',
ē: 'e',
é: 'e',
ě: 'e',
è: 'e',
ī: 'i',
í: 'i',
ǐ: 'i',
ì: 'i',
ō: 'o',
ó: 'o',
ǒ: 'o',
ò: 'o',
ū: 'u',
ú: 'u',
ǔ: 'u',
ù: 'u',
ǖ: 'v',
ǘ: 'v',
ǚ: 'v',
ǜ: 'v',
};
// 带声调字母 -> 声调数字 1-4
const TONE_TO_NUM = {
ā: 1,
á: 2,
ǎ: 3,
à: 4,
ē: 1,
é: 2,
ě: 3,
è: 4,
ī: 1,
í: 2,
ǐ: 3,
ì: 4,
ō: 1,
ó: 2,
ǒ: 3,
ò: 4,
ū: 1,
ú: 2,
ǔ: 3,
ù: 4,
ǖ: 1,
ǘ: 2,
ǚ: 3,
ǜ: 4,
};
/**
* 将带声调符号的拼音转为腾讯 SSML 用的格式:无声调 + 数字声调(如 chǎng -> chang3
* 无声调拼音(如 le、bo)末尾补 5(轻声)
* 数据中可能含 Unicode 印刷体 ɡ (U+0261),需规范为 ASCII g,否则 TTS 报 invalid words
*/
function pinyinToPhoneme(pinyin) {
if (!pinyin || typeof pinyin !== 'string') return 'unknown5';
// 规范为 TTS 可识别的拼音字符:ɡ(U+0261)->g, ɑ(U+0251)->a
let normalized = pinyin.replace(/\u0261/g, 'g').replace(/\u0251/g, 'a');
let tone = 5;
let base = '';
for (const c of normalized) {
if (TONE_TO_VOWEL[c] !== undefined) {
base += TONE_TO_VOWEL[c];
tone = TONE_TO_NUM[c];
} else {
base += c;
}
}
// ü 在 SSML 中常用 v 表示
base = base.replace(/ü/g, 'v');
return base + tone;
}
/**
* 将 introduce 中所有「当前字」用 <phoneme> 包裹,只标该字读音
*/
function buildSSML(introduce, char, pinyinPhoneme) {
const tag = `<phoneme alphabet="py" ph="${pinyinPhoneme}">${escapeXml(char)}</phoneme>`;
const parts = introduce.split(char);
const ssmlBody = parts.join(tag);
return `<speak>${ssmlBody}</speak>`;
}
function escapeXml(s) {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
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-v2-${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();
});
}
/**
* 文件名:汉字_拼音数字.mp3,拼音中的特殊字符(如 ɡ)规范为 ASCII
*/
function getVoiceFilename(char, pinyinPhoneme) {
const safePinyin = pinyinPhoneme.replace(/\s/g, '').replace(/[^\w\-]/g, '_');
return `${char}_${safePinyin}.${CODEC}`;
}
/** 生成记录用的 key(与文件名不含扩展名一致) */
function getRecordKey(char, pinyinPhoneme) {
const safePinyin = pinyinPhoneme.replace(/\s/g, '').replace(/[^\w\-]/g, '_');
return `${char}_${safePinyin}`;
}
/**
* 从 data/temp 目录加载已生成记录
*/
function loadVoiceRecordV2(tempDir) {
const recordPath = path.join(tempDir, VOICE_RECORD_FILE_V2);
let generated = [];
if (fs.existsSync(recordPath)) {
try {
const data = JSON.parse(fs.readFileSync(recordPath, 'utf-8'));
generated = data.generated || [];
} catch (e) {
generated = [];
}
}
return new Set(generated);
}
/**
* 将已生成记录写入 data/temp
*/
function saveVoiceRecordV2(tempDir, generatedList) {
const recordPath = path.join(tempDir, VOICE_RECORD_FILE_V2);
fs.writeFileSync(
recordPath,
JSON.stringify({
generated: generatedList,
updatedAt: new Date().toISOString(),
}),
'utf-8'
);
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* 从 char_introduce_v2 的一条 value 解析出 [pinyin, meaning, introduce]
* 规则:
* - 单音字:一维数组 [pinyin, meaning, introduce]index0=拼音,index2=introduce
* - 多音字:二维数组 [[pinyin, meaning, introduce], ...],用第一个元素是 string 还是 array 判断;
* 多音字只读第一个子数组,index0=拼音,index2=introduce
*/
function parseEntry(value) {
if (!Array.isArray(value) || value.length === 0) return null;
const first = value[0];
// 单音字:首元素是 string
if (typeof first === 'string') {
if (value.length < 3) return null;
return [value[0], value[1], value[2]];
}
// 多音字:首元素是 array,取第一个子数组
if (Array.isArray(first) && first.length >= 3) {
return [first[0], first[1], first[2]];
}
return null;
}
/**
* 从 JSON 文件内容中按「出现顺序」提取 key 列表,避免依赖 Object 遍历顺序。
* 只匹配行首的 "key":,避免误匹配到值字符串内的引号。
*/
function getKeyOrderFromJsonText(text) {
const keys = [];
const re = /^\s*"([^"]+)":\s*(?:\[|")/gm;
let m;
while ((m = re.exec(text)) !== null) {
const key = m[1];
if (!SKIP_KEYS.has(key)) {
keys.push(key);
}
}
return keys;
}
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_v2.json');
const voiceDir = path.join(__dirname, '../data/voice');
const tempDir = path.join(__dirname, '../data/temp');
if (!fs.existsSync(introducePath)) {
console.error('未找到 char_introduce_v2.json');
process.exit(1);
}
if (!fs.existsSync(voiceDir)) {
fs.mkdirSync(voiceDir, { recursive: true });
}
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
const text = fs.readFileSync(introducePath, 'utf-8');
const raw = JSON.parse(text);
// 按 JSON 文件中 key 的出现顺序构建列表,不依赖 Object 遍历顺序
const keyOrder = getKeyOrderFromJsonText(text);
const entries = keyOrder
.map((char) => {
const value = raw[char];
const parsed = parseEntry(value);
return parsed
? { char, pinyin: parsed[0], meaning: parsed[1], introduce: parsed[2] }
: null;
})
.filter(Boolean);
const list = MAX_CHAR_COUNT ? entries.slice(0, MAX_CHAR_COUNT) : entries;
const generatedSet = loadVoiceRecordV2(tempDir);
const toGenerate = list.filter((item) => {
const key = getRecordKey(item.char, pinyinToPhoneme(item.pinyin));
return !generatedSet.has(key);
});
const generatedList = Array.from(generatedSet);
console.log(
`${entries.length} 个汉字,本次处理前 ${list.length}${MAX_CHAR_COUNT ? `MAX_CHAR_COUNT=${MAX_CHAR_COUNT}` : ''};已生成 ${generatedList.length} 个,待生成 ${toGenerate.length}`
);
const DELAY_MS = 200;
for (let i = 0; i < toGenerate.length; i++) {
const { char, pinyin, introduce } = toGenerate[i];
const pinyinPhoneme = pinyinToPhoneme(pinyin);
const recordKey = getRecordKey(char, pinyinPhoneme);
const ssml = buildSSML(introduce, char, pinyinPhoneme);
const filename = getVoiceFilename(char, pinyinPhoneme);
const filepath = path.join(voiceDir, filename);
try {
const audioBuffer = await textToVoice(ssml);
fs.writeFileSync(filepath, audioBuffer);
generatedList.push(recordKey);
saveVoiceRecordV2(tempDir, generatedList);
console.log(
`[${i + 1}/${toGenerate.length}] ${char} (${pinyin} -> ${pinyinPhoneme}) -> ${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},已生成记录: ${path.join(tempDir, VOICE_RECORD_FILE_V2)}`
);
}
main().catch(console.error);