feat: implement Whisper transcription engine and project documentation

This commit is contained in:
Ümit Tunç
2026-04-23 08:56:57 +03:00
parent e15d47efc2
commit 9cf716168c
16 changed files with 891 additions and 120 deletions
+13 -1
View File
@@ -1 +1,13 @@
node_modules
node_modules/
bin/
out/
dist/
*.log
.DS_Store
.vscode/
.idea/
*.wav
*.mp3
*.srt
!test_real.wav
!test_real.srt
+88
View File
@@ -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.
+197 -6
View File
@@ -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.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);
}
electron.app.whenReady().then(() => {
utils.electronApp.setAppUserModelId("com.voicext.app");
electron.app.on("browser-window-created", (_, window) => {
utils.optimizer.watchWindowShortcuts(window);
},
(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();
+5 -1
View File
@@ -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 {
+112 -19
View File
@@ -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",
+3
View File
@@ -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",
+81
View File
@@ -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()
+49
View File
@@ -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
+170 -39
View File
@@ -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')
const ffmpeg = spawn(ffmpegPath, [
'-i', filePath,
'-ar', '16000',
'-ac', '1',
'-c:a', 'pcm_s16le',
tempWav,
'-y'
])
ffmpeg.on('close', (code) => {
if (code !== 0) {
return reject(new Error('FFmpeg conversion failed'))
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)
}
// 2. Whisper.cpp Inference
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 = '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
View File
@@ -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 () {
+2 -2
View File
@@ -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
View File
@@ -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>
)
}
+38
View File
@@ -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);
+1
View File
@@ -0,0 +1 @@

BIN
View File
Binary file not shown.