feat:生成音频
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user