feat: implement Whisper transcription engine and project documentation
This commit is contained in:
+198
-7
@@ -2,7 +2,174 @@
|
||||
const electron = require("electron");
|
||||
const path = require("path");
|
||||
const utils = require("@electron-toolkit/utils");
|
||||
const child_process = require("child_process");
|
||||
const fs = require("fs");
|
||||
const iconv = require("iconv-lite");
|
||||
const icon = path.join(__dirname, "../../resources/icon.png");
|
||||
const BIN_PATH = electron.app.isPackaged ? path.join(process.resourcesPath, "bin") : path.join(electron.app.getAppPath(), "bin");
|
||||
async function detectHardware() {
|
||||
return new Promise((resolve) => {
|
||||
const smi = child_process.spawn("nvidia-smi");
|
||||
smi.on("error", () => resolve({ gpu: false, name: "CPU" }));
|
||||
smi.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ gpu: true, name: "NVIDIA GPU (CUDA)" });
|
||||
} else {
|
||||
resolve({ gpu: false, name: "CPU" });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function srtTimeToMs(timeStr) {
|
||||
const match = timeStr.match(/(\d+):(\d+):(\d+)[,.](\d+)/);
|
||||
if (!match) return 0;
|
||||
const [, h, m, s, ms] = match;
|
||||
return parseInt(h) * 36e5 + parseInt(m) * 6e4 + parseInt(s) * 1e3 + parseInt(ms);
|
||||
}
|
||||
function msToSrtTime(ms) {
|
||||
const h = Math.floor(ms / 36e5);
|
||||
ms %= 36e5;
|
||||
const m = Math.floor(ms / 6e4);
|
||||
ms %= 6e4;
|
||||
const s = Math.floor(ms / 1e3);
|
||||
const rem = ms % 1e3;
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")},${String(rem).padStart(3, "0")}`;
|
||||
}
|
||||
function fixSrt(srtContent) {
|
||||
const normalizedContent = srtContent.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
const blocks = normalizedContent.trim().split(/\n\n+/);
|
||||
const entries = [];
|
||||
for (const block of blocks) {
|
||||
const lines = block.trim().split("\n");
|
||||
if (lines.length < 2) continue;
|
||||
const timeLine = lines.find((l) => l.includes("-->"));
|
||||
if (!timeLine) continue;
|
||||
const textLines = lines.filter((l) => !l.includes("-->") && !/^\d+$/.test(l.trim()));
|
||||
const text = textLines.join(" ").trim();
|
||||
if (!text) continue;
|
||||
const [startStr, endStr] = timeLine.split("-->").map((s) => s.trim());
|
||||
const startMs = srtTimeToMs(startStr);
|
||||
let endMs = srtTimeToMs(endStr);
|
||||
entries.push({ startMs, endMs, text });
|
||||
}
|
||||
const merged = [];
|
||||
let i = 0;
|
||||
while (i < entries.length) {
|
||||
const entry = { ...entries[i] };
|
||||
if (entry.endMs <= entry.startMs && i + 1 < entries.length) {
|
||||
const next = entries[i + 1];
|
||||
entry.text = (entry.text + " " + next.text).trim();
|
||||
entry.endMs = next.endMs;
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
if (entry.endMs <= entry.startMs) {
|
||||
entry.endMs = entry.startMs + 3e3;
|
||||
}
|
||||
merged.push(entry);
|
||||
}
|
||||
for (let j = 1; j < merged.length; j++) {
|
||||
if (merged[j].startMs < merged[j - 1].endMs) {
|
||||
merged[j].startMs = merged[j - 1].endMs;
|
||||
}
|
||||
if (merged[j].endMs <= merged[j].startMs) {
|
||||
merged[j].endMs = merged[j].startMs + 3e3;
|
||||
}
|
||||
}
|
||||
let srt = "";
|
||||
merged.forEach((entry, idx) => {
|
||||
srt += `${idx + 1}
|
||||
`;
|
||||
srt += `${msToSrtTime(entry.startMs)} --> ${msToSrtTime(entry.endMs)}
|
||||
`;
|
||||
srt += `${entry.text}
|
||||
|
||||
`;
|
||||
});
|
||||
return srt.trim() + "\n";
|
||||
}
|
||||
function transcribe(filePath, options, onProgress, onData) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { model = "small", language = "tr", format = "srt" } = options;
|
||||
const whisperPath = path.join(BIN_PATH, "whisper.exe");
|
||||
const modelPath = path.join(BIN_PATH, "models", `ggml-${model}.bin`);
|
||||
if (!fs.existsSync(whisperPath)) {
|
||||
return reject(new Error(`Whisper executable not found at: ${whisperPath}`));
|
||||
}
|
||||
if (!fs.existsSync(modelPath)) {
|
||||
return reject(new Error(`Model not found at: ${modelPath}. Run: node scripts/setup-binaries.js`));
|
||||
}
|
||||
const whisperArgs = ["-m", modelPath, "-f", filePath];
|
||||
if (format === "srt") whisperArgs.push("-osrt");
|
||||
else if (format === "vtt") whisperArgs.push("-ovtt");
|
||||
else if (format === "txt") whisperArgs.push("-otxt");
|
||||
if (language && language !== "auto") {
|
||||
whisperArgs.push("-l", language);
|
||||
}
|
||||
console.log("[Voicext] Whisper args:", whisperArgs.join(" "));
|
||||
const whisper = child_process.spawn(whisperPath, whisperArgs);
|
||||
let stderrOutput = "";
|
||||
whisper.stdout.on("data", (data) => {
|
||||
const text = iconv.decode(data, "cp857");
|
||||
console.log("[Whisper stdout]", text);
|
||||
onData(text);
|
||||
});
|
||||
whisper.stderr.on("data", (data) => {
|
||||
const text = iconv.decode(data, "cp857");
|
||||
stderrOutput += text;
|
||||
console.log("[Whisper stderr]", text);
|
||||
if (text.includes("Loaded model")) {
|
||||
onProgress(20);
|
||||
} else if (text.includes("Loaded audio") || text.includes("source reader")) {
|
||||
onProgress(40);
|
||||
} else if (text.includes("RunComplete") || text.includes("CPU Tasks")) {
|
||||
onProgress(80);
|
||||
} else if (text.includes("Memory Usage")) {
|
||||
onProgress(95);
|
||||
}
|
||||
});
|
||||
whisper.on("error", (err) => {
|
||||
console.error("[Voicext] Failed to start Whisper process:", err);
|
||||
reject(new Error(`Failed to start Whisper: ${err.message}`));
|
||||
});
|
||||
whisper.on("close", (code) => {
|
||||
console.log(`[Voicext] Whisper exited with code: ${code}`);
|
||||
const expectedOutput = filePath.replace(/\.[^/.]+$/, `.${format}`);
|
||||
if (code === 0) {
|
||||
if (format === "srt" && fs.existsSync(expectedOutput)) {
|
||||
try {
|
||||
const raw = fs.readFileSync(expectedOutput, "utf-8");
|
||||
const fixed = fixSrt(raw);
|
||||
fs.writeFileSync(expectedOutput, fixed, "utf-8");
|
||||
console.log("[Voicext] SRT post-processed and fixed");
|
||||
} catch (e) {
|
||||
console.warn("[Voicext] SRT post-processing warning:", e.message);
|
||||
}
|
||||
}
|
||||
if (!fs.existsSync(expectedOutput)) {
|
||||
const baseName = path.basename(filePath).replace(/\.[^/.]+$/, `.${format}`);
|
||||
const altOutput = path.join(process.cwd(), baseName);
|
||||
if (fs.existsSync(altOutput)) {
|
||||
if (format === "srt") {
|
||||
const raw = fs.readFileSync(altOutput, "utf-8");
|
||||
const fixed = fixSrt(raw);
|
||||
fs.writeFileSync(expectedOutput, fixed, "utf-8");
|
||||
} else {
|
||||
fs.copyFileSync(altOutput, expectedOutput);
|
||||
}
|
||||
fs.unlinkSync(altOutput);
|
||||
}
|
||||
}
|
||||
onProgress(100);
|
||||
resolve({ success: true, outputPath: expectedOutput });
|
||||
} else {
|
||||
reject(new Error(`Whisper failed (code ${code}). Details:
|
||||
${stderrOutput}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function createWindow() {
|
||||
const mainWindow = new electron.BrowserWindow({
|
||||
width: 1100,
|
||||
@@ -11,11 +178,10 @@ function createWindow() {
|
||||
autoHideMenuBar: true,
|
||||
...process.platform === "linux" ? { icon } : {},
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "../preload/index.mjs"),
|
||||
preload: path.join(__dirname, "../preload/index.js"),
|
||||
sandbox: false
|
||||
},
|
||||
frame: false,
|
||||
// Custom frame for premium look
|
||||
transparent: true,
|
||||
backgroundColor: "#00000000"
|
||||
});
|
||||
@@ -31,11 +197,30 @@ function createWindow() {
|
||||
} else {
|
||||
mainWindow.loadFile(path.join(__dirname, "../renderer/index.html"));
|
||||
}
|
||||
}
|
||||
electron.app.whenReady().then(() => {
|
||||
utils.electronApp.setAppUserModelId("com.voicext.app");
|
||||
electron.app.on("browser-window-created", (_, window) => {
|
||||
utils.optimizer.watchWindowShortcuts(window);
|
||||
electron.ipcMain.handle("start-transcription", async (event, { filePath, options }) => {
|
||||
try {
|
||||
const result = await transcribe(
|
||||
filePath,
|
||||
options,
|
||||
(progress) => {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("transcription-progress", progress);
|
||||
}
|
||||
},
|
||||
(data) => {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("transcription-data", data);
|
||||
}
|
||||
}
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Transcription Error:", error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
electron.ipcMain.handle("detect-hardware", async () => {
|
||||
return await detectHardware();
|
||||
});
|
||||
electron.ipcMain.on("window-controls", (event, action) => {
|
||||
const win = electron.BrowserWindow.fromWebContents(event.sender);
|
||||
@@ -43,6 +228,12 @@ electron.app.whenReady().then(() => {
|
||||
if (action === "maximize") win.isMaximized() ? win.unmaximize() : win.maximize();
|
||||
if (action === "close") win.close();
|
||||
});
|
||||
}
|
||||
electron.app.whenReady().then(() => {
|
||||
utils.electronApp.setAppUserModelId("com.voicext.app");
|
||||
electron.app.on("browser-window-created", (_, window) => {
|
||||
utils.optimizer.watchWindowShortcuts(window);
|
||||
});
|
||||
createWindow();
|
||||
electron.app.on("activate", function() {
|
||||
if (electron.BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
const electron = require("electron");
|
||||
const preload = require("@electron-toolkit/preload");
|
||||
const api = {
|
||||
windowControls: (action) => electron.ipcRenderer.send("window-controls", action)
|
||||
windowControls: (action) => electron.ipcRenderer.send("window-controls", action),
|
||||
startTranscription: (filePath, options) => electron.ipcRenderer.invoke("start-transcription", { filePath, options }),
|
||||
onTranscriptionProgress: (callback) => electron.ipcRenderer.on("transcription-progress", (_, progress) => callback(progress)),
|
||||
onTranscriptionData: (callback) => electron.ipcRenderer.on("transcription-data", (_, data) => callback(data)),
|
||||
detectHardware: () => electron.ipcRenderer.invoke("detect-hardware")
|
||||
};
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user