diff --git a/packages/voice-production/data/char/char_introduce_v2.json b/packages/voice-production/data/char/char_introduce_v2.json index fcff8e9..a12ed80 100644 --- a/packages/voice-production/data/char/char_introduce_v2.json +++ b/packages/voice-production/data/char/char_introduce_v2.json @@ -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, "雏,雏菊雏"], diff --git a/packages/voice-production/data/char/voice_generated.json b/packages/voice-production/data/temp/voice_generated.json similarity index 100% rename from packages/voice-production/data/char/voice_generated.json rename to packages/voice-production/data/temp/voice_generated.json diff --git a/packages/voice-production/data/temp/voice_generated_v2.json b/packages/voice-production/data/temp/voice_generated_v2.json new file mode 100644 index 0000000..2dfeb38 --- /dev/null +++ b/packages/voice-production/data/temp/voice_generated_v2.json @@ -0,0 +1 @@ +{"generated":["一_yi1","乙_yi3","二_er4","十_shi2","丁_ding1","厂_chang3","七_qi1","卜_bu3","八_ba1","人_ren2"],"updatedAt":"2026-02-11T03:30:00.988Z"} \ No newline at end of file diff --git a/packages/voice-production/package.json b/packages/voice-production/package.json index 1c140cc..f3a46ed 100644 --- a/packages/voice-production/package.json +++ b/packages/voice-production/package.json @@ -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" diff --git a/packages/voice-production/scripts/generate_char_voice_v2.js b/packages/voice-production/scripts/generate_char_voice_v2.js new file mode 100644 index 0000000..9dce9f4 --- /dev/null +++ b/packages/voice-production/scripts/generate_char_voice_v2.js @@ -0,0 +1,397 @@ +/** + * 使用腾讯云语音合成 TTS 为汉字介绍生成语音(v2) + * 读取 char_introduce_v2.json,按单音字/多音字取第一条 [pinyin, meaning, introduce], + * 用 SSML 仅标注当前字的读音,生成语音保存到 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 中所有「当前字」用 包裹,只标该字读音 + */ +function buildSSML(introduce, char, pinyinPhoneme) { + const tag = `${escapeXml(char)}`; + const parts = introduce.split(char); + const ssmlBody = parts.join(tag); + return `${ssmlBody}`; +} + +function escapeXml(s) { + return s + .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);