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