From 27f74048162c7c10567de57b68e55f24e3ed324e Mon Sep 17 00:00:00 2001 From: R524809 Date: Mon, 2 Feb 2026 10:38:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E7=94=9F=E6=88=90=E9=9F=B3=E9=A2=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .eslintignore | 5 + .prettierignore | 12 + .prettierrc | 15 + eslint.config.mjs | 47 ++ package.json | 15 +- packages/voice-production/.env.example | 6 + packages/voice-production/.gitignore | 11 + .../data/char/char_introduce copy.json | 662 ++++++++++++++++++ .../data/char/char_introduce.json | 662 ++++++++++++++++++ .../data/char/char_string.json | 4 +- .../data/char/voice_generated.json | 170 +++++ packages/voice-production/package.json | 29 +- .../scripts/generate_char_introduce.js | 419 +++++------ .../scripts/generate_char_voice.js | 228 ++++++ pnpm-lock.yaml | 36 + 15 files changed, 2099 insertions(+), 222 deletions(-) create mode 100644 .eslintignore create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 eslint.config.mjs create mode 100644 packages/voice-production/.env.example create mode 100644 packages/voice-production/.gitignore create mode 100644 packages/voice-production/data/char/char_introduce copy.json create mode 100644 packages/voice-production/data/char/char_introduce.json create mode 100644 packages/voice-production/data/char/voice_generated.json create mode 100644 packages/voice-production/scripts/generate_char_voice.js diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..667e1eb --- /dev/null +++ b/.eslintignore @@ -0,0 +1,5 @@ +node_modules +dist +build +.turbo +coverage diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..2187151 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,12 @@ +node_modules +dist +build +.turbo +coverage +pnpm-lock.yaml +*.min.js +*.min.css + +# 语音生成记录文件,紧凑格式避免自动格式化 +**/voice_generated.json + diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..68e058a --- /dev/null +++ b/.prettierrc @@ -0,0 +1,15 @@ +{ + "printWidth": 100, + "tabWidth": 4, + "useTabs": false, + "semi": true, + "singleQuote": true, + "quoteProps": "as-needed", + "jsxSingleQuote": false, + "trailingComma": "es5", + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "always", + "endOfLine": "lf", + "embeddedLanguageFormatting": "auto" +} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..66b076c --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,47 @@ +// @ts-check +import eslint from '@eslint/js'; +import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: [ + '**/node_modules/**', + '**/dist/**', + '**/build/**', + '**/.turbo/**', + '**/coverage/**', + 'eslint.config.mjs', + ], + }, + eslint.configs.recommended, + ...tseslint.configs.recommended, + eslintPluginPrettierRecommended, + { + languageOptions: { + globals: { + ...globals.node, + ...globals.browser, + ...globals.jest, + }, + ecmaVersion: 2022, + sourceType: 'module', + }, + }, + { + rules: { + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }, + ], + '@typescript-eslint/no-require-imports': 'warn', + 'no-console': 'off', + 'prettier/prettier': ['error', { endOfLine: 'auto' }], + }, + } +); diff --git a/package.json b/package.json index 3d9e3ef..8b5f6a2 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,14 @@ "build": "pnpm -r build", "build:api": "pnpm --filter api build", "build:web": "pnpm --filter web build", - "lint": "pnpm -r lint", + "lint": "eslint .", + "lint:fix": "eslint . --fix", "lint:api": "pnpm --filter api lint", "lint:web": "pnpm --filter web lint", "test": "pnpm -r test", "test:api": "pnpm --filter api test", - "format": "pnpm -r format", + "format": "prettier --write .", + "format:check": "prettier --check .", "format:api": "pnpm --filter api format" }, "keywords": [ @@ -36,7 +38,14 @@ "pnpm": ">=8.0.0" }, "devDependencies": { - "concurrently": "^9.1.2" + "@eslint/js": "^9.18.0", + "concurrently": "^9.1.2", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.3", + "globals": "^15.14.0", + "prettier": "^3.4.2", + "typescript-eslint": "^8.21.0" }, "packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48" } diff --git a/packages/voice-production/.env.example b/packages/voice-production/.env.example new file mode 100644 index 0000000..2943ab2 --- /dev/null +++ b/packages/voice-production/.env.example @@ -0,0 +1,6 @@ +# 腾讯云混元大模型配置 +# 请从腾讯云控制台获取 SecretId 和 SecretKey +# https://console.cloud.tencent.com/cam/capi + +TENCENT_SECRET_ID=your_secret_id_here +TENCENT_SECRET_KEY=your_secret_key_here diff --git a/packages/voice-production/.gitignore b/packages/voice-production/.gitignore new file mode 100644 index 0000000..4f99d35 --- /dev/null +++ b/packages/voice-production/.gitignore @@ -0,0 +1,11 @@ +# 环境变量文件 +.env +.env.local + +# Node +node_modules/ + +# voice +data/voice/* +# 输出文件(可选,如果不想提交生成的数据) +# data/char/char_introduce.json diff --git a/packages/voice-production/data/char/char_introduce copy.json b/packages/voice-production/data/char/char_introduce copy.json new file mode 100644 index 0000000..ca8cca4 --- /dev/null +++ b/packages/voice-production/data/char/char_introduce copy.json @@ -0,0 +1,662 @@ +[ + { + "char": "一", + "introduce": "一,一二三四的一" + }, + { + "char": "乙", + "introduce": "乙,甲乙丙丁的乙" + }, + { + "char": "二", + "introduce": "二,一二三四的二" + }, + { + "char": "十", + "introduce": "十,十全十美的十" + }, + { + "char": "丁", + "introduce": "丁,丁卯的丁" + }, + { + "char": "厂", + "introduce": "厂,厂长厂的厂" + }, + { + "char": "七", + "introduce": "七,七上八下的七" + }, + { + "char": "卜", + "introduce": "卜,占卜的卜" + }, + { + "char": "八", + "introduce": "八,八面玲珑的八" + }, + { + "char": "人", + "introduce": "人,人才的人" + }, + { + "char": "入", + "introduce": "入,入木三分的入" + }, + { + "char": "儿", + "introduce": "儿,儿童的儿" + }, + { + "char": "匕", + "introduce": "匕,匕首的匕" + }, + { + "char": "几", + "introduce": "几,几个的几" + }, + { + "char": "九", + "introduce": "九,九五之尊的九" + }, + { + "char": "刁", + "introduce": "刁,刁蛮的刁" + }, + { + "char": "了", + "introduce": "了,了然的了" + }, + { + "char": "刀", + "introduce": "刀,刀刃的刀" + }, + { + "char": "力", + "introduce": "力,力量的力" + }, + { + "char": "乃", + "introduce": "乃,乃父的乃" + }, + { + "char": "又", + "introduce": "又,又来一个的又" + }, + { + "char": "三", + "introduce": "三,一二三的三" + }, + { + "char": "干", + "introduce": "干,干活的干" + }, + { + "char": "于", + "introduce": "于,于是的于" + }, + { + "char": "亏", + "introduce": "亏,亏损的亏" + }, + { + "char": "工", + "introduce": "工,工人的人" + }, + { + "char": "土", + "introduce": "土,土地的土" + }, + { + "char": "士", + "introduce": "士,士气的士" + }, + { + "char": "才", + "introduce": "才,才能的才" + }, + { + "char": "下", + "introduce": "下,下山的下" + }, + { + "char": "寸", + "introduce": "寸,寸步难行的寸" + }, + { + "char": "大", + "introduce": "大,巨大的大" + }, + { + "char": "丈", + "introduce": "丈,十丈的丈" + }, + { + "char": "与", + "introduce": "与,与人为善的与" + }, + { + "char": "万", + "introduce": "万,万马奔腾的万" + }, + { + "char": "上", + "introduce": "上,上山的上" + }, + { + "char": "小", + "introduce": "小,小小的小" + }, + { + "char": "口", + "introduce": "口,口水的口" + }, + { + "char": "山", + "introduce": "山,山峰的山" + }, + { + "char": "巾", + "introduce": "巾,毛巾的巾" + }, + { + "char": "千", + "introduce": "千,千年的千" + }, + { + "char": "乞", + "introduce": "乞,乞丐的乞" + }, + { + "char": "川", + "introduce": "川,山川的川" + }, + { + "char": "亿", + "introduce": "亿,亿万富翁的亿" + }, + { + "char": "个", + "introduce": "个,一个的个" + }, + { + "char": "夕", + "introduce": "夕,夕阳的夕" + }, + { + "char": "久", + "introduce": "久,长久的久" + }, + { + "char": "么", + "introduce": "么,多么的么" + }, + { + "char": "勺", + "introduce": "勺,勺子的勺" + }, + { + "char": "凡", + "introduce": "凡,平凡的凡" + }, + { + "char": "丸", + "introduce": "丸,丸子的丸" + }, + { + "char": "及", + "introduce": "及,来不及的及" + }, + { + "char": "广", + "introduce": "广,广阔的广" + }, + { + "char": "亡", + "introduce": "亡,灭亡的亡" + }, + { + "char": "门", + "introduce": "门,门外的门" + }, + { + "char": "丫", + "introduce": "丫,丫头的丫" + }, + { + "char": "义", + "introduce": "义,正义的义" + }, + { + "char": "之", + "introduce": "之,之乎者也的之" + }, + { + "char": "尸", + "introduce": "尸,尸体的尸" + }, + { + "char": "己", + "introduce": "己,自己的己" + }, + { + "char": "已", + "introduce": "已,已经的已" + }, + { + "char": "巳", + "introduce": "巳,巳蛇的巳" + }, + { + "char": "弓", + "introduce": "弓,弓箭的弓" + }, + { + "char": "子", + "introduce": "子,孩子的子" + }, + { + "char": "卫", + "introduce": "卫,卫生的卫" + }, + { + "char": "也", + "introduce": "也,也是的也是" + }, + { + "char": "女", + "introduce": "女,女人的女" + }, + { + "char": "刃", + "introduce": "刃,刀刃的刃" + }, + { + "char": "飞", + "introduce": "飞,飞翔的飞" + }, + { + "char": "习", + "introduce": "习,习惯的习" + }, + { + "char": "叉", + "introduce": "叉,叉子的叉" + }, + { + "char": "马", + "introduce": "马,马匹的马" + }, + { + "char": "乡", + "introduce": "乡,乡村的乡" + }, + { + "char": "丰", + "introduce": "丰,丰富的丰" + }, + { + "char": "王", + "introduce": "王,国王的王" + }, + { + "char": "开", + "introduce": "开,开心的开" + }, + { + "char": "井", + "introduce": "井,井水的井" + }, + { + "char": "天", + "introduce": "天,天空的天" + }, + { + "char": "夫", + "introduce": "夫,丈夫的夫" + }, + { + "char": "元", + "introduce": "元,元宵节的元" + }, + { + "char": "无", + "introduce": "无,无所谓的无" + }, + { + "char": "云", + "introduce": "云,白云的云" + }, + { + "char": "专", + "introduce": "专,专注的专" + }, + { + "char": "丐", + "introduce": "丐,乞丐的丐" + }, + { + "char": "扎", + "introduce": "扎,扎针的扎" + }, + { + "char": "艺", + "introduce": "艺,艺术的艺" + }, + { + "char": "木", + "introduce": "木,木头的木" + }, + { + "char": "五", + "introduce": "五,五月的五" + }, + { + "char": "支", + "introduce": "支,支撑的支" + }, + { + "char": "厅", + "introduce": "厅,大厅的厅" + }, + { + "char": "不", + "introduce": "不,不行的不" + }, + { + "char": "犬", + "introduce": "犬,犬科的犬" + }, + { + "char": "太", + "introduce": "太,太阳的太" + }, + { + "char": "区", + "introduce": "区,区分区的区" + }, + { + "char": "历", + "introduce": "历,历史的历" + }, + { + "char": "歹", + "introduce": "歹,歹徒的歹" + }, + { + "char": "友", + "introduce": "友,朋友的友" + }, + { + "char": "尤", + "introduce": "尤,尤其的尤" + }, + { + "char": "匹", + "introduce": "匹,匹配的匹" + }, + { + "char": "车", + "introduce": "车,汽车的车" + }, + { + "char": "巨", + "introduce": "巨,巨大的巨" + }, + { + "char": "牙", + "introduce": "牙,牙齿的牙" + }, + { + "char": "屯", + "introduce": "屯,屯田的屯" + }, + { + "char": "戈", + "introduce": "戈,戈壁的戈" + }, + { + "char": "比", + "introduce": "比,比较的比" + }, + { + "char": "互", + "introduce": "互,交互的互" + }, + { + "char": "切", + "introduce": "切,亲切的切" + }, + { + "char": "瓦", + "introduce": "瓦,瓦片的瓦" + }, + { + "char": "止", + "introduce": "止,停止的止" + }, + { + "char": "少", + "introduce": "少,多少的少" + }, + { + "char": "曰", + "introduce": "曰,说话的曰" + }, + { + "char": "日", + "introduce": "日,日期的日" + }, + { + "char": "中", + "introduce": "中,中间的中" + }, + { + "char": "贝", + "introduce": "贝,贝壳的贝" + }, + { + "char": "冈", + "introduce": "冈,山冈的冈" + }, + { + "char": "内", + "introduce": "内,内部的内" + }, + { + "char": "水", + "introduce": "水,水流的水" + }, + { + "char": "见", + "introduce": "见,看见的见" + }, + { + "char": "午", + "introduce": "午,午后的午" + }, + { + "char": "牛", + "introduce": "牛,牛排的牛" + }, + { + "char": "手", + "introduce": "手,手指的手" + }, + { + "char": "气", + "introduce": "气,生气的气" + }, + { + "char": "毛", + "introduce": "毛,羽毛的毛" + }, + { + "char": "壬", + "introduce": "壬,壬寅的壬" + }, + { + "char": "升", + "introduce": "升,上升的升" + }, + { + "char": "夭", + "introduce": "夭,夭折的夭" + }, + { + "char": "长", + "introduce": "长,长度的长" + }, + { + "char": "仁", + "introduce": "仁,仁爱的仁" + }, + { + "char": "什", + "introduce": "什,什么的什" + }, + { + "char": "片", + "introduce": "片,影片的片" + }, + { + "char": "仆", + "introduce": "仆,仆从的仆" + }, + { + "char": "化", + "introduce": "化,变化的化" + }, + { + "char": "仇", + "introduce": "仇,仇恨的仇" + }, + { + "char": "币", + "introduce": "币,货币的币" + }, + { + "char": "仍", + "introduce": "仍,仍然的仍" + }, + { + "char": "仅", + "introduce": "仅,仅有的仅" + }, + { + "char": "斤", + "introduce": "斤,斤斤计较的斤" + }, + { + "char": "爪", + "introduce": "爪,爪牙的爪" + }, + { + "char": "反", + "introduce": "反,相反的反" + }, + { + "char": "介", + "introduce": "介,介绍的介" + }, + { + "char": "父", + "introduce": "父,父亲的父" + }, + { + "char": "从", + "introduce": "从,从前的从" + }, + { + "char": "仑", + "introduce": "仑,昆仑山的仑" + }, + { + "char": "今", + "introduce": "今,今天的今" + }, + { + "char": "凶", + "introduce": "凶,凶猛的凶" + }, + { + "char": "分", + "introduce": "分,分开的分" + }, + { + "char": "乏", + "introduce": "乏,乏力的乏" + }, + { + "char": "公", + "introduce": "公,公牛的公" + }, + { + "char": "仓", + "introduce": "仓,粮仓的仓" + }, + { + "char": "月", + "introduce": "月,月亮的月" + }, + { + "char": "氏", + "introduce": "氏,氏族的氏" + }, + { + "char": "勿", + "introduce": "勿,勿扰的勿" + }, + { + "char": "欠", + "introduce": "欠,欠缺的欠" + }, + { + "char": "风", + "introduce": "风,风力的风" + }, + { + "char": "丹", + "introduce": "丹,丹青的丹" + }, + { + "char": "匀", + "introduce": "匀,均匀的匀" + }, + { + "char": "乌", + "introduce": "乌,乌鸦的乌" + }, + { + "char": "勾", + "introduce": "勾,勾画的勾" + }, + { + "char": "凤", + "introduce": "凤,凤凰的凤" + }, + { + "char": "六", + "introduce": "六,六月的六" + }, + { + "char": "文", + "introduce": "文,文字的文" + }, + { + "char": "亢", + "introduce": "亢,亢奋的亢" + }, + { + "char": "方", + "introduce": "方,方形的方" + }, + { + "char": "火", + "introduce": "火,火山的火" + }, + { + "char": "为", + "introduce": "为,作为的为" + } +] diff --git a/packages/voice-production/data/char/char_introduce.json b/packages/voice-production/data/char/char_introduce.json new file mode 100644 index 0000000..ca8cca4 --- /dev/null +++ b/packages/voice-production/data/char/char_introduce.json @@ -0,0 +1,662 @@ +[ + { + "char": "一", + "introduce": "一,一二三四的一" + }, + { + "char": "乙", + "introduce": "乙,甲乙丙丁的乙" + }, + { + "char": "二", + "introduce": "二,一二三四的二" + }, + { + "char": "十", + "introduce": "十,十全十美的十" + }, + { + "char": "丁", + "introduce": "丁,丁卯的丁" + }, + { + "char": "厂", + "introduce": "厂,厂长厂的厂" + }, + { + "char": "七", + "introduce": "七,七上八下的七" + }, + { + "char": "卜", + "introduce": "卜,占卜的卜" + }, + { + "char": "八", + "introduce": "八,八面玲珑的八" + }, + { + "char": "人", + "introduce": "人,人才的人" + }, + { + "char": "入", + "introduce": "入,入木三分的入" + }, + { + "char": "儿", + "introduce": "儿,儿童的儿" + }, + { + "char": "匕", + "introduce": "匕,匕首的匕" + }, + { + "char": "几", + "introduce": "几,几个的几" + }, + { + "char": "九", + "introduce": "九,九五之尊的九" + }, + { + "char": "刁", + "introduce": "刁,刁蛮的刁" + }, + { + "char": "了", + "introduce": "了,了然的了" + }, + { + "char": "刀", + "introduce": "刀,刀刃的刀" + }, + { + "char": "力", + "introduce": "力,力量的力" + }, + { + "char": "乃", + "introduce": "乃,乃父的乃" + }, + { + "char": "又", + "introduce": "又,又来一个的又" + }, + { + "char": "三", + "introduce": "三,一二三的三" + }, + { + "char": "干", + "introduce": "干,干活的干" + }, + { + "char": "于", + "introduce": "于,于是的于" + }, + { + "char": "亏", + "introduce": "亏,亏损的亏" + }, + { + "char": "工", + "introduce": "工,工人的人" + }, + { + "char": "土", + "introduce": "土,土地的土" + }, + { + "char": "士", + "introduce": "士,士气的士" + }, + { + "char": "才", + "introduce": "才,才能的才" + }, + { + "char": "下", + "introduce": "下,下山的下" + }, + { + "char": "寸", + "introduce": "寸,寸步难行的寸" + }, + { + "char": "大", + "introduce": "大,巨大的大" + }, + { + "char": "丈", + "introduce": "丈,十丈的丈" + }, + { + "char": "与", + "introduce": "与,与人为善的与" + }, + { + "char": "万", + "introduce": "万,万马奔腾的万" + }, + { + "char": "上", + "introduce": "上,上山的上" + }, + { + "char": "小", + "introduce": "小,小小的小" + }, + { + "char": "口", + "introduce": "口,口水的口" + }, + { + "char": "山", + "introduce": "山,山峰的山" + }, + { + "char": "巾", + "introduce": "巾,毛巾的巾" + }, + { + "char": "千", + "introduce": "千,千年的千" + }, + { + "char": "乞", + "introduce": "乞,乞丐的乞" + }, + { + "char": "川", + "introduce": "川,山川的川" + }, + { + "char": "亿", + "introduce": "亿,亿万富翁的亿" + }, + { + "char": "个", + "introduce": "个,一个的个" + }, + { + "char": "夕", + "introduce": "夕,夕阳的夕" + }, + { + "char": "久", + "introduce": "久,长久的久" + }, + { + "char": "么", + "introduce": "么,多么的么" + }, + { + "char": "勺", + "introduce": "勺,勺子的勺" + }, + { + "char": "凡", + "introduce": "凡,平凡的凡" + }, + { + "char": "丸", + "introduce": "丸,丸子的丸" + }, + { + "char": "及", + "introduce": "及,来不及的及" + }, + { + "char": "广", + "introduce": "广,广阔的广" + }, + { + "char": "亡", + "introduce": "亡,灭亡的亡" + }, + { + "char": "门", + "introduce": "门,门外的门" + }, + { + "char": "丫", + "introduce": "丫,丫头的丫" + }, + { + "char": "义", + "introduce": "义,正义的义" + }, + { + "char": "之", + "introduce": "之,之乎者也的之" + }, + { + "char": "尸", + "introduce": "尸,尸体的尸" + }, + { + "char": "己", + "introduce": "己,自己的己" + }, + { + "char": "已", + "introduce": "已,已经的已" + }, + { + "char": "巳", + "introduce": "巳,巳蛇的巳" + }, + { + "char": "弓", + "introduce": "弓,弓箭的弓" + }, + { + "char": "子", + "introduce": "子,孩子的子" + }, + { + "char": "卫", + "introduce": "卫,卫生的卫" + }, + { + "char": "也", + "introduce": "也,也是的也是" + }, + { + "char": "女", + "introduce": "女,女人的女" + }, + { + "char": "刃", + "introduce": "刃,刀刃的刃" + }, + { + "char": "飞", + "introduce": "飞,飞翔的飞" + }, + { + "char": "习", + "introduce": "习,习惯的习" + }, + { + "char": "叉", + "introduce": "叉,叉子的叉" + }, + { + "char": "马", + "introduce": "马,马匹的马" + }, + { + "char": "乡", + "introduce": "乡,乡村的乡" + }, + { + "char": "丰", + "introduce": "丰,丰富的丰" + }, + { + "char": "王", + "introduce": "王,国王的王" + }, + { + "char": "开", + "introduce": "开,开心的开" + }, + { + "char": "井", + "introduce": "井,井水的井" + }, + { + "char": "天", + "introduce": "天,天空的天" + }, + { + "char": "夫", + "introduce": "夫,丈夫的夫" + }, + { + "char": "元", + "introduce": "元,元宵节的元" + }, + { + "char": "无", + "introduce": "无,无所谓的无" + }, + { + "char": "云", + "introduce": "云,白云的云" + }, + { + "char": "专", + "introduce": "专,专注的专" + }, + { + "char": "丐", + "introduce": "丐,乞丐的丐" + }, + { + "char": "扎", + "introduce": "扎,扎针的扎" + }, + { + "char": "艺", + "introduce": "艺,艺术的艺" + }, + { + "char": "木", + "introduce": "木,木头的木" + }, + { + "char": "五", + "introduce": "五,五月的五" + }, + { + "char": "支", + "introduce": "支,支撑的支" + }, + { + "char": "厅", + "introduce": "厅,大厅的厅" + }, + { + "char": "不", + "introduce": "不,不行的不" + }, + { + "char": "犬", + "introduce": "犬,犬科的犬" + }, + { + "char": "太", + "introduce": "太,太阳的太" + }, + { + "char": "区", + "introduce": "区,区分区的区" + }, + { + "char": "历", + "introduce": "历,历史的历" + }, + { + "char": "歹", + "introduce": "歹,歹徒的歹" + }, + { + "char": "友", + "introduce": "友,朋友的友" + }, + { + "char": "尤", + "introduce": "尤,尤其的尤" + }, + { + "char": "匹", + "introduce": "匹,匹配的匹" + }, + { + "char": "车", + "introduce": "车,汽车的车" + }, + { + "char": "巨", + "introduce": "巨,巨大的巨" + }, + { + "char": "牙", + "introduce": "牙,牙齿的牙" + }, + { + "char": "屯", + "introduce": "屯,屯田的屯" + }, + { + "char": "戈", + "introduce": "戈,戈壁的戈" + }, + { + "char": "比", + "introduce": "比,比较的比" + }, + { + "char": "互", + "introduce": "互,交互的互" + }, + { + "char": "切", + "introduce": "切,亲切的切" + }, + { + "char": "瓦", + "introduce": "瓦,瓦片的瓦" + }, + { + "char": "止", + "introduce": "止,停止的止" + }, + { + "char": "少", + "introduce": "少,多少的少" + }, + { + "char": "曰", + "introduce": "曰,说话的曰" + }, + { + "char": "日", + "introduce": "日,日期的日" + }, + { + "char": "中", + "introduce": "中,中间的中" + }, + { + "char": "贝", + "introduce": "贝,贝壳的贝" + }, + { + "char": "冈", + "introduce": "冈,山冈的冈" + }, + { + "char": "内", + "introduce": "内,内部的内" + }, + { + "char": "水", + "introduce": "水,水流的水" + }, + { + "char": "见", + "introduce": "见,看见的见" + }, + { + "char": "午", + "introduce": "午,午后的午" + }, + { + "char": "牛", + "introduce": "牛,牛排的牛" + }, + { + "char": "手", + "introduce": "手,手指的手" + }, + { + "char": "气", + "introduce": "气,生气的气" + }, + { + "char": "毛", + "introduce": "毛,羽毛的毛" + }, + { + "char": "壬", + "introduce": "壬,壬寅的壬" + }, + { + "char": "升", + "introduce": "升,上升的升" + }, + { + "char": "夭", + "introduce": "夭,夭折的夭" + }, + { + "char": "长", + "introduce": "长,长度的长" + }, + { + "char": "仁", + "introduce": "仁,仁爱的仁" + }, + { + "char": "什", + "introduce": "什,什么的什" + }, + { + "char": "片", + "introduce": "片,影片的片" + }, + { + "char": "仆", + "introduce": "仆,仆从的仆" + }, + { + "char": "化", + "introduce": "化,变化的化" + }, + { + "char": "仇", + "introduce": "仇,仇恨的仇" + }, + { + "char": "币", + "introduce": "币,货币的币" + }, + { + "char": "仍", + "introduce": "仍,仍然的仍" + }, + { + "char": "仅", + "introduce": "仅,仅有的仅" + }, + { + "char": "斤", + "introduce": "斤,斤斤计较的斤" + }, + { + "char": "爪", + "introduce": "爪,爪牙的爪" + }, + { + "char": "反", + "introduce": "反,相反的反" + }, + { + "char": "介", + "introduce": "介,介绍的介" + }, + { + "char": "父", + "introduce": "父,父亲的父" + }, + { + "char": "从", + "introduce": "从,从前的从" + }, + { + "char": "仑", + "introduce": "仑,昆仑山的仑" + }, + { + "char": "今", + "introduce": "今,今天的今" + }, + { + "char": "凶", + "introduce": "凶,凶猛的凶" + }, + { + "char": "分", + "introduce": "分,分开的分" + }, + { + "char": "乏", + "introduce": "乏,乏力的乏" + }, + { + "char": "公", + "introduce": "公,公牛的公" + }, + { + "char": "仓", + "introduce": "仓,粮仓的仓" + }, + { + "char": "月", + "introduce": "月,月亮的月" + }, + { + "char": "氏", + "introduce": "氏,氏族的氏" + }, + { + "char": "勿", + "introduce": "勿,勿扰的勿" + }, + { + "char": "欠", + "introduce": "欠,欠缺的欠" + }, + { + "char": "风", + "introduce": "风,风力的风" + }, + { + "char": "丹", + "introduce": "丹,丹青的丹" + }, + { + "char": "匀", + "introduce": "匀,均匀的匀" + }, + { + "char": "乌", + "introduce": "乌,乌鸦的乌" + }, + { + "char": "勾", + "introduce": "勾,勾画的勾" + }, + { + "char": "凤", + "introduce": "凤,凤凰的凤" + }, + { + "char": "六", + "introduce": "六,六月的六" + }, + { + "char": "文", + "introduce": "文,文字的文" + }, + { + "char": "亢", + "introduce": "亢,亢奋的亢" + }, + { + "char": "方", + "introduce": "方,方形的方" + }, + { + "char": "火", + "introduce": "火,火山的火" + }, + { + "char": "为", + "introduce": "为,作为的为" + } +] diff --git a/packages/voice-production/data/char/char_string.json b/packages/voice-production/data/char/char_string.json index c84bdeb..e811428 100644 --- a/packages/voice-production/data/char/char_string.json +++ b/packages/voice-production/data/char/char_string.json @@ -1,3 +1,3 @@ { - "3500": "一乙二十丁厂七天" -} \ No newline at end of file + "3500": "一乙二十丁厂七卜八人入儿匕几九刁了刀力乃又三干于亏工土士才下寸大丈与万上小口山巾千乞川亿个夕久么勺凡丸及广亡门丫义之尸己已巳弓子卫也女刃飞习叉马乡丰王开井天夫元无云专丐扎艺木五支厅不犬太区历歹友尤匹车巨牙屯戈比互切瓦止少曰日中贝冈内水见午牛手气毛壬升夭长仁什片仆化仇币仍仅斤爪反介父从仑今凶分乏公仓月氏勿欠风丹匀乌勾凤六文亢方火为" +} diff --git a/packages/voice-production/data/char/voice_generated.json b/packages/voice-production/data/char/voice_generated.json new file mode 100644 index 0000000..de6adfc --- /dev/null +++ b/packages/voice-production/data/char/voice_generated.json @@ -0,0 +1,170 @@ +{ + "chars": [ + "一", + "乙", + "二", + "十", + "丁", + "厂", + "七", + "卜", + "八", + "人", + "入", + "儿", + "匕", + "几", + "九", + "刁", + "了", + "刀", + "力", + "乃", + "又", + "三", + "干", + "于", + "亏", + "工", + "土", + "士", + "才", + "下", + "寸", + "大", + "丈", + "与", + "万", + "上", + "小", + "口", + "山", + "巾", + "千", + "乞", + "川", + "亿", + "个", + "夕", + "久", + "么", + "勺", + "凡", + "丸", + "及", + "广", + "亡", + "门", + "丫", + "义", + "之", + "尸", + "己", + "已", + "巳", + "弓", + "子", + "卫", + "也", + "女", + "刃", + "飞", + "习", + "叉", + "马", + "乡", + "丰", + "王", + "开", + "井", + "天", + "夫", + "元", + "无", + "云", + "专", + "丐", + "扎", + "艺", + "木", + "五", + "支", + "厅", + "不", + "犬", + "太", + "区", + "历", + "歹", + "友", + "尤", + "匹", + "车", + "巨", + "牙", + "屯", + "戈", + "比", + "互", + "切", + "瓦", + "止", + "少", + "曰", + "日", + "中", + "贝", + "冈", + "内", + "水", + "见", + "午", + "牛", + "手", + "气", + "毛", + "壬", + "升", + "夭", + "长", + "仁", + "什", + "片", + "仆", + "化", + "仇", + "币", + "仍", + "仅", + "斤", + "爪", + "反", + "介", + "父", + "从", + "仑", + "今", + "凶", + "分", + "乏", + "公", + "仓", + "月", + "氏", + "勿", + "欠", + "风", + "丹", + "匀", + "乌", + "勾", + "凤", + "六", + "文", + "亢", + "方", + "火", + "为" + ], + "updatedAt": "2026-02-02T02:17:19.186Z" +} diff --git a/packages/voice-production/package.json b/packages/voice-production/package.json index 797d5c1..3b73d83 100644 --- a/packages/voice-production/package.json +++ b/packages/voice-production/package.json @@ -1,14 +1,19 @@ { - "name": "voice-production", - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", - "generate:char-introduce": "node scripts/generate_char_introduce.js" - }, - "keywords": [], - "author": "", - "license": "ISC", - "packageManager": "pnpm@10.28.0" + "name": "voice-production", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "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" + }, + "keywords": [], + "author": "", + "license": "ISC", + "packageManager": "pnpm@10.28.0", + "dependencies": { + "cross-env": "^7.0.3", + "dotenv": "^16.4.7" + } } diff --git a/packages/voice-production/scripts/generate_char_introduce.js b/packages/voice-production/scripts/generate_char_introduce.js index 849f074..ed53a47 100644 --- a/packages/voice-production/scripts/generate_char_introduce.js +++ b/packages/voice-production/scripts/generate_char_introduce.js @@ -1,253 +1,262 @@ -const fs = require("fs"); -const path = require("path"); -const https = require("https"); -const crypto = require("crypto"); +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const crypto = require('crypto'); +require('dotenv').config({ path: path.join(__dirname, '../.env') }); // 腾讯云混元大模型配置 -// 请设置环境变量 TENCENT_SECRET_ID 和 TENCENT_SECRET_KEY +// 请在 .env 文件中配置 TENCENT_SECRET_ID 和 TENCENT_SECRET_KEY const SECRET_ID = process.env.TENCENT_SECRET_ID; const SECRET_KEY = process.env.TENCENT_SECRET_KEY; // API配置 -const HOST = "hunyuan.tencentcloudapi.com"; -const SERVICE = "hunyuan"; -const REGION = "ap-guangzhou"; -const ACTION = "ChatCompletions"; -const VERSION = "2023-09-01"; -const MODEL = "hunyuan-lite"; // 使用混元lite模型,可根据需要更换 +const HOST = 'hunyuan.tencentcloudapi.com'; +const SERVICE = 'hunyuan'; +const REGION = 'ap-guangzhou'; +const ACTION = 'ChatCompletions'; +const VERSION = '2023-09-01'; +const MODEL = 'hunyuan-lite'; // 使用混元lite模型,可根据需要更换 // 签名相关函数 -function sha256(message, secret = "", encoding = "hex") { - const hmac = secret - ? crypto.createHmac("sha256", secret) - : crypto.createHash("sha256"); - return hmac.update(message).digest(encoding); +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}`; + 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 date = getDate(timestamp); - // 步骤1:拼接规范请求串 - 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}`; + // 步骤1:拼接规范请求串 + 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}`; - // 步骤2:拼接待签名字符串 - const algorithm = "TC3-HMAC-SHA256"; - const credentialScope = `${date}/${SERVICE}/tc3_request`; - const hashedCanonicalRequest = sha256(canonicalRequest); - const stringToSign = `${algorithm}\n${timestamp}\n${credentialScope}\n${hashedCanonicalRequest}`; + // 步骤2:拼接待签名字符串 + const algorithm = 'TC3-HMAC-SHA256'; + const credentialScope = `${date}/${SERVICE}/tc3_request`; + const hashedCanonicalRequest = sha256(canonicalRequest); + const stringToSign = `${algorithm}\n${timestamp}\n${credentialScope}\n${hashedCanonicalRequest}`; - // 步骤3:计算签名 - 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); + // 步骤3:计算签名 + 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); - // 步骤4:拼接Authorization - const authorization = `${algorithm} Credential=${secretId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; + // 步骤4:拼接Authorization + const authorization = `${algorithm} Credential=${secretId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; - return authorization; + return authorization; } // 调用混元API async function callHunyuan(prompt) { - return new Promise((resolve, reject) => { - const timestamp = Math.floor(Date.now() / 1000); + return new Promise((resolve, reject) => { + const timestamp = Math.floor(Date.now() / 1000); - const payload = JSON.stringify({ - Model: MODEL, - Messages: [ - { - Role: "user", - Content: prompt, - }, - ], - Stream: false, + const payload = JSON.stringify({ + Model: MODEL, + Messages: [ + { + Role: 'user', + Content: prompt, + }, + ], + Stream: false, + }); + + 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.Choices) { + resolve(result.Response.Choices[0].Message.Content.trim()); + } 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(); }); - - 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.Choices) { - resolve(result.Response.Choices[0].Message.Content.trim()); - } 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(); - }); } -// 为单个汉字生成介绍 -async function generateIntroduce(char) { - const prompt = `请为汉字"${char}"生成一个简短的词组介绍,格式为"${char},XXX的${char}",其中XXX是一个包含该汉字的常见词语。 -例如: -- 汉字"天"的介绍是"天,天空的天" -- 汉字"地"的介绍是"地,大地的地" -- 汉字"人"的介绍是"人,人民的人" +// 批量为多个汉字生成介绍(单次 API 调用处理多个汉字) +async function generateBatchIntroduce(chars) { + const charList = chars.join('、'); + const prompt = `请为以下汉字分别生成简短的词组介绍。 -请只输出介绍内容,不要包含其他解释。比如对于"天",只输出"天,天空的天"。`; +汉字列表:${charList} - try { - const response = await callHunyuan(prompt); - // 清理响应,只保留核心内容 - let introduce = response.replace(/["""]/g, "").trim(); - // 如果响应包含多余内容,尝试提取核心部分 - const match = introduce.match(/.,.+的./); - if (match) { - introduce = match[0]; +要求: +1. 每个汉字的介绍格式为:"字,词组的字",其中词组是一个包含该汉字的常见词语 +2. 请严格按照 JSON 数组格式输出,不要有其他内容 + +示例输出格式: +[{"char":"天","introduce":"天,天空的天"},{"char":"地","introduce":"地,大地的地"},{"char":"人","introduce":"人,人民的人"}] + +请直接输出 JSON 数组,不要包含任何解释或 markdown 代码块标记。`; + + try { + const response = await callHunyuan(prompt); + // 尝试解析 JSON 响应 + let jsonStr = response.trim(); + + // 移除可能的 markdown 代码块标记 + jsonStr = jsonStr.replace(/^```json?\s*/i, '').replace(/\s*```$/i, ''); + + // 尝试提取 JSON 数组 + const jsonMatch = jsonStr.match(/\[[\s\S]*\]/); + if (jsonMatch) { + jsonStr = jsonMatch[0]; + } + + const parsed = JSON.parse(jsonStr); + + // 验证并修正结果 + const results = []; + for (const char of chars) { + const found = parsed.find((item) => item.char === char); + if (found && found.introduce) { + // 确保格式正确 + let introduce = found.introduce.replace(/["""]/g, '').trim(); + if (!introduce.startsWith(char + ',')) { + introduce = `${char},${introduce}`; + } + results.push({ char, introduce }); + } else { + // 如果没找到,使用默认格式 + results.push({ char, introduce: `${char},${char}字的${char}` }); + } + } + + return results; + } catch (error) { + console.error(`批量生成介绍失败:`, error.message); + // 失败时返回默认格式 + return chars.map((char) => ({ + char, + introduce: `${char},${char}字的${char}`, + })); } - // 确保格式正确:如果没有逗号,添加逗号 - if (!introduce.startsWith(char + ",")) { - // 尝试提取词组部分 - const wordMatch = introduce.match(/(.+的.)/); - if (wordMatch) { - introduce = `${char},${wordMatch[1]}`; - } else { - introduce = `${char},${char}字的${char}`; - } - } - return introduce; - } catch (error) { - console.error(`生成 "${char}" 介绍失败:`, error.message); - return `${char},${char}字的${char}`; - } } // 延时函数 function delay(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } // 主函数 async function main() { - // 检查环境变量 - if (!SECRET_ID || !SECRET_KEY) { - console.error("请设置环境变量 TENCENT_SECRET_ID 和 TENCENT_SECRET_KEY"); - console.error("例如:"); - console.error( - " export TENCENT_SECRET_ID=your_secret_id" - ); - console.error( - " export TENCENT_SECRET_KEY=your_secret_key" - ); - process.exit(1); - } - - // 读取汉字数据 - const charStringPath = path.join( - __dirname, - "../data/char/char_string.json" - ); - const charString = JSON.parse(fs.readFileSync(charStringPath, "utf-8")); - - // 获取所有汉字 - const chars = charString["3500"].split(""); - console.log(`共有 ${chars.length} 个汉字需要处理`); - - // 检查是否有已存在的进度文件 - const outputPath = path.join( - __dirname, - "../data/char/char_introduce.json" - ); - let results = []; - let startIndex = 0; - - if (fs.existsSync(outputPath)) { - try { - results = JSON.parse(fs.readFileSync(outputPath, "utf-8")); - startIndex = results.length; - console.log(`发现已有进度,从第 ${startIndex + 1} 个汉字继续`); - } catch (e) { - console.log("已有文件解析失败,从头开始"); + // 检查环境变量 + if (!SECRET_ID || !SECRET_KEY) { + console.error('请在 .env 文件中配置 TENCENT_SECRET_ID 和 TENCENT_SECRET_KEY'); + console.error(''); + console.error('1. 复制 .env.example 为 .env:'); + console.error(' cp .env.example .env'); + console.error(''); + console.error('2. 编辑 .env 文件,填入你的腾讯云密钥:'); + console.error(' TENCENT_SECRET_ID=your_secret_id'); + console.error(' TENCENT_SECRET_KEY=your_secret_key'); + process.exit(1); } - } - // 批量处理 - const BATCH_SIZE = 10; // 每批处理10个 - const DELAY_BETWEEN_BATCHES = 1000; // 批次间延迟1秒 + // 读取汉字数据 + const charStringPath = path.join(__dirname, '../data/char/char_string.json'); + const charString = JSON.parse(fs.readFileSync(charStringPath, 'utf-8')); - for (let i = startIndex; i < chars.length; i += BATCH_SIZE) { - const batch = chars.slice(i, Math.min(i + BATCH_SIZE, chars.length)); - console.log( - `处理第 ${i + 1} - ${Math.min(i + BATCH_SIZE, chars.length)} 个汉字...` - ); + // 获取所有汉字 + const chars = charString['3500'].split(''); + console.log(`共有 ${chars.length} 个汉字需要处理`); - // 并行处理当前批次 - const batchResults = await Promise.all( - batch.map(async (char, idx) => { - // 每个请求之间稍微错开 - await delay(idx * 100); - const introduce = await generateIntroduce(char); - console.log(` ${char}: ${introduce}`); - return { char, introduce }; - }) - ); + // 检查是否有已存在的进度文件 + const outputPath = path.join(__dirname, '../data/char/char_introduce.json'); + let results = []; + let startIndex = 0; - results.push(...batchResults); - - // 每批处理完后保存进度 - fs.writeFileSync(outputPath, JSON.stringify(results, null, 2), "utf-8"); - console.log(`已保存进度:${results.length}/${chars.length}`); - - // 批次间延迟,避免API限流 - if (i + BATCH_SIZE < chars.length) { - await delay(DELAY_BETWEEN_BATCHES); + if (fs.existsSync(outputPath)) { + try { + results = JSON.parse(fs.readFileSync(outputPath, 'utf-8')); + startIndex = results.length; + console.log(`发现已有进度,从第 ${startIndex + 1} 个汉字继续`); + } catch (e) { + console.log('已有文件解析失败,从头开始'); + } } - } - console.log(`\n完成!共生成 ${results.length} 个汉字介绍`); - console.log(`结果已保存到: ${outputPath}`); + // 批量处理配置 + const BATCH_SIZE = 20; // 每次 API 调用处理 20 个汉字 + const DELAY_BETWEEN_BATCHES = 1000; // 批次间延迟1秒 + + for (let i = startIndex; i < chars.length; i += BATCH_SIZE) { + const batch = chars.slice(i, Math.min(i + BATCH_SIZE, chars.length)); + console.log( + `\n处理第 ${i + 1} - ${Math.min(i + BATCH_SIZE, chars.length)} 个汉字(共 ${batch.length} 个)...` + ); + console.log(`汉字列表: ${batch.join('')}`); + + // 单次 API 调用处理整批汉字 + const batchResults = await generateBatchIntroduce(batch); + + // 打印结果 + for (const item of batchResults) { + console.log(` ${item.char}: ${item.introduce}`); + } + + results.push(...batchResults); + + // 每批处理完后保存进度 + fs.writeFileSync(outputPath, JSON.stringify(results, null, 2), 'utf-8'); + console.log(`已保存进度:${results.length}/${chars.length}`); + + // 批次间延迟,避免API限流 + if (i + BATCH_SIZE < chars.length) { + await delay(DELAY_BETWEEN_BATCHES); + } + } + + console.log(`\n完成!共生成 ${results.length} 个汉字介绍`); + console.log(`结果已保存到: ${outputPath}`); } main().catch(console.error); diff --git a/packages/voice-production/scripts/generate_char_voice.js b/packages/voice-production/scripts/generate_char_voice.js new file mode 100644 index 0000000..f52a1ad --- /dev/null +++ b/packages/voice-production/scripts/generate_char_voice.js @@ -0,0 +1,228 @@ +/** + * 使用腾讯云语音合成 TTS 为汉字介绍生成语音 + * 读取 char_introduce.json,将每个 introduce 文本合成语音保存到 data/voice 目录 + */ +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'; + +// 音色:101001 女声标准(免费),101002 男声标准 +const VOICE_TYPE = 101015; +const CODEC = 'mp3'; +const SAMPLE_RATE = 16000; + +// 生成语音的汉字数量限制,设为 0 或 null 表示不限制(生成全部) +const MAX_CHAR_COUNT = null; + +// 已生成语音记录文件路径(相对于 data/char) +const VOICE_RECORD_FILE = 'voice_generated.json'; + +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-${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(); + }); +} + +function getSafeFilename(char) { + // 汉字转 Unicode 编码作为文件名,避免特殊字符问题 + const code = char.codePointAt(0).toString(16); + return `${char}_${code}.${CODEC}`; +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function loadVoiceRecord(charDir, voiceDir) { + const recordPath = path.join(charDir, VOICE_RECORD_FILE); + let chars = []; + if (fs.existsSync(recordPath)) { + try { + const data = JSON.parse(fs.readFileSync(recordPath, 'utf-8')); + chars = data.chars || []; + } catch (e) { + chars = []; + } + } + // 如果记录为空,从 voiceDir 扫描已有音频文件初始化 + if (chars.length === 0 && fs.existsSync(voiceDir)) { + const files = fs.readdirSync(voiceDir).filter((f) => f.endsWith(`.${CODEC}`)); + chars = files + .map((f) => { + const name = path.basename(f, `.${CODEC}`); + return name.split('_')[0]; + }) + .filter(Boolean); + if (chars.length > 0) { + saveVoiceRecord(charDir, chars); + console.log(`从已有文件初始化记录,识别到 ${chars.length} 个已生成语音`); + } + } + return { chars }; +} + +function saveVoiceRecord(charDir, chars) { + const recordPath = path.join(charDir, VOICE_RECORD_FILE); + fs.writeFileSync( + recordPath, + JSON.stringify({ chars, updatedAt: new Date().toISOString() }), + 'utf-8' + ); +} + +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.json'); + const voiceDir = path.join(__dirname, '../data/voice'); + + if (!fs.existsSync(introducePath)) { + console.error('未找到 char_introduce.json,请先运行 generate-introduce 脚本'); + process.exit(1); + } + + if (!fs.existsSync(voiceDir)) { + fs.mkdirSync(voiceDir, { recursive: true }); + } + + const fullList = JSON.parse(fs.readFileSync(introducePath, 'utf-8')); + const list = MAX_CHAR_COUNT ? fullList.slice(0, MAX_CHAR_COUNT) : fullList; + const { chars: generatedChars } = loadVoiceRecord(charDir, voiceDir); + const generatedSet = new Set(generatedChars); + + const toGenerate = list.filter((item) => !generatedSet.has(item.char)); + console.log( + `共有 ${list.length} 个汉字待处理${MAX_CHAR_COUNT ? `(限制前 ${MAX_CHAR_COUNT} 个)` : ''},其中 ${generatedChars.length} 个已生成,${toGenerate.length} 个待生成` + ); + + const DELAY_MS = 200; // 请求间隔,避免限流 + + for (let i = 0; i < toGenerate.length; i++) { + const { char, introduce } = toGenerate[i]; + const filename = getSafeFilename(char); + const filepath = path.join(voiceDir, filename); + + try { + const audioBuffer = await textToVoice(introduce); + fs.writeFileSync(filepath, audioBuffer); + generatedChars.push(char); + saveVoiceRecord(charDir, generatedChars); + console.log(`[${i + 1}/${toGenerate.length}] 完成: ${char} -> ${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}`); +} + +main().catch(console.error); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc6df14..42d6409 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,30 @@ importers: .: devDependencies: + '@eslint/js': + specifier: ^9.18.0 + version: 9.39.2 concurrently: specifier: ^9.1.2 version: 9.2.1 + eslint: + specifier: ^9.18.0 + version: 9.39.2 + eslint-config-prettier: + specifier: ^10.0.1 + version: 10.1.8(eslint@9.39.2) + eslint-plugin-prettier: + specifier: ^5.2.3 + version: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2))(eslint@9.39.2)(prettier@3.8.1) + globals: + specifier: ^15.14.0 + version: 15.15.0 + prettier: + specifier: ^3.4.2 + version: 3.8.1 + typescript-eslint: + specifier: ^8.21.0 + version: 8.53.1(eslint@9.39.2)(typescript@5.9.3) apps/admin: dependencies: @@ -230,6 +251,15 @@ importers: specifier: ^8.20.0 version: 8.53.1(eslint@9.39.2)(typescript@5.9.3) + packages/voice-production: + dependencies: + cross-env: + specifier: ^7.0.3 + version: 7.0.3 + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + packages: '@angular-devkit/core@19.2.17': @@ -2964,6 +2994,10 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + globals@16.5.0: resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} engines: {node: '>=18'} @@ -7893,6 +7927,8 @@ snapshots: globals@14.0.0: {} + globals@15.15.0: {} + globals@16.5.0: {} globalthis@1.0.4: