feat: 生成语音v2版本,兼容多音字问题
This commit is contained in:
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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);
|
||||
Reference in New Issue
Block a user