Files
2026-06-19 18:45:55 +08:00

469 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import axios from 'axios';
import * as fs from 'fs';
import * as path from 'path';
import { app } from 'electron';
import logger from '../log/main';
import { Apps } from '../app/index';
import { withoutUnsupportedProxy } from '../httpClient';
/**
* 在线 TTS(千问 Qwen TTS)客户端
*/
export interface CosyVoiceConfig {
apiKey: string;
model: string;
voice: string;
format?: string;
sampleRate?: number;
rate?: number;
volume?: number;
pitch?: number;
instruction?: string;
language_hints?: string;
language?: string;
emotion?: string;
outputPath?: string;
}
export interface CosyVoiceResult {
success: boolean;
audioPath?: string;
error?: string;
requestId?: string;
}
const QWEN_TTS_URL = 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation';
function getOutputFormat(outputPath: string, configFormat?: string): 'wav' | 'mp3' {
const ext = path.extname(outputPath).toLowerCase();
if (ext === '.wav') return 'wav';
if (ext === '.mp3') return 'mp3';
if ((configFormat || '').toLowerCase() === 'wav') return 'wav';
return 'mp3';
}
function detectAudioFormat(
buffer: Buffer,
contentType?: string,
fallback: 'wav' | 'mp3' = 'mp3'
): 'wav' | 'mp3' {
const normalizedType = (contentType || '').toLowerCase();
if (normalizedType.includes('wav') || normalizedType.includes('wave')) return 'wav';
if (normalizedType.includes('mpeg') || normalizedType.includes('mp3')) return 'mp3';
if (buffer.length >= 12) {
const riff = buffer.toString('ascii', 0, 4);
const wave = buffer.toString('ascii', 8, 12);
if (riff === 'RIFF' && wave === 'WAVE') return 'wav';
}
if (buffer.length >= 3 && buffer.toString('ascii', 0, 3) === 'ID3') return 'mp3';
if (buffer.length >= 2 && buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0) return 'mp3';
return fallback;
}
async function transcodeAudioFile(
inputPath: string,
outputPath: string,
targetFormat: 'wav' | 'mp3'
): Promise<void> {
const args = targetFormat === 'wav'
? ['-y', '-i', inputPath, '-c:a', 'pcm_s16le', outputPath]
: ['-y', '-i', inputPath, '-c:a', 'libmp3lame', '-b:a', '128k', outputPath];
await Apps.spawnBinary('ffmpeg', args, {
shell: false,
});
}
async function writeAudioBufferAsTargetFormat(
audioBuffer: Buffer,
outputPath: string,
targetFormat: 'wav' | 'mp3',
contentType?: string
): Promise<void> {
const sourceFormat = detectAudioFormat(audioBuffer, contentType, targetFormat);
if (sourceFormat === targetFormat) {
fs.writeFileSync(outputPath, audioBuffer);
return;
}
const parsedOutput = path.parse(outputPath);
const tempInputPath = path.join(
parsedOutput.dir,
`${parsedOutput.name}_source_${Date.now()}${Math.random().toString(36).slice(2, 8)}.${sourceFormat}`
);
fs.writeFileSync(tempInputPath, audioBuffer);
try {
await transcodeAudioFile(tempInputPath, outputPath, targetFormat);
} finally {
try { fs.unlinkSync(tempInputPath); } catch (_error) { /* ignore */ }
}
}
/**
* 将长文本按自然断句分割为不超过 maxLen 的段落
* qwen3-tts-vc 限制每次最多600(含 instructions),保守设为300
* 普通 qwen3-tts 模型可适当放宽
*/
function splitTextForTTS(text: string, maxLen: number = 300): string[] {
if (text.length <= maxLen) return [text];
const segments: string[] = [];
let remaining = text;
while (remaining.length > 0) {
if (remaining.length <= maxLen) {
segments.push(remaining);
break;
}
// 在 maxLen 范围内找最后一个自然断句点
const chunk = remaining.slice(0, maxLen);
let splitIdx = -1;
// 优先按句号/问号/感叹号分割
for (const sep of ['。', '', '', '!', '?', '', ';', '\n']) {
const idx = chunk.lastIndexOf(sep);
if (idx > maxLen * 0.3) { splitIdx = idx + 1; break; }
}
// 其次按逗号/顿号分割
if (splitIdx === -1) {
for (const sep of ['', ',', '、', '', ':']) {
const idx = chunk.lastIndexOf(sep);
if (idx > maxLen * 0.3) { splitIdx = idx + 1; break; }
}
}
// 实在找不到就硬切
if (splitIdx === -1) splitIdx = maxLen;
segments.push(remaining.slice(0, splitIdx).trim());
remaining = remaining.slice(splitIdx).trim();
}
return segments.filter(s => s.length > 0);
}
export async function synthesizeSpeech(
text: string,
config: CosyVoiceConfig
): Promise<CosyVoiceResult> {
logger.info('[QwenTTS] 开始语音合成', {
model: config.model,
voice: config.voice,
instruction: config.instruction,
language: config.language,
language_hints: config.language_hints,
textLength: text.length,
});
try {
// ✅ 关键修复:长文本自动分段(避免 "Range of input length should be [0, 600]" 错误)
// VC 模型(声音克隆)的限制更严格,API 可能将 text + instructions 合并计算
const instructionLen = config.instruction ? config.instruction.length : 0;
const isVcModel = config.model?.includes('-vc') || config.model?.includes('_vc');
// VC 模型更保守(200字),普通模型 300 字,减去 instruction 长度
const baseMaxLen = isVcModel ? 200 : 300;
const effectiveMaxLen = Math.max(100, baseMaxLen - instructionLen);
const segments = splitTextForTTS(text, effectiveMaxLen);
logger.info(`[QwenTTS] 文本分段: ${segments.length}段, 各段长度: [${segments.map(s => s.length).join(', ')}]`, {
isVcModel, instructionLen, effectiveMaxLen
});
// 准备输出路径
let outputPath: string;
if (config.outputPath) {
outputPath = config.outputPath;
const outputDir = path.dirname(outputPath);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
} else {
const outputDir = path.join(app.getPath('userData'), 'temp', 'tts');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const defaultExt = (config.format || '').toLowerCase() === 'wav' ? 'wav' : 'mp3';
outputPath = path.join(outputDir, `qwen_tts_${Date.now()}.${defaultExt}`);
}
const outputFormat = getOutputFormat(outputPath, config.format);
// 如果只有一段,直接合成
if (segments.length === 1) {
return await synthesizeOneSegment(segments[0], config, outputPath);
}
// 多段:逐段合成,然后拼接 WAV
const segmentPaths: string[] = [];
const parsedOutput = path.parse(outputPath);
for (let i = 0; i < segments.length; i++) {
const segPath = path.join(
parsedOutput.dir,
`${parsedOutput.name}_seg${i}${parsedOutput.ext || (outputFormat === 'wav' ? '.wav' : '.mp3')}`
);
logger.info(`[QwenTTS] 合成第 ${i + 1}/${segments.length} 段 (${segments[i].length}字)...`);
const segResult = await synthesizeOneSegment(segments[i], config, segPath);
if (!segResult.success) {
return segResult; // 任一段失败则返回错误
}
segmentPaths.push(segPath);
}
// 拼接所有段的 WAV 文件(简单拼接 PCM 数据)
try {
await concatAudioFiles(segmentPaths, outputPath, outputFormat);
// 清理临时分段文件
for (const p of segmentPaths) {
try { fs.unlinkSync(p); } catch (e) { /* ignore */ }
}
logger.info('[QwenTTS] 多段音频拼接完成', { outputPath, segments: segments.length });
} catch (concatError) {
// 拼接失败则使用第一段
logger.error('[QwenTTS] 音频拼接失败,使用第一段', concatError);
if (segmentPaths.length > 0 && fs.existsSync(segmentPaths[0])) {
fs.copyFileSync(segmentPaths[0], outputPath);
}
}
return {
success: true,
audioPath: outputPath,
};
} catch (error: any) {
logger.error('[QwenTTS] 语音合成失败', error.response?.data || error.message || error);
return {
success: false,
error: error.response?.data?.message || error.message || '语音合成失败',
};
}
}
/**
* 合成单段文本
*/
async function synthesizeOneSegment(
text: string,
config: CosyVoiceConfig,
outputPath: string
): Promise<CosyVoiceResult> {
const instrLen = config.instruction ? config.instruction.length : 0;
const outputFormat = getOutputFormat(outputPath, config.format);
logger.info(`[QwenTTS] synthesizeOneSegment: textLen=${text.length}, instructionLen=${instrLen}, totalLen=${text.length + instrLen}, model=${config.model}`);
const response = await axios.post(
QWEN_TTS_URL,
{
model: config.model,
input: {
text,
voice: config.voice,
...(mapLanguageType(config.language_hints || config.language)
? { language_type: mapLanguageType(config.language_hints || config.language) }
: {}),
...(config.instruction ? { instructions: config.instruction, optimize_instructions: true } : {}),
},
},
withoutUnsupportedProxy({
headers: {
Authorization: `Bearer ${config.apiKey}`,
'Content-Type': 'application/json',
},
timeout: 600000,
})
);
const audioUrl = response.data?.output?.audio?.url;
const requestId = response.data?.request_id;
if (!audioUrl) {
return {
success: false,
error: response.data?.message || '未获取到音频 URL',
requestId,
};
}
const audioResponse = await axios.get(audioUrl, withoutUnsupportedProxy({
responseType: 'arraybuffer',
timeout: 600000,
}));
await writeAudioBufferAsTargetFormat(
Buffer.from(audioResponse.data),
outputPath,
outputFormat,
audioResponse.headers?.['content-type']
);
logger.info('[QwenTTS] 单段音频已保存', { outputPath, requestId, textLen: text.length });
return {
success: true,
audioPath: outputPath,
requestId,
};
}
/**
* 拼接多个 WAV 文件(假设格式相同:都是 PCM WAV)
*/
async function concatWavFiles(inputPaths: string[], outputPath: string): Promise<void> {
if (inputPaths.length === 0) throw new Error('无输入文件');
if (inputPaths.length === 1) {
fs.copyFileSync(inputPaths[0], outputPath);
return;
}
// 读取第一个文件的 WAV 头(44字节)
const firstFile = fs.readFileSync(inputPaths[0]);
const headerSize = 44;
const header = Buffer.from(firstFile.buffer, 0, headerSize);
// 收集所有 PCM 数据(跳过 WAV 头)
const pcmBuffers: Buffer[] = [];
let totalPcmSize = 0;
for (const p of inputPaths) {
const buf = fs.readFileSync(p);
const pcm = buf.slice(headerSize);
pcmBuffers.push(pcm);
totalPcmSize += pcm.length;
}
// 更新 WAV 头中的文件大小字段
const newHeader = Buffer.from(header);
newHeader.writeUInt32LE(totalPcmSize + headerSize - 8, 4); // ChunkSize
newHeader.writeUInt32LE(totalPcmSize, 40); // Subchunk2Size
// 写入文件
const outputBuf = Buffer.concat([newHeader, ...pcmBuffers]);
fs.writeFileSync(outputPath, outputBuf);
}
async function concatMp3Files(inputPaths: string[], outputPath: string): Promise<void> {
const concatListPath = path.join(
path.dirname(outputPath),
`concat_${Date.now()}_${Math.random().toString(36).slice(2)}.txt`
);
const concatList = inputPaths
.map(filePath => {
const normalized = filePath.replace(/\\/g, '/').replace(/'/g, "'\\''");
return `file '${normalized}'`;
})
.join('\n');
fs.writeFileSync(concatListPath, concatList, 'utf-8');
try {
await Apps.spawnBinary('ffmpeg', [
'-y',
'-f', 'concat',
'-safe', '0',
'-i', concatListPath,
'-c:a', 'libmp3lame',
'-b:a', '128k',
outputPath,
], {
shell: false,
});
} finally {
try { fs.unlinkSync(concatListPath); } catch (_error) { /* ignore */ }
}
}
async function concatAudioFiles(
inputPaths: string[],
outputPath: string,
format: 'wav' | 'mp3'
): Promise<void> {
if (format === 'wav') {
await concatWavFiles(inputPaths, outputPath);
return;
}
await concatMp3Files(inputPaths, outputPath);
}
export function getAvailableVoices(model: string): Array<{ id: string; name: string; desc: string }> {
if (model.startsWith('qwen3-tts') || model.startsWith('qwen-tts')) {
return [
{ id: 'Cherry', name: '芊悦', desc: '普通话,女声,通用播报' },
{ id: 'Serena', name: '苏瑶', desc: '普通话,女声,温柔自然' },
{ id: 'Ethan', name: '晨煦', desc: '普通话,男声,稳重清晰' },
{ id: 'Jada', name: '上海-阿珍', desc: '上海话,女声,方言播报' },
{ id: 'Dylan', name: '北京-晓东', desc: '北京话,男声,方言播报' },
{ id: 'Li', name: '南京-老李', desc: '南京话,男声,方言播报' },
{ id: 'Marcus', name: '陕西-秦川', desc: '陕西话,男声,方言播报' },
{ id: 'Roy', name: '闽南-阿杰', desc: '闽南语,男声,方言播报' },
{ id: 'Peter', name: '天津-李彼得', desc: '天津话,男声,方言播报' },
{ id: 'Sunny', name: '四川-晴儿', desc: '四川话,女声,方言播报' },
{ id: 'Eric', name: '四川-程川', desc: '四川话,男声,方言播报' },
{ id: 'Rocky', name: '粤语-阿强', desc: '粤语,男声,方言播报' },
{ id: 'Kiki', name: '粤语-阿清', desc: '粤语,女声,方言播报' },
];
}
return [];
}
export function getInstructionForLanguage(language: string): string | undefined {
if (!language || language === '中文' || language === '中文(普通话)') {
return undefined;
}
const dialectMap: Record<string, string> = {
'上海话': '请用上海话表达。',
'北京话': '请用北京话表达。',
'四川话': '请用四川话表达。',
'南京话': '请用南京话表达。',
'陕西话': '请用陕西话表达。',
'闽南话': '请用闽南话表达。',
'天津话': '请用天津话表达。',
'粤语': '请用粤语表达。',
'广东话': '请用粤语表达。',
};
if (dialectMap[language]) {
return dialectMap[language];
}
const languageMap: Record<string, string> = {
'英语': 'Please speak in English.',
'德语': 'Please speak in German.',
'意大利语': 'Please speak in Italian.',
'葡萄牙语': 'Please speak in Portuguese.',
'西班牙语': 'Please speak in Spanish.',
'日语': 'Please speak in Japanese.',
'韩语': 'Please speak in Korean.',
'法语': 'Please speak in French.',
'俄语': 'Please speak in Russian.',
};
return languageMap[language];
}
function mapLanguageType(language?: string): string | undefined {
const languageMap: Record<string, string> = {
'中文': 'Chinese',
'中文(普通话)': 'Chinese',
'Chinese': 'Chinese',
'英语': 'English',
'English': 'English',
'德语': 'German',
'German': 'German',
'意大利语': 'Italian',
'Italian': 'Italian',
'葡萄牙语': 'Portuguese',
'Portuguese': 'Portuguese',
'西班牙语': 'Spanish',
'Spanish': 'Spanish',
'日语': 'Japanese',
'Japanese': 'Japanese',
'韩语': 'Korean',
'Korean': 'Korean',
'法语': 'French',
'French': 'French',
'俄语': 'Russian',
'Russian': 'Russian',
};
return language ? languageMap[language] : undefined;
}