From 9cf716168c1198a15002ae86851023659dc9c904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Cmit=20Tun=C3=A7?= Date: Thu, 23 Apr 2026 08:56:57 +0300 Subject: [PATCH] feat: implement Whisper transcription engine and project documentation --- .gitignore | 14 +- README.md | 88 +++++++ out/main/index.js | 205 +++++++++++++++- out/preload/index.js | 6 +- package-lock.json | 131 +++++++++-- package.json | 3 + scripts/setup-binaries.js | 81 +++++++ scripts/setup-binaries.ps1 | 49 ++++ src/main/engine/transcription.js | 231 +++++++++++++++---- src/main/index.js | 26 ++- src/renderer/index.html | 4 +- src/renderer/src/App.jsx | 78 ++++--- src/renderer/src/components/CustomSelect.jsx | 56 +++++ src/renderer/src/index.css | 38 +++ test_real.srt | 1 + test_real.wav | Bin 0 -> 32078 bytes 16 files changed, 891 insertions(+), 120 deletions(-) create mode 100644 README.md create mode 100644 scripts/setup-binaries.js create mode 100644 scripts/setup-binaries.ps1 create mode 100644 src/renderer/src/components/CustomSelect.jsx create mode 100644 test_real.srt create mode 100644 test_real.wav diff --git a/.gitignore b/.gitignore index 3c3629e..18722d3 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,13 @@ -node_modules +node_modules/ +bin/ +out/ +dist/ +*.log +.DS_Store +.vscode/ +.idea/ +*.wav +*.mp3 +*.srt +!test_real.wav +!test_real.srt \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..478fef4 --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# Voicext - Local AI Transcription Tool + +Voicext is a high-performance desktop application built with **Electron** and **React** that provides local, private, and fast speech-to-text transcription. It leverages the power of GPU-accelerated AI to convert audio files into professional subtitle formats. + +![Voicext Logo](resources/icon.png) + +## πŸš€ Features + +- **Local Processing:** Your data never leaves your machine. Privacy by design. +- **GPU Acceleration:** Utilizes DirectCompute and CUDA for lightning-fast transcription using the Const-me Whisper implementation. +- **Smart SRT Fixing:** Custom algorithm to ensure SRT files are 100% compatible with Adobe Premiere Pro and other NLEs. +- **Multi-format Output:** Supports SRT, VTT, and TXT exports. +- **Modern UI:** A sleek, responsive interface built with Tailwind CSS and Framer Motion. +- **Optimized for Turkish:** Specialized handling for Turkish character encoding and language models. + +## πŸ› οΈ Technology Stack + +- **Core:** [Electron](https://www.electronjs.org/) +- **Frontend:** [React](https://reactjs.org/) with [Vite](https://vitejs.dev/) +- **Styling:** [Tailwind CSS](https://tailwindcss.com/) & [Framer Motion](https://www.framer.com/motion/) +- **Engine:** [Const-me/Whisper](https://github.com/Const-me/Whisper) (Windows-optimized C++ implementation) +- **State Management:** [Zustand](https://github.com/pmndrs/zustand) +- **Utilities:** `ffmpeg-static`, `iconv-lite`, `adm-zip` + +## 🧠 How It Works (Algorithm) + +The transcription process follows a robust pipeline to ensure accuracy and compatibility: + +1. **Hardware Detection:** The app automatically detects if an NVIDIA GPU is available to enable hardware acceleration. +2. **Engine Initialization:** It spawns a background process using the `whisper.exe` CLI, passing the audio file and selected model (e.g., `base`, `small`). +3. **Stream Processing:** Real-time feedback is captured from the engine's stdout/stderr, providing the user with live progress updates. +4. **Post-Processing (SRT Fixer):** + - **Timestamp Normalization:** Corrects overlapping or negative duration segments. + - **Segment Merging:** Automatically merges micro-segments that often occur in long-form audio. + - **Encoding Correction:** Ensures the output is UTF-8 encoded, specifically handling Turkish characters (CP857 conversion) to prevent "mojibake". + - **NLE Compatibility:** Formats the SRT specifically for high-end video editors like Adobe Premiere Pro. + +## βš™οΈ Setup & Installation + +### Prerequisites + +- [Node.js](https://nodejs.org/) (v18 or higher) +- [Git](https://git-scm.com/) +- NVIDIA GPU (Recommended for speed, but supports CPU) + +### Installation Steps + +1. **Clone the Repository:** + ```bash + git clone https://github.com/truncgil/voicext.git + cd voicext + ``` + +2. **Install Dependencies:** + ```bash + npm install + ``` + +3. **Download Binaries & Models:** + The application requires the Whisper executable and AI models. Run the automated setup script: + ```bash + node scripts/setup-binaries.js + ``` + *This will download the Const-me Whisper CLI and the `ggml-base.bin` model into the `bin/` directory.* + +4. **Run in Development Mode:** + ```bash + npm run dev + ``` + +5. **Build for Production:** + ```bash + npm run package + ``` + +## πŸ“‚ Project Structure + +- `src/main`: Electron main process logic and transcription engine integration. +- `src/renderer`: React-based UI components and state management. +- `bin/`: (Generated) Contains `whisper.exe` and `.bin` models. +- `scripts/`: Maintenance and setup scripts for binaries. + +## πŸ“„ License + +This project is licensed under the MIT License - see the LICENSE file for details. + +--- +Developed with ❀️ by Voicext Team. diff --git a/out/main/index.js b/out/main/index.js index cdbb5da..c99658a 100644 --- a/out/main/index.js +++ b/out/main/index.js @@ -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(); diff --git a/out/preload/index.js b/out/preload/index.js index 733141f..423870b 100644 --- a/out/preload/index.js +++ b/out/preload/index.js @@ -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 { diff --git a/package-lock.json b/package-lock.json index 2ab6887..7c550aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,11 @@ "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^3.0.0", "@headlessui/react": "^2.0.0", + "adm-zip": "^0.5.17", "clsx": "^2.1.1", + "ffmpeg-static": "^5.3.0", "framer-motion": "^11.2.10", + "iconv-lite": "^0.7.2", "lucide-react": "^0.395.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -326,6 +329,21 @@ "node": ">=6.9.0" } }, + "node_modules/@derhuerst/http-basic": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz", + "integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==", + "license": "MIT", + "dependencies": { + "caseless": "^0.12.0", + "concat-stream": "^2.0.0", + "http-response-object": "^3.0.1", + "parse-cache-control": "^1.0.1" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@develar/schema-utils": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", @@ -2063,11 +2081,19 @@ "dev": true, "license": "MIT" }, + "node_modules/adm-zip": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", + "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, "license": "MIT", "dependencies": { "debug": "4" @@ -2558,7 +2584,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, "license": "MIT" }, "node_modules/builder-util": { @@ -2710,6 +2735,12 @@ ], "license": "CC-BY-4.0" }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "license": "Apache-2.0" + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2891,6 +2922,21 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/config-file-ts": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.6.tgz", @@ -3193,6 +3239,19 @@ "node": ">=12" } }, + "node_modules/dmg-builder/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dmg-builder/node_modules/jsonfile": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", @@ -3731,6 +3790,22 @@ "pend": "~1.2.0" } }, + "node_modules/ffmpeg-static": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz", + "integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==", + "hasInstallScript": true, + "license": "GPL-3.0-or-later", + "dependencies": { + "@derhuerst/http-basic": "^8.2.0", + "env-paths": "^2.2.0", + "https-proxy-agent": "^5.0.0", + "progress": "^2.0.3" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -4220,6 +4295,21 @@ "node": ">= 6" } }, + "node_modules/http-response-object": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz", + "integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==", + "license": "MIT", + "dependencies": { + "@types/node": "^10.0.3" + } + }, + "node_modules/http-response-object/node_modules/@types/node": { + "version": "10.17.60", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", + "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==", + "license": "MIT" + }, "node_modules/http2-wrapper": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", @@ -4237,7 +4327,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "6", @@ -4266,16 +4355,19 @@ } }, "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ieee754": { @@ -4315,7 +4407,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/is-ci": { @@ -4901,6 +4992,11 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/parse-cache-control": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz", + "integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==" + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -5160,9 +5256,7 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -5288,7 +5382,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -5303,14 +5396,12 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/sanitize-filename": { @@ -5516,9 +5607,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -5764,6 +5853,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -5854,9 +5949,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/verror": { "version": "1.10.1", diff --git a/package.json b/package.json index 0b274c2..dd81eb7 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,11 @@ "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^3.0.0", "@headlessui/react": "^2.0.0", + "adm-zip": "^0.5.17", "clsx": "^2.1.1", + "ffmpeg-static": "^5.3.0", "framer-motion": "^11.2.10", + "iconv-lite": "^0.7.2", "lucide-react": "^0.395.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/scripts/setup-binaries.js b/scripts/setup-binaries.js new file mode 100644 index 0000000..286615a --- /dev/null +++ b/scripts/setup-binaries.js @@ -0,0 +1,81 @@ +const fs = require('fs') +const path = require('path') +const https = require('https') +const AdmZip = require('adm-zip') + +const binDir = path.join(__dirname, '../bin') +const modelsDir = path.join(binDir, 'models') + +if (!fs.existsSync(binDir)) fs.mkdirSync(binDir, { recursive: true }) +if (!fs.existsSync(modelsDir)) fs.mkdirSync(modelsDir, { recursive: true }) + +const downloadFile = (url, dest) => { + return new Promise((resolve, reject) => { + // Check if we need to follow redirects + const req = https.get(url, (res) => { + if (res.statusCode === 301 || res.statusCode === 302) { + return downloadFile(res.headers.location, dest).then(resolve).catch(reject) + } + + if (res.statusCode !== 200) { + return reject(new Error(`Failed to download: ${res.statusCode} ${res.statusMessage}`)) + } + + const file = fs.createWriteStream(dest) + res.pipe(file) + file.on('finish', () => { + file.close(resolve) + }) + }) + + req.on('error', (err) => { + fs.unlink(dest, () => reject(err)) + }) + }) +} + +async function setup() { + try { + console.log('Downloading Const-me Whisper (v1.12)...') + const whisperZipPath = path.join(binDir, 'cli.zip') + await downloadFile('https://github.com/Const-me/Whisper/releases/download/1.12.0/cli.zip', whisperZipPath) + + console.log('Extracting Whisper...') + const zip = new AdmZip(whisperZipPath) + zip.extractAllTo(path.join(binDir, 'whisper_temp'), true) + + // Find executable and dlls + const extractPath = path.join(binDir, 'whisper_temp') + const files = fs.readdirSync(extractPath) + + // In Const-me Whisper release, there's usually a cli.exe or main.exe + let exeFound = false + files.forEach(file => { + if (file.endsWith('.exe')) { + // We'll rename the primary exe to whisper.exe + if (!exeFound) { + fs.copyFileSync(path.join(extractPath, file), path.join(binDir, 'whisper.exe')) + exeFound = true + } + } else if (file.endsWith('.dll')) { + fs.copyFileSync(path.join(extractPath, file), path.join(binDir, file)) + } + }) + + // Cleanup + fs.rmSync(extractPath, { recursive: true, force: true }) + fs.unlinkSync(whisperZipPath) + + console.log('Downloading Base Model (140MB)...') + const modelPath = path.join(modelsDir, 'ggml-base.bin') + if (!fs.existsSync(modelPath)) { + await downloadFile('https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin', modelPath) + } + + console.log('Setup Complete!') + } catch (error) { + console.error('Setup failed:', error) + } +} + +setup() diff --git a/scripts/setup-binaries.ps1 b/scripts/setup-binaries.ps1 new file mode 100644 index 0000000..55ef573 --- /dev/null +++ b/scripts/setup-binaries.ps1 @@ -0,0 +1,49 @@ +$ProgressPreference = 'SilentlyContinue' + +$binDir = "bin" +$modelsDir = "bin/models" + +# Cleanup existing lock files if any +if (Test-Path "$binDir/ffmpeg.zip") { Remove-Item "$binDir/ffmpeg.zip" -Force } +if (Test-Path "$binDir/whisper.zip") { Remove-Item "$binDir/whisper.zip" -Force } + +if (-not (Test-Path $binDir)) { New-Item -ItemType Directory -Path $binDir } +if (-not (Test-Path $modelsDir)) { New-Item -ItemType Directory -Path $modelsDir } + +Write-Host "Downloading FFmpeg..." -ForegroundColor Cyan +$ffmpegZip = "$binDir/ffmpeg_dl.zip" +Invoke-WebRequest -Uri "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip" -OutFile $ffmpegZip + +Write-Host "Downloading Const-me Whisper (v1.12)..." -ForegroundColor Cyan +$whisperZip = "$binDir/whisper_dl.zip" +# User provided link: https://github.com/Const-me/Whisper/releases/tag/1.12.0 +# Direct download link for Whisper.zip +Invoke-WebRequest -Uri "https://github.com/Const-me/Whisper/releases/download/1.12.0/Library.zip" -OutFile $whisperZip + +Write-Host "Downloading Base Model (140MB)..." -ForegroundColor Cyan +$modelFile = "$modelsDir/ggml-base.bin" +if (-not (Test-Path $modelFile)) { + Invoke-WebRequest -Uri "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin" -OutFile $modelFile +} + +Write-Host "Extracting FFmpeg..." -ForegroundColor Green +Expand-Archive -Path $ffmpegZip -DestinationPath "$binDir/ffmpeg_temp" -Force +$ffmpegExe = Get-ChildItem -Path "$binDir/ffmpeg_temp" -Filter "ffmpeg.exe" -Recurse | Select-Object -First 1 +Copy-Item $ffmpegExe.FullName -Destination "$binDir/ffmpeg.exe" +Remove-Item -Path "$binDir/ffmpeg_temp" -Recurse -Force +Remove-Item $ffmpegZip + +Write-Host "Extracting Whisper..." -ForegroundColor Green +Expand-Archive -Path $whisperZip -DestinationPath "$binDir/whisper_temp" -Force +# Const-me zip might have WhisperDesktop.exe and Whisper.dll +# We look for any exe that looks like a CLI or the main app +$whisperExe = Get-ChildItem -Path "$binDir/whisper_temp" -Filter "*.exe" -Recurse | Select-Object -First 1 +Copy-Item $whisperExe.FullName -Destination "$binDir/whisper.exe" +# Also copy DLLs if any +Get-ChildItem -Path "$binDir/whisper_temp" -Filter "*.dll" -Recurse | ForEach-Object { + Copy-Item $_.FullName -Destination "$binDir/$($_.Name)" +} +Remove-Item -Path "$binDir/whisper_temp" -Recurse -Force +Remove-Item $whisperZip + +Write-Host "Setup Complete!" -ForegroundColor Yellow diff --git a/src/main/engine/transcription.js b/src/main/engine/transcription.js index aa64d78..f99ebfc 100644 --- a/src/main/engine/transcription.js +++ b/src/main/engine/transcription.js @@ -2,13 +2,13 @@ import { spawn } from 'child_process' import path from 'path' import fs from 'fs' import { app } from 'electron' +import iconv from 'iconv-lite' -const BIN_PATH = app.isPackaged +const BIN_PATH = app.isPackaged ? path.join(process.resourcesPath, 'bin') : path.join(app.getAppPath(), 'bin') export async function detectHardware() { - // Simple check for NVIDIA GPU via nvidia-smi return new Promise((resolve) => { const smi = spawn('nvidia-smi') smi.on('error', () => resolve({ gpu: false, name: 'CPU' })) @@ -22,63 +22,194 @@ export async function detectHardware() { }) } +// ── SRT Helpers ────────────────────────────────────────────────────────── + +function srtTimeToMs(timeStr) { + const match = timeStr.match(/(\d+):(\d+):(\d+)[,.](\d+)/) + if (!match) return 0 + const [, h, m, s, ms] = match + return parseInt(h) * 3600000 + parseInt(m) * 60000 + parseInt(s) * 1000 + parseInt(ms) +} + +function msToSrtTime(ms) { + const h = Math.floor(ms / 3600000) + ms %= 3600000 + const m = Math.floor(ms / 60000) + ms %= 60000 + const s = Math.floor(ms / 1000) + const rem = ms % 1000 + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')},${String(rem).padStart(3, '0')}` +} + +// ── SRT Post-Processor ────────────────────────────────────────────── +// Const-me Whisper sometimes produces broken timestamps when splitting +// long segments. This function fixes them for Premiere Pro compatibility. +export function fixSrt(srtContent) { + // CRITIK DÜZELTME: TΓΌm Windows (\r\n) ve eski Mac (\r) satΔ±r sonlarΔ±nΔ± Linux (\n) standardΔ±na Γ§evir + 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 }) + } + + // Pass 1: Fix broken timestamps (end < start) by merging with next entry + 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 + 3000 + } + + merged.push(entry) + } + + // Pass 2: Ensure timestamps are sequential + 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 + 3000 + } + } + + // Build clean SRT + let srt = '' + merged.forEach((entry, idx) => { + srt += `${idx + 1}\n` + srt += `${msToSrtTime(entry.startMs)} --> ${msToSrtTime(entry.endMs)}\n` + srt += `${entry.text}\n\n` + }) + + return srt.trim() + '\n' +} + +// ── Transcription Engine ──────────────────────────────────────────────── + export function transcribe(filePath, options, onProgress, onData) { return new Promise((resolve, reject) => { - const { model = 'base', language = 'tr', outputFormat = 'srt' } = options - - // 1. FFmpeg Pre-processing (Convert to 16kHz mono WAV) - const tempWav = path.join(app.getPath('temp'), `voicext_${Date.now()}.wav`) - const ffmpegPath = path.join(BIN_PATH, 'ffmpeg.exe') - - const ffmpeg = spawn(ffmpegPath, [ - '-i', filePath, - '-ar', '16000', - '-ac', '1', - '-c:a', 'pcm_s16le', - tempWav, - '-y' - ]) + 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`) - ffmpeg.on('close', (code) => { - if (code !== 0) { - return reject(new Error('FFmpeg conversion failed')) + 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 = spawn(whisperPath, whisperArgs) + let stderrOutput = '' + + // Windows console encoding usually requires special handling for Turkish characters + // Using iconv-lite to decode the buffer from CP857 (Turkish DOS) or CP1254 (Turkish Windows) + // Const-me Whisper CLI usually outputs in the system codepage. + whisper.stdout.on('data', (data) => { + // Decode buffer using Turkish Windows codepage (win1254) which covers Turkish characters + 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) + + // Progress parsing + 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) } + }) - // 2. Whisper.cpp Inference - const whisperPath = path.join(BIN_PATH, 'whisper.exe') - const modelPath = path.join(BIN_PATH, 'models', `ggml-${model}.bin`) - - const whisper = spawn(whisperPath, [ - '-m', modelPath, - '-f', tempWav, - `-o${outputFormat}`, - '-l', language, - '--max-len', '42' - ]) + whisper.on('error', (err) => { + console.error('[Voicext] Failed to start Whisper process:', err) + reject(new Error(`Failed to start Whisper: ${err.message}`)) + }) - whisper.stdout.on('data', (data) => { - onData(data.toString()) - }) + whisper.on('close', (code) => { + console.log(`[Voicext] Whisper exited with code: ${code}`) + const expectedOutput = filePath.replace(/\.[^/.]+$/, `.${format}`) - whisper.stderr.on('data', (data) => { - // Whisper.cpp outputs progress to stderr - const output = data.toString() - const progressMatch = output.match(/progress\s*=\s*(\d+)%/) - if (progressMatch) { - onProgress(parseInt(progressMatch[1])) + 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) + } } - }) - - whisper.on('close', (code) => { - // Cleanup temp file - if (fs.existsSync(tempWav)) fs.unlinkSync(tempWav) - if (code === 0) { - resolve({ success: true, outputPath: filePath.replace(/\.[^/.]+$/, `.${outputFormat}`) }) - } else { - reject(new Error('Whisper transcription failed')) + // Handle cases where whisper outputs to a different path + 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:\n${stderrOutput}`)) + } }) }) -} +} \ No newline at end of file diff --git a/src/main/index.js b/src/main/index.js index aafdb7d..4c88d62 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -13,7 +13,7 @@ function createWindow() { autoHideMenuBar: true, ...(process.platform === 'linux' ? { icon } : {}), webPreferences: { - preload: join(__dirname, '../preload/index.mjs'), + preload: join(__dirname, '../preload/index.js'), sandbox: false }, frame: false, @@ -43,10 +43,14 @@ function createWindow() { filePath, options, (progress) => { - mainWindow.webContents.send('transcription-progress', progress) + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('transcription-progress', progress) + } }, (data) => { - mainWindow.webContents.send('transcription-data', data) + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('transcription-data', data) + } } ) return result @@ -60,6 +64,14 @@ function createWindow() { ipcMain.handle('detect-hardware', async () => { return await detectHardware() }) + + // Handle window controls + ipcMain.on('window-controls', (event, action) => { + const win = BrowserWindow.fromWebContents(event.sender) + if (action === 'minimize') win.minimize() + if (action === 'maximize') win.isMaximized() ? win.unmaximize() : win.maximize() + if (action === 'close') win.close() + }) } app.whenReady().then(() => { @@ -69,14 +81,6 @@ app.whenReady().then(() => { optimizer.watchWindowShortcuts(window) }) - // IPC handlers - ipcMain.on('window-controls', (event, action) => { - const win = BrowserWindow.fromWebContents(event.sender) - if (action === 'minimize') win.minimize() - if (action === 'maximize') win.isMaximized() ? win.unmaximize() : win.maximize() - if (action === 'close') win.close() - }) - createWindow() app.on('activate', function () { diff --git a/src/renderer/index.html b/src/renderer/index.html index 3a57d35..757c303 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -3,10 +3,10 @@ Voicext - + /> -->
diff --git a/src/renderer/src/App.jsx b/src/renderer/src/App.jsx index 7827ce5..86b4c11 100644 --- a/src/renderer/src/App.jsx +++ b/src/renderer/src/App.jsx @@ -16,6 +16,27 @@ import { motion, AnimatePresence } from 'framer-motion' import logo from './assets/logo.png' +import CustomSelect from './components/CustomSelect' + +const languages = [ + { id: 'auto', name: 'AUTO (Recommended)' }, + { id: 'tr', name: 'Turkish' }, + { id: 'en', name: 'English' } +] + +const models = [ + { id: 'small', name: 'SMALL (Recommended, 460MB)' }, + { id: 'base', name: 'BASE (Fast, Lower Quality, 140MB)' }, + { id: 'medium', name: 'MEDIUM (Best Quality, 1.5GB)' }, + { id: 'large', name: 'LARGE (Highest Quality, 3GB)' } +] + +const formats = [ + { id: 'srt', name: '.SRT (Premiere Pro Compatible)' }, + { id: 'vtt', name: '.VTT (Web Standard)' }, + { id: 'txt', name: '.TXT (Plain Text)' } +] + function App() { const [activeTab, setActiveTab] = useState('dashboard') const [dragActive, setDragActive] = useState(false) @@ -25,11 +46,15 @@ function App() { const [hardware, setHardware] = useState({ gpu: false, name: 'Detecting...' }) const [config, setConfig] = useState({ language: 'tr', - model: 'base', + model: 'small', format: 'srt' }) useEffect(() => { + if (!window.api) { + console.error('Electron API not found!') + return + } window.api.detectHardware().then(setHardware) window.api.onTranscriptionProgress((p) => { @@ -38,6 +63,7 @@ function App() { }, []) const handleControl = (action) => { + if (!window.api) return window.api.windowControls(action) } @@ -208,37 +234,30 @@ function App() { Quick Configuration -
- - -
+ setConfig({ ...config, language: val })} + /> -
- - -
+ setConfig({ ...config, model: val })} + /> -
- - -
+ setConfig({ ...config, format: val })} + />