feat: 生成语音v2版本,兼容多音字问题
This commit is contained in:
@@ -48,7 +48,7 @@
|
||||
],
|
||||
"于": ["yú", null, "于,于是的于"],
|
||||
"亏": ["kuī", null, "亏,亏损的亏"],
|
||||
"工": ["ɡōnɡ", null, "工,工人的人"],
|
||||
"工": ["ɡōnɡ", null, "工,工人的工"],
|
||||
"土": ["tǔ", null, "土,土地的土"],
|
||||
"士": ["shì", null, "士,士气的士"],
|
||||
"才": ["cái", null, "才,才能的才"],
|
||||
@@ -957,7 +957,6 @@
|
||||
"论": [
|
||||
["lùn", "讨论", "论,讨论的论"],
|
||||
["lún", "经典名", "论,论语的论"]
|
||||
// TODO
|
||||
],
|
||||
"讼": ["sònɡ", null, "讼,诉讼的讼"],
|
||||
"农": ["nónɡ", null, "农,农业的农"],
|
||||
@@ -1283,8 +1282,8 @@
|
||||
"吹": ["chuī", null, "吹,吹气的吹"],
|
||||
"呜": ["wū", null, "呜,呜咽的呜"],
|
||||
"吭": [
|
||||
["háng", "喉咙", "吭,吭声,喉咙的声音"],
|
||||
["kēng", "出声", "吭,吭声的吭"]
|
||||
["kēng", "出声", "吭,吭声的吭"],
|
||||
["háng", "喉咙", "吭,吭声,喉咙的声音"]
|
||||
],
|
||||
"吧": [
|
||||
["bā", "酒吧", "吧,吧吧,语气词"],
|
||||
@@ -1293,8 +1292,8 @@
|
||||
"邑": ["yì", null, "邑,城邑,城市"],
|
||||
"吼": ["hǒu", null, "吼,吼叫,大声叫喊"],
|
||||
"囤": [
|
||||
["dùn", "储存", "囤,囤积,储存"],
|
||||
["tún", "存粮的器物", "囤,粮囤的囤"]
|
||||
["tún", "存粮的器物", "囤,粮囤的囤"],
|
||||
["dùn", "储存", "囤,囤积,储存"]
|
||||
],
|
||||
"别": [
|
||||
["bié", "分别", "别,别扭,不顺畅"],
|
||||
@@ -1343,8 +1342,8 @@
|
||||
["tián", "耕作", "佃,佃作的佃"]
|
||||
],
|
||||
"作": [
|
||||
["zuō", "手工作坊", "作,作业,作品的作"],
|
||||
["zuò", "创作", "作,工作的作"]
|
||||
["zuò", "创作", "作,工作的作"],
|
||||
["zuō", "手工作坊", "作,作业,作品的作"]
|
||||
],
|
||||
"伯": [
|
||||
["bó", "伯母", "伯,伯父,伯母"],
|
||||
@@ -1403,7 +1402,7 @@
|
||||
["qiū", "古国名", "龟,龟兹的龟"]
|
||||
],
|
||||
"甸": [
|
||||
["diàn", "郊外", "甸,甸甸的甸"],
|
||||
["diàn", "郊外", "甸,沉甸甸的甸"],
|
||||
["tián", "古同'田'", "甸,甸田的甸"],
|
||||
["shèng", "古读", "甸,甸师的甸"]
|
||||
],
|
||||
@@ -1692,8 +1691,8 @@
|
||||
"抬": ["tái", null, "抬,抬举,抬起"],
|
||||
"拇": ["mǔ", null, "拇,拇指,大拇指"],
|
||||
"拗": [
|
||||
["ǎo", "折断", "拗,拗口,弯曲"],
|
||||
["ào", "别扭", "拗,拗口的拗"],
|
||||
["ǎo", "折断", "拗,拗口,弯曲"],
|
||||
["niù", "固执", "拗,执拗的拗"]
|
||||
],
|
||||
"其": [
|
||||
@@ -1714,7 +1713,7 @@
|
||||
],
|
||||
"茂": ["mào", null, "茂,茂盛,繁茂"],
|
||||
"苹": [
|
||||
["píng", "水果", "苹,浮萍,水草"],
|
||||
["píng", "水果", "苹,苹果的苹"],
|
||||
["pēng", "草名", "苹,苹萦的苹"]
|
||||
],
|
||||
"苗": ["miáo", null, "苗,幼苗,禾苗"],
|
||||
@@ -1726,8 +1725,8 @@
|
||||
"直": ["zhí", null, "直,正直的直"],
|
||||
"茁": ["zhuó", null, "茁,茁壮的茁"],
|
||||
"茄": [
|
||||
["jiā", "烟草", "茄,茄子的茄"],
|
||||
["qié", "蔬菜", "茄,茄子的茄"]
|
||||
["qié", "蔬菜", "茄,茄子的茄"],
|
||||
["jiā", "烟草", "茄,雪茄的茄"]
|
||||
],
|
||||
"茎": ["jīnɡ", null, "茎,茎叶的茎"],
|
||||
"苔": [
|
||||
@@ -1759,8 +1758,8 @@
|
||||
"述": ["shù", null, "述,叙述的述"],
|
||||
"枕": ["zhěn", null, "枕,枕头的枕"],
|
||||
"丧": [
|
||||
["sāng", "丧葬", "丧,丧事的丧"],
|
||||
["sàng", "失去", "丧,丧失的丧"]
|
||||
["sàng", "失去", "丧,丧失的丧"],
|
||||
["sāng", "丧葬", "丧,丧事的丧"]
|
||||
],
|
||||
"或": ["huò", null, "或,或许的或"],
|
||||
"画": ["huà", null, "画,画画的画"],
|
||||
@@ -1869,7 +1868,7 @@
|
||||
"岸": ["àn", null, "岸,河岸的岸"],
|
||||
"岩": ["yán", null, "岩,岩石,地壳的岩石"],
|
||||
"帖": [
|
||||
["tiè", "法帖", "帖,帖子,网络上的帖子"],
|
||||
["tiè", "法帖", "帖,字帖的帖"],
|
||||
["tiě", "帖子", "帖,请帖的帖"],
|
||||
["tiē", "顺从", "帖,服帖的帖"]
|
||||
],
|
||||
@@ -1946,9 +1945,9 @@
|
||||
"依": ["yī", null, "依,依靠的依"],
|
||||
"卑": ["bēi", null, "卑,卑微的卑"],
|
||||
"的": [
|
||||
["de", "助词", "的,我的的的"],
|
||||
["dí", "确实", "的,的确的的"],
|
||||
["dì", "靶心", "的,目的的的"],
|
||||
["de", "助词", "的,我的的的"]
|
||||
["dì", "靶心", "的,目的的的"]
|
||||
],
|
||||
"迫": [
|
||||
["pò", "逼迫", "迫,压迫的迫"],
|
||||
@@ -1968,8 +1967,8 @@
|
||||
],
|
||||
"金": ["jīn", null, "金,金子的金"],
|
||||
"刹": [
|
||||
["chà", "瞬间", "刹,刹车的刹"],
|
||||
["shā", "制动", "刹,刹车的刹"]
|
||||
["shā", "制动", "刹,刹车的刹"],
|
||||
["chà", "瞬间", "刹,刹车的刹"]
|
||||
],
|
||||
"命": ["mìnɡ", null, "命,命运的命"],
|
||||
"肴": ["yáo", null, "肴,菜肴的肴"],
|
||||
@@ -2051,8 +2050,8 @@
|
||||
"刻": ["kè", null, "刻,雕刻的刻"],
|
||||
"育": ["yù", null, "育,教育的育"],
|
||||
"氓": [
|
||||
["méng", "无赖", "氓,流氓的氓"],
|
||||
["máng", "古代百姓", "氓,氓隶的氓"]
|
||||
["máng", "无赖", "氓,流氓的氓"],
|
||||
["méng", "古代百姓", "氓,氓隶的氓"]
|
||||
],
|
||||
"闸": ["zhá", null, "闸,闸门的闸"],
|
||||
"闹": ["nào", null, "闹,热闹的闹"],
|
||||
@@ -2622,10 +2621,7 @@
|
||||
"贷": ["dài", null, "贷,贷款,借入或借出的款项"],
|
||||
"顺": ["shùn", null, "顺,顺利,顺畅"],
|
||||
"修": ["xiū", null, "修,修理,修补"],
|
||||
"俏": [
|
||||
["qiào", "好看", "俏,俏皮,活泼"],
|
||||
["xiào", "古同'肖'", "俏,俏皮的俏"]
|
||||
],
|
||||
"俏": [["qiào", "好看", "俏,俏皮,活泼"]],
|
||||
"保": ["bǎo", null, "保,保证,担保"],
|
||||
"促": ["cù", null, "促,促进,推动"],
|
||||
"俄": ["é", null, "俄,俄罗斯,国家名"],
|
||||
@@ -2634,10 +2630,7 @@
|
||||
"俭": ["jiǎn", null, "俭,节俭的俭"],
|
||||
"俗": ["sú", null, "俗,风俗的俗"],
|
||||
"俘": ["fú", null, "俘,俘虏的俘"],
|
||||
"信": [
|
||||
["xìn", "书信", "信,信任的信"],
|
||||
["shēn", "古同'伸'", "信,信阳的信"]
|
||||
],
|
||||
"信": [["xìn", "书信", "信,信任的信"]],
|
||||
"皇": ["huánɡ", null, "皇,皇帝的皇"],
|
||||
"泉": ["quán", null, "泉,泉水的泉"],
|
||||
"鬼": ["ɡuǐ", null, "鬼,鬼怪的鬼"],
|
||||
@@ -2647,10 +2640,7 @@
|
||||
["hóu", "封侯", "侯,诸侯的侯"],
|
||||
["hòu", "地名", "侯,闽侯的侯"]
|
||||
],
|
||||
"追": [
|
||||
["zhuī", "追求", "追,追赶的追"],
|
||||
["duī", "古同'雕'", "追,追琢的追"]
|
||||
],
|
||||
"追": [["zhuī", "追求", "追,追赶的追"]],
|
||||
"俊": ["jùn", null, "俊,俊杰的俊"],
|
||||
"盾": ["dùn", null, "盾,盾牌的盾"],
|
||||
"待": [
|
||||
@@ -2682,8 +2672,8 @@
|
||||
["pàn", "大号的", "胖,胖袄的胖"]
|
||||
],
|
||||
"脉": [
|
||||
["mò", "含情脉脉", "脉,脉搏的脉"],
|
||||
["mài", "血管", "脉,脉搏的脉"]
|
||||
["mài", "血管", "脉,脉搏的脉"],
|
||||
["mò", "含情脉脉", "脉,含情脉脉的脉"]
|
||||
],
|
||||
"胎": ["tāi", null, "胎,胎儿的胎"],
|
||||
"勉": ["miǎn", null, "勉,勉强的勉"],
|
||||
@@ -2737,8 +2727,8 @@
|
||||
"阀": ["fá", null, "阀,阀门,阀门的阀"],
|
||||
"阁": ["ɡé", null, "阁,阁楼,阁楼的阁"],
|
||||
"差": [
|
||||
["chà", "差别", "差,出差,出差的差"],
|
||||
["chā", "差距", "差,差异的差"],
|
||||
["chà", "差别", "差,差劲的差"],
|
||||
["chā", "差距", "差,差距的差"],
|
||||
["chāi", "差事", "差,出差的差"],
|
||||
["cī", "不齐", "差,参差的差"]
|
||||
],
|
||||
@@ -2876,8 +2866,8 @@
|
||||
"院": ["yuàn", null, "院,庭院的院"],
|
||||
"娃": ["wá", null, "娃,娃娃的娃"],
|
||||
"姥": [
|
||||
["mǔ", "外婆", "姥,姥姥的姥"],
|
||||
["lǎo", "外祖母", "姥,姥姥的姥"]
|
||||
["lǎo", "外祖母", "姥,姥姥的姥"],
|
||||
["mǔ", "外婆", "姥,天姥山的姥"]
|
||||
],
|
||||
"姨": ["yí", null, "姨,姨妈的姨"],
|
||||
"姻": ["yīn", null, "姻,姻亲的姻"],
|
||||
@@ -3087,20 +3077,20 @@
|
||||
"晓": ["xiǎo", null, "晓,清晨的晓"],
|
||||
"哮": ["xiāo", null, "哮,咆哮的哮"],
|
||||
"唠": [
|
||||
["lào", "聊天", "唠,唠叨的唠"],
|
||||
["lào", "聊天", "唠,唠嗑的唠"],
|
||||
["láo", "说个不停", "唠,唠叨的唠"]
|
||||
],
|
||||
"鸭": ["yā", null, "鸭,鸭子的鸭"],
|
||||
"晃": [
|
||||
["huǎng", "晃动(快)", "晃,摇晃的晃"],
|
||||
["huàng", "摇动", "晃,摇晃的晃"]
|
||||
["huàng", "摇动", "晃,摇晃的晃"],
|
||||
["huǎng", "晃动(快)", "晃,晃眼的晃"]
|
||||
],
|
||||
"哺": ["bǔ", null, "哺,喂养的哺"],
|
||||
"晌": ["shǎnɡ", null, "晌,午后的晌"],
|
||||
"剔": ["tī", null, "剔,剔除的剔"],
|
||||
"晕": [
|
||||
["yùn", "头晕", "晕,头晕的晕"],
|
||||
["yūn", "昏厥", "晕,晕倒的晕"]
|
||||
["yūn", "昏厥", "晕,晕倒的晕"],
|
||||
["yùn", "头晕", "晕,光晕的晕"]
|
||||
],
|
||||
"蚌": [
|
||||
["bàng", "河蚌", "蚌,蚌壳的蚌"],
|
||||
@@ -3369,7 +3359,7 @@
|
||||
"涉": ["shè", null, "涉,涉及,牵涉到某件事情或某个领域"],
|
||||
"消": ["xiāo", null, "消,消除,去除、去掉某物"],
|
||||
"涡": [
|
||||
["wō", "旋涡", "涡,漩涡,水流形成的旋转漏斗状物体"],
|
||||
["wō", "旋涡", "涡,漩涡的涡"],
|
||||
["guō", "河流名", "涡,涡河的涡"]
|
||||
],
|
||||
"浩": ["hào", null, "浩,浩瀚,形容水势、星空等非常广阔"],
|
||||
@@ -3454,8 +3444,8 @@
|
||||
"谈": ["tán", null, "谈,谈笑风生的谈"],
|
||||
"谊": ["yì", null, "谊,友谊的谊"],
|
||||
"剥": [
|
||||
["bāo", "口语读音", "剥,剥削的剥"],
|
||||
["bō", "剥离", "剥,剥削的剥"]
|
||||
["bō", "剥离", "剥,剥削的剥"],
|
||||
["bāo", "口语读音", "剥,剥削的剥"]
|
||||
],
|
||||
"恳": ["kěn", null, "恳,恳切的恳"],
|
||||
"展": ["zhǎn", null, "展,展示的展"],
|
||||
@@ -3637,8 +3627,8 @@
|
||||
"常": ["chánɡ", null, "常,常常的常"],
|
||||
"眶": ["kuànɡ", null, "眶,眼眶的眶"],
|
||||
"匙": [
|
||||
["chí", "勺子", "匙,钥匙的匙"],
|
||||
["shi", "开锁工具", "匙,钥匙的匙"]
|
||||
["shi", "开锁工具", "匙,钥匙的匙"],
|
||||
["chí", "勺子", "匙,汤匙的匙"]
|
||||
],
|
||||
"晨": ["chén", null, "晨,早晨的晨"],
|
||||
"睁": ["zhēnɡ", null, "睁,睁开的睁"],
|
||||
@@ -3709,8 +3699,8 @@
|
||||
],
|
||||
"铐": ["kào", null, "铐,手铐的铐"],
|
||||
"铛": [
|
||||
["chēng", "平底锅", "铛,钟铛的铛"],
|
||||
["dāng", "拟声词", "铛,铛铛的铛"]
|
||||
["dāng", "拟声词", "铛,铛铛的铛"],
|
||||
["chēng", "平底锅", "铛,钟铛的铛"]
|
||||
],
|
||||
"铝": ["lǚ", null, "铝,铝制的铝"],
|
||||
"铜": ["tónɡ", null, "铜,铜钱的钱"],
|
||||
@@ -4093,8 +4083,8 @@
|
||||
],
|
||||
"韩": ["hán", null, "韩,韩国的韩"],
|
||||
"朝": [
|
||||
["zhāo", "早晨", "朝,朝鲜的朝"],
|
||||
["cháo", "朝廷", "朝,朝代的朝"]
|
||||
["cháo", "朝廷", "朝,朝代的朝"],
|
||||
["zhāo", "早晨", "朝,朝阳的朝"]
|
||||
],
|
||||
"辜": ["ɡū", null, "辜,辜月的辜"],
|
||||
"葵": ["kuí", null, "葵,向日的葵"],
|
||||
@@ -4135,23 +4125,23 @@
|
||||
"粟": ["sù", null, "粟,粟米,粟的粟"],
|
||||
"棘": ["jí", null, "棘,荆棘,棘的棘"],
|
||||
"酣": ["hān", null, "酣,酣睡,酣的酣"],
|
||||
"酥": ["sū", null, "酥,指用油类和面粉等制成的软食品,如酥油糕点等"],
|
||||
"厨": ["chú", null, "厨,指负责烹饪食物的厨师"],
|
||||
"酥": ["sū", null, "酥,酥油的酥"],
|
||||
"厨": ["chú", null, "厨,厨房的厨"],
|
||||
"厦": [
|
||||
["shà", "高楼", "厦,是厦门市的简称"],
|
||||
["xià", "地名", "厦,厦门的厦"]
|
||||
],
|
||||
"硬": ["yìnɡ", null, "硬,形容物体的质地非常坚硬"],
|
||||
"硝": ["xiāo", null, "硝,指一种化学物质,常用于制作火药等"],
|
||||
"确": ["què", null, "确,表示肯定、确定的意思"],
|
||||
"硫": ["liú", null, "硫,是一种化学元素,常用于制作肥料等"],
|
||||
"雁": ["yàn", null, "雁,一种候鸟,属于鸭科"],
|
||||
"硬": ["yìnɡ", null, "硬,坚硬的硬"],
|
||||
"硝": ["xiāo", null, "硝,硝酸的硝"],
|
||||
"确": ["què", null, "确,正确的确"],
|
||||
"硫": ["liú", null, "硫,硫磺的硫"],
|
||||
"雁": ["yàn", null, "雁,大雁的雁"],
|
||||
"殖": [
|
||||
["zhí", "殖民", "殖,指生物体通过生殖繁衍后代的过程"],
|
||||
["zhí", "殖民", "殖,殖民的殖"],
|
||||
["shi", "骨头", "殖,骨殖的殖"]
|
||||
],
|
||||
"裂": [
|
||||
["liè", "分裂", "裂,指物体表面出现的裂缝或分裂"],
|
||||
["liè", "分裂", "裂,裂缝的裂"],
|
||||
["liě", "口语", "裂,裂开的裂"]
|
||||
],
|
||||
"雄": ["xiónɡ", null, "雄,形容事物雄伟、壮观"],
|
||||
@@ -4365,8 +4355,8 @@
|
||||
["suì", "于是", "遂,遂愿的遂"]
|
||||
],
|
||||
"曾": [
|
||||
["zēng", "隔代", "曾,曾经的曾"],
|
||||
["céng", "曾经", "曾,曾经的曾"]
|
||||
["céng", "曾经", "曾,曾经的曾"],
|
||||
["zēng", "隔代", "曾,曾孙的曾"]
|
||||
],
|
||||
"焰": ["yàn", null, "焰,火焰的焰"],
|
||||
"港": [
|
||||
@@ -4666,7 +4656,7 @@
|
||||
["xiè", "姓氏", "解,解姓的解"]
|
||||
],
|
||||
"煞": [
|
||||
["shā", "极;很", "煞,煞费苦心煞"],
|
||||
["shā", "极;很", "煞,煞费苦心的煞"],
|
||||
["shà", "凶气", "煞,煞气的煞"]
|
||||
],
|
||||
"雏": ["chú", null, "雏,雏菊雏"],
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"generated":["一_yi1","乙_yi3","二_er4","十_shi2","丁_ding1","厂_chang3","七_qi1","卜_bu3","八_ba1","人_ren2"],"updatedAt":"2026-02-11T03:30:00.988Z"}
|
||||
@@ -7,6 +7,7 @@
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"generate-introduce": "cross-env node scripts/generate_char_introduce.js",
|
||||
"generate-voice": "node scripts/generate_char_voice.js",
|
||||
"generate-voice-v2": "node scripts/generate_char_voice_v2.js",
|
||||
"deploy-assets": "node scripts/deploy-assets.js",
|
||||
"deploy-assets:local": "node scripts/deploy-assets.js --local",
|
||||
"deploy-assets:server": "node scripts/deploy-assets.js --server"
|
||||
|
||||
@@ -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