feat: implement Whisper transcription engine and project documentation
This commit is contained in:
+13
-1
@@ -1 +1,13 @@
|
||||
node_modules
|
||||
node_modules/
|
||||
bin/
|
||||
out/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
*.wav
|
||||
*.mp3
|
||||
*.srt
|
||||
!test_real.wav
|
||||
!test_real.srt
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 🚀 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.
|
||||
+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 {
|
||||
|
||||
Generated
+112
-19
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
? 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() {
|
||||
})
|
||||
}
|
||||
|
||||
export function transcribe(filePath, options, onProgress, onData) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { model = 'base', language = 'tr', outputFormat = 'srt' } = options
|
||||
// ── SRT Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
// 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')
|
||||
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)
|
||||
}
|
||||
|
||||
const ffmpeg = spawn(ffmpegPath, [
|
||||
'-i', filePath,
|
||||
'-ar', '16000',
|
||||
'-ac', '1',
|
||||
'-c:a', 'pcm_s16le',
|
||||
tempWav,
|
||||
'-y'
|
||||
])
|
||||
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')}`
|
||||
}
|
||||
|
||||
ffmpeg.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
return reject(new Error('FFmpeg conversion failed'))
|
||||
// ── 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 })
|
||||
}
|
||||
|
||||
// 2. Whisper.cpp Inference
|
||||
// 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 = 'small', language = 'tr', format = 'srt' } = options
|
||||
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'
|
||||
])
|
||||
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) => {
|
||||
onData(data.toString())
|
||||
// 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) => {
|
||||
// Whisper.cpp outputs progress to stderr
|
||||
const output = data.toString()
|
||||
const progressMatch = output.match(/progress\s*=\s*(\d+)%/)
|
||||
if (progressMatch) {
|
||||
onProgress(parseInt(progressMatch[1]))
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
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) => {
|
||||
// Cleanup temp file
|
||||
if (fs.existsSync(tempWav)) fs.unlinkSync(tempWav)
|
||||
console.log(`[Voicext] Whisper exited with code: ${code}`)
|
||||
const expectedOutput = filePath.replace(/\.[^/.]+$/, `.${format}`)
|
||||
|
||||
if (code === 0) {
|
||||
resolve({ success: true, outputPath: filePath.replace(/\.[^/.]+$/, `.${outputFormat}`) })
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
reject(new Error('Whisper transcription failed'))
|
||||
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}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
+13
-9
@@ -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,11 +43,15 @@ function createWindow() {
|
||||
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) {
|
||||
@@ -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 () {
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Voicext</title>
|
||||
<meta
|
||||
<!-- <meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: asset:;"
|
||||
/>
|
||||
/> -->
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+49
-29
@@ -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
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>Language:</label>
|
||||
<select className="select-custom">
|
||||
<option>AUTO (Recommended)</option>
|
||||
<option>Turkish</option>
|
||||
<option>English</option>
|
||||
</select>
|
||||
</div>
|
||||
<CustomSelect
|
||||
label="Language:"
|
||||
options={languages}
|
||||
value={config.language}
|
||||
onChange={(val) => setConfig({ ...config, language: val })}
|
||||
/>
|
||||
|
||||
<div className="input-group">
|
||||
<label>Model:</label>
|
||||
<select className="select-custom">
|
||||
<option>BASE (Fastest, 140MB)</option>
|
||||
<option>SMALL (Balanced)</option>
|
||||
<option>MEDIUM (Accurate)</option>
|
||||
<option>LARGE (Best Quality)</option>
|
||||
</select>
|
||||
</div>
|
||||
<CustomSelect
|
||||
label="Model:"
|
||||
options={models}
|
||||
value={config.model}
|
||||
onChange={(val) => setConfig({ ...config, model: val })}
|
||||
/>
|
||||
|
||||
<div className="input-group">
|
||||
<label>Format:</label>
|
||||
<select className="select-custom">
|
||||
<option>.SRT (Premiere Pro Compatible)</option>
|
||||
<option>.VTT (Web Standard)</option>
|
||||
<option>.TXT (Plain Text)</option>
|
||||
</select>
|
||||
</div>
|
||||
<CustomSelect
|
||||
label="Format:"
|
||||
options={formats}
|
||||
value={config.format}
|
||||
onChange={(val) => setConfig({ ...config, format: val })}
|
||||
/>
|
||||
|
||||
<button
|
||||
className="btn-primary"
|
||||
style={{ marginTop: '10px', opacity: file ? 1 : 0.5, cursor: file ? 'pointer' : 'not-allowed' }}
|
||||
style={{ marginTop: '20px', opacity: file ? 1 : 0.5, cursor: file ? 'pointer' : 'not-allowed' }}
|
||||
disabled={!file || isTranscribing}
|
||||
onClick={startTranscription}
|
||||
>
|
||||
@@ -281,10 +300,11 @@ function App() {
|
||||
{/* Status Bar */}
|
||||
<footer className="status-bar">
|
||||
<div className="status-indicator">
|
||||
<span>SYSTEM: RTX 5080 DETECTED (GPU Acceleration ON)</span>
|
||||
<div className="indicator-dot" style={{ backgroundColor: hardware.gpu ? '#00ff88' : '#ffaa00', boxShadow: hardware.gpu ? '0 0 8px #00ff88' : '0 0 8px #ffaa00' }}></div>
|
||||
<span>SYSTEM: {hardware.name.toUpperCase()} {hardware.gpu ? 'DETECTED (GPU ACCELERATION ON)' : 'DETECTED (CPU ONLY)'}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '20px', alignItems: 'center' }}>
|
||||
<span>READY</span>
|
||||
<span>{isTranscribing ? 'PROCESSING...' : 'READY'}</span>
|
||||
<div className="offline-badge">
|
||||
OFFLINE MODE ACTIVE
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import React, { Fragment } from 'react'
|
||||
import { Listbox, Transition } from '@headlessui/react'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
|
||||
export default function CustomSelect({ value, onChange, options, label }) {
|
||||
const selectedOption = options.find(opt => opt.id === value) || options[0]
|
||||
|
||||
return (
|
||||
<div className="input-group">
|
||||
{label && <label>{label}</label>}
|
||||
<Listbox value={value} onChange={onChange}>
|
||||
<div className="relative mt-1">
|
||||
<Listbox.Button className="select-custom text-left flex justify-between items-center w-full">
|
||||
<span className="block truncate">{selectedOption.name}</span>
|
||||
<span className="pointer-events-none">
|
||||
<ChevronDown className="h-4 w-4 text-muted" aria-hidden="true" />
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-50 mt-1 max-height-60 w-full overflow-auto rounded-md bg-dark-glass py-1 text-base shadow-lg ring-1 ring-white/10 focus:outline-none sm:text-sm backdrop-blur-xl border border-white/10">
|
||||
{options.map((option) => (
|
||||
<Listbox.Option
|
||||
key={option.id}
|
||||
className={({ active }) =>
|
||||
`relative cursor-default select-none py-2 pl-10 pr-4 ${
|
||||
active ? 'bg-white/10 text-primary-cyan' : 'text-white'
|
||||
}`
|
||||
}
|
||||
value={option.id}
|
||||
>
|
||||
{({ selected, active }) => (
|
||||
<>
|
||||
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||
{option.name}
|
||||
</span>
|
||||
{selected ? (
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary-cyan">
|
||||
<Check className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -330,6 +330,44 @@ body {
|
||||
box-shadow: 0 0 8px #00ff88;
|
||||
}
|
||||
|
||||
/* Custom Select Styles */
|
||||
.relative { position: relative; }
|
||||
.absolute { position: absolute; }
|
||||
.z-50 { z-index: 50; }
|
||||
.mt-1 { margin-top: 4px; }
|
||||
.w-full { width: 100%; }
|
||||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.bg-dark-glass {
|
||||
background: rgba(15, 15, 25, 0.9);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.max-height-60 { max-height: 240px; }
|
||||
.py-1 { padding-top: 4px; padding-bottom: 4px; }
|
||||
.py-2 { padding-top: 8px; padding-bottom: 8px; }
|
||||
.pl-10 { padding-left: 40px; }
|
||||
.pr-4 { padding-right: 16px; }
|
||||
.pl-3 { padding-left: 12px; }
|
||||
|
||||
.select-none { user-select: none; }
|
||||
.cursor-default { cursor: default; }
|
||||
.font-normal { font-weight: 400; }
|
||||
.font-medium { font-weight: 500; }
|
||||
|
||||
.text-primary-cyan { color: var(--primary-cyan); }
|
||||
|
||||
/* Animation for select dropdown */
|
||||
.transition { transition-property: opacity, transform; }
|
||||
.ease-in { transition-timing-function: cubic-bezier(0.4, 0, 1, 1); }
|
||||
.duration-100 { transition-duration: 100ms; }
|
||||
.opacity-0 { opacity: 0; }
|
||||
.opacity-100 { opacity: 1; }
|
||||
|
||||
.drop-zone.drag-active {
|
||||
border-color: var(--primary-cyan);
|
||||
background: rgba(0, 242, 255, 0.05);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user