@webgal-tools/voice
Version:
WebGAL GPT-SoVITS语音合成应用
485 lines (479 loc) • 21.2 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.translateService = exports.TranslateService = void 0;
exports.translate = translate;
exports.setCharacterStyle = setCharacterStyle;
exports.checkTranslatorService = checkTranslatorService;
const ai_1 = require("ai");
const openai_1 = require("@ai-sdk/openai");
const anthropic_1 = require("@ai-sdk/anthropic");
const google_1 = require("@ai-sdk/google");
const mistral_1 = require("@ai-sdk/mistral");
const cohere_1 = require("@ai-sdk/cohere");
const ollama_ai_provider_1 = require("ollama-ai-provider");
const logger_1 = require("@webgal-tools/logger");
const node_path_1 = require("node:path");
/**
* 角色语言特色配置存储
*/
const characterStyles = new Map();
/**
* 统一翻译服务类
*/
class TranslateService {
constructor() {
this.modelCache = new Map();
}
/**
* 获取或创建AI模型实例
*/
getModel(config) {
const cacheKey = `${config.model_type}:${config.base_url}:${config.model_name}`;
if (this.modelCache.has(cacheKey)) {
return this.modelCache.get(cacheKey);
}
let model;
switch (config.model_type) {
case 'ollama':
const ollamaProvider = (0, ollama_ai_provider_1.createOllama)({
baseURL: config.base_url,
});
model = ollamaProvider(config.model_name);
break;
case 'openai':
if (!config.api_key) {
throw new Error('OpenAI 需要提供 api_key');
}
const openaiProvider = (0, openai_1.createOpenAI)({
baseURL: config.base_url !== 'https://api.openai.com/v1' ? config.base_url : undefined,
apiKey: config.api_key,
});
model = openaiProvider(config.model_name);
break;
case 'anthropic':
if (!config.api_key) {
throw new Error('Anthropic 需要提供 api_key');
}
const anthropicProvider = (0, anthropic_1.createAnthropic)({
baseURL: config.base_url !== 'https://api.anthropic.com' ? config.base_url : undefined,
apiKey: config.api_key,
});
model = anthropicProvider(config.model_name);
break;
case 'google':
if (!config.api_key) {
throw new Error('Google 需要提供 api_key');
}
const googleProvider = (0, google_1.createGoogleGenerativeAI)({
baseURL: config.base_url !== 'https://generativelanguage.googleapis.com/v1beta' ? config.base_url : undefined,
apiKey: config.api_key,
});
model = googleProvider(config.model_name);
break;
case 'mistral':
if (!config.api_key) {
throw new Error('Mistral 需要提供 api_key');
}
const mistralProvider = (0, mistral_1.createMistral)({
baseURL: config.base_url !== 'https://api.mistral.ai/v1' ? config.base_url : undefined,
apiKey: config.api_key,
});
model = mistralProvider(config.model_name);
break;
case 'cohere':
if (!config.api_key) {
throw new Error('Cohere 需要提供 api_key');
}
const cohereProvider = (0, cohere_1.createCohere)({
baseURL: config.base_url !== 'https://api.cohere.ai/v1' ? config.base_url : undefined,
apiKey: config.api_key,
});
model = cohereProvider(config.model_name);
break;
case 'custom':
// 对于自定义供应商,尝试使用通用的OpenAI兼容格式
const customProvider = (0, openai_1.createOpenAI)({
baseURL: config.base_url,
apiKey: config.api_key || 'dummy-key',
});
model = customProvider(config.model_name);
break;
default:
throw new Error(`不支持的模型类型: ${config.model_type}`);
}
this.modelCache.set(cacheKey, model);
return model;
}
/**
* 获取角色的语言特色
*/
getCharacterStyle(character) {
return characterStyles.get(character) || '保持角色原有的语言风格和语气';
}
/**
* 构建翻译提示词
*/
buildTranslatePrompt(character, text, targetLanguage, context, globalPrompt, characterPrompt) {
const characterStyle = this.getCharacterStyle(character);
let prompt = `你是一个专业的翻译助手,专门负责将游戏对话翻译成${targetLanguage}。
翻译规则:
1. 只输出翻译目标句子被翻译后的内容,不要包含任何解释、注释或额外文字,错误示例: “你好!(nihao!)”
2. 保持原文的情感色彩和语气,不要修改目标语句的意思。错误示例:“你好!” -> “こんにちは、元気ですか?” 正确示例: “你好!” -> “こんにちは!”
3. 翻译要自然流畅,符合${targetLanguage}的表达习惯
4. 用户如果提供了信息,则一定要遵守用户的意思:
示例: 用户提示:若叶(わかば)睦(むつみ)
用户目标: 若叶同学?
分析:这里直接叫同学的姓,说明并不是很亲近的人,应该使用さん来保证礼貌
用户提供了若叶(わかば),最终翻译为:わかばさん?
`;
// 添加角色信息
prompt += `\n\n
- 正在说话的人:${character}
`;
// 添加角色特定的提示词
if (characterPrompt) {
prompt += `\n- 说话的人的专属说明:${characterPrompt}`;
}
// 添加全局提示词
if (globalPrompt) {
prompt += `\n\n全局翻译指导:\n${globalPrompt}`;
}
// 添加上下文信息
if (context) {
prompt += `\n\n上下文信息:\n${context}`;
}
prompt += `\n\n请翻译以下文本:\n${text}`;
logger_1.logger.debug(prompt);
return prompt;
}
/**
* 构建模型选择提示词
*/
buildModelSelectionPrompt(character, text, targetLanguage, scannedFiles, context, globalPrompt, characterPrompt) {
let prompt = `你是一个专业的翻译和语音模型选择助手。
任务说明:
1. 将文本翻译成${targetLanguage}
2. 分析对话内容的情绪和语调
3. 从可用的模型文件中选择最合适的组合
4. 返回JSON格式的结果
可用的模型文件:
`;
// 添加可用的GPT模型文件
if (scannedFiles.gpt_files.length > 0) {
prompt += `\nGPT模型文件:\n`;
for (const file of scannedFiles.gpt_files) {
prompt += `- ${file}\n`;
}
}
// 添加可用的SoVITS模型文件
if (scannedFiles.sovits_files.length > 0) {
prompt += `\nSoVITS模型文件:\n`;
for (const file of scannedFiles.sovits_files) {
prompt += `- ${file}\n`;
}
}
// 添加可用的参考音频文件
if (scannedFiles.ref_audio_files.length > 0) {
prompt += `\n参考音频文件:\n`;
for (const file of scannedFiles.ref_audio_files) {
prompt += `- ${file}\n`;
}
}
// 添加角色信息
prompt += `\n\n角色信息:
- 正在说话的人:${character}
`;
// 添加角色特定的提示词
if (characterPrompt) {
prompt += `\n- 说话的人的专属说明:${characterPrompt}`;
}
// 添加全局提示词
if (globalPrompt) {
prompt += `\n\n全局翻译指导:\n${globalPrompt}`;
}
// 添加上下文信息
if (context) {
prompt += `\n\n上下文信息:\n${context}`;
}
prompt += `\n\n请翻译以下文本,并根据文本内容选择最合适的模型文件:
"${text}"
返回格式(必须是有效的JSON):
{
"gpt": "选择的GPT模型文件路径(必须是.ckpt文件)",
"sovits": "选择的SoVITS模型文件路径(必须是.pth文件)",
"ref_audio": "选择的参考音频文件路径",
"translated_text": "翻译后的文本",
"emotion": "描述这段对话的情绪特征"
}
注意:
1. 必须返回有效的JSON格式
2. 根据对话内容的实际情感色彩选择模型文件
3. 翻译要自然流畅,符合${targetLanguage}的表达习惯
4. 文件名可能包含情绪、角色状态等信息,请根据文件名和对话内容进行匹配
5. 如果不确定,可以选择看起来最通用或最中性的文件
6. 路径格式说明:请严格按照上面提供的文件路径进行选择,不要修改路径格式
7. 如果找不到完全匹配的文件,请选择文件名最相似的文件
8. 字段名必须准确:使用"sovits"而不是"sovit"
9. 文件扩展名必须准确:GPT文件必须是.ckpt,SoVITS文件必须是.pth`;
logger_1.logger.debug(prompt);
return prompt;
}
/**
* 智能路径匹配函数
* 处理AI返回的路径与扫描结果路径的匹配问题
*/
findBestMatchingPath(aiPath, scannedPaths) {
if (!aiPath || scannedPaths.length === 0) {
return null;
}
// 标准化路径分隔符
const normalizedAiPath = aiPath.replace(/\\/g, '/').toLowerCase();
const normalizedScannedPaths = scannedPaths.map(p => p.replace(/\\/g, '/').toLowerCase());
// 1. 精确匹配
const exactMatch = normalizedScannedPaths.find(p => p === normalizedAiPath);
if (exactMatch) {
return scannedPaths[normalizedScannedPaths.indexOf(exactMatch)];
}
// 2. 文件名匹配(忽略路径)
const aiFileName = node_path_1.default.basename(normalizedAiPath);
const fileNameMatch = normalizedScannedPaths.find(p => node_path_1.default.basename(p) === aiFileName);
if (fileNameMatch) {
return scannedPaths[normalizedScannedPaths.indexOf(fileNameMatch)];
}
// 3. 模糊匹配(包含关系)
const fuzzyMatches = normalizedScannedPaths.filter(p => p.includes(aiFileName) || aiFileName.includes(node_path_1.default.basename(p)));
if (fuzzyMatches.length > 0) {
// 选择最长的匹配(通常更具体)
const bestMatch = fuzzyMatches.reduce((a, b) => a.length > b.length ? a : b);
return scannedPaths[normalizedScannedPaths.indexOf(bestMatch)];
}
// 4. 部分路径匹配
const aiPathParts = normalizedAiPath.split('/').filter(p => p.length > 0);
const pathMatches = normalizedScannedPaths.filter(p => {
const scannedParts = p.split('/').filter(sp => sp.length > 0);
return aiPathParts.some(part => scannedParts.includes(part));
});
if (pathMatches.length > 0) {
// 选择匹配度最高的
const bestMatch = pathMatches.reduce((a, b) => {
const aScore = aiPathParts.filter(part => a.includes(part)).length;
const bScore = aiPathParts.filter(part => b.includes(part)).length;
return aScore > bScore ? a : b;
});
return scannedPaths[normalizedScannedPaths.indexOf(bestMatch)];
}
return null;
}
/**
* 模型选择和翻译
*/
async selectModelAndTranslate(character, speech, targetLanguage, scannedFiles, config, characterConfig, context) {
if (!targetLanguage) {
throw new Error('未提供翻译目标语言');
}
try {
const model = this.getModel(config);
const prompt = this.buildModelSelectionPrompt(character, speech, targetLanguage, scannedFiles, context, config.additional_prompt, characterConfig?.prompt);
const result = await (0, ai_1.generateText)({
model,
prompt,
temperature: 0.3, // 使用较低的温度以获得更一致的结果
maxTokens: 1000,
});
let responseText = result.text.trim();
if (!responseText) {
throw new Error('模型选择结果为空');
}
logger_1.logger.debug(responseText);
// 提取JSON内容
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error('响应中没有找到有效的JSON格式');
}
const jsonText = jsonMatch[0];
let selectionResult;
try {
selectionResult = JSON.parse(jsonText);
}
catch (parseError) {
throw new Error(`JSON解析失败: ${parseError}`);
}
// 验证响应结构
if (!selectionResult.gpt || !selectionResult.sovits || !selectionResult.ref_audio ||
!selectionResult.translated_text || !selectionResult.emotion) {
throw new Error('响应JSON缺少必要的字段');
}
// 使用智能路径匹配验证选择的文件
const matchedGpt = this.findBestMatchingPath(selectionResult.gpt, scannedFiles.gpt_files);
if (matchedGpt) {
if (matchedGpt !== selectionResult.gpt) {
logger_1.logger.info(`GPT路径匹配: "${selectionResult.gpt}" -> "${matchedGpt}"`);
}
selectionResult.gpt = matchedGpt;
}
else {
logger_1.logger.warn(`无法匹配GPT文件: ${selectionResult.gpt},使用第一个可用文件`);
selectionResult.gpt = scannedFiles.gpt_files[0];
}
const matchedSovits = this.findBestMatchingPath(selectionResult.sovits, scannedFiles.sovits_files);
if (matchedSovits) {
if (matchedSovits !== selectionResult.sovits) {
logger_1.logger.info(`SoVITS路径匹配: "${selectionResult.sovits}" -> "${matchedSovits}"`);
}
selectionResult.sovits = matchedSovits;
}
else {
logger_1.logger.warn(`无法匹配SoVITS文件: ${selectionResult.sovits},使用第一个可用文件`);
selectionResult.sovits = scannedFiles.sovits_files[0];
}
const matchedRefAudio = this.findBestMatchingPath(selectionResult.ref_audio, scannedFiles.ref_audio_files);
if (matchedRefAudio) {
if (matchedRefAudio !== selectionResult.ref_audio) {
logger_1.logger.info(`参考音频路径匹配: "${selectionResult.ref_audio}" -> "${matchedRefAudio}"`);
}
selectionResult.ref_audio = matchedRefAudio;
}
else {
logger_1.logger.warn(`无法匹配参考音频文件: ${selectionResult.ref_audio},使用第一个可用文件`);
selectionResult.ref_audio = scannedFiles.ref_audio_files[0];
}
logger_1.logger.info(`[模型选择] ${character}: "${speech}" -> 情绪:${selectionResult.emotion}, 翻译:"${selectionResult.translated_text}"`);
return selectionResult;
}
catch (error) {
logger_1.logger.error(`模型选择失败 [${config.model_type}] ${character}:`, error);
// 回退方案:使用默认选择
const fallbackResult = {
gpt: scannedFiles.gpt_files[0] || '',
sovits: scannedFiles.sovits_files[0] || '',
ref_audio: scannedFiles.ref_audio_files[0] || '',
translated_text: speech, // 回退到原文
emotion: 'neutral'
};
// 检查回退方案是否有效
if (!fallbackResult.gpt || !fallbackResult.sovits || !fallbackResult.ref_audio) {
logger_1.logger.error(`回退方案无效:缺少必要的模型文件`);
logger_1.logger.error(`可用GPT文件: ${scannedFiles.gpt_files.length}个`);
logger_1.logger.error(`可用SoVITS文件: ${scannedFiles.sovits_files.length}个`);
logger_1.logger.error(`可用参考音频文件: ${scannedFiles.ref_audio_files.length}个`);
throw new Error('没有可用的模型文件进行回退');
}
logger_1.logger.warn(`使用回退方案: ${JSON.stringify(fallbackResult)}`);
return fallbackResult;
}
}
/**
* 翻译文本
*/
async translate(character, speech, targetLanguage, config, characterConfig, context) {
if (!targetLanguage) {
throw new Error('未提供翻译目标语言');
}
try {
const model = this.getModel(config);
const prompt = this.buildTranslatePrompt(character, speech, targetLanguage, context, config.additional_prompt, characterConfig?.prompt);
const result = await (0, ai_1.generateText)({
model,
prompt,
temperature: 0.3, // 使用较低的温度以获得更一致的翻译
maxTokens: 1000,
});
let translatedText = result.text.trim();
if (!translatedText) {
throw new Error('翻译结果为空');
}
// 清理响应内容
this.cleanupTranslationResult(translatedText);
// 移除可能的思考标签内容
if (translatedText.includes('<think>')) {
translatedText = (translatedText.split('</think>')[1] ?? '').trim();
}
// 移除开头和结尾的引号
if ((translatedText.startsWith('"') && translatedText.endsWith('"')) ||
(translatedText.startsWith('"') && translatedText.endsWith('"'))) {
translatedText = translatedText.slice(1, -1);
}
const hasAdditionalInfo = context || config.additional_prompt || characterConfig?.prompt;
if (hasAdditionalInfo) {
logger_1.logger.info(`[翻译+增强] ${character}: "${speech}" -> "${translatedText}"`);
}
else {
logger_1.logger.info(`[翻译] ${character}: "${speech}" -> "${translatedText}"`);
}
return translatedText;
}
catch (error) {
logger_1.logger.error(`翻译失败 [${config.model_type}] ${character}:`, error);
logger_1.logger.error(`回退到原文: "${speech}"`);
return speech;
}
}
/**
* 清理翻译结果
*/
cleanupTranslationResult(text) {
return text.trim()
.replace(/^["'""]|["'""]$/g, '') // 移除首尾引号
.replace(/\n+/g, ' ') // 将多个换行符替换为单个空格
.trim();
}
/**
* 检查服务可用性
*/
async checkAvailability(config) {
try {
const model = this.getModel(config);
// 发送一个简单的测试请求
const result = await (0, ai_1.generateText)({
model,
prompt: '请回复"测试成功"',
maxTokens: 100,
});
logger_1.logger.info(`${config.model_type} 测试消息: ${result.text.trim()}`);
return result.text.length > 2;
}
catch (error) {
logger_1.logger.error(`服务可用性检查失败 [${config.model_type}]:`, error);
return false;
}
}
/**
* 清理模型缓存
*/
clearCache() {
this.modelCache.clear();
}
/**
* 设置角色语言特色
*/
setCharacterStyle(character, style) {
characterStyles.set(character, style);
}
/**
* 获取所有角色样式
*/
getAllCharacterStyles() {
return new Map(characterStyles);
}
/**
* 移除角色样式
*/
removeCharacterStyle(character) {
characterStyles.delete(character);
}
}
exports.TranslateService = TranslateService;
// 创建单例实例
const translateService = new TranslateService();
exports.translateService = translateService;
// 导出便利函数以保持向后兼容
async function translate(character, speech, targetLanguage, config, context) {
return translateService.translate(character, speech, targetLanguage, config, undefined, context);
}
function setCharacterStyle(character, style) {
translateService.setCharacterStyle(character, style);
}
// 已废弃:使用 translateService.checkAvailability() 替代
async function checkTranslatorService(config) {
return translateService.checkAvailability(config);
}