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

691 lines
24 KiB
TypeScript

import { spawn } from "child_process";
import * as path from "path";
import * as fs from "fs";
import { Log } from "../log/main";
import { getPythonPath } from "../../lib/python-util";
import { getFFmpegExecutablePath, resourceExists } from "../../lib/resource-path";
import { getPythonScriptPath } from "../../lib/resource-path";
import { getFFmpegPath } from "../shell";
import { fileURLToPath } from "url";
import { dirname } from "path";
import { spawnPython } from "../../lib/python-spawn-util";
// ES 模块中定义 __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* FUNASR语音识别模块
* 使用FUNASR模型进行语音识别,生成带时间戳的字幕
*/
export interface FunasrWord {
word: string;
start: number; // 开始时间(秒)
end: number; // 结束时间(秒)
confidence?: number; // 置信度(可选)
}
export interface FunasrSegment {
text: string;
start: number;
end: number;
words: FunasrWord[];
}
export interface FunasrResult {
segments: FunasrSegment[];
language: string;
duration: number;
}
/**
* 使用FUNASR模型识别音频生成字幕
* @param audioPath 音频文件路径
* @param options 识别选项
*/
export async function recognizeAudioWithFunasr(
audioPath: string,
options?: {
language?: string; // 语言:zh(中文)、en(英文)
modelPath?: string; // FUNASR模型路径(可选)
enableWordTimestamp?: boolean; // 是否启用词级时间戳
}
): Promise<FunasrResult> {
try {
Log.info("funasr.recognizeAudio", { audioPath, options });
// 默认选项
const config = {
language: options?.language || 'zh',
enableWordTimestamp: options?.enableWordTimestamp !== false,
modelPath: options?.modelPath
};
// TODO: 这里需要根据实际的FUNASR部署方式调用
// 方式1:调用FUNASR HTTP API(推荐)
// 方式2:调用FUNASR Python命令行
// 方式3:使用FUNASR Electron Native模块
// 示例:使用HTTP API调用FUNASR服务
const result = await callFunasrHttpApi(audioPath, config);
Log.info("funasr.recognizeAudio.success", {
segmentCount: result.segments.length,
duration: result.duration
});
return result;
} catch (error) {
Log.error("funasr.recognizeAudio.error", error);
throw new Error(`FUNASR识别失败: ${error?.message || error}`);
}
}
/**
* 调用FUNASR HTTP API或备用方案
* 支持多种后端:本地FUNASR API、Python脚本、系统语音识别等
*/
async function callFunasrHttpApi(
audioPath: string,
config: any
): Promise<FunasrResult> {
// 首先尝试本地FUNASR API
const funasrApiUrl = process.env.FUNASR_API_URL || 'http://localhost:10095/api/v1/recognize';
try {
Log.info("funasr.httpApi.attempting", { funasrApiUrl, audioPath });
// 检查音频文件是否存在
if (!fs.existsSync(audioPath)) {
throw new Error(`音频文件不存在: ${audioPath}`);
}
// 读取音频文件
const audioBuffer = fs.readFileSync(audioPath);
const audioBase64 = audioBuffer.toString('base64');
Log.info("funasr.httpApi.audioLoaded", {
audioSize: audioBuffer.length,
audioBase64Length: audioBase64.length
});
// 调用FUNASR API,使用AbortController实现超时
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 60000); // 60秒超时
try {
Log.info("funasr.httpApi.sending", {
url: funasrApiUrl,
bodySize: JSON.stringify({
audio: audioBase64,
language: config.language,
enable_word_timestamp: config.enableWordTimestamp
}).length
});
const response = await fetch(funasrApiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
audio: audioBase64,
language: config.language,
enable_word_timestamp: config.enableWordTimestamp
}),
signal: controller.signal
});
clearTimeout(timeout);
Log.info("funasr.httpApi.response", {
status: response.status,
statusText: response.statusText
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`FUNASR API返回错误: ${response.status} ${response.statusText}, 响应: ${errorText.substring(0, 200)}`);
}
const data = await response.json();
// 解析FUNASR响应,转换为标准格式
Log.info("funasr.httpApi.success", {
segmentCount: data.segments?.length || 1
});
return parseFunasrResponse(data);
} catch (fetchError: any) {
clearTimeout(timeout);
Log.info("funasr.httpApi.fetchError", {
name: fetchError.name,
message: fetchError.message,
cause: fetchError.cause?.message
});
if (fetchError.name === 'AbortError') {
throw new Error("FUNASR API请求超时(60秒)");
}
// 如果是网络错误,提供更详细的诊断信息
if (fetchError.message.includes('fetch failed') || fetchError.code === 'ECONNREFUSED') {
throw new Error(
`无法连接到FUNASR API: ${funasrApiUrl}\n` +
`请确保FUNASR服务已启动。\n` +
`Docker 启动命令: docker run -p 10095:10095 alibaba-pai/funasr:latest\n` +
`或设置环境变量: FUNASR_API_URL=http://你的FUNASR地址:10095/api/v1/recognize`
);
}
throw fetchError;
}
} catch (error) {
Log.info("funasr.httpApi.failed", {
error: error?.message,
apiUrl: process.env.FUNASR_API_URL || 'http://localhost:10095/api/v1/recognize',
hasEnv: !!process.env.FUNASR_API_URL
});
// 尝试使用Python脚本进行本地语音识别
try {
Log.info("funasr.pythonScript.attempting", {
pythonScript: getPythonScriptPath('funasr_recognize.py'),
pythonPath: getPythonPath()
});
return await callFunasrPythonScript(audioPath, config);
} catch (pythonError) {
Log.info("funasr.python.failed", {
error: pythonError?.message,
pythonPath: getPythonPath()
});
// 如果都失败,抛出错误让用户知道语音识别失败
throw new Error(
`语音识别失败: FUNASR API和本地Python脚本都不可用。\n` +
`\n请选择以下任意方案:\n` +
`\n方案1 - 使用Docker部署FUNASR:\n` +
` docker run -p 10095:10095 alibaba-pai/funasr:latest\n` +
`\n方案2 - 安装FUNASR Python库:\n` +
` pip install funasr torch torchaudio\n` +
`\n方案3 - 安装Google Speech Recognition备用库:\n` +
` pip install SpeechRecognition pydub\n` +
`\n详细错误:\n` +
` HTTP API错误: ${error?.message || error}\n` +
` Python脚本错误: ${pythonError?.message || pythonError}`
);
}
}
}
/**
* 使用Python脚本调用FUNASR(备用方案)
*/
async function callFunasrPythonScript(
audioPath: string,
config: any
): Promise<FunasrResult> {
// 调用Python脚本进行语音识别
// 使用统一的资源路径工具,自动处理开发和生产环境
let pythonScript = getPythonScriptPath('funasr_recognize.py');
// 【修复】如果在默认位置找不到脚本,尝试在源码目录查找(开发环境)
if (!fs.existsSync(pythonScript)) {
const sourcePath = path.join(process.cwd(), 'electron', 'mapi', 'ipAgent', 'python', 'funasr_recognize.py');
if (fs.existsSync(sourcePath)) {
pythonScript = sourcePath;
Log.info("funasr.pythonScript.foundInSource", { pythonScript });
}
}
Log.info("funasr.pythonScript.starting", {
pythonScript,
audioPath,
language: config.language || 'zh'
});
if (!fs.existsSync(pythonScript)) {
const errorMsg = `语音识别脚本不存在: ${pythonScript}`;
Log.error("funasr.pythonScript.notFound", { pythonScript });
throw new Error(errorMsg);
}
if (!fs.existsSync(audioPath)) {
const errorMsg = `音频文件不存在: ${audioPath}`;
Log.error("funasr.pythonScript.audioNotFound", { audioPath });
throw new Error(errorMsg);
}
try {
// 使用统一的 Python 调用工具(支持中文路径)
const stdout = await spawnPython([
pythonScript,
'--audio', audioPath,
'--language', config.language || 'zh',
'--enable-word-timestamp', config.enableWordTimestamp ? '1' : '0'
], {
timeout: 120000, // 120秒超时
addFFmpegToPath: true
});
// 解析 JSON 输出
let jsonStr = stdout.trim();
if (!jsonStr.startsWith('{')) {
const jsonStart = jsonStr.indexOf('{');
if (jsonStart !== -1) {
jsonStr = jsonStr.substring(jsonStart);
let braceCount = 0;
let jsonEnd = 0;
for (let i = 0; i < jsonStr.length; i++) {
if (jsonStr[i] === '{') braceCount++;
if (jsonStr[i] === '}') braceCount--;
if (braceCount === 0) {
jsonEnd = i + 1;
break;
}
}
if (jsonEnd > 0) {
jsonStr = jsonStr.substring(0, jsonEnd);
}
}
}
Log.info("funasr.pythonScript.parsing", {
extractedJsonLength: jsonStr.length,
jsonStart: jsonStr.substring(0, 50)
});
const result = JSON.parse(jsonStr);
if (result.success === false) {
throw new Error(result.error || "未知错误");
}
Log.info("funasr.pythonScript.success", {
segments: result.segments?.length || 0,
language: result.language
});
return result;
} catch (error: any) {
const errorMsg = `Python脚本执行失败: ${error.message}`;
Log.error("funasr.pythonScript.error", { error: error.message });
throw new Error(errorMsg);
}
}
/**
* 解析FUNASR API响应
*/
function parseFunasrResponse(data: any): FunasrResult {
// 根据实际FUNASR API响应格式解析
// 这里假设返回格式类似Whisper
const segments: FunasrSegment[] = data.segments?.map((seg: any) => ({
text: seg.text || seg.sentence,
start: seg.start || seg.begin_time / 1000,
end: seg.end || seg.end_time / 1000,
words: seg.words?.map((w: any) => ({
word: w.word || w.text,
start: w.start || w.begin_time / 1000,
end: w.end || w.end_time / 1000,
confidence: w.confidence
})) || []
})) || [];
return {
segments,
language: data.language || 'zh',
duration: data.duration || segments[segments.length - 1]?.end || 0
};
}
/**
* 生成Mock数据(开发测试用)
*/
function generateMockFunasrResult(audioPath: string): FunasrResult {
Log.info("funasr.generateMockData", { audioPath });
// 模拟生成一些测试数据
const mockSegments: FunasrSegment[] = [
{
text: "欢迎使用FUNASR语音识别功能",
start: 0,
end: 3,
words: [
{ word: "欢迎", start: 0, end: 0.5, confidence: 0.95 },
{ word: "使用", start: 0.5, end: 1.0, confidence: 0.92 },
{ word: "FUNASR", start: 1.0, end: 2.0, confidence: 0.88 },
{ word: "语音", start: 2.0, end: 2.5, confidence: 0.93 },
{ word: "识别", start: 2.5, end: 2.8, confidence: 0.91 },
{ word: "功能", start: 2.8, end: 3.0, confidence: 0.94 }
]
},
{
text: "这个功能可以自动生成精确的字幕时间轴",
start: 3.5,
end: 7,
words: [
{ word: "这个", start: 3.5, end: 3.8, confidence: 0.96 },
{ word: "功能", start: 3.8, end: 4.2, confidence: 0.93 },
{ word: "可以", start: 4.2, end: 4.6, confidence: 0.95 },
{ word: "自动", start: 4.6, end: 5.0, confidence: 0.94 },
{ word: "生成", start: 5.0, end: 5.4, confidence: 0.92 },
{ word: "精确的", start: 5.4, end: 6.0, confidence: 0.90 },
{ word: "字幕", start: 6.0, end: 6.5, confidence: 0.95 },
{ word: "时间轴", start: 6.5, end: 7.0, confidence: 0.93 }
]
}
];
return {
segments: mockSegments,
language: 'zh',
duration: 7.0
};
}
/**
* 使用浏览器自动化下载抖音视频(绕过直接下载限制)
* @param videoUrl 抖音视频URL
* @param downloadPath 下载保存路径
*/
export async function downloadDouyinVideoWithBrowser(
videoUrl: string,
downloadPath: string
): Promise<string> {
return new Promise((resolve, reject) => {
Log.info("funasr.downloadDouyinVideo.browser", { videoUrl, downloadPath });
// 启动无头浏览器下载视频
const browserArgs = [
'--headless',
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--disable-gpu',
'--window-size=1920,1080',
'--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
];
const browserProcess = spawn('node', [
path.join(__dirname, 'browser', 'douyin-downloader.js'),
'--url', videoUrl,
'--output', downloadPath,
'--timeout', '60000'
], {
stdio: ['pipe', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
browserProcess.stdout.on('data', (data) => {
stdout += data.toString();
});
browserProcess.stderr.on('data', (data) => {
stderr += data.toString();
});
browserProcess.on('close', (code) => {
if (code === 0) {
try {
const result = JSON.parse(stdout);
if (result.success && result.videoPath) {
Log.info("funasr.downloadDouyinVideo.success", { videoPath: result.videoPath });
resolve(result.videoPath);
} else {
reject(new Error(result.error || '浏览器下载失败'));
}
} catch (parseError) {
reject(new Error(`浏览器下载返回无效结果: ${parseError?.message}`));
}
} else {
reject(new Error(`浏览器下载进程失败 (代码${code}): ${stderr}`));
}
});
browserProcess.on('error', (err) => {
reject(new Error(`启动浏览器下载失败: ${err.message}`));
});
// 设置超时
setTimeout(() => {
browserProcess.kill();
reject(new Error('浏览器下载超时(60秒)'));
}, 60000);
});
}
/**
* 将FUNASR识别结果转换为SRT格式
*/
export function funasrResultToSrt(result: FunasrResult, options?: {
maxCharsPerLine?: number;
maxLinesPerSubtitle?: number;
}): string {
const config = {
maxCharsPerLine: options?.maxCharsPerLine || 20,
maxLinesPerSubtitle: options?.maxLinesPerSubtitle || 2
};
let srtContent = '';
let index = 1;
for (const segment of result.segments) {
// 将长句子分割成多行
const lines = splitTextIntoLines(segment.text, config.maxCharsPerLine);
const linesForSubtitle: string[] = [];
for (let i = 0; i < lines.length; i += config.maxLinesPerSubtitle) {
const subtitleLines = lines.slice(i, i + config.maxLinesPerSubtitle);
const subtitleText = subtitleLines.join('\n');
// 计算该字幕段的时间(按比例分配)
const ratio = i / lines.length;
const nextRatio = Math.min((i + config.maxLinesPerSubtitle) / lines.length, 1);
const duration = segment.end - segment.start;
const start = segment.start + duration * ratio;
const end = segment.start + duration * nextRatio;
srtContent += `${index}\n`;
srtContent += `${formatSrtTime(start)} --> ${formatSrtTime(end)}\n`;
srtContent += `${subtitleText}\n\n`;
index++;
}
}
return srtContent;
}
/**
* 将文本按最大字符数分割成多行
* 优化:遇到标点符号(,。!?等)时立即换行,使字幕更清晰
*/
function splitTextIntoLines(text: string, maxCharsPerLine: number): string[] {
const lines: string[] = [];
let currentLine = '';
// 定义换行标点符号:遇到这些标点立即换行
// 中文标点:,。!?、;:等
// 英文标点:,.!?;:等
const lineBreakPunct = /[,。!?、;:,\.!\?;:]/;
// 按字符遍历文本
for (let i = 0; i < text.length; i++) {
const char = text[i];
// 将字符添加到当前行
currentLine += char;
// 计算当前行长度(中文字符算1,英文字符算0.5)
const lineLength = calculateLineLengthFunasr(currentLine);
// 情况1: 遇到换行标点符号,立即换行(保留标点符号)
if (lineBreakPunct.test(char)) {
if (currentLine.trim()) {
lines.push(currentLine.trim());
}
currentLine = '';
}
// 情况2: 当前行达到最大长度限制,换行
else if (lineLength >= maxCharsPerLine) {
// 尝试在最后一个标点符号处分割
let lastPunctIndex = -1;
for (let j = currentLine.length - 2; j >= 0; j--) {
if (lineBreakPunct.test(currentLine[j])) {
lastPunctIndex = j;
break;
}
}
if (lastPunctIndex >= 0) {
// 在标点符号后分割(包含标点符号)
const beforePunct = currentLine.substring(0, lastPunctIndex + 1);
const afterPunct = currentLine.substring(lastPunctIndex + 1);
if (beforePunct.trim()) {
lines.push(beforePunct.trim());
}
currentLine = afterPunct;
} else {
// 没有找到合适的标点符号,在当前字符前强制分割
const lineToAdd = currentLine.slice(0, -1);
if (lineToAdd.trim()) {
lines.push(lineToAdd.trim());
}
currentLine = char;
}
}
}
// 添加最后一行
if (currentLine.trim()) {
lines.push(currentLine.trim());
}
// 过滤掉空行
const filteredLines = lines.filter(line => line.trim().length > 0);
return filteredLines.length > 0 ? filteredLines : [text];
}
/**
* 计算文本行的显示长度
* 中文字符算1,英文字符算0.5,标点符号算0.5
*/
function calculateLineLengthFunasr(text: string): number {
let length = 0;
for (const char of text) {
if (/[\u4e00-\u9fff]/.test(char)) {
// 中文字符
length += 1;
} else if (/[a-zA-Z0-9]/.test(char)) {
// 英文字母和数字
length += 0.5;
} else {
// 标点符号和空格
length += 0.5;
}
}
return length;
}
/**
* 格式化SRT时间格式
* @param seconds 时间(秒)
* @returns SRT时间格式字符串 (HH:MM:SS,mmm)
*/
function formatSrtTime(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
const ms = Math.floor((seconds % 1) * 1000);
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')},${String(ms).padStart(3, '0')}`;
}
/**
* 从视频中提取音频(用于FUNASR识别)
* 注意:FUNASR可以使用44.1kHz立体声音频,识别效果更好
* @param videoPath 视频文件路径
* @param outputAudioPath 输出音频路径(可选)
* @param useHighQuality 是否使用高质量音频(44.1kHz立体声),默认true
*/
export async function extractAudioFromVideo(
videoPath: string,
outputAudioPath?: string,
useHighQuality: boolean = true
): Promise<string> {
const parsedPath = path.parse(videoPath);
const audioPath = outputAudioPath || path.join(parsedPath.dir, `${parsedPath.name}_audio.wav`);
// 使用高质量音频参数:44.1kHz立体声,这样可以保持原始音频质量
// 大多数现代ASR系统(包括FUNASR)都能处理高质量音频,且识别效果更好
const sampleRate = useHighQuality ? '44100' : '16000';
const channels = useHighQuality ? '2' : '1';
Log.info("funasr.extractAudio", { videoPath, audioPath, sampleRate, channels, useHighQuality });
return new Promise(async (resolve, reject) => {
let ffmpegPath: string;
try {
// ⚠️ 只使用程序自带的 FFmpeg,不依赖系统环境
ffmpegPath = await getFFmpegPath();
Log.info("funasr.extractAudio.usingFFmpeg", { ffmpegPath });
} catch (error: any) {
Log.error("funasr.extractAudio.ffmpegPathError", { error: error.message });
reject(new Error(`无法获取 FFmpeg 路径: ${error.message}`));
return;
}
// 🔧 修复中文路径:规范化路径参数
const normalizePathForFFmpeg = (filePath: string): string => {
if (process.platform === 'win32') {
// Windows 上使用正斜杠,FFmpeg 可以正确处理
return filePath.replace(/\\/g, '/');
}
return filePath;
};
// spawn 选项:使用 shell: false 以正确传递参数
const spawnOptions: any = {
shell: false
};
const ffmpegProcess = spawn(ffmpegPath, [
'-i', normalizePathForFFmpeg(videoPath),
'-vn', // 不包含视频
'-acodec', 'pcm_s16le', // PCM编码
'-ar', sampleRate, // 采样率:高质量用44.1kHz,低质量用16kHz
'-ac', channels, // 声道:高质量用立体声(2),低质量用单声道(1)
'-y', // 覆盖已存在文件
normalizePathForFFmpeg(audioPath)
], spawnOptions);
let stderrOutput = '';
ffmpegProcess.stderr.on('data', (data) => {
stderrOutput += data.toString();
});
ffmpegProcess.on('close', (code) => {
if (code === 0) {
Log.info("funasr.extractAudio.success", { audioPath });
resolve(audioPath);
} else {
Log.error("funasr.extractAudio.error", { code, stderr: stderrOutput });
reject(new Error(`提取音频失败,退出码: ${code}`));
}
});
ffmpegProcess.on('error', (err) => {
Log.error("funasr.extractAudio.spawnError", err);
reject(err);
});
});
}